Backend/Spring

디지몬으로 이해하는 JPA의 N+1 문제의 원인과 해결방안

chillmyh 2025. 6. 6. 18:26

디지몬인 이유는 따로 없다. 그냥 내가 좋아해서.

 

사실 거창하게(?) 적었지만 디지몬은 큰 의미없다.

디지몬을 모르는 사람은 오히려 혼란스러울 수 있다.

1. N + 1 문제란?

N + 1 문제는 JPA에서 연관된 엔티티를 조회할 때, 불필요하게 많은 SQL 쿼리가 발생하는 현상을 말한다.

 

예를 들어, 1개의 쿼리로 N개의 엔티티를 가져온 다음,
각각의 엔티티에 대해 추가로 또 다른 쿼리를 1개씩 실행한다면?

→ 총 N + 1개의 쿼리가 실행된다.

 

이 현상은 대부분 JPA의 연관 관계 + 지연 로딩(LAZY) 설정이 맞물릴 때 발생하며,
성능 병목의 대표적인 원인 중 하나다.

 

1.1 지연 로딩(LAZY)과 즉시 로딩(EAGER)

JPA는 연관관계를 설정할 때 fetch 속성을 통해 해당 연관 객체를 언제 로딩할 것인지를 정의할 수 있다.

@ManyToOne(fetch = FetchType.LAZY)
private Digimon digimon;

 

fetch의 대표적인 두 가지 설정은 다음과 같다:

  • FetchType.EAGER – 즉시 로딩 (즉시 쿼리 수행)
  • FetchType.LAZY – 지연 로딩 (접근 시점에 쿼리 수행)

 

1.1.1 즉시로딩(FetchType.EAGER)

즉시 로딩은 엔티티를 조회하는 순간, 연관된 엔티티도 함께 가져온다.

@ManyToOne(fetch = FetchType.EAGER)
private Digimon digimon;
Tamer t = em.find(Tamer.class, 1L);
System.out.println(t.getDigimon().getName());

 

이때 다음 두 개의 쿼리가 실행될 수 있다.

select * from tamer where id = 1;
select * from digimon where id = ?;

 

필요하지 않아도 자동으로 로딩되기 때문에,
성능 최적화가 어려워지고 제어권도 개발자 손을 떠나게 된다.

 

즉시 로딩은 코드가 단순해 보일 수 있지만,
불필요한 쿼리 실행과 성능 제어의 어려움 때문에 실무에서는 지양된다.

 

1.1.2 지연로딩(FetchType.LAZY)

지연 로딩은 엔티티 조회 시, 연관 엔티티를 가져오지 않고 프록시만 할당한다.
필드에 접근하는 시점에 쿼리를 실행한다.

@ManyToOne(fetch = FetchType.LAZY)
private Digimon digimon;
Tamer t = em.find(Tamer.class, 1L); // 쿼리 1번만 실행
String name = t.getDigimon().getName(); // 이 시점에 쿼리 실행됨

 

→ 필요한 시점에만 불러오니 성능상 이점이 있다.

장점은 필요한 시점에만 쿼리를 날릴 수 있다는 것이지만,


단점으로는 트랜잭션 범위를 벗어난 접근 시 LazyInitializationException이 발생할 수 있고,
프록시 객체에 대한 직렬화, null 체크 등에서 문제를 유발할 수 있다.

 

1.1.3 연관 관계에 따른 기본 fetch 전략 (Default)

연관 관계 기본 fetch 전략
@ManyToOne LAZY
@OneToOne EAGER
@OneToMany LAZY
@ManyToMany LAZY

 

 

 

→ 예를 들어 @OneToOne은 특별히 설정하지 않아도 즉시 로딩이기 때문에
의도치 않게 N + 1 문제가 발생할 수 있다.

 

디지몬과 테이머는 파트너 관계기 때문에 즉시 로딩..

2. 예제 상황

배경 설정

  • Tamer: 디지몬과 파트너가 된 인간 캐릭터들
  • Digimon: 각 테이머가 소유한 디지몬 친구
@Entity
public class Digimon {
    @Id @GeneratedValue
    private Long id;
    private String name;
}

@Entity
public class Tamer {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToOne(fetch = FetchType.LAZY)
    private Digimon digimon;
}

 

우리가 하고 싶은 일은

"모든 테이머들과 그들의 디지몬 이름을 화면에 보여주고 싶다!"

이다.

List<Tamer> tamers = em.createQuery("select t from Tamer t", Tamer.class)
                       .getResultList();

for (Tamer tamer : tamers) {
    System.out.println(tamer.getName() + " - " + tamer.getDigimon().getName());
}

 

3. 여기서 발생하는 N + 1 문제

위 코드를 실행하면 다음과 같은 쿼리가 실행된다.

 

1. 테이머 조회 (쿼리 1번)

먼저, 모든 테이머를 가져오는 쿼리 1개가 실행된다.

select * from tamer;

→ 테이머(Tamer) 리스트만 가져온다. 예:
태일, 매튜, 한솔, 미나, 소라...

 

