7 분 소요

📌 Topic

  • 💡 JPQL fetch join 한계
  • 💡 다형성 쿼리
  • 💡 엔티티 직접 사용
  • 💡 Named 쿼리
  • 💡 벌크 연산

⚡ 01. 페치 조인의 특징과 한계

01-1. 페치 조인 대상에 별칭 사용?

// MemberList as m <- fetch join에는 별칭을 주면 안된다
String query = "select t From JpqlTeam t join fetch t.memberList as m";

// m.username 이런 식으로 사용을 하면 안된다
String query2 = "select t From JpqlTeam t join fetch t.memberList as m where m.username";
String query3 = "select t From JpqlTeam t join fetch t.memberList as m where m.age > 10";
  • 페치 조인 대상에는 별칭을 줄 수 없으며, 주면 안된다.
  • Hibernate는 가능하지만 가급적이면 사용을 지양해야 한다.
  • 팀을 조회하는데 3명의 맴버만 따로 조회해서 조작한다는 것 자체가 위험한 행위.

객체 그래프라는 것은 데이터를 전부 다 조회 하는 것을 의미한다. 여기서 데이터를 전부 조회 하는 것이 아닌 데이터를 걸러서 조회하는 것이 좋아 보일 수도 있다.

예를 들자면 100건의 데이터를 조회 한다고 했을 때 팀과 연관된 맴버를 위에서(row 기준) 5개만 가져오고 싶다고 가정하자. 하지만 이러한 상황에서는 Team을 조회해서 데이터를 뽑아야 하는것이 아니라, Member 자체를 조회해서 데이터를 걸러내는 방식으로 진행해야 한다. 아래 예시를 간단히 살펴보자.

// 정합성 이슈로 인해 별칭을 사용하면 안된다
select t From Team t join fetch t.memberList m; // -> x
select m From Member m join m.team where m.age > 10; // -> o

01-2. 둘 이상의 컬렉션에 페치 조인 사용?

  • 일단 결론은 둘 이상의 컬렉션(Collection)은 페치 조인이 불가능하다.

1 : N 은 데이터 뻥튀기가 발생 되는데, 둘 이상의 컬렉션에 패치 조인을 사용하게 되면 데이터 정합성에 문제가 발생할 수 있다.

01-3. 컬렉션을 페치 조인하면 페이징 사용이 가능한가?

// paging api
List<JpqlMember> resultList = em.createQuery("select m From JpqlMember m order by m.age desc", JpqlMember.class)
        .setFirstResult(35)
        .setMaxResults(10) // 최대 갯수 지정 35 ~ 45
        .getResultList();

// target이 Entity인 경우는 페치 조인해도 페이징 가능
select m From Member m join m.team
  • 컬렉션(Collection)을 페치 조인 하면 페이징 API 사용이 불가능하다.
    • 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징 가능.
    • 일대다, 다대다는 패치 조인 페이징이 불가능하다.
    • Hibernate는 경고 로그를 남기고 메모리에서 페이징 수행.
      • 상당히 위험한 행동.
// hibernate paging test
String query = "select t From JpqlTeam t join fetch t.memberList m";

List<JpqlTeam> teamList = em.createQuery(query, JpqlTeam.class)
                            .setFirstResult(0)
                            .setMaxResults(1)
                            .getResultList();
// 경고 로그 남기고, 메모리에서 페이징 수행 -> 상당히 위험
3 26, 2022 5:57:11 오후 org.hibernate.hql.internal.ast.QueryTranslatorImpl list
WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
Hibernate:
    /* select
        t
    From
        JpqlTeam t
    join
        fetch t.memberList m */ select
            jpqlteam0_.TEAM_ID as team_id1_13_0_,
            memberlist1_.id as id1_10_1_,
            jpqlteam0_.name as name2_13_0_,
            memberlist1_.age as age2_10_1_,
            memberlist1_.TEAM_ID as team_id5_10_1_,
            memberlist1_.type as type3_10_1_,
            memberlist1_.username as username4_10_1_,
            memberlist1_.TEAM_ID as team_id5_10_0__,
            memberlist1_.id as id1_10_0__
        from
            JPQL_TEAM jpqlteam0_
        inner join
            JPQL_MEMBER memberlist1_
                on jpqlteam0_.TEAM_ID=memberlist1_.TEAM_ID

데이터 정합성 문제

2022_03_26_fetch_paging

위에서 설명했지만 컬렉션에 fetch 조인을 사용하는 경우에는 페이징 처리가 불가능하다. 일대다, 다대다 관계에서 fetch 조인을 사용하게 되면 데이터가 뻥튀기 되는 현상이 발생하게 되는데 이러한 문제 때문에 페이징 처리를 하게 되면 데이터 정합성을 보장할 수 없다.

