Spring

쿼리 지옥 JPA의 N+1 문제를 둘러싼 오해

Luti 2025. 8. 26. 03:20

 

 

동료 분들이랑 대화를 하다가 JPA의 N+1 문제에 대한 이야기가 나왔습니다.

사실 그동안은 N+1 문제에 대해 제대로 탐구해보지는 않았고, 면접 단골 문제라기에 그저 암기해 둔 수준에 불과했습니다.

근데 대화를 하다 보니까 각자 정확한 문제 정의부터, 원인, 해결 방법까지 이해하고 있는 게 서로 조금씩 다르더라고요.

혹시 제가 이해하고 있는 바가 틀릴 수도 있겠다는 생각과 명확하게 정리를 해놓고자 가볍게 자료들을 찾고 있었는데, 아래와 같이 저를 헷갈리게 하는 말들이 너무 많았습니다.

 

"`LAZY` 때문에 N+1 문제가 발생한다."

"`EAGER` 때문에 N+1 문제가 발생한다."

"`EAGER`가 N+1 문제를 해결하는 방법이다."

"`EAGER`로 로딩하면 `JPQL` 사용 시 N+1 문제가 발생하는 경우가 있다."

"`페치 조인`은 `EAGER` 처럼 데이터를 한 번에 조회한다."

 

더 이상 자료를 찾는 것보다는 직접 코드를 작성하고 실행되는 쿼리를 하나하나 살펴보는 것이 더 의미가 있겠다 싶어서 간단하게 실험을 해봤고요, 결과를 공유하고 위와 같은 N+1 관련 오해들을 풀어보겠습니다.

 

 

# 00. JPA에서 Proxy는 어떻게 사용되는가

 

N+1 문제에 대해 알아보기 전에 알아 두어야 하는 것이 JPA에서 프록시가 어떻게 사용되는지에 대한 내용입니다.

결국 N+1 문제에 대해 이야기 할 때 반드시 함께 나오는 내용이 `LAZY`로딩과 `EAGER` 로딩이며, 두 페치 타입의 차이가 연관 엔티티를 프록시로 가져오냐, 실제 엔티티로 가져오냐에 있기 때문입니다.

 

## 프록시의 필요성

 

프록시의 필요성에 대해 먼저 살펴보겠습니다.

 

 

Member 엔티티는 Team을 필드로 가지고 있습니다.

이때 id가 1인 Member의 name을 조회하기 위해 `em.find(Member.class, 1L).getName()`을 호출하는 상황입니다.

만약 JPA가 항상 연관된 Team 엔티티를 함께 조회해야 한다면, SQL은 이렇게 나갈 것입니다.

select m.*, t.*
from member m
join team t on m.team_id = t.id
where m.id = 1;

이 경우 Team 정보가 필요하지 않은데도 불필요하게 Team까지 조회하게 되며, 이는 성능상의 낭비가 발생한다고 볼 수 있습니다.

 

하지만, JPA는 이 상황에서 Member를 조회하면서 Team에 대해서는 실제 Team 대신 프록시 객체를 주입할 수 있습니다.

프록시 Team은 원본 Team 엔티티에 대한 참조만을 가지고 있는 가짜 객체입니다.

 

덕분에 SQL은 이렇게 단순해집니다.

select m.*
from member m
where m.id = 1;

JPA는 프록시 덕분에 필요 없는 데이터를 건너뛰며, 최적화된 쿼리를 실행하도록 합니다.

 

만약 이후 Team의 name 필드에 대한 접근이 일어난다면? (`member.getTeam().getName()`)

select t.*
from team t
where t.id = ?

그제야 Team을 조회하는 SQL이 추가 실행 됩니다.

 

 

## LAZY 로딩과 EAGER 로딩

 

`LAZY` 로딩과 `EAGER` 로딩은 연관 엔티티를 위와 같이 프록시로 가져올지, 아니면 실제 엔티티로 가져올지의 차이에서 발생합니다.

 

@Entity
public class Member {
	@Id
	@GeneratedValue
	private Long id;

