⟡ 배경·문제 정의
회사에 입사하고 3개월쯤 지났을 때, 선배가 "이 API 좀 느린데 확인해볼래?"라며
게시판 조회 API 하나를 던져주었다.
로컬에서 테스트해보니 응답시간이 3초. 데이터가 겨우 100건인데 말이다.
쿼리 로그를 켜보니 SELECT 문이 101번 실행되고 있었다.
전형적인 N+1 문제였다.
fetch join을 적용하면 되겠지 싶었지만, 현실은 그렇게 호락호락하지 않았다.
결국 fetch join → @EntityGraph → @BatchSize + DTO 변환까지
3번의 시도 끝에 응답시간을 300ms로 줄일 수 있었다.
⟡ 최종 해결 요약
최종적으로 선택한 방식은 다음과 같다:
Post 엔티티: @BatchSize(size = 100) 적용
Service 계층: DTO 변환으로 엔티티 직접 노출 방지
결과: 쿼리 101개 → 2개로 감소, 응답시간 3초 → 300ms
핵심은 fetch join이 만능이 아니라는 것과,
상황에 맞는 최적화 전략을 선택해야 한다는 점이었다.
⟡ 시도한 루트들
- 아무것도 모르고 Lazy Loading만 사용하던 시절
시도한 이유
레거시 코드는 이렇게 생겼다(실제 코드는 보안상 문제가 될 수 있어서 가장 비슷하게 작성했습니다.)
// Post.java
@Entity
public class Post {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
// getter, setter 생략
}
// Comment.java
@Entity
public class Comment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
// getter, setter 생략
}
// PostService.java
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
public List<Post> getPosts() {
List<Post> posts = postRepository.findAll();
// 여기서 N+1 발생
for (Post post : posts) {
System.out.println("댓글 수: " + post.getComments().size());
}
return posts;
}
}
발생한 문제
쿼리 로그를 보니 이렇게 찍혔다

Hibernate: select post0_.id, post0_.title, post0_.content from post post0_
Hibernate: select comments0_.post_id, comments0_.id, comments0_.content from comment comments0_ where comments0_.post_id=?
Hibernate: select comments0_.post_id, comments0_.id, comments0_.content from comment comments0_ where comments0_.post_id=?
Hibernate: select comments0_.post_id, comments0_.id, comments0_.content from comment comments0_ where comments0_.post_id=?
... (100번 반복)
배운 점
FetchType.LAZY는 실제 사용 시점에 쿼리를 날린다- 반복문 안에서 연관 엔티티를 조회하면 N+1 문제가 발생한다
- 쿼리 로그(
spring.jpa.show-sql=true)는 필수다
- fetch join으로 해결하려다 MultipleBagFetchException 만남
시도한 이유
"fetch join을 쓰면 한 방에 다 가져올 수 있다"는 걸 배웠으니 바로 적용했다.
// PostRepository.java
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT DISTINCT p FROM Post p JOIN FETCH p.comments")
List<Post> findAllWithComments();
}
적용 방법
// PostService.java
public List<Post> getPosts() {
return postRepository.findAllWithComments();
}
쿼리 로그를 확인하니
Hibernate:
select distinct
post0_.id,
post0_.title,
post0_.content,
comments1_.post_id,
comments1_.id,
comments1_.content
from post post0_
inner join comment comments1_ on post0_.id=comments1_.post_id
문제점/한계
좋아! 쿼리가 1개로 줄었다! 그런데 요구사항이 추가되었다.
"태그(Tag)도 같이 조회해주세요."
Post와 Tag도 OneToMany 관계를 추가하고 fetch join을 시도했다:
@Query("SELECT DISTINCT p FROM Post p " +
"JOIN FETCH p.comments " +
"JOIN FETCH p.tags")
List<Post> findAllWithCommentsAndTags();
실행하자마자 나오는 시뻘건 문구 . .

org.hibernate.loader.MultipleBagFetchException:
cannot simultaneously fetch multiple bags
JPA: 너 List 2개 이상 fetch join 못해
나: 왜...?
JPA: 카르테시안 곱 터지거든
배운 점
- fetch join은 ToOne 관계에서는 여러 개 사용 가능
- ToMany 관계는 1개만 fetch join 가능 (2개 이상 시 MultipleBagFetchException)
- 이유: 카르테시안 곱(Cartesian Product)으로 인한 데이터 뷰
Hibernate ORM 6.0.0.CR1 User Guide
Fetching, essentially, is the process of grabbing data from the database and making it available to the application. Tuning how an application does fetching is one of the biggest factors in determining how an application will perform. Fetching too much dat
docs.hibernate.org
- @EntityGraph로 시도했지만 여전히 아쉬움
시도한 이유
"@EntityGraph를 쓰면 fetch join보다 편하다"는 글을 보고 적용했다.
// PostRepository.java
public interface PostRepository extends JpaRepository<Post, Long> {
@EntityGraph(attributePaths = {"comments"})
List<Post> findAll();
}
적용 방법
// PostService.java
public List<Post> getPosts() {
return postRepository.findAll();
}
문제점/한계
쿼리는 fetch join과 동일하게 나갔다. 그런데 . .
- 여전히 ToMany 관계 2개 이상은 불가
- 코드는 간결해졌지만 성능상 이득은 없음
- 카르테시안 곱 문제는 여전
데이터가 많아지면?
- Post 100개 × Comment 평균 10개 = 1000 rows 반환
- 중복 데이터가 많아 메모리 낭비

