본문 바로가기

Spring 정리/Spring JPA

페치 조인 ( fetch join ) - 2

728x90
반응형

*목차

페치 조인 특징 3가지와 한계

 1. 페치조인에 별칭을 줄 수 없다.

 2. 둘 이상의 컬렉션을 페치조인 할 수 없다.

 3. 컬렉션 페치조인은 페이징 API를 사용할 수 없다

   - 페이징 API 한계 해결하기    

   1. 엔티티 페치조인 API로 뒤집기

   2. LAZY 조회하기

   3. @BatchSize 이용하기

 

 

* 페치 조인의 특징과 한계

 

1. 페치 조인 대상에는 별칭을 줄 수 없습니다.

(하이버네이트는 가능하지만, 가급적 사용하지 않는다.)

 

페치 조인의 컨셉은 "나랑 연관되어 있는 애들을 모두 가져오겠다" 입니다. Team의 연관 컬렉션 Member가 5명인데 특정 3명만 조회하고 싶다면, 페치 조인을 가급적 사용하면 안됩니다. 무의식적으로 5명을 기대하고 Member 리스트를 사용할 가능성이 있습니다.

 

//잘못된 사용
String query = "select t From t join fetch t.members m where m.age > 10";

//올바른 사용
String query = "select t From t join fetch t.members";

 

where 조건들을 걸어서 특정 데이터를 가져오는 것이 아니라 모든 엔티티를 전부 가져와야 합니다.  따라서 별칭을 사용하지 않습니다. ( 특별히 조인 패치를 계속 체인형식으로 쓰는 경우에만 사용하도록 합니다. )

 

 

 

2. 둘 이상의 컬렉션은 페치 조인을 할 수 없습니다.

 

//잘못된 사용
String query = "select t From t join fetch t.members join fetch t.orders";

//올바른 사용(하나만!)
String query = "select t From t join fetch t.members";

 

[1 : N] 도 데이터가 뻥튀기가 되는데, 둘 이상의 페치 조인을 이용해서 [1 : N : N] 형태로 쿼리를 조회하면 데이터가 뻥튀기 뿐 아니라 정합성 맞추기가 쉽지 않습니다.

 

 

3. 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없습니다.(setFirstResult, setMaxResults)

 

@OneToOne, @ManyToOne 같은 단일 연관 엔티티는 페치 조인해도 페이징이 가능합니다.

하지만, 하이버네이트는 경고 로그를 남기고 메모리에서 페이징을 하는데 이는 굉장히 위험합니다.

 

왜냐하면, 

 

1. 페이징을 하지만 모든 데이터를 메모리에 로딩 후 페이징합니다.

2. distinct와 사용하면 데이터 정합성에 어긋납니다.

 

위험한 이유는 결과 DB를 기준으로 페이징 결과를 조회하기 때문입니다. join fetch와 distinct로 가공한 결과값에서 페이징을 처리하기 때문에 아래에서 팀A는 회원 1과 회원2 총 2명이 소속됨에도 불구하고 회원1만 있는것처럼 조회합니다.

 

 

 

String query = "select t From Team t join fetch t.members";

List<Team> result = em.createQuery(query, Team.class)
    .setFirstResult(0)
    .setMaxResults(2)
    .getResultList();

 

Hibernate: 
    /* select
        t 
    From
        Team t 
    join
        fetch t.members m */ select
            team0_.id as id1_5_0_,
            members1_.id as id1_3_1_,
            team0_.name as name2_5_0_,
            members1_.age as age2_3_1_,
            members1_.city as city3_3_1_,
            members1_.street as street4_3_1_,
            members1_.zipcode as zipcode5_3_1_,
            members1_.name as name6_3_1_,
            members1_.TEAM_ID as TEAM_ID9_3_1_,
            members1_.endDate as endDate7_3_1_,
            members1_.startDate as startDat8_3_1_,
            members1_.TEAM_ID as TEAM_ID9_3_0__,
            members1_.id as id1_3_0__ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.id=members1_.TEAM_ID
teamName=팀A,members=2
-> member = Member{id=3, name='회원1', age=0, team=Team{id=1, name='팀A'}}
-> member = Member{id=4, name='회원2', age=0, team=Team{id=1, name='팀A'}}
teamName=팀B,members=1
-> member = Member{id=5, name='회원3', age=0, team=Team{id=2, name='팀B'}}

 

 쿼리를 보면 페이징에 대한 처리가 없습니다. 모든 데이터를 다 조회한다는 뜻입니다. join fetch를 한 상태에서 페이징 쿼리를 날리면 데이터가 적을때는 상관없지만 데이터가 많을 때 문제가 됩니다. 예를 들어, Team이 100만개가 있으면, 100만개를 모두 메모리에 적재한 이후에 페이지를 조회하게 됩니다. 따라서 페이징이 가능하지만 절대 일반 페이징 방법을 사용해서는 안됩니다.

 

* 해결책

 

* 페이징의 연관관계 주인을 뒤집어서 해결

 

N : 1인 Member : Team인 경우, Member를 기준으로 하면, 연관관계가 컬렉션이 아닌, 엔티티가 되기 때문에 뻥튀기의 문제가 없어집니다.

 

String query = "select m From Member m join fetch m.team t";

List<Team> result = em.createQuery(query, Team.class)
    .setFristResult(0)
    .setMaxResults(1)
    .getResultList();

 

 

* 일반적인 LAZY 로딩(성능 문제)

 

String query = "select t From Team t";

List<Team> result = em.createQuery(query, Team.class)
.setFirstResult(0)
.setMaxResults(2)
.getResultList();

