この記事について
漫画の本棚を記録・公開できる個人開発サービス ComiCard を作る過程で固めた設計判断を、技術的なトピックごとにまとめます。全ユーザーが1つのDBを共有するアプリで「公開ページ」「共有マスタへの書き込み」「外部APIのレート制限」「LLMを使ったバッチ」「2ユーザー間のマッチング」「AIとの協働開発」「e2eの実行環境」のそれぞれで何に気をつけたかを扱います。
対象システムの概要
- 本棚管理: 作品の登録・編集・削除。作品情報(タイトル/著者/出版社/発売日/表紙)とユーザー別の状態(ステータス・評価・メモ)を分離
- 外部API連携: 楽天ブックスのタイトル検索で書誌情報を自動入力。巻ごとの検索結果を「作品=1巻」へ畳み込む
- ステータス: 積読/読書中/読了
- 検索・絞り込み・並び替え: キーワード/出版社/ステータスの AND 絞り込み+評価/タイトル/登録順ソート
- 公開プロフィール & 名刺: 「今いちばんの一冊」「推しの3冊」を選んで、ログイン不要の共有ページを生成
- ComiMatch(棚マッチング): 2人の公開本棚を突き合わせ、相性スコア・共通作品・相互おすすめ・評価相関を可視化
技術スタック:
| 領域 | 採用 |
|---|---|
| フレームワーク | Next.js 16(App Router / Server Components / Server Actions) |
| 言語 | TypeScript / React 19 |
| スタイル | Tailwind CSS v4 |
| バックエンド | Supabase(PostgreSQL / Auth / Row Level Security) |
| 入力検証 | Zod(Server Action 境界で実行時検証) |
| OGP画像生成 | next/og(satori) |
| テスト | Vitest(単体)/ Playwright(E2E) |
| CI/CD | GitHub Actions → 成功時のみ Vercel 本番デプロイ |
1. 公開ページは「不透明トークン+公開列だけ返す RPC」に一本化
公開プロフィールはログイン不要で誰でも見られます。一方、DB には非公開メモ(memo)やユーザーID など、公開してはいけない列が同居しています。
ここで「anon ロールにテーブルの SELECT 権限を配って RLS で絞る」形にはせず、公開読み取りを SECURITY DEFINER の RPC 1本に閉じ込め、公開可能な列だけを射影して返す設計にしました。
create or replace function public.get_public_shelf(p_token text)
returns jsonb
language sql
stable
security definer
set search_path = ''
as $$
select case when p.id is null then null else jsonb_build_object(
'profile', jsonb_build_object(
'display_name', p.display_name,
'avatar_url', p.avatar_url,
'bio', p.bio
),
'items', coalesce((
select jsonb_agg(jsonb_build_object(
'work_id', uc.work_id,
'status', uc.status,
'rating', uc.rating,
'book_comments', uc.book_comments, -- 公開コメント(memo は返さない)
...
ポイント:
- トークンは 128bit 乱数の不透明トークン。URL を知っていること自体を閲覧権限(capability)として扱います
-
is_publicフラグでオーナーがいつでも公開を止められます - anon に付与するのはこの関数 1 本の EXECUTE のみ。テーブル権限は一切渡しません。「anon にテーブル権限を配らない」原則の例外ではなく、関数単位の能力付与として整理しました
- 公開経路専用の型(
PublicShelf)を別に定義し、memoを含む内部型を公開ページで使い回せないよう型レベルでも分離しています
2. 全ユーザー共有マスタへの書き込みは「1列だけ更新できる RPC」に閉じる
作品マスタ(works)は全ユーザー共有です。表記ゆれによる重複を防ぐため、正規化キー(dedup_key)で同一作品を同定し、同じ作品は全員が同じ行を参照します。
共有マスタに汎用の UPDATE ポリシーを開くと、悪意ある(あるいは誤った)更新が全ユーザーに波及します。かといって完結フラグ(is_completed)のようにユーザーが更新したい列もある。そこで 更新対象を 1 列に限定した SECURITY DEFINER RPC を用意し、書き込みをそこに一本化しました。
- RPC 内で「呼び出しユーザーがその作品を自分の本棚に持っている場合のみ許可」のガードを入れる
-
is_completed以外の列(タイトル・著者など)には触れない - RLS の UPDATE ポリシー自体は開かない(列単位で制限できないため)
「行単位の制御は RLS、列単位の制御は RPC」という住み分けです。
なお、現時点では完結フラグの運用自体を一時凍結しています(完結の設定・完結フィルタ・完結ラベル表示はいずれも無効化。その代わり「読了」は完結条件を問わず自由に選択できます)。共有マスタである以上、1人の誤操作や悪意ある更新が同じ作品を持つ全ユーザーに波及します。RPC で更新経路は絞れていても、モデレーションや監査ログといった荒らし耐性の土台が公開水準に達していないと判断し、機能ごと止めました。書き込み経路(RPC・DDL)自体は残しているので、土台が整い次第フラグを戻すだけで復帰します。この判断は、「共有マスタは書き込み経路を絞るだけでは足りない」という上記の設計の裏返しでもあります。
3. satori で OGP 名刺画像を生成する
SNS に URL を貼ったときのリッチプレビューは next/og(satori)でサーバー生成しています。名刺そのものは通常の React で書いた HTML ページとして分離し、OGP はその「静止画版」という役割分担です。
satori は任意の HTML/CSS が使えるわけではなく、実装で複数回詰まりました:
- flexbox のみ対応(grid 不可)・inline style 必須。Tailwind のクラスは使えないため、名刺の配色を inline で再現する必要がありました
-
Fragment を列レイアウトに畳めないことがある。縦積みは明示的に
display: flex; flexDirection: columnのdivで包む必要があります -
フォントは ttf/otf/woff のみ(woff2 不可)。Google Fonts の
text=パラメータで表示文字だけの動的サブセットを取得し、Node 既定の UA で ttf を得る形にしました - 書影はサーバー側で fetch して data URL 化。外部 CDN の CORS・可用性に依存しないためですが、任意 URL をサーバーが fetch する構図になるため SSRF ガード(許可ホストの検証)を併せて実装しました
4. 外部 API のレート制限は「事前投入カタログ」で根本から外す
楽天ブックス API はアプリケーション単位で約 1 リクエスト/秒の制限があります。全ユーザーがサーバー経由で単一の API キーを共有する構成では、利用者が増えるほど検索がこの制限に詰まります。
キャッシュ(ミス時は API を叩く)では緩和止まりなので、バッチで漫画カタログを DB に事前投入し、ユーザーの検索経路から API 依存自体を外す方式にしました。
- 楽天ブックス+楽天Kobo の併走クロールでカタログを構築(電子限定作品の穴を防ぐため)。カタログは約 4.9 万作品
- 1req/秒のスロットリングを守りつつ、ジャンル細分化+発売日ソートの両方向スイープで 1 クエリ 3,000 件のページング上限を回避
- 日次リフレッシュ(GitHub Actions cron)で全行を約 80 日周期で再訪し、書誌情報の鮮度を保つ
- ユーザー検索はまず自前 DB に当て、0 件時のみ API へフォールバック
地味に効いたのが正規化の TS/SQL 一致です。同一作品の同定キーは「正規化タイトル+著者」ですが、紙と電子で同じタイトルのダッシュ表記が - と ー に揺れる実データがあり(例: "NARUTO-ナルトー" vs "NARUTOーナルトー")、そのままだと別作品に割れます。ダッシュ系文字を ASCII - へ写像する正規化を入れました。
// SQL 関数 work_dedup_key の translate(..., 'ーー‐‑‒–—―−-', '----------') と一致させる
const DASH_CHARS = /[ーー‐‑‒–—―−-]/g;
export function canonicalizeDashes(s: string): string {
return s.replace(DASH_CHARS, '-');
}
「削除」ではなく「写像」なのは、長音を消すと "ビール"→"ビル" のような過剰マージが起きるためです。この正規化は SQL 関数と TS 純関数の両方に存在するため、コメントで相互参照を明記して二重定義のズレを防いでいます。
5. ジャンル分類は LLM に判定させ、TS 側で二重検証する
名刺ページのジャンル割合(先頭の画像で棒グラフになっている部分)は、あらすじを LLM に読ませて分類したものです。ここも「全ユーザー共有マスタへの書き込み」なので、2節と同じ制約が乗ります。
-
書き込み経路は日次バッチ1本に限定(
genre is nullの行だけを対象にする冪等な設計。ユーザー操作からの書き込みは無く、対象0件ならそもそも LLM を呼びません) - LLM の出力は構造化出力(JSON Schema)で enum に縛り、さらに TS 側でも検証します。DB に CHECK 制約は意図的に置かず、ジャンルの一覧を TypeScript の型として単一の正にしました。タクソノミーを変えたくなったときに DDL を増やさず、TS を直してから全件再分類コマンドを流すだけで収束します
- 実行はサーバー内バッチのみで、キーはユーザーの手元に出ません
決定的ロジック(4節のカタログや次節の ComiMatch)と違い、ここはLLM の判定をそのままプロダクトの表示に使っている唯一の箇所です。だからこそ「壊れても壊滅しない」構造(対象は null 行だけ・型で二重検証・再実行で収束)を優先しました。
6. 2つの公開本棚を突き合わせる「ComiMatch」の設計
ComiMatch は、2人の公開本棚の URL を突き合わせて 相性スコア・共通作品・相互おすすめ・評価相関 を可視化する機能です。
面白いのは、この機能が RLS もスキーマ変更もゼロで成立していることです。既存の公開基盤(1節の不透明トークン)にそのまま乗っています。「両方の本棚 URL を持っている=双方が共有に合意した」という capability モデルなので、新しい権限もテーブルも要らない。マッチのロジックは全部 features/match/ の純関数に閉じ込め、Vitest で単体テストしています。
集合演算がそのまま「作品一致」になる
作品マスタ works は共有マスタで、表記ゆれは dedup_key で既に同定済みです(4節)。つまり work_id が一致すれば作品が一致。マッチ側で名寄せは不要で、共通作品の抽出は work_id 集合の内積そのものになります。ここが「共有マスタにしておいた」ことの副次的な恩恵でした。
相性スコア:Jaccard が内包で低く出る問題
素直に考えると、2つの集合の類似度は Jaccard 係数(|A∩B| / |A∪B|)です。ところが実データで試すと、片方が他方を丸ごと内包するケース(初心者の10冊が、ベテランの500冊に全部含まれている)で不当に低いスコアが出ました。共通作品は完全一致なのに、和集合が巨大なので Jaccard が小さくなるためです。
かといって overlap 係数(|A∩B| / min(|A|,|B|))だけにすると、今度は「小さな棚が巨大棚に全部入っている」を過剰に 100% 評価してしまう。そこで両者の平均をブレンドし、内包を適度に持ち上げつつ極端な優遇は避ける形にしました。
const jaccard = unionSize > 0 ? intersection.length / unionSize : 0;
// overlap(重なり)係数: 内包(B⊂A)で 1.0。Jaccard とブレンドして規模差の厳しさを緩める。
const overlap = minSize > 0 ? intersection.length / minSize : 0;
const similarity = (jaccard + overlap) / 2;
この集合類似度を土台に、意味的に強いシグナルを重み付けで足します——「今いちばんの一冊」の一致(大)/「推しの3冊」の被り(中)/共通作品の読了率(小)。各シグナルを 0〜1 に正規化し、重み合計を 1.0 にしてあるので、スコアは自然に 0〜100 に収まり上限クランプが要りません。
export const MATCH_WEIGHTS = {
similarity: 0.6, // Jaccard×overlap のブレンド
signature: 0.15, // 今いちばんの一冊の一致
pick: 0.15, // 推しの3冊の被り
finished: 0.1, // 共通作品の両者読了率
} as const;
評価相関:コサインは「真逆」を表現できない
「2人の評価センスが似ているか」も出したくて、当初はコサイン類似度を考えました。ところが評価は 1〜5 のすべて正の値です。全成分が正のベクトルどうしのコサインは常に 1 付近に飽和し、「評価が真逆」を表現できません。
そこで平均中心化したベクトルのコサイン=ピアソン相関係数(−1〜+1)に読み替えました。これなら「似ている/真逆」を正しく −1〜+1 で表せます。
さらに実データで、もっと厄介なバグが出ました。ある共通作品群で、片方のユーザーが有名作を軒並み★5にしていて、唯一★4を付けた一冊を相手が★5にしていた。それだけで相関が r = −0.47「正反対」 と表示されたのです。ピアソン相関は各自の標準偏差で割るため、評価の広がりが小さいと、たった1冊のズレで符号が反転するという不安定さがあります。
対策として、**どちらかの評価の広がり(max − min)が 2 未満なら数値を断定せず「判定保留」**にしました。「全部★5」のような分散のほぼ無いデータで無理に相関を出さない、というガードです。データ不足(共通の評価済みが3冊未満)とは別の理由として区別し、UI の文言も出し分けています。
相互おすすめ:AI を使わない決定的レコメンド
「相手が★4以上を付けていて、自分はまだ持っていない作品」を抽出して、おすすめとして出します。AI は使いません。決定的なロジックなので「誰の本棚由来か」を根拠として示せるのが、AI レコメンドには無い強みです。
マッチ結果は名刺と同じ satori 基盤で動的 OGP 化しているので、マッチ URL 自体がシェアできるコンテンツになります。
7. AI を「統制して」開発する
本プロジェクトは Claude(実装)と Gemini(レビュー)を組み合わせて開発しました。重視したのは「AI に作らせる」ではなく、仕様・制約・検証ゲートを自分で設計し、AI をその枠内で動かすことです。
- 仕様ファースト: 設計制約・確定仕様を AGENTS.md と docs/ に言語化し、AI はこの枠内で実装する。セッションをまたいでも判断がブレない
- レビューはあえて別系統の AI に: 実装に使った AI と同じモデルに検証させると視点が重なるため、レビューは非 Claude(Gemini / Google Antigravity)に分離
- push を止める自動ゲート: pre-push フックで Vitest と Gemini レビューを自動実行し、テスト失敗・レビュー BLOCK なら push 自体が中止される
デプロイまでの流れは多層ゲートで直列化しています:
① commit ─ post-commit フック(レビュー用プロンプト生成)
② push ─── pre-push フック(Vitest + AI レビュー。失敗で push 中止)
③ GitHub Actions(ESLint + Vitest)
④ Vercel 本番デプロイ(③成功時のみ。Git 自動デプロイは OFF にして CI を起点に一本化)
Vercel の Git 連携デプロイは push で即・CI と並列に走ってしまうため OFF にし、CI から Vercel CLI でデプロイする形にして「テストが通ったコードだけが本番に出る」を構造で保証しています。DB のスキーマ変更(DDL)も、当初はこのパイプラインの対象外でしたが、後述の理由から現在は Supabase の GitHub 連携で push 時に自動適用する経路へ一本化しています。
8. テストのピラミッドと、e2eを本番Supabaseから切り離した話
- 単体 = Vitest: 純関数(ソート・絞り込み・ステータス判定・シリーズ畳み込み・正規化)を対象に、DB/API/ブラウザなしで高速に回す。テストは対象ファイルの隣にコロケーション
- E2E = Playwright: 実ブラウザ+ローカルの Supabase スタックでユーザー操作フローを通す。遅く副作用があるため CI からは除外し、機能完了などの節目で手動実行
ロジックを features/comics/sort.ts / filter.ts / status.ts のような純関数モジュールに集約する設計は、テスト容易性のためでもあります。UI に判定式を散らさない構えが、そのまま「Vitest で守れる範囲」を広げてくれました。
当初 e2e は「本番 Supabase に対して直接実行する」という割り切りで動かしていました。個人開発の初期はそれで問題ありませんでしたが、本番 SECRET キーがテスト実行環境から到達可能というリスクを残したままにはできないと判断し、Supabase CLI のローカル Docker スタックへ移行しました。
移行してみて分かったのは、接続経路が実は2系統あるということです。(a) テストランナー自身が管理API操作に使う接続と、(b) Playwright が起動する dev サーバーの接続。どちらも .env 系のファイルから設定を読むのですが、片方だけをローカル向けに切り替えると「アプリは本番に書き込み、テストだけがローカルを見ている」というズレた状態がそのまま動いてしまいます。両方を新しい .env ファイルにまとめて、本番キーを一切含めない構成にして初めて安全になりました。
もう一つ踏んだ地雷が reuseExistingServer オプションです。CI以外では既存の dev サーバーを再利用する設定になっていたのですが、これだと npm run dev で起動しっぱなしの(本番を向いた)サーバーがそのまま使われてしまい、まさに避けたかった事故を自分で起こしにいく形になります。ここは常に新しいサーバーを起動する設定に変えました。
最後に、接続先を間違えたときにテストが0件のまま静かに終わるのが一番怖かったので、接続URLが 127.0.0.1/localhost でなければ即座に例外を投げるガードを入れました。実際に本番URLを紛れ込ませてテストしたところ、狙いどおり「ローカルスタックを指していません」というエラーで即停止することを確認しています。
移行後は、本番の SECRET キーがテスト実行環境から到達不能になりました。それまで直列実行にしていたのも安全側に振った結果だったので、あわせて並列実行を解禁したところ、e2e フルスイート(39件)が直列 44.7 秒だったのに対し並列で 34% 短縮できました。
まとめ
「全ユーザー共有のDB」「外部APIの単一キー共有」「テストが本番に触れる」といった、個人開発でも起きがちな構造的リスクに対して、権限をRPC単位まで絞る・書き込み経路を機能ごと止められるようにしておく・実行環境を物理的に分離する、という対策を積み重ねました。ソースは非公開のためコード全体は追えませんが、考え方の参考になれば幸いです。
これらの実装は個人開発サービス ComiCard で使っています: https://comicard.app/?utm_source=qiita&utm_medium=blog&utm_campaign=launch&utm_content=qiita-article






![pre-pushフックでGeminiレビューが動き、[PASS]判定とレビュー詳細(セキュリティ・設計・リソース管理の指摘)を表示しているターミナル出力(任意)](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F3723230%2Fa63479fc-065b-439e-9cd9-e46a5c8b9482.png?ixlib=rb-4.1.1&auto=format&gif-q=60&q=75&s=33ebb302e2b829e1f7bf78131e3afab2)