JPA를 사용하다 보면 N+1 문제를 생각보다 많이 마주치게 된다. 대부분 이는 구글링을 통해 블로그 글이나 레퍼런스로 해결할 수 있는 방법을 알려주고 있다. 하지만 개인적으로 N+1에 대한 문제점이나 원인 혹은 JPA N+1을 의도한 바가 있는지를 면밀히 살펴볼 것이다.
JPA N+1 문제
대부분의 사람들이 정의하는 N+1이란 아래와 같다.
연관관계가 있는 엔티티를 조회할 때 조회된 개수 N개만큼의 쿼리가 추가로 발생하는 것

위와 같이 Team-Member는 1:N 양방향 매핑 관계이다. 이때 N+1이 발생하는 상황은 다음과 같다.
- Team A, Team B 2개의 Team 엔티티가 존재하는 상황
- 각 팀에 엔티티에 비어있는 멤버가 없다고 가정, 즉 2팀에 모두 멤버가 있는 것.
여기서 TeamRepository의 findAll로 모든 팀을 가져온 뒤, Member들의 이름을 호출해보자.
fun test() : List<String> {
return teamRepository.findAll()
.flatMap { it.members!! }
.map { it.name }
}

결과는 모든 팀 조회 Query임에 불구하고, 각 팀에 속한 멤버들은 조회하기 위해 2개의 쿼리가 더 실행되는 것을 볼 수 있다. 이때 발생하는 것이 바로 JPA N+1이다.
만약 팀이 2개가 아니라 10000개 그 이상이라고 생각해 보자. 생각만으로도 아찔하다.. 1개의 쿼리를 통해 수십, 수백 개의 쿼리가 실행될 수 있다. 언어상 N+1이지만, 주체가 먼저 실행되기 때문에 1+N이라는 단어가 더욱 적절할지도 모른다.
그렇다면 왜 쿼리에서는 멤버들을 조회해 올까?
사실 답은 JPA를 쓰는 입장에서 살펴보면 간단하다. 첫 번째 쿼리인 팀 조회 쿼리는 정상적으로 팀을 가져온다.
두 번째 쿼리가 2개가 나가는 이유는 JPA 엔티티 매핑 과정 때문이다.
다시 한번 결과를 살펴보자.
-
- 먼저 'select * from team'을 통해 가져온 테이블의 컬럼과 Team 엔티티를 맵핑한다.
-
- 'select * from member where team_id = ?'을 통해 조회한 Team 엔티티에 속한 Member 엔티티를 맵핑한다.
이러한 과정으로 먼저 Team 테이블에서 컬럼들을 Team 엔티티로 맵핑하고, 이후에 조회한 Team에 해당하는 FK를 가진 Member 테이블의 컬럼들을 조회하여 Member 엔티티를 맵핑하여 최종 Team 엔티티를 가져오는 것이다.
결과적으로 매핑되어 있는 Team과 Member를 모두 만들어서 최종적으로 Team Entity를 반환하는 것이다.
왜 Join이 아닌 별개의 쿼리로 보는가
이는 JPA의 Fetch Type을 살펴보면 조금씩 의도(?)가 보이기 시작한다.
먼저 EAGER 타입으로 멤버를 가져오는 것으로 바꾸고, findById를 통해 Team을 호출한다면 이는 즉시로딩으로 인해 Team과 member를 Join 해서 가져올 것이다. (위 테스트 과정에서는 Lazy 타입을 사용했다.)
그렇다면 EAGER와 Lazy가 무엇이고 Eager를 사용한다면 과연 N+1을 해결할 수 있을 것인가? 에 도달한다.
- JPA에서 Fetch Type인 EAGER, LAZY 로딩은 '데이터를 가져오는 시점'에 관한 설정이다.
- JPA의 Fetch Type의 공식문서를 살펴보면 다음과 같이 서술되어 있다.
Defines strategies for fetching data from the database requirement data must be eagerly fetched hint data should be fetched lazily when it is first accessed.
결과적으로 Fetch Type은 다음과 같다.
- Fetch Type은 '데이터베이스에서 데이터를 가져오는 전략'이다.
- EAGER 전략 : 데이터베이스에서 데이터를 즉시 가져와야 한다는 '요구사항'이다.
- LAZY 전략 : 데이터를 처음 사용할 때 느리게 가져오라는 '힌트'이다.
⇒ EAGER는 DB에서 데이터를 즉시 가져오는 전략이고, LAZY는 데이터를 사용하는 시점에 데이터를 가져오는 전략이다.
위 공식 문서를 조금 더 집중적으로 본다면 EAGER는 'requirement'로, JPA를 구현하는 구현체들이 강제로 구현해야 하는 어떤 요구사항의 의미를 가진다. 반면 LAZY는 'hint'로, JPA는 '데이터를 사용하는 시점에 데이터를 가져와라!' 하는 힌트를 제공할 뿐 내부 구현은 제공하지 않는다.
따라서, JPA를 구현하는 구현체들은 LAZY 전략을 사용할 때 JPA가 제공한 hint를 바탕으로 '데이터를 사용하는 시점에 데이터를 가져오는' 코드를 구현하게 되는 것이다.
그렇다면 과연 EAGER는 N+1이 발생하지 않을까? 아래 두 가지로 테스트해볼 것이다.
- EAGER로 바꾸고 오로지 Team만 조회하는 findAll을 날려볼 것이다.
- Lazy로 바꾸고 오로지 Team만 조회하는 findAll을 날려볼 것이다.
- EAGER로 팀만 가져온 경우 (Team 엔티티를 가져오는 시점에 즉시 Member 엔티티도 가져온다는 점을 확인할 수 있으며, N+1이 발생한다)

