MyBatis(마이바티스)는 내가 직접 쓴 SQL을 데이터베이스에 보내 주는 라이브러리다. 이걸 쓰다 보면 같은 고민이 자꾸 돌아온다. 동적 쿼리에서 조건을 어디에 둘 것인가다.

여기서 「조건」은 「이름에 ‘길동’이 들어간 주문만」 같은 거르는 규칙이다. 테이블 여러 개를 이어 붙이는 쿼리에서는 이 조건을 놓을 자리가 두 군데 있다. 테이블을 이을 때 쓰는 JOIN의 ON 뒤와, 어떤 행만 고를지 적는 WHERE 뒤다(SQL에서 ON …, WHERE …처럼 키워드로 시작하는 한 토막을 「절」이라고 부른다).

관리자 페이지의 검색 필터를 만들 때 이 고민이 특히 심해진다.

  • JOIN ON 절에 넣으면? 나는 성능이 좋다고 봤다. SQL은 글자 순서대로 읽으면 JOIN으로 행을 먼저 이어 붙이고 WHERE로 나중에 거르는 모양이라, 붙이는 단계에서 미리 거르면 다룰 행이 줄어든다고 생각했다. 대신 XML의 <if> 태그가 ON 절 사이사이에 끼어 읽기 어려워진다
  • WHERE 절에 넣으면? 코드는 깔끔하다. 대신 같은 이유로 성능을 잃을 수 있다고 봤다

실제로 DB가 어떤 순서로 처리할지는 DB 안의 옵티마이저가 정한다. 위 생각은 내가 SQL 모양을 보고 한 판단이고, 이 글에서 재 본 적은 없다.

이 글은 실무에서 이 고민을 어떻게 풀었는지 적는다. 그리고 다시 읽으며 확인해 보니 두 자리는 성능만 다른 것이 아니라 결과 자체가 다를 수 있었다. 그 확인 결과도 함께 적는다(「다시 확인해 보니」 상자).

먼저 알려 둔다(2026-10-20) — 이 글의 처음 결론은 「성능을 위해 검색 조건을 JOIN ON에 둔다」였다. 그 결론은 틀렸다. LEFT JOIN에서 검색 조건을 ON으로 옮기면 주문이 걸러지지 않는다. 「길동」으로 검색해도 모든 주문이 나오고, 이름이 안 맞는 주문은 고객 이름만 빈다. 그래서 결론을 「결과를 거르는 조건은 WHERE, 짝으로 붙일 행만 고르는 조건은 ON. 성능은 짐작하지 않고 재서 정한다」로 고쳤다. 본문의 처음 생각(ON이 빠를 거라고 본 것)은 그 결론에 닿기까지의 경로라서 지우지 않았다. 어디서 틀렸는지는 「다시 확인해 보니」 상자에, 고친 결론은 맨 아래 「결론」에 있다.


마이바티스 동적 쿼리는 테이블을 여러 개 이을 때 복잡해진다

XML 매퍼에 if 태그로 조건을 붙이는 기본 모양

MyBatis에서 동적 쿼리는 주로 매퍼 XML에 쓴다. 자바 쪽에는 메서드 선언만 있는 Mapper 인터페이스를 두고, 그 구현 객체는 내가 쓰지 않고 MyBatis가 실행 중에 만든다. 그 메서드를 부르면 MyBatis가 XML에서 이름이 같은 SQL을 찾아 실행한다.