	private String name;

	@ManyToOne(fetch = LAZY)
	private Team team;
    
    // ...
}

 

`LAZY` 로딩은 Team에 대한 연관접근 없이 Member에 대한 데이터만 조회할 때 Team을 프록시 객체로 주입합니다.

앞서 설명드린 내용과 같은 상황입니다.

 

@Entity
public class Member {
	@Id
	@GeneratedValue
	private Long id;

	private String name;

	@ManyToOne(fetch = EAGER)
	private Team team;
    
    // ...
}

 

`EAGER` 로딩은 Team에 대한 연관접근이 있는지 없는지 신경 쓰지 않고, Member에 대해 데이터를 조회할 때 실제 Team까지 원본 객체로 조회합니다.

 

 

# 01. N+1 문제는 왜 발생하는가

 

관련 소스 코드는 아래 레포지토리에서 확인 가능합니다.
https://github.com/LeeEuyJoon/jpa-N-plus-1

 

## 다수의 멤버 조회 시 발생 쿼리

 

명확하게 N+1 문제가 발생하는 케이스를 직접 확인해 보기 위해 다양한 케이스에 대해 요청을 보내고 발생하는 쿼리를 확인해 보는 시간을 가졌습니다.

기본적으로 Member 테이블의 모든 레코드를 조회하는 상황입니다.

Member는 Team과 Major를 필드로 가지고 있고, Member는 세 명이며, Team과 Major는 각 Member에 대해 고유합니다.

 

실험의 분기는 다음과 같습니다.

1. Fetch Type : `LAZY` 로딩 / `EAGER` 로딩

2. 조회 방식 : `JpaRepository.findAll()` / `JPQL` `(SELECT m FROM Member m)` / `JPQL Fetch Join`

3. 연관 엔티티 접근 여부 : 단순 `member.getName()` / `member.getTeam()` 및 `member.getMajor()` 실행

 

총 2 * 3 * 2 = 12개의 요청이 있었으며 결과는 아래와 같습니다.

조회 방법 Fetch 연관 접근 쿼리 수
findAll() LAZY 1
findAll() LAZY 1 + N(3) + N(3) = 7
findAll() EAGER 1 + N(3) + N(3) = 7
findAll() EAGER 1 + N(3) + N(3) = 7
JPQL (SELECT m) LAZY 1
JPQL (SELECT m) LAZY 1 + N(3) + N(3) = 7
JPQL (SELECT m) EAGER 1 + N(3) + N(3) = 7
JPQL (SELECT m) EAGER 1 + N(3) + N(3) = 7
JPQL + Fetch Join LAZY 1
JPQL + Fetch Join LAZY 1
JPQL + Fetch Join EAGER 1
JPQL + Fetch Join EAGER 1

 

 

모든 요청에 대한 쿼리는 아래 노션에 정리해 두었습니다.
https://noon-blizzard-1ca.notion.site/N-1-25825c4dfa9f80adae64e6f9ba4537a4

 

 

## N+1 원인에 대한 오해

 

위 실험 결과에서 개인적으로 주목할 만한 포인트는 두 가지라고 생각합니다.

 

먼저, 연관 접근 (`member.getTeam()` 및 `member.getMajor()`) 이 있는 상황에서는 `Fetch Join`을 사용하지 않는 한 `LAZY`, `EAGER` 할 것 없이 N+1 문제가 발생한다는 것입니다.

 

N+1 관련해서 정보를 찾다 보면 종종 "`LAZY` 로딩 때문에 N+1이 발생하는 것이다", "`EAGER` 로딩 때문에 N+1이 발생하는 것이다" 등 페치 타입을 N+1 문제의 핵심으로 둔 글들을 볼 수 있습니다.

 

하지만 실험 결과를 보면, `LAZY`에서도, `EAGER`에서도 N+1은 발생했습니다.

핵심은 다수의 객체를 조회할 때 Team이나 Major와 같은 연관 엔티티에 대한 접근이 있냐 없냐의 차이에 있다고 볼 수 있습니다.

