JPA N+1 Issue

devty·2023년 12월 12일
0

SpringBoot

목록 보기
1/11

서론

무슨 일이 발생했나?

  • JPA를 사용하여 데이터를 조회할 때, 예상과 달리 쿼리가 빈번하게 실행되는 문제를 발견할 수 있다.
  • 일반적으로는 단일 쿼리로 모든 관련 데이터를 불러올 것으로 기대하지만, 실제로는 각 연관 엔티티에 접근할 때마다 추가 쿼리가 발생한다.
  • 이러한 현상은 Lazy Loading 방식으로 인해 발생하며, 이는 N+1 쿼리 문제로 알려져 있다.
  • 예를 들어, User 엔티티가 10개 있고, 각 User에 연관된 Post 엔티티를 조회할 경우, 초기 User 조회를 위한 1회 쿼리에 더해 각 UserPost를 조회하는 10회의 추가 쿼리가 발생한다.
  • 이렇게 총 11회의 쿼리가 실행된다. 하지만 만약 User 조회 결과가 10만 개라면, 상황은 매우 심각해진다.
  • 한 번의 서비스 로직 실행으로 데이터베이스 조회가 무려 10만 번 발생할 수 있기 때문이다.
  • 이런 상황을 방지하기 위해, 연관 관계가 있는 엔티티들을 효율적으로 한 번에 불러오는 몇 가지 방법들이 있다.
  • 아래서 설명하겠다!

본론

싱글 연관관계 매핑 테스트

  • User, Post Entity
    @Entity
    @Table(name = "users")
    public class UserJpaEntity {
        @Id
        @GeneratedValue
        private Long id;
        private String email;
        private String password;
        private String nickname;
        private String name;
    
        @Builder.Default
        @OneToMany(mappedBy = "userJpaEntity", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
        private List<PostJpaEntity> postJpaEntities = new ArrayList<>();
    
        public void addPost(PostJpaEntity postJpaEntity) {
            postJpaEntities.add(postJpaEntity);
        }
    }
    
    @Entity
    @Table(name = "posts")
    public class PostJpaEntity {
    
        @Id
        @GeneratedValue
        private Long id;
    
        private String title;
    
        private String content;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "user_id")
        private UserJpaEntity userJpaEntity;
    
        public void addUser(UserJpaEntity userJpaEntity) {
            this.userJpaEntity = userJpaEntity;
        }
    }
    • 보는 것과 같이 User와 Post는 1:N 관계를 가지고 있다.
    • 두 Entity는 Lazy로 Join 되어 있으며 두 Entity에 새로운 연관관계 테이블을 만들지 않기 위해, Mapped By를 사용하여 연관관계의 주인을 설정하였다.
    • 두 entity 모두 JPA를 상속 받아서 사용하였다.
    • 위 상태에서 테스트 코드를 작성해보겠다.

