본문으로 건너뛰기

안동민 개발노트

본문 시작

MyBatis SQL 매핑

공유 애플리케이션 포트를 MyBatis mapper와 단일 XML로 구현하고 값 바인딩, 전체 행 매핑, 생성 키와 Spring 트랜잭션 참여를 검증합니다.

MyBatis는 SQL을 직접 유지하면서 파라미터 바인딩, 실행과 결과 매핑의 반복을 줄입니다.

이 장의 MyBatis 구현도 별도 저장소 모델을 만들지 않습니다. PostRepositoryPostQuery를 구현하고, mapper 파라미터와 투영만 어댑터 안에 둡니다.

MyBatis는 두 애플리케이션 포트를 단일 mapper와 Spring 트랜잭션에 연결한다

MYBATIS · ONE MAPPER XML · SPRING TRANSACTION

MyBatis는 두 애플리케이션 포트를 단일 mapper와 Spring 트랜잭션에 연결한다

명령과 조회 포트는 각각 MyBatis 어댑터를 거쳐 같은 PostMapper namespace로 모인다. 하나의 XML이 생성 키·조회·검색·갱신·삭제를 정의하고, 모든 값은 준비된 구문에 바인딩되어 Spring이 묶은 같은 DataSource 트랜잭션에서 실행된다.

애플리케이션 포트에서 MyBatis mapper와 Spring 트랜잭션으로 이어지는 구조 PostRepository와 PostQuery가 각각 MyBatis 명령·조회 어댑터로 연결되고 두 어댑터는 하나의 PostMapper를 호출한다. PostMapper는 한 PostMapper XML의 statement와 resultMap을 사용하며, SqlSessionTemplate은 트랜잭션 관리자와 동일한 DataSource의 schema version 1 데이터베이스에서 실행한다. APPLICATION MYBATIS SPRING / JDBC ID SAME TX COMMAND PORT PostRepository QUERY PORT PostQuery ADAPTER MyBatisPost Repository ADAPTER MyBatisPost Query ONE NAMESPACE PostMapper adapter-private method ONE XML PostMapper.xml #{ } · resultMap generated key · size + 1 SPRING-BOUND SESSION SqlSessionTemplate no commit · no rollback ONE RESOURCE IDENTITY DataSource · DB schema.sql version 1 INVARIANT statement ID는 한 곳 · caller 값은 모두 binding · port 밖 기술 타입 0개 TRANSACTION SqlSessionFactory와 DataSourceTransactionManager는 같은 DataSource instance를 참조
  1. APPLICATION PORTS

    PostRepository와 PostQuery가 공개 계약을 고정한다

    생성·갱신·삭제와 owner 범위 조회·검색·요약은 기술 타입을 노출하지 않습니다.

  2. ADAPTERS

    두 MyBatis 어댑터가 application 타입을 그대로 소비한다

    생성 키 파라미터와 조회 투영만 package 내부에 두고 최종 결과는 PostSnapshot과 PostPage로 복원합니다.

  3. ONE MAPPER

    PostMapper와 PostMapper.xml의 namespace·ID를 일대일로 맞춘다

    insert, find, page, summary, update, delete와 연습 쿼리가 하나의 XML에서만 정의됩니다.

  4. SAFE MAPPING

    모든 caller 값은 준비된 구문에 바인딩된다

    리터럴 percent·underscore·역슬래시를 escape하고 resultMap이 LocalDate와 Instant를 전체 row에 매핑합니다.

  5. SPRING TRANSACTION

    SqlSessionTemplate이 같은 DataSource의 현재 transaction에 참여한다

    mapper XML은 commit이나 rollback을 호출하지 않으며 schema.sql version 1에서 실행됩니다.

generated key 누락, namespace 불일치와 row-count 이상은 실패로 닫는다. 0행 update/delete만 공개 포트의 false이며 기술 예외가 포트를 통과하지 않는다.


하나의 mapper 인터페이스와 namespace

Java 메서드와 XML statement ID는 일대일입니다. 메서드 파라미터 이름은 @Param 또는 단일 파라미터 객체로 고정하고 컴파일러의 리플렉션 이름에 기대지 않습니다.

src/main/java/board/mybatis/PostMapper.java
package board.mybatis;

import java.util.List;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import board.application.ChangedPost;
import board.application.DeletePostCommand;

@Mapper
interface PostMapper {
    int insert(PostInsertParameter parameter);

