1. N+1 문제란

게시글 목록을 조회하는 상황을 봅시다. 게시글이 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 문제입니다.


2. 직접 확인하기

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) 나오는 것이 보일 겁니다.


3. EAGER로 바꾸면 해결될까?

"LAZY라서 나중에 쿼리가 나가는 거면, EAGER로 바꾸면 되지 않나?"라고 생각할 수 있습니다.