본문 바로가기
JPA

JPA N+1 문제와 해결

by 배털 2022. 3. 25.

이 글은 2021년 11월에 작성되었으며 블로그를 이전하며 옮기게 되었습니다.

제 글에 문제가 있다면 댓글로 알려주시면 감사하겠습니다! 🙇‍♂️

저는 JPA를 이용하며 프로젝트를 하며 N+1 문제를 만나보진 않았습니다.
하지만 JPA를 사용하면 자주 만나게 되는 것이 N+1 문제입니다.

JPA를 공부 할 때에 예시 상황을 만들고 공부를 했었지만
공부한 지 시간이 좀 지났기 때문에 프로젝트 중 N+1 문제가 생기더라도
당황하지 않고 빠르게 해결하기 위해, 다시 공부하기 위해 이 글을 작성합니다.

이동욱 님의이동욱 님의 글을 참고하여
Java8 -> Java11,
SpringBoot 1.5.X -> 2.5.X,
Junit4 -> Junit5로 버전을 업그레이드하여
코드를 작성하였고, 이 글을 작성하였습니다
참고하였습니다 🙇‍♂️

모든 코드는 Github에 있습니다.

먼저 클래스 다이어그램과 ERD입니다.

클래스 다이어그램, ERD

위와 같은 구조에서 Academy를 호출하여 그 안에 속한 Subject를 사용한다고 가정해보겠습니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class AcademyService {

    private final AcademyRepository academyRepository;

    @Transactional(readOnly = true)
    public List<String> findAllSubjectNames(){
        return extractSubjectNames(academyRepository.findAll());
    }

    /**
     * Lazy Load를 수행하기 위해 메소드를 별도로 생성
     */
    private List<String> extractSubjectNames(List<Academy> academies){
        log.info(">>>>>>>>[모든 과목을 추출한다]<<<<<<<<<");
        log.info("Academy Size : {}", academies.size());

        return academies.stream()
                .map(a -> a.getSubjects().get(0).getName())
                .collect(Collectors.toList());
    }
}

여기서 서비스의 findAllSubjectNames를 호출하면 어떤 일이 발생하는지 테스트 코드를 작성하여 쿼리가 어떻게 생성되는지 확인해보겠습니다.

@Commit
@SpringBootTest
class AcademyServiceTest {

    @Autowired private AcademyRepository academyRepository;
    @Autowired private TeacherRepository teacherRepository;
    @Autowired private AcademyService academyService;

    @BeforeEach
    public void setup() {
        List<Academy> academies = new ArrayList<>();

        Teacher teacher = teacherRepository.save(new Teacher("선생님"));

        for(int i=0;i<10;i++){
            Academy academy = Academy.builder()
                    .name("강남스쿨"+i)
                    .build();

            academy.addSubject(Subject.builder().name("자바웹개발" + i).teacher(teacher).build());
            academy.addSubject(Subject.builder().name("파이썬자동화" + i).teacher(teacher).build()); // Subject를 추가
            academies.add(academy);
        }
        academyRepository.saveAll(academies);
    }

      @Test
      public void Academy여러개를_조회시_Subject가_N1_쿼리가발생한다() throws Exception {
          //given
          List<String> subjectNames = academyService.findAllSubjectNames();

          //then
          assertEquals(10, subjectNames.size());
      }
 }

위의 테스트코드를 실행 보면

DB에는 이렇게 저장되고

전체 조회하는 쿼리 1개와
각각의 Academy가 본인들의 subject를 조회하는 쿼리 10개가 발생한 것을 확인할 수 있습니다.

이렇게 하위 엔티티들을 첫 쿼리 실행 시 한 번에 가져오지 않고, LAZY로딩(지연 로딩)으로 필요한 곳에서 사용되어 쿼리가 실행될 때 발생하는 문제, 1개의 쿼리로 인해 N개의 쿼리가 더 나가는 상황N+1 문제라고 합니다.

(

1+N이 더 맞는 것 같은데 왜 N+1인지 모르겠네요😝

)

