最古参プロダクトの認証基盤リプレースに XP とレガシーコード改善で挑む

はじめに

こんにちは。Speeda の二要素認証開発チームです。
本日 6/8 から開催されている 2026年度 人工知能学会全国大会に、弊社ユーザベースも協賛しております。
スポンサーブースもございますので、よろしければ足をお運びいただけますと幸いです。

先日、私達が提供している経済情報プラットフォーム「Speeda」の認証基盤をリプレースしました。
多くの方にご利用いただいている弊社の最古参プロダクトであり、お客様にご不便をおかけしないよう注意を払う必要がありました。
このように慎重さが求められるプロジェクトでは、リスクコントロールのために運営が保守的になってしまうこともあるのではないでしょうか。

それでも私達はエクストリーム・プログラミング (XP) による開発を諦めませんでした。
結果的に大きな混乱を招くことなく、リプレースは終えられたと自負しています。
今回はプロジェクト全体を振り返りながら、その成功に寄与したと思われる要因を明らかにしていこうと思います。

どんなプロジェクトだったか?

プロジェクトのゴール

プロジェクトのゴールは「Speeda のユーザーにログイン時の二要素認証を提供すること」でした。
昨今世の中で増加している不正アクセス対策を講じて、お客様に安心してプロダクトをご利用いただけるようにするのが目的です。

この二要素認証を導入するため、これまでプロダクトに実装されていた ID/パスワードによる認証を廃止して、新規に構築した OpenID Connect (OIDC) ベースの認証基盤を利用する構成に変更しました。
この新しい認証基盤に二要素認証を実装したことで Speeda に限らず、最近リリースした Speeda AI Agent シリーズの各プロダクトでも同じログイン体験ができるようになりました。

チーム構成

チーム構成は以下のとおりでした。

  • プロダクトオーナー: 1 名
  • デザイナー: 1 名
  • エンジニア
    • 認証基盤の開発チーム: 3 名
    • Speeda の二要素認証開発チーム: 4 名

認証基盤の開発チームは、私達とは別の組織に所属しています。こちらはスクラムを採用していますが、アジャイルな開発を志向しているという点は変わりません。

取り組んだこと

口頭によるコミュニケーションを大事にする

このプロジェクトは、認証基盤の開発チームが先行して開発を始めていました。
既に二要素認証の基本的な実装は出来上がっており、Speeda への組み込みが決まったタイミングで私達が定例ミーティングに呼ばれました。
それ以降、前述のチームによる週次の議論が始まりました。
後に聞いたところによると、それまでは認証基盤の開発チームが各ステークホルダーと個別にコミュニケーションを取っていたそうです。
しかし今ひとつ非効率な感覚があり、このような関係者を全員集める形に切り替えたと聞きました。

図らずも、これは XP の「全員同席」プラクティスに通ずるものがあると感じました。
専門性に応じて分業しているとはいえ、みな同じ目的に向かって走る仲間です。
様々な意思決定において、それぞれの知見をかけ合わせた最適解を出す。そのためには伝言ゲームを排して、全員揃った場で議論したほうが素早く結論にたどり着けると再確認しました。
チーム内だけでは決定できないときは、他部署のメンバーも招待して会話しました。

一方で、すべての議題を定例ミーティングまで寝かせてはなりません。大事なのは、素早い意思決定のために最適な手段を選び続けることです。
したがってプロジェクトの方針を大きく左右するようなトピックがある場合は、随時関係者を招集して議論しました。

特にお互いのプログラムが密に連携するエンジニアチーム同士は、細やかな仕様のすり合わせが必要になります。
彼らは私達のようにペアプログラミングをしておらず、そのため Gather1 にも常駐していません。
自チームの他メンバーはそうした状況に加え、彼らと面識がなかったこともあってか少々話しかけにくさを感じていたようです。
認証基盤チームは、主にビジネスサイド向けに問い合わせや作業依頼を受け付けるワークフローを Slack に用意していました。
プロジェクト序盤は、私達のチームからはそれを使って相談を持ちかけることが多かったです。
しかしテキストコミュニケーションに不慣れなメンバーが多く、こちらの意図が正しく伝わるような文章を組み立てるのに時間を要していました。
結果的に、非効率的なコミュニケーションになっていたと思います。

