많은 개발자가 N+1 문제는 JPA에만 있는 것으로 안다. 「SQL을 직접 쓰는 MyBatis에는 그런 문제가 없겠지」라고 생각한다.

하지만 MyBatis에도 N+1 문제는 있다. 그리고 내가 쓴 SQL만 봐서는 추가로 나가는 쿼리가 보이지 않아서 더 늦게 알아챈다.

이 글에서는 MyBatis에서 N+1이 어디서 생기는지부터 실무에서 고르는 해결 전략까지 다룬다.

이 글 내내 쓰는 장면은 게시판이다. DB에 테이블이 둘 있다.

  • post — 게시글. 한 행이 게시글 하나다. 컬럼은 id, title 등
  • comment — 댓글. 한 행이 댓글 하나이고, post_id 컬럼에 「어느 게시글의 댓글인지」 게시글 id가 들어 있다

게시글 하나에 댓글이 여러 개 달린다. 자바 쪽에는 Post 클래스와 Comment 클래스가 있고, Post 안에 List<Comment> comments 필드가 있다. 하고 싶은 일은 「게시글 목록을 댓글까지 채워서 가져오기」다.

N+1이라는 이름은 쿼리 수에서 왔다. 게시글 목록을 가져오는 쿼리가 1번 나간다(+1). 그다음 게시글 N개마다 「이 게시글의 댓글을 달라」는 쿼리가 한 번씩, 모두 N번 더 나간다. 합쳐서 N+1번이다. 게시글이 100개면 쿼리가 101번 나간다.


MyBatis에서는 <collection select>가 N+1을 만든다

JPA와 MyBatis에서 N+1이 생기는 자리가 다르다

JPA의 경우:

@Entity
public class Post {
    @OneToMany(fetch = FetchType.LAZY)  // 여기서 N+1 발생 가능
    private List<Comment> comments;
}
  • @Entity — 이 클래스가 post 테이블과 짝인 Entity라는 애너테이션이다
  • @OneToMany — 이 필드가 「게시글 하나에 딸린 댓글 여러 개」를 담는 컬렉션이라는 뜻이다(용어)
  • fetch = FetchType.LAZY — 댓글을 게시글과 같이 가져오지 않고, comments를 실제로 꺼내 쓰는 순간에 가져오라는 뜻이다(지연 로딩). 게시글 100개를 돌며 comments를 하나씩 꺼내면 그때마다 댓글 쿼리가 나가서 N+1이 된다

MyBatis의 경우:

MyBatis는 SQL을 XML 파일(매퍼 XML)에 적어 두고, SQL 결과를 자바 객체에 어떻게 채울지도 같은 파일에 적는다. 그 「채우는 법」을 적은 표가 resultMap이다.

<resultMap id="PostWithComments" type="Post">
    <id property="id" column="post_id"/>
    <result property="title" column="title"/>
    <!-- 여기서 N+1 발생! -->
    <collection property="comments" 
                column="post_id" 
                select="selectCommentsByPostId"/>
</resultMap>
  • <resultMap id="PostWithComments" type="Post"> — 이 표의 이름은 PostWithComments이고, 결과를 Post 객체로 만든다
  • <id property="id" column="post_id"/> — SQL 결과의 post_id 컬럼 값을 Post의 id 필드(property)에 넣는다. <result>와 같은 일을 하되 「이 값이 게시글을 구분하는 번호다」라고 따로 표시한 것이다. 아래 해결 전략 1에서 이 표시가 쓰인다
  • <result property="title" column="title"/> — title 컬럼 값을 title 필드에 넣는다
  • <collection property="comments" ...> — comments 필드는 리스트이니 여러 개를 채운다는 뜻이다
  • select="selectCommentsByPostId" — 그 리스트는 같은 XML 파일 안에 따로 적어 둔 selectCommentsByPostId라는 SELECT를 불러서 채우라는 뜻이다. 그 SELECT는 SELECT * FROM comment WHERE post_id = #{postId}처럼 게시글 하나의 댓글을 가져오는 쿼리라고 보면 된다. SELECT는 테이블에서 행을 꺼내 오는 SQL이고, WHERE는 「post_id가 이 값인 행만」 같은 조건을 적는 자리, #{postId}는 자바에서 넘긴 값이 들어갈 자리다
  • column="post_id" — 그 SELECT를 부를 때 지금 행의 post_id 값을 넘긴다

이렇게 <collection>(리스트)이나 <association>(객체 하나)에 select 속성을 달면(중첩 select), MyBatis는 게시글 목록 쿼리의 결과를 한 행씩 읽으면서 행마다 selectCommentsByPostId를 한 번씩 실행한다. Post가 100개면 Comment 조회 쿼리가 100번 실행된다. MyBatis 공식 문서도 이 방식을 「N+1 Selects Problem」이라고 부른다.

내가 쓴 SQL에는 추가 쿼리가 보이지 않는다

