본문 바로가기

MyBatis SQL 매핑

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

MyBatis는 SQL을 직접 유지하면서 파라미터 바인딩과 실행, 결과 매핑의 반복을 줄여 줍니다. 이 장의 MyBatis 구현도 별도의 저장 모델을 만들지 않습니다. PostRepository와 PostQuery를 구현하고, 매퍼의 파라미터와 투영만 어댑터 안에 둡니다.

MYBATIS · ONE MAPPER + SPRING TRANSACTION

두 포트의 MyBatis 구현이 한 mapper로 모인다

명령·조회 어댑터가 같은 PostMapper를 호출합니다. SQL 정의와 결과 매핑은 하나의 namespace·XML에서 관리합니다.

명령·조회 어댑터와 단일 MyBatis mapperPostRepository와 PostQuery는 각각 MyBatis 어댑터를 거쳐 같은 PostMapper를 호출한다. mapper 메서드와 XML statement ID는 일대일이다. 호출자의 Spring 트랜잭션이 있으면 같은 DataSource의 경계에 참여한다. 트랜잭션 밖 mapper 실행은 자동 커밋되며 본문에서 별도로 구별한다.PostRepository명령 포트PostQuery조회 포트MyBatisPostRepository명령 어댑터MyBatisPostQuery조회 어댑터PostMapper공유 namespaceID 연결PostMapper.xml#{값} · resultMapSqlSessionTemplateSpring 트랜잭션 참여DataSource · DB동일 인스턴스XML은 정의를 제공하고, 세션은 현재 트랜잭션에서 SQL을 실행합니다.
두 어댑터가 같은 mapper와 실행 경로로 합류한다desktop과 같은 여덟 노드·일곱 간선이다. 두 포트→각 어댑터→공유mapper로 합류하고 XML의ID정의→Spring관리세션→같은DataSource로 이어진다. 명령과조회포트를 한 순차요청으로 묶지 않는다. SpringTX가있으면참여하고밖의mapper실행자동commit은본문조건으로구분한다.PostRepository명령 포트MyBatisPostRepository명령 어댑터PostQuery조회 포트MyBatisPostQuery조회 어댑터PostMapper공유 namespacePostMapper.xml값 · resultMapSqlSessionTemplateSpring TX 참여DataSource · DB같은 인스턴스
  1. 명령·조회 포트 → 어댑터

    두 공개 포트의 명령·결과 타입은 그대로 유지합니다. 어댑터 내부 파라미터와 투영만 MyBatis에 맞춥니다.

  2. 두 어댑터 → PostMapper → 한 XML

    namespace와 statement ID는 일대일입니다. 생성 키, 검색, 갱신, 삭제와 resultMap을 한 XML에서 정의합니다.

  3. Spring-bound session → 같은 DataSource

    SqlSessionTemplate은 호출자의 Spring 트랜잭션에 참여합니다. 트랜잭션 관리자와 SqlSessionFactory는 같은 DataSource를 받습니다.

값 바인딩과 전체 행 매핑

#{...}로 준비된 구문에 값을 바인딩합니다. LocalDate·Instant와 전체 열을 스냅샷으로 복원합니다.

정확한 결과와 실패 경계

한 양수 생성 키를 확인합니다. update/delete의 0행만 false이며 키 누락·행 수 이상·binding 실패는 실패로 닫습니다.

XML과 어댑터에서 직접 commit/rollback을 호출하지 않습니다. 공개 포트에는 MyBatis 기술 타입이 나타나지 않습니다.

그림은 PostRepository와 PostQuery가 각각 MyBatis의 명령 어댑터와 조회 어댑터를 거쳐 같은 PostMapper로 모이고, 공유 namespace로 PostMapper.xml의 statement에 연결된 뒤, SqlSessionTemplate이 같은 DataSource에서 SQL을 실행하는 흐름을 보여 줍니다. 명령 포트와 조회 포트를 한 요청의 순서로 묶은 것이 아닙니다. 호출한 쪽의 Spring 트랜잭션이 있으면 세션이 거기에 참여하고, 트랜잭션 밖에서 매퍼를 실행하면 자동 커밋된다는 점은 본문에서 따로 다룹니다.


매퍼 인터페이스 하나와 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);
}

findByTitles가 받는 TitleSelection은 고를 제목을 0개부터 50개까지로 제한하는 어댑터 전용 클래스입니다. 코드는 이 글 끝의 연습 문제 2 해설에 있습니다. 매퍼와 아래 테스트가 이 클래스를 쓰므로 src/main/java/board/mybatis/TitleSelection.java를 먼저 만들어 둡니다.