そこでプロジェクト定例で認証基盤チームに、今後は予定が空いていれば急にミーティングを入れさせてもらいたいとお願いしました。ペアプロで常時話している私たちには、会話ベースのコミュニケーションの方が向いていたからです。
当然快諾いただいたのですが、明示的に許可を得たことで、彼らと面識のなかったメンバーも話しかける際の心理的ハードルが下がったようでした。

リプレースできる条件を定義する

当たり前にやるべきことではありますが、どれだけ多くの視点から検討できるかによって質は大きく変わります。
もちろんゴールは「ユーザーが躓くことなくログインできること」です。そうすると、必然的にログイン処理の改修は必要になります。
しかし、本当にそれだけで良いのでしょうか?そう考えて、私達は以下の観点から思考を進めていきました。

ログインに関連するイベントを洗い出す

「ユーザーが Speeda のアカウントを手に入れてからログインするまで」にスコープを広げて、どのようなイベントがあるかを考えてみた図がこちらです。

ユーザーが Speeda のアカウントを手に入れてからログインするまで

この図から、以下の事柄が読み解けます。

  • 既存ユーザーか、新規ユーザーかでフローが異なる
  • 既存ユーザーは Speeda に設定されているパスワードでログインできることを期待している
    • しかしパスワードはハッシュ化して保存しているため、ユーザーが設定した値をシステムが知ることはできない
    • そこで、一度従来のログイン認証をしてもらい、認証に成功したらパスワード欄に入力された値を用いて新基盤のアカウントを作成する必要がある
  • 新規ユーザーも管理者ユーザーか、一般ユーザーかによってパスワードが設定されるタイミングが異なる
    • 異なる画面でパスワードを入力するため、それぞれに新基盤のアカウントを作成する処理を入れる必要がある

当時は自覚していませんでしたが、これは簡易的なイベントストーミングの実践と言えるかもしれません。

パスワードが更新されたとき

一般的な Web サービスと同様に、Speeda ではユーザー自身で自由にパスワードを更新できます。
またパスワードを忘れてしまった場合は、登録されているメールアドレスに再設定用のリンクを送ることができます。

これらのパスワードが更新される場面において、新基盤アカウントのパスワードを更新する実装が必要になります。

同一人物が複数アカウントを使い分けている際のログイン体験を考慮

Speeda は契約しているプランによって、利用可能な機能が異なります。
それゆえに機能を使い分けるために複数のプランを契約して、同一人物がアカウントを切り替えてログインするユースケースが存在すると、プロダクトオーナーから聞きました。
よって、こうしたユーザーのログイン体験が悪化しないようにしなければなりません。

基本的なログイン処理が完成したタイミングで、このパターンを想定した動作検証を行いました。
すると、以下の事象が発生することに気づきました。

  1. アカウント A のメールアドレスを入力
  2. アカウント A のパスワードを入力
  3. 二要素認証の認証コードを入力
  4. アカウント A でログイン完了
  5. Speeda のセッションがタイムアウトする
  6. アカウント B のメールアドレスを入力
  7. アカウント A でログイン完了

このように、ユーザーの意図しないアカウントでログインしてしまいます。
これは 5. で Speeda のセッションがタイムアウトしても、新認証基盤のセッションは有効だったのが原因でした。
そこで「新認証基盤のセッションが有効な状態で、当該セッションのものと異なるアカウントでログインを試みた場合は、従前のセッションを破棄する」対応を、認証基盤チームに行っていただきました。

契約プランの変更オペレーションへの対応

一部の契約プランのユーザーは、当面は引き続きプロダクトに実装されている従来の ID/パスワードによるログイン認証のままになります。
しかし、そうしたユーザーも AI 機能が利用できる新しいプランへの移行が進んでいます。このタイミングで二要素認証を適用する必要があります。

そこで契約管理の業務プロセスを設計している BPM チームの方に、契約変更のオペレーションの詳細を伺いました。そのオペレーションの中で、二要素認証対象に切り替えるフローを組み込んでいただきました。