현재 상황에서는 Academy가 10개로 (첫 조회 쿼리 1개) + (Academy의 subject 10개 조회 쿼리 10개) = 11밖에 발생하지 않았지만, 만약 Academy 조회 결과가 10만 개라면 한 번의 서비스 로직을 실행하는데 DB 조회 쿼리가 10만 번이 발생하는 상황입니다.

그래서 이렇게 연관관계가 맺어진 Entity를 한 번에 가져오기 위한 몇 가지 방법들이 있습니다.

1. Join Fetch

첫 번째 방법은 join fetch를 사용하는 방법입니다.

/**
* 1. join fetch를 통한 조회
*/
@Query("select a from Academy a join fetch a.subjects")
List<Academy> findAllJoinFetch();

조회 시 바로 가져오고 싶은 Entity 필드를 지정(join fetch a.subjects)하는 것입니다.
이렇게 바꾼 후 테스트 코드를 실행하면

이렇게 하나의 쿼리로 모두 조회할 수 있습니다.

만약 Subject의 하위 Entity까지 한 번에 가져와야 할 때에도
아래와 같은 방법으로 쉽게 해결할 수 있습니다.

/**
* 5. Academy+Subject+Teacher를 join fetch로 조회
*/
@Query("select distinct a from Academy a join fetch a.subjects s join fetch s.teacher")
List<Academy> findAllWithTeacher();

a.subjects를 s로 alias 하여 s의 teacher를 join fetch 하면 한 번에 가져올 수 있습니다.

단, 이 방법은 불필요한 쿼리문이 추가되는 단점이 있습니다.

이 필드는 Eager 조회, 저 필드는 Lazy 조회를 해야 한다까지
쿼리에서 표현하는 것은 불필요하다고 생각하실 분들이 계실 수 있습니다.

그런 분들은 2번 방법을 사용해보시면 좋을 것 같습니다.

2. @EntityGraph

두 번째 방법은 @EntityGraph를 사용하는 방법입니다.

/**
* 2. @EntityGraph
*/
@EntityGraph(attributePaths = "subjects")
@Query("select a from Academy a")
List<Academy> findAllEntityGraph();

@EntityGraphattributePaths에 쿼리 수행 시
바로 가져올 필드명을 지정하면 LAZY(지연 로딩)가 아닌 Eager(즉시 로딩) 조회로 가져오게 됩니다.

위처럼 원본 쿼리의 손상 없이 EAGER/LAZY 필드를 정의하고 사용할 수 있게 되었습니다.
추가로 Teacher까지 한 번에 가져오는 쿼리도 아래와 같이 표현할 수 있습니다.

/**
* 6. Academy+Subject+Teacher를 @EntityGraph 로 조회
*/
@EntityGraph(attributePaths = {"subjects", "subjects.teacher"})
@Query("select DISTINCT a from Academy a")
List<Academy> findAllEntityGraphWithTeacher();

🚨 사용 시 주의사항

Join Fetch

SELECT academy0_.id          AS id1_0_0_, 
       subjects1_.id         AS id1_1_1_, 
       academy0_.name        AS name2_0_0_, 
       subjects1_.academy_id AS academy_3_1_1_, 
       subjects1_.name       AS name2_1_1_, 
       subjects1_.teacher_id AS teacher_4_1_1_, 
       subjects1_.academy_id AS academy_3_1_0__, 
       subjects1_.id         AS id1_1_0__ 
FROM   academy academy0_ 
       INNER JOIN subject subjects1_ 
               ON academy0_.id = subjects1_.academy_id 

@EntityGraph

SELECT academy0_.id          AS id1_0_0_, 
       subjects1_.id         AS id1_1_1_, 
       academy0_.name        AS name2_0_0_, 
       subjects1_.academy_id AS academy_3_1_1_, 
       subjects1_.name       AS name2_1_1_, 
       subjects1_.teacher_id AS teacher_4_1_1_, 
       subjects1_.academy_id AS academy_3_1_0__, 
       subjects1_.id         AS id1_1_0__ 
FROM   academy academy0_ 
       LEFT OUTER JOIN subject subjects1_ 
                    ON academy0_.id = subjects1_.academy_id 

JoinFetchInner Join, @EntityGraphOuter Join이라는 차이점이 있습니다.

공통적으로 카테시안 곱(Cartesian Product)이 발생하여 Subject의 수만큼 Academy가 중복 발생하게 됩니다.

