【Go】巨大なforループからの脱却。Spring Batch思想とオニオンアーキテクチャで作る堅牢なバッチ処理基盤
はじめに
Goでバッチ処理を書く際、そのシンプルさゆえに「巨大な for ループの中でデータを取得し、計算し、DBを更新する」というベタ書き(手続き型)のコードになりがちではないでしょうか?
初期段階では良くても、運用が進むにつれて以下の問題が発生します。
- ビジネスロジックとインフラ(DB構造やSQL)の密結合
- 各バッチに散らばり、コピペされるログ出力・エラー処理・Slack通知
- 単体テストを書くためにいちいちDBを起動しなければならない
本記事では、過去のプロジェクトで実装して非常に体験が良かった**「Spring Batchのアーキテクチャ思想」と「オニオンアーキテクチャ(DDD)」をGoに持ち込んだバッチ処理基盤**について解説します。
「アーキテクチャを定義するって偉大だな」と実感できる設計の強みと、Goならではのトレードオフについても赤裸々にまとめました。
なぜ「Spring Batch」の思想なのか?
Javaの世界でデファクトスタンダードとなっているSpring Batchには、バッチ処理を堅牢かつクリーンにするための素晴らしい概念があります。これをGoで模倣することで、処理の責務を劇的に整理できます。
1. JobとStepによるワークフロー制御
バッチ全体を Job と定義し、その中に複数の Step を数珠繋ぎにして実行します。
(例:「Step1で古いデータをTRUNCATE」→「Step2で外部APIから大量データを取得・同期」→「Step3で完了通知」)
2. Tasklet と Chunk の明確な使い分け
各Stepは、処理の性質によって2種類に分類します。この境界線を明確にすることが設計の第一歩です。
- Tasklet(単体処理型): レコード単位のループを伴わない、1回完結の処理(例:テーブルのクリーニング、ファイル存在チェック等)。
- Chunk(大量データ処理型): 数百万件のレコードを一定件数(例: 1000件)ごとに読み込み、加工し、コミットする処理。メモリ枯渇(OOM)を防ぎます。
3. 処理の責務分離(Reader / Processor / Writer)
Chunk処理の内部を、さらに3つのコンポーネントに分割します。
- Reader: データを外部(DBやファイル)から読み込み、型安全なDTOに詰める
- Processor: DTOを受け取り、データを加工・計算する(純粋なビジネスロジック)
- Writer: 加工後のデータを外部(DBなど)へ一括保存(バルクインサート)する
ディレクトリ構造とDDDレイヤーへのマッピング
Spring Batchの概念を、オニオンアーキテクチャ(DDD)のレイヤーと組み合わせてGoのディレクトリ構造に落とし込むと、以下のようになります。
cmd/
└── batch/
└── main.go # エントリーポイント(JobLauncher)
internal/
├── framework/ # 【共通基盤層】Spring Batchのコアエンジン
│ ├── runner.go # JobRunner(開始・終了・エラー通知などのライフサイクル制御)
│ ├── job.go # Job定義(複数のStepを順次実行するオーケストレーター)
│ ├── step.go # Step定義(Tasklet / Chunkを統一して扱うインターフェース)
│ └── chunk_executor.go # Chunk制御(Reader -> Processor -> Writer のループとTx管理)
│
├── domain/ # 【ドメイン層】外部依存ゼロのビジネスルール
│ └── user/ # エンティティ、値オブジェクト(VO)、ドメインサービス
│
├── usecase/ # 【アプリケーション層】各Stepの具体的なビジネスロジック
│ └── user_sync/ # 例:ユーザー同期バッチ
│ ├── job.go # Job全体の定義(Step1 -> Step2 の流れを組み立てる)
│ ├── step1_cleanup.go # 【Step1】Tasklet:前処理(旧データ削除など)
│ └── step2_sync/ # 【Step2】Chunk:大量データ同期処理
│ └── processor.go # ItemProcessor(データ加工・ビジネスロジック)
│
└── infrastructure/ # 【インフラ層】技術的詳細・外部接続
└── user_sync/
├── reader.go # ItemReader(DBから逐次取得)
└── writer.go # ItemWriter(DBへバルクインサート)
各要素の責務は以下の通りです。
| ディレクトリ・ファイル | Spring Batch の要素 | オニオン / DDD レイヤー | 役割と構成のイメージ |
|---|---|---|---|
cmd/batch/main.go |
JobLauncher | 最外周(Entry Point) | 実行引数(Job名など)を受け取り、指定されたJobをキックする |
framework/runner.go |
JobRunner | 共通基盤層 | バッチ全体の共通ライフサイクル(ログ出力、実行ログDB記録、エラー時のSlack通知等)を制御 |
usecase/user_sync/job.go |
Job | アプリケーション層 | 「Step1(前処理Tasklet)→ Step2(同期Chunk)」という処理フローの定義 |
usecase/.../step1.go |
Step (Tasklet) | アプリケーション層 | ループを伴わない単体処理。特定テーブルのTRUNCATEやファイル事前チェックなど |
usecase/.../processor.go |
Step (Chunk) | アプリケーション層 | ReaderからDTOを受け取り、ドメインモデルを呼び出して計算。外部依存がないためDBレスで爆速の単体テストが可能。 |
infrastructure/.../reader.go |
ItemReader | インフラストラクチャ層 | DB/API等の外部からデータをChunk単位分読み込む |
infrastructure/.../writer.go |
ItemWriter | インフラストラクチャ層 | 加工後のスライス([]DTO)を受け取り、DBへ一括保存(バルクインサート)する |
このように、「データ取得・保存(インフラ層)」と「加工・計算(ユースケース/ドメイン層)」を明確に分離することで、インフラ起因の変更に強く、テストが極めて容易なバッチが実現します。
さらに、処理の開始・終了・エラー発生時の振る舞い(共通ライフサイクル)を framework/ 側に隠蔽しているため、開発者は「ビジネスロジックを書くこと」だけに集中できます。
エラーハンドリング:即時停止+全ロールバックの割り切り
エラー発生時の挙動については、本家Spring Batchにあるような複雑な「Skip」や「Retry(途中からの再開)」の仕組みをGoで無理に自作することは避けました。
理由は、独自で状態管理やリトライ機構を作り込むとバグの温床になりやすいためです。
基本方針は「エラーが発生したらそのチャンク(トランザクション)を全ロールバックし、処理自体を即時停止(Fatal)させる」という割り切った設計にしています。
中途半端にデータが残らないため、原因調査後にそのまま再実行できる冪等性(べきとうせい)を担保する方が、運用上はるかに安全で実用的です。
デメリット・トレードオフ(Goの並列処理との相性)
ここまでアーキテクチャの偉大さを語りましたが、この設計にはGoならではの弱点(トレードオフ)も存在します。
それは、チャンクトランザクションの中に、Goが得意とする並列処理(goroutine / channel)を極めて組み込みにくいという点です。
例えば Chunk Size = 1000 の中で、Processorの処理をgoroutineでFan-out(並列化)させたとします。
すると処理順序がバラバラになり、もし一部のgoroutineでエラーが発生した場合、共有しているトランザクション(DB接続セッション)のロールバック制御やコンテキストの管理が極めて煩雑になります。
【現実的な解】
Goで並列処理を行いたい場合は、Chunkの内部でgoroutineを回すのではなく、対象データのID範囲などでJob自体を分割し(パーティショニング)、コンテナやプロセスレベルで並列稼働させるアプローチを取るのがバッチ処理としては安全です。
おわりに
Goでバッチ処理を書く際、「とりあえず動くベタ書きの for ループ」を作るのは簡単です。
しかし、型の恩恵を受けられるDTOを用意し、Job/Stepに分割し、Reader/Processor/Writerに責務を分け、共通処理をフレームワークに逃がすことで、バッチ開発のDX(開発者体験)と保守性は劇的に向上します。
「アーキテクチャを定義する」ことの偉大さを実感できる設計ですので、新規バッチ基盤の構築を検討している方の参考になれば幸いです。