포스트

association/collection과 N+1 쿼리의 함정

resultMap의 nested select가 행마다 쿼리를 추가로 던지는 N+1 문제와, join 기반 nested resultMap으로 한 방에 조회하는 차이를 정리한다.

association/collection과 N+1 쿼리의 함정

연관 데이터를 함께 조회하는 일은 흔하다. 주문과 그 주문의 고객, 게시글과 그 댓글들. MyBatis는 association(1:1)과 collection(1:N)으로 이를 표현하는데, 매핑 방식을 어떻게 쓰느냐에 따라 쿼리가 한 번 나가기도, 행 수만큼 나가기도 한다. 후자가 그 악명 높은 N+1 문제다.

두 가지 방식: nested select vs nested result

association/collection을 채우는 길은 두 가지다.

1) Nested select — select 속성으로 별도 쿼리를 지정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<resultMap id="orderMap" type="Order">
  <id     column="order_id" property="id"/>
  <result column="order_no" property="orderNo"/>
  <association property="customer" column="cust_id"
              select="findCustomerById"/>   <!-- 행마다 이 쿼리 실행 -->
</resultMap>

<select id="findOrders" resultMap="orderMap">
  SELECT order_id, order_no, cust_id FROM orders
</select>
<select id="findCustomerById" resultType="Customer">
  SELECT cust_id, cust_name FROM customers WHERE cust_id = #{id}
</select>

주문 목록을 1번 조회한 뒤, 반환된 주문 행 N개 각각에 대해 findCustomerById를 N번 더 실행한다. 총 1+N번. 이게 N+1이다.

flowchart TD
    A["SELECT orders → N행"] --> B["row1: SELECT customer"]
    A --> C["row2: SELECT customer"]
    A --> D["... rowN: SELECT customer"]
    B --> E["쿼리 총 1+N회"]
    C --> E
    D --> E

2) Nested result — JOIN으로 한 번에 가져와 결과를 매핑으로 접는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<resultMap id="orderMap" type="Order">
  <id     column="order_id"  property="id"/>
  <result column="order_no"  property="orderNo"/>
  <association property="customer" javaType="Customer">
    <id     column="cust_id"   property="id"/>
    <result column="cust_name" property="name"/>
  </association>
</resultMap>

<select id="findOrders" resultMap="orderMap">
  SELECT o.order_id, o.order_no, c.cust_id, c.cust_name
  FROM orders o JOIN customers c ON o.cust_id = c.cust_id
</select>

쿼리는 단 한 번이다. MyBatis가 결과셋을 받아 <id> 컬럼을 기준으로 행을 그룹핑해 객체 트리로 조립한다.

왜 nested select가 위험한가

목록 화면에서 주문 100건을 보여주면 nested select는 쿼리 101번을 던진다. 각 쿼리가 네트워크 왕복과 파싱 비용을 동반하니, 단건으로는 빠른 쿼리여도 100배가 되면 응답 시간이 폭발한다. 게다가 이 문제는 개발 환경의 적은 데이터에서는 드러나지 않는다. 운영에서 목록이 커진 뒤에야 느려진다.

collection의 N+1과 카티전 곱 트레이드오프

collection(1:N)도 nested select면 부모 N개당 자식 쿼리 N번이 나간다. 다만 JOIN으로 바꾸면 다른 문제가 생긴다. 부모 1건에 자식 M건이면 결과셋이 M행으로 부풀고, 부모 컬럼이 M번 중복된다(카티전 곱). MyBatis가 <id>로 접어주긴 하지만, 전송되는 행 수와 데이터량이 늘어난다. 자식이 매우 많으면 JOIN 한 방보다 “부모 목록 1쿼리 + 자식 IN 절 1쿼리”로 2번에 나누는 편이 나을 때도 있다.

운영 함정

lazy loading이 N+1을 숨긴다. nested select에 fetchType="lazy"를 걸면 실제 접근 시점에 쿼리가 나간다. 디버깅 중엔 안 나가다가, 뷰 렌더링이나 직렬화가 모든 프로퍼티를 건드리는 순간 N개의 쿼리가 한꺼번에 터진다. 로그에서 같은 형태의 쿼리가 반복되면 N+1을 의심한다. SQL 로그를 켜고 목록 조회 시 쿼리 횟수를 직접 세어보는 것이 가장 확실하다.

핵심 요약

  • nested select는 행마다 추가 쿼리를 던져 1+N번 → N+1 문제.
  • JOIN 기반 nested result map은 한 쿼리로 가져와 <id> 기준으로 객체를 조립한다.
  • 1:N JOIN은 카티전 곱으로 행이 부푼다. 자식이 많으면 IN 절 2쿼리 분할도 고려한다.
  • SQL 로그로 목록 조회 시 실제 쿼리 횟수를 확인해 N+1을 잡는다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.