<select id="findUsers" parameterType="SearchCondition" resultType="User">
  SELECT u.* FROM users u
  WHERE 1=1
  <if test="userName != null">
    AND u.name LIKE CONCAT('%', #{userName}, '%')
  </if>
  <if test="userStatus != null">
    AND u.status = #{userStatus}
  </if>
</select>

한 줄씩 따라가면 이렇다.

  • <select id="findUsers" ...> — 이 SQL의 이름이 findUsers다. parameterType은 검색 조건을 담아 넘기는 자바 객체(SearchCondition), resultType은 결과 행 하나를 담을 자바 클래스(User)다
  • SELECT u.* FROM users u — users 테이블에서 모든 컬럼(*)을 가져온다. 뒤의 u는 테이블 별칭이라 u.name은 「users 테이블의 name 컬럼」이다
  • WHERE 1=1 — 1은 늘 1과 같으니 아무것도 거르지 않는다. 뒤에 붙는 조건이 모두 AND로 시작할 수 있게 깔아 둔 받침이다. 조건이 하나도 안 붙어도 WHERE 뒤가 비어 문법이 깨지는 일이 없다
  • <if test="userName != null"> — SearchCondition의 userName 필드가 null이 아닐 때만 안쪽 줄을 SQL에 붙인다. 자바의 if와 같다
  • u.name LIKE CONCAT('%', #{userName}, '%') — LIKE는 「이 모양을 포함하는가」를 본다. CONCAT은 글자를 이어 붙여 %값% 모양을 만든다. #{userName}은 자바 객체의 값이 들어갈 자리다. 실제로 DB에 보낼 때는 이 자리가 ?로 바뀌고 값은 따로 실려 간다(아래 로그에서 직접 보인다). 값을 SQL 글자에 바로 이어 붙이지 않으니, 사용자가 입력한 값이 SQL 명령으로 바뀌어 실행되는 SQL 인젝션을 막는다. MyBatis 설명서도 #{}는 PreparedStatement의 ? 자리로 안전하게 넣고, 값을 그대로 이어 붙이는 ${}에 사용자 입력을 넣으면 SQL 인젝션이 생길 수 있다고 적는다
  • u.status = #{userStatus} — userStatus 값과 같은 행만 고른다

조건이 이 정도면 충분하다. 하지만 여러 테이블을 잇고 조건이 많아지면 사정이 달라진다.

관리자 주문 목록 조회가 전형적인 장면이다

관리자 페이지에서 주문 목록을 조회하는 경우다.

<select id="findOrders" parameterType="OrderSearchCondition" resultType="OrderVO">
  SELECT
    o.order_id, o.order_date, o.total_amount,
    c.customer_name, c.customer_email,
    p.product_name, p.category
  FROM orders o
  LEFT JOIN customers c ON o.customer_id = c.customer_id
  LEFT JOIN order_items oi ON o.order_id = oi.order_id
  LEFT JOIN products p ON oi.product_id = p.product_id
  WHERE 1=1
  <if test="customerName != null">
    AND c.customer_name LIKE CONCAT('%', #{customerName}, '%')
  </if>
  <if test="productCategory != null">
    AND p.category = #{productCategory}
  </if>
</select>
  • resultType="OrderVO" — 결과 행 하나를 OrderVO 객체 하나에 담는다. VO는 값을 담는 객체라는 뜻으로 붙인 이름이다
  • FROM orders o — 주문 테이블을 o라고 부른다. 그 아래 c · oi · p도 같은 방식으로 각 줄에서 정한 별칭이다
  • LEFT JOIN customers c ON o.customer_id = c.customer_id — 주문마다 customer_id가 같은 고객 행을 찾아 옆에 붙인다. LEFT JOIN이라 짝이 되는 고객이 없어도 주문 행은 남고, 고객 컬럼만 NULL이 된다
  • 나머지 두 LEFT JOIN — 주문 → 주문 항목 → 상품 순으로 이어 붙인다. 주문 하나에 항목이 셋이면 결과에 그 주문이 세 줄로 나온다
  • WHERE 1=1 뒤의 두 <if> — 고객 이름과 상품 분류 조건을 WHERE 쪽에 붙인다

내가 여기서 불편했던 것은 LEFT JOIN의 뜻이 흐려진다는 점이었다. LEFT JOIN은 「짝이 없어도 주문은 남긴다」는 뜻인데, WHERE에서 오른쪽 테이블 컬럼으로 거르면 짝이 없던 주문(고객 컬럼이 NULL)은 조건을 통과하지 못하고 빠진다. SQL에서 NULL에 LIKE를 걸면 자바처럼 예외가 나는 대신 결과도 NULL(참도 거짓도 아님)이 되고, WHERE는 참인 행만 남기기 때문이다. SQLite에서 SELECT NULL LIKE '%길동%'을 돌려 보면 NULL이 나오고, 고객이 없는 주문은 WHERE를 거친 뒤 결과에서 사라졌다. 그러면

  • 동적으로 붙는 조건에 따라 어떤 주문이 남고 빠지는지가 코드만 봐서는 분명하지 않다
  • 개발자마다 쿼리의 의도를 다르게 읽을 수 있다
  • 나중에 버그가 났을 때 원인을 찾기 어렵다

JOIN ON에 조건을 넣으면 XML이 복잡해지는 대가를 치른다

ON 절에 if 태그를 넣은 모양

그래서 조건을 JOIN ON 쪽으로 옮기는 방식을 검토했다. ON에 두면 고객 조건은 「어떤 고객을 짝으로 붙일지」에만 쓰이고 주문은 LEFT JOIN 뜻대로 남는다. 내가 이쪽으로 기운 더 큰 이유는 앞에서 말한 성능이었다.

<select id="findOrders" parameterType="OrderSearchCondition" resultType="OrderVO">
  SELECT o.order_id, o.order_date, o.total_amount, c.customer_name, p.product_name
  FROM orders o
  LEFT JOIN customers c ON o.customer_id = c.customer_id
    <if test="customerName != null">
      AND c.customer_name LIKE CONCAT('%', #{customerName}, '%')
    </if>
  LEFT JOIN order_items oi ON o.order_id = oi.order_id
  LEFT JOIN products p ON oi.product_id = p.product_id
    <if test="productCategory != null">
      AND p.category = #{productCategory}
    </if>
  WHERE 1=1
</select>
  • ON o.customer_id = c.customer_id 바로 뒤의 <if> — 값이 있으면 ON 조건에 AND c.customer_name LIKE …가 덧붙는다. 「고객 id가 같고, 이름에 그 글자가 들어간 고객」만 짝으로 붙인다는 뜻이 된다
  • 상품 쪽 <if>도 같다. 짝이 되는 상품의 조건에 분류 조건이 붙는다
  • WHERE 1=1 — 이 예제에서는 WHERE에 붙는 조건이 없지만, 뒤에서 WHERE 조건을 더할 자리로 남겨 두었다

내가 본 장점:

  • DB가 조인하는 단계에서 이미 거르니 처리할 행 수가 줄어든다
  • 특히 LEFT JOIN에서는 결과 행 수 자체가 달라진다(이게 무슨 뜻인지는 바로 아래 상자에서 다시 본다)
  • 데이터가 클수록 효과가 뚜렷하다

단점:

  • XML 소스가 복잡해진다
  • ON 절 사이에 <if> 태그가 끼어 읽기 어렵다
  • 어떤 조건이 선택 조건인지 한눈에 안 보인다
  • 코드 리뷰 때 의도를 읽기 어렵다

다시 확인해 보니 ON에 둔 조건은 주문을 거르지 않는다

다시 확인해 보니(2026-10-19) — LEFT JOIN의 ON에 둔 조건은 왼쪽(주문) 행을 줄이지 않는다. 짝으로 붙일 오른쪽(고객) 행만 고르고, 조건에 안 맞으면 주문은 그대로 남긴 채 고객 컬럼을 NULL로 채운다. 반대로 WHERE에 둔 조건은 결과에서 행을 뺀다. 그래서 위 두 쿼리는 같은 답을 주지 않는다.

내 컴퓨터에서 바로 돌릴 수 있는 작은 DB인 SQLite(3.49.1)에 주문 3건(고객 홍길동 · 김철수 · 홍길순)을 넣고, 위 두 쿼리와 같은 모양의 SQL을 '%길동%'으로 직접 돌려 봤다. MyBatis 없이 SQL만 돌린 것이다.

조건 자리 나온 행 (주문 번호, 고객 이름)
WHERE (1, 홍길동) — 홍길동의 주문 1건만 남는다
ON (1, 홍길동), (2, NULL), (3, NULL) — 주문 3건이 다 남고, 이름이 안 맞는 두 건은 고객 이름이 비었다

「결과 행 수 자체가 달라진다」는 장점이 바로 이 차이다. ON으로 옮기는 것은 성능만 바꾸는 최적화가 아니라 화면에 나오는 목록을 바꾸는 일이다. 「이름으로 검색하면 그 고객의 주문만 보인다」를 원했다면 ON으로 옮긴 쿼리는 원하는 답이 아니다. WHERE 쪽이 느리다는 앞의 판단도 이 글에서 재 본 적은 없다.


데이터 규모를 모를 때 나는 선제적 최적화를 골랐다

개발 초기에는 보통 운영 환경에 데이터가 얼마나 쌓일지 모른다. 이때 고를 수 있는 길은 둘이다.

  1. 사후 최적화 — 일단 깔끔하게 쓰고, 성능 문제가 생기면 고친다
  2. 선제적 최적화 — 처음부터 성능을 생각한 모양으로 쓴다

내가 선제적 최적화를 고른 이유는 셋이다.

  • MyBatis의 리팩토링 비용 — JPA는 SQL을 대신 만들어 주지만, MyBatis는 SQL을 매퍼 XML마다 손으로 쓴다. 그래서 같은 모양의 쿼리가 여러 파일에 퍼져 있다
  • 나중에 고치기 어렵다 — 패턴을 바꾸려면 이미 쓴 쿼리를 모두 찾아 하나씩 고쳐야 한다
  • 운영 중인 쿼리를 바꾸는 부담 — 이미 사용자가 쓰고 있는 쿼리를 바꾸면 결과가 달라지지 않는지 조심해야 한다

그래서 성능을 우선하되, 가독성 문제는 다른 방법으로 풀기로 했다.


XML 대신 로그에 찍힌 실행 SQL을 읽기로 했다

읽어야 할 것은 XML이 아니라 실행된 SQL이다

생각을 바꿔 보니 이런 결론이 나왔다.

실제로 읽어야 하는 것은 「실행된 SQL」이지, 「XML 매핑 코드」가 아니다.

로그는 프로그램이 도는 동안 무슨 일이 있었는지 콘솔이나 파일에 한 줄씩 남기는 기록이다. MyBatis는 설정만 해 주면 실제로 DB에 보낸 SQL을 로그로 찍는다. <if>가 다 풀린 뒤의 SQL이라 XML보다 읽기 쉽다.

매퍼 이름으로 DEBUG를 켜야 SQL이 찍힌다

Spring Boot를 쓰면 설정은 application.yml(YAML 형식) 또는 같은 내용을 키=값으로 적는 application.properties에 한다. 둘 중 하나만 쓰면 된다.

먼저 알아 둘 것이 하나 있다. 매퍼 XML은 맨 바깥을 <mapper> 태그가 감싸고, 그 태그의 namespace에 이 파일의 이름표를 단다. 보통 짝이 되는 자바 인터페이스의 전체 이름을 쓴다.

<mapper namespace="com.example.order.mapper.OrderMapper">
  <select id="findOrders" ...> ... </select>
</mapper>

MyBatis는 이 둘을 이어 com.example.order.mapper.OrderMapper.findOrders를 그 SQL의 전체 이름으로 쓴다.

고친 곳(2026-10-19) — 처음에는 아래처럼 org.apache.ibatis 쪽에 DEBUG를 켜라고 적었다. 이 저장소에 받아 둔 MyBatis 소스를 읽어 보니 이 설정으로는 SQL이 찍히지 않는다. org.apache.ibatis는 MyBatis 자신의 클래스들이 들어 있는 패키지 이름이고, SQL을 찍는 로거의 이름은 SQL마다 따로, 위의 전체 이름(namespace + . + select의 id)이다(SQL 하나의 정보를 담는 MappedStatement 클래스가 그 이름으로 로거를 만든다). 두 번째 줄의 DefaultSqlSession(MyBatis가 SQL 실행 요청을 받는 클래스)에는 로거가 아예 없다.

# 처음에 적었던 설정 — 이것으로는 SQL이 찍히지 않는다
logging:
  level:
    org.apache.ibatis: DEBUG
    org.apache.ibatis.session.defaults.DefaultSqlSession: DEBUG
# SQL이 찍히는 설정 — 내 매퍼들이 있는 패키지 이름을 적는다
logging:
  level:
    com.example.order.mapper: DEBUG
# 같은 설정을 application.properties로 적으면
logging.level.com.example.order.mapper=DEBUG
  • logging.level.<이름>: DEBUG — 그 이름의 로거가 DEBUG 이상의 로그를 찍게 한다. 로그 레벨은 낮은 쪽부터 TRACE < DEBUG < INFO < WARN < ERROR 순이고, DEBUG는 개발할 때만 보는 자잘한 기록이다. 레벨을 DEBUG로 두면 TRACE는 찍히지 않는다
  • com.example.order.mapper — 예시 이름이다. 매퍼 XML의 namespace가 com.example.order.mapper.OrderMapper라면 findOrders의 SQL 로거 이름은 com.example.order.mapper.OrderMapper.findOrders다. 로거 이름은 점(.)으로 이어진 계층이라, 레벨을 따로 정하지 않은 로거는 가장 가까운 윗 이름의 레벨을 물려받는다. 그래서 패키지 이름 하나에 DEBUG를 켜면 그 아래 매퍼의 SQL이 전부 찍힌다

이렇게 설정하고 실행하면 이런 로그가 나온다.

==>  Preparing: SELECT ... FROM orders o LEFT JOIN customers c ON o.customer_id = c.customer_id AND c.customer_name LIKE CONCAT('%', ?, '%') ...
==> Parameters: 길동(String)
  • ==> — DB로 보내는 쪽이라는 표시다
  • Preparing: — DB에 보낼 SQL 틀이다. #{customerName} 자리가 ?로 바뀌어 있다. 줄바꿈과 겹친 공백은 한 칸으로 줄여 한 줄로 찍는다
  • Parameters: — ? 자리에 순서대로 들어갈 값과 그 자바 타입이다. 값이 여럿이면 쉼표로 이어진다

레벨을 TRACE로 올리면 DB가 돌려준 결과도 <== Columns: …(컬럼 이름)와 <== Row: …(행 하나의 값) 줄로 찍힌다. DEBUG에서는 이 두 줄이 안 나온다(결과를 찍는 MyBatis 코드가 레벨이 TRACE일 때만 찍게 되어 있다).

로그로 검증하는 순서

이제 이 순서를 따른다.

  1. 쿼리 작성 — 조건을 뜻에 맞는 자리에 둔다. 결과를 거르는 조건은 WHERE, 짝으로 붙일 행만 고르는 조건은 ON이다(처음엔 「성능을 우선해 JOIN ON에 조건을 둔다」였다 — 맨 위 「먼저 알려 둔다」)
  2. 로그 확인 — 실제로 실행된 SQL을 본다
  3. SQL 분석 — 로그의 SQL은 <if>가 풀린 보통 SQL이라 XML보다 읽기 쉽다. 한 줄로 길게 찍히니 필요하면 줄을 나눠 놓고 본다
  4. 성능 검증 — EXPLAIN 같은 명령으로 DB가 그 쿼리를 어떤 순서로 처리할지(쿼리 계획) 본다
  5. 반복 개선 — 성능에 문제가 있으면 고친다

주문 검색 쿼리를 성능 쪽으로 바꾼 예제

Before — 가독성만 생각한 방식

<select id="findFilteredOrders" parameterType="FilterCondition" resultType="OrderVO">
  SELECT o.order_id, o.order_date, c.customer_name, p.product_name
  FROM orders o
  LEFT JOIN customers c ON o.customer_id = c.customer_id
  LEFT JOIN order_items oi ON o.order_id = oi.order_id
  LEFT JOIN products p ON oi.product_id = p.product_id
  WHERE 1=1
  <if test="customerName != null">
    AND c.customer_name LIKE CONCAT('%', #{customerName}, '%')
  </if>
  <if test="productCategory != null">
    AND p.category = #{productCategory}
  </if>
  <if test="orderDateFrom != null">
    AND o.order_date >= #{orderDateFrom}
  </if>
</select>

세 조건(고객 이름 · 상품 분류 · 주문일 하한)이 전부 WHERE에 있다. o.order_date >= #{orderDateFrom}은 그 날짜 이후의 주문만 고른다.

내가 본 문제: 데이터가 커지면 LEFT JOIN이 일단 붙인 행들을 DB가 다 들고 있다가 WHERE에서 버린다고 봤다.

After — 성능을 생각한 방식

<select id="findFilteredOrders" parameterType="FilterCondition" resultType="OrderVO">
  SELECT o.order_id, o.order_date, c.customer_name, p.product_name
  FROM orders o
  LEFT JOIN customers c ON o.customer_id = c.customer_id
    <if test="customerName != null">
      AND c.customer_name LIKE CONCAT('%', #{customerName}, '%')
    </if>
  LEFT JOIN order_items oi ON o.order_id = oi.order_id
  LEFT JOIN products p ON oi.product_id = p.product_id
    <if test="productCategory != null">
      AND p.category = #{productCategory}
    </if>
  WHERE 1=1
  <if test="orderDateFrom != null">
    AND o.order_date >= #{orderDateFrom}
  </if>
</select>

고객 이름과 상품 분류는 ON으로 옮겼고, 주문일은 주문 테이블 자신의 컬럼이라 WHERE에 남겼다.

내가 기대한 개선: 조인 단계에서 이미 거르니 처리할 데이터가 줄어든다.

단, 앞의 「다시 확인해 보니」 상자대로 Before와 After는 같은 목록을 주지 않는다. Before는 이름이 맞는 고객의 주문만 돌려주고, After는 주문일 조건만 맞으면 모든 주문을 돌려주되 이름이 안 맞는 주문의 고객 이름을 비워 둔다.

로그로 확인한 실행 SQL

개발 중에는 이 SQL을 본다. 실제 로그는 앞에서 본 대로 ?와 Parameters: 줄로 나뉘어 찍힌다. 예를 들어 customerName = '홍길동', productCategory = '전자제품', orderDateFrom = '2025-01-01'이면 이렇다. Parameters: 줄은 값 뒤 괄호에 그 값의 자바 타입을 찍는다. 여기서는 orderDateFrom 필드가 String이라고 쳤고, 필드가 LocalDate라면 2025-01-01(LocalDate)로 찍힌다.

==>  Preparing: SELECT o.order_id, o.order_date, c.customer_name, p.product_name FROM orders o LEFT JOIN customers c ON o.customer_id = c.customer_id AND c.customer_name LIKE CONCAT('%', ?, '%') LEFT JOIN order_items oi ON o.order_id = oi.order_id LEFT JOIN products p ON oi.product_id = p.product_id AND p.category = ? WHERE 1=1 AND o.order_date >= ?
==> Parameters: 홍길동(String), 전자제품(String), 2025-01-01(String)

아래는 그 로그의 ?에 Parameters: 값을 내가 손으로 넣고 줄을 나눠 본 모양이다. 로그가 이렇게 찍히는 것은 아니다.

SELECT o.order_id, o.order_date, c.customer_name, p.product_name
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.customer_id AND c.customer_name LIKE '%홍길동%'
LEFT JOIN order_items oi ON o.order_id = oi.order_id
LEFT JOIN products p ON oi.product_id = p.product_id AND p.category = '전자제품'
WHERE 1=1 AND o.order_date >= '2025-01-01'

실행된 SQL을 읽으면:

  • <if>가 사라져 로직이 바로 보인다
  • 조인 조건(ON 뒤 — 어떤 행을 짝으로 붙일지)과 필터 조건(WHERE 뒤 — 어떤 행을 결과에 남길지)이 갈려 보인다
  • 내가 의도한 자리에 조건이 붙었는지 확인할 수 있다

조건이 많아질 때 XML을 정리하는 세 가지 방법

JOIN ON에 조건이 많아지면 나는 다음 방법으로 읽기 쉽게 만들었다.

1. sql과 include로 조건을 떼어 낸다

<!-- 같은 매퍼 XML 안에 조각을 정의한다 -->
<sql id="customerNameCondition">
  <if test="customerName != null">
    AND c.customer_name LIKE CONCAT('%', #{customerName}, '%')
  </if>
</sql>

<!-- 메인 쿼리에서 끼워 쓴다 -->
<select id="findFilteredOrders" parameterType="FilterCondition" resultType="OrderVO">
  SELECT o.order_id, o.order_date, c.customer_name
  FROM orders o
  LEFT JOIN customers c ON o.customer_id = c.customer_id
    <include refid="customerNameCondition" />
  <!-- (나머지 JOIN과 WHERE는 앞 예제와 같아서 줄였다) -->
</select>
  • <sql id="customerNameCondition"> — 그 자체로는 실행되지 않는 SQL 조각에 이름을 붙여 둔다. <select>는 실행할 SQL 하나, <sql>은 끼워 넣을 조각이다
  • <include refid="customerNameCondition" /> — refid에 적은 이름의 조각을 이 자리에 그대로 붙여 넣는다. 그래서 실행되는 SQL은 앞의 After 쿼리와 같다

처음에는 주석에 「별도 SQL 파일에 정의」라고 적었다. MyBatis 소스를 읽어 보니, 이 예제처럼 refid에 점(.) 없이 이름만 쓰면 MyBatis는 지금 매퍼의 namespace를 앞에 붙여 조각을 찾는다. 그래서 이 예제 그대로라면 조각이 같은 매퍼 XML 안에 있어야 한다. 다른 파일에 둔 조각을 쓰려면 refid에 그 파일의 namespace까지 붙인 전체 이름(점이 들어간 이름)을 적는다 — 점이 있으면 MyBatis가 namespace를 덧붙이지 않고 그대로 찾는다. 예제는 이름만 쓰므로 주석을 「같은 매퍼 XML 안」으로 고쳤다.

2. 복잡한 조건은 서브쿼리로 떼어 낸다

<select id="findFilteredOrders" parameterType="FilterCondition" resultType="OrderVO">
  SELECT o.order_id, o.order_date, c.customer_name, filtered_items.product_name
  FROM orders o
  LEFT JOIN customers c ON o.customer_id = c.customer_id
  LEFT JOIN (
    SELECT oi.order_id, p.product_name, p.category
    FROM order_items oi
    JOIN products p ON oi.product_id = p.product_id
    <if test="productCategory != null">
      WHERE p.category = #{productCategory}
    </if>
  ) filtered_items ON o.order_id = filtered_items.order_id
  WHERE 1=1
  <if test="orderDateFrom != null">
    AND o.order_date >= #{orderDateFrom}
  </if>
</select>
  • 괄호 안의 SELECT — 서브쿼리다. 주문 항목과 상품을 먼저 이어 붙이고, 분류 조건이 있으면 거기서 거른다
  • 괄호 안의 JOIN — LEFT가 없는 그냥 JOIN(INNER JOIN)이라 짝이 되는 상품이 있는 항목만 남는다
  • ) filtered_items ON … — 괄호의 결과에 filtered_items라는 이름을 붙이고, 테이블처럼 주문과 LEFT JOIN 한다
  • SELECT … filtered_items.product_name — 바깥에서는 괄호 안의 별칭 p가 안 보이므로 filtered_items의 컬럼으로 부른다

고친 곳(2026-10-19) — 처음에는 바깥 SELECT에 p.name을 적었다. 바깥 쿼리는 괄호 안의 p를 볼 수 없어서 이대로는 실행되지 않는다. 이번에 예제들의 컬럼 이름을 product_name으로 맞춘 뒤 SQLite에서 p.product_name으로 돌려 보니 no such column: p.product_name(그런 컬럼이 없다) 오류가 났고, filtered_items.product_name으로 바꾸니 돌았다.

상품 분류 조건이 괄호 안 한곳에 모여서, ON 절 사이에 <if>가 끼던 문제가 사라진다. 이 경우도 서브쿼리를 LEFT JOIN 했기 때문에 분류가 안 맞는 주문은 빠지지 않고 상품 이름만 비어 나온다.

3. 결과 행까지 로그로 보려면 TRACE를 켠다

고친 곳(2026-10-19) — 처음에는 「더 자세한 로깅」으로 아래 설정을 적었다. 소스를 읽어 보니 SimpleExecutor(MyBatis 안에서 SQL을 실제로 DB에 보내는 클래스)에는 자기 이름의 로거가 없어서 이 줄은 아무것도 바꾸지 않는다. SQL은 앞에서 본 매퍼 이름의 로거로 찍힌다. org.mybatis.spring.SqlSessionUtils(MyBatis를 Spring에 붙여 주는 라이브러리의 클래스)의 DEBUG는 「SqlSession(MyBatis가 DB와 이야기하는 창구 객체)을 만들었다 · 닫았다」 같은 소식만 찍고 SQL은 찍지 않는다.

# 처음에 적었던 설정 — SQL을 더 자세히 보여 주지 않는다
logging:
  level:
    org.apache.ibatis.executor.SimpleExecutor: TRACE
    org.mybatis.spring.SqlSessionUtils: DEBUG

결과 행까지 보고 싶으면 SQL을 찍는 매퍼 로거의 레벨을 TRACE로 올린다.

logging:
  level:
    com.example.order.mapper: TRACE
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
  • com.example.order.mapper: TRACE — 앞의 Preparing · Parameters 줄에 더해 Columns · Row 줄도 찍힌다
  • pattern.console — 콘솔에 찍는 한 줄의 모양이다. %d{yyyy-MM-dd HH:mm:ss}는 그 로그가 찍힌 날짜와 시각, %msg는 로그 내용, %n은 줄바꿈이다. 이 기호들은 Spring Boot가 기본으로 쓰는 로그 라이브러리인 Logback의 설명서에서 확인했다. 이렇게 줄이면 시각과 내용만 남고 어느 로거가 찍었는지는 안 보인다

팀에 제안한 동적 쿼리 작성 원칙

  1. 조건 자리는 뜻으로 정한다
    • 결과를 거르는 조건(검색 필터)은 WHERE에 둔다
    • 짝이 없어도 왼쪽 행은 남기고, 붙일 짝만 고르는 조건은 LEFT JOIN의 ON에 둔다
    • 처음 제안은 「성능 우선 사고 — 조인 조건에 필터를 두어 불필요한 데이터 처리를 줄인다」였다. ON으로 옮기면 목록이 바뀐다는 것을 확인하고 고쳤다(「다시 확인해 보니」 상자)
  2. 로그 기반 검증
    • 개발 중에는 실행된 SQL을 늘 확인한다
    • 로그의 SQL을 읽고 의도한 자리에 조건이 붙었는지 알아볼 수 있으면 된 것으로 본다
  3. 복잡성 관리
    • 조건이 5개 이상이면 <include>나 서브쿼리로 떼어 낸다. 5개는 내가 경험으로 정한 선이고 잰 근거는 없다
    • 코드 리뷰 때 실행된 SQL을 같이 본다
  4. 성능 검증
    • 큰 데이터에 대해서는 EXPLAIN으로 쿼리 계획을 본다
    • 인덱스를 쓰는지 확인한다

결론

조건을 어디에 둘지는 성능이 아니라 뜻이 정한다. 처음 이 글의 고민은 「성능(ON)과 가독성(WHERE) 중 무엇을 고르나」였는데, 두 자리는 같은 답을 주는 두 가지 쓰는 법이 아니었다. LEFT JOIN에서 둘은 다른 목록을 돌려준다.

  • 결과를 거르는 조건은 WHERE — 「이름에 ‘길동’이 들어간 고객의 주문만」처럼 화면에 나올 행을 줄이는 조건이다. 관리자 검색 필터는 대부분 이쪽이다
  • 짝으로 붙일 행만 고르는 조건은 ON — 「주문은 다 보이되, 고객 정보는 이 조건에 맞을 때만 붙인다」처럼 왼쪽 행은 남겨야 할 때다
  • 성능은 짐작하지 않고 잰다 — 「ON이 빠르다」는 처음 판단은 이 글에서 잰 적이 없다. 실제 처리 순서는 DB의 옵티마이저가 정하므로, 느리다고 느껴지면 EXPLAIN으로 쿼리 계획을 보고 인덱스부터 확인한다
  • XML이 복잡해지면 실행 SQL을 읽는다 — 이것은 처음 결론에서 그대로 남는다. <if>가 끼어 XML이 읽기 어려워도, 로그에 찍힌 실행 SQL을 보면 조건이 의도한 자리에 붙었는지 알 수 있다

처음 결론은 「XML 소스는 성능을 우선해 JOIN ON에 조건을 두고, 잃은 가독성은 실행 SQL을 읽어 메운다」였다. 버린 이유는 그 결론이 깨지는 장면이 분명해서다. 고객 이름으로 검색하는 화면에 이 결론을 따르면, 검색어와 상관없이 모든 주문이 나오고 고객 이름 칸만 빈다. 성능을 얻은 것이 아니라 검색이 동작하지 않는 것이다. 이 틀린 결론을 지금까지 붙들고 있었던 것은 「같은 결과를 더 빨리 얻는다」는 전제를 확인하지 않았기 때문이다. 그 전제를 SQL 한 번 돌려 보는 것으로 확인할 수 있었다(「다시 확인해 보니」 상자).

대가도 있다. 검색 조건을 WHERE에 두면 LEFT JOIN으로 남기려던 짝 없는 주문(고객 행이 없는 주문)도 검색 결과에서 빠진다. 검색 화면에서는 그게 맞는 동작이지만, 「짝이 없는 주문도 늘 보여야 한다」는 화면이라면 조건을 따로 설계해야 한다.


다른 사람은 이 문제를 어떻게 푸는지 궁금하다

MyBatis로 복잡한 동적 쿼리를 다루며 비슷한 고민을 한 사람이 있다면, 다른 방식으로 풀고 있는지 궁금하다.

  • 지금 쓰는 MyBatis 쿼리 패턴은 무엇인가
  • 성능과 가독성 문제를 어떻게 푸는가
  • 쓰고 있는 팁이 있는가

댓글로 남겨 주면 다음에 이 글을 고칠 때 참고하겠다.


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

댓글남기기