日本の業務システムでは、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);
ただし @Many の select はサブクエリを別途発行するので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まで一通り動かしてみるのが理解への近道です。