    PostProjection find(
            @Param("postId") long postId,
            @Param("memberId") long memberId);

    PostProjection findByIdempotencyKey(
            @Param("memberId") long memberId,
            @Param("clientRequestId") String clientRequestId);

    List<PostProjection> findPage(PostSearchParameter parameter);

    PostSummaryProjection summary(@Param("memberId") long memberId);

    int update(ChangedPost command);

    int delete(DeletePostCommand command);

    List<PostProjection> findByTitles(
            @Param("memberId") long memberId,
            @Param("selection") TitleSelection selection);
}

공개 포트에는 mapper나 XML 투영이 나타나지 않습니다. 조회 어댑터가 내부 PostProjection을 검증된 PostSnapshot으로 바꾸고, size + 1 행으로 다음 커서를 판정합니다.

src/main/java/board/mybatis/MyBatisPostQuery.java
package board.mybatis;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import org.springframework.stereotype.Repository;
import board.application.PostCursor;
import board.application.PostPage;
import board.application.PostPersistenceException;
import board.application.PostQuery;
import board.application.PostSearch;
import board.application.PostSnapshot;
import board.application.PostSummary;
@Repository
public final class MyBatisPostQuery implements PostQuery {
    private static final Pattern REQUEST_ID =
            Pattern.compile("[A-Za-z0-9_-]{8,64}");
    private final PostMapper mapper;
    public MyBatisPostQuery(PostMapper mapper) {
        this.mapper = mapper;
    }
    @Override
    public Optional<PostSnapshot> find(long postId, long memberId) {
        requirePositive(postId, "postId");
        requirePositive(memberId, "memberId");
        PostProjection projection = call(
                "find post",
                () -> mapper.find(postId, memberId));
        return Optional.ofNullable(projection)
                .map(MyBatisPostQuery::toSnapshot);
    }
    @Override
    public Optional<PostSnapshot> findByIdempotencyKey(
            long memberId,
            String clientRequestId
    ) {
        requirePositive(memberId, "memberId");
        if (clientRequestId == null
                || !REQUEST_ID.matcher(clientRequestId).matches()) {
            throw new IllegalArgumentException("invalid clientRequestId");
        }
        PostProjection projection = call(
                "find post by idempotency key",
                () -> mapper.findByIdempotencyKey(
                        memberId, clientRequestId));
        return Optional.ofNullable(projection)
                .map(MyBatisPostQuery::toSnapshot);
    }
    @Override
    public PostPage findPage(PostSearch search) {
        if (search == null) {
            throw new IllegalArgumentException("search is required");
        }
        List<PostProjection> projections = call(
                "find post page",
                () -> mapper.findPage(PostSearchParameter.from(search)));
        if (projections == null || projections.size() > search.size() + 1) {
            throw failure("invalid page row count");
        }
        var snapshots = new ArrayList<PostSnapshot>(projections.size());
        for (PostProjection projection : projections) {
            PostSnapshot snapshot = toSnapshot(projection);
            if (snapshot.memberId() != search.memberId()
                    || snapshot.publishedOn().isBefore(search.from())
                    || snapshot.publishedOn().isAfter(search.to())) {
                throw failure("query returned a row outside its scope");
            }
            snapshots.add(snapshot);
        }
        verifyStrictOrder(snapshots);
        boolean hasNext = snapshots.size() > search.size();
        List<PostSnapshot> items = List.copyOf(
                snapshots.subList(
                        0, Math.min(search.size(), snapshots.size())));
        Optional<PostCursor> nextCursor = hasNext
                ? Optional.of(cursorFromLast(items))
                : Optional.empty();
        return new PostPage(items, nextCursor);
    }
    @Override
    public PostSummary summary(long memberId) {
        requirePositive(memberId, "memberId");
        PostSummaryProjection projection = call(
                "summarize posts",
                () -> mapper.summary(memberId));
        if (projection == null) {
            throw failure("summary row is missing");
        }
        try {
            return new PostSummary(
                    memberId,
                    projection.postCount(),
                    projection.totalCharacters());
        } catch (RuntimeException mappingFailure) {
            throw new PostPersistenceException(
                    "invalid summary projection", mappingFailure);
        }
    }
    static PostSnapshot toSnapshot(PostProjection projection) {
        if (projection == null) {
            throw failure("post projection is missing");
        }
        try {
            return new PostSnapshot(
                    projection.id(),
                    projection.memberId(),
                    projection.title(),
                    projection.content(),
                    projection.publishedOn(),
                    projection.clientRequestId(),
                    projection.createdAt(),
                    projection.version());
        } catch (RuntimeException mappingFailure) {
            throw new PostPersistenceException(
                    "invalid post projection", mappingFailure);
        }
    }
    private static void verifyStrictOrder(List<PostSnapshot> rows) {
        var ids = new HashSet<Long>();
        for (int index = 0; index < rows.size(); index++) {
            PostSnapshot current = rows.get(index);
            if (!ids.add(current.id())) {
                throw failure("duplicate post in page");
            }
            if (index == 0) {
                continue;
            }
            PostSnapshot previous = rows.get(index - 1);
            int dateOrder = previous.publishedOn()
                    .compareTo(current.publishedOn());
            if (dateOrder < 0
                    || (dateOrder == 0 && previous.id() <= current.id())) {
                throw failure("page order is not strictly descending");
            }
        }
    }
    private static PostCursor cursorFromLast(List<PostSnapshot> items) {
        if (items.isEmpty()) {
            throw failure("look-ahead page has no returned item");
        }
        PostSnapshot last = items.getLast();
        return new PostCursor(last.publishedOn(), last.id());
    }
    private static void requirePositive(long value, String name) {
        if (value <= 0) {
            throw new IllegalArgumentException(name + " must be positive");
        }
    }
    private static <T> T call(String action, Supplier<T> operation) {
        try {
            return operation.get();
        } catch (PostPersistenceException failure) {
            throw failure;
        } catch (RuntimeException adapterFailure) {
            throw new PostPersistenceException(
                    action + " failed", adapterFailure);
        }
    }
    private static PostPersistenceException failure(String message) {
        return new PostPersistenceException(
                message, new IllegalStateException(message));
    }
}
record PostProjection(
        long id,
        long memberId,
        String title,
        String content,
        LocalDate publishedOn,
        String clientRequestId,
        Instant createdAt,
        long version
) {
}
record PostSummaryProjection(
        long postCount,
        long totalCharacters
) {
}
record PostSearchParameter(
        long memberId,
        String keywordPattern,
        LocalDate from,
        LocalDate to,
        Integer minimumCharacters,
        LocalDate afterPublishedOn,
        Long afterId,
        int limit
) {
    static PostSearchParameter from(PostSearch search) {
        PostCursor cursor = search.after().orElse(null);
        return new PostSearchParameter(
                search.memberId(),
                likePattern(search.keyword()),
                search.from(),
                search.to(),
                search.minimumCharacters(),
                cursor == null ? null : cursor.publishedOn(),
                cursor == null ? null : cursor.id(),
                search.size() + 1);
    }
    private static String likePattern(String keyword) {
        if (keyword == null || keyword.isBlank()) {
            return null;
        }
        String literal = keyword.strip()
                .toLowerCase(Locale.ROOT)
                .replace("\\", "\\\\")
                .replace("%", "\\%")
                .replace("_", "\\_");
        return "%" + literal + "%";
    }
}

