MyBatis WHERE·SELECT 공통화는 단순한 코드 정리 기법이 아니라, 운영 환경의 휴먼 에러를 줄이는 설계 결정입니다. MyBatis는 SQL을 직접 작성하는 만큼 XML이 빠르게 커지고, 같은 WHERE 절이 row 조회와 count 조회에 두 번씩 들어가며, 같은 컬럼 리스트가 여러 SELECT문에 반복됩니다. 이 상태에서 새 조건 하나만 추가하려 해도 두 곳을 동시에 수정해야 하고, 한 곳을 빠뜨리는 순간 목록과 카운트가 어긋나는 데이터 정합성 사고가 발생합니다.
이 글에서는 MyBatis의 <sql>과 <include> 태그를 활용한 WHERE 공통화·SELECT 컬럼 공통화·정렬과 페이징 공통화 세 가지 실전 패턴을 정리합니다. 공통화 전후 코드 비교와, 운영에서 자주 빠지는 5가지 함정까지 함께 살펴보시기 바랍니다.
MyBatis 공통화가 필요한 순간
다음과 같은 상황은 거의 모든 MyBatis 기반 프로젝트에서 마주칩니다.
| 상황 | 발생하는 문제 |
|---|---|
| 페이징 조회 (row + count 한 쌍) | 같은 WHERE 절이 두 쿼리에 동시 존재 |
| 같은 테이블의 다중 조회 API | 같은 컬럼셋이 여러 SELECT에 반복 |
| 동적 검색 조건 다수 | 같은 <if> 블록이 반복 |
| 정렬·페이징 처리 | LIMIT/OFFSET 구문이 모든 목록 쿼리에 반복 |
이런 반복은 단순히 코드량 문제로 끝나지 않습니다. 더 큰 위험은 변경 시점에 발생하는 휴먼 에러입니다.
휴먼 에러 시나리오
운영 중에 다음과 같은 사고가 자주 발생합니다.
새 검색 조건 “활성 사용자만 보기”가 추가되었습니다. 개발자는 row 조회 쿼리에
AND status = 'ACTIVE'조건을 추가했지만, count 쿼리는 깜빡 잊고 건드리지 않았습니다. 결과적으로 화면에는 10건이 보이는데 “총 25건 중”이라는 카운트가 나옵니다. QA 단계에서는 발견되지 않았다가, 운영 첫날 사용자 문의로 드러납니다.
이 사고의 본질은 같은 조건이 두 곳에 따로 존재하는 구조 자체에 있습니다. 한 곳만 고치면 자동으로 다른 곳이 따라오는 구조였다면 처음부터 발생하지 않을 사고입니다.
패턴 1: WHERE 절 공통화로 row와 count 동시 관리
가장 효과가 큰 공통화는 단연 WHERE 절 공통화입니다. 페이징이 필요한 모든 목록 조회 API에 그대로 적용됩니다.
공통화 전 — 같은 조건이 두 번
<select id="findOrders" parameterType="OrderSearch" resultType="Order">
SELECT order_id, customer_name, amount, status, created_at
FROM orders
WHERE 1=1
<if test="customerName != null and customerName != ''">
AND customer_name LIKE CONCAT('%', #{customerName}, '%')
</if>
<if test="status != null">
AND status = #{status}
</if>
<if test="startDate != null">
AND created_at >= #{startDate}
</if>
ORDER BY created_at DESC
LIMIT #{offset}, #{size}
</select>
<select id="countOrders" parameterType="OrderSearch" resultType="long">
SELECT COUNT(*)
FROM orders
WHERE 1=1
<if test="customerName != null and customerName != ''">
AND customer_name LIKE CONCAT('%', #{customerName}, '%')
</if>
<if test="status != null">
AND status = #{status}
</if>
<if test="startDate != null">
AND created_at >= #{startDate}
</if>
</select>
<if> 블록 3개가 그대로 두 번 반복됩니다. 조건이 늘어날수록 차이가 더 벌어지고, 빠뜨릴 가능성도 같이 커집니다.
공통화 후 — <sql> + <include>
<!-- 공통 WHERE 절을 sql 태그로 추출 -->
<sql id="searchCondition">
WHERE 1=1
<if test="customerName != null and customerName != ''">
AND customer_name LIKE CONCAT('%', #{customerName}, '%')
</if>
<if test="status != null">
AND status = #{status}
</if>
<if test="startDate != null">
AND created_at >= #{startDate}
</if>
</sql>
<select id="findOrders" parameterType="OrderSearch" resultType="Order">
SELECT order_id, customer_name, amount, status, created_at
FROM orders
<include refid="searchCondition"/>
ORDER BY created_at DESC
LIMIT #{offset}, #{size}
</select>
<select id="countOrders" parameterType="OrderSearch" resultType="long">
SELECT COUNT(*)
FROM orders
<include refid="searchCondition"/>
</select>
이렇게 바꾸면 새 검색 조건 추가는 searchCondition 한 곳에만 추가하면 끝납니다. row와 count가 자동으로 함께 적용되므로, 앞서 본 휴먼 에러 자체가 구조적으로 발생할 수 없게 됩니다.
적용 시점
WHERE 절 공통화는 다음 조건일 때 즉시 적용하는 것이 좋습니다.
- 같은 테이블·같은 조건으로 row와 count를 함께 조회하는 경우
- 동적 조건(
<if>)이 2개 이상인 경우 - 검색 조건이 앞으로 확장될 가능성이 있는 경우
조건이 단 한 줄이고 앞으로도 그럴 것이 분명하다면 굳이 공통화하지 않아도 됩니다. 공통화의 가치는 변경 가능성에서 나옵니다.
패턴 2: SELECT 컬럼 공통화로 XML 부피 감소
목록 조회·상세 조회·내보내기(export) API 등이 같은 컬럼셋을 사용하는 경우 매우 흔합니다. 컬럼이 20개를 넘어가면 XML 가독성이 빠르게 떨어집니다.
공통화 전 — 같은 컬럼 리스트가 여러 곳에
<select id="findOrder" resultType="Order">
SELECT order_id, customer_id, customer_name, customer_phone,
product_id, product_name, quantity, unit_price, total_amount,
status, payment_method, shipping_address, memo,
created_at, created_by, updated_at, updated_by
FROM orders
WHERE order_id = #{orderId}
</select>
<select id="findOrders" resultType="Order">
SELECT order_id, customer_id, customer_name, customer_phone,
product_id, product_name, quantity, unit_price, total_amount,
status, payment_method, shipping_address, memo,
created_at, created_by, updated_at, updated_by
FROM orders
<include refid="searchCondition"/>
ORDER BY created_at DESC
</select>
컬럼 17개가 그대로 두 SELECT문에 반복됩니다. 새 컬럼 추가 시 모든 SELECT문을 수정해야 하고, 한 곳이라도 빠뜨리면 그쪽 API만 컬럼이 누락됩니다.
공통화 후 — 컬럼 셋도 sql 태그로
<sql id="orderColumns">
order_id, customer_id, customer_name, customer_phone,
product_id, product_name, quantity, unit_price, total_amount,
status, payment_method, shipping_address, memo,
created_at, created_by, updated_at, updated_by
</sql>
<select id="findOrder" resultType="Order">
SELECT <include refid="orderColumns"/>
FROM orders
WHERE order_id = #{orderId}
</select>
<select id="findOrders" resultType="Order">
SELECT <include refid="orderColumns"/>
FROM orders
<include refid="searchCondition"/>
ORDER BY created_at DESC
</select>
각 SELECT문이 두 줄로 줄어들었고, 컬럼 추가는 orderColumns 한 곳에만 하면 됩니다.
별칭(alias)과의 결합
조인이 들어가면 테이블 별칭이 필요해집니다. 이 경우 <sql>에 별칭을 함께 정의합니다.
<sql id="orderColumnsWithAlias">
o.order_id, o.customer_id, o.customer_name,
o.product_id, o.product_name, o.quantity, o.total_amount,
o.status, o.created_at,
c.email AS customer_email, c.grade AS customer_grade
</sql>
<select id="findOrdersWithCustomer" resultType="OrderWithCustomer">
SELECT <include refid="orderColumnsWithAlias"/>
FROM orders o
LEFT JOIN customers c ON c.customer_id = o.customer_id
<include refid="searchCondition"/>
</select>
알리아스를 포함해 정의해 두면, 같은 조인 구조를 쓰는 다른 쿼리에서도 그대로 재사용할 수 있습니다.
패턴 3: 정렬·페이징·집계 절 공통화
목록 쿼리에는 거의 항상 같은 모양의 정렬·페이징 절이 붙습니다. 이것도 공통화 대상입니다.
<sql id="defaultOrderBy">
ORDER BY created_at DESC, order_id DESC
</sql>
<sql id="paging">
LIMIT #{offset}, #{size}
</sql>
<select id="findOrders" resultType="Order">
SELECT <include refid="orderColumns"/>
FROM orders
<include refid="searchCondition"/>
<include refid="defaultOrderBy"/>
<include refid="paging"/>
</select>
여기서 한 걸음 더 나아가 동적 정렬까지 공통화할 수도 있습니다.
<sql id="dynamicOrderBy">
<choose>
<when test="sortBy == 'amount'">ORDER BY total_amount DESC</when>
<when test="sortBy == 'customer'">ORDER BY customer_name ASC</when>
<otherwise>ORDER BY created_at DESC, order_id DESC</otherwise>
</choose>
</sql>
호출 측은 sortBy 파라미터만 넘기면 되고, 모든 목록 쿼리가 일관된 정렬 규칙을 따르게 됩니다.
공통화 시 자주 빠지는 5가지 함정
공통화는 강력하지만 잘못 적용하면 가독성과 디버깅을 모두 해칠 수 있습니다.
함정 1: 너무 잘게 쪼개면 가독성이 떨어진다
<sql> 조각이 10개씩 되면, 한 쿼리를 이해하려고 매번 여러 조각을 따라가야 합니다. 한 쿼리의 큰 줄기는 한눈에 보이도록 유지하시기 바랍니다. 보통 SELECT 컬럼·WHERE·정렬·페이징 정도의 4~5개 조각이 적정선입니다.
함정 2: namespace가 다르면 include가 안 된다
다른 매퍼 파일의 <sql>을 참조하려면 풀 경로를 명시해야 합니다.
<!-- 같은 매퍼 내 -->
<include refid="searchCondition"/>
<!-- 다른 매퍼의 sql 참조 -->
<include refid="com.example.mapper.CommonMapper.dateRange"/>
가능하면 매퍼 단위로 공통화를 우선 적용하고, 정말 범용적인 조각만 별도 CommonMapper.xml로 분리합니다.
함정 3: 파라미터 이름 충돌
<sql> 안에서 사용하는 #{name}은 호출 측 컨텍스트의 파라미터를 그대로 참조합니다. 호출 측 DTO 필드명과 다르면 그대로 NPE 또는 매핑 실패가 발생합니다. 공통 조각이 의존하는 파라미터를 주석으로 명시하시기 바랍니다.
<!-- requires: customerName, status, startDate -->
<sql id="searchCondition">
...
</sql>
함정 4: 동적 SQL 안에서 공통화하면 디버깅이 어려워진다
여러 단계의 <if>·<choose> 안에 <include>가 깊이 중첩되면, 실제로 생성되는 SQL을 머릿속으로 따라가기 어렵습니다. MyBatis 로그 레벨을 DEBUG로 두고 생성된 최종 SQL을 항상 확인하시기 바랍니다.
logging:
level:
com.example.mapper: DEBUG
이전 글에서 다룬 디버깅 사고방식(값의 흐름 추적) 이 그대로 적용됩니다. 추측하지 말고 로그로 확인합니다.
함정 5: 추상화가 깊어지면 SQL 가독성이 손실된다
공통화는 도구이지 목적이 아닙니다. SQL이 한눈에 안 들어오면 공통화를 풀어 다시 펼쳐 두는 것이 정답일 때가 많습니다. 특히 복잡한 단일 쿼리(통계·집계)는 굳이 공통화하지 않는 편이 디버깅과 튜닝에 모두 유리합니다.
실무 운영 체크리스트
다음 항목을 코드 리뷰 단계에서 점검하시기 바랍니다.
첫째, 같은 테이블의 row 조회와 count 조회 WHERE 절이 같은 <sql>을 참조하는지 확인합니다. 따로 작성되어 있다면 즉시 공통화 대상입니다.
둘째, 같은 컬럼 리스트가 2개 이상의 SELECT문에서 반복되고 있는지 확인합니다. 컬럼 추가/삭제 변경 빈도가 높은 테이블일수록 효과가 큽니다.
셋째, 목록 쿼리의 정렬·페이징 절이 통일되어 있는지 확인합니다. 통일되어 있지 않다면 API 간 사용자 경험이 들쭉날쭉해집니다.
넷째, 공통 <sql> 조각의 의존 파라미터가 주석으로 명시되어 있는지 확인합니다. 후임 개발자가 따라가기 쉬워집니다.
다섯째, MyBatis 로그 레벨이 개발 환경에서 DEBUG로 설정되어 있어 생성된 최종 SQL을 항상 확인할 수 있는지 확인합니다.
여섯째, 공통화 조각이 너무 잘게 쪼개져 있지 않은지 확인합니다. 한 쿼리의 큰 흐름이 한눈에 보이는 수준을 유지합니다.
일곱째, 동적 정렬·페이징을 공통 처리하는 표준 패턴이 팀 내에 정해져 있는지 확인합니다. 신규 API 작성 시 같은 패턴을 따르도록 합니다.
자주 묻는 질문 (FAQ)
Q1. JPA와 비교하면 MyBatis 공통화는 왜 필요한가요?
JPA는 JPQL과 메서드 이름 기반 쿼리로 공통화가 어느 정도 내장되어 있지만, MyBatis는 SQL을 직접 작성하므로 공통화를 개발자가 의도적으로 적용해야 합니다. 다만 MyBatis의 장점은 SQL을 100% 통제할 수 있다는 점이고, 공통화 패턴까지 적절히 활용하면 유지보수성과 성능 통제를 모두 잡을 수 있습니다.
Q2. 공통 SQL을 별도 XML 파일로 분리해도 되나요?
가능합니다. 매우 범용적인 조각(예: 공통 날짜 범위 조건, 페이징, 공통 컬럼)은 CommonMapper.xml로 분리하고, 도메인별 조각은 각 매퍼에 두는 2단 구조가 가장 깔끔합니다. 분리한 조각은 풀 경로(com.example.mapper.CommonMapper.id)로 참조합니다.
Q3. PageHelper 같은 페이징 라이브러리를 쓰면 count 공통화가 필요 없지 않나요?
PageHelper는 자동으로 count 쿼리를 생성해 주지만, WHERE 조건이 복잡하면 자동 생성 쿼리가 비효율적일 수 있습니다. 또한 자동 생성 결과가 우리 의도와 다른 경우 디버깅이 어려워집니다. 명시적인 count 쿼리 + WHERE 공통화 패턴이 통제성에서 더 안전합니다.
Q4. 공통 조각이 호출 측의 파라미터에 의존하는 게 깔끔하지 않게 느껴집니다.
MyBatis의 <sql>은 본질적으로 텍스트 치환이므로 파라미터 의존을 완전히 격리할 수는 없습니다. 다만 공통 조각이 의존하는 파라미터를 주석으로 명시하고, 파라미터 객체(DTO)의 필드명을 팀 표준으로 통일하면 의존성을 충분히 관리할 수 있습니다.
Q5. 공통화하다 보면 동적 SQL이 복잡해지는데 한계가 있나요?
있습니다. 분기가 4단계 이상 깊어지거나, <choose> 안에 <include>가 중첩되기 시작하면 가독성과 디버깅 비용이 급격히 올라갑니다. 이 시점이 오면 쿼리를 여러 개로 분리하거나, 자바 코드 단에서 일부 로직을 처리하는 것이 더 깔끔합니다. 모든 분기를 XML로 해결하려 하지 마시기 바랍니다.
마무리
MyBatis WHERE·SELECT 공통화의 본질은 코드량 절감이 아니라 “한 곳을 고치면 자동으로 모든 곳이 따라오는 구조” 를 만들어 휴먼 에러를 구조적으로 차단하는 일입니다. WHERE 절을 공통화하면 row와 count의 조건 불일치 사고가 사라지고, SELECT 컬럼을 공통화하면 컬럼 추가 시 API별 누락 사고가 사라집니다. 정렬·페이징까지 공통화하면 API 간 사용자 경험까지 일관됩니다.
핵심을 다시 정리하면 다음과 같습니다. <sql> + <include> 로 WHERE·SELECT·정렬·페이징을 분리하고, 같은 테이블의 row 조회와 count 조회는 반드시 같은 WHERE 조각을 참조하게 합니다. 다만 너무 잘게 쪼개지 말고, 공통 조각의 의존 파라미터를 주석으로 명시하며, 개발 환경에서는 MyBatis DEBUG 로그로 생성된 최종 SQL을 항상 확인하시기 바랍니다. 공통화는 도구이지 목적이 아니라는 점을 잊지 않는 것이 마지막 원칙입니다.
핵심 요약
- WHERE 절 공통화: row 조회와 count 조회의 조건 불일치 사고를 구조적으로 차단합니다.
- SELECT 컬럼 공통화: 컬럼 추가 시 모든 SELECT문을 따라 수정하던 휴먼 에러를 제거합니다.
- 정렬·페이징 공통화: 모든 목록 API에 일관된 사용자 경험을 보장합니다.
<sql>+<include>가 핵심 도구이며, 다른 매퍼 참조 시에는 풀 경로(namespace.id)를 명시합니다.- 공통화 함정: 너무 잘게 쪼개지 말고, 파라미터 의존을 주석으로 명시하며, 개발 환경에서 DEBUG 로그로 최종 SQL을 항상 확인합니다.
“MyBatis WHERE·SELECT 공통화 실전 패턴”에 대한 1개의 생각