싱글 연관관계 N+1 Issue 테스트 코드

  • JPA_OneToMany_Lazy_Loading_Test
    @Transactional
    @SpringBootTest
    @DisplayName("1:N 관계에서 N에서 LAZY Loading 을 사용할 때 발생하는 N+1 문제를 확인합니다.")
    public class JPA_OneToMany_Lazy_Loading_Test {
    
        @BeforeEach
        public void setup() {
            for (int i = 1; i <= 10; i++) {
                // UserJpaEntity 객체 생성
                UserJpaEntity userJpaEntity = UserJpaEntity.builder()
                        .email("user" + i + "@example.com")
                        .password("password")
                        .nickname("nickname" + i)
                        .name("User " + i)
                        .build();
    
                // PostJpaEntity 객체 생성
                PostJpaEntity postJpaEntity1 = PostJpaEntity.builder()
                        .title("Title " + i)
                        .content("Content " + i)
                        .build();
    
                PostJpaEntity postJpaEntity2 = PostJpaEntity.builder()
                        .title("Title " + i)
                        .content("Content " + i)
                        .build();
    
                // UserJpaEntity에 PostJpaEntity를 추가
                userJpaEntity.addPost(postJpaEntity1);
                userJpaEntity.addPost(postJpaEntity2);
    
                // PostJpaEntity에 UserJpaEntity를 추가
                postJpaEntity1.addUser(userJpaEntity);
                postJpaEntity2.addUser(userJpaEntity);
    
                // UserJpaEntity를 저장 (CascadeType.ALL에 의해 PostJpaEntity도 저장됩니다)
                userJpaRepo.save(userJpaEntity);
            }
        }
    }
    • 기본적인 set up이라 넘어가도록 하겠다.
  • Test 1 → N+1 문제 발생
    @Test
    @DisplayName("Lazy Loading을 사용할 때 발생하는 N+1 문제를 확인합니다.")
    public void test1() {
        em.flush();
        em.clear();
        System.out.println("------------ 영속성 컨텍스트 비우기 -----------\n");
    
        System.out.println("------------ USER 전체 조회 요청 ------------");
        List<UserJpaEntity> userJpaEntities = userJpaRepo.findAll();
        System.out.println("------------ USER 전체 조회 완료. [1번의 쿼리 발생]------------\n");
    
        System.out.println("------------ USER와 연관관계인 POST 내용 조회 요청 ------------");
        userJpaEntities.forEach(userJpaEntity -> System.out.println("USER IN POST ID: " + userJpaEntity.getPostJpaEntities().get(0).getId()));
        System.out.println("------------ USER와 연관관계인 POST 내용 조회 완료  [조회된 USER 개수(N=10) 만큼 추가적인 쿼리 발생] ------------\n");
    }
    • 나오는 출력물은 아래와 같다. Sout을 제외하고 Hibernate만 뽑아서 보여주겠다.
      ------------ 영속성 컨텍스트 비우기 -----------
      
      ------------ USER 전체 조회 요청 ------------
      Hibernate: select userjpaent0_.id as id1_6_, userjpaent0_.email as email2_6_, userjpaent0_.name as name3_6_, userjpaent0_.nickname as nickname4_6_, userjpaent0_.password as password5_6_ from users userjpaent0_
      ------------ USER 전체 조회 완료. [1번의 쿼리 발생]------------
      
      ------------ USER와 연관관계인 POST 내용 조회 요청 ------------
      Hibernate: select postjpaent0_.user_id as user_id4_3_0_, postjpaent0_.id as id1_3_0_, postjpaent0_.id as id1_3_1_, postjpaent0_.content as content2_3_1_, postjpaent0_.title as title3_3_1_, postjpaent0_.user_id as user_id4_3_1_ from posts postjpaent0_ where postjpaent0_.user_id=?
      Hibernate: select postjpaent0_.user_id as user_id4_3_0_, postjpaent0_.id as id1_3_0_, postjpaent0_.id as id1_3_1_, postjpaent0_.content as content2_3_1_, postjpaent0_.title as title3_3_1_, postjpaent0_.user_id as user_id4_3_1_ from posts postjpaent0_ where postjpaent0_.user_id=?
      Hibernate: select postjpaent0_.user_id as user_id4_3_0_, postjpaent0_.id as id1_3_0_, postjpaent0_.id as id1_3_1_, postjpaent0_.content as content2_3_1_, postjpaent0_.title as title3_3_1_, postjpaent0_.user_id as user_id4_3_1_ from posts postjpaent0_ where postjpaent0_.user_id=?
      Hibernate: select postjpaent0_.user_id as user_id4_3_0_, postjpaent0_.id as id1_3_0_, postjpaent0_.id as id1_3_1_, postjpaent0_.content as content2_3_1_, postjpaent0_.title as title3_3_1_, postjpaent0_.user_id as user_id4_3_1_ from posts postjpaent0_ where postjpaent0_.user_id=?
      Hibernate: select postjpaent0_.user_id as user_id4_3_0_, postjpaent0_.id as id1_3_0_, postjpaent0_.id as id1_3_1_, postjpaent0_.content as content2_3_1_, postjpaent0_.title as title3_3_1_, postjpaent0_.user_id as user_id4_3_1_ from posts postjpaent0_ where postjpaent0_.user_id=?
      Hibernate: select postjpaent0_.user_id as user_id4_3_0_, postjpaent0_.id as id1_3_0_, postjpaent0_.id as id1_3_1_, postjpaent0_.content as content2_3_1_, postjpaent0_.title as title3_3_1_, postjpaent0_.user_id as user_id4_3_1_ from posts postjpaent0_ where postjpaent0_.user_id=?
      Hibernate: select postjpaent0_.user_id as user_id4_3_0_, postjpaent0_.id as id1_3_0_, postjpaent0_.id as id1_3_1_, postjpaent0_.content as content2_3_1_, postjpaent0_.title as title3_3_1_, postjpaent0_.user_id as user_id4_3_1_ from posts postjpaent0_ where postjpaent0_.user_id=?
      Hibernate: select postjpaent0_.user_id as user_id4_3_0_, postjpaent0_.id as id1_3_0_, postjpaent0_.id as id1_3_1_, postjpaent0_.content as content2_3_1_, postjpaent0_.title as title3_3_1_, postjpaent0_.user_id as user_id4_3_1_ from posts postjpaent0_ where postjpaent0_.user_id=?
      Hibernate: select postjpaent0_.user_id as user_id4_3_0_, postjpaent0_.id as id1_3_0_, postjpaent0_.id as id1_3_1_, postjpaent0_.content as content2_3_1_, postjpaent0_.title as title3_3_1_, postjpaent0_.user_id as user_id4_3_1_ from posts postjpaent0_ where postjpaent0_.user_id=?
      Hibernate: select postjpaent0_.user_id as user_id4_3_0_, postjpaent0_.id as id1_3_0_, postjpaent0_.id as id1_3_1_, postjpaent0_.content as content2_3_1_, postjpaent0_.title as title3_3_1_, postjpaent0_.user_id as user_id4_3_1_ from posts postjpaent0_ where postjpaent0_.user_id=?
      ------------ USER와 연관관계인 POST 내용 조회 완료  [조회된 USER 개수(N=10) 만큼 추가적인 쿼리 발생] ------------
    • 총 쿼리는 1(전체 조회) + 10(User Entity 연관관계인 Post Entity를 조회시) = 11번 발생했다.
    • Lazy Join은 그렇다, 필요한 즉시 가져와서 사용하는 Join인 것이였다.
    • 기본적인 Lazy로는 해결하기 어려우니 다음 방법을 사용해보겠다.
  • Test 2 → Fetch Join을 사용하여 N+1 문제 해결하기
    public interface UserJpaRepo extends JpaRepository<UserJpaEntity, Long> {
        @Query("select u from UserJpaEntity u join fetch u.postJpaEntities")
        List<UserJpaEntity> findAllByFetchJoin();
    }
    • Fetch Join으로 가져오기 위해, 네이티브 쿼리에 형태로 가져온다.

      @Test
      @DisplayName("Fetch Join을 사용하여 N+1 문제를 해결합니다.")
      public void test2() {
          em.flush();
          em.clear();
          System.out.println("------------ 영속성 컨텍스트 비우기 -----------\n");
      
          System.out.println("------------ USER 전체 조회 요청 ------------");
          List<UserJpaEntity> userJpaEntities = userJpaRepo.findAllByFetchJoin();
          System.out.println("------------ USER 전체 조회 완료. [1번의 쿼리 발생]------------\n");
      
          System.out.println("------------ USER와 연관관계인 POST 내용 조회 요청 ------------");
          userJpaEntities.forEach(userJpaEntity -> System.out.println("USER IN POST ID: " + userJpaEntity.getPostJpaEntities().get(0).getId()));
          System.out.println("------------ USER와 연관관계인 POST 내용 조회 완료  [Fetch Join을 사용하여 추가적인 쿼리 발생하지 않음] ------------\n");
      
          System.out.println("------------ USER SIZE 확인 ------------");
          System.out.println("userJpaEntities.size() : " + userJpaEntities.size());
          System.out.println("------------ USER SIZE 확인 완료 [20개가 조회!?, Fetch Join(Inner Join)은 카테시안 곱이 발생하여 POST의 수만큼 USER가 중복이 발생]------------\n");
      }
    • 나오는 출력물은 아래와 같다. Sout을 제외하고 Hibernate만 뽑아서 보여주겠다.

      ------------ USER 전체 조회 요청 ------------
      Hibernate: select userjpaent0_.id as id1_6_0_, postjpaent1_.id as id1_3_1_, userjpaent0_.email as email2_6_0_, userjpaent0_.name as name3_6_0_, userjpaent0_.nickname as nickname4_6_0_, userjpaent0_.password as password5_6_0_, postjpaent1_.content as content2_3_1_, postjpaent1_.title as title3_3_1_, postjpaent1_.user_id as user_id4_3_1_, postjpaent1_.user_id as user_id4_3_0__, postjpaent1_.id as id1_3_0__ from users userjpaent0_ inner join posts postjpaent1_ on userjpaent0_.id=postjpaent1_.user_id
      ------------ USER 전체 조회 완료. [1번의 쿼리 발생]------------
      
      ------------ USER와 연관관계인 POST 내용 조회 요청 ------------
      ------------ USER와 연관관계인 POST 내용 조회 완료  [Fetch Join을 사용하여 추가적인 쿼리 발생하지 않음] ------------
      
      ------------ USER SIZE 확인 ------------
      userJpaEntities.size() : 20
      ------------ USER SIZE 확인 완료 [20개가 조회!?, Fetch Join(Inner Join)은 카테시안 곱이 발생하여 POST의 수만큼 USER가 중복이 발생]------------
    • 총 쿼리는 1(전체 조회)번 진행이 된다.

    • Fetch Join을 통해 User, Post를 한 쿼리에 다 가져온다.

    • 하지만 Fetch Join에도 단점이 존재하는데, Fetch Join은 카테시안 곱이 적용되어 Subject(Post)의 수 만큼 User가 중복 조회된다.

      • 총 User의 개수는 20개(User 1개당 Post가 2개씩 존재하므로)로 측정이 된다.
      • 또한, User 1개당 Row가 2개씩 생성이 된다.
    • 이 방법을 해결하기 위해 @Query("select u from UserJpaEntity u join fetch u.postJpaEntities")를 → @Query("select DISTINCT u from UserJpaEntity u join fetch u.postJpaEntities")로 수정하여 중복을 제거하고 뽑아낼 수 있다.

      • 중복을 제거하고 총 User의 개수인 10개만 뽑아낸걸 볼 수 있다.
  • Test 3 → EntityGraph을 사용하여 N+1 문제 해결하기
    public interface UserJpaRepo extends JpaRepository<UserJpaEntity, Long> {
        @EntityGraph(attributePaths = {"postJpaEntities"})
        @Query("select u from UserJpaEntity u")
        List<UserJpaEntity> findAllByEntityGraph();
    }
    • EntityGraph는 Entity가 어떻게 로드 될지 제어하는데 사용된다.

    • attributePaths 속성에 postJpaEntities를 지정하여, UserJpaEntity를 조회할 때 연관된 postJpaEntities 컬렉션도 함께 즉시 로드(Eager Loading)하도록 지시합니다.

      @Test
      @DisplayName("EntityGraph를 사용하여 N+1 문제를 해결합니다.")
      public void test3() {
          em.flush();
          em.clear();
          System.out.println("------------ 영속성 컨텍스트 비우기 -----------\n");
      
          System.out.println("------------ USER 전체 조회 요청 ------------");
          List<UserJpaEntity> userJpaEntities = userJpaRepo.findAllByEntityGraph();
          System.out.println("------------ USER 전체 조회 완료. [1번의 쿼리 발생]------------\n");
      
          System.out.println("------------ USER와 연관관계인 POST 내용 조회 요청 ------------");
          userJpaEntities.forEach(userJpaEntity -> System.out.println("USER IN POST ID: " + userJpaEntity.getPostJpaEntities().get(0).getId()));
          System.out.println("------------ USER와 연관관계인 POST 내용 조회 완료  [Fetch Join을 사용하여 추가적인 쿼리 발생하지 않음] ------------\n");
      
          System.out.println("------------ USER SIZE 확인 ------------");
          System.out.println("userJpaEntities.size() : " + userJpaEntities.size());
          System.out.println("------------ USER SIZE 확인 완료 [20개가 조회!?, EntityGraph(Outer Join)도 카테시안 곱이 발생하여 POST의 수만큼 USER가 중복이 발생]------------\n");
      }
    • 나오는 출력물은 아래와 같다. Sout을 제외하고 Hibernate만 뽑아서 보여주겠다.

      ------------ USER 전체 조회 요청 ------------
      Hibernate: select userjpaent0_.id as id1_6_0_, postjpaent1_.id as id1_3_1_, userjpaent0_.email as email2_6_0_, userjpaent0_.name as name3_6_0_, userjpaent0_.nickname as nickname4_6_0_, userjpaent0_.password as password5_6_0_, postjpaent1_.content as content2_3_1_, postjpaent1_.title as title3_3_1_, postjpaent1_.user_id as user_id4_3_1_, postjpaent1_.user_id as user_id4_3_0__, postjpaent1_.id as id1_3_0__ from users userjpaent0_ left outer join posts postjpaent1_ on userjpaent0_.id=postjpaent1_.user_id
      ------------ USER 전체 조회 완료 [1번의 쿼리 발생]------------
      
      ------------ USER와 연관관계인 POST 내용 조회 요청 ------------
      ------------ USER와 연관관계인 POST 내용 조회 완료  [EntityGraph 을 사용하여 추가적인 쿼리 발생하지 않음] ------------
      
      ------------ USER SIZE 확인 ------------
      userJpaEntities.size() : 10
      ------------ USER SIZE 확인 완료 [10개가 조회]------------
    • 위 처럼 쿼리는 1번(전체 조회) 조회가 된다.

    • EntityGraph도 Fetch Join과 마찬가지로 Eager Join으로 모든 데이터를 항상 가져오므로 성능에 부정적인 영향을 끼칠수도 있다.

    • 마찬가지로 로드된 엔티티들은 모두 영속성 컨텍스트에서 보관되므로, 큰 데이터 세트의 경우 메모리 사용량이 크게 증가한다.

  • Test 4 → BatchSize을 사용하여 N+1 문제 해결하기
    @Builder.Default
    @BatchSize(size = 5)
    @OneToMany(mappedBy = "userJpaEntity", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<PostJpaEntity> postJpaEntities = new ArrayList<>();
    @Test
    @DisplayName("BatchSize를 사용하여 N+1 문제를 해결합니다.")
    public void test4() {
        em.flush();
        em.clear();
        System.out.println("------------ 영속성 컨텍스트 비우기 -----------\n");
    
        System.out.println("------------ USER 전체 조회 요청 ------------");
        List<UserJpaEntity> userJpaEntities = userJpaRepo.findAll();
        System.out.println("------------ USER 전체 조회 완료. [1번의 쿼리 발생]------------\n");
    
        System.out.println("------------ USER와 연관관계인 POST 내용 조회 요청 ------------");
        userJpaEntities.forEach(userJpaEntity -> System.out.println("USER IN POST ID: " + userJpaEntity.getPostJpaEntities().get(0).getId()));
        System.out.println("------------ USER와 연관관계인 POST 내용 조회 완료 ------------\n");
    }
    • User 1개에 가져올 Post의 개수를 5개로 지정한다.
    • 나오는 출력물은 아래와 같다. Sout을 제외하고 Hibernate만 뽑아서 보여주겠다.
      ------------ 영속성 컨텍스트 비우기 -----------
      
      ------------ USER 전체 조회 요청 ------------
      Hibernate: select userjpaent0_.id as id1_6_, userjpaent0_.email as email2_6_, userjpaent0_.name as name3_6_, userjpaent0_.nickname as nickname4_6_, userjpaent0_.password as password5_6_ from users userjpaent0_
      ------------ USER 전체 조회 완료. [1번의 쿼리 발생] ------------
      
      ------------ USER와 연관관계인 POST 내용 조회 요청 ------------
      Hibernate: select postjpaent0_.user_id as user_id4_3_1_, postjpaent0_.id as id1_3_1_, postjpaent0_.id as id1_3_0_, postjpaent0_.content as content2_3_0_, postjpaent0_.title as title3_3_0_, postjpaent0_.user_id as user_id4_3_0_ from posts postjpaent0_ where postjpaent0_.user_id in (?, ?, ?, ?, ?)
      USER IN POST ID: 2
      USER IN POST ID: 5
      USER IN POST ID: 8
      USER IN POST ID: 11
      USER IN POST ID: 14
      Hibernate: select postjpaent0_.user_id as user_id4_3_1_, postjpaent0_.id as id1_3_1_, postjpaent0_.id as id1_3_0_, postjpaent0_.content as content2_3_0_, postjpaent0_.title as title3_3_0_, postjpaent0_.user_id as user_id4_3_0_ from posts postjpaent0_ where postjpaent0_.user_id in (?, ?, ?, ?, ?)
      USER IN POST ID: 17
      USER IN POST ID: 20
      USER IN POST ID: 23
      USER IN POST ID: 26
      USER IN POST ID: 29
      ------------ USER와 연관관계인 POST 내용 조회 완료 ------------
    • 따라서 쿼리는 총 1(전체 조회) + 2(총 10개의 Post를 5개씩 가져오기에) = 3번 발생한다.
    • @BatchSize를 사용함으로써, 연관 엔티티를 로드하는 데 필요한 데이터베이스 쿼리 수를 크게 줄일 수 있다. 이는 특히 많은 수의 연관 엔티티가 있는 경우에 성능을 크게 개선할 수 있다.
    • @BatchSize는 개별 엔티티 또는 컬렉션 필드에 적용할 수 있어, 어플리케이션의 특정 부분에 대해 세밀한 성능 튜닝을 할 수 있다.
    • 더 큰 size 값은 한 번에 더 많은 데이터를 로드하지만, 메모리 사용량과 처리 시간이 증가할 수 있으므로, 적절한 배치 크기를 선택하는 것이 중요하다.
      • 데이터가 일정 개수 이하라는 보장이 있다면, Batch Size 적용만으로도 충분하지만 데이터가 많다면 문제가 될 가능성이 충분하다. →Batch Size 보다 월등하게 많을 땐 OOM 이슈가 발생한다.

Fetch Join, EntityGraph 차이

  • 공통점
    1. 두 방법 모두 연관된 엔티티를 즉시 로드하는 방식을 제공한다.
    2. Fetch JoinEntityGraph 모두 내부적으로 SQL 조인을 사용한다.
    3. 두 방법 모두 적절하게 사용될 때 데이터베이스의 불필요한 호출을 줄이고 성능을 최적화하는 데 도움이 된다.
  • 차이점
    1. 사용 방식

      • Fetch Join : JPQL 또는 Criteria API를 사용하여 쿼리에서 직접 조인을 정의한다. 이는 더 유연하고, 쿼리마다 다른 로딩 전략을 적용할 수 있다.
      • EntityGraph : 메소드 레벨에서 어노테이션을 사용하여 엔티티의 로딩 방식을 정의한다. 이는 정적이며, 쿼리마다 다르게 적용하기 어렵다.
    2. 복잡한 쿼리:

      • Fetch Join : 복잡한 쿼리에 적합하며, 동적으로 쿼리를 조정할 수 있다.
      • EntityGraph : 간단한 쿼리에 적합하며, 복잡한 쿼리에는 제한적일 수 있다.
    3. Hibernate

      // **Fetch Join**
      Hibernate: 
          select
              userjpaent0_.id as id1_6_0_,
              postjpaent1_.id as id1_3_1_,
              userjpaent0_.email as email2_6_0_,
              userjpaent0_.name as name3_6_0_,
              userjpaent0_.nickname as nickname4_6_0_,
              userjpaent0_.password as password5_6_0_,
              postjpaent1_.content as content2_3_1_,
              postjpaent1_.title as title3_3_1_,
              postjpaent1_.user_id as user_id4_3_1_,
              postjpaent1_.user_id as user_id4_3_0__,
              postjpaent1_.id as id1_3_0__ 
          from
              users userjpaent0_ 
          inner join
              posts postjpaent1_ 
                  on userjpaent0_.id=postjpaent1_.user_id
      
      // **EntityGraph**
      Hibernate: 
          select
              userjpaent0_.id as id1_6_0_,
              postjpaent1_.id as id1_3_1_,
              userjpaent0_.email as email2_6_0_,
              userjpaent0_.name as name3_6_0_,
              userjpaent0_.nickname as nickname4_6_0_,
              userjpaent0_.password as password5_6_0_,
              postjpaent1_.content as content2_3_1_,
              postjpaent1_.title as title3_3_1_,
              postjpaent1_.user_id as user_id4_3_1_,
              postjpaent1_.user_id as user_id4_3_0__,
              postjpaent1_.id as id1_3_0__ 
          from
              users userjpaent0_ 
          left outer join
              posts postjpaent1_ 
                  on userjpaent0_.id=postjpaent1_.user_id
      • Inner Join은 두 테이블에서 일치하는 데이터만 반환하는 반면, Outer Join은 한쪽 테이블에 데이터가 없는 경우에도 결과를 포함시킵니다.
      • Inner Join은 일반적으로 Outer Join보다 결과 집합의 크기가 작다. 왜냐하면 Outer Join은 일치하지 않는 행까지 포함하기 때문이다.
      • 데이터의 포함 여부와 분석 목적에 따라 적합한 조인 유형을 선택한다.

멀티 연관관계 N+1 Issue 테스트 코드

  • Category, Product, Review, Customer Entity
    @Entity
    @Table(name = "categorys")
    public class Category {
        @Id
        @GeneratedValue
        private Long id;
    
        private String name;
    
        @OneToMany(mappedBy = "category", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
        private List<Product> products = new ArrayList<>();
    }
    
    @Entity
    @Table(name = "products")
    public class Product {
        @Id
        @GeneratedValue
        private Long id;
    
        private String name;
        private Double price;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "category_id")
        private Category category;
    
        @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
        private List<Review> reviews = new ArrayList<>();
    }
    
    @Entity
    @Table(name = "reviews")
    public class Review {
        @Id
        @GeneratedValue
        private Long id;
    
        private String content;
        private int rating;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "product_id")
        private Product product;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "customer_id")
        private Customer customer;
    }
    
    @Entity
    @Table(name = "customers")
    public class Customer {
        @Id
        @GeneratedValue
        private Long id;
    
        private String name;
        private String email;
    
        @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
        private List<Review> reviews = new ArrayList<>();
    }
    • Category와 Product : 일대다(1:N) 관계. 한 Category는 여러 Product를 가질 수 있습니다.
    • Product와 Review : 일대다(1:N) 관계. 한 Product는 여러 Review를 가질 수 있습니다.
    • Customer와 Review : 일대다(1:N) 관계. 한 Customer는 여러 Review를 작성할 수 있습니다.
    • Review : ProductCustomer와의 다대일(N:1) 관계이다.
  • Multiple_JPA_OneToMany_Lazy_Loding_Test
    @Transactional
    @SpringBootTest
    public class Multiple_JPA_OneToMany_Lazy_Loding_Test {
    
        @BeforeEach
        void setup() {
            for (int i = 0; i < 2; i++) {
                Category category = Category.builder()
                        .name("category_name" + i)
                        .build();
                categoryRepo.save(category);
    
                for (int j = 0; j < 2; j++) {
                    Product product = Product.builder()
                            .name("product_name" + j)
                            .price(10.0)
                            .category(category)
                            .build();
                    productRepo.save(product);
    
                    for (int k = 0; k < 2; k++) {
                        Customer customer = Customer.builder()
                                .name("customer_name" + k)
                                .email("customer_email" + k)
                                .build();
                        customerRepo.save(customer);
    
                        for (int l = 0; l < 2; l++) {
                            Review review = Review.builder()
                                    .content("review_content" + l)
                                    .rating(5)
                                    .product(product)
                                    .customer(customer)
                                    .build();
                            reviewRepo.save(review);
                        }
                    }
                }
            }
        }
    }
    • Category: 2개 생성
    • Product: 4개 생성 (각 Category당 2개씩)
    • Customer: 8개 생성 (각 Product당 2개씩)
    • Review: 16개 생성 (각 Customer당 2개씩)
  • Test 1 → N+1 문제 발생
    @Test
    @DisplayName("Lazy Loading을 사용할 때 발생하는 N+1 문제를 확인합니다.")
    void test1() {
        em.flush();
        em.clear();
        System.out.println("------------ 영속성 컨텍스트 비우기 -----------\n");
    
        System.out.println("--------------PRODUCT 조회--------------");
        List<Product> products = productRepo.findAll();
        System.out.println("--------------PRODUCT 조회 끝---------------\n");
    
        for (Product product : products) {
            System.out.println("PRODUCT : " + product.getName());
            System.out.println("CATEGORY : " + product.getCategory().getName()); // 2번
            for (Review review : product.getReviews()) { // 4번
                System.out.println("REVIEW : " + review.getContent());
                System.out.println("CUSTOMER : " + review.getCustomer().getName()); // 8번
            }
        }
    }
    • 나오는 출력물은 아래와 같다. Sout을 제외하고 Hibernate만 뽑아서 보여 주겠습니다.
      ------------ 영속성 컨텍스트 비우기 -----------
      
      --------------PRODUCT 조회--------------
      Hibernate: select product0_.id as id1_4_, product0_.category_id as category4_4_, product0_.name as name2_4_, product0_.price as price3_4_ from products product0_
      --------------PRODUCT 조회 끝---------------
      
      Hibernate: select category0_.id as id1_1_0_, category0_.name as name2_1_0_ from categorys category0_ where category0_.id=?
      
      Hibernate: select reviews0_.product_id as product_5_5_0_, reviews0_.id as id1_5_0_, reviews0_.id as id1_5_1_, reviews0_.content as content2_5_1_, reviews0_.customer_id as customer4_5_1_, reviews0_.product_id as product_5_5_1_, reviews0_.rating as rating3_5_1_ from reviews reviews0_ where reviews0_.product_id=?
      Hibernate: select customer0_.id as id1_2_0_, customer0_.email as email2_2_0_, customer0_.name as name3_2_0_ from customers customer0_ where customer0_.id=?
      Hibernate: select customer0_.id as id1_2_0_, customer0_.email as email2_2_0_, customer0_.name as name3_2_0_ from customers customer0_ where customer0_.id=?
      
      Hibernate: select reviews0_.product_id as product_5_5_0_, reviews0_.id as id1_5_0_, reviews0_.id as id1_5_1_, reviews0_.content as content2_5_1_, reviews0_.customer_id as customer4_5_1_, reviews0_.product_id as product_5_5_1_, reviews0_.rating as rating3_5_1_ from reviews reviews0_ where reviews0_.product_id=?
      Hibernate: select customer0_.id as id1_2_0_, customer0_.email as email2_2_0_, customer0_.name as name3_2_0_ from customers customer0_ where customer0_.id=?
      Hibernate: select customer0_.id as id1_2_0_, customer0_.email as email2_2_0_, customer0_.name as name3_2_0_ from customers customer0_ where customer0_.id=?
      
      Hibernate: select category0_.id as id1_1_0_, category0_.name as name2_1_0_ from categorys category0_ where category0_.id=?
      
      Hibernate: select reviews0_.product_id as product_5_5_0_, reviews0_.id as id1_5_0_, reviews0_.id as id1_5_1_, reviews0_.content as content2_5_1_, reviews0_.customer_id as customer4_5_1_, reviews0_.product_id as product_5_5_1_, reviews0_.rating as rating3_5_1_ from reviews reviews0_ where reviews0_.product_id=?
      Hibernate: select customer0_.id as id1_2_0_, customer0_.email as email2_2_0_, customer0_.name as name3_2_0_ from customers customer0_ where customer0_.id=?
      Hibernate: select customer0_.id as id1_2_0_, customer0_.email as email2_2_0_, customer0_.name as name3_2_0_ from customers customer0_ where customer0_.id=?
      
      Hibernate: select reviews0_.product_id as product_5_5_0_, reviews0_.id as id1_5_0_, reviews0_.id as id1_5_1_, reviews0_.content as content2_5_1_, reviews0_.customer_id as customer4_5_1_, reviews0_.product_id as product_5_5_1_, reviews0_.rating as rating3_5_1_ from reviews reviews0_ where reviews0_.product_id=?
      Hibernate: select customer0_.id as id1_2_0_, customer0_.email as email2_2_0_, customer0_.name as name3_2_0_ from customers customer0_ where customer0_.id=?
      Hibernate: select customer0_.id as id1_2_0_, customer0_.email as email2_2_0_, customer0_.name as name3_2_0_ from customers customer0_ where customer0_.id=?
      
    • productRepo.findAll() → 1번 실행
    • product.getCategory().getName() → 2번 실행
    • product.getReviews() → 4번 실행
    • review.getCustomer().getName() → 8번 실행
    • 1 + 2 + 4 + 8 = 15번 쿼리 실행이 됩니다.
  • Test 2 → Fetch Join을 사용하여 N+1 문제 해결하기
    public interface ProductRepo extends JpaRepository<Product, Long> {
        @Query("SELECT p FROM Product p JOIN FETCH p.category JOIN FETCH p.reviews r JOIN FETCH r.customer")
        List<Product> findAllByMultiFetchJoin();
    }
    @Test
    @DisplayName("Lazy Loading을 사용할 때 발생하는 N+1 문제를 확인합니다.")
    void test2() {
        em.flush();
        em.clear();
        System.out.println("------------ 영속성 컨텍스트 비우기 -----------\n");
    
        System.out.println("--------------PRODUCT 조회--------------");
        List<Product> products = productRepo.findAllByMultiFetchJoin();
        System.out.println("--------------PRODUCT 조회 끝---------------\n");
    
        for (Product product : products) {
            System.out.println("PRODUCT : " + product.getName());
            System.out.println("CATEGORY : " + product.getCategory().getName());
            for (Review review : product.getReviews()) {
                System.out.println("REVIEW : " + review.getContent());
                System.out.println("CUSTOMER : " + review.getCustomer().getName());
            }
        }
    }
    • 나오는 출력물은 아래와 같다. Sout을 제외하고 Hibernate만 뽑아서 보여 주겠습니다.
      ------------ 영속성 컨텍스트 비우기 -----------
      
      --------------PRODUCT 조회--------------
      Hibernate: 
          select
              product0_.id as id1_4_0_,
              category1_.id as id1_1_1_,
              reviews2_.id as id1_5_2_,
              customer3_.id as id1_2_3_,
              product0_.category_id as category4_4_0_,
              product0_.name as name2_4_0_,
              product0_.price as price3_4_0_,
              category1_.name as name2_1_1_,
              reviews2_.content as content2_5_2_,
              reviews2_.customer_id as customer4_5_2_,
              reviews2_.product_id as product_5_5_2_,
              reviews2_.rating as rating3_5_2_,
              reviews2_.product_id as product_5_5_0__,
              reviews2_.id as id1_5_0__,
              customer3_.email as email2_2_3_,
              customer3_.name as name3_2_3_ 
          from
              products product0_ 
          inner join
              categorys category1_ 
                  on product0_.category_id=category1_.id 
          inner join
              reviews reviews2_ 
                  on product0_.id=reviews2_.product_id 
          inner join
              customers customer3_ 
                  on reviews2_.customer_id=customer3_.id
      --------------PRODUCT 조회 끝---------------
    • productRepo.findAllByMultiFetchJoin() → 1번 실행

