8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

大規模レガシーコードの「多段条件分岐フラグ」を読み解く:初期値・蓄積・強制上書きの3段階で捉える調査手法

8
Posted at

はじめに

大規模なJavaレガシーシステムを保守していると、「コードを読んでもなぜその値になるのかわからない」という状況によく遭遇します。

ロジックを追って「ここで1にしているはずなのに、なぜDBには0が入っているんだ?」と首をかしげた経験がある方も多いのではないでしょうか。

今回はサービス申請管理テーブルの「出力フラグ」(以下 output_flag)が特定条件で期待値と異なる動作をした調査プロセスを共有します。単純にgrepするだけでなく、「フラグのライフサイクルを3段階で捉える」 という視点を持つことで問題を解決できました。同じ悩みを持つ方のヒントになれば幸いです。


問題の発端

あるとき、特定の条件下でデータが外部システムへCSV連携されないという不具合が報告されました。

DBを確認すると output_flag = 0(出力なし)になっています。しかしコードを追うと、「出力する(1)」に設定するロジックが確かに存在しています。それなのになぜか0になっている。

// こういうコードが確かに存在している
record.setOutputFlag(StaticNum.FLAG_OUTPUT); // = 1

「どこかで上書きされているはず」という仮説を立て、調査を開始しました。


調査アプローチ:3段階ライフサイクルで読む

フラグの値を追う際、以下の3段階で整理することで、複雑なコードが一気に読みやすくなりました。

ステージ 役割 特徴
Stage 1 初期値の設定 ループ開始時に1回だけ設定される
Stage 2 条件による蓄積・上書き 複数の業務条件が順次フラグを書き換える
Stage 3 最終強制上書き ループ「外」で全件一括上書き(見落としやすい)

コードの「最後の代入がどこか」を意識することが調査の鍵となりました。


Stage 1:初期値

処理開始時にフラグをデフォルト「1(出力する)」として初期化します。

// 商品ループ開始時
int outputFlag = StaticNum.FLAG_OUTPUT;   // = 1
String orderType = Const.ORDER_TYPE_OTHER; // デフォルトのオーダー種別( = 0)

定数定義はシンプルです。

/** 出力する */
public static final int FLAG_OUTPUT = 1;
/** 出力しない */
public static final int FLAG_NOT_OUTPUT = 0;

ここまでは単純明快です。問題はここから先にあります。


Stage 2:条件分岐の蓄積

ひとつの商品レコードに対して、複数の業務条件が順番にフラグを書き換えていきます。今回のコードでは条件の数をカウントすると 8種類 ありました。

2-1. 処理区分チェック(第1関門)

if (processType == null
        || (!Const.PROCESS_ADD.equals(processType)
                && !Const.PROCESS_DEL.equals(processType))) {
    outputFlag = StaticNum.FLAG_NOT_OUTPUT; // → 0

    // 例外: 特定の処理区分の場合は戻す
    if (Const.PROCESS_SPECIAL.equals(processType)) {
        outputFlag = StaticNum.FLAG_OUTPUT; // → 1
        orderType  = Const.ORDER_TYPE_SPECIAL;
    }
}

処理区分が「追加」「削除」以外なら即座に0になりますが、特定の処理区分だけは例外的に1に戻されます。

2-2. リクエスト商品リスト照合(第2関門)

boolean existsInRequest = requestItemCodeList.contains(itemCode);

if (!existsInRequest) {
    if (!Const.PROCESS_DEL.equals(processType)) {
        outputFlag = StaticNum.FLAG_NOT_OUTPUT; // → 0
    } else if (!StaticNum.CHILD_ITEM_CODE_LIST.contains(itemCode)) {
        outputFlag = StaticNum.FLAG_NOT_OUTPUT; // → 0
    }
}

リクエストに含まれていない商品は基本的に0になりますが、「子商品リスト(StaticNum.CHILD_ITEM_CODE_LIST)」に含まれる削除操作だけは0にしない例外があります。

2-3. 環境変数による除外リスト(第3・第4関門)

// 環境変数① CSVに出力しない商品リストA
if (csvExcludeListA != null) {
    for (String excludeCode : csvExcludeListA) {
        if (excludeCode.equals(itemCode)) {
            outputFlag = StaticNum.FLAG_NOT_OUTPUT; // → 0
        }
    }
}

