2024年 Webシステム技術選定
自社にある3年ほど開発したノーコードシステムをリアルなコードにフルリプレイスするにあたって、システムおよび Web アプリの構成を検討したのでその結論と検討過程。
今後新しく入ってくるメンバー向けに ADR 的なものがあると良いと思うので記録しておく(半年ほど開発してみて振り返り、アップデートもかけたいと思う)。
まとめ
アーキテクチャ
- GraphQL API でフロントエンドとバックエンドを分割
- バックエンドとフロントエンドで TypeScript を採用
- 全体をモノレポで管理する
- バックエンドもフロントエンドもサーバーは同一プロセスで動かし、同時にデプロイする
- サーバーを全て Vercel に乗せることでデータベース以外はブランチごとに環境を作れるようにする
- 決済システムと認証システムは外部化
フロントエンド
- UI ライブラリは React を使う
- Web アプリフレームワークは Next.js を控えめに利用する
- CSS は Tailwind で記述する
バックエンド
- GraphQL スキーマはコードではなくスキーマ定義言語(SDL)で定義する
- GraphQL 周りの各種ツールセットは The Guild のエコシステムに乗る(Yoga, Server Preset etc.)
- O/R Mapper は Prisma を採用する
- テストフレームワークは vitest を採用する
前提:事業とサービス
前提が違うと変わってくる部分もあるので、多少コンテキストについても言及しておく。
自社では SANU 2nd Home というサービスをやっている。
月額5.5万円で会員になることで自然の中にセカンドホームを持てる、メンバーシップ型サービス。
ソフトウェアが扱っているもの
As Is で言うと、複数の側面を併せ持つシステムになっている。
本来的なソフトウェア構造としては、このコアなシステムがメンバー向けに Web アプリを提供しつつ、オペレーターと呼ばれるリアルな拠点運営のための Web アプリを提供している。
To Be で言うと、モバイルアプリの展開や他の事業展開も色々と考えられる。
規模感
画面数で言うと50くらい、データベースのカラム数で言うと1000くらい(ロジックが一切ないコンテンツデータも多いので数字に比例する複雑性があるわけではないが)。
つまりノーコードとは言え、それなりにしっかりした Web システムくらいの大きさになっている。
アーキテクチャ特性
- 機能要件に加えて体験に関わる非機能要件を重視(特に画像が多い)
- ロー・トラフィック、ハイ・レベニュー(一般的な toC と比べると10倍から100倍は高い)
- 継続的デリバリーが価値を生む
- 扱う領域が広大なので一定の拡張性・発展性も必要
その他、サービスの開発・運営に関わる人の種類が非常に多いことも重要だろう。エンジニア、デザイナー、プロダクトマネージャーだけでなく、土地調達、建築、不動産管理、清掃、運営、障害時の対応(災害などリアルなものも含めて)などがあって総合的にサービスが形作られている。
API で分割するかどうか
決定
バックエンドとフロントエンドは、GraphQL API を境界として Web アプリとコアシステムに分割する。
そう遠くない未来にモバイルアプリを展開することが想定されるため(というか普通に僕が欲しい笑)
あり得た代替案
プログラミング言語&処理系
決定
フロントエンドは TypeScript、これは一択なので議論の余地はないだろう。
バックエンドも TypeScript にした。
あり得た代替案
- バックエンドの言語に Go を採用
- TypeScript の型の表現力がフラットに見て優れていると判断し、TypeScript に決定した
- 特にモバイルアプリが存在する世界において、バックエンドとフロントエンドの言語を統一することには本質的にあまり意味はないので、そういった選定理由ではない
- バックエンドの言語に Ruby (Rails) を採用
- 型があることを要件にしたので Ruby は選択肢としなかった
- また、API で分割する時点で Ruby on Rails の力を100%は引き出せないこと、Rails 自体がそういった志向を持っていないことも大きい
- TypeScript の処理系の Deno を採用
- Deno の最初の開発者体験は優れているが、CoomonJS ベースのエコシステムとの差異などに込み入ったところでぶつかるし、結局 TypeScript を使っていればそこまでそれも気にならないので普通に Node.js を採用
リポジトリ戦略
決定
高速な開発を重視してモノレポを採用。
Next.js で構築した Web アプリと、GraphQL API を提供するシステムがそれぞれディレクトリを分けて存在する。
あり得た代替案
- リポジトリを分けて開発する
デプロイ戦略
決定
GitHub flow にした上で、バックエンドもフロントエンドも同一プロセスにして同時にデプロイする。
ここはちょっと変わった方法を採用している。どちらも Node.js で動くことから、(論理的には GraphQL API で分割つつ)物理的に同じプロセスとして動かしている。
これによって短期的にパフォーマンスや開発面でシンプルさの恩恵をうけることができる。
また後述するが、これによって全てを Vercel に乗せることが可能になり、プルリクエストごとにプレビュー環境が立ち上がるということがバックエンドも含めて実現できることが大きい。
ただし、モバイルアプリを展開した際には、Web アプリとバックエンドが結合していることは望ましくないためこの時点では切り離すことになると思う。
あり得た代替案
- バックエンドとフロントエンドを別プロセスにして、別々にデプロイする
- Git flow にする
クラウドインフラ
決定
Vercel をメインに利用しつつ、コントロールを増やしたい度合いに応じて AWS に移していく。
上述のようにサービスに関わる人の種類が多いため、改善フィードバックの受けやすさが重要であり、プルリクエストごとにプレビュー環境が立ち上がったり、Web 上から Figma のようにコメントをつけられてフィードバックをできる機能は価値が高い。
また画像を多く扱うため、配信周りが整っているのも地味に嬉しい。
ただし将来的な移行も視野に入れて非同期ジョブ周りなどアプリケーションコードに結合しやすい箇所は、汎用のクラウドインフラ として Amazon Web Service を利用していく。
あり得た代替案
汎用クラウドインフラについては長期的な投資になるため、星取表を作成して検討した(あくまで自分のいる環境における評価軸と選択であり、エンプラなど前提が変われば評価軸自体も増減するだろう)。
事業観点:
Azure | AWS | GCP | |
---|---|---|---|
運営元 | Microsoft | Amazon | |
運営元の経営リスク | ◎ | ◎ | ◯ |
クラウド事業の継続性 | ◎ | ◎ | ◯ |
他クラウドとの連携性 | (未調査) | ◎ | △ |
価格・機能観点:
Azure | AWS | GCP | |
---|---|---|---|
価格 | △ | ◎ | △ |
機能 | (未調査) | ◎ | ◎ |
AI 統合 | ◎ | △ | ◯ |
運用人材 | △ | ◎ | ◯ |
フロントエンド:UI ライブラリ
React を使う。
Meta が利用していること、そこから GraphQL エコシステムの不安もないことなど、フロントエンドをマクロに見て長期的に採用する技術としては固いと判断。
あり得た代替案
- Vue
- React に比べるとエンジニア以外も取っ付きやすいなどの違いはあるが、自身の特性と今後のチームを考慮するとそこまでメリットにはならない。
フロントエンド:CSS フレームワーク
Tailwind を使う。これが恒久的な選択肢になるかは不明だが、2024年時点ではベストな選択肢であるため、将来的には異なるものを使う可能性がある。
基本的に、経験者がきちんと使う分には問題ない技術だと見ている。インラインであることには議論もあるが、クラス名をつけるなどは人類には(できなくはないが)難しい。総じて、デフォルトで抽象化することを推奨するフレームワークよりは筋が良いというのが自分の考え。
ただし、Tailwind から CSS に入門すると知識の可搬性が低いため、将来的に入門者などが入ってきた段階では Learn CSS など学習資料をきちんと整備することが重要だろう。
あり得た代替案
- StyledComponents などの CSS-in-JS 系
- UI ライブラリや JS 処理系などがいろいろ出ている中で、JS と結合していない方が生き残りやすい
- CSS Module などのより生の CSS に近い系
- Tailwind の方が開発が早い
フロントエンド:Webアプリフレームワーク
ひとまず Next.js を採用する。ただし Next.js への依存を強くしすぎないようにマネジメントする。
例えば:
- API レイヤーを構築するため、Serve Actions は基本的に使わない
- NextLink は使うがデフォルトで prefetch する挙動をしないように変更して使う
あり得た代替案
- そもそもフレームワークを使わない
- 前職は一時期これでやっていたが現代の Web アプリは全部自前で用意できるほど単純ではない
- Remix などの同レベルのフレームワークを使う
- 別に Remix でも良いのだが、Next.js でも良いよねというのが現時点での立場
- この辺は現時点で絶対的な正解がないため、可搬性のマネジメントの方が重要だと考えている
- Vercel を使っていないと辛い部分はあると思うが、Vercel を使うのもある
- 別に Remix でも良いのだが、Next.js でも良いよねというのが現時点での立場
- RedwoodJS などより高次のフレームワークを使う
- フロントエンドは Next.js、バックエンドは GraphQL resolver が実質的なフレームワークの役割を果たすので too much だと判断
フロントエンド:その他の各種ライブラリ選定
- GraphQL クライアント:Apollo
- GraphQL コード生成:GraphQL Codegen
- フォームバインディング:React Hook Form
- バリデーション:zod
- アニメーション:React Transision Group(この仕様が入ったら不要になりそう)
- ヘッドレス UI コンポーネント:Radix Primitives
バックエンド:GraphQL をどう定義するか?
決定
スキーマ定義言語(SDL) で定義する。可読性、LLM のドメイン理解可能性など含めてこちらの方が良いと判断した。
あり得た代替案
- コード・ファースト:TypeScript のコードとして定義する各種フレームワークを使う
バックエンド:GraphQL 周りの各種ツール
決定
- 実行:GraphQL.js
- サーバー:GraphQL Yoga
- コード生成:GraphQL Codegen (Server Preset)
The Guild のエコシステムを中心に採用している。
バックエンド:O/R Mapper & Database Migration
決定
Prisma を採用する。
O/R Mapper ではあるが、Rails における ActiveRecord とは異なり、テーブルデータゲートウェイであり素のままではプレーンなオブジェクトが返ってくる。
この辺はすでに先人が色々な議論 [1][2] をしているが、最近は Prisma エクステンションによってオブジェクトにメソッドを生やしたり、リレーションによくあり絞り込みのメソッドを定義するくらいのことはできるようになっているので、まあまあ使えるかなと思った。
ただし、根本的には TypeScript できちんと型をつけながら ActiveRecord と全く同じことを実現することはできない。そこはもうそう言うものなので諦めよう。
あり得た代替案
- TypeORM など他の O/R Mapper
- Prisma の方がスキーマ定義とマイグレーションとの統合が優れている
- SQL を直接文字列として扱う系のローレベルのライブラリ
- そこまでハイトラフィックなサービスではないので、SQL の細かいチューニングなどは当面やらない。つまりレイヤーを下げることによるコントロールがそこまでメリットにならない。
バックエンド:非同期ジョブ [TBD]
この辺は AWS の SQS かなー。ここは開発以上に運用観点が大事なので、今後検討する。
バックエンド:その他の各種ライブラリ選定
決定
- 日付ライブラリ:Stage-3 まできている Temporal を採用し、Polyfill として js-temporal を使う
- ※ 宿泊システムなので日付が超重要なデータ型になる
データベース [TBD]
ひとまず Postgres (AWS の RDS) を使っているが、サーバーレスでより運用負荷の低いものや、ブランチ機能などよりチーム開発の生産性を上げてくれるものもいくつかあるので、今後検討していく。
テスト戦略
決定
短期でリプレイスを行うことを重視し、GraphQL API のテストを薄く広く書きつつ、コアロジックの単体テストを vitest や DB も統合したテストを書く。
フロントエンドは複雑なものを除いて継続的テストは導入しない。
チーム化を進めるにあたって、徐々にこのあたりのテストの比重を徐々に継続性重視に再配分していく。
なお、テストフレームワークについては vitest を採用している(jest は ESM 対応が大変なので)。
認証システム
決定
Auth0 を利用する。
前提としてリアルな人々が使うサービスであり認証という機能自体が重要、かつ認証技術が今も進化している領域である(数年後にはSANUキャビンが虹彩認証で解錠できる世界になってるかも・・!?)
事業特性としては、個人に加えて法人にもサービス展開していることからサービスの成長に伴って複雑なセキュリティ要件が求められる可能性が高い。また Auth0 はアクティブユーザー数課金だが、SANU のサービスは高単価なビジネスモデルでありこの程度ならコストよりも利便性が重要。
あり得た代替案
最初は内製して、複雑な要件が出てきてからこの手の IDaaS に移行する。明らかに、最初から入れておく方が簡単であり、ユーザーの負担も少ない。
決済システム
決定
Stripe を利用する。
これは自前で作るという選択肢は今のフェーズではないが、3%という手数料の位置付けとしては将来的に国外展開を行った時に現地決済への対応がスピーディにできること、その手の開発コストを持たなくて良いことが重要だと考えている。この手の要件は国や地域によっては異なっていても、自社のサービス特有の事情があるわけではない。つまり複雑度があり事業のコアではないので、内製しない。
意見的なもの
ここまでの開発を踏まえて、トレードオフ・ポイントとなる意思決定の流れを何個かまとめておく。
フロントエンドとバックエンドの分離をするかどうか
まず、アーキテクチャを考える上で全体に影響するポイントとしてユーザーインターフェイス(フロントエンド)を分離するかどうかがある。
これを a) 同一アプリを複数プラットフォームに展開するか、b) 同じドメインのシステムを複数アプリに展開するかどうか の2つの観点で見てみる。
「同一アプリを複数プラットフォームに展開する」場合の典型的な例はモバイルアプリ。iOS / Andorid のネイティブ技術が入ってくるため技術的な同質性が意味を持たなくなるのと、各アプリストアを通じてデリバリーのサイクルも必然的に分離される。そのため、これはバックエンドとWebフロントエンドの分離、および技術的な選択の独立性につながる。
モバイルアプリを展開するかどうかはプロダクト戦略によるが、一般的にコンシューマ向けサービスだと(そこにユーザーがいるので)どこかの段階で展開をしていくという話になりやすい。
「同じドメインのシステムを複数アプリに展開する」例は、ツーサイドのマッチングプラットフォームなどだろう。前職の Wantedly もそれにあたり、採用担当者が採用業務で使うアプリと、個人ユーザーが使うアプリではUI もユースケースも異なるが情報は共有している。こういったケースでは、UIを持つフロントエンドとバックエンドを密結合な形で作ると、どちらかのデリバリーで困ることになる。
SANU 2nd Home の場合は前者は見込まれ、後者も可能性が高いため、フロントエンドとバックエンドは論理的には分離しておく必要がある。
どのような分離技術を使うか
フロントエンドとバックエンドのそれぞれでどの技術を使うかの前に、どのような分離技術を使うかを考える。これもいくつかポイントがあるが、特定の処理系と紐づく分離技術と、特定の処理系から独立な分離技術があり、場合によってはトレードオフになる。
今回はモバイルアプリが入ることも踏まえると、特定の処理系から独立な分離技術が望ましい(iOS を Swift で書いている時に API の変更を提案したくなった時、TypeScript で書かれた GraphQL Nexus の user.ts
にプルリクを出すよりは、graphql.schema
にプルリクを出したいでしょう)。
ということで Protocol Buffers / gRPC や GraphQL(のうちコードファーストではないもの)を検討していくことになる。より単純な RPC を使うかクエリ言語を使うかはトレードオフになる。例えば、Production Ready GraphQL ではこのように図示化されている。
SANU 2nd Home はユーザー単価がサブスク5.5万円と高く、1リクエストあたりの処理を安く抑えるよりも価値を上げていくことが重要なため、最適化よりもカスタマイズ性のある GraphQL を採用した。
GraphQL という技術については以前の発表資料なども参照:
技術を的に当てる技術について - GraphQL を入れ直した話 / 吉祥寺.pm28 - Speaker Deck
分離された各部に対してどの技術を使うか
フロントエンドとバックエンドを API スキーマによって分離することで、技術的な選択の独立性が生まれる。
つまり、バックエンドが Go でフロントエンドが TypeScript、といった選択でも何ら問題ないわけだ。実際 iOS アプリではクライアントが Swift になるわけだから、こういった独立性自体は必要なものだ。
「言語を統一しておくと良い」は事実だが必ずしも決定的ではない
もちろん Web フロントエンドは TypeScript 一択なのだけど、バックエンドはどうするか?これは自明ではない。もちろん、言語を統一しておく方が認知負荷が下がって良いということはある。
ただ同時に、結局のところフルサイクルのデリバリーを本気で考えていくと技術的異質性に向き合うのは必要だと思うのだ(最近 onk さんが良いことを言っていた)。
それに、プログラムの見た目が同じでもバックエンドで必要となる技術(データベース設計、情報設計、厳格なキャッシュ管理、並行処理 etc.)と、フロントエンドで必要となる技術(Webブラウザ、UI フレームワーク、デザイン理解etc.)は大きく異なるためそちらの方が支配的であり、必ずしも言語の違いは決定的な要因にならない。しかもこの傾向は、GitHub Copilot など AI 技術の発達でより顕著になってきてる。
(とはいえ、スタートアップで時間がない中で少しでも覚えることを減らしておきたいという気持ちも非常に理解ができるところなのだが… uhyo 氏の言うところの積極的な技術選定 の観点も同時に持っておきたい。やはり学習コストというのは 1-shot の問題であり、事業として成立することが分かっているサービスにおいては持続的に向き合う上での技術的性質がパラメータとして重要だと思うからだ)
それでも型は欲しい
バックエンドを高速に作る技術として Rails(というか ActiveRecord)がある。
しかし API にスキーマがありデータベースにもスキーマがあるという風になると、どうなるか。結局のところバックエンドのサーバーで動くプログラムというのはクライアントから受け取った API リクエストをデータベースに連携する仕事が殆どなわけだから、通信の両端点に型が付いていることになる。
このようなアーキテクチャでは、それを扱うプログラムに型が付いていることでやはり品質が非常に安定する。よって、TypeScript や Go など型のある言語が選択肢になる。
TypeScript の型は普通に優れている
そういった目線で各種言語を見ていくと、TypeScript の型というのが結構普通に良い感じなことがわかる。
GraphQL には union も enum もあるが、TypeScript であればそれを直接的に表現できる。Go でもこの情報が抜け落ちると言ったことはないが、やっぱりどうしてもちょっと苦しい感じにならざるおえない。
こういった検討は GraphQL のサーバーを実装することを想定しているが、サーバーが複数になり連携をする場合 [1] は 「API のサーバーでありクライアントでもある」という関係が発生するのでクライアントになった場合のことも考えておくと良いだろう。そして GraphQL クライアントとしての TypeScript は、Web フロントエンドで使っていれば分かるように相性が良い[2]。
ここでは GraphQL との相性という形で述べたが、より一般的に制約を型で表現しやすいということは重要だ。しかも人間が端的に理解できるということは LLM に対しても同様になる。Copilot のような支援系が意味的な理解をできる今のプログラミング環境では、むしろ型で論理的に縛れることの価値があると感じる(とはいえ LLM-friendly なプログラミング言語は大きなテーマだと思うのでここで深入りは避ける)。
ともあれこういった一連の事柄を踏まえて、結局バックエンドにも TypeScript を採用することにした。
シンプルなアーキテクチャから始める
現在と未来のトレードオフ
これを具体的な技術選定に落としていくと、TypeScript で書かれた React / Next.js サーバーと、同じく TypeScript で書かれた GraphQL yoga サーバーが通信する世界になる。
そういう世界でローカルで複数のサーバーを立てるのはちょっと面倒だし、ステージング環境・本番環境へのデプロイと通信も考える必要がある。
おそらくこういうアーキテクチャは、モバイルアプリが存在する開発体制や複数の Web アプリが並列に開発されている開発体制では必要だけれども、実は2-3人で1つの Web アプリ開発する間は不要だ。デプロイサイクルもリソース割り当ても最初は全部同じで良い。
だから、できれば、現在と未来をトレードオフにしたくはない。シンプルな構成で良い間はシンプルさの恩恵を受けたい。でもそれによって未来の発展性も犠牲にしたくはない。
現在のアーキテクチャ
上記を勘案して採っているのが、Next.js のサーバーが GraphQL API も受け付けるという今の構成だ。「全部同じ Node.js で動くサーバーであれば、Next.js のサーバーが GraphQL のエンドポイントを持っていればそれで良いのでは?」というアイデアから来ている。
それをモノレポのコードベースで管理している。
こうすると、ローカル環境では一つのサーバーを立てるだけで済むし、開発環境・本番環境でもデプロイが単純で済む。加えて、インフラに採用している Vercel では作業ブランチごとにデプロイが行われ、プレビュー環境が立ち上がるのだが(改善のフィードバックサイクルを早める)、これも自動的に上手く動く。単純さの恩恵。
分離されたアーキテクチャ
順当に行けば、すぐにデリバリーチームの複線化が行われることだろう。
例えば、モバイルアプリとバックエンドを含むエンジニアがいるチームと、Web フロントエンドを中心に改善するエンジニアのチーム、といった形。
こうすると、必ずしもバックエンドと Web フロントエンドがセットになるから、デプロイサイクルを切り離したくなる。こうなったら、GraphQL を話すサーバーと Web フロントエンドのための Next.js のサーバーが独立に動作するアーキテクチャに変更する。論理的には API スキーマによって分離されているので、大幅なプログラム変更はおそらく不要だろう。
ユーザーインターフェイスの分離は途中から行うのは非常に大規模なリアーキテクチャになることが常だが、そういったコストを将来に引き受けるのではなく、今ほんの少しだけスキーマ定義というコストを払っておく、というのがここで採用しているトレードオフだと言える。
さらにドメインの展開が進めば、(相当先だと思うが)バックエンドをモジュラーモノリスとして分離することでデプロイサイクルを分離するといったことも考えられる。
補遺
以上はある種の進化的アーキテクチャだが、このようなことを実現するために全てを TypeScript / Node.js にしているという技術的な同質性が有利に働いたのは最初から予想していたわけではないことは明記しておきたい。
あくまで、開発フェーズにおける要件の違いを考慮し、トレードオフになってしまう部分を時間軸の中で調整していった結果だったりするので、まずもって大事なのはそれを考えることなのかなと思っている。
同じような話は他にもある
本格的に TypeScript で API サーバーを書こうとした時、TypeScript が JavaScript であるという現実に向き合うことになる。
例えば、時刻と日付。宿泊システムにおける超重要データ型。Temporal という提案があるが、まだ完全に標準には落ち切っていない。言語に組み込まれていないということは Prisma などの O/R Mapper でもマッピングされないということで、データベースでは日付型だが Prisma を介すと Date
になるということが起きる。そこで、これを Temporal に戻す層を設けている。
これは組織の変化ではなくエコシステムの変化に対応するという話で、TypeScript でサーバーを書くケースが増えていけばこういったことも起こるだろうから、その時に進化できるように備えている。
チームの変化、事業の変化、エコシステムの変化と上手に付き合って行きたいですね。