JPA는 프레임워크가 쿼리를 자동으로 만들기 때문에 「내가 모르는 쿼리가 나간다」는 이야기, 곧 N+1이 잘 알려져 있다. 반면 MyBatis는 개발자가 SQL을 직접 쓰기 때문에 「쿼리를 내가 다 썼는데 무슨 N+1이냐」고 생각하게 된다.

하지만 위 resultMap을 쓰면, 내가 부른 것은 게시글 목록 쿼리 하나인데 MyBatis가 결과를 채우는 도중에 댓글 쿼리를 행 수만큼 더 실행한다. 내가 쓴 SQL 파일 어디에도 「100번 실행」이라는 글자는 없다. 그래서 쿼리 로그(아래 「N+1을 찾는 네 단계」)를 켜 보기 전에는 모른다.


해결 전략 1 — JOIN과 resultMap으로 한 번에 가져온다

가장 먼저 떠올리는 해결책은 JOIN이다

N+1을 푸는 가장 직관적인 방법은 JOIN으로 게시글과 댓글을 쿼리 한 번에 가져오는 것이다.

<select id="selectPostsWithComments" resultMap="PostWithCommentsJoin">
    SELECT 
        p.id AS post_id,
        p.title,
        c.id AS comment_id,
        c.content
    FROM post p
    LEFT JOIN comment c ON p.id = c.post_id
</select>

<resultMap id="PostWithCommentsJoin" type="Post">
    <id property="id" column="post_id"/>
    <result property="title" column="title"/>
    <collection property="comments" ofType="Comment">
        <id property="id" column="comment_id"/>
        <result property="content" column="content"/>
    </collection>
</resultMap>
  • <select id="selectPostsWithComments" resultMap="PostWithCommentsJoin"> — 이 SELECT의 결과는 아래 PostWithCommentsJoin이라는 resultMap대로 채운다. 두 태그는 이 이름으로 이어진다(앞 절의 PostWithComments와는 다른 표다)
  • FROM post p — post 테이블을 쓰고, 이 쿼리 안에서는 짧게 p라고 부른다. comment c도 같다
  • p.id AS post_id — p.id 값을 결과에서는 post_id라는 이름으로 내보낸다. 게시글 id와 댓글 id가 둘 다 id라 이름이 겹치지 않게 바꾼 것이다
  • LEFT JOIN comment c ON p.id = c.post_id — 게시글 id와 댓글 post_id가 같은 것끼리 한 줄로 붙인다. LEFT JOIN이라 댓글이 없는 게시글도 한 줄 남고, 그 줄의 comment_id · content 칸은 NULL(값 없음)이다
  • <collection property="comments" ofType="Comment"> — 이번에는 select 속성이 없다. 다른 쿼리를 부르지 않고, 같은 결과 행에 들어 있는 comment_id, content 컬럼으로 Comment 객체를 만들어 comments 리스트에 넣는다. ofType은 리스트 안에 담길 객체의 타입이다. 댓글 칸이 전부 NULL인 줄에서는 Comment 객체를 만들지 않는다(MyBatis 공식 문서: 자식 객체는 그 컬럼 중 하나라도 값이 있을 때만 만든다)
  • 안쪽 <id property="id" column="comment_id"/> — 게시글 쪽 <id>와 같은 일을 댓글에 한다. 「이 값이 댓글을 구분하는 번호다」라는 표시다. MyBatis 공식 문서는 JOIN 결과를 묶을 때 이 <id>를 꼭 적으라고 하고, 빼도 동작은 하지만 성능이 크게 떨어진다고 적는다

MyBatis는 <id> 값이 같은 행을 객체 하나로 묶는다

JOIN 결과는 게시글과 댓글이 섞인 표다. 게시글 1번에 댓글이 둘, 게시글 2번에 댓글이 하나라면 결과는 이렇게 생긴다.

post_id title comment_id content
1 첫 글 10 좋아요
1 첫 글 11 감사합니다
2 둘째 글 12 질문 있어요

MyBatis는 <resultMap>을 보고 다음과 같이 움직인다.

  1. JOIN 결과로 Post와 Comment가 섞인 여러 row를 받는다
  2. <id> 태그에 적은 컬럼(post_id)으로 Post를 구분한다
  3. 같은 Post id를 가진 row들을 하나의 Post 객체로 묶는다. 위 표에서는 1번 행과 2번 행이 Post 하나가 된다
  4. <collection>의 Comment들은 그 Post의 comments 리스트에 모인다

내가 직접 묶는 코드를 짤 필요가 없다.

JOIN은 같은 게시글 데이터를 댓글 수만큼 다시 실어 온다

하지만 여기에는 숨겨진 성능 문제가 있다. 위 표에서 「첫 글」이 두 번 나온 것을 보면 된다. JOIN 결과는 게시글 정보를 댓글 줄마다 다시 적는다. 이 표 전체가 DB에서 내 프로그램으로 건너온다.

