はじめに

はじめまして。東京海洋大学 海洋工学部2年の恩田梨那(@cs_riri)と申します。

今回、2026年7月 サイバーエージェント「CA Tech JOBインターン」にて1ヶ月間、タップルのサーバーサイドエンジニアチームとして就業させていただきました。

これまで、サイバーエージェントが開校当初からパートナーシップを結んでいる、学費無料のエンジニア養成機関「42 Tokyo」でC言語を用いたシステムプログラミングや、2026年春のサイバーエージェントGo CollegeインターンでGo言語を用いたバックエンド開発の基礎などを学んできました。

また、2026年3月からはCA Tech Loungeというサイバーエージェントが運営するエンジニアコミュニティにも所属しています。

今回のインターンでは、推薦(レコメンド)に用いるデータを、ユーザーの情報が更新されたことに伴って非同期に反映する処理を、タップルのサーバーサイドで採用されているNode.js(TypeScript)で実装しました。

期間中に出した5本のプルリクエストのうち、最後の1本は取り下げています。動かなかったからではありません。実装を進めるうちに、発火するかどうかを判定するガードコードだけで100行近くに膨らみ、その重さ自体が設計の誤りを示していたからです。

この記事では、一度は実装した設計をレビューを経て転換することになった、その顛末を中心にご紹介します。

背景: 推薦システムを支えるデータ連携の課題

タップルのバックエンドはマイクロサービスアーキテクチャを採用しており、各サービス間は非同期通信やAPI通信によって連携しています。

(参考記事: タップルのマイクロサービス完全移行を振り返る)

今回のインターンでは、ユーザーの情報が更新された際、その変更を推薦システム側へ非同期に連携・反映する仕組みについて、要件整理・設計から実装、引き継ぎまでを一貫して担当しました。

推薦システムは、データをもとにユーザーの情報をアルゴリズムに投入できる形に組み立てて使用しています。そのため、不適切な情報が混入したり、更新が反映されずに古い情報のまま扱われたりすると、推薦の結果に直接影響してしまいます。

いかにして最新のデータを確実かつ効率的に連携するか。これを目的として、「ユーザー情報の更新イベントを受け取り、別サービスのAPIからデータを取得して推薦システム側へ渡す」という一連の処理の実装に取り組みました。

 

設計の葛藤①: SQSの順序非保証にどう立ち向かうか(Push型とPull型の選択)

推薦システム側のworker(イベントを受け取って処理を行うプロセス)は、ユーザーの情報の更新をトリガーに発火するイベントを受けて動作します。イベントを設計する時点で、更新後の実データ(ユーザー情報)をそのままpayloadに載せて渡す(push型)か、userIdだけを載せてworker側が改めて保管場所へ最新のデータを取得しにいく(pull型)かの2択がありました。

push型は追加のAPI呼び出しが不要で一見効率的に見えますが、今回のサービス間通信に利用しているSQS標準キューは配信順序を保証しません。そのため、ユーザーが短時間に複数回情報を更新したり、何らかの事情でリトライが発生すると、古い更新のイベントが後から届いて新しい状態を上書きしてしまうリスクがありました。

pull型であればイベントには常にuserIdだけを載せ、処理のタイミングで最新の状態を取得しに行くため、この順序の問題を回避できます。加えて、同じイベントを再処理しても結果が変わらない冪等な作りにしやすいという利点があります。
つまり、同じ順序の入れ替わりが起きても、push型は古い値がそのまま残るのに対し、pull型は受け取った時点で最新を取りに行くため、どちらの順で届いても同じ結果になります。

一方で、pull型は追加のAPI呼び出しが1回増えるため、通信回数の面では不利です。それでもpull型を採用したのは、順序の問題を回避できることに加えて、SQSに載せるpayloadを最小に保てるからでした。

実データをpayloadに載せると、発行側のデータ構造の変更がそのままイベントのスキーマの変更になり、購読側に波及します。つまりSQSを挟んで非同期にしたはずの2つのサービスが、payloadの形を通じて結合してしまいます。userIdだけを載せる形にすれば、購読側は「自分が必要な形のデータを、必要なAPIから取りに行く」ことができ、発行側は自分の都合でデータ構造を変えられます。通信回数と結合度のトレードオフとして、後者を優先したという判断です。
この設計判断をめぐるプルリクエストには、50件近いコメントが寄せられました。上に書いたトレードオフのうち、結合度の観点は自分では最初に挙げられておらず、レビューの中で言語化されたものです。

 

設計の葛藤②: 100行のガードコードが教える罠(「事実イベント」への転換)

