Spring Boot環境におけるオブジェクトコピー手法の比較と最適化

はじめに

JavaおよびSpring Bootを活用したアプリケーション開発において、データベース上のエンティティからビジネスロジック用のDTO(Data Transfer Object)へのデータ転送は日常的に頻繁に行われる処理です。型の変換やプロパティのマッピングを手動で記述するのは冗長でありバグの原因にもなりかねません。本記事では、この「オブジェクトコピー」を実現するために広く利用される4つの代表的なライブラリとフレームワークの動作原理、実装方法、そしてパフォーマンス特性について詳しく解説します。

テスト用エンティティの定義

後続の評価のために、以下のような関連する値型・参照型のデータを保持するクラス群を用意します。Lombokのアノテーションを活用してゲッター/セッターの自動生成を行っています。

// 車両情報を表すルートエンティティ
@Data
@Accessors(chain = true)
public class Vehicle implements Serializable {
    private Long vehicleId;
    private String model;
    private String manufacturer;
    private Dimensions dimensions; // 入れ子オブジェクト
    private Double engineCapacity;
    private Double torque;
    private LocalDate releaseDate;
    private BigDecimal price;
}

// 寸法情報を表すサブエンティティ
@Data
@Accessors(chain = true)
public class Dimensions implements Serializable {
    private Double length;
    private Double width;
    private Double height;
}

// マッピング先の詳細情報用DTO
@Data
@Accessors(chain = true)
public class VehicleDetail implements Serializable {
    private Long vehicleId;
    private String model;
    private String manufacturer;
    private Dimensions dimensions;
    private Double engineCapacity;
    private Double torque;
    private LocalDate releaseDate;
    private BigDecimal price;
    private String color;
    private Integer categoryType;
}

モックデータの生成基盤

ベンチマーク計測用にランダムなテストデータを大量に作成する必要があります。ここでは`java-faker`を用いて、日本語ロケール設定で氏名・住所・数字などを生成します。

<dependency>
    <groupId>com.github.javafaker</groupId>
    <artifactId>javafaker</artifactId>
    <version>1.0.2</version></dependency>

データ構築ロジックは以下の通りです。ループ処理内で個別オブジェクトをインスタンス化しています。

private List<Vehicle> createMockData(int count) {
    Faker faker = new Faker(new Locale("ja-JP"));
    List<Vehicle> mockList = new ArrayList<>(count);
    for (int i = 0; i < count; i++) {
        mockList.add(instantiateSingleVehicle(faker));
    }
    return mockList;
}

private Vehicle instantiateSingleVehicle(Faker faker) {
    Vehicle v = new Vehicle();
    v.setVehicleId(Math.abs(faker.number().randomNumber(9)));
    v.setModel(faker.cat().breed());
    v.setManufacturer(faker.company().name());
    
    Dimensions d = new Dimensions();
    d.setLength(faker.number().randomDouble(6, 4500, 7500));
    d.setWidth(d.getLength() * 0.3);
    d.setHeight(d.getLength() * 0.15);
    v.setDimensions(d);
    
    v.setEngineCapacity(faker.number().randomDouble(5, 1000, 14000));
    v.setTorque(faker.number().randomDouble(3, 100, 80000));
    v.setReleaseDate(LocalDate.ofEpochDay(faker.number().numberBetween(0, 18000)));
    v.setPrice(BigDecimal.valueOf(faker.number().randomDigit()));
    return v;
}

1. Springframework.util.BeanUtils

Springフレームワークに標準で搭載されているヘルパーメソッドです。依存関係を追加することなく利用でき、初学者でも最も手軽に導入できます。

Vehicle source = instantiateSingleVehicle(new Faker(new Locale("ja-JP")));
VehicleDetail target = new VehicleDetail();

// Spring公式メソッドによるコピー
BeanUtils.copyProperties(source, target);
  • 動作原理: Javaのリフレクションを利用し、同名かつ同型のプロパティを列挙してSetter経由で代入します。
  • 制約事項: プロパティ名の完全一致が求められます。型が異なると例外やNull代入になります(例:IntegerからStringへの変換は不可)。プリミティブ型とラッパー型の変換も基本的には行われません。
  • 推奨度: スプラッシュ画面や小規模な処理では十分ですが、大規模リストのループ処理ではリフレクションのオーバーヘッドが顕著になります。

2. Apache Commons BeanUtils

かつてJavaエコシステムで事実上のデファクトスタンダードだったライブラリです。現在はSpring側のメソッドが一般的になりましたが、古いレガシーコードとの連携で見かけることがあります。

<dependency>
    <groupId>commons-beanutils</groupId>
    <artifactId>commons-beanutils</artifactId>
    <version>1.9.4</version></dependency>
