DB

[JPA] N+1 문제와 해결법

날아 2024. 4. 24. 01:24

토이 프로젝트를 진행하던 중 무수히 많은 N+1 문제를 만날 위기에 봉착했다. (....) 

주로 컬렉션 연관관계를 조회하던 중 이러한 참사가 일어날 뻔 했는데, 그 때마다 강의를 보고 공부했던 내용과 구글링(...)을 통해 해당 문제들을 해결할 수 있었다. 

JPA에서 발생할 수 있는 N+1 문제와, 이를 어떻게 적절하게 해결할 수 있을지 정리하기 위해 글을 적고자 한다. 

 

 

1. N+1 문제란?

JPA는 객체로 관계형 데이터베이스의 테이블 속 칼럼을 관리한다.

이 과정에서 객체는 서로 연관관계를 가질 수 있는데, 이 과정에서 N+1 문제가 발생한다. 

개발자가 1 개의 쿼리를 날렸을 때, 해당 객체가 가지고 있는 연관관계의 객체들을 조회 하는 이유(N)로 추가적인 쿼리가 나가는 것을 N+1 문제라고 한다. 

 


2. 발생 시나리오 

2-1. 즉시로딩

즉시로딩 (fetch = FetchType.EAGER)은 연관된 엔티티 객체를 한 번에 불러오는 전략이다. 

 

실제 토이 프로젝트 erd 의 일부이다.

만약 project(게시글) 엔티티에서 member(멤버) 엔티티를 참조하고 있을 때, 즉시로딩 전략을 취할 경우 project 엔티티 객체를 읽어올 때 member 엔티티 객체 또한 한 번에 불러온다. 

만약 project 게시글만 읽고 싶은 경우에 필요 없는 데이터까지 로딩하므로 비효율적인 N+1 문제가 일어나는 것이다. 

 

해결법 - (fetch = FetchType.LAZY)

따라서 실무에서는 모든 연관관계를 지연로딩 (fetch = FetchType.LAZY)로 설정하기를 권장한다. 

지연로딩의 경우 project 엔티티는 프록시 객체로 가져온다. 

그리고 후에 실제 객체를 사용하는 시점에(실제로 project 객체를 참조하는 시점에) 초기화 되며 DB에 실제 쿼리가 나간다.

이렇게 즉시로딩에서 일어나는 N+1 문제를 해결할 수 있다. 

 

@...ToMany 의 경우 디폴트 전략이 지연로딩이다. 

다만 @...ToOne의 경우 디폴트 전략이 즉시로딩이다. 이를 지연로딩으로 설정해주는 것이 좋다. 

(왜 이렇게 따로 디폴트 전략을 세웠는지 궁금하다. JPA 자체에서 모두 지연로딩으로 설정해두었으면 좋지 않았을까..?)

 

다만, @OneToOne관계에서 연관관계의 주인이 아닌 엔티티는 지연 로딩으로 설정하는 것이 불가능하다. 

(JPA는 연관관계 객체가 존재하지 않으면 프록시 객체를 생성하지 않고 해당 연관관계 필드를 null로 비워 두려고 하는데 연관관계의 주인이 아닌 테이블에는 FK가 없기 때문에 연관관계 주인 테이블과 Join 하지 않으면 이를 알 수 없기 때문이다. 따라서 항상 즉시로딩 된다.)

 


 

2-2. 지연로딩

즉시로딩을 지연로딩으로 설정하면 N+1 문제가 해결되는 것이 아니었나? 지연로딩에서도 N+1 문제가 발생하면 지연로딩을 즉시로딩으로 설정하는 이유가 무엇인가? 궁금할 수도 있을 것이다. 

 

지연로딩의 경우 앞서 설명한 처럼, project 엔티티를 Lazy 전략으로 가져온 이후 연관관계인 member 엔티티를 다시 조회하는 경우 N+1 문제가 발생한다. 

 

해결법 - Fetch join

JPQL의 기본 문법인 Fetch join을 사용하여 DB에서 값을 가져올 때 연관된 데이터를 함께 가져올 수 있다. 

 

Fetch join을 sql의 join과 헷갈리는 경향이 있는데, 이 둘은 완전히 다르다. 

sql의 join의 경우 JPQL에서 조회하는 주체가 되는 엔티티만 조회하여 영속화한다. 따라서, member 엔티티를 조회할 경우 다시 한 번 쿼리가 실행되며 N+1 문제를 해결할 수 없다. 

반면, Fetch join은 조회의 주체가 되는 엔티티 의외에 연관 엔티티도 함께 영속화한다. 따라서 member 엔티티를 조회할 경우 이미 영속성 컨텍스트에 들어있기 때문에 따로 쿼리가 실행되지 않는다. (= N+1 문제 해결) 

 

Fetch Join의 유의점 - Collection 연관관계 (1:N 관계)

다만 Fetch Join의 경우 유의할 사항이 있다. 바로 Collection 연관관계에 Fetch Join을 걸 경우이다. (1:N의 경우) 

Fetch join을 Collection에 대해서 할 경우 SQL Native Join 쿼리가 발생하게 되고 이 경우 1:N에서 1쪽의 데이터는 중복된 상태로 조회하게 된다. 

 

A팀에 memberA, memberB이 있고 B팀에 memberC, memberD가 있다고 가정하면 다음과 같은 레코드 형태로 데이터를 받아오고 이를 객체로 매핑하게 된다.

레코드 team member
1 A memberA
2 A memberB
3 B memberC
4 B memberD

 

