JPA

JPA에서 발생할 수 있는 문제들과 해결방법 정리 (N + 1, 무작위 JOIN..)

@xftg77g 2022. 8. 9. 17:30

0. N:1 환경설정

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Parent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
}
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Child {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @ManyToOne(fetch = FetchType.EAGER) // default: EAGER
    private Parent parent;
}
@BeforeEach로 DB 구성
총 parent 2개, 각 child 2개씩
영속성 컨텍스트(EntityManager) 캐시에 영향받지 않는 쿼리를 발생시키기 위하여 영속성 컨텍스트를 비워줌
    final int CHILD_COUNT = 2;

    @BeforeEach
    void before() {
        Parent parent1 = parentRepository.save(
                Parent.builder()
                        .name("testName1")
                        .build()
        );

        for(int i = 0; i < CHILD_COUNT; i++)
            childRepository.save(
                    Child.builder()
                            .parent(parent1)
                            .build()
            );

        Parent parent2 = parentRepository.save(
                Parent.builder()
                        .name("testName2")
                        .build()
        );

        for(int i = 0; i < CHILD_COUNT; i++)
            childRepository.save(
                    Child.builder()
                            .parent(parent2)
                            .build()
            );

        em.flush();
        em.clear();

        System.out.println("******************************* Test Start *******************************");
    }
Test용 print 함수 정의
<T> void print(T data) {
    System.out.println("[" + data + "]");
}

1. N:1 관계에서 EAGER 로딩 사용

1-1. 단일 객체 조회 시

@Test
void test() {
    Child child = em.find(Child.class, 1L); // 맨 처음 진행되는 테스트라서 그냥 1L로 대충 설정하였음..
}
Hibernate: 
    select
        child0_.id as id1_0_0_,
        child0_.parent_id as parent_i2_0_0_,
        parent1_.id as id1_1_1_ 
    from
        child child0_ 
    left outer join
        parent parent1_ 
            on child0_.parent_id=parent1_.id 
    where
        child0_.id=?

발생하는 쿼리

  • JPA 내부적으로 최적화하여 left outter join 쿼리가 발생하는 것을 볼 수 있음

발생할 수 있는 문제

  • EAGER 로딩 되는 객체가 많으면 join되는 테이블이 많아져, 성능 저하 문제를 일으킬 수 있다.

해결방법

  • EAGER 로딩을 사용하지 않고 LAZY 로딩을 사용한다.

1-2. 다중 객체 조회 시

@Test
void test() {
    List<Child> children = childRepository.findAll();
    
    // 또는 JPQL 직접 사용
    List<Child> children = em.createQuery("select c from Child c", Child.class).getResultList();
}
Hibernate: 
    select
        child0_.id as id1_0_,
        child0_.parent_id as parent_i2_0_ 
    from
        child child0_
Hibernate: 
    select
        parent0_.id as id1_1_0_ 
    from
        parent parent0_ 
    where
        parent0_.id=?
Hibernate: 
    select
        parent0_.id as id1_1_0_ 
    from
        parent parent0_ 
    where
        parent0_.id=?

 

발생하는 쿼리

  • child 객체를 모두 조회하고 이후에 parent 필드의 EAGER 로딩을 위한 추가 쿼리가 나가는 것을 볼 수 있음

발생할 수 있는 문제

  • 그 유명한 N + 1 문제가 발생한다.

해결방법

  • N + 1 문제의 해결 방법은 맨 아래에서.

2. N:1 관계에서 LAZY 로딩 사용

2-1. 단일 객체 조회 시

@Test
void test() {
    Child child = em.find(Child.class, 1L);
    Parent parent = child.getParent();
    print(parent.getId());
    print(parent.getName());
}
Hibernate: 
    select
        child0_.id as id1_0_0_,
        child0_.parent_id as parent_i2_0_0_ 
    from
        child child0_ 
    where
        child0_.id=?
[1]
Hibernate: 
    select
        parent0_.id as id1_1_0_,
        parent0_.name as name2_1_0_ 
    from
        parent parent0_ 
    where
        parent0_.id=?
[testName1]

발생하는 쿼리

  • LAZY 로딩이기 때문에 parent 필드를 실질적으로 참조할 때 쿼리가 발생한다.
  • 단순히 parent의 id를 참조할 때는 쿼리가 나가지 않는다. (FK를 통해 바로 참조할 수 있기 때문인 것 같다)
  • 하지만 id 필드 외에 다른 필드의 참조를 시도하면 쿼리가 발생한다.

발생할 수 있는 문제

  • 준영속 상태인 객체의 프록시 객채로 초기화되어 있는 필드를 참조하면 org.hibernate.LazyInitializationException: could not initialize proxy - no Session 문제가 발생할 수 있다.