나: @EntityGraph 편하네!
성능: 근데 나 별로 안 빨라짐
배운 점
- @EntityGraph는 fetch join의 어노테이션 버전일 뿐
- 근본적인 N+1 해결은 맞지만, 카르테시안 곱 문제는 동일
- 데이터가 많으면 오히려 성능 저하 가능
- @BatchSize + DTO 변환으로 완벽 해결(최종)
시도한 이유
선배가 "BatchSize 써봐"라고 조언해주었다. 찾아보니 IN 절로 묶어서 가져오는 방식이었다.
최종 적용 코드
// Post.java
@Entity
public class Post {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@BatchSize(size = 100) // 핵심!
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
// getter 생략
}
// PostResponseDto.java
@Getter
public class PostResponseDto {
private Long id;
private String title;
private int commentCount;
public PostResponseDto(Post post) {
this.id = post.getId();
this.title = post.getTitle();
this.commentCount = post.getComments().size(); // 여기서 Lazy Loading 발생
}
}
// PostService.java
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
public List<PostResponseDto> getPosts() {
List<Post> posts = postRepository.findAll();
// DTO 변환하면서 comments 접근 → BatchSize 발동
return posts.stream()
.map(PostResponseDto::new)
.collect(Collectors.toList());
}
}
쿼리 로그 확인
-- 1번: Post 조회
Hibernate: select post0_.id, post0_.title, post0_.content from post post0_
-- 2번: Comment를 IN 절로 100개씩 묶어서 조회
Hibernate:
select comments0_.post_id, comments0_.id, comments0_.content
from comment comments0_
where comments0_.post_id in (?, ?, ?, ... ?) -- 최대 100개
결과
| 항목 | Before | After |
|---|---|---|
| 쿼리 수 | 101개 | 2개 |
| 응답 시간 | 3초 | 300ms |
| 메모리 | 중복 데이터 多 | 최소화 |

Before: SELECT 101번
After: SELECT 2번
나: 이게 되네?!
배운 점
@BatchSize는 IN 절을 사용해 한 번에 여러 건 조회- DTO 변환으로 필요한 데이터만 가져옴
- ToMany 관계 여러 개도 문제없음 (각각 BatchSize 적용 가능)
- fetch join보다 메모리 효율적
⟡ 최종 정리 및 배운 점
이번 N+1 문제 해결 과정에서 얻은 인사이트는 다음과 같다

- fetch join은 만능이 아니다 - ToMany 관계 2개 이상은 MultipleBagFetchException 발생
- @BatchSize는 실무에서 가장 실용적 - IN 절로 묶어서 쿼리 수 최소화
- DTO 변환은 필수 - 엔티티 직접 노출 시 양방향 참조로 인한 무한루프 위험
- 상황에 맞는 전략 선택 - ToOne은 fetch join, ToMany는 BatchSize
- 항상 쿼리 로그 확인 - 성능 문제는 추측이 아닌 측정으로
주니어: N+1이 뭔가요?
시니어: 너도 곧 알게 될 거야...
(3개월 후)

⟡ 전체 실행 가능 예제 코드
// Post.java
import javax.persistence.*;
import org.hibernate.annotations.BatchSize;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@BatchSize(size = 100)
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
protected Post() {}
public Post(String title, String content) {
this.title = title;
this.content = content;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public String getContent() { return content; }
public List<Comment> getComments() { return comments; }
}
// Comment.java
import javax.persistence.*;
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
protected Comment() {}
public Comment(String content, Post post) {
this.content = content;
this.post = post;
post.getComments().add(this);
}
public Long getId() { return id; }
public String getContent() { return content; }
public Post getPost() { return post; }
}
// PostRepository.java
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostRepository extends JpaRepository<Post, Long> {
}
// PostResponseDto.java
public class PostResponseDto {
private Long id;
private String title;
private int commentCount;
public PostResponseDto(Post post) {
this.id = post.getId();
this.title = post.getTitle();
this.commentCount = post.getComments().size();
}
public Long getId() { return id; }
public String getTitle() { return title; }
public int getCommentCount() { return commentCount; }
}
// PostService.java
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {
private final PostRepository postRepository;
public List<PostResponseDto> getPosts() {
List<Post> posts = postRepository.findAll();
return posts.stream()
.map(PostResponseDto::new)
.collect(Collectors.toList());
}
}
# application.yml
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100 # 전역 설정도 가능
⟡ 참조 및 레퍼런스
1. Hibernate User Guide - Fetching
Hibernate ORM 6.0.0.CR1 User Guide
Fetching, essentially, is the process of grabbing data from the database and making it available to the application. Tuning how an application does fetching is one of the biggest factors in determining how an application will perform. Fetching too much dat
docs.hibernate.org
2. Baeldung - JPA/Hibernate Batch Insert : https://www.baeldung.com/jpa-hibernate-batch-insert-update
3. Vladmihalcea - N+1 Query Problem
N+1 query problem with JPA and Hibernate - Vlad Mihalcea
Learn what the N+1 query problem is and how you can avoid it when using SQL, JPA, or Hibernate for both EAGER and LAZY associations.
vladmihalcea.com
4. 우아한형제들 기술블로그 - JPA N+1 문제 및 해결방안
응? 이게 왜 롤백되는거지? | 우아한형제들 기술블로그
이 글은 얼마 전 에러로그 하나에 대한 호기심과 의문으로 시작해서 스프링의 트랜잭션 내에서 예외가 어떻게 처리되는지를 이해하기 위해 삽질을 해본 경험을 토대로 쓰여졌습니다. 스프링의
techblog.woowahan.com
5. Spring Data JPA Reference - @EntityGraph
Spring Data JPA :: Spring Data JPA
Oliver Gierke, Thomas Darimont, Christoph Strobl, Mark Paluch, Jay Bryant, Greg Turnquist Copies of this document may be made for your own use and for distribution to others, provided that you do not charge any fee for such copies and further provided that
docs.spring.io