2. 디지몬 정보 접근 시마다 쿼리 발생

이후 for문 안에서 각 Tamer의 Digimon에 접근하는 순간마다 쿼리가 발생한다.

select * from digimon where id = ?;

→ 태일 → 아구몬
→ 매튜 → 파피몬
→ 미나 → 팔몬
→ 리키 → 파닥몬
→ 히카리 → 가트몬

 

총 1 + N개의 쿼리, N이 커질수록 병목은 커진다.

 

 

→ 즉, 테이머가 8명이면 디지몬 쿼리도 8번, 총 1 + 8 = 9번 쿼리 실행

 

이것이 바로 N + 1 문제다.

 

4. 원인 정리 : 지연 로딩(Lazy)과 순회

  • JPA는 @ManyToOne, @OneToOne 연관관계에 대해 기본적으로 지연 로딩을 사용한다.
  • em.createQuery("select t from Tamer t")로 테이머만 불러온다.
  • 그리고 t.getDigimon().getName() 접근 시마다 디지몬에 대한 쿼리가 발생한다.

N명의 테이머가 존재할 경우, 디지몬 쿼리는 N번 발생

 

이런 N+1 문제를 해결하기 위해서는 해결 방법은 fetch join, @EntityGraph 등을 사용해 볼 수 있다.

 

5. 해결 방안 1 : Fetch Join

JPA에서는 fetch join을 사용해 N + 1 문제를 해결할 수 있다.
fetch join은 연관된 엔티티를 즉시 조인해서 함께 가져오는 방법이다.

List<Tamer> tamers = em.createQuery(
    "select t from Tamer t join fetch t.digimon", Tamer.class
).getResultList();

 

→ 실행되는 쿼리:

 

select t.id, t.name, d.id, d.name
from tamer t
join digimon d on t.digimon_id = d.id;

 

쿼리 1번으로 모든 테이머와 디지몬을 함께 로딩

 

Member와 Team을 한 번의 조인 쿼리로 가져오기 때문에 총 1개의 쿼리만 실행된다.

 

주의: fetch join을 과도하게 사용하면 조인 폭주로 오히려 성능이 저하될 수 있다.

 

6. 해결 방법 2 : @EntityGraph 사용 (Spring Data JPA)

Spring Data JPA에서는 @EntityGraph를 사용해서 N + 1 문제를 해결할 수 있다.
이는 fetch join과 유사하게 작동하지만 쿼리를 직접 작성하지 않아도 된다.

@EntityGraph(attributePaths = {"digimon"})
List<Tamer> findAll();
  • 내부적으로 fetch join처럼 동작
  • 쿼리를 직접 작성하지 않아도 연관된 디지몬을 같이 가져올 수 있다

 

7. 해결 방법 3 : Batch Size 설정 (다수의 지연 로딩 상황에서)

JPA 구현체인 Hibernate에서는 지연 로딩된 컬렉션/엔티티에 대해 batch size를 설정할 수 있다.
이 설정을 통해 N개의 쿼리를 1개의 IN 쿼리로 줄일 수 있다.

spring.jpa.properties.hibernate.default_batch_fetch_size=100

 

그러면 100명의 Tamer에 대해 Digimon을 지연로딩 하더라도 내부적으로 다음과 같은 형태로 바뀐다.

select * from digimon where id in (1, 2, 3, 4, 5);

 

즉, 100번 쿼리 날리는 것을 1번으로 묶을 수 있어

쿼리 수는 줄고 성능은 향상되고 N + 1 을 완화할 수 있다.

 

8. 결론과 정리

디지몬 세계관에서는 전세계에 수많은 테이머들이 있기에

시즌이 거듭되면서 축적된 테이머들과 디지몬의 데이터들이 있을 것이다.

 

만약 이 구조에서 매번 디지몬을 N개 쿼리로 불러온다면,

서버는 과부하에 걸릴 수 있고, 실제 서비스에서도 심각한 성능 병목이 된다.

  1. findAll()은 지연로딩 시 반드시 N + 1 가능성을 의심해야 한다.
  2. EAGER가 자동으로 문제를 해결해주지 않는다.
  3. 따라서 연관된 엔티티가 필요하다면 fetch join이나 @EntityGraph로 명시적으로 처리해야 한다.
  4. 경우에 따라 Hibernate의 batch fetch size를 활용해서도 충분히 성능을 개선할 수 있다.
전략 N + 1 발생 여부 장점 단점
LAZY (기본) 발생함 명시적 제어 가능 연관 접근 시마다 쿼리 발생
EAGER 발생함 코드가 간단함 의도치 않은 쿼리 + 쿼리 제어 어려움
fetch join 발생 안함 가장 명시적, 효율적 쿼리 복잡해질 수 있음
@EntityGraph 발생 안함 재사용 가능, 쿼리 안 써도 됨 유연성이 fetch join보다 낮음
batch fetch 완화됨 설정만으로 성능 개선 쿼리 추적이 어려움