JPA/JPA 기본

48-2. 패치 조인의 한계

sdafdq 2023. 11. 2. 12:31

중요한 내용이라 이해가 부족한 거 같아서 다시 하고 넘어감.

 

 

패치 조인 대상에는 별칭을 줄 수 없다.

fetch join이라는 것은 기본적으로 나와 연관된 것들을 모두 끌고 오는 것이다.

패치조인은 별칭을 주지 않는 것이 관례이다.

 

애초에 객체 그래프 사상 자체가,

team에서 members 조회하면 그 team의 모든 members가 모두 나온다는 것을 가정하고 설계를 하였다.

 

그래서 fetch join 하는데 where해서 조건 줘서 걸러내서 가져오면 JPA는 객체 그래프 사상을 team의 members는 모두 나온다는 것을 가정하고 설계 하였기 때문에 뭐 cascade나 orphanRemoval  등 여러 옵션이 붙어있으면 작동이 이상하게 될 수도 있다.

 

 

그리고 만약 팀A를 조회하는데 하나는 팀A의 members 100개를 다 가져오고 다른 하나는 팀A의 members 5개만 걸러서 조회했다고 하면, 영속성 컨텍스트 입장에서는 이걸 어떻게 관리해야 할 지 굉장히 난감하다.

 

 

패치 조인 할 때에는 조인 대상에게 별칭을 사용하지 말자.

 

 

 

 

둘 이상의 컬렉션을 패치 조인하면 안된다.

 

일대다도 데이터 뻥튀기가 된다.

이거는 일대 다 * 다 이다.

 

패치 조인할 때 컬렉션은 단 하나만 지정하자.

 

 

 

 

 

 

일대일, 다대일은 데이터 뻥튀기가 안되기 때문에 페이징 해도 상관이 없다.

그런데 컬렉션은 

일대다는 이렇게 데이터가 뻥튀기 되어 가져온다.

근데 여기서 페이징 사이즈를 1로 했다고 가정하면.

 

그럼 이 중에서 짤리고 위에꺼 하나만 나온다.

그렇게 되면 team의 members는 저 회원1 한명만 가지고 있다는 식으로 정리가 된다.

 

페이징은 철저히 DB중심적인 거다. DB에서 어떻게 row수를 줄일까 하는..

 

그럼 결국 JPA에서는 아, teamA의 members는 회원1밖에 없구나, 그렇게 알게 된다.

JPA는 그냥 DB에서 내려준 결과만 아는거다.

 

그래서, 하이버네이트도 이런 위험성이 있어서,

 

컬렉션을 패치 조인 한 것을 페이징 하려고 하면,

List<Team> result1 = em.createQuery("select t from Team t join fetch t.members", Team.class)
        .setFirstResult(0)
        .setMaxResults(2)
        .getResultList();
Hibernate: 
    /* select
        t 
    from
        Team t 
    join
        fetch t.members */ select
            t1_0.id,
            m1_0.team_id,
            m1_0.id,
            m1_0.age,
            m1_0.type,
            m1_0.username,
            t1_0.name 
        from
            Team t1_0 
        join
            Member m1_0 
                on t1_0.id=m1_0.team_id

나가는 쿼리는 그냥 다 끌고 오면서,

경고로 남기는 로그가

11월 02, 2023 11:47:37 오전 org.hibernate.query.sqm.internal.QuerySqmImpl executionContextFordoList
WARN: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

 

직역하자면 대충

firstResult/maxResults로 특정되어졌다. 콜렉션 fetch와 함께.

메모리에서 진행한다.

 

라고 그냥,

일단 DB상에서는 페이징 하지 않고 다 끌고 온 다음에,

메모리 상에서 페이징 하는거다.

 

 

 

그러기 때문에, 페이징을 하고 싶으면 그냥 다대일 관계로 가져와라.

List<Member> result1 = em.createQuery("select m from Member m join fetch m.team", Member.class)
        .setFirstResult(0)
        .setMaxResults(2)
        .getResultList();

이러면 말 그대로 많은 member들을 팀과함께 가지고 오는데, 그 중 몇개만.

데이터 뻥튀기가 일어날 일도 없다. 아니 애초에 많은 상태라..

 

 

 

 

그래도 나는 team으로 members를 가져오면서 페이징으로 가져와야 돼, 하면

List<Team> result1 = em.createQuery("select t from Team t", Team.class)
        .setFirstResult(0)
        .setMaxResults(2)
        .getResultList();

for (Team s : result1) {
    for(Member m : s.getMembers()){
        System.out.println("m = " + m.getUsername());
    }
}

일단 이렇게 보면,

Hibernate: 
    /* select
        t 
    from
        Team t */ select
            t1_0.id,
            t1_0.name 
        from
            Team t1_0 offset ? rows fetch first ? rows only

이렇게 쿼리를 보낸다. offset은 limit 처럼 페이징 처리를 하는 명령어 이다.

 

그 후,

for (Team s : result1) {
    for(Member m : s.getMembers()){
        System.out.println("m = " + m.getUsername());
    }
}

fetchType을 LAZY 했으므로 (fetch join과는 연관 없다) 위 코드처럼 접근할 때 쿼리를 보낸다.

Hibernate: 
    select
        m1_0.team_id,
        m1_0.id,
        m1_0.age,
        m1_0.type,
        m1_0.username 
    from
        Member m1_0 
    where
        m1_0.team_id=?
m = 관리자
m = 유저1

Hibernate: 
    select
        m1_0.team_id,
        m1_0.id,
        m1_0.age,
        m1_0.type,
        m1_0.username 
    from
        Member m1_0 
    where
        m1_0.team_id=?
m = 유저2
m = 유저3

이렇게.

만약 team이 100개 였으면 쿼리를 100개 보냈을 것이다.

