💥

3年目のプロダクトでコアデータの構造を作り直す

に公開

これはなに

Nstock株式会社の祖業である株式報酬SaaSは、サービスローンチから数えて3年目になり、100社以上の企業様にご導入いただいています。その一方でコア機能である ストックオプションの個数/状態管理 の方法に課題が出てきていました。

ここまでは運用してこられたし次の2年もおそらくやっていけそうですが、その先の拡張も自信を持って乗り越えられるとは言いづらくなりつつありました。そういう状況の中で、次の10年を戦えるデータを生み出し続けるためにコア機能を作り直しました。

そんな話をします。

書いてる人

Nstock株式会社で株式報酬SaaSのソフトウェアエンジニアをしているnonoです。2025/5に入社してから、おおむねバックエンドを中心とした開発に取り組んでます。

課題 - 重視すべきプロダクト特性の変化と認知負荷の高まり

我々の開発している株式報酬SaaSでは、企業が発行するストックオプション(以下SO)について管理事務を行う 事務局 、SOを付与された 権利者 を主なユーザとし、SOにまつわる手続きのサポートや、保有状況の可視化などを行います。
https://nstock.com/
それらの機能を実現する前提として、 いつの時点で、誰が、どのようなSOを、どういった状態で、何個持っているか という状態を管理する機能は欠かせません。一方でプロダクトの SOの個数/状態管理 機能という側面を切り出すと、以下のような点から開発・運用負荷の高まりがチーム内で認識されていました。

  • SOを表示する画面は複数あるが、画面によって異なるテーブルを参照している。おおむねテーブルの構造は代表的なユースケースとなる画面の構造に一致している。
  • SOの個数・状態が変更される場合は関連するテーブルにそれぞれ同期的に書き込みを行っている。
  • これにより改修時の見通しが悪く、運用時のOps難易度が高い。

プロジェクト開始前の構成図。複数の更新処理が複数のテーブルを更新し、複数のテーブルが複数の参照処理から参照されている

歴史的には事務局の課題解決から出発したと聞いています。開発の初期においてはまず関係者と共通の認知モデルを獲得する必要があり、まずは事務局メンバーが実務で遭遇する書面や帳票の情報を整理したUIが、共有した認知モデルの出発点となるでしょう。その共有したモデル = UI構造が物理的なデータ構造として実装されたものと類推しています。

その時の状況を踏まえると、画面に近いデータモデルから始め、抽象的なモデルを置かないという意思決定は正しかったと思います。機能要件の不確実性が高い状態では関係者と認識の齟齬が生じるリスクが高く、これを避けることは重要です。手探りの状態でデータ設計の抽象化を行っても、誤った/早すぎる抽象化のリスクも高いでしょう。

一方でこの構造のまま機能追加・拡張を重ねたことにより、

  • テーブル間の相関関係が断片化しており、改修の影響範囲の見積もりを立てることが難しい。
  • 必要な情報を得ることが難しく、例えば権利者が今いくつのSOを持ちいくつ行使可能なのか、といった基礎的な情報を得るには複数のテーブルを結合するクエリ[1]を発行する必要がある。
  • 整合性はアプリケーションロジックに委ねられるため、実装誤り等で容易に不整合が永続化される。
  • SQL Opsでデータ修正を行う場合、関連箇所の網羅的な変更が必要となるため難易度が高い。

などの課題が顕在化し、開発・運用への負担感として表れていました。

直面していた課題を抽象化すると、大きく以下の2点に集約されます。

  • 我々が今後重視したいプロダクト特性とはズレてきている。
  • 更新時に影響があるテーブルを全部知らないとバグる。

課題1. 重視したいプロダクト特性とのズレ

みんな大好きオウム本に倣って我々のプロダクトが備えたい性質を特性(-ity)の形で表現すると以下のようになります。

特定 重要度 評価
データ整合性 ◎(最重要) 金銭に関わるサービスであるため、数のズレ、内部不整合は許容できない。
拡張性 ◯(大事) 機能拡張や変更が引き続き想定されるため、更新/参照ともに拡張しやすさを意識する必要がある。
パフォーマンス △(相対的に重要度が低い) 権利者向けの機能を備えToC的な側面も含むものの、利用頻度や取り扱うデータ量からパフォーマンスの重要度は高くない。事後的に改善可能であれば、最初から作り込む必要性は薄い。

画面の構造と一致するようなデータを個別に保持する戦略をこれらの特性で評価すると、以下のようになります。

特性 評価 備考
データ整合性 アプリケーションやDB制約で追加の工夫を備えない限り、不整合は発生しやすい。
拡張性 ユースケース特化のデータ構造を個別に備える為、新たなユースケースへの対応コストは高い。
パフォーマンス 画面に必要なデータ構造をそのまま保持しているため、参照時のコストは最小化されている。