一部ログイン手段の廃止

これまで Speeda では NewsPicks アカウントによるログインを提供していましたが、二要素認証は実装されていませんでした。
セキュリティ向上のために二要素認証を導入するという目的に照らすと、これを残してしまうと対策が不完全になると考えました。

また新認証基盤に移行していただくことで、複数プロダクトに同一のアカウントでログインできるようになります。その観点でも新基盤に一本化するのが望ましいと考え、プロダクトオーナーと協議のうえ廃止する決断をしました。

少しずつ不確実性を下げる

頻繁にリリースする

Speeda の Product Team の方針として、ユーザーストーリーは小さい単位で作っています。
その意図はチームメンバーが書いた過去記事2に詳しく書かれていますが、端的に言えばフィードバックサイクルを頻繁に回すためです。

今回も社内ユーザーにだけ開発中のログイン画面を公開して、実際にプロジェクトメンバーが触ってみることを繰り返しました。前節に挙げた観点の一部や、考慮が漏れていたエッジケースはこの営みで発見されました。
それらもユーザーストーリーとして管理して、都度優先順位を考えて計画に反映していきました。

実装できていなくても想像してみる

二要素認証は今年の 4 月にリリースしたいという事情がありました。
またログイン体験を悪化させないという前提を踏まえると、開発終盤で想定外が多数見つかることはプロジェクトの致命傷になります。

そこで私達は未実装の機能についても、想像力を働かせて想定されるリスクを意識的に発見するよう心がけました。
そのために毎週の関係者全員で集まる定例ミーティングの前には、一度開発を止めて自チームのメンバー全員でこうした内容の議論を行いました。
思惑通りリスクの早期発見ができ、開発終盤に一般公開までに必要なストーリーが大幅に増えることは避けられました。

もちろん実装してみないと得られないフィードバックはありますし、想像よりも実践後に得られるもののほうが解像度が高く良質です。
それでも、このようにデッドラインが決まったプロジェクトでは、その質が劣るとしても想像によって得られるフィードバックにかなり助けられました。

というのも私達のチームは技術面・アジャイルなマインドセットともに、決して習熟度が高くなかったためです。それゆえチームの開発スピードを維持するのが難しいという事情がありました。
進捗が思わしくなければアラートを上げるわけですが、そのタイミングが遅いほど取りうる選択肢は減ってしまいます。

個人的には「リスクへの対処は顕在化してからではなく、可能性に気づき次第すぐに行動を始める」べきだと考えています。結果的に問題なかったとしても、それによって何か不利益があるわけではないため、基本的に良いことしかないと思います。
おかげで昨年末に、私達のチームだけでは 4 月までに間に合わない可能性が濃厚であることを申し出ることができ、認証基盤の開発チームに Speeda の開発を手伝っていただくよう調整できました。

テスタビリティの高い実装にリファクタリングする

Speeda は、Apache Wicket という Web フレームワークで構築されています。Swing のように、全てを Java のコンポーネントで記述することが特徴のフレームワークです。

ログイン処理に関する従来のソースコードは、十分にユニットテストが用意されているとは言えませんでした。特に画面のイベントハンドラから呼び出される処理は、その画面クラス自体に実装されていることが多かったです。その処理内で画面上のコンポーネントを操作するような、画面と密結合した実装が目立ちユニットテストの実装が困難でした。

そこで今回のプロジェクトでは、以下の手法を用いて少しずつユニットテストできる範囲を増やしながら開発を進めました。

Clean Architecture を意識した設計

以下のような設計により、ビジネスロジック層と UI 層を分離しました。

Speeda における Clean Architecture の実践

このように画面クラス自身に Presenter の役割を兼任させることで、Wicket のライフサイクルに沿う形で実装できました。
これから具体的なコード例を挙げて解説していきます。

Usecase の実装

今回は例として、ボタンクリック時に固定のエラーメッセージを出すものとしましょう。

package com.uzabase.speeda.demo.usecase;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class Usecase {
    
    private final Presenter presenter;
    
    public void execute() {
        presenter.showError("test");
    }
}