해결방법

  • @Transactional 을 사용하여 트랜잭션 유지시키기

2-2. 다중 객체 조회 시

@Test
void test0() {
    List<Child> children = childRepository.findAll();

    for(var c : children) {
        Parent parent = c.getParent();
        print(parent.getId());
        print(parent.getName());
    }
}
Hibernate: 
    select
        child0_.id as id1_0_,
        child0_.parent_id as parent_i2_0_ 
    from
        child child0_
[1]
Hibernate: 
    select
        parent0_.id as id1_1_0_,
        parent0_.name as name2_1_0_ 
    from
        parent parent0_ 
    where
        parent0_.id=?
[testName1]
[1]
[testName1]
[2]
Hibernate: 
    select
        parent0_.id as id1_1_0_,
        parent0_.name as name2_1_0_ 
    from
        parent parent0_ 
    where
        parent0_.id=?
[testName2]
[2]
[testName2]

발생하는 쿼리

  • LAZY 로딩이기 때문에 parent 필드를 실질적으로 참조할 때 쿼리가 발생한다.

발생할 수 있는 문제

  • EAGER 로딩과 동일하게 N + 1 문제가 발생한다.

해결 방법

  • N + 1 문제의 해결 방법은 맨 아래에서.

3. N:1 양방향 관계 설정 후 Parent에서 EAGER 로딩 사용

Parent 필드에 N:1 양방향 관계 적용

@OneToMany(mappedBy = "parent", fetch = FetchType.EAGER)
@Default
private Set<Child> children = new HashSet<>();

3-1. 모든 Parent 조회

@Test
void test() {
    List<Parent> parents = parentRepository.findAll();
}
Hibernate: 
    select
        parent0_.id as id1_1_,
        parent0_.name as name2_1_ 
    from
        parent parent0_
Hibernate: 
    select
        children0_.parent_id as parent_i3_0_0_,
        children0_.id as id1_0_0_,
        children0_.id as id1_0_1_,
        children0_.name as name2_0_1_,
        children0_.parent_id as parent_i3_0_1_ 
    from
        child children0_ 
    where
        children0_.parent_id=?
Hibernate: 
    select
        children0_.parent_id as parent_i3_0_0_,
        children0_.id as id1_0_0_,
        children0_.id as id1_0_1_,
        children0_.name as name2_0_1_,
        children0_.parent_id as parent_i3_0_1_ 
    from
        child children0_ 
    where
        children0_.parent_id=?

발생하는 쿼리

  • Parent를 모두 조회하고 이후에 Children을 조회하는 것을 볼 수 있음

발생할 수 있는 문제

  • N + 1 문제가 발생한다.

해결방법

  • N + 1 문제의 해결 방법은 맨 아래에서.

예상 결과

  • 현재 상황에서 LAZY 로딩을 적용하더라도 결국에는 N + 1 문제가 발생할 것으로 보인다.

N + 1 문제 해결 방법

1. @BatchSize(size = ?) 적용

EAGER 로딩을 사용하면 @BatchSize가 동작하지 않으므로 EAGER 로딩 사용은 전제 조건에서 배제된다. 

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Parent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    // EAGER 로딩을 사용하면 @BatchSize는 무시됨
    @OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
    @BatchSize(size = 100)
    @Default
    private Set<Child> children = new HashSet<>();
}

전체 조회

@Test
void test() {
    List<Parent> parents = parentRepository.findAll();

    for(var p : parents) {
        System.out.println(p.getChildren().size());
    }
}

발생 쿼리

Hibernate: 
    select
        parent0_.id as id1_1_,
        parent0_.name as name2_1_ 
    from
        parent parent0_
2022-08-09 17:01:02.765 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_1_] : [BIGINT]) - [1]
2022-08-09 17:01:02.769 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([name2_1_] : [VARCHAR]) - [testParent1]
2022-08-09 17:01:02.770 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_1_] : [BIGINT]) - [2]
2022-08-09 17:01:02.770 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([name2_1_] : [VARCHAR]) - [testParent2]
Hibernate: 
    select
        children0_.parent_id as parent_i3_0_1_,
        children0_.id as id1_0_1_,
        children0_.id as id1_0_0_,
        children0_.name as name2_0_0_,
        children0_.parent_id as parent_i3_0_0_ 
    from
        child children0_ 
    where
        children0_.parent_id in (
            ?, ?
        )