상황:

  • Post 1개 (데이터 크기: 1KB)
  • Comment 50개 (각 0.5KB)

DB에서 내 프로그램으로 건너오는 양 비교:

  • N+1 방식: 1KB + 25KB = 26KB
  • JOIN 방식: (1KB + 0.5KB) × 50 = 75KB

여기서는 Post가 1개라 N+1 방식이어도 쿼리는 2번뿐이다. 이 비교가 보려는 것은 쿼리 수가 아니라 실려 오는 데이터 양이다. 쿼리를 따로 보내면 게시글 1KB는 한 번만 오지만, JOIN 결과에서는 게시글 데이터가 댓글 50줄마다 붙어서 50번 중복된다. 댓글이 많을수록 게시글 데이터가 계속 반복된다.

이 중복은 비용이 셋으로 번진다.

  • DB에서 내 프로그램으로 보내는 양이 늘어난다
  • 내 프로그램의 메모리에 중복된 데이터가 일단 다 올라온다
  • 앞 절의 묶기(같은 post_id인지 행마다 비교하는 일)를 결과 행 수만큼 해야 한다. 묶는 코드는 내가 안 짰지만 그 일 자체가 사라진 것은 아니다

해결 전략 2 — 쿼리를 두 번으로 나누고 자바에서 붙인다

게시글 한 번, 댓글 한 번만 가져온다

실무에서 가장 많이 쓰는 방법은 쿼리를 2번으로 나누되, 데이터 중복 없이 가져오는 것이다. 게시글은 게시글대로 한 번, 그 게시글들의 댓글은 한꺼번에 한 번 가져오고, 둘을 자바 코드에서 붙인다.

1단계: Post 조회

<select id="selectPosts" resultType="Post">
    SELECT * FROM post
    WHERE ... 
    LIMIT 10 OFFSET 0
</select>
  • resultType="Post" — 결과를 Post 객체로 만든다. resultMap처럼 컬럼과 필드를 하나하나 짝짓지 않고, 컬럼 이름과 같은 이름의 필드에 알아서 넣는다. 여기서는 리스트를 채울 일이 없으니 이것으로 충분하다
  • 컬럼은 post_id · created_at처럼 밑줄로 잇고 자바 필드는 postId · createdAt처럼 대문자로 잇는다. 이 둘을 같은 이름으로 쳐 주는 MyBatis 설정이 mapUnderscoreToCamelCase이고 기본값은 꺼짐(false)이다. 이 글의 코드는 이 설정을 켜 두었다고 가정한다. 켜 두지 않으면 post_id 값이 postId 필드에 들어가지 않아 아래 Comment::getPostId가 비어 버린다
  • SELECT * FROM post — *는 「모든 컬럼」이다
  • WHERE ... — WHERE는 조건을 적는 자리다. ...는 화면마다 다른 조건(검색어 등)이 들어갈 자리를 줄여 쓴 것이다
  • LIMIT 10 OFFSET 0 — 앞에서 0개를 건너뛰고 10개만 가져온다(LIMIT · OFFSET). 지금은 숫자를 박아 두었고, 아래 「페이징」 절에서 이 숫자를 바꿀 수 있게 만든다

2단계: Comment 한꺼번에 조회

<select id="selectCommentsByPostIds" resultType="Comment">
    SELECT * FROM comment
    WHERE post_id IN
    <foreach collection="postIds" item="id" 
             open="(" separator="," close=")">
        #{id}
    </foreach>
</select>
  • WHERE post_id IN (1, 2, 3) — SQL의 IN은 「post_id가 괄호 안 값 중 하나인 행」을 고른다. 게시글 10개의 댓글을 쿼리 한 번에 가져오는 것이 이 줄이다
  • <foreach collection="postIds" item="id" open="(" separator="," close=")"> — 자바에서 넘긴 postIds 리스트를 돌며 괄호 안을 만든다(<foreach>). 맨 앞에 (, 값 사이에 ,, 맨 뒤에 )를 붙여서 (1,2,3) 모양이 된다
  • #{id} — 리스트에서 꺼낸 값 하나가 들어갈 자리다(#{})

이 XML을 자바에서 부르려면 Mapper 인터페이스가 있어야 한다. 구현 클래스는 내가 만들지 않는다. MyBatis가 이 인터페이스의 구현 객체를 대신 만들어 메서드 이름(selectCommentsByPostIds)과 XML의 id를 이어 주고, Spring을 쓰면 그 객체를 내 클래스에 넣어 준다(의존성 주입). 아래 코드의 postMapper · commentMapper 변수가 그렇게 받은 객체다. 받는 모양은 뒤 「Service와 매퍼 XML」 절에 있다.

public interface CommentMapper {
    List<Comment> selectCommentsByPostIds(@Param("postIds") List<Long> postIds);
}

