DBA가 쿼리를 직접 검토하고 승인하는 절차가 있는 조직에서는, 실행될 SQL이 파일에 그대로 보이는 편이 관리가 쉽습니다.
이 파트에서 다루는 내용
국내 현장에서 MyBatis 비중은 여전히 큽니다
해외 자료만 보면 JPA가 표준처럼 보이지만, 국내 공공·금융 SI에서는 MyBatis가 여전히 주력입니다. 유행이 아니라 이유가 있습니다.
여러 테이블 조인, 윈도 함수, DB 전용 함수를 쓰는 조회는 SQL로 쓰는 편이 명확하고 튜닝도 직접적입니다.
기존 프로젝트가 MyBatis로 되어 있으면 새 기능도 같은 방식으로 만듭니다. 두 방식이 섞이면 유지보수가 어려워집니다.
SQL에 익숙한 팀에게는 진입 장벽이 낮습니다. JPA는 영속성 컨텍스트 개념을 모르면 예측하지 못한 동작을 만납니다.
매퍼 인터페이스와 SQL을 짝지어 씁니다
MyBatis는 자바 인터페이스와 SQL을 연결합니다. 인터페이스에 메서드를 선언하고, 같은 이름의 SQL을 XML 또는 애노테이션으로 정의합니다.
Spring Boot에서는 MyBatis 측이 제공하는 스타터를 추가하면 매퍼 스캔과 SqlSession 설정이 자동으로 처리됩니다.
// 1) 매퍼 인터페이스
@Mapper
public interface OrderMapper {
OrderSummary findById(@Param("orderId") Long orderId);
List<OrderSummary> search(OrderSearchCondition condition);
int updateStatus(@Param("orderId") Long orderId,
@Param("status") String status);
}
// 2) 설정 (application.yml)
// mybatis:
// mapper-locations: classpath:mapper/**/*.xml
// configuration:
// map-underscore-to-camel-case: truemap-underscore-to-camel-case를 켜면 DB의 order_id 컬럼이 자바의 orderId로 자동 매핑됩니다. 이것만으로도 resultMap 작성이 크게 줄어듭니다.
<mapper namespace="com.example.order.mapper.OrderMapper">
<select id="findById" resultType="com.example.order.dto.OrderSummary">
SELECT o.order_id,
o.status,
o.total_amount,
m.name AS member_name
FROM orders o
JOIN member m ON m.member_id = o.member_id
WHERE o.order_id = #{orderId}
</select>
<update id="updateStatus">
UPDATE orders
SET status = #{status},
updated_at = NOW()
WHERE order_id = #{orderId}
</update>
</mapper>namespace는 매퍼 인터페이스의 전체 경로와 정확히 일치해야 합니다. id는 메서드 이름과 같아야 합니다. 둘 중 하나라도 어긋나면 기동 시점이나 호출 시점에 오류가 납니다.
동적 SQL이 MyBatis의 실질적인 강점입니다
검색 화면처럼 조건이 선택적으로 들어오는 경우, 조건 개수만큼 쿼리를 만들 수는 없습니다. MyBatis는 조건에 따라 SQL을 조립하는 문법을 제공합니다.
- if: 조건이 참일 때만 포함
- where: 불필요한 WHERE와 앞쪽 AND 제거
- choose · when · otherwise: 여러 갈래 중 하나 선택
- foreach: IN 절과 대량 입력 조립
- trim: 접두·접미 문자열 직접 제어
동적 SQL이 길어지면 어떤 조건에서 어떤 쿼리가 나가는지 파악하기 어려워집니다. 분기가 많아지면 조회 목적별로 쿼리를 나누는 편이 낫습니다.
<select id="search" resultType="com.example.order.dto.OrderSummary">
SELECT order_id, status, total_amount, ordered_at
FROM orders
<where>
<if test="status != null">
AND status = #{status}
</if>
<if test="fromDate != null and toDate != null">
AND ordered_at BETWEEN #{fromDate} AND #{toDate}
</if>
<if test="memberIds != null and memberIds.size() > 0">
AND member_id IN
<foreach item="id" collection="memberIds" open="(" separator="," close=")">
#{id}
</foreach>
</if>
</where>
ORDER BY ordered_at DESC
</select>where 태그는 조건이 하나도 없으면 WHERE 자체를 빼고, 첫 조건 앞의 AND도 알아서 제거합니다. 직접 문자열을 이어 붙이는 방식보다 안전합니다.
#{} 와 ${} 를 구분하지 못하면 보안 사고가 납니다
MyBatis에서 값을 넣는 방법은 두 가지입니다. 이름이 비슷하지만 동작이 완전히 다르고, 잘못 쓰면 SQL 인젝션에 그대로 노출됩니다.
PreparedStatement의 물음표 자리로 치환되고 값은 별도로 전달됩니다. 값에 따옴표나 SQL 구문이 들어 있어도 문자열로 처리됩니다.
SQL 문자열에 값을 그대로 끼워 넣습니다. 사용자 입력이 들어가면 임의의 SQL이 실행될 수 있습니다.
정렬 컬럼명이나 테이블명처럼 값이 아닌 식별자를 넣어야 하는 경우입니다. 이때도 사용자 입력을 그대로 쓰지 말고 허용 목록으로 검증합니다.
<!-- 위험: 사용자 입력이 SQL에 그대로 들어갑니다 -->
<select id="searchByName" resultType="Member">
SELECT * FROM member WHERE name = '${name}'
</select>
<!-- name 값으로 ' OR '1'='1 이 들어오면 전체 행이 조회됩니다 -->
<!-- 안전: 값은 바인딩으로 전달합니다 -->
<select id="searchByName" resultType="Member">
SELECT * FROM member WHERE name = #{name}
</select>
<!-- 정렬 컬럼처럼 식별자가 필요하면 허용 목록으로 제한한 값만 넘깁니다 -->
<select id="findAllSorted" resultType="Member">
SELECT * FROM member ORDER BY ${sortColumn} DESC
</select>자바 코드에서 sortColumn을 받을 때 정해진 값 목록에 포함되는지 먼저 검사해야 합니다. 검사 없이 넘기면 위 첫 번째 예시와 같은 문제가 됩니다.
MyBatis 코드를 리뷰할 때 달러 기호 치환이 있으면 무조건 멈추고 확인합니다. 값에 쓰였다면 즉시 바인딩으로 바꾸고, 식별자에 쓰였다면 허용 목록 검증이 있는지 확인합니다. 이 한 가지 습관으로 대부분의 인젝션을 막을 수 있습니다.
JPA와 MyBatis는 목적으로 나눠 씁니다
둘 중 하나만 옳은 것이 아닙니다. 각자 잘하는 영역이 다르고, 한 프로젝트에서 함께 쓰기도 합니다.
- 생성·수정·삭제가 잦은 도메인 로직
- 테이블 구조가 객체 구조와 비슷한 경우
- 반복적인 단순 CRUD
- 변경 감지로 코드가 짧아지는 영역
- 여러 테이블을 조인하는 복잡한 조회
- 통계·집계·리포트 쿼리
- DB 전용 함수나 힌트가 필요한 튜닝
- SQL을 검토·승인하는 절차가 있는 조직
같은 DataSource를 쓰면 트랜잭션이 함께 동작합니다. 변경은 JPA로, 복잡한 조회는 MyBatis로 나누는 구성이 흔합니다.
JPA로 변경한 내용이 아직 flush되지 않은 상태에서 MyBatis로 조회하면 이전 값이 보입니다. 순서가 중요한 로직에서는 flush 시점을 확인해야 합니다.
3.x와 4.x 차이
본문은 현장에서 가장 많이 쓰는 3.x 기준입니다. 4.x에서 달라진 부분만 아래에 정리합니다.
- MyBatis 연동 스타터는 Spring Boot 공식 스타터가 아니라 MyBatis 프로젝트에서 제공합니다. 그래서 Spring Boot 버전을 올릴 때 스타터가 해당 버전을 지원하는지 별도로 확인해야 합니다.
- 3.x로 올릴 때 javax에서 jakarta로 바뀐 영향이 MyBatis 연동에도 미칩니다. 스타터 버전을 맞추지 않으면 기동 단계에서 실패합니다.
- 4.x는 릴리스가 최근이라 연동 라이브러리 대응 시점이 프로젝트마다 다릅니다. 업그레이드 전에 사용 중인 스타터의 지원 버전을 먼저 확인합니다.