try {
    org.apache.commons.beanutils.BeanUtils.copyProperties(target, source);
} catch (IllegalAccessException | InvocationTargetException e) {
    throw new RuntimeException("プロパティコピー失敗", e);
}
  • 仕様上の差異: 引数の順序がSpringとは逆(target, source)である点に注意が必要です。
  • 例外処理: メソッドシグネチャ上チェック例外をスローするため、必ず`try-catch`ブロックで囲む必要があります。
  • 型変換: 内部で文字列経由の変換を試みるケースがありますが、単純な数値リフレクションと比較すると性能劣化が目立ちます。

3. CGLIB BeanCopier

バイトコード生成技術であるCGLIBを活用した高速コピー機構です。実行時にReflectionではなく生成されたマッパークラスを用いるため、大幅な高速化が期待できます。

<dependency>
    <groupId>cglib</groupId>
    <artifactId>cglib-nodep</artifactId>
    <version>3.3.0</version></dependency>
// インスタンス生成はコストが高いのでキャッシュ推奨
final BeanCopier copier = BeanCopier.create(Vehicle.class, VehicleDetail.class, false);

// コピー実行
copier.copy(source, target, null);
  • メリット: 初回作成時のオーバーヘッドはあるものの、以降のコピー処理はほぼネイティブメソッド呼び出し並みの速度を発揮します。
  • 注意点: クラス定義に変更があった場合、Copierの再作成が必要です。また、パラメータとして`Converter`または`Copier`を渡すことで複雑なカスタマイズも可能ですが、デフォルトのまま使う分には非常にシンプルなAPIです。

4. MapStruct

アノテーションプロセッサを採用した現代的なオブジェクトマッピングライブラリです。コンパイル時に変換コードを生成するため、実行時のオーバーヘッドが最小限に抑えられ、IDEの補完支援を受けることができます。

<dependencies>
    <!-- コンパイラプラグイン -->
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct-processor</artifactId>
        <version>1.5.5.Final</version>
        <scope>provided</scope>
    </dependency>
    <!-- ランタイム依存 -->
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>1.5.5.Final</version>
    </dependency>
</dependencies>

インターフェースとアノテーションのみで変換ロジックを宣言します。

@Mapper(componentModel = "singleton")
public interface VehicleMapper {
    VehicleMapper INSTANCE = Mappers.getMapper(VehicleMapper.class);

    // アトリビュート名が異なる場合のマッピング指定
    @Mapping(source = "model", target = "brandName")
    VehicleDetail convertToDetail(Vehicle source);
}
VehicleDetail detail = VehicleMapper.INSTANCE.convertToDetail(source);
  • 設計思想: 「约定优于配置」。Getter/Setter呼び出しコードをコンパイル時に自動的に生成します。
  • 拡張性: `@Mapping`アノテーションによりプロパティ名不一致や型変換(enum→文字列等)、カスタムメソッドの呼び出しを柔軟に定義可能です。
  • 適合シーン: バッチ処理や大量データのETL、あるいは複雑な構造体間のマッピングが必要な場合のベストプラクティスです。

ベンチマーク評価

各手段の実行速度を定量的に比較します。検証環境では`10万件`および`100万件`のリスト変換を行い、`org.springframework.test.util.StopWatch`で経過時間を計測しました。追加フィールドへの個別代入処理は共通条件下で行っています。

データ量Spring BeanUtilsApache BeanUtilsCGLIB BeanCopierMapStruct
10万件~43%~43%~8%~5%
100万件~43%~46%~6%~5%

※パーセントは各方式が占める総実行時間比を示します。

測定結果から明らかなように、リフレクション駆動のSpringおよびApache系は線形に時間が伸びますが、バイトコード生成(CGLIB)およびコンパイル時生成(MapStruct)は圧倒的な速度差を見せました。特に100万件レベルになると、CGLIBとMapStructは他方式と比べて約10倍近い高速化を果たしています。

シャローコピーとディープコピーの挙動確認

上記で紹介したいずれの手法も、デフォルト動作としてはシャローコピー(浅いコピー)となります。これは配下にある参照型オブジェクト(今回の例でいう`dimensions`)そのもののメモリ番地をそのまま共有するためです。

// 検証サンプル
Vehicle src = ...;
VehicleDetail dst = new VehicleDetail();
BeanUtils.copyProperties(src, dst);

// 元オブジェクトの入れ子を操作
src.getDimensions().setHeight(100.0);
src.setModel("新規名称");

// dst.getDimensions()のheightも100.0に更新される現象が発生
// これにより両者の状態同期が意図せず起こってしまう

深い階層を持つ完全独立したコピー(ディープコピー)が必要な場合は、JSON直列化を経由する方法や、MapStruct内のカスタムマッピングメソッド実装、あるいはCloneableインターフェースの実装を検討する必要があります。

タグ: spring-framework mapstruct CGLIB apache-commons-beanutils java-object-mapping

9月12日 09:18 投稿