@Param("postIds")가 XML의 collection="postIds"와 짝이다. 이걸 빼면 MyBatis는 컴파일된 클래스에 남은 매개변수 이름을 쓴다. 그런데 자바 컴파일러는 -parameters 옵션을 줘야 매개변수 이름을 클래스 파일에 남기고, 옵션이 없으면 이름이 arg0이 된다. 그러면 리스트를 arg0 · list · collection 같은 이름으로만 찾을 수 있어서 postIds로는 못 찾는다(MyBatis ParamNameResolver와 JDK Parameter.getName 소스로 확인했다). 빌드 옵션에 따라 되다 말다 하지 않게 이름을 @Param으로 못 박아 둔다.

3단계: Java 코드에서 붙이기

// 1. Post 조회
List<Post> posts = postMapper.selectPosts();

// 2. Post ID 수집
List<Long> postIds = posts.stream()
    .map(Post::getId)
    .collect(Collectors.toList());

// 3. Comment 일괄 조회
List<Comment> comments = commentMapper.selectCommentsByPostIds(postIds);

// 4. Map으로 그룹핑
Map<Long, List<Comment>> commentMap = comments.stream()
    .collect(Collectors.groupingBy(Comment::getPostId));

// 5. Post에 Comment 매핑
posts.forEach(post -> {
    List<Comment> postComments = commentMap.getOrDefault(
        post.getId(), 
        Collections.emptyList()
    );
    post.setComments(postComments);
});
  • 1번 — postMapper는 PostMapper 인터페이스 객체다. selectPosts()를 부르면 1단계 SQL이 실행되고 Post 10개가 리스트로 온다
  • 2번 — 게시글 리스트에서 id만 뽑아 List<Long>을 만든다. stream()은 리스트를 for 문 없이 하나씩 흘려보내는 스트림이고, .map(Post::getId)는 「각 post를 post.getId()로 바꿔라」를 줄여 쓴 것이다(메서드 참조). .collect(Collectors.toList())는 흘러나온 값을 다시 리스트에 담는다. for 문으로 postIds.add(post.getId())를 반복한 것과 같다
  • 3번 — 그 id 리스트로 2단계 SQL을 한 번 실행한다. 게시글 10개의 댓글이 섞인 채로 한 리스트에 온다
  • 4번 — 섞인 댓글을 게시글 id별로 나눈다. Collectors.groupingBy(Comment::getPostId)는 각 댓글의 getPostId() 값을 열쇠로 삼아, 같은 열쇠끼리 리스트로 모은 Map을 만든다. 그래서 결과가 「게시글 id → 그 게시글의 댓글 리스트」인 Map<Long, List<Comment>>가 된다
  • 5번 — 게시글을 하나씩 돌며(forEach, post -> { ... }는 람다다) Map에서 자기 id의 댓글 리스트를 꺼내 넣는다. getOrDefault(열쇠, 기본값)은 열쇠가 없으면 기본값을 돌려준다. 댓글이 하나도 없는 게시글은 Map에 열쇠가 없으니 빈 리스트(Collections.emptyList())를 넣는다

쿼리 2번과 중복 없는 전송을 같이 얻는다

장점:

  • 쿼리 2번 (N+1보다 훨씬 적음)
  • 데이터 중복 전송 없음 (JOIN보다 효율적)
  • DB 부하가 적음 — 두 테이블을 붙이는 JOIN 작업을 DB에 시키지 않는다
  • 게시글이 몇 개든 쿼리 수가 2번으로 같아서 성능을 미리 짐작할 수 있음
  • 묶는 과정이 내 코드에 있어서 중간 값을 찍어 보며 디버깅하기 쉬움

단점:

  • 코드 길이 증가
  • 붙이는 로직을 직접 써야 함
  • 화면마다 거의 같은 모양으로 반복되는 보일러플레이트 코드

그래도 나는 「코드가 좀 길어도 명확하고 성능 좋은 게 낫다」를 실무의 정석으로 본다. 사용자의 요청이 많이 들어오는 서비스일수록 더 그렇다. 요청 하나마다 이 조회가 한 번씩 돌기 때문이다.


페이징을 걸면 IN 절에 들어가는 ID가 한 페이지 분량으로 줄어든다

게시글을 페이지로 끊으면 댓글 조회도 그만큼 작아진다

실무에서는 목록을 대부분 페이징한다(한 페이지에 몇 개씩 끊어 보여 준다). 그러면 전략 2는 게시글을 전부 가져올 때보다 오히려 더 가벼워진다.

// 1. Post 10개만 조회 (페이징)
List<Post> posts = postMapper.selectPosts(page, size);  // 10개

// 2. Comment 조회 (딱 10개의 Post ID만)
List<Long> postIds = extractIds(posts);  // 10개의 ID만
List<Comment> comments = commentMapper.selectCommentsByPostIds(postIds);

