Tech Blog

DevPortSeedInitializer で開発DBを毎回同じ状態にする — Spring Boot の CommandLineRunner 活用

Spring Boot Java PostgreSQL CommandLineRunner テストデータ dev profile

この記事でわかること

  • Spring Boot の CommandLineRunner + @Profile("dev") で起動時に固定テストデータを投入する書き方
  • upsert ロジック(findById + save)で 再起動しても重複登録にならない 仕掛け
  • 駅周辺に複数ポートをリング状に配置する分散アルゴリズム
  • 動作確認用の 異常系データ(満杯ポート・メンテナンス機体)を意図的に混ぜる 設計

対象読者

  • 開発環境の DB を毎回 truncate + insert から解放されたい人
  • E2E 確認時に「満杯ポート」「壊れた機体」など異常系の固定データを欲しい人
  • Spring Boot 標準機能だけで seed 機構を組みたい人(Flyway / Liquibase より軽量に)

動作環境

項目バージョン
Java21
Spring Boot3.4.5
PostgreSQL17.2
Spring Data JPA3.x

シリーズ記事(開発進行中) — この記事は 街中の電動キックボードシェアリングを自作して理解する — 設計・実装・運用の記録シリーズ の一部です。プロジェクトは現在も継続中で、新しい記事や設計判断の追記が随時行われます。プロジェクト全体の動機・採用技術・画面IDなどの用語表・他記事への索引はリンク先にまとめています。

はじめに

PoC フェーズで PostgreSQL を扱うとき、毎回こんな問題に直面します。

  • DB を作り直したらマスタデータが消えて画面が空っぽ
  • 仲間に開発環境を共有するときに「初期データの SQL を Slack で渡す」運用
  • 「満杯のポート」や「メンテ中の機体」など、異常系のデータを毎回手で作る

これを アプリ起動時に Java コードで投入する 仕組みで解決しました。

アプリを起動した直後のホーム地図 — 4駅×20ポートが配置済み

緑のピンが「利用可能機体ありポート」、グレーが「利用可能機体ゼロ or 満杯ポート」。このばらつき自体が seed コードで意図的に作られている のがこの記事のテーマです。


全体構造

@Component
@Profile("dev")
@RequiredArgsConstructor
public class DevPortSeedInitializer implements CommandLineRunner {

    private final PortRepository portRepository;
    private final ScooterRepository scooterRepository;

    @Override
    public void run(String... args) {
        seedStation("TOKYO", "東京駅周辺", 35.681236, 139.767125);
        seedStation("IKEBUKURO", "池袋駅周辺", 35.728926, 139.710380);
        seedStation("OTSUKA", "大塚駅周辺", 35.731401, 139.728662);
        seedStation("SHINJUKU", "新宿駅周辺", 35.690921, 139.700258);
    }
    // ...
}

ポイント:

  • @Profile("dev")prod では Bean が作られない。本番環境で誤投入が起きない
  • CommandLineRunner.run は Spring Boot 起動の最後で1度だけ呼ばれる
  • 駅ごとにメソッドを分けて、地理的な分散と件数管理を一箇所に集約

upsert ロジックで再起動に強くする

単純な save だけだと、起動するたびに同じ ID で挿入が走って一意制約違反になる。findById で既存をチェックして、無ければ新規ビルダー、あれば既存エンティティを更新する形にしました。

private void seedStation(String code, String stationLabel, double centerLat, double centerLng) {
    for (int i = 1; i <= 20; i++) {
        String index = String.format("%02d", i);
        String portId = "PORT-" + code + "-" + index;
        String portName = stationLabel + "ポート" + index;

        LatLng latLng = distributedPoint(centerLat, centerLng, i);
        Port port = portRepository.findById(portId)
            .orElseGet(() -> Port.builder().portId(portId).build());
        port.setName(portName);
        port.setLatitude(BigDecimal.valueOf(latLng.lat));
        port.setLongitude(BigDecimal.valueOf(latLng.lng));
        port.setTotalCapacity(12);
        portRepository.save(port);

        // 機体も同様にupsert
        upsertScooter(port, "SC-" + code + "-" + index + "-A", "AVAILABLE");
        upsertScooter(port, "SC-" + code + "-" + index + "-B", "AVAILABLE");
        upsertScooter(port, "SC-" + code + "-" + index + "-C", "MAINTENANCE");
    }
}

private void upsertScooter(Port port, String scooterId, String status) {
    Scooter scooter = scooterRepository.findById(scooterId)
        .orElseGet(() -> Scooter.builder().scooterId(scooterId).build());
    scooter.setStatus(status);
    scooter.setBatteryLevel("AVAILABLE".equals(status) ? 85 : 45);
    scooter.setCurrentLatitude(port.getLatitude());
    scooter.setCurrentLongitude(port.getLongitude());
    scooter.setCurrentPort(port);
    scooterRepository.save(scooter);
}

これで:

  • 初回起動: 全件 INSERT
  • 2回目以降: 既存は UPDATE(座標や状態が変わっていたら追従)
  • 動作中にユーザーが状態変更(IN_USE など)した場合は次回起動時に強制的に AVAILABLE/MAINTENANCE に戻る

