MyBatis SQL 매핑
회원 게시판 매퍼 인터페이스·resultMap·동적 XML을 작성하고 #{} 바인딩, 트랜잭션 참여, 생성된 키와 매핑 차이를 H2 통합 테스트로 검증합니다.
MyBatis는 SQL을 직접 제어하면서 실행·매핑 반복을 줄입니다.
그 대신 ${} 치환, 네임스페이스 차이, null과 트랜잭션 연결을 관리해야 합니다.
Mapper 인터페이스
파라미터 이름은 @Param으로 XML과 고정합니다.
컴파일러의 -parameters 옵션이나 리플렉션 이름 우연에 기대지 않습니다.
package board.mybatis;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface PostMapper {
int insertParameter(NewPostParameter parameter);
Optional<PostRow> findById(@Param("id") long id);
List<PostRow> search(
@Param("authorId") long authorId,
@Param("keyword") String keyword,
@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("limit") int limit);
int updateWithVersion(
@Param("id") long id,
@Param("authorId") long authorId,
@Param("expectedVersion") long expectedVersion,
@Param("title") String title,
@Param("content") String content);
record PostRow(
Long id,
long authorId,
String title,
String content,
LocalDate createdOn,
long version
) {
}
}생성된 키 세터는 영속성 파라미터에만 두고 불변 도메인 레코드와 분리합니다.
애노테이션·XML SQL
@Select, @Insert, @Update, @Delete는 매퍼 메서드와 SQL을 한 파일에서 읽게 합니다.
조건 분기가 없고 투영이 작은 조회는 이동할 문맥이 적어 애노테이션 방식이 잘 맞습니다.
반대로 긴 조인, 반복되는 열 매핑, <if>·<foreach>가 필요한 쿼리는 XML이 SQL 구조를 더 선명하게 보여 줍니다.
package board.mybatis;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface PostSummaryQuery {
@Select("""
select id,
title,
content
from posts
where author_id = #{authorId}
order by created_on desc, id desc
limit #{limit}
""")
List<PostSummary> recent(
@Param("authorId") long authorId,
@Param("limit") int limit);
record PostSummary(
long id,
String title,
String content
) {
}
}열 별칭과 레코드 컴포넌트 이름을 정확히 맞추면 작은 투영은 별도 resultMap 없이도 읽을 수 있습니다.
실제 빌드에서 생성자 매핑을 한 번 실행해 컴파일러 옵션이나 MyBatis 버전에 기대는 부분을 확인합니다.
매핑 규칙이 복잡해지는 순간 애노테이션 문자열을 늘리지 말고 이름 기반 resultMap이 있는 XML로 옮깁니다.
같은 매퍼 메서드 ID를 애노테이션과 XML에 동시에 정의하지 않습니다.
SQL의 대표 소스가 둘이면 수정한 쪽과 실행되는 쪽이 달라질 수 있고 시작 과정에서 매핑된 구문 충돌이 납니다.
애노테이션 방식도 #{} 바인딩 원칙은 같으며 ${}로 클라이언트 입력을 이어 붙이지 않습니다.
resultMap과 열 별칭
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="board.mybatis.PostMapper">
<resultMap id="postRow" type="board.mybatis.PostMapper$PostRow">
<constructor>
<arg column="id" javaType="java.lang.Long" />
<arg column="author_id" javaType="long" />
<arg column="title" javaType="java.lang.String" />
<arg column="content" javaType="java.lang.String" />
<arg column="created_on" javaType="java.time.LocalDate" />
<arg column="version" javaType="long" />
</constructor>
</resultMap>
<sql id="postColumns">
id, author_id, title, content, created_on, version
</sql>
<select id="findById" resultMap="postRow">
select <include refid="postColumns" />
from posts
where id = #{id}
</select>
<select id="search" resultMap="postRow">
select <include refid="postColumns" />
from posts
<where>
author_id = #{authorId}
<if test="keyword != null and !keyword.isBlank()">
and (lower(title) like concat('%', lower(#{keyword}), '%')
or lower(content) like concat('%', lower(#{keyword}), '%'))
</if>
<if test="from != null">
and created_on >= #{from}
</if>
<if test="to != null">
and created_on <= #{to}
</if>
</where>
order by created_on desc, id desc
limit #{limit}
</select>
<update id="updateWithVersion">
update posts
set title = #{title},
content = #{content},
version = version + 1
where id = #{id}
and author_id = #{authorId}
and version = #{expectedVersion}
</update>
</mapper><sql>은 자주 반복되는 SQL 텍스트를 이름 붙이고 <include>가 매퍼를 파싱할 때 그 조각을 삽입합니다.
위 postColumns처럼 여러 조회가 반드시 같은 투영을 유지해야 할 때 유용합니다.
Java 메서드 호출이나 런타임 쿼리 빌더가 아니므로 조각 안의 문법은 최종 구문 문맥에서 완전해야 합니다.
재사용 단위는 안정적인 열 목록처럼 작고 의미가 분명해야 합니다.
권한 조건이나 테넌트 조건을 거대한 조각 하나로 숨기면 어떤 쿼리가 어떤 행을 허용하는지 읽기 어려워집니다.
<include>의 속성 치환에도 외부 입력을 전달하지 말고, 별칭이 필요하면 매퍼가 정한 고정 값만 사용합니다.
조각 ID 변경과 열 추가는 이를 포함하는 모든 구문의 매핑 테스트로 확인합니다.
#{title}는 준비된 구문의 파라미터이고 ${title}는 SQL 텍스트 치환입니다.
${}에 클라이언트 값이 들어가면 주입이 됩니다.
ORDER BY처럼 식별자를 바인딩할 수 없을 때도 Java 열거형별 구문을 나누거나 XML <choose>에서 고정 조각만 선택합니다.
XML 네임스페이스, 구문 ID, 메서드 이름은 시작 컨텍스트 테스트에서 함께 검증합니다.
변경 가능한 입력의 격리
package board.mybatis;
import java.time.LocalDate;
public final class NewPostParameter {
private Long id;
private final long authorId;
private final String title;
private final String content;
private final LocalDate createdOn;
public NewPostParameter(
long authorId,
String title,
String content,
LocalDate createdOn
) {
this.authorId = authorId;
this.title = title;
this.content = content;
this.createdOn = createdOn;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public long getAuthorId() { return authorId; }
public String getTitle() { return title; }
public String getContent() { return content; }
public LocalDate getCreatedOn() { return createdOn; }
}<insert id="insertParameter"
parameterType="board.mybatis.NewPostParameter"
useGeneratedKeys="true"
keyProperty="id"
keyColumn="id">
insert into posts(
author_id, title, content, created_on, version)
values(
#{authorId}, #{title}, #{content}, #{createdOn}, 0)
</insert>어댑터는 생성된 ID를 확인한 뒤 저장된 도메인 인스턴스로 복원합니다.
Spring 트랜잭션 참여
MyBatis-Spring의 SqlSessionTemplate은 Spring 트랜잭션에 바인딩된 SqlSession을 사용합니다.
DataSourceTransactionManager와 SqlSessionFactory는 같은 DataSource 빈을 사용해야 합니다.
Spring 7 조합은 다음 버전으로 재현합니다.
implementation 'org.mybatis:mybatis:3.5.19'
implementation 'org.mybatis:mybatis-spring:4.1.0'package board.mybatis;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Repository;
@Repository
public final class MyBatisPostRepository {
private final PostMapper mapper;
public MyBatisPostRepository(PostMapper mapper) {
this.mapper = mapper;
}
public void change(
long id,
long authorId,
long version,
String title,
String content
) {
int changed = mapper.updateWithVersion(
id, authorId, version, title, content);
if (changed != 1) {
throw new OptimisticLockingFailureException(
"post changed or not found");
}
}
}Spring 변환 뒤에도 제약 조건별 도메인 규칙은 JdbcTemplate 어댑터와 같아야 합니다. XML에 트랜잭션 명령을 넣지 않습니다.
MyBatis 통합 검증
package board.mybatis;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
@MapperScan("board.mybatis")
public class MyBatisIntegrationConfiguration {
@Bean
DataSource dataSource() throws Exception {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:mybatis;DB_CLOSE_DELAY=-1");
dataSource.setUser("sa");
try (var connection = dataSource.getConnection();
var statement = connection.createStatement()) {
statement.execute("""
create table posts(
id bigint generated by default as identity primary key,
author_id bigint not null,
title varchar(80) not null,
content varchar(5000) not null,
created_on date not null,
version bigint not null)
""");
}
return dataSource;
}
@Bean
SqlSessionFactory sqlSessionFactory(DataSource dataSource)
throws Exception {
var factory = new SqlSessionFactoryBean();
factory.setDataSource(dataSource);
factory.setMapperLocations(
new PathMatchingResourcePatternResolver().getResources(
"classpath:board/mybatis/*Mapper.xml"));
return factory.getObject();
}
@Bean
PlatformTransactionManager transactionManager(
DataSource dataSource
) {
return new DataSourceTransactionManager(dataSource);
}
}package board.mybatis;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.annotation.Transactional;
@SpringJUnitConfig(MyBatisIntegrationConfiguration.class)
@Transactional
class PostMapperTest {
@Autowired
PostMapper mapper;
@Test
void XML_resultMap과_제목_본문_검색이_함께_동작한다() {
var parameter = new NewPostParameter(
41L, "MyBatis", "XML 매퍼로 저장한 게시글 본문",
LocalDate.parse("2026-07-14"));
int changed = mapper.insertParameter(parameter);
var rows = mapper.search(
41L,
"게시글 본문",
LocalDate.parse("2026-07-01"),
LocalDate.parse("2026-07-31"),
20);
assertThat(changed).isEqualTo(1);
assertThat(parameter.getId()).isPositive();
assertThat(rows).hasSize(1);
assertThat(rows.getFirst().title()).isEqualTo("MyBatis");
assertThat(rows.getFirst().content())
.isEqualTo("XML 매퍼로 저장한 게시글 본문");
}
}mapper XML parsed = true
insert changed rows = 1
generated id positive = true
dynamic title/body keyword condition = applied
resultMap LocalDate = 2026-07-14
transaction rollback after test = true구성과 XML을 함께 올려 매퍼 바인딩을 실행합니다. 네임스페이스와 메서드 누락을 정적 텍스트 검사만으로 판단하지 않습니다.
네임스페이스 차이는 BindingException으로 확인합니다.
${}와 달리 #{}는 공격 문자열도 하나의 값으로 바인딩합니다.
wrong namespace -> BindingException: invalid bound statement
missing result column -> persistence mapping failure
title = "Spring') or 1=1 --" with #{} -> rows 0
transaction after mapper exception -> rolled back
silent execution of another statement = false연습 문제
여러 제목을 선택적 IN 조건으로 받는 <foreach> 쿼리를 추가하세요.
빈 목록이면 전체 결과가 아니라 빈 결과를 반환하고, 목록 크기 50을 넘으면 쿼리 전에 거부하세요.
${} 없이 파라미터가 생성되는지 SQL 로그와 실제 공격 문자열로 검증합니다.
해설 보기
어댑터가 빈 목록을 먼저 반환하면 XML에서 in ()을 만들지 않습니다.
비어 있지 않은 분기는 컬렉션 항목을 #{title}로 바인딩합니다.
package board.mybatis;
import java.util.List;
public final class TitleSelection {
private final List<String> values;
public TitleSelection(List<String> values) {
if (values == null || values.isEmpty() || values.size() > 50) {
throw new IllegalArgumentException("1 to 50 titles required");
}
this.values = List.copyOf(values);
}
public List<String> values() {
return values;
}
}테스트 값에 Spring') or 1=1 -- 문자열을 넣고 정확히 같은 제목의 행만 조회되는지 확인합니다.
쿼리 텍스트에 공격 문자열이 직접 합쳐지지 않아야 합니다.