// 3. 매핑
mapCommentsToPost(posts, comments);
  • selectPosts(page, size) — 앞의 1단계 SQL에 박아 둔 LIMIT 10 OFFSET 0을, 몇 번째 페이지(page)와 한 페이지 크기(size)로 바꿀 수 있게 만든 것이다. 실제로 SQL에 넘기는 모양은 아래 「Service와 매퍼 XML」 절에 있다
  • extractIds(posts) — 3단계 코드의 2번(id만 뽑기)을 메서드로 묶었다고 보면 된다
  • mapCommentsToPost(posts, comments) — 3단계 코드의 4번과 5번(나누고 붙이기)을 메서드로 묶었다고 보면 된다

페이징의 이점:

  • IN 절에 들어가는 ID가 적다. 페이징 없이 게시글이 1만 개라면 IN 괄호 안에 ID가 10,000개 들어가지만, 페이징하면 10개다
  • 메모리 사용량 최소화
  • 빠른 응답 속도

댓글도 많으면 미리보기로 끊거나 API를 나눈다

게시글 하나에 댓글이 너무 많으면 댓글도 끊어서 가져와야 한다.

방법 1: 미리보기 방식 (Window 함수 활용)

SELECT * FROM (
    SELECT 
        *,
        ROW_NUMBER() OVER (
            PARTITION BY post_id 
            ORDER BY created_at DESC
        ) as rn
    FROM comment
    WHERE post_id IN (1, 2, 3, ..., 10)
) sub
WHERE rn <= 3  -- 각 Post당 최신 3개만
  • 안쪽 SELECT — 게시글 10개의 댓글을 가져오면서 각 댓글 옆에 rn이라는 번호를 하나 붙인다
  • ROW_NUMBER() OVER (PARTITION BY post_id ORDER BY created_at DESC) — Window 함수다. PARTITION BY post_id는 「같은 게시글의 댓글끼리 따로」, ORDER BY created_at DESC는 「작성 시각이 늦은 것부터」라는 뜻이다. 그래서 게시글마다 최신 댓글이 1, 그다음이 2, 3 … 으로 번호가 매겨지고, 다음 게시글에서는 다시 1부터 시작한다
  • ) sub — 괄호 안 SELECT의 결과를 표 하나처럼 쓰려고 sub라는 이름을 붙였다. 바깥 SELECT는 이 표에서 고른다
  • WHERE rn <= 3 — 번호가 3 이하인 줄만 남긴다. 게시글마다 최신 댓글 3개씩만 오는 것이다

목록 화면에서 많이 쓴다(유튜브 댓글 미리보기처럼).

방법 2: API 완전 분리

GET /posts?page=1          → Post 목록 + Comment 개수만
GET /posts/1/comments?page=1  → Comment는 상세보기에서

목록 화면을 그리는 요청과 댓글을 가져오는 요청을 아예 다른 주소로 나누는 방법이다. GET은 「가져오기」 요청이고(HTTP 메서드), ?page=1은 몇 번째 페이지인지를 주소 뒤에 붙여 보내는 값이다(쿼리 파라미터). /posts/1/comments의 가운데 1은 게시글 id다. 「1번 게시글의 댓글」이라는 뜻이다. 목록 요청은 게시글과 댓글 개수만 돌려주고(개수는 COUNT 쿼리로 센다. 그 SQL은 이 글에서 다루지 않는다), 댓글 본문은 사용자가 게시글을 눌러 상세 화면에 들어갔을 때 두 번째 요청으로 가져온다.

얻는 것은 둘이다. 요청이 목록용과 댓글용으로 나뉘어 한 요청에 몰리던 일이 흩어지고, 「목록 요청은 목록만, 댓글 요청은 댓글만」으로 맡는 일이 분명해진다.


실무에서는 N+1을 찾고, 상황별로 고르고, 잰 뒤에 고친다

N+1을 찾는 네 단계

개발 단계: 쿼리 로그 확인

로그는 프로그램이 도는 동안 무슨 일이 있었는지 콘솔에 찍히는 기록이다. MyBatis는 실행하는 SQL을 DEBUG 레벨 로그로 찍어 준다. 그 레벨이 켜져 있지 않으면 안 보이니 application.yml에서 켠다.

# application.yml
logging:
  level:
    com.example.mapper: DEBUG  # 매퍼가 든 패키지의 SQL 로그를 켠다
  • com.example.mapper — 내 Mapper 인터페이스들이 들어 있는 패키지다. MyBatis는 SQL 로그를 SQL마다 「namespace.id」 이름의 로거로 찍는다(com.example.mapper.PostMapper.selectPosts처럼). namespace는 매퍼 XML 파일에 붙인 이름으로, 보통 Mapper 인터페이스의 전체 이름과 같게 적는다. 그래서 그 이름의 앞부분인 패키지를 켜면 그 안의 SQL이 전부 찍힌다
  • DEBUG — 로그 레벨 중 하나다. MyBatis는 SQL을 DEBUG 레벨로 찍기 때문에 이 레벨까지 켜야 보인다