Presenter の実装

引数で受け取ったメッセージをエラーとして画面に表示する Presenter を、以下のように定義します。

package com.uzabase.speeda.demo.presenter;

public interface Presenter {
    void showError(String message);
}

Wicket の WebPage を継承した画面クラスにインナークラスを用意して、この Presenter を実装します。
あわせて、画面上のボタンクリック時に Usecase を呼び出すイベントハンドラを登録しました。

package com.uzabase.speeda.demo;

import com.uzabase.speeda.demo.presenter.Presenter;
import com.uzabase.speeda.demo.usecase.Usecase;
import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.markup.html.form.Button;
import org.apache.wicket.markup.html.panel.FeedbackPanel;

public class DemoPage extends WebPage {

    public DemoPage() {
        add(new FeedbackPanel("feedback"));
        add(new Button("login") {
            @Override
            public void onSubmit() {
                new Usecase(new PresenterImpl()).execute();
            }
        });
    }

    class PresenterImpl implements Presenter {
        @Override
        public void showError(String message) {
            error(message);
        }
    }
}

画面クラス自身が Presenter を実装することもできますが、以下の理由でインナークラスを使う判断をしました。

  • 複数の Presenter が同一シグネチャのメソッドを持つものの、それぞれの挙動は異なる場合の実装が容易である。
  • Presenter のメソッドが実装されている箇所がインナークラス内にまとまり、また画面クラス自身が必要とする処理と分離されることで、見通しが良くなる。

補足

Usecase, Presenter のライフサイクルについて

サンプルコードにある通り、私達はイベントハンドラが呼び出されたタイミングで都度生成しては破棄する方針で実装しました。

非 static インナークラスの Presenter を画面クラスのフィールドで保持すると、外側の画面クラスまでセッションに保存されます。
画面クラスは UI コンポーネントや状態を持つため、オブジェクトのサイズが大きくなりやすいです。それをセッションに保持してしまうと、セッションサイズ肥大化・それに伴うパフォーマンス悪化が懸念されます。
そのため、Usecase の実行時に都度生成し、積極的に GC させる方針にしました。

また Wicket の Ajax 機能を使う場合、イベントハンドラには毎回異なる AjaxRequestTarget が渡されます。
画面を更新するためには Presenter に当該インスタンスを渡す必要があるため、その観点でも Presenter は都度生成するほうが扱いやすいと考えました。

依存性注入について

実際のコードでは、各種 Input Port は DI フレームワークを使って画面クラスに注入しています。
そして、画面クラスに注入されたインスタンスを Usecase のコンストラクタに渡しています。

画面操作が伴わない箇所だけをメソッドに切り出す

新規にフルスクラッチで実装した箇所については、基本的に前述のアプローチを採用しました。
しかし改修箇所が限定的であり、レイヤリングし直すのが工数的にも難しいと判断した場面もあります。

その際は、画面操作が伴わないロジックだけをメソッドに切り出し、画面操作は従来どおりイベントハンドラから呼び出される画面クラスのメソッドに任せました。
切り出したメソッドをユニットテスト可能にするため、以下のいずれかのパターンを適用しました。

static インナークラスを使う例

たとえば、ロジックの戻り値に応じて表示するエラーメッセージを変える処理があったとします。
その場合、このような実装が考えられます。

package com.uzabase.speeda.demo;

import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.markup.html.form.Button;
import org.apache.wicket.markup.html.panel.FeedbackPanel;

import java.io.Serializable;

public class DemoPage extends WebPage {

    private GetMessageLogic logic = new GetMessageLogic();

    public DemoPage() {
        add(new FeedbackPanel("feedback"));
        add(new Button("login") {
            @Override
            public void onSubmit() {
                String message = logic.execute();
                error(message);
            }
        });
    }

    static class GetMessageLogic implements Serializable {
        String execute() {
            return "test";
        }
    }
}

GetMessageLogicDemoPage とは独立してインスタンスが生成できるため、ユニットテストも容易に実装できます。
実際のコードでは、依存関係はコンストラクタで渡すようにしています。

interface のデフォルト実装を使う例