- Lazy로 팀만 가져온 경우(LAZY 로딩이기 때문에 Member 엔티티는 즉시 불러오지 않게 된다. 이는 곧, Member 엔티티를 현재 사용하지 않기 때문이다.)

Lazy 타입으로 바꾸어도 결과적으로 맨 처음 테스트 했던 결과를 살펴보면, 각 팀의 멤버가 필요한 순간 추가로 Query가 발생하기 때문에 이는 N+1이 발생한다는 것을 알게 된다.
⇒ Team을 불러올 때 Lazy로 설정되어 있으면 각 멤버들이 프록시 객체로 저장되어 있고 이는 실제 사용할 때 Query를 날리기 때문이다.
그렇다면 왜 JPA는 바로 Join 하는 방식이 아닌 추가 Query를 통해 이를 처리했을까?
이를 반대로 생각해 보자. 추가 쿼리가 아닌 무조건 Join인 경우 일 때 어떻게 될까?
⇒ 필요로 하지도 않았는데도 Join Query가 나가게 되고 이는 곧 비효율적인 상황에 근접할 가능성이 매우 높다는 결론에 도달한다.
1:N 관계에서 N에 해당하는 엔티티를 즉시 사용하지 않는 다면, 해당 시점에 N을 가져오지 않아도 된다. 왜냐하면 이것은 사용하지 않는 데이터이기 때문이다. (ex - team을 가져온 뒤 members를 사용하지 않는 경우)
극단적으로 생각했을 때 한 팀의 속한 멤버가 100명…. 1억 명이라고 가정하고 해당 데이터를 사용하지 않는다면? 같이 가져오는 행위 자체가 비효율적이라는 것이다.
결과적으로 JPA는 이를 의도한 것으로 생각된다. 데이터를 ‘사용시점'에 불러오도록! 이게 곧 Lazy 로딩의 탄생 설화가 아닐까 싶다.
이렇게 설계됨으로써 N+1 문제는 발생하지만, 위의 비효율적인 상황이 더 치명적이라고 생각한 것이다.
그래서 JPA의 Spec을 살펴보면, @OneToMany와 @ManyToOne의 default Fetch Type이 다르다.
- @OneToMany : 기본 LAZY 로딩
- @ManyToOne : 기본 EAGER 로딩
@OneToMany는 앞서 예시로 든 '즉시 데이터를 사용하지 않는 상황'이 발생할 확률이 비교적 크기 때문에 LAZY 로딩을 기본으로 하여 사용할 시점에 데이터를 불러와서 효율을 높이도록 했음을 알 수 있다.
@ManyToOne은 EAGER 로딩이 기본이다. 이는 @OneToMany의 컬렉션 객체를 가져오는 것과 비교해서 1개의 객체만 가져오면 되고, @OneToMany의 컬렉션 객체는 모든 요소가 사용되지 않을 상황이 많은 반면@ManyToOne의 1 엔티티는 N 엔티티를 사용할 때 같이 사용하는 상황이 많다고 판단을 했다고 추측할 수 있다.
결과
결론적으로 '왜 JPA는 Join 대신 추가 쿼리를 발생하게 했는지'에 대한 원인(?)의 결론은 다음과 같다.
- 많은 데이터를 가져오는데 즉시 사용하지 않는 경우에 Join 쿼리 사용 시 비효율적인 상황이 발생.
- 데이터를 사용하는 시점에 불러와야 하는 구현(LAZY 로딩)을 추가
- Lazy 로딩 추가로 인하여 기본적으로는 Join 대신 추가 조회 쿼리를 사용하는 방향으로 설계
N+1 문제의 해결
앞서 JPA가 왜 Join이 아닌 추가 Qurey를 사용하는지 살펴보았다. 이제 여기서 발생했던 N+1 문제를 해결해 볼 차례이다.
An Introduction to Hibernate 6
An Introduction to Hibernate 6
To interact with the database, that is, to execute queries, or to insert, update, or delete data, we need an instance of one of the following objects: a JPA EntityManager, a Hibernate Session, or a Hibernate StatelessSession. The Session interface extends
docs.jboss.org
JPA의 구현체인 Hibernate에서는 N+1 문제의 해결책을 다음과 같이 제시한다.
Hibernate provides several strategies for efficiently fetching associations and avoiding N+1 selects
1. outer join fetching —where an association is fetched using a left outer join
2. batch fetching —where an association is fetched using a subsequent select with a batch of primary keys
3. subselect fetching —where an association is fetched using a subsequent select with keys re-queried in a subselect.
Hibernate는 이중 Outer join fetching을 사용하는 것을 가장 권장하고 있다.
Outer join fetching is usually the best way to fetch associations, and it’s what we use most of the time.
Outer join fetching
크게 이를 사용하는 방법은 2가지 정도가 있다.
- 'fetch join' JPQL 작성
- EntityGraph 사용
먼저 fetch join을 살펴보자.
Spring Data JPA를 사용하면, JPQL을 사용하기 위해 @Query를 통해 다음과 같이 작성할 수 있습니다.
@Query("SELECT t FROM Team t LEFT JOIN FETCH t.members")
fun findAllWithFetchJoin(): List<Team>