처음에 이 글은 여기를 org.apache.ibatis: DEBUG로 적었다. org.apache.ibatis는 MyBatis 자신의 클래스가 들어 있는 패키지라서, 이걸 켜도 내 SQL 줄은 찍히지 않는다. MyBatis 소스와 공식 로깅 문서로 확인하고 고쳤다. 소스에서는 SQL 하나의 정보를 담는 MyBatis 내부 클래스(MappedStatement)가 SQL의 id로 로거를 만들고, DB 연결을 감싸 로그를 찍는 내부 클래스(ConnectionLogger)가 그 로거에 DEBUG로 SQL을 찍는다.

로그를 켜고 게시글 목록을 한 번 불러 보면 콘솔에 ==> Preparing: SELECT ... 모양의 줄이 SQL 하나마다 찍힌다. N+1이면 Preparing: SELECT * FROM comment WHERE post_id = ? 줄이 게시글 수만큼 줄줄이 이어지는 것으로 알아챈다.

테스트 단계: 쿼리 카운터

@Test
void testNPlusOne() {
    // 쿼리 카운트 측정
    int beforeCount = getQueryCount();
    
    List<PostDto> posts = postService.getPostsWithComments(1, 10);
    
    int afterCount = getQueryCount();
    int executedQueries = afterCount - beforeCount;
    
    // 쿼리는 5번 미만(4번 이하)만 허용
    assertThat(executedQueries).isLessThan(5);
}
  • @Test — 이 메서드가 테스트 하나라는 표시다(JUnit)
  • getQueryCount() — 지금까지 실행된 쿼리 수를 돌려주는 메서드라고 가정했다. 이 글에는 그 구현이 없다
  • postService — 아래 「Service와 매퍼 XML」 절의 PostService 객체다. Spring 테스트에서 의존성 주입으로 받아 둔다고 가정했다
  • 1페이지 10개를 한 번 부르기 전과 후의 쿼리 수 차이가 executedQueries다
  • assertThat(executedQueries).isLessThan(5) — 그 수가 5보다 작아야 통과한다(어서션)
  • 테스트 DB에는 게시글을 10개 넣어 두었다고 가정한다. 전략 2라면 쿼리는 2번이라 통과하고, N+1이라면 1 + 10 = 11번이라 이 줄에서 실패한다. 게시글을 3개만 넣으면 N+1이어도 1 + 3 = 4번이라 통과해 버리니, 테스트 데이터는 기준 숫자보다 넉넉히 넣는다

운영 단계: APM 모니터링

  • Pinpoint, New Relic, DataDog 같은 APM 제품으로 요청 하나에 쿼리가 몇 번 나갔는지 본다
  • 오래 걸린 쿼리를 모아 둔 Slow Query Log를 분석한다
  • DB 연결을 빌려 주는 Connection Pool이 바닥나지 않는지 본다. 쿼리는 빌린 연결 위에서 나가니, 요청 하나가 쿼리를 수십 번 보내면 그만큼 연결을 쥐고 있는 시간이 길어진다

예방 단계: 코드 리뷰

<!-- 이런 코드가 보이면 빨간불! N+1 위험 -->
<collection property="comments" 
            select="selectComments"/>

매퍼 XML에서 <collection>이나 <association>에 select 속성이 붙어 있으면, 그 resultMap을 쓰는 목록 조회가 N+1을 만든다.

상황별로 해결 전략을 고른다

상황 추천 방법 이유
소량 데이터 (< 100개) JOIN + ResultMap 간단하고 충분히 빠름
중량 데이터 (100~1000개) 2번 쿼리 분리 데이터 중복 최소화
대량 데이터 (> 1000개) 페이징 + 2번 쿼리 필수!
게시글 하나에 댓글이 아주 많음 Comment 페이징 미리보기 또는 API 분리

앞에서 JOIN의 중복 전송을 문제로 짚었는데 소량에서 JOIN을 추천하는 이유는, 중복되는 양이 데이터 수에 비례하기 때문이다. 수가 적으면 중복도 작아서 「코드가 짧다」는 JOIN의 장점이 이긴다. 표의 100 · 1000은 재서 나온 값이 아니라 대략의 경계이고, 무엇의 개수인지(게시글 수인지 JOIN 결과 행 수인지)도 처음에 정해 두지 않았다.

성능 최적화는 잰 뒤에 한다

조기 최적화는 악의 근원이다. (Premature optimization is the root of all evil)

실무 접근법:

  1. 먼저 빠르게 개발 → 기능 우선
  2. 운영하면서 모니터링 → 실제 부하 확인
  3. 문제 발견 시 최적화 → 측정 기반 개선
  4. AI 도움받아 리팩토링 → 빠른 개선

사용자가 10명일 때는 N+1 때문에 쿼리가 100번 더 나가도 문제없다. 사용자가 10만 명이 되면 그때 최적화하면 된다.

