
사실 거창하게(?) 적었지만 디지몬은 큰 의미없다.
디지몬을 모르는 사람은 오히려 혼란스러울 수 있다.
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개 쿼리로 불러온다면,
서버는 과부하에 걸릴 수 있고, 실제 서비스에서도 심각한 성능 병목이 된다.
- findAll()은 지연로딩 시 반드시 N + 1 가능성을 의심해야 한다.
- EAGER가 자동으로 문제를 해결해주지 않는다.
- 따라서 연관된 엔티티가 필요하다면 fetch join이나 @EntityGraph로 명시적으로 처리해야 한다.
- 경우에 따라 Hibernate의 batch fetch size를 활용해서도 충분히 성능을 개선할 수 있다.
| 전략 | N + 1 발생 여부 | 장점 | 단점 |
| LAZY (기본) | 발생함 | 명시적 제어 가능 | 연관 접근 시마다 쿼리 발생 |
| EAGER | 발생함 | 코드가 간단함 | 의도치 않은 쿼리 + 쿼리 제어 어려움 |
| fetch join | 발생 안함 | 가장 명시적, 효율적 | 쿼리 복잡해질 수 있음 |
| @EntityGraph | 발생 안함 | 재사용 가능, 쿼리 안 써도 됨 | 유연성이 fetch join보다 낮음 |
| batch fetch | 완화됨 | 설정만으로 성능 개선 | 쿼리 추적이 어려움 |
'Backend > Spring' 카테고리의 다른 글
| 디지몬으로 이해하는 JPA 순환 참조 문제 (0) | 2025.06.06 |
|---|---|
| 헥사고날 아키텍처에 대해 알아보았다. (3) | 2025.06.06 |
| Maven 설치 및 환경변수 설정 (With Intel Mac) (0) | 2025.03.05 |
| 카카오페이 결제취소 API 서비스에 적용하기 (Spring Boot, Vue.js) (0) | 2025.01.21 |
| [스프링 핵심 원리 - 기본편] 객체 지향 설계와 스프링 (0) | 2024.03.21 |