Team 엔티티를 가져올 때 Member를 join 해서 가져오기 때문에 Member를 가져오는 추가 조회 쿼리가 발생하지 않는다. 따라서 N+1 문제가 해결된 것을 볼 수 있다.
Hibernate에서는 fetch join에 대해 다음과 같이 설명하고 있다.
Unfortunately, by its very nature, join fetching simply can’t be lazy. So to make use of join fetching, we must plan ahead.
fetch join은 Lazy 하게 데이터를 가져오지 않으므로 fetch join을 사용할 때는 계획적으로 사용해야 한다는 뜻이다. LAZY 로딩 + fetch join을 사용한다고 하면, 결국 Team 엔티티를 조회할 때 EAGER 하게 모든 Member를 join 해서 가져오기 때문이다.
이때 의문이 든다. 그럼 Lazy 로딩에 필요성에 대한 의문이다. Hibernate가 정리한 이 의문에 대한 답변 살펴보자.
Our general advice is: Avoid the use of lazy fetching, which is often the source of N+1 selects.
Now, we’re not saying that associations should be mapped for eager fetching by default!
It sounds as if this tip is in contradiction to the previous one, but it’s not. It’s saying that you must explicitly specify eager fetching for associations precisely when and where they are needed.
fetch join을 사용할 때 일반적으로 'N+1이 발생할 수 있는 LAZY 로딩'을 피하라고 하면서 그렇다고 EAGER 로딩을 사용하라는 건 아니고, LAZY 로딩을 기본으로 사용하라고 언급하고 있다. 결과적으로 기본적으로 Lazy 로딩을 사용하되, 필요한 곳에 fetch join을 사용하라는 것을 확인할 수 있다.
EntityGraph를 사용해서 이를 해결할 수도 있다.
@Query("select t from Team t")
@EntityGraph(attributePaths = ["members"])
fun findAllWithEntityGraph(): List<Team>
따라서 마찬가지로 N+1 문제가 해결된 것을 볼 수 있다.
batch & subselect fetching
batch fetching과 subselect fetching은 모두 N+1 문제에서 발생하는 추가 조회 쿼리를 없애는 방향이 아니라 추가 조회 쿼리를 1개의 쿼리로 줄이는 방향으로 문제를 해결한다. 따라서 추가 조회 쿼리를 없애는 것은 아니기 때문에 N+1 문제를 완벽하게 해결한다고 볼 수는 없을 것 같다.
단, 페치 조인 시에 거대한 카사디안 곱과 거대한 집합이 생성되는 경우에 최상의 솔루션이 될 수 있다.
@BatchSize
어노테이션을 사용하면 BatchFetching을 사용할 수 있다. 이는 특정 어노테이션이 아니라 아래처럼 전역적으로 사용할 수 있다.
- application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 3