// 環境変数② CSVに出力しない商品リストB(別カテゴリ)
if (csvExcludeListB != null) {
    for (String excludeCode : csvExcludeListB) {
        if (excludeCode.equals(itemCode)) {
            outputFlag = StaticNum.FLAG_NOT_OUTPUT; // → 0
        }
    }
}

プロパティファイルから読み込んだ商品コードリストと一致した場合にのみ0になります。2種類の除外リストがあり、それぞれ異なる商品カテゴリを管理していました。

重要なポイントは「コードに値がハードコードされていない」ことです。 環境変数を確認しないと、実際に何が除外されているか分かりません。

2-4. 特定契約種別+商品契約解約操作の例外(第5関門)

boolean isSpecialContractCancel =
    Const.CONTRACT_TYPE_SPECIAL.equals(contractType)
    && Const.ORDER_CANCEL.equals(orderType);

if (isSpecialContractCancel
        && Const.PROCESS_DEL.equals(processType)
        && specialItemCodeList.contains(itemCode)) {
    outputFlag = StaticNum.FLAG_OUTPUT; // → 強制的に1に戻す
}

特定の契約種別かつ商品契約解約処理の場合、DBから動的に取得した商品コードリストに含まれていれば強制的に1に戻します。「2-3で0にされた後に1に戻る」ケースがここで発生します。

2-5. 特定サービスの削除操作(第6関門)

if ((SERVICE_ID_FOO.equals(serviceOrderId)
        || SERVICE_ID_BAR.equals(serviceOrderId))
        && Const.PROCESS_DEL.equals(processType)
        && TARGET_ITEM_CODES.contains(itemCode)) {
    outputFlag = StaticNum.FLAG_OUTPUT; // → 強制的に1に戻す
}

特定のサービスオーダーIDかつ特定商品コードの削除操作の場合、0→1に戻します。

2-6. 環境変数による包含リスト(第7関門)

// 環境変数③ 強制的に1にする商品リスト(特定オーダー種別のみ有効)
if (Const.ORDER_TYPE_CHANGE.equals(orderType)) {
    for (String includeCode : csvIncludeListC) {
        if (includeCode.equals(itemCode)) {
            outputFlag = StaticNum.FLAG_OUTPUT; // → 1
        }
    }
}

オーダー種別が「変更」の場合だけ有効になる包含リストです。2-3の除外リストとは逆方向に働く環境変数が存在していました。

2-7. 特定操作の固定上書き(第8関門)

if (Const.ITEM_CODE_X.equals(itemCode)
        && Const.PROCESS_CHANGE.equals(processType)) {
    outputFlag = StaticNum.FLAG_OUTPUT; // → 無条件に1
    orderType  = Const.ORDER_TYPE_ADD_OPTION;
}

8つの条件を経た後、outputFlag の値がようやく確定します。

ここまでで十分複雑に見えますが、実はここがゴールではありませんでした。


Stage 3:ループ外での強制上書き(最大の盲点)

商品ループ内で計算された outputFlag は各レコード(record)にセットされ、一連のループが終了した後に、以下のコードが実行されていました。

// 商品ループ終了後
if (!isPrivilegedChannel(request.getChannelCode())) {
    forceOverrideFlags(applyList, request); // ← これが全件上書き
}

forceOverrideFlags の中身を確認してみると:

private void forceOverrideFlags(
        List<ApplyRecord> applyList,
        RequestBean request) {

    for (ApplyRecord record : applyList) {
        record.setOutputFlag(StaticNum.FLAG_NOT_OUTPUT); // 全件 → 0
        if (Const.ORDER_CANCEL.equals(request.getOrderType())) {
            record.setOrderType(Const.ORDER_TYPE_CANCEL);
        }
    }
}

isPrivilegedChannel の実装:

public static boolean isPrivilegedChannel(String channelCode) {
    // 環境変数からチャネルコードリストを取得
    String[] privilegedChannels =
        EnvProps.get("PRIVILEGED_CHANNEL_CODE_LIST").split(",");
    return Arrays.asList(privilegedChannels).contains(channelCode);
}