아래 「정리」의 「<collection select> 피하기」 · 「페이징은 필수」와 부딪쳐 보이지만, 그쪽은 새로 짤 때 처음부터 고르는 기본값이다. 여기서 말하는 것은 이미 돌아가는 코드를 재 보지도 않고 뜯어고치러 들지 말라는 것이다.


전략 2를 Service와 매퍼 XML로 끝까지 짠 예제

Service는 페이지를 OFFSET으로 바꿔 게시글을 가져오고 댓글을 붙인다

@Service
@RequiredArgsConstructor
public class PostService {
    
    private final PostMapper postMapper;
    private final CommentMapper commentMapper;
    
    /**
     * N+1 문제를 해결한 Post 목록 조회
     */
    public List<PostDto> getPostsWithComments(int page, int size) {
        // 1. Post 페이징 조회 (page는 1부터 센다)
        int offset = (page - 1) * size;
        List<Post> posts = postMapper.selectPosts(offset, size);
        
        if (posts.isEmpty()) {
            return Collections.emptyList();
        }
        
        // 2. Post ID 추출
        List<Long> postIds = posts.stream()
            .map(Post::getId)
            .collect(Collectors.toList());
        
        // 3. Comment 일괄 조회 (단 1번의 쿼리)
        List<Comment> comments = commentMapper.selectCommentsByPostIds(postIds);
        
        // 4. Post별 Comment 그룹핑
        Map<Long, List<Comment>> commentMap = comments.stream()
            .collect(Collectors.groupingBy(Comment::getPostId));
        
        // 5. DTO 변환 + 매핑
        return posts.stream()
            .map(post -> {
                List<Comment> postComments = commentMap.getOrDefault(
                    post.getId(), 
                    Collections.emptyList()
                );
                return PostDto.of(post, postComments);
            })
            .collect(Collectors.toList());
    }
}
  • @Service — 이 클래스가 실제 업무를 처리하는 Service라는 표시다
  • @RequiredArgsConstructor — final 필드 두 개를 받는 생성자를 Lombok이 대신 만들어 준다(용어). 생성자가 코드에 안 보여도, Spring이 그 생성자로 PostMapper와 CommentMapper 객체를 넣어 준다(의존성 주입)
  • int offset = (page - 1) * size; — 화면에서 받은 페이지 번호를 SQL의 OFFSET(건너뛸 개수)으로 바꾼다. 2페이지, 크기 10이면 10개를 건너뛴다. 페이지 번호를 1부터 센다고 가정했다
  • if (posts.isEmpty()) — 게시글이 없으면 댓글 쿼리를 보내지 않고 바로 빈 리스트를 돌려준다
  • 2~4번은 앞 「Java 코드에서 붙이기」의 2~4번과 같다
  • 5번 — 게시글마다 자기 댓글 리스트를 꺼내 PostDto로 만든다. DTO는 화면에 돌려줄 값만 담는 그릇이다. Post를 그대로 내보내지 않고 화면용 모양으로 한 번 옮긴다. PostDto.of(post, postComments)는 게시글과 댓글을 받아 DTO를 만드는 정적 팩토리 메서드이고, 구현은 이 글에 없다

매퍼 XML은 게시글 · 댓글 · 댓글 미리보기 SQL을 하나씩 둔다

Service가 부르는 selectPosts(offset, size)가 SQL의 #{offset} · #{size}와 이어지도록, PostMapper 인터페이스에도 @Param으로 이름을 붙인다. CommentMapper는 앞 「Comment 한꺼번에 조회」의 인터페이스와 같다.

public interface PostMapper {
    List<Post> selectPosts(@Param("offset") int offset, @Param("size") int size);
}
<!-- PostMapper.xml -->
<mapper namespace="com.example.mapper.PostMapper">
    
    <!-- Post 페이징 조회 -->
    <select id="selectPosts" resultType="Post">
        SELECT 
            id,
            title,
            content,
            author_id,
            created_at
        FROM post
        ORDER BY created_at DESC
        LIMIT #{size} OFFSET #{offset}
    </select>
    
</mapper>

<!-- CommentMapper.xml -->
<mapper namespace="com.example.mapper.CommentMapper">
    
    <!-- Comment 일괄 조회 (IN 절 사용) -->
    <select id="selectCommentsByPostIds" resultType="Comment">
        SELECT 
            id,
            post_id,
            content,
            author_id,
            created_at
        FROM comment
        WHERE post_id IN
        <foreach collection="postIds" item="id" 
                 open="(" separator="," close=")">
            #{id}
        </foreach>
        ORDER BY created_at DESC
    </select>
    
    <!-- Comment 미리보기 조회 (각 Post당 3개씩) -->
    <select id="selectPreviewComments" resultType="Comment">
        SELECT * FROM (
            SELECT 
                *,
                ROW_NUMBER() OVER (
                    PARTITION BY post_id 
                    ORDER BY created_at DESC
                ) as row_num
            FROM comment
            WHERE post_id IN
            <foreach collection="postIds" item="id" 
                     open="(" separator="," close=")">
                #{id}
            </foreach>
        ) ranked
        WHERE row_num <![CDATA[<=]]> 3
    </select>
    