for(Team team : result) {
  System.out.println("teamName=" + team.getName() + ","
  + "members=" + team.getMembers().size());
  for(Member member : team.getMembers()) {
    System.out.println("-> member = " + member);
  }
}

 

 

Hibernate: 
    /* select
        t 
    From
        Team t */ select
            team0_.id as id1_5_,
            team0_.name as name2_5_ 
        from
            Team team0_ limit ?
Hibernate: 
    select
        members0_.TEAM_ID as TEAM_ID9_3_0_,
        members0_.id as id1_3_0_,
        members0_.id as id1_3_1_,
        members0_.age as age2_3_1_,
        members0_.city as city3_3_1_,
        members0_.street as street4_3_1_,
        members0_.zipcode as zipcode5_3_1_,
        members0_.name as name6_3_1_,
        members0_.TEAM_ID as TEAM_ID9_3_1_,
        members0_.endDate as endDate7_3_1_,
        members0_.startDate as startDat8_3_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
teamName=팀A,members=2
-> member = Member{id=3, name='회원1', age=0, team=Team{id=1, name='팀A'}}
-> member = Member{id=4, name='회원2', age=0, team=Team{id=1, name='팀A'}}
Hibernate: 
    select
        members0_.TEAM_ID as TEAM_ID9_3_0_,
        members0_.id as id1_3_0_,
        members0_.id as id1_3_1_,
        members0_.age as age2_3_1_,
        members0_.city as city3_3_1_,
        members0_.street as street4_3_1_,
        members0_.zipcode as zipcode5_3_1_,
        members0_.name as name6_3_1_,
        members0_.TEAM_ID as TEAM_ID9_3_1_,
        members0_.endDate as endDate7_3_1_,
        members0_.startDate as startDat8_3_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
teamName=팀B,members=1
-> member = Member{id=5, name='회원3', age=0, team=Team{id=2, name='팀B'}}

 

Team A를 조회하면, 연관된 Member를 LAZY 로딩합니다. 그리고, Team B를 조회할 때, 연관된 Member를 LAZY 로딩합니다. 결과적으로 총 3번의 쿼리가 발생했습니다. 만약에 Team을 10개 조회한다고 하면, 각 Team마다 연관된 엔티티들을 LAZY 로딩하느라 엄청나게 쿼리가 많아질 성능 문제는 있습니다. ( N + 1 문제 )

 

 

* @BatchSize 이용하기

 

//Team.java
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<Member>();

 

* persistence.xml에 batch size 글로벌 셋팅하기

 

<property name="hibernate.default_batch_fetch_size" value="100" />

 

한번에 쿼리를 100개씩 넘기도록 설정합니다.

 

String query = "select t From Team t";

List<Team> result = em.createQuery(query, Team.class)
.setFirstResult(0)
.setMaxResults(2)
.getResultList();

for(Team team : result) {
  System.out.println("teamName=" + team.getName() + ","
  + "members=" + team.getMembers().size());
  for(Member member : team.getMembers()) {
    System.out.println("-> member = " + member);
  }
}

 

@BatchSize(size = 100)을 설정했기 때문에 LAZY로딩을 할 때, List<Team> result는 최대 100개씩 넘겨받게 됩니다. 따라서 쿼리 한방에 모든 조회를 손쉽게 처리할 수 있습니다.

 

Hibernate: 
    /* select
        t 
    From
        Team t */ select
            team0_.id as id1_5_,
            team0_.name as name2_5_ 
        from
            Team team0_ limit ?
Hibernate: 
    /* load one-to-many domain.Team.members */ select
        members0_.TEAM_ID as TEAM_ID9_3_1_,
        members0_.id as id1_3_1_,
        members0_.id as id1_3_0_,
        members0_.age as age2_3_0_,
        members0_.city as city3_3_0_,
        members0_.street as street4_3_0_,
        members0_.zipcode as zipcode5_3_0_,
        members0_.name as name6_3_0_,
        members0_.TEAM_ID as TEAM_ID9_3_0_,
        members0_.endDate as endDate7_3_0_,
        members0_.startDate as startDat8_3_0_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID in (
            ?, ?
        )
teamName=팀A,members=2
-> member = Member{id=3, name='회원1', age=0, team=Team{id=1, name='팀A'}}
-> member = Member{id=4, name='회원2', age=0, team=Team{id=1, name='팀A'}}
teamName=팀B,members=1
-> member = Member{id=5, name='회원3', age=0, team=Team{id=2, name='팀B'}}

 

지금 size가 100이므로, 만약 Team이 150개이면 100개의 쿼리를 날리고 이후에 50개의 쿼리를 날립니다.

 

 

* 정리 

 

1. fetch join 사용한다.

 

2. fetch join으로 가져온 다음 애플리케이션에서 조작하여 DTO를 만든다. 

 

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

 

 

N + 1이 터지는 곳만 fetch join을 사용해서 최적화를 하면 대부분의 문제를 해결 할 수 있다.

 

* 참고

 

자바 ORM 표준 JPA 프로그래밍 - 기본편

728x90
반응형

'Spring 정리 > Spring JPA' 카테고리의 다른 글

일반 Join vs Fetch Join  (0) 2022.06.29
JPA를 이용해 History 테이블 자동 생성하기  (1) 2021.12.14
페치 조인 ( fetch join ) -1  (0) 2021.10.05
N + 1 문제  (0) 2021.09.30
영속성 전이(CASCADE)와 고아객체  (0) 2021.09.14