해결 방안

성능상 가장 주의해야 하는 것이 N + 1문제

일대다 관계를 다대일 관계로 방향을 변경하고 조회

// 해결 방안 01 : 방향으로 뒤짚어서 조회하고 페이징 처리, 회원에서 팀으로 가도록 방향을 수정
String query = "select m From JpqlMember m join fetch m.team t";
List<JpqlTeam> teamList = em.createQuery(query, JpqlTeam.class)
                                        .setFirstResult(0)
                                        .setMaxResults(1)
                                        .getResultList();

fetch join을 제거하고, @BetchSize 어노테이션 샤용

// 해결 방안 02 : join fetch를 과감하게 제거 한다
String query = "select t From JpqlTeam t";
List<JpqlTeam> teamList = em.createQuery(query, JpqlTeam.class)
                            .setFirstResult(0)
                            .setMaxResults(2)
                            .getResultList();
// 해결 방안 02 : @BetchSize 어노테이션 사용
@Entity
public class Team {
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<JpqlMember> memberList = new ArrayList<>();
}

01-4. 페치 조인의 특징과 한계

  • fetch join은 연관된 엔티티들을 SQL 한 번으로 조회 - 성능 최적화.
  • 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선함.
@OneToMany(fetch = FetchType.LAZY) // 글로벌 로딩 전략
  • 실무에서 글로벌 로딩 전략은 모두 지연 로딩.
  • 최적화가 필요한 곳은 페치 조인 적용.
    • 즉, N + 1 문제가 발생하는 곳에만 페치 조인을 적용 한다.

01-5. 페치 조인 정리

  • 모든 것을 페치 조인으로 해결할 수 는 없다.
  • 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다.

여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면, 페치 조인 보다는 일반 조인을 사용하고 필요한 데이터만 조회해서 DTO로 반환 하는것이 효과적이다.

⚡ 02. 다형성 쿼리

2022_03_26_다형성

예를 들어서 다형적으로 설계를 한 상태라고 가정을 하자. 이 때 JPA는 특수한 기능을 제공한다.

02-1. TYPE

  • 조회 대상을 특정 자식으로 한정.
  • Item 중에 Book, Movie를 조회해라.
-- JPQL
select i from item i
where type(i) IN (Book, Movie)
-- DB
select i from i
where i.DTYPE in ('B', 'M')

02-2. TREAT(JPA 2.1)

  • 자바의 타입 캐스팅과 유사
  • 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용.
  • FROM, WHERE, SELECT 사용.
  • 부모인 Item과 자식 Book이 있다.
-- JPQL : downcasting 느낌으로 사용
select i from Item i
where treat(i as Book).author = 'kim'
-- DB
select i.* from Item i
where i.DTYPE = 'B' and i.auther = 'kim'

⚡ 03. 엔티티 직접 사용

03-1. 엔티티 직접 사용 - 기본 키 값

  • JPQL에서 엔티티를 직접 사용하면 SQL에서 해당 엔티티의 기본 키 값을 사용.
-- JPQL
select count(m.id) from Member m -- 엔티티의 아이디를 사용
select count(m) from Member m -- 엔티티를 직접 사용

보통 우리는 데이터를 조회 할 때 데이터 자체를 넘기기 엔티티를 직접 넘기는 경우는 존재하지 않는다. 하지만 JPQL에서는 count(m)과 같이 엔티티를 직접 넣어서 사용이 가능하다. 위에서 말했다시피 엔티티를 직접 사용하게 되면 SQL에서 해당 엔티티의 기본 키 값을 사용한다.

-- DB : JPQL 둘다 같은 다음 SQL 실행
select count(m.id) as cnt from Member m
  • 즉, 기본 키 값이 찍힌다는 의미다.
// 엔티티를 파라미터로 전달
String jpql = "select m from Member m where m = :member";
List resultList = em.createQuery(jpql)
                    .setParameter("member", member)
                    .getResultList();
  • 엔티티를 where 절에 파라미터로 전달 하여도 기본 키 값이 들어간다.
// 식별자를 직접 전달
String jpql = "select m from Member m where m = :membrId";
List resultList = em.createQuery(jpql)
                    .setParameter("memberId", membrId)
                    .getResultList();
  • 결국 위 소스랑 동일한 예제임
-- 실행 된 SQL
select m.* from Member m where m.id = ?

03-2. 엔티티 직접 사용 - 외래 키 값

@Entity
public class Member {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID") // 이 값이 외래 키 값이다
    private JpqlTeam team;
}
// JPQL : Member가 Team의 외래 키 값을 들고 있음
Team team = em.find(Team.class, 1L);

String qlString = "select m from Member m where m.team = :team";
List resultList = em.createQuery(qlString)
                    .setParameter("team", team)
                    .getResultList();