위의 표와 같이 team A에 대한 객체가 member의 n 만큼 중복 생성(2개) 됨을 알 수 있다.  

즉, Fetch Join을 통해 조회 시 연관관계 객체(collection 객체)의 수인 n 만큼 주인 엔티티의 객체가 생성되는 것이다.

 

  • Distinct 절을 사용해야 한다.
    • JPQL 상의 Distinct로 SQL에서의 Distinct와는 조금 다르다.
    • SQL에서는 Distinct는 DB에서 수행되며 join되어 발생한 데이터 형태에서 각 row를 비교하여 다른 경우만 남긴다. 위의 표를 볼 경우, team은 겹치지만 member는 겹치지 않으므로 어떠한 row도 같지 않다. 
    • 반면 JPQL의 Distinct는 조회 대상 엔티티, 즉 Select 쿼리 바로 다음에 오는 엔티티 객체에 대해서 Distinct를 수행한다. team의 A가 겹친다고 판단하는 것이다. 따라서 중복을 제거할 수 있다. 
  • Collection Fetch Join은 하나까지만 가능하다.
    • 여러 Collection에 대해서 Fetch Join을 하게 되면 잘못된 결과가 발생하기 때문에 하나만 가능하다. 
  • Paging을 하면 안된다.
    • 페이징 기능을 사용하여 데이터를 검색할 때, 일반적으로 데이터베이스에서 필요한 범위의 데이터만 가져온 후 이를 화면에 표시한다. 
    • 그러나 Fetch Join을 사용할 경우 서로 관련된 데이터를 함께 로드하게 되며, 이 때문에 페이징 처리가 제대로 이루어지지 않을 수 있다. 예를 들어, teamA에 2명의 멤버가 있고, TeamB에 2명의 멤버가 있다면 영속화 되어있는 엔티티는 무조건 이 관계를 그대로 표현하여 TeamA 엔티티의 Collection에는 2명의 Member가 있고, TeamB 엔티티의 Collection에는 2명의 Member가 있어야 한다.
    • 만약 페이징을 위해 Native Sql Join쿼리에 의해서 생긴 스키마에 대해서 하게 된다면 JPA 입장에서는 실제 DB 레코드의 관계와 다른 데이터를 받게될 수 있고, 누락된 레코드 관계가 있다는 것을 알 수가 없게 된다. page size를 3으로 적용하면 다음의 표와 같이 데이터를 가져오게 되고 TeamB는 1 명의 Member만 가지고 있는 것으로 알게 된다.
레코드 Team Member
1 A memberA
2 A memberB
3 B memberC
  • 이러한 문제를 방지하고 객체 관점에서 페이징을 적용하기 위해 JPA에서 페이징을 하게 되면 join쿼리 레코드 관점이 아니라 조회 주 대상 엔티티(Team 엔티티)에 대해서 페이징을 적용한다.
  • 만약, 해당 JPQL에서 page size 3을 하면 Team을 3개까지만 가져오는 의미이고 Team은 두개이므로 모두 가져올 수 없게 된다. 
  • 이러한 동작 방식은 Out Of Memory를 일으킬 수 있다. 
  • JPA는 pagination 요청을 하여도 중복 데이터를 없애기 위해 limit offset을 걸지 않고 List의 모든 값을 select해서 일단 인메모리에 모두 가져와서 application에서 필요한 페이지만큼 반환을 해주게 된다. 
  • 100만 건 중 10 건의 데이터만 페이징 하고 싶었으나 100만 건을 모두 메모리에 가져오는 셈이다.. OOM이 발생할 확률이 매우 높다.
  • 따라서 컬렉션 Fetch Join에서는 절대로 페이징을 해서는 안된다.

 

2-3. Collection Fetch Join의 한계

앞선 설명과 같이 컬랙션을 Fetch Join하게 된다면 Pagination에서 갯수를 판단하기 힘들기 때문에 임의로 인메모리에서 조정한다고 설명했다. 

이를 해결할 방법은 Collection을 제외한 객체만을 Fetch Join한 뒤에 Collection을 따로 조회하는 법인데 이 역시 N+1 문제가 발생할 가능성이 높다. 

 

해결법 - @BatchSize, defalut_batch_fetch_size 

따라서 컬렉션 조인을 하는 경우 fetch join을 사용하지 않고 조회할 컬렉션 필드에 대해서 @BatchSize를 걸어 해결한다. 

@BatchSize는 지연 로딩시 프록시 객체를 조회할 때 where in절로 묶어서 한 번에 조회할 수 있게 해주는 옵션이다. 

(완전히 1개의 쿼리만 나가는 것은 아니지만 batchSize를 적절히 분배함으로서 무수히 많은 N 문제를 피할 수 있는 셈이다.)

실제 토이 프로젝트에서 배치 사이즈를 적용한 사례 (다들 100~1000 정도를 추천해서 100으로 설정했다.)

 

만약 Team에서 member를 조회할 경우, (TeamA에 2명의 멤버가 있다고 가정)

Team을 먼저 조회하고 member는 프록시 객체로 영속성 컨텍스트에 가져온다. = 1개의 쿼리

그 후 member를 조회한다.(batchSize가 100이므로 2명의 멤버를 한꺼번에 가져온다. 101명일 경우 2번에 걸쳐 가져올 것이다.) = 1개의 쿼리

총 2번의 쿼리가 실행될 것이다. 

 


3. 정리

  • 모든 연관관계는 지연로딩으로 설정
  • @...ToOne 관계는 fetch Join을 사용
  • 컬렉션 연관관계는 @BatchSize 사용

 

 

참고