공개 포트에는 매퍼나 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은 정해진 절만 고릅니다.


매퍼 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의 식별자이며, 오프라인에서는 함께 들어 있는 리졸버로 해석합니다. 임의의 외부 엔티티나 네트워크 응답을 입력으로 삼지 않습니다.

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


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

CreatePostCommand는 불변인 애플리케이션 입력입니다. MyBatis가 생성된 ID를 넣을 수 있도록, 어댑터 전용 파라미터에만 null일 수 있는 필드를 둡니다.

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 두 좌표의 버전을 아래처럼 고정합니다. 2026년 10월 말에 MyBatis 3.6.0이 나왔지만, 여기서는 MyBatis-Spring Boot Starter 4.1.0이 Spring Boot 4.1과 짝지어 쓰는 3.5.19와 MyBatis-Spring 4.1.0을 그대로 씁니다. 기존 프로젝트의 build.gradle.kts 의존성 블록에 더하며, 저장 어댑터마다 별도의 빌드 파일이나 중복 선언을 만들지 않습니다.

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

명령 어댑터는 공개 포트가 돌려주는 사실의 의미를 바꾸지 않습니다. 수정과 삭제는 한 행이면 true, 0행이면 false이고, 한 행을 넘으면 영속성의 실패입니다.

생성 중에 SQLState 23505가 DuplicateKeyException으로 바뀐 경우만 회원과 요청 키의 중복으로 좁힙니다. 이 insert에서 호출한 쪽이 충돌시킬 수 있는 유일 값은 (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에 묶인 세션과 같은 DataSource

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

호출한 쪽의 Spring 트랜잭션이 있으면 매퍼는 그 트랜잭션에 참여합니다. Spring 트랜잭션 밖에서 Spring이 관리하는 매퍼를 호출하면 그 실행은 자동 커밋되므로, 어댑터가 커밋을 호출하지 않는다는 것과 호출 전체를 롤백할 수 있다는 것은 같은 말이 아닙니다. MyBatis-Spring의 트랜잭션 규칙에서 같은 DataSource의 조건과 트랜잭션 밖의 호출을 나눠 확인합니다.

테스트 구성은 앞 글의 전체 스키마와 클래스별 H2 구성을 가져오고, MyBatis의 연결만 더합니다.

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의 바인딩을 실제로 실행합니다. 생성 키와 LocalDate, Instant의 매핑, 글자 그대로의 검색, 키셋 페이지, 같은 DataSource와 자동 롤백을 한 문맥에서 확인합니다.

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

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

MyBatis 어댑터도 첫 글의 공통 테스트에 붙입니다. 구체 클래스는 위의 MyBatis 구성을 그대로 쓰되 다른 H2 이름을 주므로, Spring 테스트는 PostMapperTest와 다른 컨텍스트와 DB를 만듭니다. 이 클래스에는 PostMapperTest처럼 @Transactional을 붙이지 않습니다. Spring 테스트는 테스트 메서드를 선언한 클래스와 그 상위 클래스에서 @Transactional을 찾는데, 상속한 메서드를 선언한 곳은 애노테이션이 없는 PostPersistenceContract이므로 붙여도 테스트 트랜잭션이 생기지 않습니다. 그래서 매퍼 호출은 Spring 트랜잭션 밖에서 하나씩 자동 커밋되고, 커밋된 행은 resetDatabase()가 메서드마다 지웁니다. 메서드들이 한 DB를 차례로 쓰도록 실행도 SAME_THREAD로 고정합니다.

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

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.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.TestPropertySource;

import board.application.PostQuery;
import board.application.PostRepository;
import board.contract.PostPersistenceContract;

@SpringJUnitConfig(MyBatisIntegrationConfiguration.class)
@TestPropertySource(properties =
        "board.test.database=mybatis_post_contract_test")
@Execution(ExecutionMode.SAME_THREAD)
class MyBatisPostContractTest extends PostPersistenceContract {
    @Autowired
    JdbcTemplate jdbc;

    @Autowired
    PostRepository repository;

    @Autowired
    PostQuery query;

    @Override
    protected PostRepository repository() {
        return repository;
    }

    @Override
    protected PostQuery query() {
        return query;
    }

    @Override
    protected void resetDatabase() {
        jdbc.update("delete from post_event_outbox");
        jdbc.update("delete from daily_post_stats");
        jdbc.update("delete from posts");
        jdbc.update("delete from members");
    }

    @Override
    protected void insertMember(long memberId) {
        jdbc.update("""
                insert into members(
                    id, email, password_hash, name,
                    active, daily_character_limit)
                values (?, ?, ?, ?, true, 10000)
                """,
                memberId,
                "mybatis-" + memberId + "@example.com",
                "{noop}password",
                "MyBatis member");
    }
}

더 깊이: 한 트랜잭션에서 같은 조회를 두 번 하면

MyBatis에는 세션마다 조회 결과를 담아 두는 로컬 캐시(local cache)가 있습니다. MyBatis 설정 문서에 따르면 localCacheScope의 기본값은 SESSION이어서, 한 SqlSession에서 실행한 조회는 모두 캐시됩니다. 그리고 MyBatis-Spring의 SqlSessionTemplate은 현재 Spring 트랜잭션에 연결된 SqlSession을 씁니다. 그래서 한 트랜잭션 안에서 같은 매퍼 조회를 같은 파라미터로 두 번 부르면, 두 번째 호출은 DB에 가지 않고 캐시된 결과를 돌려받을 수 있습니다.

MyBatis가 직접 실행한 변경은 이 캐시를 비웁니다. 매퍼 XML 문서에서 insert, update, delete의 flushCache 기본값은 true이므로, 이 글의 명령 어댑터처럼 insert 뒤에 다시 읽으면 새 결과를 봅니다. 문제는 같은 트랜잭션에서 JdbcTemplate처럼 MyBatis를 거치지 않는 경로로 행을 바꾼 경우입니다. 그 변경은 MyBatis 세션의 캐시를 비우지 않으므로, 뒤이은 같은 MyBatis 조회가 변경 전의 결과를 돌려줄 수 있습니다. 한 트랜잭션에서 두 기술을 섞어 쓴다면 localCacheScope를 STATEMENT로 바꿔 구문이 끝날 때마다 캐시를 버리게 하거나, 그 조회에 flushCache="true"를 지정합니다.


정리

  • MyBatis 어댑터도 공통 포트를 구현하며, 매퍼의 파라미터와 투영은 어댑터 안에만 둡니다.
  • 매퍼 메서드와 XML statement ID는 일대일로 맞추고, 파라미터 이름은 @Param이나 파라미터 객체로 고정합니다.
  • #{...}로 값만 바인딩하고, 식별자나 정렬 같은 구조는 고정된 statement나 <choose> 분기로 고릅니다.
  • 생성 키는 어댑터 전용 파라미터로 받고, 한 행과 하나의 양수 키를 확인한 뒤 전체 행을 다시 읽습니다.
  • SqlSessionFactory와 트랜잭션 관리자는 같은 DataSource를 쓰며, 트랜잭션 밖의 매퍼 실행은 자동 커밋됩니다.

연습 문제

1. 매퍼 호출 뒤의 실패

INSERT와 생성 키의 설정은 성공했지만, 이어지는 스냅샷 검증이 실패한다고 가정합니다. ① 매퍼 호출이 트랜잭션 밖에 있는 경우와, ② 같은 DataSource의 Spring 트랜잭션이 호출 전체를 감싸고 이 예외가 바깥으로 전달되어 롤백되는 경우에, 저장된 행은 어떻게 달라지나요?

해설 보기

①은 매퍼의 실행이 이미 자동 커밋되었을 수 있으므로, 뒤의 Java 예외만으로 INSERT가 취소되었다고 답하지 않습니다. ②는 아직 끝나지 않은 같은 트랜잭션의 INSERT이므로 바깥 롤백의 대상이 됩니다. 어댑터가 원인을 보존하고 키를 정확히 검사하는 일은 두 경우 모두 필요하지만, 원자적으로 끝내는 일은 실제 호출을 감싼 트랜잭션이 맡습니다. 이 비교는 코드와 공식 호출 규칙을 따라 판단한 것입니다.

2. 여러 제목을 고르는 <foreach>

여러 제목을 골라 조회하는 <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 텍스트로 합쳐지지 않습니다. 테스트에서는 빈 목록이 빈 결과를 돌려주는지, 51개 목록이 DB 호출 전에 예외를 내는지, ' OR '1'='1 같은 문자열이 그 제목을 가진 행만 찾는지를 각각 확인합니다.

다음 글 JPA 영속성 컨텍스트에서는 같은 포트를 JPA의 영속성 컨텍스트와 @Version에 연결하고, persist와 flush, 커밋이 각각 언제 보이는지 구분합니다.