JPA의 N+1 문제는 백엔드 면접 단골이자 실무 성능 장애의 흔한 원인입니다. “연관 엔티티를 조회할 때 쿼리가 N번 더 나간다”는 설명은 많지만, 이 글에서는 실제로 Spring Boot 프로젝트를 띄워 쿼리가 몇 번 나가는지 직접 측정했습니다. 숫자와 실제 Hibernate 로그로 확인합니다.
실험 환경
- Spring Boot 3.3.5 / Java 21 / Spring Data JPA / H2(인메모리)
- 엔티티:
Team1 : NMember(멤버는@ManyToOne(fetch = LAZY)로 팀 참조) - 데이터: 팀 10개, 각 팀에 멤버 1명 → 멤버 10명
- 실행 SQL 횟수는 추측이 아니라 Hibernate
Statistics.getPrepareStatementCount()로 카운트
① 문제 재현 — 평범한 조회가 11번의 쿼리가 되는 과정
가장 흔한 코드입니다. 멤버를 전부 조회한 뒤, 각 멤버의 팀 이름을 사용합니다.
List<Member> members = memberRepository.findAllPlain(); // select ... from member
for (Member m : members) {
m.getTeam().getName(); // 팀을 처음 건드리는 순간 지연 로딩 발생
}
실행하면 Hibernate가 찍은 SQL은 이렇습니다(일부).
-- 멤버 전체 조회 (1번)
select m1_0.id, m1_0.name, m1_0.team_id from member m1_0
-- 그 다음, 팀을 건드릴 때마다 팀 조회가 따로 나간다
select t1_0.id, t1_0.name from team t1_0 where t1_0.id=?
select t1_0.id, t1_0.name from team t1_0 where t1_0.id=?
select t1_0.id, t1_0.name from team t1_0 where t1_0.id=?
... (팀마다 반복)
멤버를 가져오는 쿼리 1번 + 팀을 지연 로딩하는 쿼리 10번 = 총 11번. 이것이 N+1입니다. 여기서 “1”은 처음의 목록 조회, “N”은 연관 엔티티를 채우기 위한 추가 조회입니다.
② fetch join 으로 한 번에 가져오기
JPQL에서 join fetch로 연관 엔티티를 함께 조회하면 추가 쿼리가 사라집니다.
@Query("select m from Member m join fetch m.team")
List<Member> findAllWithTeam();
이때 나가는 SQL은 조인된 단 한 줄입니다.
select m1_0.id, m1_0.name, t1_0.id, t1_0.name
from member m1_0 join team t1_0 on t1_0.id = m1_0.team_id
③ 측정 결과
같은 코드 흐름에서 1차 캐시를 비우고(em.clear()) 두 방식을 각각 측정한 결과입니다.
| 방식 | 실행된 SQL 수 | 소요 시간 |
|---|---|---|
| 일반 조회 + 지연 로딩 (N+1) | 11개 | 72ms |
| fetch join | 1개 | 10ms |
멤버가 10명일 때 쿼리가 11번 → 1번으로 줄었습니다. 멤버가 100명이면 N+1은 101번, 1,000명이면 1,001번이 됩니다. 쿼리 횟수가 데이터 수에 비례해 늘어난다는 것이 N+1이 무서운 진짜 이유입니다.
한 가지 솔직하게 짚자면, 위 시간(72ms vs 10ms)은 인메모리 H2에 데이터 10건이라 차이가 작아 보입니다. 실제 운영 DB에서는 쿼리 1번마다 네트워크 왕복 비용이 붙기 때문에, 쿼리 횟수가 수십~수백 배로 늘면 응답 시간은 그보다 훨씬 가파르게 악화됩니다. 그래서 측정에서 봐야 할 핵심 지표는 시간이 아니라 실행 SQL 횟수입니다.
④ fetch join 말고 다른 선택지
- @EntityGraph — 리포지토리 메서드에
@EntityGraph(attributePaths = "team")를 붙여 페치 조인과 같은 효과를 선언적으로. JPQL을 직접 쓰기 싫을 때. - 배치 페치(
hibernate.default_batch_fetch_size) — 지연 로딩을 유지하되, 흩어진 팀 조회를where id in (?, ?, ?...)로 묶어 N번을 몇 번으로 줄임. 컬렉션(@OneToMany) 페치 조인의 페이징 문제를 피할 때 특히 유용. - 컬렉션 fetch join + 페이징 주의 —
@OneToMany를 fetch join 하면서 페이징하면 Hibernate가 메모리에서 페이징(HHH000104경고)하므로, 이 경우엔 배치 사이즈 방식이 안전합니다.
정리
- N+1은 “목록 조회 1번 + 연관 엔티티 조회 N번”으로, 쿼리 수가 데이터 수에 비례해 폭증한다.
- 실측에서 멤버 10명 기준 11번 → 1번으로 줄었고, 데이터가 많아질수록 격차는 선형으로 커진다.
- 해결의 1순위는 fetch join / @EntityGraph, 페이징·컬렉션 상황에서는 배치 페치 사이즈를 함께 고려한다.
- 측정할 때는 시간보다 실행 SQL 횟수를 봐라. 그게 N+1의 본질이다.