이렇게 사용한다면 in Query로 하나의 쿼리로 실행된다.
subselect fetching은 설정을 통해 추가 조회 쿼리를 서브 쿼리로 줄이는 해결 방법이다. 예시에서 Member - Team에 Team 2개가 존재하여 2개의 추가 조회 쿼리가 발생했습니다. 이를 subselect fetching을 통해 1번의 쿼리로 줄일 수 있다.

batch & subselect fetching의 공통점이 존재한다.
Hibernate에서는 batch & subselect fetching의 공통점으로 다음과 같이 언급하고 있습니다.
they can be performed lazily
바로 LAZY 하게 동작한다는 점이다. join fetching(페치 조인)을 사용하게 되면 LAZY하게 동작하지 않기 때문에 즉시 Join하여 데이터를 가져다. 그래서 무조건적으로 사용하면 안되고, 상황에 따라 적용해야하는 유연성이 필요하다고 한다.
그러나 batch & subselect fetching는 LAZY하게 동작하기 때문에 LAZY 로딩에서 선언하기만 하면 상황을 고려할 필요가 없어서 편리하다는 장점이 있다. 하지만, Hibernate는 이에 대해서 다음과 같이 언급합니다.
It turns out that this is a convenience we’re going to have to surrender.
바로, 이러한 편리함은 우리가 포기해야 한다는 의미를 담고 있다. 즉, batch & subselect fetching이 편리하더라도 join fetching(페치 조인)을 권장한다는 의미이다.
그렇다면 fetch join은 만능일까?
결론부터 얘기하자면 만능이 아니다. 살펴볼 문제는 페이지네이션과 MutipleBagFetchException이라는 것이다.
페이지네이션
코드로 어떤 문제가 있는지 바로 살펴보자.
@Query("SELECT t FROM Team t LEFT JOIN FETCH t.members")
fun findAllWithFetchJoin(pageable: Pageable): Page<Team>

Query를 자세히 살펴보면 Offset에 대한 쿼리가 존재하지 않는다. 분명 페이지 Request 객체를 넘겨서 페이징을 위해 사용했지만, 이는 처리되지 않았다. 그리고 경고문구가 하나 존재한다.
WARN 5436 --- [nio-8080-exec-3] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
해석해 보면 collection fetch에 대해서 paging처리가 됐긴 한데 applying in memory, 즉 인메모리를 적용해서 조인을 했다고 한다.
실제 날아간 쿼리와 이 문구를 통합해서 이해를 해보면 일단 List의 모든 값을 select 해서 인메모리에 저장하고, application 단에서 필요한 페이지만큼 반환을 알아서 해주었다는 이야기가 된다.
즉.. 사실상 Paging을 한 이유가 없어지는 것이다. 100만 건의 데이터가 있을 때 그중 10건의 데이터만 paging 하고 싶었으나 100만 건을 다 가져온다? 그것도 메모리에? OOM(Out of Memory)이 발생할 확률이 매우 높습니다.
따라서 Pagination에서는 fetch join을 하고 싶어서 한다고 하더라도 해결을 할 수 없다.
그렇다면 무엇이 해결책인가…?
배치 사이즈가 이에 대한 해답이 될 수도 있다. @BatchSize
애노테이션 혹은 yaml에 이를 기술하고 적용해 보자.