最後の点は「dev 起動のたびに既知の初期状態に戻る」効果があって、確認作業中に状態が壊れても再起動で立て直せる安心感があります。


駅周辺にリング状に配置する

20ポートを「全部同じ座標」に置くと地図上で重なってしまうので、距離をバラけさせます。

private LatLng distributedPoint(double centerLat, double centerLng, int index) {
    int ring = index <= 8 ? 0 : (index <= 14 ? 1 : 2);
    double radiusMeters = ring == 0 ? 180 : (ring == 1 ? 320 : 520);
    double angleDeg = (index * 31) % 360;

    double latDelta = (radiusMeters / 111_320.0) * Math.cos(Math.toRadians(angleDeg));
    double lngScale = Math.max(Math.cos(Math.toRadians(centerLat)), 0.1);
    double lngDelta = (radiusMeters / (111_320.0 * lngScale)) * Math.sin(Math.toRadians(angleDeg));
    return new LatLng(centerLat + latDelta, centerLng + lngDelta);
}

アルゴリズム:

  • index 1〜8 は内側のリング(半径 180m)
  • index 9〜14 は中間のリング(半径 320m)
  • index 15〜20 は外側のリング(半径 520m)
  • 角度は index * 31 % 360 で配置(31 は 360 と互いに素なので、index が増えるにつれ偏らない)

111_320.0 は緯度1度あたりのメートル数(赤道付近)。経度は緯度によって変わるので cos(centerLat) で補正。

これで駅を中心に、ポートが同心円状にバラけた状態になり、地図上で全部のピンを目視できます。


異常系データを意図的に混ぜる

動作確認で「満杯ポート」「全機体メンテ中のポート」を見つけたい場面が出てきます。これも seed で意図的に作っておく:

for (int i = 1; i <= 20; i++) {
    // i % 7 == 0(各駅で i=7, 14 の2件)を「満杯ポート」とし、
    // totalCapacity を機体数と同数にすることで available_empty_slots = 0 を作る。
    boolean isFullPort = i % 7 == 0;
    String nameSuffix = isFullPort ? "(満杯)" : "";
    int totalCapacity = isFullPort ? 3 : 12;

    Port port = portRepository.findById(portId)
        .orElseGet(() -> Port.builder().portId(portId).build());
    port.setName(portName + nameSuffix);
    port.setTotalCapacity(totalCapacity);
    portRepository.save(port);

    int availableCount = i % 5 == 0 ? 0 : 2;
    upsertScooter(port, "SC-" + code + "-" + index + "-A",
        availableCount >= 1 ? "AVAILABLE" : "MAINTENANCE");
    upsertScooter(port, "SC-" + code + "-" + index + "-B",
        availableCount >= 2 ? "AVAILABLE" : "MAINTENANCE");
    upsertScooter(port, "SC-" + code + "-" + index + "-C", "MAINTENANCE");
}

i の値で性質を仕込みます:

  • i % 7 == 0(i=7, 14)→ 満杯ポート。各駅 2件、4駅×2 = 8件
  • i % 5 == 0(i=5, 10, 15, 20)→ AVAILABLE 機体ゼロのポート。各駅 4件、4駅×4 = 16件
  • それ以外 → AVAILABLE 機体 2台 + MAINTENANCE 1台

地図を開けば、緑のピン(普通)と、グレーのピン(満杯 or 利用可能ゼロ)が混在して表示される。クライアント側の 「ピン色分け」「ボタン非活性化」のロジックが正しく動くかを毎回確認できる 状態が、起動直後から再現されます。

PORT-OTSUKA-14(満杯) — i=14 の seed 条件で totalCapacity=3 が割り当てられた結果

ポート名末尾の「(満杯)」も seed コードで isFullPort ? "(満杯)" : "" と組み立てているため、画面で見たときに 「これは seed の意図通り」 と一目で判別できます。動作確認で「ボタン非活性が想定通りに出るか」を見るための、固定された再現性ある検証データです。


なぜ Flyway / Liquibase ではないか

DB マイグレーションツール(Flyway / Liquibase)でも seed は可能です。しかし dev 用のテストデータには Java で書く理由があります:

  • 数百行のループや座標計算を SQL で書くのは可読性が低い
  • 緯度経度の コード生成ロジック を SQL に閉じ込めると、テストで再利用できない
  • @Profile("dev") で完全に dev に閉じ込められる(Flyway は prod でも走らせる前提が多い)

逆に、スキーマ管理は Flyway / Liquibase に任せて、テストデータだけ CommandLineRunner という分業がしっくり来ます。


学び

  • 開発用 seed は @Profile("dev") + CommandLineRunner の組み合わせがシンプルで強い
  • upsert ロジック(findById → save)で再起動耐性を持たせると、運用中に状態が壊れても怖くない
  • 駅・座標・件数・異常系を コードで宣言的に書ける ので、レビューしやすい
  • i % N == 0 のような剰余分岐で 異常系の固定パターンを意図的に作る と、毎回の動作確認が楽になる

PoC の最初は「動かしながら設計を確かめる」フェーズ。そのときに 「DBを開いたらいつも同じ景色になっている」 ことは、想像以上に開発体験を安定させてくれました。

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。