게시글 목록을 조회하는 상황을 봅시다. 게시글이 3개 있고, 각 게시글에 댓글이 달려있습니다.
@Transactional(readOnly = true)
public List<PostListResponse> getAllPosts() {
List<Post> posts = postRepository.findAll(); // 1번 쿼리
return posts.stream()
.map(PostListResponse::from) // 여기서 문제 발생
.toList();
}
PostListResponse.from()을 다시 봅시다:
public static PostListResponse from(Post post) {
return PostListResponse.builder()
.id(post.getId())
.title(post.getTitle())
.author(post.getAuthor())
.commentCount(post.getComments().size()) // ← 이 줄!
.createdAt(post.getCreatedAt())
.build();
}
post.getComments().size()를 호출합니다. comments는 LAZY 로딩이니까, 실제로 접근하는 이 시점에 DB에서 댓글을 가져옵니다.
콘솔의 SQL 로그를 보면:
-- 1번: 게시글 전체 조회
SELECT * FROM post;
-- 2번: 1번 게시글의 댓글 조회
SELECT * FROM comment WHERE post_id = 1;
-- 3번: 2번 게시글의 댓글 조회
SELECT * FROM comment WHERE post_id = 2;
-- 4번: 3번 게시글의 댓글 조회
SELECT * FROM comment WHERE post_id = 3;
게시글 조회 1번 + 각 게시글의 댓글 조회 N번 = 총 N+1번의 쿼리가 실행됩니다.
게시글이 3개면 4번, 100개면 101번, 1000개면 1001번의 쿼리가 나갑니다. 이것이 N+1 문제입니다.
Step 1: 앱을 실행하고 게시글 3개를 만들고, 각각에 댓글을 2개씩 달아보기
POST /api/posts → {"title": "글1", "content": "내용1", "author": "A"}
POST /api/posts → {"title": "글2", "content": "내용2", "author": "B"}
POST /api/posts → {"title": "글3", "content": "내용3", "author": "C"}
POST /api/posts/1/comments → {"content": "댓글1-1", "author": "X"}
POST /api/posts/1/comments → {"content": "댓글1-2", "author": "Y"}
POST /api/posts/2/comments → {"content": "댓글2-1", "author": "X"}
POST /api/posts/2/comments → {"content": "댓글2-2", "author": "Y"}
POST /api/posts/3/comments → {"content": "댓글3-1", "author": "X"}
POST /api/posts/3/comments → {"content": "댓글3-2", "author": "Y"}
Step 2: 전체 조회 요청
GET /api/posts
Step 3: IntelliJ 콘솔에서 SQL 로그를 확인하세요. SELECT가 4번(1 + 3) 나오는 것이 보일 겁니다.
"LAZY라서 나중에 쿼리가 나가는 거면, EAGER로 바꾸면 되지 않나?"라고 생각할 수 있습니다.