Spring Boot JPA N+1 문제 해결하기
목록 화면 하나에 쿼리가 수십 번 나간다면
JPA로 개발하다 보면 목록 화면 하나를 띄웠을 뿐인데 SQL 로그가 수십 줄씩 찍히는 경험을 하게 된다. 게시글 20개를 조회했는데 실제로 나간 쿼리는 21번, 50개면 51번. 이게 바로 **N+1 문제**다.
까다로운 점은 기능 자체는 멀쩡히 동작한다는 것이다. 그래서 개발 단계에서는 그냥 넘어가기 쉽고, 트래픽이 늘어난 운영 환경에서 갑자기 DB 부하와 응답 지연으로 드러난다. 실제로 kLo(kraft.io.kr)를 운영하면서 게시글 목록 API에서 가장 먼저 손봤던 부분도 이 N+1이었다.
이 글에서는 N+1이 **왜** 생기는지부터, fetch join · `@EntityGraph` · `default_batch_fetch_size`까지 상황별 해결책을 코드와 함께 정리한다.
N+1은 언제 발생하는가
N+1은 **연관 엔티티를 지연 로딩(LAZY)으로 설정한 상태에서, 조회한 엔티티 목록을 순회하며 연관 엔티티에 접근할 때** 발생한다. 목록을 가져오는 쿼리 1번 + 각 엔티티의 연관관계를 채우기 위한 쿼리 N번, 합쳐서 N+1번이다.
먼저 흔한 연관관계 엔티티를 보자. 게시글(`Post`)은 작성자(`Member`)를 N:1로, 댓글(`Comment`)을 1:N으로 가진다.
```java
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member\_id")
private Member author;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
}
```
이제 게시글 목록을 조회하고 작성자 이름을 출력해 보자.
```java
List posts = postRepository.findAll(); // SELECT * FROM post (쿼리 1번)
for (Post post : posts) {
// 루프를 돌 때마다 author를 채우는 쿼리가 추가로 실행된다
System.out.println(post.getAuthor().getName()); // SELECT * FROM member WHERE id = ? (쿼리 N번)
}
```
실행하면 로그가 이렇게 찍힌다.
```text
select p.* from post p
select m.* from member m where m.id = 1
select m.* from member m where m.id = 2
select m.* from member m where m.id = 3
... (게시글 수만큼 반복)
```
원인은 명확하다. `author`가 LAZY이기 때문에 처음엔 프록시만 들고 있다가, `getAuthor().getName()`으로 **실제 값에 접근하는 순간** 추가 쿼리가 나간다. 그게 게시글 수만큼 반복되는 것이다.
그렇다면 EAGER로 바꾸면 될까? 오히려 더 나쁘다. EAGER 역시 목록 조회 시 N+1을 유발하는 데다, 어디서 어떤 쿼리가 나갈지 제어하기 어렵다. **연관관계는 항상 LAZY로 두고, 필요한 곳에서 조회 방법으로 푸는 것**이 정석이다.
해결 1 — Fetch Join
가장 직관적인 방법은 JPQL의 `join fetch`다. 연관 엔티티를 조인해서 **한 번의 쿼리로** 함께 가져온다.
```java
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("select p from Post p join fetch p.author")
List<Post> findAllWithAuthor();
}
```
이렇게 하면 게시글과 작성자를 조인한 단일 쿼리가 나가고, 21번이 1번으로 줄어든다.
다만 컬렉션(`comments` 같은 1:N)을 fetch join 하면 조인 특성상 행이 뻥튀기되어 결과가 중복된다. 이때는 `distinct`가 필요하다.
```java
@Query("select distinct p from Post p join fetch p.comments")
List findAllWithComments();
```
그리고 fetch join에는 두 가지 함정이 있다.
- **컬렉션 fetch join + 페이징**: 하이버네이트가 페이징을 DB가 아닌 **메모리에서 처리**한다(`HHH000104` 경고). 데이터가 많으면 OOM으로 이어질 수 있어 사실상 금지에 가깝다.
- **컬렉션을 둘 이상 fetch join**: `MultipleBagFetchException`이 발생한다.
즉 fetch join은 **ToOne(N:1, 1:1) 연관이나, 페이징이 없는 단순 조회**에 잘 맞는다.
해결 2 — @EntityGraph
같은 fetch join을 어노테이션으로 선언적으로 처리할 수도 있다. JPQL을 직접 쓰지 않고 "이 연관은 함께 가져와라"라고 페치 전략만 지정하는 방식이다.
```java
public interface PostRepository extends JpaRepository<Post, Long> {
@EntityGraph(attributePaths = {"author"})
@Query("select p from Post p")
List<Post> findAllWithAuthorGraph();
}
```
가독성이 좋고 메서드 단위로 조회 전략을 관리하기 편하다. 내부적으로는 fetch join과 비슷하게 동작하므로 **컬렉션 + 페이징 제약은 동일하게 적용**된다는 점만 기억하면 된다.
해결 3 — default_batch_fetch_size (실무에서 가장 자주 쓰는 카드)
컬렉션을 페이징과 함께 조회해야 한다면 위 두 방법은 막힌다. 이때 빛을 발하는 게 `default_batch_fetch_size`다.
```yaml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
```
이 옵션을 켜면 지연 로딩으로 연관 엔티티를 가져올 때, 하이버네이트가 필요한 ID들을 모아 **`IN` 절로 묶어 한 번에** 조회한다. 결과적으로 N번 나가던 쿼리가 `ceil(N / size)`번으로 줄어든다. 게시글 100개여도 size가 100이면 연관 조회는 단 1~2번이다.
전역 설정 대신 특정 연관관계에만 적용하고 싶다면 `@BatchSize`를 쓴다.
```java
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
@BatchSize(size = 100)
private List comments = new ArrayList<>();
```
`default_batch_fetch_size`는 페이징과도 자연스럽게 어울리기 때문에, 실무에서는 기본으로 켜두는 경우가 많다.
실무 적용 팁과 조합 패턴
상황별로 선택지가 다르다. 정리하면 다음과 같다.
- **목록(페이징) + 단일 연관(ToOne)** → fetch join 또는 `@EntityGraph`
- **목록(페이징) + 컬렉션(ToMany)** → `default_batch_fetch_size`
- 둘이 섞여 있으면 **ToOne은 fetch join으로, ToMany는 batch size로** 조합하는 패턴이 가장 깔끔하다.
여기에 더해 기억해 둘 것들.
- **화면에 일부 컬럼만 필요하다면 DTO 프로젝션이 더 근본적인 해법이다.** 목록에 작성자 이름만 보여주면 되는데 엔티티 전체를 끌어올 이유가 없다.
```java
@Query("select new cohttp://m.kraft.klo.dto.PostListItem(p.id, p.title, m.name) " +
"from Post p join p.author m")
List findPostList();
```
필요한 컬럼만 SELECT하므로 fetch 전략을 고민할 필요 자체가 사라진다.
- **쿼리 수는 반드시 로그로 검증한다.** `spring.jpa.show-sql`이나 p6spy로 실제로 몇 번 나가는지 눈으로 확인하는 습관이 중요하다. "해결했다고 생각했는데 여전히 N+1"인 경우가 의외로 많다.
kLo에서도 게시글 목록에서 작성자와 댓글을 게시글마다 따로 조회하다 보니 한 페이지를 그리는 데 쿼리가 수십 번씩 나가고 있었다. ToOne인 작성자는 fetch join으로, 댓글 쪽은 batch size로 정리하고, 화면 전용 목록은 DTO 프로젝션으로 분리하니 쿼리 수가 한눈에 들어올 만큼 줄었다. (※ 실제 엔티티 구조와 수치는 프로젝트 상황에 맞게 조정)
마무리
N+1은 "LAZY를 잘못 써서" 생기는 문제가 아니라, **LAZY를 제대로 쓰면 자연히 만나게 되는** 문제다. 그래서 정답도 하나가 아니다.
- 단일 연관 → **fetch join / @EntityGraph**
- 컬렉션 + 페이징 → **default_batch_fetch_size**
- 화면 전용 조회 → **DTO 프로젝션**
핵심은 결국 **내가 날리는 쿼리 수를 항상 로그로 확인하는 습관**이다. 도구는 상황에 맞게 고르면 된다. 다음 글에서는 `default_batch_fetch_size`와 페이징을 함께 쓸 때 하이버네이트가 내부적으로 어떻게 동작하는지 더 깊게 다뤄보겠다.
'Programming > Spring Boot' 카테고리의 다른 글
| Spring Boot Graceful Shutdown 설정: 배포 중 요청을 안전하게 종료하는 방법 (0) | 2026.07.28 |
|---|---|
| Spring Boot LTS는 없을까? 4.1·4.0·3.5 지원 기간과 버전 선택 (0) | 2026.07.14 |
| Spring Boot에서 REST API 응답 구조를 일관되게 설계하는 방법 (0) | 2026.06.25 |
| Spring Boot REST API 에러 응답 설계: HTTP 상태코드부터 ProblemDetail까지 (0) | 2026.06.10 |
| Spring Boot 4.0 핵심 변경점 정리 — Java 25 지원부터 API 버저닝까지 (0) | 2026.04.29 |