上述の通り、現状のデータ設計にも利点はあります。特に立ち上げ時期には重要な特性を満たせるでしょう。しかしローンチから3年目を迎え、開発チームのドメイン理解が進み、機能数が増えてきた状況においては、相対的に優先度の下がる特性となっていました。

課題2. 知らないとバグる

複数テーブル間の意味的な関連性はコードを読み解かなければ認知できず、考慮漏れがあれば容易に不整合が永続化されます(DBのcheck制約等で軽減すること自体は可能です)。開発初期の機能・テーブル数であれば相互関係を意識しながら開発を進められたかもしれません。

しかし、構造的にすべてのテーブルを候補として関係性を認知する必要があるため、今後も更に増えていくだろう機能数に対していつかは認知の限界を迎えます。またコードに対する深い理解がないと改修リスクが高い状態であり、新規メンバーの参入コストも高いと言えます。

例えば ある権利者が持っているSO個数を減らす というケースでは、以下のような形で順次テーブルを更新していました。(ドメインのことばが多いかもしれませんが、順次テーブルを更新しているという点を読み取っていただければ大丈夫です)

public class AdjustmentEventsCreator {
    // 減少を記録するとともに、SO個数を取り扱うすべてのテーブルを更新する
    public void register(){
        // 減少したという事実自体を記録する
        registerAdjustmentEvents();

        // 画面に表示するための減少したという履歴データを登録する
        registerAdjustmentHistories();

        // 権利者がいくつSOを持っているかの総数を更新する
        updateMemberSoSummary();

        // SOを束ねる単位であるPlanから、残っているSO個数を減らす
        updatePlan();

        // SOの個数を更新する
        updateAndSaveStockOptions();
    }
}

類似の処理を実装する場合は同様にそれぞれ更新する必要があり、実装漏れがあれば容易に整合しないデータが永続化されます。

上記のスニペットは整理した状態ですが、実際のコードはもう少し複雑です。データソース間の相関関係はコードとDBスキーマに断片化しており、機能改修時に設計案を立てることはできても、妥当性の検証は困難でした。何をしなければいけない/してはいけないかを認知すること自体が難しい = unknown unknowns 的な難しさを抱えた構造といえるでしょう。

技術的アプローチ - SSOT + Event Sourcing

課題を解決する手法としては、大きく2段階でアプローチを決めました。

  1. Single Source Of Truthとなるような基礎的なデータ管理空間を切り出す。
  2. データ管理空間においてはEventを記録し、その積み上げにより状態を復元する = Event Sourcing。

Single Source Of Truth

第一に、中核となるデータ管理領域を設けて、ここで SOの個数/状態管理 という責務を担うことにしました。課題の原因の一つは、更新時に各画面に対応したテーブルに適切な更新を行う必要がある = 更新処理の中で参照系の複雑さを取り込んでしまっていることにあると考えたためです。

このような空間を設けることで、

  1. データ管理領域は更新リクエストに対して、時系列を通じて一貫した整合性の保証と、現在の状態が適切に復元できることに集中する。
  2. 更新処理はデータ管理領域に対しての書き込みに専念し、実現したい変更内容をデータ管理領域への更新リクエストへと変換することと、不整合時のハンドリングに集中する。
  3. 参照時は格納されたデータからのデータの抽出・結合に専念し、整合性を信頼する。

という関係に複雑さを分解することができます。
責務分離後の構成図。SOの個数/状態管理を切り出して更新系と参照系の責務を分離する

利点として、この空間には SOはどのような事柄の影響を受け、どのように変化するのか のカタログが形成されることになります。これにより、何をしなければいけない/してはいけないか がこのモジュールにリスト化されるため、unknown unknowns を抜け出すことができそうです。

Event Sourcing

第二に、データ管理領域のデータ設計としてはEventを中心としたデータ設計を行うようにしました。SOに状態/個数変化をもたらす出来事を Event として抽出し、これを順次適用することで現在、ひいてはある時点のSOの状態を復元可能とするように構想しました。これに対し既存のデータ構造は、画面UIに相当する処理結果そのものを保存しており、状態 = State を保持していると言えそうです。

評価ポイントとしては以下のような観点です。

  • 参照方法の拡張について、Stateを保存する方法に比べアプリケーションで吸収できる余地が大きい。
  • 更新方法の拡張についてはEventの追加という形で対応しうる。
  • 事業ドメイン的にも会計/帳簿管理的なデータ設計との親和性が高い(高そう)。
  • 証跡管理の要件(出来事の削除を許容したいが削除された事実を残したい)を、EventをキャンセルするEvent,という形で無理なく取り込むことが出来る。

