많은 개발자가 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>을 보고 다음과 같이 움직인다.
- JOIN 결과로 Post와 Comment가 섞인 여러 row를 받는다
<id>태그에 적은 컬럼(post_id)으로 Post를 구분한다- 같은 Post id를 가진 row들을 하나의 Post 객체로 묶는다. 위 표에서는 1번 행과 2번 행이 Post 하나가 된다
<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이 실행되고Post10개가 리스트로 온다 - 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)
실무 접근법:
- 먼저 빠르게 개발 → 기능 우선
- 운영하면서 모니터링 → 실제 부하 확인
- 문제 발견 시 최적화 → 측정 기반 개선
- 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)은 같다.
정리
해야 할 것
- 쿼리 로그 항상 확인
- 개발 환경에서 매퍼 패키지의 DEBUG 로그 켜기
- 예상과 다르게 쿼리가 여러 번 실행되는지 확인
<collection select="">피하기- 편하지만 N+1의 주범
- 대신 2번 쿼리 분리 방식 사용
- 페이징은 필수
- 목록 조회는 무조건 페이징 처리
- 10개씩 끊어서 처리하면 성능 문제 대부분 해결
- 측정 기반 최적화
- 문제가 생기기 전에 과도한 최적화 금지
- APM으로 실제 병목 지점 확인 후 개선
하지 말아야 할 것
- JOIN이 무조건 정답이라고 생각하기
- 데이터 중복 전송 문제를 고려한다
- 관계가 복잡하면 오히려 역효과다. 게시글에 댓글 리스트와 태그 리스트처럼 자식 리스트가 둘 이상 달린 것을 한 번에 JOIN하면, 결과 행 수가 「댓글 수 × 태그 수」로 곱해져 중복이 훨씬 커진다
- 복잡한 쿼리를 한 방에 해결하려는 욕심
- 가독성과 유지보수성을 희생한다
- 2~3번의 단순한 쿼리가 더 나을 수 있다
- Java 코드에서 붙이기를 무조건 꺼리기
- 「XML에서 다 해결해야 해!」(전략 1처럼 JOIN과 resultMap으로 매퍼 XML 안에서 다 묶는 것) → 고정관념이다
- 코드로 붙이면 디버깅도 쉽고 명확하다
이 전략들을 실무에 어떻게 적용했는지 경험을 나눠 달라
이 글에서 다룬 MyBatis N+1 해결 전략을 실무에서 어떻게 적용하고 있는지 궁금하다.
- JOIN과 2번 쿼리 분리 중 어느 쪽을 더 쓰는가
- 페이징할 때 따로 쓰는 요령이 있는가
- N+1 문제로 고생한 경험이 있는가
댓글로 경험과 요령을 남겨 주면 좋겠다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기