PostSearchParameter는 검색 문자열을 SQL 구조로 바꾸지 않습니다. %, _, 역슬래시는 리터럴 데이터로 이스케이프되고 XML은 정해진 절만 선택합니다.


단 하나의 mapper XML

열 투영, 생성 키 insert, 조회, 동적 검색, 요약, 버전 갱신과 삭제를 모두 같은 namespace에 둡니다. Java 메서드와 XML statement를 중복 애노테이션으로 정의하지 않습니다.

src/main/resources/board/mybatis/PostMapper.xml
<?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="postProjection" type="board.mybatis.PostProjection">
    <constructor>
      <idArg column="id" javaType="_long" />
      <arg column="member_id" javaType="_long" />
      <arg column="title" javaType="java.lang.String" />
      <arg column="content" javaType="java.lang.String" />
      <arg column="published_on" javaType="java.time.LocalDate" />
      <arg column="client_request_id" javaType="java.lang.String" />
      <arg column="created_at" javaType="java.time.Instant" />
      <arg column="version" javaType="_long" />
    </constructor>
  </resultMap>

  <resultMap id="postSummaryProjection"
             type="board.mybatis.PostSummaryProjection">
    <constructor>
      <arg column="post_count" javaType="_long" />
      <arg column="total_characters" javaType="_long" />
    </constructor>
  </resultMap>

  <sql id="postColumns">
    id, member_id, title, content, published_on,
    client_request_id, created_at, version
  </sql>

  <insert id="insert"
          parameterType="board.mybatis.PostInsertParameter"
          useGeneratedKeys="true"
          keyProperty="id"
          keyColumn="id">
    insert into posts(
        member_id, title, content, published_on,
        client_request_id, created_at, version)
    values(
        #{memberId}, #{title}, #{content}, #{publishedOn},
        #{clientRequestId}, #{createdAt}, 0)
  </insert>

  <select id="find" resultMap="postProjection">
    select <include refid="postColumns" />
      from posts
     where id = #{postId}
       and member_id = #{memberId}
  </select>

  <select id="findByIdempotencyKey" resultMap="postProjection">
    select <include refid="postColumns" />
      from posts
     where member_id = #{memberId}
       and client_request_id = #{clientRequestId}
  </select>

  <select id="findPage"
          parameterType="board.mybatis.PostSearchParameter"
          resultMap="postProjection">
    select <include refid="postColumns" />
      from posts
     where member_id = #{memberId}
       and published_on &gt;= #{from}
       and published_on &lt;= #{to}
    <if test="keywordPattern != null">
       and (lower(title) like #{keywordPattern} escape '\'
            or lower(content) like #{keywordPattern} escape '\')
    </if>
    <if test="minimumCharacters != null">
       and char_length(content) &gt;= #{minimumCharacters}
    </if>
    <if test="afterPublishedOn != null">
       and (published_on &lt; #{afterPublishedOn}
            or (published_on = #{afterPublishedOn}
                and id &lt; #{afterId}))
    </if>
     order by published_on desc, id desc
     limit #{limit}
  </select>

  <select id="summary" resultMap="postSummaryProjection">
    select count(*) as post_count,
           coalesce(sum(char_length(content)), 0) as total_characters
      from posts
     where member_id = #{memberId}
  </select>

  <update id="update" parameterType="board.application.ChangedPost">
    update posts
       set title = #{title},
           content = #{content},
           version = version + 1
     where id = #{id}
       and member_id = #{memberId}
       and version = #{expectedVersion}
  </update>

  <delete id="delete"
          parameterType="board.application.DeletePostCommand">
    delete from posts
     where id = #{postId}
       and member_id = #{memberId}
       and version = #{expectedVersion}
  </delete>

  <select id="findByTitles" resultMap="postProjection">
    select <include refid="postColumns" />
      from posts
     where member_id = #{memberId}
    <choose>
      <when test="selection.values.isEmpty()">
       and 1 = 0
      </when>
      <otherwise>
       and title in
        <foreach collection="selection.values"
                 item="title"
                 open="(" separator="," close=")">
          #{title}
        </foreach>
      </otherwise>
    </choose>
     order by published_on desc, id desc
  </select>
</mapper>

DTD 주소는 MyBatis가 아는 Mapper 3.0 식별자이며 오프라인 실행에서는 번들 resolver로 해석합니다. 임의의 외부 엔티티나 네트워크 응답을 입력으로 삼지 않습니다.

#{...}는 준비된 구문의 값 자리입니다. 식별자나 정렬처럼 값 바인딩이 불가능한 SQL 구조도 사용자 문자열 치환으로 만들지 않고, 고정된 statement나 <choose> 분기로 선택합니다.


생성 키를 받는 내부 파라미터

CreatePostCommand는 불변 애플리케이션 입력입니다. 생성된 ID를 MyBatis가 설정할 수 있도록 어댑터 전용 파라미터에만 nullable 필드를 둡니다.

src/main/java/board/mybatis/PostInsertParameter.java
package board.mybatis;

import java.time.Instant;
import java.time.LocalDate;

import board.application.postcreation.CreatePostUseCase.CreatePostCommand;

final class PostInsertParameter {
    private Long id;
    private final long memberId;
    private final String title;
    private final String content;
    private final LocalDate publishedOn;
    private final String clientRequestId;
    private final Instant createdAt;

    PostInsertParameter(CreatePostCommand command, Instant createdAt) {
        if (command == null || createdAt == null) {
            throw new IllegalArgumentException(
                    "command and createdAt are required");
        }
        this.memberId = command.memberId();
        this.title = command.title();
        this.content = command.content();
        this.publishedOn = command.publishedOn();
        this.clientRequestId = command.clientRequestId();
        this.createdAt = createdAt;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public long getMemberId() {
        return memberId;
    }

    public String getTitle() {
        return title;
    }

    public String getContent() {
        return content;
    }

    public LocalDate getPublishedOn() {
        return publishedOn;
    }

    public String getClientRequestId() {
        return clientRequestId;
    }

    public Instant getCreatedAt() {
        return createdAt;
    }
}

insert가 정확히 한 행을 바꾸고 하나의 양수 키를 돌려준 뒤에만 전체 행을 다시 읽어 PostSnapshot으로 반환합니다.


애플리케이션 포트를 구현하는 명령 어댑터

MyBatis와 MyBatis-Spring은 Boot BOM이 버전을 관리하지 않으므로 두 좌표를 계약에 고정합니다. 실제 루트 빌드는 통합 담당자가 한 번만 병합합니다.

build.gradle.kts
dependencies {
    implementation("org.mybatis:mybatis:3.5.19")
    implementation("org.mybatis:mybatis-spring:4.1.0")
}

명령 어댑터는 공개 포트의 사실적 의미를 바꾸지 않습니다. update/delete는 한 행이면 true, 0행이면 false이고 1행을 넘으면 영속성 실패입니다.

생성 중 SQLState 23505DuplicateKeyException으로 번역된 경우만 member/request-key 중복으로 좁힙니다. 이 insert에서 호출자가 충돌시킬 수 있는 유일한 unique 값은 (member_id, client_request_id)이고, 다른 무결성 실패는 PostPersistenceException입니다.

src/main/java/board/mybatis/MyBatisPostRepository.java
package board.mybatis;
import java.sql.SQLException;
import java.time.Instant;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Repository;
import board.application.ChangedPost;
import board.application.DeletePostCommand;
import board.application.DuplicatePostRequestException;
import board.application.PostPersistenceException;
import board.application.PostRepository;
import board.application.PostSnapshot;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
@Repository
public final class MyBatisPostRepository implements PostRepository {
    private final PostMapper mapper;
    public MyBatisPostRepository(PostMapper mapper) {
        this.mapper = mapper;
    }
    @Override
    public PostSnapshot create(
            CreatePostCommand command,
            Instant createdAt
    ) {
        var parameter = new PostInsertParameter(command, createdAt);
        final int changed;
        try {
            changed = mapper.insert(parameter);
        } catch (RuntimeException failure) {
            if (isDuplicateRequest(failure)) {
                throw new DuplicatePostRequestException(
                        command.memberId(),
                        command.clientRequestId(),
                        failure);
            }
            throw new PostPersistenceException(
                    "create post failed", failure);
        }
        if (changed != 1) {
            throw failure("create changed " + changed + " rows");
        }
        Long generatedId = parameter.getId();
        if (generatedId == null || generatedId <= 0) {
            throw failure("one positive generated id is required");
        }
        final PostProjection projection;
        try {
            projection = mapper.find(generatedId, command.memberId());
        } catch (RuntimeException readFailure) {
            throw new PostPersistenceException(
                    "read created post failed", readFailure);
        }
        if (projection == null) {
            throw failure("created post cannot be read");
        }
        PostSnapshot persisted = MyBatisPostQuery.toSnapshot(projection);
        PostSnapshot expected = new PostSnapshot(
                generatedId,
                command.memberId(),
                command.title(),
                command.content(),
                command.publishedOn(),
                command.clientRequestId(),
                createdAt,
                0L);
        if (!persisted.equals(expected)) {
            throw failure("created post differs from supplied fields");
        }
        return persisted;
    }
    @Override
    public boolean update(ChangedPost command) {
        if (command == null) {
            throw new IllegalArgumentException("command is required");
        }
        final int changed;
        try {
            changed = mapper.update(command);
        } catch (RuntimeException failure) {
            throw new PostPersistenceException(
                    "update post failed", failure);
        }
        return exactMutationResult("update", changed);
    }
    @Override
    public boolean delete(DeletePostCommand command) {
        if (command == null) {
            throw new IllegalArgumentException("command is required");
        }
        final int changed;
        try {
            changed = mapper.delete(command);
        } catch (RuntimeException failure) {
            throw new PostPersistenceException(
                    "delete post failed", failure);
        }
        return exactMutationResult("delete", changed);
    }
    private static boolean exactMutationResult(
            String operation,
            int changed
    ) {
        if (changed == 1) {
            return true;
        }
        if (changed == 0) {
            return false;
        }
        throw failure(operation + " changed " + changed + " rows");
    }
    private static boolean isDuplicateRequest(Throwable failure) {
        boolean duplicateKey = false;
        boolean uniqueSqlState = false;
        Set<Throwable> seen = Collections.newSetFromMap(
                new IdentityHashMap<>());
        for (Throwable current = failure;
             current != null && seen.add(current);
             current = current.getCause()) {
            duplicateKey |= current instanceof DuplicateKeyException;
            if (current instanceof SQLException sqlFailure) {
                Set<SQLException> sqlSeen = Collections.newSetFromMap(
                        new IdentityHashMap<>());
                for (SQLException sql = sqlFailure;
                     sql != null && sqlSeen.add(sql);
                     sql = sql.getNextException()) {
                    uniqueSqlState |= "23505".equals(sql.getSQLState());
                }
            }
        }
        return duplicateKey && uniqueSqlState;
    }
    private static PostPersistenceException failure(String message) {
        return new PostPersistenceException(
                message, new IllegalStateException(message));
    }
}

어댑터는 트랜잭션을 시작하거나 커밋하지 않습니다. 호출자의 Spring 트랜잭션에 참여하고 공개 예외에 원인을 보존합니다.


Spring-bound session과 같은 데이터소스

SqlSessionFactory, JdbcTemplate, DataSourceTransactionManager는 같은 DataSource 인스턴스를 받아야 합니다. MyBatis XML에는 commit이나 rollback 명령을 넣지 않습니다.

테스트 구성은 앞 문서의 전체 스키마·클래스별 H2 구성을 가져오고 MyBatis wiring만 추가합니다.

src/test/java/board/mybatis/MyBatisIntegrationConfiguration.java
package board.mybatis;

import javax.sql.DataSource;

import org.apache.ibatis.session.SqlSessionFactory;
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.context.annotation.Import;
import org.springframework.core.io.ClassPathResource;

import board.jdbc.DatabaseTestConfiguration;

@Configuration(proxyBeanMethods = false)
@Import(DatabaseTestConfiguration.class)
@MapperScan(basePackageClasses = PostMapper.class)
public class MyBatisIntegrationConfiguration {
    @Bean
    SqlSessionFactory sqlSessionFactory(DataSource dataSource)
            throws Exception {
        var factory = new SqlSessionFactoryBean();
        factory.setDataSource(dataSource);
        factory.setMapperLocations(
                new ClassPathResource(
                        "board/mybatis/PostMapper.xml"));
        SqlSessionFactory result = factory.getObject();
        if (result == null) {
            throw new IllegalStateException(
                    "SqlSessionFactory was not created");
        }
        return result;
    }

    @Bean
    MyBatisPostRepository myBatisPostRepository(PostMapper mapper) {
        return new MyBatisPostRepository(mapper);
    }

    @Bean
    MyBatisPostQuery myBatisPostQuery(PostMapper mapper) {
        return new MyBatisPostQuery(mapper);
    }
}

통합 테스트는 XML 파싱과 statement binding을 실제로 실행합니다. 생성 키와 LocalDate/Instant 매핑, 리터럴 검색, keyset 페이지, 같은 데이터소스와 자동 롤백을 한 문맥에서 확인합니다.

src/test/java/board/mybatis/PostMapperTest.java
package board.mybatis;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.catchThrowable;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.transaction.AfterTransaction;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import board.application.DuplicatePostRequestException;
import board.application.PostPage;
import board.application.PostQuery;
import board.application.PostRepository;
import board.application.PostSearch;
import board.application.PostSnapshot;
import board.application.postcreation.CreatePostUseCase.CreatePostCommand;
@SpringJUnitConfig(MyBatisIntegrationConfiguration.class)
@TestPropertySource(properties =
        "board.test.database=mybatis_post_mapper_test")
@Transactional
@Execution(ExecutionMode.SAME_THREAD)
class PostMapperTest {
    private static final long MEMBER_ID = 61L;
    private static final LocalDate DAY =
            LocalDate.parse("2026-08-29");
    @Autowired
    DataSource dataSource;
    @Autowired
    JdbcTemplate jdbc;
    @Autowired
    PlatformTransactionManager transactionManager;
    @Autowired
    SqlSessionFactory sqlSessionFactory;
    @Autowired
    PostRepository repository;
    @Autowired
    PostQuery query;
    @Autowired
    PostMapper mapper;
    @BeforeEach
    void insertMember() {
        jdbc.update("""
                insert into members(
                    id, email, password_hash, name,
                    active, daily_character_limit)
                values (?, ?, ?, ?, true, 10000)
                """,
                MEMBER_ID,
                "mybatis-" + MEMBER_ID + "@example.com",
                "{noop}password",
                "MyBatis member");
    }
    @Test
    void Spring_transaction과_SqlSessionFactory가_같은_DataSource를_쓴다() {
        assertThat(jdbc.getDataSource()).isSameAs(dataSource);
        assertThat(transactionManager)
                .isInstanceOf(DataSourceTransactionManager.class);
        assertThat(((DataSourceTransactionManager) transactionManager)
                .getDataSource()).isSameAs(dataSource);
        assertThat(sqlSessionFactory.getConfiguration()
                .getEnvironment().getDataSource()).isSameAs(dataSource);
    }
    @Test
    void generated_key와_resultMap이_application_snapshot을_복원한다() {
        var command = command(
                "MyBatis",
                "XML mapper로 저장한 게시글",
                "mybatis-0001");
        Instant createdAt = Instant.parse("2026-08-29T03:00:00Z");
        PostSnapshot created = repository.create(command, createdAt);
        assertThat(created.id()).isPositive();
        assertThat(created.memberId()).isEqualTo(command.memberId());
        assertThat(created.title()).isEqualTo(command.title());
        assertThat(created.content()).isEqualTo(command.content());
        assertThat(created.publishedOn()).isEqualTo(command.publishedOn());
        assertThat(created.clientRequestId())
                .isEqualTo(command.clientRequestId());
        assertThat(created.createdAt()).isEqualTo(createdAt);
        assertThat(created.version()).isZero();
        assertThat(query.find(created.id(), MEMBER_ID))
                .contains(created);
        assertThat(query.findByIdempotencyKey(
                MEMBER_ID, command.clientRequestId()))
                .contains(created);
    }
    @Test
    void literal_검색과_keyset_cursor가_같은_정렬_contract를_지킨다() {
        PostSnapshot percent = create(
                "100% coverage", "percent literal", "mybatis-1000", 1);
        PostSnapshot underscore = create(
                "a_b guide", "underscore literal", "mybatis-1001", 2);
        PostSnapshot backslash = create(
                "path\\segment", "backslash literal", "mybatis-1002", 3);
        PostSnapshot attack = create(
                "Spring') or 1=1 --",
                "bound as one title",
                "mybatis-1003",
                4);
        assertThat(literalSearch("100%").items())
                .extracting(PostSnapshot::id)
                .containsExactly(percent.id());
        assertThat(literalSearch("a_b").items())
                .extracting(PostSnapshot::id)
                .containsExactly(underscore.id());
        assertThat(literalSearch("\\").items())
                .extracting(PostSnapshot::id)
                .containsExactly(backslash.id());
        assertThat(literalSearch("Spring') or 1=1 --").items())
                .extracting(PostSnapshot::id)
                .containsExactly(attack.id());
        PostPage first = query.findPage(search(null, Optional.empty(), 2));
        PostPage second = query.findPage(search(
                null, first.nextCursor(), 2));
        assertThat(first.items())
                .extracting(PostSnapshot::id)
                .containsExactly(attack.id(), backslash.id());
        assertThat(second.items())
                .extracting(PostSnapshot::id)
                .containsExactly(underscore.id(), percent.id());
        assertThat(first.nextCursor()).isPresent();
        assertThat(second.nextCursor()).isEmpty();
    }
    @Test
    void duplicate_request는_원인을_보존하고_rollback된다() {
        var command = command(
                "Duplicate",
                "첫 저장만 남아야 한다",
                "mybatis-2000");
        Instant createdAt = Instant.parse("2026-08-29T05:00:00Z");
        repository.create(command, createdAt);
        Throwable failure = catchThrowable(
                () -> repository.create(command, createdAt));
        assertThat(failure).isInstanceOfSatisfying(
                DuplicatePostRequestException.class,
                duplicate -> {
                    assertThat(duplicate.memberId()).isEqualTo(MEMBER_ID);
                    assertThat(duplicate.clientRequestId())
                            .isEqualTo(command.clientRequestId());
                    assertThat(duplicate.getCause()).isNotNull();
                });
        assertThat(jdbc.queryForObject(
                "select count(*) from posts where member_id = ?",
                Long.class,
                MEMBER_ID)).isEqualTo(1L);
    }
    @Test
    void foreach는_빈_목록과_공격문자열을_안전하게_바인딩한다() {
        create("Normal", "normal row", "mybatis-3000", 1);
        PostSnapshot attack = create(
                "Spring') or 1=1 --",
                "exact title only",
                "mybatis-3001",
                2);
        assertThat(mapper.findByTitles(
                MEMBER_ID, new TitleSelection(List.of())))
                .isEmpty();
        assertThat(mapper.findByTitles(
                MEMBER_ID,
                new TitleSelection(List.of("Spring') or 1=1 --"))))
                .extracting(PostProjection::id)
                .containsExactly(attack.id());
        assertThatIllegalArgumentException()
                .isThrownBy(() -> new TitleSelection(
                        Collections.nCopies(51, "Too many")));
    }
    @AfterTransaction
    void mapper_DML은_자동_rollback된다() {
        assertThat(jdbc.queryForObject(
                "select count(*) from posts", Long.class)).isZero();
        assertThat(jdbc.queryForObject(
                "select count(*) from members", Long.class)).isZero();
    }
    private PostSnapshot create(
            String title,
            String content,
            String clientRequestId,
            long seconds
    ) {
        return repository.create(
                command(title, content, clientRequestId),
                Instant.parse("2026-08-29T04:00:00Z")
                        .plusSeconds(seconds));
    }
    private PostPage literalSearch(String keyword) {
        return query.findPage(search(keyword, Optional.empty(), 20));
    }
    private PostSearch search(
            String keyword,
            Optional<board.application.PostCursor> after,
            int size
    ) {
        return new PostSearch(
                MEMBER_ID,
                keyword,
                DAY,
                DAY,
                null,
                after,
                size);
    }
    private CreatePostCommand command(
            String title,
            String content,
            String clientRequestId
    ) {
        return new CreatePostCommand(
                MEMBER_ID,
                title,
                content,
                DAY,
                clientRequestId);
    }
}
MyBatis mapper 결과
mapper resource count = 1
namespace and Java mapper = board.mybatis.PostMapper
generated id positive = true
LocalDate and Instant full-row mapping = exact
literal percent, underscore, backslash, attack-string matches = exact only
keyset pages = 2 / 2 with cursor present / empty
SqlSessionFactory and transaction manager DataSource identity = same
MyBatis 실패 관찰
duplicate member/request key -> DuplicatePostRequestException with cause
zero-row update/delete -> false
more-than-one-row mutation -> PostPersistenceException
caller text -> bound value, never SQL structure
mapper failure inside test transaction -> rolled back
raw MyBatis or Spring persistence type crossing public port = false

정적 문자열 검사만으로 mapper가 연결됐다고 결론 내리지 않습니다. 컨텍스트 시작, XML 파싱, 실제 statement 호출과 롤백을 모두 실행해야 namespace·ID·타입 핸들러·트랜잭션 연결을 함께 검증할 수 있습니다.


연습 문제

여러 제목을 선택적으로 조회하는 <foreach> statement를 계약 테스트로 확장하세요.

빈 목록은 전체 조회가 아니라 빈 결과를 만들고, 50개를 넘는 목록은 DB 호출 전에 거부해야 합니다. 공격 문자열도 각각 하나의 값으로 바인딩되어 정확히 같은 제목만 조회되어야 합니다.

해설 보기

선택 값은 어댑터 전용 불변 객체에서 0..50개로 제한합니다. XML은 빈 목록에 고정된 거짓 조건을 사용하고, 비어 있지 않은 분기만 <foreach>를 실행합니다.

src/main/java/board/mybatis/TitleSelection.java
package board.mybatis;

import java.util.List;

final class TitleSelection {
    private final List<String> values;

    TitleSelection(List<String> values) {
        if (values == null || values.size() > 50) {
            throw new IllegalArgumentException(
                    "zero to 50 titles are required");
        }
        if (values.stream().anyMatch(title ->
                title == null
                        || title.isBlank()
                        || title.length() > 80)) {
            throw new IllegalArgumentException("invalid title selection");
        }
        this.values = List.copyOf(values);
    }

    public List<String> getValues() {
        return values;
    }
}

XML의 selection.values는 위 getter를 읽습니다. 목록의 각 원소는 #{title}로 바인딩되며 SQL 텍스트로 합쳐지지 않습니다.

다음 문서에서는 같은 포트를 JPA 영속성 문맥과 @Version에 연결하고 persist, flush와 commit이 관찰되는 시점을 구분합니다.