設計案の評価

これら2段階を組み合わせた設計を重視したいプロダクト特性で評価すると以下のようになるかと思います。

特性 評価(以前の手法) 評価(EventSource) 備考
データ整合性 保存時にEvent列の妥当性を検証可能 + 参照時は唯一のデータソースを参照するため、画面間不整合は起こり得ない。
拡張性 上述の通り。
パフォーマンス 更新時はユースケースごとのStateを更新不要な分多少改善する。参照時は常にEvent集合を復元する場合、悪化が予見される。

参照パフォーマンスについて補足すると、単純にDBのI/Oだけでも数倍に増えます。例えばあるSOの最新の状態を取得したいというユースケースに対して、Stateを保持する形式では1行で済むでしょう。一方でEventから最新の状態を復元する場合、そのSOに対して発生したすべてのEventを取得して復元する必要があるため、数行から場合によっては数十行のレコードを取得して復元する必要があります[2]

従ってパフォーマンスについては検討が必要でしたが、多くのユースケースは最新の状態にのみ関心を持つため、最新状態をprojectionとして供給することで対応可能と見込みました。

意思決定 - 膨らんでいく課題をいつ倒す?

課題の緊急性はありませんでした。向こう1-2年以内にリリースが計画されている機能も、今の構造の上に実装する事自体が不可能というわけではありません。

一方で課題の性質上、それぞれ対応が遅れるにつれて膨らんでいく性質がありました。機能間の関連グラフの本数が増えるほど悪化していきます。またSOに関するデータが断片化しているため、SOの個数管理という中核に関する新規機能の追加は影響範囲が広く、行いづらい状態でした。

また当時の状況として、以下のようなタイミングでもありました。

  • Nstockでは複数の事業を行っており、マルチプロダクト間の連携を進めている。連携するデータの形式や仕様は後から変更しづらい可能性が高く、その前に プロダクトにとって取り回しやすいデータの出し方 を見極める必要がある。
  • データの値域の拡張(具体的には今まで整数しか入れられなかった項目に小数を入れられるようにする)が予定されており、これを現在のデータ構造で対応する場合複数テーブルのデータマイグレーションが必要になる。

これらの状況を踏まえてチームで議論し、重たい課題だが今が一番楽に倒せるという判断を行い、コア機能の作り直しに着手しました。

移行計画 - 攻めた変更のための安全な移行計画

移行計画の大前提として、以下のようなスコープ設定を行いました。

  • 振る舞いは変えない。
    • ただし、実装過程で検出された仕様未定義の箇所についての変更は許容する。
  • 今後の拡張性に備えた設計は、後から追加が困難なもののみ行う。
    • 後から追加可能な対応は行わない。
  • ストックオプションの個数と状態にスコープを絞る。
    • 権利者の状態管理など、関連する箇所に手を出し始めると際限なくスコープが広がる。

データ構造の飛躍を伴うため、移行に際しては機械的に・確実に検証できることを重視してこのようなスコープ設定としました。

データ移行の検証は大きく以下のようなSTEPを刻みました(実際には並走する工程もあります)

データ移行計画の概要図

  1. 基礎的なデータ構造を設計・実装する(プロダクションコードから呼び出されないデータ空間の実装)。
  2. 旧データ構造から新データ構造へのマイグレーション処理を実装し、整合性検証を行う。
  3. プロダクションコードから新旧両方のデータ構造にそれぞれデータを書き込むように変更する。
    a. 新データの書き込みに発生した例外はすべてログ出力のみで握りつぶす。
  4. プロダクションコードから新旧両方のデータ構造それぞれを参照し、比較の上旧データを返却するよう実装する。
    a. 新データの参照/新旧比較時に発生した例外はすべてログ出力のみで握りつぶす。
  5. 全データの検証バッチを実行しての整合性検証を行う。
  6. 参照系から旧データ参照・比較処理を除却し新データ構造から取得したデータを返却させる。
  7. 更新系において新データ構造にのみデータを書き込むようにする。

特に 全データの検証 においては、すべてのSOについて、すべての二重参照処理を通じて参照・比較して齟齬がないことを確認しています。「リリース前に確実に検証したい」という点はもちろんですが、我々のプロダクトの性質上、毎日頻繁に操作されるものではありません。見落としがあった場合、リリース後に早期に気づくことは期待しづらいため、確実に全件で検証するという方法をとりました。

取らなかった選択肢

今回は意図的に泥臭いアプローチを取りました。検討したものの取らなかった方法としては以下のようなものがあります。

  1. インフラレベルで隔離環境を構築し、DB replication + アクセスミラーリング + 新規実装系で動作検証を行う。
  2. 実装のリファクタリングを先行して行い、移行対象テーブルを隠蔽するClassを実装しきってから移行作業を行う。