(물론 `EAGER`의 경우 `Fetch Join`하지 않을 시 연관 엔티티 접근 여부 상관 없이 N+1이 발생합니다.)

 

두 번째로 주목할 만한 포인트는 `EAGER` 로딩 시에 연관 엔티티를 Join으로 한 번에 가져오지 않고 추가 쿼리로 가져온다는 것입니다.

`EAGER` 로딩은 설명드렸듯, 연관 접근이 발생하든 발생하지 않든 관련 엔티티를 원본 객체로 조회합니다.

만약, `EAGER` 로딩이 모든 멤버에 대한 Team과 Major 객체들을 조인을 이용한 한방 쿼리를 실행시켰다면, `EAGER`에서는 N+1 문제가 발생하지 않았을 것입니다.

 

하지만, `EAGER` 로딩도 마찬가지로 최초 Member에 대한 select 문 이후 각 Member id에 대한 Team과 Major의 select문이 별도로 실행되었습니다. 

 

 

## 그럼 EAGER는 항상 추가 쿼리로 연관 엔티티를 조회하는 건가?

 

위 실험 결과를 보고 이 생각이 들더라고요.

'어 그럼 `EAGER` 로딩은 항상 추가 쿼리로 연관 엔티티를 조회하는 건가?'

 

궁금증을 해결하기 위해 이번에는 폐치 타입을 `EAGER`로 두고 멤버 전체가 아닌, `findById()` 메서드를 통해 단일 멤버에 대한 조회를 시도했습니다.

결과는 아래와 같은 쿼리가 실행되었습니다.

Hibernate: 
    select
        m1_0.id,
        m2_0.id,
        m2_0.name,
        m1_0.name,
        t1_0.id,
        t1_0.name 
    from
        member m1_0 
    left join
        major m2_0 
            on m2_0.id=m1_0.major_id 
    left join
        team t1_0 
            on t1_0.id=m1_0.team_id 
    where
        m1_0.id=?

 

만약 `EAGER`가 매번 추가 쿼리를 통해 연관 엔티티를 조회하는 게 맞았다면, 위 시도에서는 쿼리가 세 개 발생해야 했을 것입니다.

근데 이번에는 조인을 통해 연관 엔티티를 한 번에 가져옵니다...

 

`findAll()` 혹은 `JPQL`의 SELECT 구문과 지금 이 상황의 차이가 뭐길래 서로 다른 방식으로 쿼리가 실행되는 걸까요.

 

이를 공식 Hibernate 사용자 가이드에서 다루고 있는데, 해당 문서에서는 두 경우를 `Direct fetching`과 `Entity queries`로 이름을 붙여 설명합니다.

`Direct fetching`의 경우 즉시 로딩되어야 하므로, 생성된 SQL 쿼리에 LEFT JOIN 절을 추가한다고 명시되어 있고요,

`Entity queries`의 경우 엔티티 쿼리의 페치 정책은 재정의할 수 없기 때문에, `EAGER`로 연관이 로딩되었음을 보장하고자 secondary select를 사용한다고 합니다.

 

문서의 예시 코드에서도 `Direct fetching`의 경우 `find()` 메서드를, `Entity qureis`의 경우 `JPQL` SELECT 문을 사용하는 것을 볼 수 있습니다.

 

그럼 여기서 "`페치 조인`은 `EAGER`처럼 데이터를 한 번에 조회한다."라는 말의 오해도 해결할 수 있습니다.

`EAGER` 로딩에서는 위와 같은 상황에 따라 조인을 하는 경우도, 추가 쿼리를 실행하는 경우도 있기 때문에, "`페치 조인`은 `EAGER`로 Direct fetching 하는 경우 처럼 데이터를 join 해서 한 번에 조회한다."라고 수정하면 오해 없이 이해가 가능하다고 볼 수 있습니다.

 

결론은,