先ほどのコードは、このようにも書き換えられます。

インターフェース

package com.uzabase.speeda.demo;

public interface GetMessageLogic {
    default String getMessage() {
        return "test";
    }
}

画面クラス

package com.uzabase.speeda.demo;

import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.markup.html.form.Button;
import org.apache.wicket.markup.html.panel.FeedbackPanel;

public class DemoPage extends WebPage implements GetMessageLogic {

    public DemoPage() {
        add(new FeedbackPanel("feedback"));
        add(new Button("login") {
            @Override
            public void onSubmit() {
                error(getMessage());
            }
        });
    }
}

いわゆる Mixin パターンによる実装です。
Java の interface はフィールドを持てない制約があるため、必要な依存関係はすべて interface に定義した getter メソッド経由で取得する必要があります。

私達は static インナークラスパターンを適用しようとしてコンストラクタ引数が大量になってしまった際に、それを避ける目的で採用しました。
また Speeda には Lombok が導入されているため、画面クラス上の依存関係フィールドに @Getter アノテーションを付与するだけで getter の実装は完了します。
こうした状況下では、このほうがコードの見通しが良いと考えました。

実装上の注意点

Wicket の画面クラスのフィールドに保持されるクラスは、原則 Serializable を実装する必要があります。
Wicket はステートフルな Web フレームワークであり、コンポーネントやモデルの状態をバイト列に変換して保持しています。 そのため Serializable を実装していない場合、オブジェクトの復元に失敗して内部エラーが発生する可能性があります。

サンプルコードでは省略しましたが、プロダクションコードでは serialVersionUID フィールドによるバージョニングも検討してください。

異常系処理の品質にこだわる

ログイン処理で失敗すると、プロダクトが一切利用できなくなってしまいます。
そんな事態を避けるためにも、あらゆるエラーを想定して適切に処理する必要があります。

しかし目の前のユーザーストーリーを終わらせることに集中していると、それが達成できた時点で即座にリリースしたくなります。 その行動は全く間違っていませんが、一方で異常系パターンは能動的に調べようとしない限り見つけにくいのも事実です。 そのため、私達のチームでは以下に取り組みました。

カンバンに "Crack" レーンを設ける

チームのイテレーション振り返りで、異常系処理の考慮漏れに気づくメンバーが限られていることが課題に挙がりました。
その対策として、FigJam で用意していたカンバンに "Crack" レーンを設けました。

チームで運用していたカンバンのイメージ

ユーザーストーリーの実装が完了して CI による自動テストが成功したあとに、「あえてシステムを壊す (Crack) ような意地悪なテストを考えてみる」という試みです。
対象のストーリーで生まれた差分を見直して、外部 I/O が発生する処理における例外処理の漏れや、内部状態が想定外になるような操作手順を探します。
ここで発見した問題は別途ユーザーストーリーとして切り出して、後ほど対応する形にしました。

というのも開発中の機能はフィーチャートグルで社内メンバーにしか公開していないため、仮に不備があってもユーザーへの悪影響は生じません。 フィードバックサイクルを細かく回すという観点で、当初予定していたスコープが満たせれば本番環境にリリースすることを徹底しました。

異常系の E2E テストを充実させる

受け入れテスト駆動開発 (ATDD) を実践している私達としては、当然 "Crack" レーンで発見された問題についても E2E テストを実装したいところです。
しかし異常系のテストは、意図的に再現することが難しいことがほとんどです。

E2E テストでは認証基盤チームの開発するアプリケーションの代わりに、カスタマイズされていない Keycloak の Docker イメージを使っていました。
正常系テストは容易に実施できた一方で、そこにエラーを注入するにはひと工夫が必要です。
そこで、私達はKeycloak の手前に WireMock を立てて、Speeda は WireMock 経由でアクセスする構成を取りました。

WireMock によるエラーの注入

WireMock はプロキシサーバーとして振る舞うことができます3。この仕組みを使い、このようなマッピング定義を作成しました。