team1인 member들 얻어오고.. team2인 멤버들 얻어오고..

 

이 때 할 수 있는 옵션이,

Team 엔티티에 저 가져오는 members

@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();

저 batchSize 하면 

 

우리가 team DB에서 가져올 때

List<Team> result1 = em.createQuery("select t from Team t", Team.class)
        .setFirstResult(0)
        .setMaxResults(3)
        .getResultList();

저 리스트에 있는 모든 팀들, 

 

for (Team s : result1) {
    for(Member m : s.getMembers()){
        System.out.println("m = " + m.getUsername());
    }
}

이렇게 DB에 접근하면서 LAZY 로딩 상태기 때문에 이 때 DB에 쿼리를 보내게 되는데,

 

저 때 하나하나 내 팀 뿐만 아니라 리스트에 있는 다른 팀들의 멤버들도 찾아오게끔 인쿼리로 최대 batchSize인 100개만큼 한번에 가져온다.

 

Hibernate: 
    /* select
        t 
    from
        Team t */ select
            t1_0.id,
            t1_0.name 
        from
            Team t1_0 offset ? rows fetch first ? rows only
==== before use data
Hibernate: 
    select
        m1_0.team_id,
        m1_0.id,
        m1_0.age,
        m1_0.type,
        m1_0.username 
    from
        Member m1_0 
    where
        m1_0.team_id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
m = 관리자
m = 유저1
m = 유저2
m = 유저3
m = 유저4
m = 유저5

이렇게 된다. 

처음에 페이징 해서 team들을 가져오고,

그 다음에 Team의 members에 접근해서 그제서야 member를 가지고 오게 되는데,

 

저렇게 한번에 가져온다.

where in의 저 ? 안에는 List<Team> 에 있는 team들의 id가 담겨있을 것이다.

저렇게 최대 설정한 뱃치 사이즈인 100개씩 한번에 가져오는 것이다.

 

만약 저 List가 150개 있으면 처음 날릴때는 100개 날리고 두번째 날릴때는 남은 50개 날리고

 

저거는 보통 1000 이하로 설정 한다.

 

이런 1 + n 으로 쿼리가 나가는 문제를 보통 fetch join으로, 그게 안될때는 

 

이렇게 컬렉션의 경우에는 @BatchSize로 해결

 

또, 저런 @batchSize 옵션을 일일이 필드에다 줘야 하는 게 아니라,

 

persistence.xml에

<persistence-unit name="hello">
    <properties>
        <property name="jakarta.persistence.jdbc.driver" value="org.h2.Driver"/>
        <property name="jakarta.persistence.jdbc.user" value="sa"/>
        <property name="jakarta.persistence.jdbc.password" value="1234"/>
        <property name="jakarta.persistence.jdbc.url" value="jdbc:h2:tcp://localhost/./test"/>

        <property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"/>

        <property name="hibernate.show_sql" value="true"/>
        <property name="hibernate.format_sql" value="true"/>
        <property name="hibernate.use_sql_comments" value="true"/>
        <property name="hibernate.hbm2ddl.auto" value="create"/>
        <property name="hibernate.default_batch_fetch_size" value="100"/>
    </properties>
</persistence-unit>
<property name="hibernate.default_batch_fetch_size" value="100"/>

이렇게 아예 저 영속성의 옵션으로 줄 수 있다.

 

저렇게 해 두면 실제로 필드에 일일이 명시하지 않아도 알아서 List의 경우 동작하는 모양이다.

저거는 이제 컬렉션을 가져오는 경우 뿐 아니라,

List<Member> result1 = em.createQuery("select m from Member m", Member.class)
        .setFirstResult(0)
        .setMaxResults(3)
        .getResultList();

for(Member m : result1){
    System.out.println("m = " + m.getTeam().getName());
}

이렇게 List<>(getResultList) 해서 가져오는 거 

저거 다 해당되는 듯 하다.

 

저 옵션 하나 주는 거 로도 꽤 최적화 할 수 있다.

 

그러니까, BatchSize는 getResultList 해서 여러값들을 가져온 것을,

LAZY, 지연로딩이었던 거 접근하는 시점에 그 List에 있는 것들을 한번에 싹 다 가져오는 기술이다.

 

 

 

 

아니면 DTO로 쿼리 직접 짜든가

 

패치조인은 LAZY같은 글로벌 로딩 전략보다 우선함.

 

 

 

실무에서 글로벌 로딩 전략은 모두 LAZY로

최적화가 필요한 곳은 패치 조인 적용. (필요한 곳만, N + 1이 발생하는 곳만)

 

JPA의 성능 문제의 대부분은 N + 1 한 7~80% 정도?

 

엔티티의 모양이 아닌 전혀다른 결과를 내야 한다면, 패치 조인보다는 일반조인을 하고 필요한 데이터들만 조회해서 DTO로 반환시키는 것이 효과적.

 

 

3가지 방법이 있음

패치 조인해서 엔티티를 조회해 온다. 그걸 그대로 쓴다.

패치조인해서, 그걸 애플리케이션에서 DTO로 바꿔서 사용한다.

JPQL을 짤 때 뉴 Operation으로 DTO로 가져온다.

 

뉴 오퍼레이션은

저렇게 new 해서 엔티티와 관련없는 거 패키지 지정해서 가져올 수 있는거 인 듯? 

생성자로 넣을 수 있네.

 

 

'JPA > JPA 기본' 카테고리의 다른 글

50. 엔티티 직접 사용  (0) 2023.11.03
49. 다형성 쿼리  (0) 2023.11.03
48. 페치 조인 한계  (0) 2023.11.01
47. 페치 조인 기본  (0) 2023.11.01
46. 경로 표현식  (0) 2023.11.01