N+1 문제 총정리 — EAGER만 문제가 아니다
이전 글에서 EAGER의 N+1 문제를 다뤘다.
"그러면 LAZY로 바꾸면 되는 거 아니야?"
아니다. LAZY에서도 N+1은 터진다.
오히려 LAZY의 N+1이 더 위험하다. 코드에서 안 보이니까.
1. 택배로 이해하기
회원 100명의 팀 이름을 출력해야 한다.
EAGER의 N+1:
"회원 목록 주세요" → 회원 100명 도착
→ 근데 택배 기사가 각 회원의 팀 정보도 따로따로 배달
→ 택배차 101번 옴 (1번 + 100번)
LAZY에서 N+1 안 터지는 경우:
"회원 목록 주세요" → 회원 100명 도착
→ 팀 정보? 안 열어봄 → 택배차 1번만 옴
LAZY에서 N+1 터지는 경우:
"회원 목록 주세요" → 회원 100명 도착
→ 근데 각 회원의 팀 이름을 하나씩 열어봄
→ 열어볼 때마다 택배차가 옴
→ 택배차 101번 옴 (1번 + 100번)
LAZY는 "안 쓰면 안 가져온다"일 뿐, "쓰면 하나씩 가져온다."
2. EAGER에서의 N+1 (복습)
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.EAGER)
private Team team;
}
List<Member> members = em.createQuery(
"SELECT m FROM Member m", Member.class
).getResultList();
-- 1번: Member 전체 조회
SELECT * FROM member
-- N번: 각 Member의 Team 조회
SELECT * FROM team WHERE id = 1
SELECT * FROM team WHERE id = 2
SELECT * FROM team WHERE id = 3
...
SELECT * FROM team WHERE id = 100
JPQL은 SQL을 그대로 만들기 때문에, EAGER여도 JOIN을 자동으로 넣지 않는다.
Member를 가져온 뒤, "어? EAGER네?" 하고 Team을 하나씩 추가 조회한다.
3. LAZY에서도 N+1이 터지는 경우
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY) // LAZY로 바꿨다
private Team team;
}
List<Member> members = em.createQuery(
"SELECT m FROM Member m", Member.class
).getResultList();
// 여기까지는 쿼리 1번. 문제없다.
SELECT * FROM member
-- 끝. Team 쿼리 안 나감. LAZY니까.
근데 이렇게 하면?
// 각 회원의 팀 이름을 출력
for (Member member : members) {
System.out.println(member.getTeam().getName()); // ← 여기!
}
-- for문이 돌면서 Team 쿼리가 N번 나감
SELECT * FROM team WHERE id = 1 -- 1번째 회원의 팀
SELECT * FROM team WHERE id = 2 -- 2번째 회원의 팀
SELECT * FROM team WHERE id = 3 -- 3번째 회원의 팀
...
SELECT * FROM team WHERE id = 100 -- 100번째 회원의 팀
LAZY인데 N+1이 터졌다.
EAGER N+1: 조회하는 순간 바로 터짐 (눈에 보임)
LAZY N+1: for문에서 getter 호출할 때 터짐 (코드에서 안 보임)
LAZY의 N+1이 더 위험한 이유는, 쿼리 로그를 안 보면 모른다는 것이다.
코드만 보면 그냥 getter 호출인데, 내부에서는 SELECT가 N번 나가고 있다.
4. 해결법 1 — fetch join
가장 많이 쓰는 방법이다.
List<Member> members = em.createQuery(
"SELECT m FROM Member m JOIN FETCH m.team", Member.class
).getResultList();
-- 실행되는 SQL: JOIN으로 한 번에 가져옴
SELECT m.*, t.*
FROM member m
INNER JOIN team t ON m.team_id = t.id
// 이제 for문 돌아도 추가 쿼리 안 나감
for (Member member : members) {
System.out.println(member.getTeam().getName()); // 이미 가져옴
}
쿼리 1번으로 끝난다.
fetch join 주의점
// 1:N 컬렉션 fetch join 시 데이터 중복 가능
List<Team> teams = em.createQuery(
"SELECT t FROM Team t JOIN FETCH t.members", Team.class
).getResultList();
// Team A에 Member가 3명이면 → Team A가 3번 나옴
// 해결: DISTINCT 사용
List<Team> teams = em.createQuery(
"SELECT DISTINCT t FROM Team t JOIN FETCH t.members", Team.class
).getResultList();
// 컬렉션 fetch join은 페이징 불가
List<Team> teams = em.createQuery(
"SELECT t FROM Team t JOIN FETCH t.members", Team.class
)
.setFirstResult(0)
.setMaxResults(10) // ← 경고! 메모리에서 페이징함
.getResultList();
// Hibernate 경고:
// HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
// → 전체 데이터를 메모리에 올린 뒤 잘라냄 → OutOfMemoryError 위험
5. 해결법 2 — @EntityGraph
fetch join을 어노테이션으로 쓸 수 있다. Spring Data JPA에서 편하다.
public interface MemberRepository extends JpaRepository<Member, Long> {
// 기본 findAll()은 Team을 안 가져옴 (LAZY)
// @EntityGraph를 붙이면 fetch join과 같은 효과
@EntityGraph(attributePaths = {"team"})
@Override
List<Member> findAll();
}
-- 실행되는 SQL
SELECT m.*, t.*
FROM member m
LEFT JOIN team t ON m.team_id = t.id
// 메서드 이름 쿼리에도 사용 가능
@EntityGraph(attributePaths = {"team"})
List<Member> findByName(String name);
// 여러 연관관계 동시에
@EntityGraph(attributePaths = {"team", "orders"})
List<Member> findAll();
장점: JPQL 안 써도 됨, 간결함
단점: 복잡한 조건이 필요하면 결국 JPQL fetch join을 써야 함
6. 해결법 3 — @BatchSize
fetch join이 안 되는 상황이 있다. 컬렉션 2개 이상 동시 fetch join은 불가능하다.
// 이렇게 하면 MultipleBagFetchException 터짐
List<Team> teams = em.createQuery(
"SELECT t FROM Team t JOIN FETCH t.members JOIN FETCH t.projects",
Team.class
).getResultList();
// → org.hibernate.loader.MultipleBagFetchException:
// cannot simultaneously fetch multiple bags
이때 @BatchSize를 쓴다.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members;
}
List<Team> teams = em.createQuery(
"SELECT t FROM Team t", Team.class
).getResultList();
// Team이 150개라면?
for (Team team : teams) {
team.getMembers().size(); // 이 시점에 IN 쿼리 발생
}
-- @BatchSize 없으면: Team 150개 → Member 쿼리 150번
SELECT * FROM member WHERE team_id = 1
SELECT * FROM member WHERE team_id = 2
...
SELECT * FROM member WHERE team_id = 150
-- @BatchSize(size = 100)이면: IN 절로 묶어서 2번
SELECT * FROM member WHERE team_id IN (1, 2, 3, ... , 100) -- 100개
SELECT * FROM member WHERE team_id IN (101, 102, ... , 150) -- 50개
쿼리 150번 → 쿼리 2번으로 줄어든다.
글로벌 설정 (권장)
엔티티마다 @BatchSize 붙이기 귀찮으면 글로벌로 설정한다.
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
이러면 모든 지연 로딩에 자동으로 IN 절이 적용된다.
실무에서 가장 먼저 해야 할 설정이다.
7. 해결법 4 — DTO 직접 조회
엔티티가 아니라 필요한 데이터만 뽑아오는 방법이다.
// DTO 정의
public class MemberTeamDto {
private String memberName;
private String teamName;
public MemberTeamDto(String memberName, String teamName) {
this.memberName = memberName;
this.teamName = teamName;
}
}
List<MemberTeamDto> result = em.createQuery(
"SELECT new com.example.dto.MemberTeamDto(m.name, t.name) " +
"FROM Member m JOIN m.team t", MemberTeamDto.class
).getResultList();
-- 실행되는 SQL: 필요한 컬럼만 가져옴
SELECT m.name, t.name
FROM member m
JOIN team t ON m.team_id = t.id
장점: 필요한 것만 가져오니까 성능 최고, N+1 원천 차단
단점: DTO 만들어야 하고, 패키지명까지 써야 해서 번거로움
8. 해결법 비교
┌──────────────────┬────────────┬──────────────┬──────────────┐
│ 방법 │ 쿼리 수 │ 편의성 │ 제한사항 │
├──────────────────┼────────────┼──────────────┼──────────────┤
│ fetch join │ 1번 │ ★★★★ │ 컬렉션 2개↑ │
│ │ │ │ 동시 불가 │
│ │ │ │ 페이징 주의 │
├──────────────────┼────────────┼──────────────┼──────────────┤
│ @EntityGraph │ 1번 │ ★★★★★ │ 복잡한 조건 │
│ │ │ │ 처리 어려움 │
├──────────────────┼────────────┼──────────────┼──────────────┤
│ @BatchSize │ 1 + α번 │ ★★★★★ │ 완전한 1번은 │
│ │ (매우 적음) │ │ 아님 │
├──────────────────┼────────────┼──────────────┼──────────────┤
│ DTO 직접 조회 │ 1번 │ ★★★ │ DTO 작성 │
│ │ │ │ 필요 │
└──────────────────┴────────────┴──────────────┴──────────────┘
9. 실무 권장 전략
1단계: 글로벌 BatchSize 설정
→ application.yml에 default_batch_fetch_size: 100
→ 이것만으로 대부분의 N+1이 사라짐
2단계: 핵심 API에 fetch join 적용
→ 자주 호출되는 API는 fetch join으로 쿼리 1번으로 줄이기
3단계: 성능이 중요한 곳은 DTO 직접 조회
→ 화면에 필요한 데이터만 뽑아오기
// 이 순서로 적용하면 된다
// 1. 먼저 글로벌 설정 (전체 적용)
spring.jpa.properties.hibernate.default_batch_fetch_size=100
// 2. 핫 API에 fetch join
@Query("SELECT m FROM Member m JOIN FETCH m.team WHERE m.status = :status")
List<Member> findActiveMembers(@Param("status") String status);
// 3. 대시보드 같은 곳은 DTO
@Query("SELECT new MemberSummaryDto(m.name, t.name, COUNT(o)) " +
"FROM Member m JOIN m.team t LEFT JOIN m.orders o " +
"GROUP BY m.name, t.name")
List<MemberSummaryDto> getMemberSummary();
10. 정리
N+1 문제:
❌ EAGER만의 문제가 아니다
❌ LAZY로 바꿔도 getter 호출하면 똑같이 터진다
✅ LAZY + fetch join 조합이 정답
✅ 글로벌 BatchSize 설정이 첫 번째 할 일
✅ 성능 중요한 곳은 DTO 직접 조회
핵심 한 줄
N+1은 "로딩 전략"의 문제가 아니라 "조회 전략"의 문제다.
EAGER든 LAZY든, 연관 데이터를 어떻게 가져올지 명시하지 않으면 N+1은 언제든 터진다.
다음 편 예고: @Transactional의 함정 — 붙였는데 왜 안 돼?
@Transactional을 붙이면 다 되는 줄 알았는데, 5가지 경우에 안 된다. private 메서드, self-invocation, checked exception... 하나씩 코드로 확인한다.
N+1, JPA, JPQL, fetch join, EntityGraph, BatchSize, DTO, Hibernate, Spring Data JPA, 지연로딩, 즉시로딩, 성능최적화, 쿼리최적화, ORM
'Java' 카테고리의 다른 글
| JPA 더티 체킹 — save() 안 해도 되는데 왜? 그리고 언제 안 될까? (0) | 2026.04.20 |
|---|---|
| @Transactional의 함정 — 붙였는데 왜 안 돼? (1) | 2026.04.20 |
| Java NIO의 고급 사용법을 비동기 처리, 파일 처리, 서버 구현 (2) | 2025.07.17 |
| writeObject와 readObject 메서드를 통한 커스텀 직렬화 (0) | 2025.07.17 |
| Spring Boot 기반 MQTT → Kafka 브릿지 아키텍처 구조에서 4가지 설정 (2) | 2025.06.27 |