MapperでSELECTを書くところまでは順調でも、「注文と明細をJOINして Order の中に List<OrderItem> を詰めたい」となった途端に手が止まりますよね。resultMapの association と collection の書き分けが分からない、とりあえずネストselectで動かしたらSQLログにSELECTが大量に流れた、という相談は本当によく聞きます。
この記事ではresultMapの階層マッピングに絞って、JOIN一発で親子オブジェクトを組み立てる書き方、ネストselectとの発行SQL本数の違い、そしてページングと組み合わせたときに件数がズレる罠までを扱います。MyBatisの導入やMapperの基本は MyBatisの実装ガイド に譲ります。
動作確認は Spring Boot 3.x、mybatis-spring-boot-starter 3.0.4(MyBatis 3.5.16)、H2、Lombok(@Data)で行っています。
題材とする3テーブルとサンプルデータ
顧客・注文・明細の3テーブルを使います。H2のインメモリDBに schema.sql と data.sql を置くだけで動くので、Dockerは要りません。注文10件・明細25件を入れておき、後のN+1実測でこの件数をそのまま使います。
-- schema.sql
CREATE TABLE customers (
id BIGINT PRIMARY KEY, name VARCHAR(100), email VARCHAR(100));
CREATE TABLE orders (
id BIGINT PRIMARY KEY, customer_id BIGINT, ordered_at TIMESTAMP, status VARCHAR(20));
CREATE TABLE order_items (
id BIGINT PRIMARY KEY, order_id BIGINT, product_name VARCHAR(100),
quantity INT, unit_price DECIMAL(10, 2));
-- data.sql(顧客2件、注文10件、明細25件)
INSERT INTO customers VALUES (1, '山田太郎', '[email protected]'), (2, '鈴木花子', '[email protected]');
INSERT INTO orders VALUES
(1, 1, '2026-09-01 10:00:00', 'PAID'), (2, 2, '2026-09-01 11:00:00', 'PAID'),
(3, 1, '2026-09-02 10:00:00', 'SHIPPED'), (4, 2, '2026-09-02 11:00:00', 'PAID'),
(5, 1, '2026-09-03 10:00:00', 'PAID'), (6, 2, '2026-09-03 11:00:00', 'SHIPPED'),
(7, 1, '2026-09-04 10:00:00', 'PAID'), (8, 2, '2026-09-04 11:00:00', 'PAID'),
(9, 1, '2026-09-05 10:00:00', 'SHIPPED'), (10, 2, '2026-09-05 11:00:00', 'PAID');
INSERT INTO order_items VALUES
(1, 1, 'コーヒー豆', 2, 1200), (2, 1, 'ドリッパー', 1, 800), (3, 1, 'フィルター', 1, 300),
(4, 2, 'マグカップ', 3, 1500), (5, 2, 'コーヒー豆', 1, 1200), (6, 2, 'ミル', 1, 4500),
(7, 3, 'ケトル', 1, 6000), (8, 3, 'スケール', 1, 3000), (9, 3, 'コーヒー豆', 3, 1200),
(10, 4, 'サーバー', 1, 2500), (11, 4, 'フィルター', 2, 300), (12, 4, 'ドリッパー', 1, 800),
(13, 5, 'コーヒー豆', 5, 1200), (14, 5, 'マグカップ', 2, 1500), (15, 5, 'ミル', 1, 4500),
(16, 6, 'ケトル', 1, 6000), (17, 6, 'コーヒー豆', 1, 1200),
(18, 7, 'スケール', 1, 3000), (19, 7, 'フィルター', 3, 300),
(20, 8, 'サーバー', 1, 2500), (21, 8, 'ドリッパー', 2, 800),
(22, 9, 'コーヒー豆', 2, 1200), (23, 9, 'マグカップ', 1, 1500),
(24, 10, 'ミル', 1, 4500), (25, 10, 'コーヒー豆', 1, 1200);
Java側のDTOはこうです。ネストマッピングでは親オブジェクトを先に作り、後から子を add していくので、record ではなく setter 付きのクラスにしておきます。
@Data
public class Order {
private Long id;
private LocalDateTime orderedAt;
private String status;
private Customer customer; // 多対1
private List<OrderItem> items; // 1対多
}
@Data
public class Customer { private Long id; private String name; private String email; }
@Data
public class OrderItem {
private Long id; private String productName; private Integer quantity; private BigDecimal unitPrice;
}
カラムは snake_case、Javaは camelCase ですが、本記事のresultMapはすべて <result column="ordered_at" property="orderedAt"/> のような明示マッピングなので、camelCase 変換設定には依存しません。map-underscore-to-camel-case が効くのは自動マッピングのときだけです。後半のアノテーション例で自動マッピングを使うので有効にしておきますが、ネストresultMap内で自動マッピングに頼りたい場合は後述の autoMapping の注意を先に読んでください。ログと遅延ロードの設定もまとめて載せておきます。
spring:
datasource:
url: jdbc:h2:mem:shop
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true # 自動マッピング時のみ作用する
lazy-loading-enabled: true
aggressive-lazy-loading: false
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
associationで多対1(Order → Customer)をマッピングする
まずはいちばん簡単な多対1からいきましょう。注文から見て顧客は1件なので association を使います。
<resultMap id="orderWithCustomer" type="com.example.Order">
<id property="id" column="id"/>
<result property="orderedAt" column="ordered_at"/>
<result property="status" column="status"/>
<association property="customer" javaType="com.example.Customer">
<id property="id" column="customer_id"/>
<result property="name" column="customer_name"/>
<result property="email" column="customer_email"/>
</association>
</resultMap>
<select id="selectWithCustomer" resultMap="orderWithCustomer">
SELECT o.id, o.ordered_at, o.status,
c.id AS customer_id, c.name AS customer_name, c.email AS customer_email
FROM orders o JOIN customers c ON c.id = o.customer_id
</select>
ポイントは、SQL側で c.id AS customer_id のように別名を付けて、resultMapの column と1対1で対応させることです。association の中に <id> と <result> を直接書くインライン記法で、まずはこれで動きます。
<id> は必ず書いてください。MyBatisは <id> の値で「同じ行か」を判定してオブジェクトを再利用します。多対1では影響が小さいものの、次のcollectionで効いてきます。
collectionで1対多(Order → OrderItemのリスト)をマッピングする
ここが本丸です。注文と明細をLEFT JOINすると、結果の行は「親×子」に膨らみます。
| id | status | item_id | product_name |
|---|---|---|---|
| 1 | PAID | 1 | コーヒー豆 |
| 1 | PAID | 2 | ドリッパー |
| 2 | PAID | 4 | マグカップ |
この3行を「注文1(明細2件)と注文2(明細1件)」に畳み込んでくれるのが collection です。親の <id> が同じ行をグルーピングして1つの Order にまとめ、子行は items に add されていきます。
<resultMap id="orderWithItems" type="com.example.Order">
<id property="id" column="id"/>
<result property="orderedAt" column="ordered_at"/>
<result property="status" column="status"/>
<collection property="items" ofType="com.example.OrderItem">
<id property="id" column="item_id"/>
<result property="productName" column="product_name"/>
<result property="quantity" column="quantity"/>
<result property="unitPrice" column="unit_price"/>
</collection>
</resultMap>
<select id="selectWithItems" resultMap="orderWithItems">
SELECT o.id, o.ordered_at, o.status,
i.id AS item_id, i.product_name, i.quantity, i.unit_price
FROM orders o LEFT JOIN order_items i ON i.order_id = o.id
ORDER BY o.id, i.id
</select>
collection では javaType ではなく ofType で要素の型を指定します。ここを間違えると List に Order を詰めようとして訳の分からないエラーになるので注意しましょう。
<id> については、省略と誤指定で症状が違います。省略した場合、MyBatisは全ての <result> カラムの値を並べて行の同一性を判定するので、この例では一応正しくグルーピングされます。ただし比較が遅くなりますし、カラムを追加したりNULLの列が混ざったりした途端にグルーピングが崩れやすいので、将来の事故の種だと思ってください。一方で誤指定は派手に出ます。親の <id> を item_id に向けると注文が明細の行数分だけ重複して返ってきますし、子側の <id> が全行で同じ値を指すと明細が1件にマージされて消えます。「件数がおかしい」と思ったら、まず <id> を疑ってください。
明細のない注文もLEFT JOINで拾われます。このとき子resultMapにマッピングした列がすべてNULLになるため OrderItem は生成されず、items は空のListになります。子側に外部キーやデフォルト値のようなNULLにならない列が混ざっていて空の明細が生成されてしまう場合は、notNullColumn="id" のように判定に使う列を明示しましょう。
association と collection は1つのresultMapに同居できます。3テーブルの完成形と、対応するSELECTはこうです。
<resultMap id="orderDetail" type="com.example.Order">
<id property="id" column="id"/>
<result property="orderedAt" column="ordered_at"/>
<result property="status" column="status"/>
<association property="customer" javaType="com.example.Customer">
<id property="id" column="customer_id"/>
<result property="name" column="customer_name"/>
</association>
<collection property="items" ofType="com.example.OrderItem">
<id property="id" column="item_id"/>
<result property="productName" column="product_name"/>
<result property="quantity" column="quantity"/>
<result property="unitPrice" column="unit_price"/>
</collection>
</resultMap>
<select id="selectDetailInline" resultMap="orderDetail">
SELECT o.id, o.ordered_at, o.status,
c.id AS customer_id, c.name AS customer_name,
i.id AS item_id, i.product_name, i.quantity, i.unit_price
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items i ON i.order_id = o.id
ORDER BY o.id, i.id
</select>
ネストしたresultMapの再利用とcolumnPrefixでカラム衝突を避ける
インライン記法は分かりやすい反面、テーブルが増えるとresultMapが肥大化します。それに3テーブル全部に id があるので、別名を毎回考えるのも面倒ですよね。
そこで子側を独立したresultMapとして定義し、resultMap 属性で参照します。あわせて columnPrefix を指定すると、子resultMapはプレフィックスなしのカラム名で書けます。
<resultMap id="customerResultMap" type="com.example.Customer">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="email" column="email"/>
</resultMap>
<resultMap id="orderItemResultMap" type="com.example.OrderItem">
<id property="id" column="id"/>
<result property="productName" column="product_name"/>
<result property="quantity" column="quantity"/>
<result property="unitPrice" column="unit_price"/>
</resultMap>
<!-- 基本形。単体SELECTでも使う -->
<resultMap id="orderResultMap" type="com.example.Order">
<id property="id" column="id"/>
<result property="orderedAt" column="ordered_at"/>
<result property="status" column="status"/>
</resultMap>
<!-- extendsで基本形を継承し、ネスト部分だけ足す -->
<resultMap id="orderDetailResultMap" type="com.example.Order" extends="orderResultMap">
<association property="customer" resultMap="customerResultMap" columnPrefix="c_"/>
<collection property="items" resultMap="orderItemResultMap" columnPrefix="i_"/>
</resultMap>
<select id="selectDetail" resultMap="orderDetailResultMap">
SELECT o.id, o.ordered_at, o.status,
c.id AS c_id, c.name AS c_name, c.email AS c_email,
i.id AS i_id, i.product_name AS i_product_name,
i.quantity AS i_quantity, i.unit_price AS i_unit_price
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items i ON i.order_id = o.id
ORDER BY o.id, i.id
</select>
対応関係は単純で、columnPrefix="c_" を付けた association は、c_id を id、c_name を name として customerResultMap に渡します。SQL側の別名が c_ で始まっていれば、customerResultMap は単体の顧客SELECTとJOINの両方で使い回せるわけです。
ひとつ注意点があります。既定の autoMappingBehavior=PARTIAL では、ネストresultMapを含むresultMapはトップレベルも含めて自動マッピングされません。resultMap / association / collection ごとに autoMapping="true" を付ければ個別に有効化できますが、id や name の衝突を誤って拾う危険があるので、ネストでは明示マッピング + columnPrefix にしておくのが安全です。
アノテーション記法(@Results / @One / @Many)で書く場合
XMLを使わないプロジェクト向けに、同じことをアノテーションで書くとこうなります。参照先の CustomerMapper と OrderItemMapper は単体SELECTを持つだけで、自動マッピングなのでここで map-underscore-to-camel-case が効いています。
@Mapper
public interface OrderMapper {
@Select("SELECT id, ordered_at, status, customer_id FROM orders")
@Results(id = "orderDetail", value = {
@Result(property = "id", column = "id", id = true),
@Result(property = "orderedAt", column = "ordered_at"),
@Result(property = "status", column = "status"),
@Result(property = "customer", column = "customer_id",
one = @One(select = "com.example.CustomerMapper.findById", fetchType = FetchType.EAGER)),
@Result(property = "items", column = "id",
many = @Many(select = "com.example.OrderItemMapper.findByOrderId", fetchType = FetchType.LAZY))
})
List<Order> selectAll();
@Select("SELECT id, ordered_at, status, customer_id FROM orders WHERE id = #{id}")
@ResultMap("orderDetail") // 定義済みresultMapの再利用
Order findById(Long id);
}
@Mapper
public interface CustomerMapper {
@Select("SELECT id, name, email FROM customers WHERE id = #{id}")
Customer findById(Long id);
}
@Mapper
public interface OrderItemMapper {
@Select("SELECT id, product_name, quantity, unit_price FROM order_items WHERE order_id = #{orderId}")
List<OrderItem> findByOrderId(Long orderId);
}
XMLとの対応はこの表の通りです。
| XML | アノテーション |
|---|---|
<association> | @One |
<collection> | @Many |
<resultMap id="..."> | @Results(id = "...") |
resultMap="..." での参照 | @ResultMap("...") |
fetchType="lazy" | FetchType.LAZY |
上の例を見て気づいたと思いますが、@One / @Many の基本はネストselect方式です。つまり親1本 + 子N本のSQLが流れます。MyBatis 3.5.5以降なら @Many(resultMap = "...", columnPrefix = "i_") でJOIN一発のネストresultMapも書けますが、参照先を別メソッドの @Results(id = ...) で用意する必要があり、SQLも文字列の中に書くので長いJOINは読みづらくなります。
割り切りとしては、階層マッピングをJOIN一発で書きたいならXML、アノテーションで書くならネストselect方式のN+1を受け入れるか対策する、と考えておくとブレません。
ネストselectとネストresultMapで発行SQL本数を実測比較する
N+1問題は概念で語るより、SQLログの本数で見たほうが早いです。同じ注文一覧を2通りで書いて比べてみましょう。
(A) はネストselect方式です。select 属性で子取得用のSELECTを指定し、親の id を column で渡します。本数を確実に計測するため fetchType="eager" で即時ロードにしています。lazy にした場合の挙動は次節で扱うので、lazy版も同じブロックに用意しておきます。
<!-- (A) ネストselect方式。eagerで即時ロード -->
<resultMap id="orderNestedSelect" type="com.example.Order" extends="orderResultMap">
<collection property="items" column="id" ofType="com.example.OrderItem"
select="selectItemsByOrderId" fetchType="eager"/>
</resultMap>
<!-- 次節で使うlazy版 -->
<resultMap id="orderNestedSelectLazy" type="com.example.Order" extends="orderResultMap">
<collection property="items" column="id" ofType="com.example.OrderItem"
select="selectItemsByOrderId" fetchType="lazy"/>
</resultMap>
<select id="selectAllNestedSelect" resultMap="orderNestedSelect">
SELECT id, ordered_at, status FROM orders ORDER BY id
</select>
<select id="selectAllNestedSelectLazy" resultMap="orderNestedSelectLazy">
SELECT id, ordered_at, status FROM orders ORDER BY id
</select>
<select id="selectItemsByOrderId" resultMap="orderItemResultMap">
SELECT id, product_name, quantity, unit_price FROM order_items WHERE order_id = #{orderId}
</select>
(B) は先ほどの selectDetail そのまま、JOIN一発のネストresultMap方式です。log-impl を設定してあるので、標準出力にSQLが流れます。
# (A) ネストselect方式。親1本 + 子10本 = 11本
==> Preparing: SELECT id, ordered_at, status FROM orders ORDER BY id
====> Preparing: SELECT id, product_name, quantity, unit_price FROM order_items WHERE order_id = ?
====> Parameters: 1(Long)
====> Preparing: SELECT id, product_name, quantity, unit_price FROM order_items WHERE order_id = ?
====> Parameters: 2(Long)
... (注文10まで続く)
<== Total: 10
# (B) ネストresultMap方式。1本
==> Preparing: SELECT o.id, o.ordered_at, o.status, c.id AS c_id, ... LEFT JOIN order_items i ON i.order_id = o.id ORDER BY o.id, i.id
<== Total: 25
親の件数が増えるとどうなるかは一目瞭然です。
| 親の件数 | (A) ネストselect | (B) ネストresultMap |
|---|---|---|
| 10件 | 11本 | 1本 |
| 100件 | 101本 | 1本 |
| 1,000件 | 1,001本 | 1本 |
これはJPAの @OneToMany でも構造はまったく同じで、対策の考え方は JPAのパフォーマンス最適化記事 で扱っているものと共通です。
公平のために (B) の弱点も書いておきます。(B) の Total: 25 が示す通り、転送される行数は親×子(明細25件分)に膨らみます。1つの注文に明細が数百件あるようなデータだと、親の列が何百回も繰り返し転送されるので、ネストselectのほうが軽くなることもあります。
遅延ロード設定とJacksonシリアライズで結局N+1になる落とし穴
「ネストselectでも fetchType="lazy" にしたから、使わなければSQLは流れないでしょ」と思いますよね。それ自体は正しいです。lazy-loading-enabled=true で全体の既定値を遅延にでき、fetchType を個別に書けばそちらが優先されます。aggressive-lazy-loading=false(既定値)なら、どれか1つのgetterを呼んでも他の遅延プロパティまで一緒にロードされることはありません。
問題はREST APIです。遅延ロードはJavassistのプロキシで実装されていて、@RestController から Order をそのまま返すと、Jacksonが全getterを呼びます。getItems() が呼ばれた瞬間にプロキシが解決されて、結局N+1本のSQLが流れます。eager なら親取得時に、lazy なら Jackson のgetter呼び出し時に、というタイミングの違いだけで、合計は同じ11本です。
@RestController
@RequiredArgsConstructor
public class OrderController {
private final OrderMapper orderMapper;
// NG: lazyでもシリアライズ時にgetItems()が呼ばれ、注文の数だけSELECTが流れる
@GetMapping("/orders/lazy")
public List<Order> listLazy() {
return orderMapper.selectAllNestedSelectLazy();
}
// OK: 一覧はJOIN一発で取り、レスポンス用DTOに詰め替える
@GetMapping("/orders")
public List<OrderResponse> list() {
return orderMapper.selectDetail().stream()
.map(OrderResponse::from)
.toList();
}
}
public record OrderResponse(Long id, String status, String customerName, List<OrderItem> items) {
static OrderResponse from(Order o) {
return new OrderResponse(o.getId(), o.getStatus(), o.getCustomer().getName(), o.getItems());
}
}
それだけでなく、プロキシの内部フィールド handler をJacksonがプロパティとして拾ってシリアライズエラーになることもあります。lazy-load-trigger-methods の既定値には equals や hashCode、toString が含まれるので、ログに toString() で出しただけで全ロードされる、というのも地味にハマります。
回避策はシンプルで、上のOK例のようにレスポンス用のDTOに明示的に詰め替えるか、一覧APIではJOIN一発のresultMapを使って lazy に頼らないことです。JPAでも同じ構造の問題が LazyInitializationException として現れます。LazyInitializationExceptionの記事 と読み比べると腹落ちしやすいです。
ページネーションと併用すると件数がズレる罠と回避策
collection 付きのresultMapにそのままLIMITを掛けると、かなり分かりにくいバグになります。いちばん素直な回避策は、親IDを先に絞る2段クエリです。NG例と一緒に載せておきます。
<!-- NG: JOIN後の行が5行で切られる -->
<select id="selectDetailPaged" resultMap="orderDetailResultMap">
SELECT o.id, o.ordered_at, o.status,
c.id AS c_id, c.name AS c_name, c.email AS c_email,
i.id AS i_id, i.product_name AS i_product_name, i.quantity AS i_quantity, i.unit_price AS i_unit_price
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items i ON i.order_id = o.id
ORDER BY o.id, i.id
LIMIT 5 OFFSET 0
</select>
<!-- OK 1本目: 親だけをページングしてIDを取る -->
<select id="selectOrderIds" resultType="long">
SELECT id FROM orders ORDER BY id LIMIT #{limit} OFFSET #{offset}
</select>
<!-- OK 2本目: 絞ったIDでJOINし、collectionを畳む -->
<select id="selectDetailByIds" resultMap="orderDetailResultMap">
SELECT o.id, o.ordered_at, o.status,
c.id AS c_id, c.name AS c_name, c.email AS c_email,
i.id AS i_id, i.product_name AS i_product_name, i.quantity AS i_quantity, i.unit_price AS i_unit_price
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items i ON i.order_id = o.id
WHERE o.id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY o.id, i.id
</select>
NG例は「1ページ5件」のつもりが、LIMITは親×子の行に掛かるので返ってくる注文は2件です。しかも5行目で切られた注文2は明細が途中までしか入っていません。RowBounds でもPageHelperでも同じです。PageHelperはSQLにLIMITを差し込むだけなので、total も子行数の25でカウントされ、ページ数が注文数と合わなくなります。
2段クエリのMapper側は List<Long> selectOrderIds(@Param("limit") int limit, @Param("offset") int offset) と List<Order> selectDetailByIds(@Param("ids") List<Long> ids) のように宣言します。複数引数と foreach には @Param が必要で、忘れると BindingException になります。1本目にPageHelperを掛ければ total も注文数で正しく取れます。IDが0件のときはIN句が空になってSQLエラーになるので、Java側で先に返しておきましょう。
1本のSQLで済ませたいなら、FROM句で orders を LIMIT 付きのサブクエリにしてから order_items を LEFT JOIN する形もあります。ただしPageHelperが外側にもLIMITを足してしまうので相性が悪く、手書きLIMIT向きです。もう1つ、一覧はcollectionなしで取り、明細は別クエリでIN句一括取得してJavaで Map<Long, List<OrderItem>> に詰める方法もあります。件数は確実に正しいですが、コード量は増えます。
PageHelperやRowBoundsの設定そのものは MyBatisのページネーション記事 にまとめてあります。
ネストselectとネストresultMapの選び方
基本方針は「JOIN一発のネストresultMapをデフォルトにする」です。SQLが1本で本数が予測でき、REST APIとの相性も良いからです。
| 観点 | ネストresultMap(JOIN一発) | ネストselect |
|---|---|---|
| 発行SQL本数 | 1本 | 1 + N本 |
| 転送量 | 親×子に膨らむ | 必要な分だけ |
| ページング | 親IDを先に絞る工夫が必要 | 親のLIMITがそのまま効く |
| アノテーション対応 | 3.5.5以降で可、実質XML向き | 素直に書ける |
| 遅延ロード | 不可 | 可(REST APIでは注意) |
ネストselect + lazy が向くのは、親が少なく子が非常に多い、子を使わない画面のほうが多い、子の取得ロジックを単体Mapperと共有したい、といったケースです。
多対多も考え方は同じで、中間テーブルをJOINして片側を collection で畳むだけです。JPAの関連マッピングとの違いが気になる方は JPAのエンティティ関連マッピング記事 を、そもそもMyBatisとJPAのどちらにするかで迷っている方は MyBatisとJPAの比較記事 を参考にしてください。
まとめ
resultMapの階層マッピングで押さえるべきなのは、多対1は association、1対多は collection、<id> が行をグルーピングするキー、そして columnPrefix でカラム衝突を避ける、の4点です。
そのうえで、SQLログを出して本数を確認する習慣をつけましょう。lazy はREST APIではほぼ効かないと思っておいたほうが安全ですし、collection とページングを併用するなら親IDを先に絞るのが鉄則です。
Mapperの基本に戻りたいときは MyBatisの実装ガイド を、ページングの方式選びは ページネーション記事 をあわせてどうぞ。