Fetch Type이 `EAGER`인 경우에 대해, 식별자를 통해 특정 엔티티에 직접 접근하는 경우는 조인으로, JPQL SELECT 문을 사용하는 경우는 추가 쿼리 (secondary select)로 연관 엔티티를 조회한다고 볼 수 있습니다.

 

 

## "EAGER를 사용하면 JPQL 사용 시 N+1 문제가 발생하는 경우가 있다"

 

JPA로 유명한 김영한 님 강의 들을 때 이런 말씀이 있었습니다.

"`EAGER`를 사용하면 `JPQL` 사용 시 N+1 문제가 발생하는 경우가 있다"

 

이 말씀을 기억하고 있다가 실험 결과를 확인했는데, findAll() 메서드를 사용하든 JPQL SELECT 문을 사용하든, N+1 문제는 똑같이 발생했습니다.

그렇다면, 꼭 `JPQL`이라서 문제가 발생하는 경우는 없는 게 아닌가? 생각이 들더라고요.

다시 말하면 `EAGER`와 `JPQL`을 같이 사용하는 것이 원인이 된다면, 적어도 `JPQL`이 아닌 `findAll()`을 사용하는 경우에서는 결과가 달랐어야 하지 않나 하는 생각이었습니다.

 

다시 한번 의문을 가지고 자료를 조사해 봤습니다.

`SimpleJpaRepository` 소스 코드에서 `findAll()`이 구현된 부분을 찾아보면 위와 같습니다.

`findAll()`은 내부에서 `getQuery(...)`를 호출해서 쿼리를 실행하고

 

`getQuery()`는 `JPQL` 기반의 `TypedQuery`를 만들어 실행합니다.

 

이를 통해 `findAll()` 메서드로 전체 데이터를 조회하는 동작이 `JPQL`의 SELECT문을 사용하는 것과 같은 동작임을 알 수 있습니다.

이것이 제가 진행한 실험에서 `findAll()`과 `JPQL` SELECT문의 실행 결과가 같은 이유였으며,

"`EAGER`를 사용하면 `JPQL` 사용 시 N+1 문제가 발생하는 경우가 있다"는 말 역시 정확하게 맞는 말이라고 이해할 수 있었습니다.

 

 

# 02. 결론

 

"`LAZY` 때문에 N+1 문제가 발생한다."

-> `LAZY`에서 N+1 문제가 발생하는 건 맞는데 `LAZY` 때문에 발생하는 건 아니다.

 

"`EAGER` 때문에 N+1 문제가 발생한다."

-> `EAGER`에서 N+1 문제가 발생하는 건 맞는데 `EAGER` 때문에 발생하는 건 아니다.

 

"`EAGER`가 N+1 문제를 해결하는 방법이다."

-> 절대 아니다.

 

"`EAGER`로 로딩하면 `JPQL` 사용 시 N+1 문제가 발생하는 경우가 있다."

-> 그렇다. 연관 접근이 없는 상황에서 `LAZY`일 때는 `Entity qureis`의 경우 추가 쿼리가 발생하지 않지만, `EAGER`는 발생한다.

 

"`페치 조인`은 `EAGER`처럼 데이터를 한 번에 조회한다."

-> 반은 맞고 반은 틀리다. `EAGER`로딩의 경우 `Direct fetching`일 때는 Join을 통해, `Entity quries`일 때는 추가 쿼리 (secondary select)를 통해 데이터를 한 번에 조회한다.

 

 

그래서 N + 1은 왜 발생하나?

 

한 문장으로 정리하는 것이 쉽지 않은 것 같습니다. 그게 N+1을 둘러싼 많은 오해가 있는 이유이기도 하겠죠.

저는 아래와 같이 정리해보겠습니다.

 

 "다수의 엔티티를 조회(`Entity quires`)할 때 `EAGER` 로딩이 secondary select를 발생시키거나, `LAZY` 로딩 상태에서 연관 엔티티에 접근하면서 각 연관 엔티티에 대한 추가 SELECT 문이 실행되는 상황에서 N+1 문제가 발생합니다."

 


 

 

 

끄으읕