2022-08-09 17:01:02.788 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2022-08-09 17:01:02.789 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicBinder      : binding parameter [2] as [BIGINT] - [2]
2022-08-09 17:01:02.795 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_0_0_] : [BIGINT]) - [1]
2022-08-09 17:01:02.796 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([name2_0_0_] : [VARCHAR]) - [testChild]
2022-08-09 17:01:02.796 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([parent_i3_0_0_] : [BIGINT]) - [1]
2022-08-09 17:01:02.797 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([parent_i3_0_1_] : [BIGINT]) - [1]
2022-08-09 17:01:02.798 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_0_1_] : [BIGINT]) - [1]
2022-08-09 17:01:02.807 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_0_0_] : [BIGINT]) - [2]
2022-08-09 17:01:02.808 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([name2_0_0_] : [VARCHAR]) - [testChild]
2022-08-09 17:01:02.808 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([parent_i3_0_0_] : [BIGINT]) - [1]
2022-08-09 17:01:02.809 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([parent_i3_0_1_] : [BIGINT]) - [1]
2022-08-09 17:01:02.810 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_0_1_] : [BIGINT]) - [2]
2022-08-09 17:01:02.812 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_0_0_] : [BIGINT]) - [3]
2022-08-09 17:01:02.813 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([name2_0_0_] : [VARCHAR]) - [testChild]
2022-08-09 17:01:02.813 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([parent_i3_0_0_] : [BIGINT]) - [2]
2022-08-09 17:01:02.813 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([parent_i3_0_1_] : [BIGINT]) - [2]
2022-08-09 17:01:02.814 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_0_1_] : [BIGINT]) - [3]
2022-08-09 17:01:02.815 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_0_0_] : [BIGINT]) - [4]
2022-08-09 17:01:02.817 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([name2_0_0_] : [VARCHAR]) - [testChild]
2022-08-09 17:01:02.818 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([parent_i3_0_0_] : [BIGINT]) - [2]
2022-08-09 17:01:02.818 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([parent_i3_0_1_] : [BIGINT]) - [2]
2022-08-09 17:01:02.819 TRACE 22384 --- [    Test worker] o.h.type.descriptor.sql.BasicExtractor   : extracted value ([id1_0_1_] : [BIGINT]) - [4]
2
2

1-2. 각 Parent의 Child 개수를 200개로 늘리고 @BatchSize(size = 5) 다시 수행

발생 쿼리

Hibernate: 
    select
        parent0_.id as id1_1_,
        parent0_.name as name2_1_ 
    from
        parent parent0_
Hibernate: 
    select
        children0_.parent_id as parent_i3_0_1_,
        children0_.id as id1_0_1_,
        children0_.id as id1_0_0_,
        children0_.name as name2_0_0_,
        children0_.parent_id as parent_i3_0_0_ 
    from
        child children0_ 
    where
        children0_.parent_id in (
            ?, ?
        )
200
200

발생한 쿼리를 통해 알 수 있는 점

  • @BatchSize를 이용하면 내부적으로 IN절을 사용하여 N+1 문제를 해결할 수 있다.
  • @BatchSize의 size를 지정하여도 내부적으로 몇 개의 쿼리가 나가는지는 정확히 알 수 없다. (짐작만 가능)
  • @BatchSize를 이용하기 까다로운 경우 직접 IN 절을 사용하여 최적화를 시도할 수 있다.

2. Fetch Join 사용

JPQL 직접 작성

@Repository
public interface ParentRepository extends JpaRepository<Parent, Long> {

    @Query("select distinct p from Parent p join fetch p.children")
    List<Parent> findAllUsingFetch();
}

전체 조회

@Test
void test() {
    List<Parent> parents = parentRepository.findAllUsingFetch();

    for(var p : parents) {
        System.out.println(p.getChildren().size());
    }
}

발생 쿼리

Hibernate: 
    select
        distinct parent0_.id as id1_1_0_,
        children1_.id as id1_0_1_,
        parent0_.name as name2_1_0_,
        children1_.name as name2_0_1_,
        children1_.parent_id as parent_i3_0_1_,
        children1_.parent_id as parent_i3_0_0__,
        children1_.id as id1_0_0__ 
    from
        parent parent0_ 
    inner join
        child children1_ 
            on parent0_.id=children1_.parent_id
200
200

발생한 쿼리를 통해 알 수 있는 점

  • Fetch Join을 이용하면 내부적으로 inner join이 발생하여 한 번에 N+1 문제를 해결할 수 있다.
  • 다만 OneToMany 또는 ManyToMany의 경우 중복 데이터가 발생할 수 있다.
  • 따라서 distinct를 사용하여 중복 데이터를 제거해줘야 한다.
  • 반면에 OneToOne, ManyToOne은 데이터 중복이 발생하지 않는다.

EntityGraph 방식은 추후 정리 예정