つまり:

チャネルコードが環境変数 PRIVILEGED_CHANNEL_CODE_LIST に含まれない場合、Stage 2 で計算したすべての outputFlag の値が無効化され、全件 0 になる。

これが今回のバグの真因でした。

Before / After で整理すると

Before(修正前の認識):

Stage 2 の出力フラグ判定 → DB保存値
(Stage 3 の存在に気づいていなかった)

After(実際の処理フロー):

Stage 1: outputFlag = 1(デフォルト)
  ↓
Stage 2: 8条件を経て outputFlag が確定(0 or 1)
  ↓
Stage 3: チャネルコードチェック
  - 特権チャネル → Stage 2 の結果をそのまま使用
  - 一般チャネル → 全件 outputFlag = 0 に強制上書き ← ここが見落とされていた
  ↓
DB保存

調査のポイント:「最後の代入」を探せ

実際にやったこと

1. setOutputFlag( を全件 grep

  • 呼び出し元ファイルが25件ヒット

2. 呼び出し箇所を「ループ内」「ループ外」に分類

  • ループ「外」の呼び出しが3箇所あることに気づく

3. ループ外の代入をコールグラフで追跡

  • forceOverrideFlagsisPrivilegedChannel → 環境変数参照 という連鎖を発見

4. 実際の環境変数の設定値を確認

  • 調査対象の環境では PRIVILEGED_CHANNEL_CODE_LIST に当該チャネルコードが含まれていなかった → 原因特定

教訓

フラグの最終値 = 最後に代入した処理の結果

当たり前のようですが、「最後の代入がループ外にある」という可能性は見落としやすいです。特にループ内でさんざん条件分岐した後、ループ外で一括上書きするパターンは、コードを「上から読む」だけでは気づきにくいです。


同パターンが現れる他のモジュール

今回の調査後、リポジトリ全体で isPrivilegedChannel をgrepしたところ、複数のサブモジュールで同じパターンが使われていました。

モジュールA(申込処理)         ← 今回調査した箇所
モジュールB(特定サービス申込処理)← 同じパターンあり
モジュールC(サポート申込処理)  ← 同じパターンあり
モジュールD(提携サービス申込処理)← 同じパターンあり

つまりこのパターンは「アーキテクチャ上の設計方針」として採用されていました。チャネルコードが特定のリストに含まれない場合はフラグを強制的に無効化するという仕様が、複数モジュールに横断的に存在していたわけです。

ドキュメントにはどこにも書かれていませんでした。


まとめ:3段階チェックリスト

フラグ値の調査時に今日から使えるチェックリストをまとめます。

□ Stage 1: 初期値はどこで設定されているか?
    → ループ開始直前の変数宣言を探す

□ Stage 2: どんな条件がフラグを書き換えるか?
    → setXxxFlag() を全件 grep し、ループ「内」の呼び出しを列挙

□ Stage 3: ループ外での後処理はないか?(★ここが最重要)
    → setXxxFlag() のループ「外」の呼び出しを確認
    → 引数に List が渡されている「全件上書き系」のメソッドに注意
    → 環境変数・設定値に依存したフラグがある場合は実際の設定値も確認

「なぜ期待と違う値になっているのか」がわからないとき、Stage 3 から逆引きすると解決が速いです。

最終的にDBに保存された値から遡って setXxxFlag(FLAG_NOT_OUTPUT) の呼び出し元を探し、その呼び出し条件を確認するアプローチが今回は最も効率的でした。


おわりに

大規模レガシーコードには「なぜそう動くのか」がコードを読むだけではわからないケースが多々あります。今回のポイントをまとめると:

  • 多段条件分岐フラグは「最後の代入」が正解
  • ループ「外」の代入は見落としやすい(特にメソッド分割されていると)
  • 環境変数依存の判定は「コードだけ読んでも分からない」ことがある
  • 同じパターンが複数モジュールに横断していることが多い

リファクタリングを検討する場合は、まずこの3段階を図式化してから進めると、要件の抜け漏れを防ぎやすくなります。

レガシーコードと格闘する日々を送っている方の参考になれば幸いです。

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?