日本の業務システムでは、JPAよりもMyBatisが選ばれる現場がまだまだ多いですよね。SQLを自分で書けるので、複雑な検索やチューニングが効きやすいのが理由です。ただ「Spring Bootにゼロから組み込むとなると、どこに何を書けばいいのか分からない」という声もよく聞きます。

この記事では、依存追加からMapper定義(アノテーションとXMLの両方)、動的SQL、結合結果のマッピング、そして @Transactional 連携までを、動くコード中心に順を追って見ていきます。JPAとどちらを選ぶかという話は MyBatisとJPAの比較記事 に譲り、ここでは実装に集中しましょう。

MyBatisとmybatis-spring-boot-starterの役割

MyBatisは「SQLは自分で書き、結果のマッピングは任せる」タイプのO/Rマッパーです。JPAのように自動でSQLを生成しない代わりに、書いたSQLがそのまま実行されるので挙動が読みやすいのが特徴です。

Spring Bootでは mybatis-spring-boot-starter を入れるだけで、SqlSessionFactory の生成やMapperのスキャンが自動構成されます。DataSourceさえ設定すれば、あとはMapperインターフェースを書くだけで動きます。SQLを自前で書きたいがマッピングは楽をしたい、という中間の立ち位置だと思ってください。

依存追加とDataSource・接続設定

まず依存を追加します。GradleならこうなりますJDBCドライバは使うDBに合わせてください。

dependencies {
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4'
    runtimeOnly 'com.mysql:mysql-connector-j'
}

接続とMyBatisの設定は application.yml にまとめます。

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/shop
    username: app
    password: secret