</mapper>
  • <mapper namespace="com.example.mapper.PostMapper"> — 이 XML 파일의 이름이다(namespace). PostMapper 인터페이스의 전체 이름과 같아서, 인터페이스의 selectPosts 메서드가 이 파일의 id="selectPosts" SQL로 이어진다
  • selectPosts — 최신 글부터(ORDER BY created_at DESC) #{offset}개를 건너뛰고 #{size}개를 가져온다
  • selectCommentsByPostIds — 앞 2단계의 SQL에 필요한 컬럼만 적고 최신순 정렬을 붙인 것이다. 위 Service가 부르는 것은 이 SQL이다
  • selectPreviewComments — 앞 「미리보기 방식」의 SQL을 매퍼 XML로 옮긴 것이다. 위 Service는 댓글을 전부 가져오는 판이라 이 SQL을 부르지 않는다. 목록 화면에서 게시글마다 댓글 3개만 보여 주려면 CommentMapper 인터페이스에 List<Comment> selectPreviewComments(@Param("postIds") List<Long> postIds);를 더하고, Service 3번 줄의 selectCommentsByPostIds를 이 메서드로 바꿔 부른다
  • <![CDATA[<=]]> — XML에서는 <가 태그의 시작이라 SQL의 <=를 그냥 쓸 수 없다. CDATA로 감싸 「이건 태그가 아니라 글자다」라고 알려 준다. 앞 절의 SQL은 XML 밖이라 <=를 그대로 썼다

MyBatis와 JPA의 N+1 해결은 도구만 다르고 본질은 같다

항목 MyBatis JPA
발생 원인 <collection select=""> Lazy Loading
기본 해결책 JOIN + ResultMap Fetch Join
고급 해결책 2번 쿼리 분리 @BatchSize
제어 수준 완전 수동 (SQL 직접) 반자동 (JPQL)
익히려면 SQL 이해 필요 JPA 메커니즘 이해 필요
디버깅 쿼리 로그로 명확 때때로 불명확

JPA의 @BatchSize는 연관 데이터를 하나씩 가져오지 않고 여러 ID를 IN 쿼리 하나로 묶어 가져오게 하는 설정이다. 이렇게 묶어 가져오는 것을 Batch Fetch라고 부른다. 이 글의 전략 2(IN으로 댓글을 한 번에)가 바로 그 일을 손으로 하는 것이다.

핵심: 두 기술 모두 N+1 문제가 있고, 본질적인 해결 방법(JOIN 또는 Batch Fetch)은 같다.


정리

해야 할 것

  1. 쿼리 로그 항상 확인
    • 개발 환경에서 매퍼 패키지의 DEBUG 로그 켜기
    • 예상과 다르게 쿼리가 여러 번 실행되는지 확인
  2. <collection select=""> 피하기
    • 편하지만 N+1의 주범
    • 대신 2번 쿼리 분리 방식 사용
  3. 페이징은 필수
    • 목록 조회는 무조건 페이징 처리
    • 10개씩 끊어서 처리하면 성능 문제 대부분 해결
  4. 측정 기반 최적화
    • 문제가 생기기 전에 과도한 최적화 금지
    • APM으로 실제 병목 지점 확인 후 개선

하지 말아야 할 것

  1. JOIN이 무조건 정답이라고 생각하기
    • 데이터 중복 전송 문제를 고려한다
    • 관계가 복잡하면 오히려 역효과다. 게시글에 댓글 리스트와 태그 리스트처럼 자식 리스트가 둘 이상 달린 것을 한 번에 JOIN하면, 결과 행 수가 「댓글 수 × 태그 수」로 곱해져 중복이 훨씬 커진다
  2. 복잡한 쿼리를 한 방에 해결하려는 욕심
    • 가독성과 유지보수성을 희생한다
    • 2~3번의 단순한 쿼리가 더 나을 수 있다
  3. Java 코드에서 붙이기를 무조건 꺼리기
    • 「XML에서 다 해결해야 해!」(전략 1처럼 JOIN과 resultMap으로 매퍼 XML 안에서 다 묶는 것) → 고정관념이다
    • 코드로 붙이면 디버깅도 쉽고 명확하다

이 전략들을 실무에 어떻게 적용했는지 경험을 나눠 달라

이 글에서 다룬 MyBatis N+1 해결 전략을 실무에서 어떻게 적용하고 있는지 궁금하다.

  • JOIN과 2번 쿼리 분리 중 어느 쪽을 더 쓰는가
  • 페이징할 때 따로 쓰는 요령이 있는가
  • N+1 문제로 고생한 경험이 있는가

댓글로 경험과 요령을 남겨 주면 좋겠다.


자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기