はじめに
「COBOLはJavaに移植できます。構文が違うだけですから」
この言葉を、提案書や設計レビューで何度聞いただろうか。そして同じ数だけ、その後のプロジェクトが炎上する現場を見てきた。
COBOLからJavaへの移植が失敗するのは、技術力の問題ではない。「何を移植しているのか」の認識が間違っていることが原因だ。
この記事では、現場で繰り返されてきた失敗を3つのパターンに整理し、なぜそれが起きるのかを解体する。
パターン1. 「構文変換で済む」と思った──業務ロジックの意味を読まなかった
起きること
最初に行われるのが「対応表の作成」だ。
COMPUTE → =(代入)または算術演算
MOVE → =(代入)またはコピー
PERFORM → メソッド呼び出し
IF/ELSE → if/else
EVALUATE → switch
この対応表を使って機械的に変換すると、「コンパイルが通るJavaコード」は生成できる。しかし正しく動くJavaコードにはならない。
REDEFINES句という地雷
COBOLのREDEFINES句は、同じメモリ領域を複数の型として解釈する宣言だ。
01 CUSTOMER-RECORD.
05 CUST-TYPE PIC X(1).
05 CUST-DATA PIC X(50).
05 CUST-DATA-CORP REDEFINES CUST-DATA.
10 CORP-NAME PIC X(30).
10 CORP-CODE PIC X(10).
10 CORP-FLAG PIC X(10).
05 CUST-DATA-INDIV REDEFINES CUST-DATA.
10 INDIV-NAME PIC X(20).
10 INDIV-DOB PIC X(8).
10 INDIV-ADDR PIC X(22).
CUST-DATAという50バイト領域を、法人用(CUST-DATA-CORP)と個人用(CUST-DATA-INDIV)の2種類の構造として使い回している。実行時にCUST-TYPEの値によってどちらとして読むかが変わる。
Javaに変換するとき、これを単純にクラスとフィールドに置き換えると:
// 間違った変換
class CustomerRecord {
String custType;
String custData; // ← CUST-DATAを単純に文字列で持つ
String corpName; // ← REDEFINES先を独立フィールドとして持つ
String corpCode;
String corpFlag;
String indivName;
String indivDob;
String indivAddr;
}
この変換では、メモリ上で同一領域を共有しているという事実が失われる。COBOLではCUST-DATAに書き込めばCORP-NAMEの先頭30バイトも同時に変わるが、Javaの独立フィールドではそうならない。
REDEFINESの変換には2段階ある
ここで重要な区別がある。REDEFINESの変換には**「意味的な排他性の再現」と「メモリ共有の副作用の再現」**の2段階があり、必要なレベルが異なる。
ステップ1:意味的な排他性の再現
COBOLのCUST-TYPEによって法人/個人を切り替えるという「排他的な解釈」を表現するなら、sealed interfaceとrecordが適切だ。
// 意味的な排他性を再現するアプローチ
sealed interface CustData permits CustDataCorp, CustDataIndiv {}
record CustDataCorp(String corpName, String corpCode, String corpFlag)
implements CustData {}
record CustDataIndiv(String indivName, String indivDob, String indivAddr)
implements CustData {}
class CustomerRecord {
String custType;
CustData custData; // 型によって解釈が変わることを型システムで明示
}
ただしrecordは不変(Immutable)であるため、COBOL側で「CUST-DATAの一部に書き込んだらCORP-NAMEとして読み出す値もリアルタイムに変わる」というメモリ共有の副作用に依存したロジックがある場合は、このアプローチでもバグになる。
ステップ2:メモリ共有の副作用まで完全再現する
元コードがREDEFINESの副作用(一方に書くと他方も変わる)を意図的に使っている場合は、byte[]でバイト領域を直接管理する必要がある。
// メモリ共有の副作用まで完全再現する場合
class CustomerRecord {
private final byte[] raw = new byte[51]; // 1(CUST-TYPE) + 50(CUST-DATA)
// CORP-NAME: bytes[1..30]
public String getCorpName() {
return new String(raw, 1, 30, StandardCharsets.UTF_8).stripTrailing();
}
public void setCorpName(String val) {
byte[] b = String.format("%-30s", val).getBytes(StandardCharsets.UTF_8);
System.arraycopy(b, 0, raw, 1, 30);
// raw[1..30]を書き換えれば、getIndivName()の先頭20バイトも即座に変わる
}
// INDIV-NAME: bytes[1..20](CORP-NAMEと先頭20バイトが重なる)
public String getIndivName() {
return new String(raw, 1, 20, StandardCharsets.UTF_8).stripTrailing();
}
}
どちらのアプローチが必要かは、元のCOBOLコードがREDEFINESの副作用に依存しているかどうかを静的解析で確認してから決める。副作用依存の有無を確認せずにステップ1だけで済ませると、実データで初めてバグが出る。
PICのバイト長とJava型の桁落ち
COBOLのPIC句はバイト精度で型を定義する。
01 AMOUNT PIC 9(13)V99 COMP-3. * 整数13桁・小数2桁のパック10進数
01 RATE PIC 9(3)V9(4) COMP-3. * 整数3桁・小数4桁
01 RESULT PIC 9(15)V99 COMP-3. * 整数15桁・小数2桁
これをJavaのdoubleに変換すると、浮動小数点の丸め誤差が混入する。
// 危険な変換
double amount = 1234567890123.45;
double rate = 1.5000;
double result = amount * rate;
// → 浮動小数点誤差で微妙にズレる
金額計算で1円の誤差が許されない金融システムにおいて、この変換は致命的だ。正しくはBigDecimalを使い、スケールをPIC句の定義から正確に引き継がなければならない。
// 正しい変換
BigDecimal amount = new BigDecimal("1234567890123.45");
BigDecimal rate = new BigDecimal("1.5000");
BigDecimal result = amount.multiply(rate)
.setScale(2, RoundingMode.HALF_UP);
「動いた」と「正しく動いた」は別物
構文変換によって生成されたJavaコードは、単体テストでは通ることがある。問題は本番データを入れたときに出る。
REDEFINESを誤変換したコードは、法人顧客と個人顧客のデータが混在する実データで初めて破綻する。PICの精度を失ったコードは、大きな金額・多くの桁数の計算で初めて誤差を出す。
「コンパイルが通った」「テストが通った」は、「正しく移植できた」の証明にならない。
パターン2. 「バッチをREST APIに置き換えた」──実行モデルを根本から誤解した
起きること
COBOLで書かれたバッチ処理を、JavaのSpring BootでREST APIとして再実装する。一見モダンに見えるこのアプローチが、しばしば壊滅的な結果をもたらす。
COBOLバッチの実行モデル
COBOLバッチは「大量のレコードをシーケンシャルに、1件ずつ処理する」ために設計されている。
PROCEDURE DIVISION.
MAIN-PROCESS.
OPEN INPUT TRANSACTION-FILE
OPEN OUTPUT RESULT-FILE
PERFORM UNTIL END-OF-FILE
READ TRANSACTION-FILE
AT END SET END-OF-FILE TO TRUE
NOT AT END
PERFORM PROCESS-RECORD
END-READ
END-PERFORM
CLOSE TRANSACTION-FILE
CLOSE RESULT-FILE
STOP RUN.
PROCESS-RECORD.
* 1レコードの処理(数百行のビジネスロジック)
このコードは夜間バッチで1,000万件のレコードを処理することを前提として設計されている。メモリ上には常に「1件分のレコード」しか存在せず、I/Oは物理的にシーケンシャルに流れ続ける。
REST APIへの置き換えで何が壊れるか
これをREST APIに置き換えると、以下の問題が必ず発生する。
① タイムアウト
// 間違ったアプローチ
@PostMapping("/process-transactions")
public ResponseEntity<String> processAll() {
List<Transaction> all = transactionRepo.findAll(); // 1,000万件をメモリに
for (Transaction t : all) {
process(t);
}
return ResponseEntity.ok("完了");
}
1,000万件を処理するのに数時間かかるバッチを、HTTPリクエストのタイムアウト(通常30〜60秒)で受け取ろうとする。ALBのタイムアウト、Spring MVCのタイムアウト、クライアントのタイムアウトのどれかで必ず切れる。
② メモリ枯渇
findAll()で全件をリストに載せた瞬間、ヒープが枯渇してOOMKillerに殺される。COBOLが「1件しかメモリに載せない」設計で1,000万件を処理できていた理由が、ここで初めて理解される。
③ トランザクション境界の崩壊
COBOLバッチは通常、チェックポイントという仕組みで「N件処理したら中間コミット」を行う。途中で失敗しても最後のチェックポイントから再開できる。
* チェックポイントの概念(擬似コード)
IF RECORD-COUNT > 10000
EXEC SQL COMMIT END-EXEC
MOVE 0 TO RECORD-COUNT
END-IF
これはIF文の中で手続き的にコミット境界を制御する「泥臭いエラーハンドリング」だ。失敗したらどこから再開するか、ロールバック範囲はどこまでか──すべてをプログラマが手動で管理している。
Spring Batchを使っても詰まる理由
「Spring Batchを使えば解決する」という話があるが、それだけでは十分ではない。
Spring Batchは**チャンクモデル(Chunk-oriented Processing)**を基本とする。ItemReaderでN件読み込み、ItemProcessorで変換し、ItemWriterでまとめて書き込む、という宣言的なトランザクション管理だ。
// Spring Batchのチャンクモデル(概念)
@Bean
public Step transactionStep() {
return stepBuilderFactory.get("transactionStep")
.<Transaction, Result>chunk(10000) // 1チャンク = 10,000件
.reader(transactionItemReader())
.processor(transactionProcessor())
.writer(resultItemWriter())
.faultTolerant()
.skip(ValidationException.class) // スキップ設定
.retry(DeadlockLoserDataAccessException.class) // リトライ設定
.build();
}
ここで問題になるのがCOBOLのチェックポイントとSpring Batchのチャンク境界の非対称性だ。
COBOLのチェックポイントは「処理件数や特定フラグの状態を見て動的に決まる」手続き型の制御だ。一方Spring Batchのチャンクは「N件ごとに固定」という宣言的な制御だ。
COBOLのエラーハンドリングが「特定のエラーコードが来たら500件ロールバックして再処理、ただし日付が変わっていたら中断」のような複雑な状態依存の制御だった場合、これをSpring BatchのSkip/Retry/SkipPolicyにどうマッピングするかでエンジニアは頭を抱えることになる。フレームワークが変わっても、設計の理解なしには意味がない。
パターン3. 「GOTOをなくして構造化した」──制御フローの意味を破壊した
起きること
COBOLにはGO TO文がある。構造化プログラミングの観点では「悪」とされる制御フローだ。JavaエンジニアはこのGO TOを見た瞬間に「消して構造化しよう」と考える。これが3つ目の失敗パターンだ。
COBOLのGO TOが担っている意味
COBOLのGO TOは単純なジャンプではなく、業務処理の状態遷移を表現していることがある。
PROCESS-PAYMENT.
IF PAYMENT-TYPE = 'CARD'
GO TO CARD-PROCESS
END-IF
IF PAYMENT-TYPE = 'CASH'
GO TO CASH-PROCESS
END-IF
GO TO ERROR-PROCESS.
CARD-PROCESS.
PERFORM VALIDATE-CARD
IF CARD-INVALID
GO TO ERROR-PROCESS
END-IF
PERFORM CHARGE-CARD
GO TO PAYMENT-END.
CASH-PROCESS.
PERFORM VALIDATE-CASH
PERFORM RECORD-CASH
GO TO PAYMENT-END.
ERROR-PROCESS.
PERFORM LOG-ERROR
PERFORM NOTIFY-OPERATOR.
PAYMENT-END.
EXIT.
一見複雑に見えるが、制御フローグラフ(CFG)で可視化すると、これは明確な状態機械だ。
PROCESS-PAYMENT
├─ CARD → CARD-PROCESS → [INVALID → ERROR-PROCESS]
│ → CHARGE-CARD → PAYMENT-END
├─ CASH → CASH-PROCESS → PAYMENT-END
└─ OTHER → ERROR-PROCESS
機械的な「構造化」で何が壊れるか
これをif/elseに機械的に変換すると:
// 危険な変換
void processPayment() {
if (paymentType.equals("CARD")) {
validateCard();
if (cardInvalid) {
logError();
notifyOperator();
return; // ← ERROR-PROCESSの処理を「早期リターン」で代替
}
chargeCard();
return;
}
if (paymentType.equals("CASH")) {
validateCash();
recordCash();
return;
}
logError();
notifyOperator();
}
単純なケースではこれで動く。しかし問題はPERFORM THRUとALTER文だ。
PERFORM THRUの罠
PERFORM THRUは「AからBまでの段落を順番に実行する」という命令だ。
PERFORM STEP-A THRU STEP-C.
STEP-A.
MOVE 'X' TO FLAG.
STEP-B.
IF FLAG = 'X'
MOVE 100 TO COUNTER
END-IF.
STEP-C.
EXIT.
STEP-AからSTEP-Cまで順番に実行されるが、段落はGO TOによって外から割り込まれる可能性がある。PERFORM THRUの範囲内の段落が、別の文脈からGO TOで飛び込まれているコードが存在することがある。
機械的に「段落をメソッドに変換」すると、この暗黙の制御フローが切れる。
ALTER文という絶滅危惧種
さらに凶悪なのがALTER文だ。
ALTER GOTO-LABEL TO PROCEED TO NEW-TARGET.
これは実行時にGO TOの飛び先を書き換える命令だ。つまり「このGO TOがどこに飛ぶか」はソースコードを読んでもわからない。実行時の状態によって変わる。
ALTER文を含むCOBOLコードを静的に解析すると、制御フローグラフに「実行時にしか決まらないエッジ」が生まれる。これをif/elseに変換する方法は、原理的に静的解析だけでは確定できない。
なお、ALTER文はCOBOL 85で既に非推奨となっており、多くの現場では社内コーディング規約で使用が禁止されてきた。2026年現在のモダナイゼーション案件で実際に残っているケースは稀だ。ただし**「稀」は「ゼロ」ではない**。超弩級の基幹システムの深部に眠っていることがあり、万が一残っていたら静的解析が原理的に詰む。発見したら最優先で対処が必要だ。
「構造化した」と「意味を保った」は別物
GO TOを消してコードが「きれいになった」とき、それは制御フローの形を変えただけかもしれない。業務ロジックが表現していた状態遷移の意味が、正しく引き継がれているかの検証は別問題だ。
CFGレベルで元のコードと変換後のコードが同等であることを確認しなければ、「構造化した」は「動作が変わった」と同義になりうる。
なぜこの3つが繰り返されるのか
「読めれば移植できる」という過信
COBOLは英語に近い可読性を持つ言語だ。COMPUTE RESULT = A * Bと書かれていれば、Javaエンジニアでも意味が読める。この「読める」という感覚が「移植できる」という過信につながる。
しかし読めることと意味を理解することは別だ。REDEFINESが副作用依存かどうか、PERFORM THRUの範囲に何が含まれるか、ALTERがどの段落に影響するか──これらはコードを「読む」だけでは把握できない。静的解析によって制御フローとデータフローを可視化して初めてわかる。
移植とリライトの混同
「移植(Migration)」と「リライト(Rewrite)」は別物だ。
| 移植 | リライト | |
|---|---|---|
| 目的 | 動作を正確に再現する | 同じ業務を別の設計で実現する |
| 成功基準 | 元コードと同一の出力を保証 | 新しい設計が要件を満たす |
| リスク | 意味の取りこぼし | 業務知識の欠落 |
| 期間 | 長い(解析が必要) | 長い(設計が必要) |
多くのプロジェクトは「移植」を目標に掲げながら、実質的に「リライト」を行っている。そのため移植の厳密性(元コードとの動作一致)も、リライトの設計品質(新しいアーキテクチャの整合性)も、どちらも担保されない中途半端な結果になる。
業務知識とコード知識を分離して扱えない
COBOLのコードには2種類の知識が混在している。
| 種類 | 内容 | 扱い方 |
|---|---|---|
| コード知識 | 構文・制御フロー・データ構造 | 静的解析で機械的に抽出可能 |
| 業務知識 | なぜこの条件分岐があるのか、この処理順序の意味 | 人間の解釈が必要 |
「移植」ではコード知識を正確に引き継ぐことが最優先だ。業務知識の解釈は次のステップだ。この順序を守らずに「まず業務を理解してJavaで書き直す」というアプローチをとると、コード知識(特に例外処理や境界条件)が失われる。
正しいアプローチ──静的解析パイプラインという考え方
失敗パターンの共通点は「コードの表面(構文)だけを見ている」ことだ。正しいアプローチは、コードの内部構造を段階的に解析してから変換することだ。
COBOL ソースコード
↓ 字句解析・構文解析
AST(抽象構文木)
─ REDEFINESの多重解釈・副作用依存の有無を保持
─ PIC句の精度情報(スケール・桁数)を保持
↓ 制御フロー解析
CFG(制御フローグラフ)
─ GO TO/PERFORM THRUの実際のフローを可視化
─ ALTER文の影響範囲を特定(不確定エッジとして記録)
↓ データフロー解析・SSA変換
SSA(静的単一代入形式)
─ 変数の定義・参照の追跡
─ REDEFINESの副作用依存箇所を特定
↓ 意味解析・IR生成
IR(中間表現)
─ 業務ロジックの意味単位に分解
─ LLMによる意味解釈はここで適用
↓ コード生成
Java / Kotlin
─ BigDecimalスケールはPIC句から自動生成
─ REDEFINESは副作用依存の有無でsealed/byte[]を選択
─ チェックポイントはSpring BatchのChunk境界に対応
このパイプラインの核心は**「構造解析は決定論的に、意味解釈のみをLLMに委ねる」**という原則だ。
LLMに「このCOBOLをJavaに変換してください」と丸投げすると、表面的な変換は行えるが、REDEFINESの副作用依存・PERFORM THRUの暗黙フロー・ALTER文の不確定エッジを正確に保証できない。LLMが担うべきは、静的解析によって抽出された業務ロジックの「意図の解釈」だけだ。構造はエンジニアリングで解く。
おわりに
COBOLをJavaに移植することは、「古いコードを新しい言語で書き直す」ことではない。**「50年間積み上げられた業務知識を、制御フローとデータ構造の精度を保ったまま現代の言語に再実装する」**ことだ。
3つの失敗パターンはすべて、この本質への理解が欠けたときに起きる。
- 構文変換はREDEFINESの副作用とPICの精度を失う
- バッチをAPIに置き換えると実行モデルが崩壊する
- GO TOを消すと状態遷移の意味が失われる
「なぜ失敗するのか」を知ることが、「どう成功するか」への第一歩だ。
本記事では移植の失敗パターンを中心に述べた。
REDEFINES・PERFORM THRU・ALTER の解析や、COBOL バッチ移行方式、AST/CFG/SSA/IR パイプラインなどの詳細技術は、今後の検討テーマとして別途整理していく予定である。
また、**「なぜ日本のメインフレームモダナイゼーションが2035年を期限に動き出しているのか」**については、noteの連載で詳しく論じている。
第0章 日本の汎用機市場の現在地と2035年問題(note)