mybatis:
  mapper-locations: classpath:mapper/*.xml
  configuration:
    map-underscore-to-camel-case: true

mapper-locations はXMLマッパーの置き場所です。map-underscore-to-camel-case を有効にすると、user_name カラムが自動的に userName フィールドへ対応づきます。これを忘れると値がnullになりがちなので、最初に入れておくのがおすすめです。

Mapperの認識には、メインクラスに @MapperScan("com.example.shop.mapper") を付ける方法と、各インターフェースに @Mapper を付ける方法があります。パッケージ単位でまとめて管理したいなら @MapperScan が楽です。

サンプルのテーブルとエンティティ

以降は users と orders の2テーブルを題材にします。1対多を扱いたいので、ユーザーが複数の注文を持つ構成です。

CREATE TABLE users (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_name VARCHAR(100) NOT NULL
);
CREATE TABLE orders (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  amount INT NOT NULL
);

エンティティはプレーンなJavaクラスで十分です。特別なアノテーションは要りません。

public class User {
    private Long id;
    private String userName;
    private List<Order> orders;
    // getter / setter
}

アノテーション記法でMapperを定義する

短いSQLならアノテーション記法が手軽です。@Mapper を付けたインターフェースに、SQLを直接書いていきます。

@Mapper
public interface UserMapper {

    @Select("SELECT id, user_name FROM users WHERE id = #{id}")
    User findById(Long id);

    @Insert("INSERT INTO users(user_name) VALUES(#{userName})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    void insert(User user);

    @Update("UPDATE users SET user_name = #{userName} WHERE id = #{id}")
    void update(User user);

    @Delete("DELETE FROM users WHERE id = #{id}")
    void delete(Long id);
}

#{id} はパラメータのプレースホルダです。@Options(useGeneratedKeys = true, keyProperty = "id") を付けると、INSERT後にDBで採番されたIDが user.id に自動でセットされます。あとはSpringのDIで UserMapper をそのまま注入して呼ぶだけです。CRUDをREST APIから叩く流れは REST APIのCRUDチュートリアル も参考になります。

XML記法でMapperを定義する

SQLが長くなったり動的SQLを書くようになると、アノテーションでは読みにくくなります。そんなときはXMLにSQLを外出しします。namespace をMapperインターフェースの完全修飾名に合わせ、id をメソッド名に合わせるのがポイントです。

<?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="com.example.shop.mapper.UserMapper">
  <select id="findById" resultType="com.example.shop.User">
    SELECT id, user_name FROM users WHERE id = #{id}
  </select>
</mapper>

インターフェース側はSQLを書かず、メソッド宣言だけ残します。使い分けの基準はシンプルで、短い静的SQLはアノテーション、動的SQLや複雑なJOINはXML、と考えておけば大きく外しません。

//による動的SQL

MyBatisの強みが動的SQLです。検索条件が入力次第で変わるケースを、XMLで組み立てられます。

<select id="search" resultType="com.example.shop.User">
  SELECT id, user_name FROM users
  <where>
    <if test="userName != null">
      AND user_name LIKE CONCAT('%', #{userName}, '%')
    </if>
    <if test="ids != null and !ids.isEmpty()">
      AND id IN
      <foreach item="id" collection="ids" open="(" separator="," close=")">
        #{id}
      </foreach>
    </if>
  </where>
</select>

<where> は中身が生成されたときだけ WHERE を付け、先頭の余計な AND を自動で取り除いてくれます。<foreach> はリストからIN句を組み立てる定番です。ここで注意したいのが空リストの挙動で、ids が空だと IN () になって構文エラーになります。上の例のように <if> で空チェックしてから回すのが安全です。動的SQLをJavaで組みたいなら @SelectProvider という選択肢もありますが、まずはXMLが読みやすいでしょう。

resultMapで結合・1対多をマッピングする

JOIN結果を1人のUserに複数のOrderをぶら下げる形へマッピングするには resultMap を使います。<collection> が1対多、<association> が1対1に対応します。

<resultMap id="userWithOrders" type="com.example.shop.User">
  <id property="id" column="id"/>
  <result property="userName" column="user_name"/>
  <collection property="orders" ofType="com.example.shop.Order">
    <id property="id" column="order_id"/>
    <result property="amount" column="amount"/>
  </collection>
</resultMap>

<select id="findWithOrders" resultMap="userWithOrders">
  SELECT u.id, u.user_name, o.id AS order_id, o.amount
  FROM users u LEFT JOIN orders o ON o.user_id = u.id
  WHERE u.id = #{id}
</select>

このように1本のJOINでまとめて取れば、ユーザーごとに注文を取り直すN+1問題を避けられます。アノテーション派なら @Results@Many でも書けます。

@Select("SELECT id, user_name FROM users WHERE id = #{id}")
@Results({
    @Result(property = "id", column = "id"),
    @Result(property = "orders", column = "id",
            many = @Many(select = "findOrdersByUserId"))
})
User findWithOrders(Long id);

ただし @Manyselect はサブクエリを別途発行するのでN+1になりがちです。件数が増える結合はXMLのJOIN一括取得を選ぶのが無難です。

#{}と${}の違いとSQLインジェクション対策

ここは事故につながるので必ず押さえてください。#{} はPreparedStatementのプレースホルダで、値が安全にバインドされます。一方 ${} は文字列をそのままSQLに展開します。

// 安全: 値はバインドされる
@Select("SELECT * FROM users WHERE user_name = #{name}")
List<User> safe(String name);

// 危険: nameがそのまま埋め込まれSQLインジェクションになる
@Select("SELECT * FROM users WHERE user_name = '${name}'")
List<User> danger(String name);

ユーザー入力を ${} に渡すのは厳禁です。${} が要るのは、ORDER BYのカラム名やテーブル名など、値ではなくSQLの構造を動的にしたい場面だけです。その場合も入力をそのまま使わず、許可リストで検証してから使いましょう。

private static final Set<String> SORTABLE = Set.of("id", "user_name");

public List<User> list(String sortColumn) {
    if (!SORTABLE.contains(sortColumn)) {
        throw new IllegalArgumentException("invalid column");
    }
    return mapper.listOrderBy(sortColumn);
}

原則は「値には常に #{}、構造だけ許可リスト付きの ${}」です。

@Transactionalでトランザクションを管理する

複数のMapper呼び出しを1つのトランザクションにまとめるには、Service層に @Transactional を付けます。MyBatisはSpringのトランザクション管理に乗るので、特別な設定は要りません。

@Service
public class OrderService {

    private final UserMapper userMapper;
    private final OrderMapper orderMapper;

    @Transactional
    public void register(User user, Order order) {
        userMapper.insert(user);
        order.setUserId(user.getId());
        orderMapper.insert(order);
    }
}

途中で非検査例外(RuntimeException 系)が投げられれば、両方のINSERTがロールバックされます。伝播や分離レベルを細かく制御したい場合は トランザクション管理の記事 にまとめているので、そちらを参照してください。

つまずきやすいポイント

導入時によく引っかかるのは次のあたりです。BindingException が出るときは、XMLの namespace とインターフェースの完全修飾名、メソッド名と id、そして mapper-locations のパスが一致しているかを確認します。カラムがnullになるときは、map-underscore-to-camel-case の設定漏れか、resultMap のマッピング漏れを疑いましょう。

パラメータが複数あるメソッドでは @Param で名前を付けないと参照できません。

List<User> search(@Param("name") String name, @Param("ids") List<Long> ids);

もう一つ多いのが、XMLがビルド成果物に含まれずクラスパスから読めないケースです。src/main/resources 配下に置くか、ビルド設定でリソースとして取り込まれるようにしてください。

まとめ

MyBatisは、SQLを自分で握りながらマッピングの手間だけ省ける実装的なO/Rマッパーです。短いSQLはアノテーション、動的SQLや結合はXMLと使い分け、値は必ず #{} でバインドする。この基本を押さえれば、業務システムの複雑なSQLも安全に組み立てられます。まずは users / orders のような小さな題材で、CRUDから動的SQL、resultMapまで一通り動かしてみるのが理解への近道です。