offset 쿼리가 나간 것으로 확인된다. 또한, inQuery로 인하여 쿼리는 하나만 나가게 되었다. 이는 지연로딩하는 객체에 대해서 Batch성 loading을 하는 것이라고 생각하면 된다.
기존의 지연로딩에 대해서는 객체를 조회할 때 쿼리문을 날려서 N+1 문제가 발생한 반면, 객체를 조회하는 시점에 쿼리를 하나만 날리는 게 아니라 해당하는 member에 대해서 쿼리를 batch size개를 날리는 것이다.
한마디로 하나의 Query로 batch size 개수만큼 한 번에 가져오는 것이다. 이 또한 결과적으로 batch size를 적절히 설정해야 한다. 데이터에 맞춰서 적절히 사용한다면 페이지네이션을 사용하는 곳에서 적절히 사용할 수 있다.
사실 개인적인 생각으로는 페이지네이션은 오프셋 기반이 아니라 커서 기반을 사용하여 효율적인 방향으로 개선하는 게 더 효과적이라고 생각하기도 한다.
MultipleBagFetchException
fetch join은 앞서 batch size에서 이야기한 대로 하나의 collection fetch join에 대해서 인메모리에서 모든 값을 다 가져오기 때문에 pagination이 불가능했다.
fetch join을 할 때 ToMany의 경우 한 번에 fetch join을 가져오기 때문에 collection join이 2개 이상이 될 경우 너무 많은 값이 메모리로 들어와 exception이 발생한다. 그 exception이 MultipleBagFetchException
이다.
아래 사진에서 알 수 있다시피 2개 이상의 bags, 즉 collection join이 두 개이상일 때 exception이 발생한다.

문제가 왤케 많은거야..
이를 해결하고자 하는 방법은 두 가지 정도이다.
첫 번째는 여러 블로그에도 나와있는 방법인 Set으로 자료형을 바꾸면 된다. set으로 바꾼다면 해당 Exception이 발생하지 않는 것으로 확인된다. ⇒ 페이지네이션은 해결 불가능… 결국 Collection join이라는 것
결과적으로 다시 Batch Size이다.
Collection을 사용하여 Pagination에서 인메모리 로딩을 막을 수 없기 때문에 2개 이상의 Collection join을 사용하는데 Pagination을 사용해야 할 경우도 인메모리를 사용하지 않고 사용할 수 있다.
정리하면 아래와 같다.
- List 자료구조를 꼭 사용해야 하는 경우
- 2개 이상의 Collection join을 사용하는데 Pagination을 사용해야 해서 인메모리 OOM을 방지하고자 하는 경우
BatchSize를 사용하면 호출하는 당시에 한 번에 모든 데이터를 가져오는 동작구조를 가진다. 결국 이 또한 필요한 만큼의 데이터를 한번에 가져오는 것으로 해결할 수 있다.
결론
위에서 정말 많은 내용들이 다루었다. N+1의 근본적인 문제와 이에 따른 해결책, 해결책에 대한 문제점, 문제점에 대한 해결을 다뤘다. 구글링을 통해 찾은 해결책은 크게 다르지는 않지만 조금 더 왜 이런 문제가 발생하는지에 대해 궁금해서 해당 글을 작성하게 되었다.
결과적으로 Best practice는 없다만, 문제가 발생했을 때 이를 해결할 수 있는 방법들을 살펴보았다.
만약 내가 위 같은 상황들을 동시 다발적으로 겪고 있다면 머리가 아플 것이다. 위 과정들을 조합하여 생각하다 보면 충분히 해결할 수 있다고 느낀다.
해당 글에서 Querydsl을 다루지는 않았지만 Querydsl을 통해 fetch join을 더 잘할 수 있고 DTO를 통해서도 해결가능하다. 물론 Querydsl 뿐만 아니라 다른 ORM 혹은 query 빌더에서는 또 다른 방식으로 적절히 처리되고 있을 것 같다. 이 또한 나중에 살펴보면 좋을 것 같다. 더 많은 시야를 위해!
'서버 개발(생각과 구현)' 카테고리의 다른 글
단일 프로젝트 구조와 멀티 모듈 구조 (0) | 2025.01.01 |
---|---|
Custom Swagger를 통해 협업, 업무 효율성 동시에 잡기 (0) | 2024.12.27 |
외부 API와 트랜잭션의 관계를 조심하자 (0) | 2024.12.23 |
락의 필요성과 실제 사용을 위한 분산 락, 낙관적 락, 비관적 락 탐구 (0) | 2024.12.22 |
서킷 브레이커(CircuitBreaker)가 필요한 경우 (0) | 2024.12.21 |