1は構築コストとそれによる検証の容易さ/確実さのトレードオフで、構築コストが過大だと判断して見送りました。愚直に二重参照/二重書き込みを行っても許容できないほどのサービスレベルの低下は見込まれないという判断のもとでもあります。もっとパフォーマンスセンシティブで、ビッグバン的な切り替えリリースが必要になるようなケースでは取りうる選択肢だったかと思います。

2は有望な選択肢です。移行対象のテーブルへのアクセスを一箇所に集約すれば、検証コストの抑制だけでなく、移行完了時点で保守性の向上を達成できるかもしれません。しかし適切なリファクタにはコードへの洞察が必要です。実装と格闘し終えた後に行う方が、圧倒的に意義の深いリファクタを行えるはずです。そのため事前のリファクタは最低限に留め、後から整理するという作戦を取りました。

我々の事業環境、コードベースの規模、チームメンバー構成、宣言した期日等の変数から上記の選択を取りませんでしたが、これらの変数が変われば、十分検討の余地のある選択肢だったと思います。

ふりかえり

執筆時点でプロジェクトとしては完了しており、現在のプロダクトは完全に新規のデータ系の上で動いています。リードタイムとしては4ヶ月程度、延べ8人月程度のプロジェクトとなり、概ね当初の予定通りの着地となりました。幸いに大きな障害もなく、移行を終えることができました。

上記でご紹介した AdjustmentEvnetsCreator は以下のような状態まで簡素化されました。

public class AdjustmentEventsCreator {
    // 減少を記録する
    public void register(){
        // SOに対して減少Eventを追加する
        addEventsToStockOption();

        // SOを束ねる単位であるPlanから、残っているSO個数を減らす
        // 既存のママ。今回のスコープ外
        updatePlan();

        // 以下はEventからの導出項目となったので更新不要
        // 画面に表示するため履歴データ
        // 権利者がいくつSOを持っているかの総数
        // SOの個数
    }
}

個人としては入社が2025/5でこのプロジェクトの始動が5月下旬なので、入社した初月から取り組むにしてはなかなかヘビーでした。一方で面白い体験もできました。

  • ここまでに書いたことはプロジェクト開始時点で決まっていたわけではなく、エンジニア間で議論しながら決めていった結果です。ドメイン理解を深めながら設計・実装を磨き込んでいくプロセスは素朴に楽しかったです。
  • 実装過程で考慮漏れが見つかり、Eventの基礎設計を変えなきゃいけなくなった時はなかなか痺れる思いがありましたが、終わってみれば2日で作り直し切れたので腕力 = 実装力には自信がつきました。
  • 整合性を厳密に検証する機能がデータ構造側に備わったことで、仕様の余白を多く埋めることができました。「その仕様は本来どうすべきなのか?」を議論する中で開発チームのドメイン理解自体を深めることができたんじゃないかと思ってます。
  • プロダクトの中核的な部分にディープダイブできたので、解像度が非常に高まりました。ストックオプションの数、状態がどのような業務・手続きの影響を受けてどう変化するのか etc.

予定されていた値域の変更に携わっていたメンバーからも、データマイグレーションを最小化して機能提供にかける日数を大幅に短縮できたとフィードバックを貰っており、思ったより早く成果が収穫できているようで今は少し安心しています[3]

ふりかえりで貰った付箋。社内ではRe:SOPSというコードネームで進んでいたプロジェクトでした

まとめ

株式報酬SaaSチームではローンチ3年目を迎えるプロダクトのコア機能の、特にデータ構造を刷新しました。技術的なチャレンジはありましたが、検証を固く進めるなど攻めと守りのバランスを取りながら意思決定してプロジェクトを進行しました。

ふりかえると元々やりたかったのは、この先10年の運用・拡張に耐えられるしなやかなデータ構造を生産し続けるよう、アプリケーションを脱皮させることでした。現状、その目論見はうまくいっているように感じられています。今回作ったアーキテクチャの時間の試練への挑戦は始まったばかりですが、少なくとも生み出すデータの寿命は数年伸ばせたんじゃないかなと捉えています。

一方で安全にリリースするためにスコープアウトした課題は山積みです。一緒に課題をなぎ倒しながら事業を伸ばすエンジニアを募集しています。

興味ありましたらお気軽にカジュアル面談からお声がけください。

脚注
  1. 200行程度の、複数の副問い合せを含むクエリでした ↩︎

  2. 実際にはコンピューティングコストも相応に追加で発生します ↩︎

  3. こういうタイミングでアラートが鳴りがちなので気は抜けませんが ↩︎

Nstock Tech Blog

Discussion