// JPQL
String qlString = "select m from Member m where m.team.id = :teamId";
List resultList = em.createQuery(qlString)
                    .setParameter("teamId", teamId)
                    .getResultList();
-- DB
select m.* from Member m where m.team_id = ?

💡 결론은 DB 입장에서는 엔티티도 하나의 값으로 봐야 하기에 기본 키 값, 외래 키 값을 데이터로 받는다.

⚡ 03. JPQL - Named 쿼리

  1. 미리 정의해서 이름을 부여해두고 사용하는 JPQL.
  2. 정적 쿼리.
  3. 어노테이션, XML에 정의.
  4. 애플케이션 로딩 시점에 초기화 후 재사용.
  5. Spring-Data-JPA 에서는 @Query 어노테이션에 작성해서 사용된다.
  6. 애플리케이션 로딩 시점에 쿼리를 검증.

애플리케이션 로딩 시점에 쿼리를 검증 한다는 의미는 사전에 버그를 발견 할 수 있다는 의미다. 즉, 이러한 장점을 활용하기 위해 JPQL Named query가 사용이 된다.

// Spring-Data-JPA 에서 더 편하게 변경이 된 예시
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = "UPDATE Member m SET m.status = :status WHERE m.userName = :userName")
int updateByName(@Param("userName") String userName, @Param("status") int status);

03-1. Named 쿼리 - 어노테이션에 정의

@Entity
@NamedQuery(
    name = "Member.findByUserName",
    query = "select m from Member m where m.username = :username"
)
public class Member {
    ...
}
List<Member> resultList =
    em.createNamedQuery("Member.findByUserName", Member.class)
        .setParameter("username", "회원1")
        .getResultList();

03-2. Named 쿼리 - XML에 정의

<!-- META-INF/persistence.xml -->
<persistence-unit name="jpabook">
    <mapping-file>META-INF/ormMember.xml</mapping-file>
</persistence-unit>
<!-- META-INF/ormMember.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="http://wmlns.jcp.org/xml/ns/persistence/orm" version="2.1">

    <named-query name="Member.findByUserName">
        <query><![CDATA[
            select m
            from Member m
            where m.username = :username
        ]]></query>
    </named-query>

    <named-query name="Member.count">
        <query>select count(m) from Member m</query>
    </named-query>

</entity-mappings>

03-3. Named 쿼리 환경에 따른 설정

  • XML이 항상 우선권을 갖는다.
  • 애플리케이션 운영 환경에 따라 다른 XML을 배포할 수 있다.

⚡ 04. JPQL - 벌크 연산

일반적으로 사용이되는 UPDATE, DELETE 문이라 생각.

  1. 재고가 10개 미만인 모든 상품의 가격을 10% 상승 시키려면?
  2. JPA 변경 감지 기능으로 실행하려면 너무 많은 SQL이 실행 해야 한다.
    1. ex) 재고가 10개 미만인 상품을 리스트로 조회한다.
    2. ex) 상품 엔티티의 가격을 10% 증가시킨다.
    3. ex) 트랜잭션 커밋 시점에 변경 감지가 동작한다.
  3. 변경된 데이터가 100건이라면 100번의 UPDATE SQL 실행.

한 번의 SQL로 해결이 되는 문제를 100번의 SQL로 해결 할 필요는 없으니 bulk를 사용하자.

04-1. 벌크 연산 예제

  • 쿼리 한 번으로 여러 테이블의 로우를 변경(엔티티)한다.
  • executeUpdate() 의 결과는 영향받은 엔티티 수를 반환 한다.
  • UPDATE, DELETE 지원.
  • INSERT(insert into .. select, 하이버네이트 지원)
String qlString = "update Product p " +
                  "set p.price = p.price * 1.1 " +
                  "where p.stockAmount < :stockAmount";

int resultCnt = em.createQuery(qlString)
                  .setParameter("stockAmount", 10)
                  .executeUpdate();

04-2. 벌크 연산 주의

벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리를 날린다. 이러한 문제를 해결하기 위해 아래 두 가지 방법을 참고 해야 한다.

  1. 벌크 연산을 먼저 실행.
  2. 벌크 연산 수행 후 영속성 컨텍스트를 초기화 한다.
// FLUSH 자동 호출 commit, query, flush
int resultCount = em.createQuery("update JpqlMember m set m.age = 20")
                    .executeUpdate();
em.clear(); // 초기화

JpqlMember jpqlMember = em.find(JpqlMember.class, member1.getId());
System.out.println("jpqlMember = " + jpqlMember.getAge());
System.out.println("resultCount = " + resultCount);
  • 웬만하면 bulk 연산 수행 후에는 영속성 컨텍스트를 초기화 해주는 것이 좋다.

참고 자료

댓글남기기