결론

후기

  • N+1 문제를 해결하기 위해 Lazy Loading, Fetch Join, EntityGraph와 같은 다양한 접근법을 적용해 보았다.
  • 이 방법들 중에서 Batch Size 설정이 특히 눈에 띄는 해결책이 되었습니다.
  • Batch Size를 사용하면 관련 엔티티의 로딩을 일괄적으로 처리할 수 있다는 장점이 있었다.
    • 예를 들어, 한 Product 엔티티와 연관된 여러 Review 엔티티가 있을 때, Batch Size가 설정되면 JPA는 한 번의 쿼리로 설정된 수만큼의 Review를 함께 로드한다.
    • 이는 각 Review에 대해 개별적인 쿼리를 실행하는 대신, 더 큰 단위의 데이터를 한꺼번에 가져와서 데이터베이스에 대한 쿼리 호출 수를 줄이는 효과를 가져온다.
  • Fetch Join이나 EntityGraph 방법이 종종 추가적인 쿼리 최적화나 복잡한 쿼리 구조를 필요로 하는 반면, Batch Size는 간단하게 적용할 수 있으며, 특히 많은 수의 연관 엔티티가 있는 경우에 유용했습니다.
  • Batch Size 설정을 통해 로딩되는 엔티티의 양을 조절하면, 네트워크 비용과 데이터베이스 부하를 줄이면서도 데이터의 필요한 부분만 효과적으로 로드할 수 있었다.
  • 이러한 특성 때문에 나는 N+1 문제를 해결하기 위해 Batch Size를 많이 사용할 것 같다!
profile
지나가는 개발자

0개의 댓글