当初は、ユーザー情報の変更はすべて1つのイベントにまとめる設計を決定し、実装を進めました。送信内容はどの項目が更新されてもuserIdのみとし、受け取る側でその都度最新の情報を取得しにいく形にしました。これはサービス間通信の許可をTerraformで設定する運用負荷も踏まえた判断でもありました。今回の場合は分離することで4つのイベントをホワイトリストに入れる必要が出てくるためです。

実装を進める中で、各送信側において同値更新(実質的に値が変わっていない更新)による無駄な実行を防ぐため、更新前後の値を比較するガードを追加しました。

ここで、項目ごとに個別に値を比較すると、項目同士が連動して変化するケースを取りこぼす例があることに気づきました。例えるなら、名字(単一項目)を変えたときに氏名全体(連動する項目)も再評価しなければならないような関係です。

そのため、「全項目(今回の場合は20以上の項目)をまとめて比較するか、比較自体をやめるか」の二択に設計は絞られました。

比較対象を絞れば軽くなるようにも見えますが、どの項目が連動するかを列挙して維持し続ける必要があり、結局その一覧が送信側の新しい負債になります。

設計判断として、まず安全側に倒そうと全ての項目を同時検証するガードを実装しました。しかしそれが積み重なった結果、関連のコードだけで100行近くになり、実装は少しずつ重くなっていきました。

 

// ユーザー情報を更新する別サービスの処理。
async function updateUser(userId: string, patch: UserPatch) {
  const before = await userRepo.find(userId);
  const after  = await userRepo.update(userId, patch);

  // --- 以下、すべて「発火すべきか」を判定するためのガード ---

  // (1) 退会フローだけは専用の発火メソッドを通す
  if (patch.kind === "withdraw") {
    return publishWithdrawEvent(userId);
  }

  // (2) 既に退会済みのユーザーには発火しない
  if (after.isWithdrawn) return;

  // (3) 同値更新の除外。
  //     項目ごとの比較では、複数項目から導出される派生値の
  //     変化を取りこぼすため、20以上の項目を一括で比較する
  if (isDeepEqualOnRecommendationFields(before, after)) return;

  // (4) 同一トランザクション内の多重更新を1回に畳む
  if (dedupeStore.hasFiredInThisTx(userId)) return;
  dedupeStore.markFired(userId);

  await publish("user.updated", { userId });   // payload は userId のみ
}

脚注: 転換前の発行側疑似コード。ユーザーを更新する処理なのに、私の実装したコードでは行数の大半を推薦システム側の都合が占めている。

 

レビューを通じて、こうした重さが「受け取る側の関心事が別サービスの更新処理に寄りすぎている」という形で可視化されました。

これは、葛藤①でpayloadをuserIdだけに絞ったときと同じ問題が、今度はコードの側で起きていたということでもあります。SQSに載せるデータを最小にしても、発火するかどうかの判断を発行側に置いてしまえば、結局そこで2つのサービスが結合してしまいます。

メンターさんと議論した結果、この1つにまとめたイベントは廃止し、更新項目のまとまりごとの事実イベントを個別に発行・購読する設計に転換することになりました。

これは、運用負荷は一定程度増えるが購読側で十分運用可能な範囲であると整理したことと、結合度を下げる価値の方が上回ると判断したためです。

同様にガードについても実行コストとコードの維持運用コストを比較し、取り下げています。

// 発行側
async function updateUser(userId: string, patch: UserPatch) {
  const after = await userRepo.update(userId, patch);

  // 起きた事実をそのまま流すだけ。捨てるかどうかは購読側の関心事。
  await publish(eventNameOf(patch.kind), { userId });
}

// 購読側(推薦システムの worker)
async function onUserFactEvent(e: FactEvent) {
  const latest = await profileApi.fetchAsOthersSee(e.userId); // 複数取得API
  if (!latest) return;
  await recommendationStore.upsert(e.userId, latest);         // 冪等
}

脚注: 転換後の発行側/購読側の疑似コード。発行側と購読側の責務を分離している。

 

一度実装してみたからこそ、設計の問題が実装の「重さ」として表面化した、という経験でした。イベントを更新項目単位に分けたことで、発行側は自分が担当する項目が変わったかだけを見ればよくなり、推薦システム側が使う派生値全体を意識した20項目一括比較は不要になりました。

事前の設計レビューでここまで洗い出せた可能性は否定できません。ただ、どこまでが設計時に予見すべきもので、どこからが実装しないと分からないものだったのかを、事後に切り分けてみました。

予見すべきだったのは、「イベントの発火条件を発行側に持たせること自体が、購読側のドメイン知識(推薦システムがどの項目を必要としているか)が発行側に漏れ出し、密結合を生む」というアーキテクチャ上の原則です。

一方で実装して初めて見えたのは、「20以上の項目が連動する複雑さ」や、それを制御するコードが「100行のガードコードという物理的な重さ(保守の負債)」として立ち現れることの恐ろしさでした。

