안녕하세요, 자바파커입니다.
게시글 100개를 불렀습니다. 쿼리는 101번 나갔습니다.
로컬에서는 아무 문제가 없습니다. 데이터가 열 건뿐이니까요. 그런데 운영에 올라가고 목록에 천 건이 쌓이는 순간, 응답이 갑자기 느려집니다. 로그를 켜 보면 똑같은 모양의 SQL이 화면을 가득 채우고 있습니다.
N+1 문제입니다. 그리고 솔직히 말씀드리면, 지금 여러분 프로젝트에도 거의 확실히 하나쯤 있습니다.
결론부터 말씀드리면 — 목록을 가져오는 쿼리 1번 + 각 항목의 연관 데이터를 가져오는 쿼리 N번입니다. ORM이 연관 관계를 "필요할 때 가져오도록"(지연 로딩) 만들어 뒀는데, 반복문 안에서 그 필요가 N번 발생하기 때문입니다.
어떻게 101번이 되나
코드는 아주 평범하게 생겼습니다.
List<Post> posts = postRepository.findAll(); // ① 쿼리 1번
for (Post post : posts) {
System.out.println(post.getAuthor().getName()); // ② 여기서 매번 1번
}실제로 나가는 SQL은 이렇습니다.
-- ① 목록
select * from post;
-- ② 반복문이 돌 때마다
select * from member where id = 1;
select * from member where id = 2;
select * from member where id = 3;
...
select * from member where id = 100;getAuthor()까지는 아무 일도 안 일어납니다. getName()을 호출하는 순간 프록시가 실제 값을 채우려고 쿼리를 날립니다. 게시글이 100개면 그 순간이 100번 옵니다.
여기서 이름이 나옵니다 — 목록 1번 + 항목마다 N번, 그래서 N+1입니다.
왜 로컬에서는 안 걸리나
이 문제가 위험한 이유는 조용하기 때문입니다.
- 에러가 안 납니다. 결과는 정확합니다.
- 로컬 데이터가 열 건이면 쿼리 11번이고, 체감이 안 됩니다.
- 테스트도 통과합니다. 쿼리 횟수를 검증하지 않는 한 잡히지 않습니다.
그러다 데이터가 늘어나는 만큼 정직하게 선형으로 느려집니다. 그게 어느 날 장애가 됩니다.
| 게시글 수 | 지연 로딩 | fetch join |
|---|---|---|
| 10건 | 11회 | 1회 |
| 100건 | 101회 | 1회 |
| 1,000건 | 1,001회 | 1회 |
오른쪽 열이 이 글의 결론입니다. 데이터가 100배로 늘어도 쿼리는 한 번입니다.
흔한 오해 — LAZY 라서 생기는 게 아닙니다
가장 많이 하는 처방이 EAGER로 바꾸는 것인데, 거의 항상 더 나빠집니다.
@ManyToOne(fetch = FetchType.EAGER) // ❌ 해결책이 아닙니다
private Member author;EAGER로 바꿔도 JPQL로 목록을 조회하면 여전히 N+1이 납니다. JPQL은 적힌 대로 SQL을 만들고, 그다음 EAGER 연관을 채우려고 다시 N번 쿼리를 날립니다. 오히려 이제는 그 엔티티를 쓰는 모든 조회에서 연관 데이터가 딸려 옵니다. 필요 없는 곳에서도요.
정리하면 이렇습니다.
- 지연 로딩은 원인이 아니라 조건입니다. 원인은 "반복문 안에서 연관을 건드린 것"입니다.
@ManyToOne·@OneToOne은 기본값이 EAGER라 손대지 않아도 N+1이 납니다. 전부LAZY로 두는 게 출발점입니다.- 그다음, 조회하는 쪽에서 필요한 걸 한 번에 가져오도록 지정합니다.
해결 ① fetch join — 한 번에 조인해서 가져온다
가장 직접적인 방법입니다.
@Query("select p from Post p join fetch p.author")
List<Post> findAllWithAuthor();select p.*, m.* from post p inner join member m on p.author_id = m.id;쿼리 1번입니다. 스프링 데이터 JPA를 쓰신다면 @EntityGraph로도 같은 일을 합니다.
@EntityGraph(attributePaths = {"author"})
List<Post> findAll();다만 함정이 하나 있습니다. 컬렉션(@OneToMany)을 fetch join 하면서 페이징을 같이 걸면 안 됩니다.
// ⚠ 위험 — 하이버네이트가 전체를 메모리로 읽어 올린 뒤 페이징합니다
@Query("select p from Post p join fetch p.comments")
Page<Post> findAll(Pageable pageable);조인 결과가 행 단위로 뻥튀기되어 DB에서 페이징을 할 수 없기 때문입니다. 하이버네이트는 경고 로그만 남기고 전부 읽어 옵니다(firstResult/maxResults specified with collection fetch). 데이터가 많으면 그대로 메모리 문제가 됩니다.
컬렉션을 둘 이상 fetch join 하는 것도 안 됩니다. 행이 곱해져서 하이버네이트가 MultipleBagFetchException을 던집니다.
해결 ② batch fetch — IN 절로 묶는다
컬렉션이거나 페이징이 필요하면 이쪽이 정답입니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100이렇게 두면 지연 로딩이 하나씩 나가지 않고 모아서 한 번에 나갑니다.
select * from member where id in (1, 2, 3, ..., 100);쿼리가 1 + N에서 1 + (N / batch_size) 로 줄어듭니다. 100건이면 2회입니다.
fetch join보다 쿼리가 한 번 더 나가지만, 페이징이 정상 동작하고 컬렉션도 안전합니다. 실무에서는 이걸 전역으로 켜 두고, 무거운 조회만 fetch join으로 따로 잡는 조합을 많이 씁니다.
해결 ③ DTO로 직접 조회 — 엔티티를 안 쓴다
목록 화면처럼 읽기만 하는 경우에는 엔티티를 가져올 이유가 없습니다.
@Query("""
select new com.example.PostListDto(p.id, p.title, m.name)
from Post p join p.author m
""")
List<PostListDto> findAllForList();필요한 컬럼만 조인해서 한 번에 가져옵니다. 영속성 컨텍스트에 엔티티를 올리지 않으니 메모리도 덜 씁니다. 대신 수정이 안 됩니다 — 읽기 전용 화면에만 쓰세요.
어떻게 찾아내나
가장 확실한 건 SQL을 눈으로 보는 것입니다.
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.orm.jdbc.bind: trace # 바인딩 파라미터까지로그를 켜고 목록 API를 한 번 호출해 보세요. 같은 모양의 select가 여러 줄 반복되면 그게 N+1입니다.
다만 눈으로 보는 건 놓치기 쉽습니다. 한 번 잡고 나면 테스트로 고정하는 걸 권합니다.
// 하이버네이트 통계로 실제 실행된 쿼리 수를 센다 (fetch join 적용 후 = 1회)
Statistics stats = entityManagerFactory
.unwrap(SessionFactory.class).getStatistics();
stats.clear();
postService.getList();
assertThat(stats.getPrepareStatementCount()).isEqualTo(1);이렇게 두면 누군가 나중에 연관을 하나 더 건드렸을 때 테스트가 먼저 깨집니다. N+1은 리뷰에서 놓치기 쉬운 종류라, 사람이 아니라 테스트가 지키게 하는 편이 낫습니다.
JPA만의 문제가 아닙니다
이름만 다를 뿐 모든 ORM에 있습니다.
| ORM | 해결 방법 |
|---|---|
| JPA / Hibernate | join fetch · @EntityGraph · default_batch_fetch_size |
| Django ORM | select_related() (조인) · prefetch_related() (IN 절) |
| Rails ActiveRecord | includes() · preload() · eager_load() |
| Prisma | include · select |
| Sequelize | include |
| GraphQL 리졸버 | DataLoader — 같은 틱의 요청을 모아 한 번에 |
특히 GraphQL은 구조적으로 N+1이 나기 쉽습니다. 필드마다 리졸버가 독립적으로 도니까요. DataLoader로 배치를 묶는 게 사실상 필수입니다.
자주 묻는 질문 (FAQ)
Q. 전부 EAGER로 바꾸면 편하지 않나요?
A. 아니요. JPQL 조회에서는 EAGER여도 N+1이 그대로 나고, 대신 그 엔티티를 쓰는 모든 조회가 무거워집니다. 연관은 전부 LAZY로 두고, 필요한 조회에서만 fetch join이나 batch fetch로 채우는 게 정석입니다.
Q. fetch join과 batch fetch 중 뭘 써야 하나요?
A. 페이징이 없고 단일 연관이면 fetch join, 페이징이 있거나 컬렉션이면 batch fetch입니다. 실무에서는 default_batch_fetch_size를 전역으로 켜 두고 무거운 조회만 fetch join으로 잡는 조합이 무난합니다.
Q. 쿼리 101번이 그렇게 나쁜가요? 인덱스 타면 빠른데요. A. 각 쿼리는 빠릅니다. 문제는 왕복 비용입니다. 네트워크 왕복이 1ms만 걸려도 100번이면 100ms이고, 커넥션을 그 시간만큼 점유합니다. 동시 요청이 늘면 커넥션 풀이 먼저 고갈됩니다. 쿼리 하나하나가 빠른 것과 요청 하나가 빠른 것은 다른 이야기입니다.
정리
- N+1 = 목록 1번 + 항목마다 N번. 반복문 안에서 연관을 건드리면 발생합니다.
- 에러가 안 나고 로컬에서 안 걸립니다. 데이터가 늘어야 드러나서 장애로 발견되는 경우가 많습니다.
- EAGER는 해결책이 아닙니다. 연관은
LAZY, 조회하는 쪽에서 필요한 것만 채웁니다. - 단일 연관이면 fetch join, 컬렉션·페이징이면 batch fetch, 읽기 전용이면 DTO 조회.
- 한 번 고쳤으면 쿼리 수를 테스트로 고정하세요.
지금 프로젝트에서 목록 API 하나만 골라 SQL 로그를 켜 보세요. 같은 select가 반복되고 있다면 그게 첫 번째입니다.
관련해서 DB 인덱스가 안 먹는 경우와 커넥션 풀 고갈도 같이 보시면 좋습니다. 세 가지가 실무에서 서로 얽혀 나타납니다.