확인을 위해 테스트 코드를 작성해보면 아래와 같습니다.

@BeforeEach
public void setup() {
    List<Academy> academies = new ArrayList<>();

    Teacher teacher = teacherRepository.save(new Teacher("선생님"));

    for(int i=0;i<10;i++){
        Academy academy = Academy.builder()
                .name("강남스쿨"+i)
                .build();

        academy.addSubject(Subject.builder().name("자바웹개발" + i).teacher(teacher).build());
        academy.addSubject(Subject.builder().name("파이썬자동화" + i).teacher(teacher).build()); // Subject를 추가
        academies.add(academy);
    }

    academyRepository.saveAll(academies);
    System.out.println("====================save all====================");
}

@Test
public void Academy여러개를_joinFetch로_가져온다() throws Exception {
    //given
    List<Academy> academies = academyRepository.findAllJoinFetch();
    List<String> subjectNames = academyService.findAllSubjectNamesByJoinFetch();

    //then
    assertEquals(20, academies.size()); // 20개가 조회!?
    assertEquals(20, subjectNames.size()); // 20개가 조회!?

    // JoinFetch는 InnerJoin, Entity Graph는 Outer Join
    // 공통적으로 카테시안 곱(Cartesian Product)이 발생하여 Subject의 수 만큼 Academy가 중복발생하게 됩니다.
    // 그래서 20개가 조회되는 것 입니다.
}

해결 방안

두 가지 방법이 있습니다.

  • 먼저 첫 번째 방법은 필드의 타입을 Set으로 선언하는 것입니다.
    (Set은 중복을 허용하지 않는 자료구조이기 때문에 중복등록이 되지 않는 점을 이용합니다.)
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name="academy_id")
private Set<Subject> subjects = new LinkedHashSet<>();

(Set은 순서가 보장되지 않기에 LinkedHashSet을 사용하여 순서를 보장합니다.)

  • 두 번째 방법은 DISTINCT를 사용하여 중복을 제거하는 것입니다.
    (Set보다는 List가 적합하다고 판단될 때)
    이 방법은 쿼리에 적용하는 방법이라 join fetch, @EnityGraph 모두 동일하게 사용됩니다.
/**
* DISTINCT + join fetch
*/
@Query("select DISTINCT a from Academy a join fetch a.subjects s join fetch s.teacher")
List<Academy> findAllWithTeacher();
/**
* DISTINCT + @EntityGraph
*/
@EntityGraph(attributePaths = {"subjects", "subjects.teacher"})
@Query("select DISTINCT a from Academy a")
List<Academy> findAllEntityGraphWithTeacher();

두 가지 방법 중 상황에 맞게 사용하시면 될 것 같습니다.

@NamedEntityGraphs

N+1 문제 해결을 얘기할 때 @NamedEntityGraphs가 예시로 많이 등장하곤 하는데

@NamedEntityGraphs의 경우 Entity에 관련해서 모든 설정 코드를 추가해야 하는데,
이동욱 님 생각엔 Entity가 해야 하는 책임에 포함되지 않는다고 생각하신다고 합니다.

A 로직에서는 Fetch전략을 어떻게 가져가야 한다는 것은 해당 로직의 책임이지, Entity의 책임이 아니다.
Entity에선 실제 도메인에 관련된 코드만 작성하고,
상황에 따라 유동적인 Fetch 전략을 가져가는 것은 전적으로 서비스/레파지토리에서 결정해야 하는 일.

마무리

이렇게 한번 더 공부 함으로써 N+1 문제에 대해 더 확실히 알게 되고
프로젝트 중 N+1 문제가 생기더라도 당황하지 않고 해결할 수 있을 것 같습니다
글 읽어주셔서 감사합니다 😊

'JPA' 카테고리의 다른 글

JPA 값타입  (0) 2022.03.25
JPA 프록시와 연관관계 관리  (0) 2022.03.25
JPA 상속관계 매핑과 고급매핑  (0) 2022.03.25
JPA 다양한 연관관계 매핑  (0) 2022.03.25
JPA 연관관계 매핑과 연관관계 주인  (1) 2022.03.25

댓글