この議論では、「発火制御は別サービス側に書かず、イベントは事実としてそのまま流し、それをどう扱うか(捨てるかどうか)の判断は受け取る側に置く」という原則も合意しました。

脚注: 処理の重い部分が発行側から購読側へ移っている

 

落とし穴:UGC特有の「審査状態」と、主体・客体視点の切り分け

これも同じイベント設計の枠組みの中で見つかった落とし穴です。
タップルは、本人確認の徹底や24時間365日の監視体制など、安心安全に関する取り組みを行っています。その一環として、ユーザーが投稿したコンテンツ(UGC: User Generated Content)を審査する仕組みがあります。

今回連携に使用するAPIを検討する中で、ユーザー自身の視点で情報を取得する用途と、他ユーザーからの見え方を扱う用途とでは、想定されている使われ方が異なることに気づきました。前者は本人が自分の情報を正しく把握できることを目的として設計されており、いわば「主体(自分)」の視点としては適切な仕様でした。

一方で今回実装する反映処理は、推薦の材料として「他ユーザーからどう見えるか」という「客体」の視点のデータを扱う必要があります。この視点の違いを踏まえずにAPIを選定すると、審査状態を考慮できていないデータが推薦システムに混入しうるという設計上のリスクがありました。

そこで、審査状態を適切に考慮した設計になっているAPIを調査した上で選定し、混入を防ぐ設計としました。

これで取得時点の未承認コンテンツの混入は防げましたが、それだけでは終わりませんでした。取得時点では未承認だったコンテンツが、その後承認されるケースがあるためです。

承認されたタイミングで改めて反映処理を実行しないと、そのコンテンツはいつまでもデータに反映されないままになってしまいます。
なお、「審査中に更新があった場合どう扱うか」については、該当コンテンツが未承認の場合は反映をスキップする設計を採用しました。このスキップが成立するのは、承認された瞬間に再発火することが保証されているからです。
そのため「コンテンツが承認されたタイミングで再度イベントを発火させる」ことを発行側の必須要件として引き継ぎました。

 

学び:実装の「重さ」は設計へのフィードバックである

設計の要点は、正しく動く道筋を組み立てることだけでなく「うまく失敗を処理できるか」にある、という感覚を持てたことが、今回一番の学びでした。やり取りにどんなデータを載せるかも、正常系の動きだけでは決められません。配信の遅延・順序が保証されないこと・リトライ・審査中という状態など、起こりうる異常系を並べてみて、初めて選択肢が絞られていきました。

ただし冪等性は、その「失敗の処理」を万能に守ってくれるわけではありません。処理結果の正しさは守ってくれても、処理のコストまでは守ってくれないからです。だからこそ、そのコストをどちら側で払うかが設計判断になります。今回は発行側にガードを置いてコストを削る代わりに結合を招くより、購読側が自分の関心事として捨てる形を選びました。

実装の重さそのものが、こうした設計の誤りを教えてくれるフィードバックになることも、あわせて実感しました。

また、DDDやクリーンアーキテクチャといった設計の原則は、知識として知っていることと、実際のプロダクションコードの中でそれを保ち続けることの間には大きな距離があると知りました。今回のように複数人でコードを触り続ける環境では、原則を一度適用して終わりではなく、レビューや議論を重ねながら維持していく地道さこそが重要なのだと実感しています。

 

おわりに

短い期間でしたが、設計判断とその転換を通して、多くのことを学ばせていただきました。

また、本記事には書ききれませんでしたが、業務を遂行するにあたってのポイントや、チームでの仕事の進め方、AI活用に至るまでたくさんの視点でアドバイスをいただくことができました。

今回お世話になったタップルの開発チームには率直に意見を言い合える雰囲気があり、疑問や違和感をすぐに相談できる環境でした。今回、一度実装した設計を見直すという判断を前向きに行えたのも、こうした心理的安全性の高さがあってこそだと感じています。さらに、エンジニアとビジネス職の方の距離も近く、ワンチームで施策に取り組める点も、タップルのよさと感じています。

ご指導いただいたトレーナー・メンターの方、株式会社タップルの皆さま、ランチや面談を実施いただいたサイバーエージェントの皆さま、人事の皆さまに深く感謝申し上げます。

この記事では、なるべく等身大の部分を見せられるように執筆しました。あっという間の1ヶ月間、心残りがないと言うと嘘になります。一方で個人開発では得ることのできなかった知見を得て、大変貴重な経験をさせていただきました。

もしCA Tech JOBインターンに興味がある方がいらっしゃればぜひチャレンジしてもらいたいと心から思います。

 

最後までご覧いただきありがとうございました。