{
    "mappings": [
      {
        "priority": 100,
        "request": {
          "method": "ANY",
          "urlPattern": ".*"
        },
        "response": {
          "proxyBaseUrl": "http://oidc-server:41262",
          "additionalProxyRequestHeaders": {
            "X-Forwarded-Host": "{{request.host}}",
            "X-Forwarded-Port": "{{request.port}}"
          }
        }
      }
    ]
  }

Response Templating 機能4によって、レスポンスの内容を動的に制御することができます。
この仕組みを使って、クライアントからのリクエストの内容に応じた X-Forwarded-* ヘッダーを付与するようにしました。
あわせて Keycloak のコマンドライン引数には --proxy-headers xforwarded を与えて、このヘッダーを解釈できるようにします5

priority に大きな値を設定しているのは、このプロキシルールの優先順位を下げるためです。
異常系のテストでは、一時的に priority の値がそれより小さいマッピング定義を登録すれば良いわけです。
そうすれば異常系のマッピング定義が優先して適用されるため、意図したタイミングでエラーを発生させることができます。

例として、認証基盤から OIDC メタデータが正常に取得できない状況を再現してみます。

{
  "priority": 1,
  "metadata": {
    "case": "empty-metadata"
  },
  "request": {
    "method": "GET",
    "urlPath": "/realms/ub-account/.well-known/openid-configuration"
  },
  "response": {
    "fault": "EMPTY_RESPONSE",
    "fixedDelayMilliseconds": 10000
  }
}

上記のマッピング定義により「10 秒待たされてから空のレスポンスが返る」という振る舞いになります。
これを使って、私達は適切な時間でタイムアウトしてエラーメッセージが表示されるというテストケースを実装できました。

また例のように metadata でマッピング定義に識別子を与えると、異常系テストの終了後に元々の挙動に戻しやすくなります。
Java のクライアントライブラリでは removeStubsByMetadata メソッドを使って、該当のマッピング定義だけ容易に削除できるためです6

なおマッピング定義には id 項目も存在し、ユーザーが任意の値を設定することもできます。metadata と同様に該当のマッピング定義だけ削除するメソッドも、クライアントライブラリに用意されています。
しかし id は UUID 形式のため、意図を込めた命名はできません。そのため、設定可能な値の自由度が高い前者を推奨します。

おわりに

弊社では、よく守破離における「守を徹底する」という言葉を耳にします。
その言葉通りに XP が定義する価値とプラクティスを、私達なりに忠実に取り組み続けられたプロジェクトだったと感じます。
チームでの協働、こまめなリファクタリングとリリースを繰り返し、得られたフィードバックを受けての軌道修正……
認証基盤リプレースという一見ゴールがわかりやすいプロジェクトでしたが、それでも当初は見えていなかった様々な問題への対処が求められました。

それら全てを最初から見通すことはできないからこそ、いかに様々な変化を受け入れて適応していくかが鍵になります。
まさに、いわゆる白本7の原著副題にある "Embrace Change" (変化を抱擁せよ)という姿勢が重要であるかを再認識する機会になりました。
このプロジェクトにおける学びが、少しでも皆様のお役に立てられれば幸いです。

お読みいただきありがとうございました。

🎓 学生の方へ | 1day インターンイベント開催!

Speeda ソフトウェアエンジニア職 のインターンイベントを開催します!

Speedaプロダクトチームではアジャイル開発手法の一種であるXP(エクストリームプログラミング)を実践しており、今回のインターンではプラクティスの中でも特徴的なペアプログラミングをみなさんに体験していただきます!

チーム開発を更に深めることができるペアプログラミングの経験は我々の文化を体験することや、今後のエンジニアとしての活動の幅を広げるきっかけになると思います。

少しでもご興味をお持ちいただけた方は、ぜひお気軽にエントリーください。

エントリーはこちら!


  1. Speeda の開発チーム全体で利用しているバーチャルオフィスツール。採用に至った経緯はこちらが詳しいです。
  2. ストーリーを小さくすること大きくすること - Uzabase for Engineers
  3. Proxying | WireMock
  4. Response Templating | WireMock
  5. Configuring a reverse proxy - Keycloak
  6. Stub Metadata | WireMock
  7. Extreme Programming Explained: Embrace Change
Page top