Claude Code で CakePHP → Next.js をフルリライトして判断したこと
はじめに
以前、CakePHP から Go にリプレースしている話を書きましたが、問題提起から約2ヶ月で無事完了しました。今回はもう一本、教育向け動画配信システムの CakePHP 3.5(PHP 7.1)から Next.js(TypeScript)へのフルリライトについて記録します。前回の認証基盤は他システムから呼ばれる API が主体だったため Go + React の分離構成を選びましたが、今回は動画配信という UI 中心のシステムのため、フルスタックで完結する Next.js が適切と判断しました。
対象システムは長年運用してきたもので、OS・ミドルウェア・PHP のバージョンがことごとく EOL に達していました。今回のリライトはそれらを現行世代に刷新することが主目的です。現時点では STG 環境の構築まで完了しており、本番移行に向けた調整はこれからですが、実装としてはほぼ仕上がっている状況です。
実装のほぼ全量を Claude Code が担当しました。自分の役割はアーキテクチャ判断・技術選定・最終確認に徹し、コードを直接書く機会はほとんどありませんでした。
この記事では、自分が判断を求められた場面を中心に記録します。
技術スタック
| 分類 | 採用技術 |
|---|---|
| フレームワーク | Next.js 15 + TypeScript |
| 認証 | NextAuth v5(Credentials + JWT) |
| ORM | Prisma v7 + Aurora MySQL |
| UI | shadcn/ui + Tailwind CSS v4 |
| インフラ | ECS Fargate / CloudFront / ALB / Terraform |
| 状態管理 | DynamoDB(排他ログインのみ) |
| 動画配信 | Uliza(既存継続) |
動画コンテンツの配信には引き続き Uliza を利用しています。動画データの移行コストが大きいため、配信基盤はそのまま継続する判断をしました。
① JWT stateless で始めたら排他ログインが実装できなかった
なぜ排他ログインが必要か
教育向けサービスの性質上、受講ライセンスは生徒1人1アカウントという前提があります。旧 CakePHP システムでは users.token(DB)と Redis セッションを組み合わせて排他ログインを実装しており、Next.js への移行でもこの仕組みを引き継ぐ必要がありました。
stateless JWT の壁
NextAuth v5 + Credentials プロバイダの組み合わせでは database sessions が非対応のため、JWT strategy が唯一の選択肢です。コールバックにセッション情報を埋め込むだけで動き、Redis や DB に依存しないシンプルな構成です。
ところが stateless である以上、発行済みのトークンを後から無効化する手段がありません。「別の端末でログインしたら古いセッションを蹴る」という処理は、どこかに「現在有効なトークンID」を持たなければ実現できません。
判断: ElastiCache ではなく DynamoDB
旧システムが Redis を使っていたため、AWS 移行後も ElastiCache for Redis が自然な選択肢でした。しかしシステム規模に対してコストと運用負荷が見合わないと判断し、代替案を検討しました。
| 案 | メリット | デメリット |
|---|---|---|
| ElastiCache for Redis | 旧構成と同等、TTL 管理が楽 | VPC 内配置が必要、コストが高い |
| RDS(既存 DB) | 追加インフラなし | 認証フローが DB に強依存になる |
| DynamoDB | サーバーレス、TTL 自動削除、安価 | AWS 限定 |
インフラはすでに AWS に寄せているため、DynamoDB を選びました。持つ状態は { user_id → session_token } だけです。新しいログインが来たら上書き、照合時に不一致なら蹴ります。
実装
ログイン時(authorize コールバック)
// auth.ts
const sessionToken = randomUUID();
try {
await setUserToken(String(user.id), sessionToken);
} catch (err) {
// DynamoDB 障害時はログイン自体は通す(fail-open)
console.error("[authorize] setUserToken failed", err);
}
return { ...userFields, sessionToken };
JWT に sessionToken を埋め込む(auth.config.ts)
jwt({ token, user }) {
if (user) token.sessionToken = user.sessionToken;
return token;
},
session({ session, token }) {
session.user.sessionToken = token.sessionToken as string | undefined;
return session;
},
DynamoDB への読み書き(userTokenStore.ts)
export async function setUserToken(userId: string, sessionToken: string) {
const expiresAt = Math.floor(Date.now() / 1000) + 8 * 24 * 60 * 60; // 8日後
await doc.send(new PutCommand({
TableName: process.env.USER_TOKENS_TABLE_NAME,
Item: { user_id: userId, session_token: sessionToken, expires_at: expiresAt },
}));
}
export async function getUserToken(userId: string) {
const res = await doc.send(new GetCommand({
TableName: process.env.USER_TOKENS_TABLE_NAME,
Key: { user_id: userId },
}));
if (!res.Item) return undefined; // レコード無し = TTL 失効 or 未登録
return res.Item.session_token as string;
}
Server Component layout で照合(sessionGuard.ts)
export async function assertActiveSession(session: Session) {
if (!isUserTokenStoreEnabled()) return; // env 未設定なら skip
const jwtToken = session.user.sessionToken;
if (!jwtToken) {
redirect("/login"); // 機能 ON 前の古い JWT
}
let dbToken: string | undefined;
try {
dbToken = await getUserToken(session.user.id);
} catch {
return; // DynamoDB 障害時は fail-open
}
if (dbToken === undefined) redirect("/login"); // TTL 失効
if (dbToken !== jwtToken) redirect("/login?error=concurrent"); // 別端末ログイン
}
ポイント
- DynamoDB の TTL(
expires_at)で古いレコードは自動削除される。JWT の maxAge(7日)より1日長く設定してズレを吸収 - DynamoDB 障害時は fail-open(ログイン・ページ表示ともに通す)。動画を視聴するだけのシステムという特性上、障害中に一時的にライセンス検証が緩むことより、サービスが止まることの方が問題が大きいと判断した
-
USER_TOKENS_TABLE_NAME未設定なら機能ごと無効化できるため、ローカル開発では DynamoDB なしで動く
② レガシーパスワードを bcrypt に透明移行した
旧システムのパスワード形式
旧 CakePHP システムのパスワードは、現代基準では暗号学的に十分とは言えない独自方式で保存されていました。移行のタイミングで bcrypt への刷新を行いました。
判断: パスワードリセット強制 vs ログイン時に透明移行
| 案 | メリット | デメリット |
|---|---|---|
| 全員パスワードリセット | 移行が即座に完了 | ユーザー全員への連絡・問い合わせ対応が必要 |
| ログイン時に透明移行 | ユーザーへの影響ゼロ | 旧検証ロジックをしばらく維持する必要がある |
教育向けサービスで問い合わせ対応コストをかけたくなかったため、透明移行を選びました。
実装
// auth.ts
if (isLegacyPassword(user.password)) {
valid = verifyLegacyPassword(password, user.password);
if (valid) {
// bcrypt に自動移行
const hashed = await hashPassword(password);
await prisma.users.update({
where: { id: user.id },
data: { password: hashed },
});
}
} else {
valid = await verifyPassword(password, user.password);
}
ログイン成功時に旧形式かどうかを判定し、旧形式であれば bcrypt に変換して DB を更新します。次回以降のログインからは bcrypt で検証されます。ユーザーはパスワードを変更することなく、自然に移行が完了します。
なお、Go リプレース記事で触れた PHP $2y$ ハッシュの話とは別の問題です。
-
前回(Go リプレース): bcrypt 同士の互換性(PHP
$2y$↔ Go$2a$/$2b$) - 今回(Next.js リライト): 非ハッシュ形式 → bcrypt への形式そのものの変更
③ Next.js の実行基盤に Fargate を選んだ
選択肢の整理
Next.js を AWS 上で動かす場合、次の4案を検討しました。
| 案 | 特徴 | 見送り理由 |
|---|---|---|
| Amplify Hosting | Next.js に最適化されたマネージドホスティング | 独自のデプロイモデルで既存 Terraform と馴染まない |
| App Runner | マネージドコンテナ、Fargate より運用が軽い | 既存の ECS/Terraform 知見が活きない |
| Lambda + CloudFront(OpenNext) | サーバーレス、アイドル時のコストがかからない | OpenNext のビルド・デプロイが既存の Terraform 中心のパイプラインと二重管理になりやすい |
| ECS Fargate | コンテナで動かす、既存構成と統一できる | — |
判断: Fargate
認証基盤の Go リプレース時にも ECS Fargate を採用しており、Terraform によるインフラ管理・デプロイパイプラインの知見がチームに蓄積されていました。新たにマネージドサービスの運用を覚えるよりも、既存の構成に揃える方が合理的と判断しました。
なお、Amplify や Lambda 構成が得意とする ISR(Incremental Static Regeneration)は、動画配信というシステムの性質上そもそも必要なく、機能面での差は判断に影響しませんでした。
まとめ
今回のリライトを通じて感じた役割分担を整理すると次のようになります。
| 役割 | 担当 |
|---|---|
| 要件定義・技術選定 | 人間 |
| アーキテクチャ・セキュリティ判断 | 人間 |
| 実装・テスト・リファクタ | Claude Code |
| コードレビュー・最終承認 | 人間 |
今回のリライトでは、次の3つの判断が自分に委ねられました。
- 排他ログインの状態をどこに持つか(①)
- レガシーパスワードからの移行をユーザー体験とどう両立させるか(②)
- 実行基盤を既存知見にどう揃えるか(③)
AI に実装を任せるとコードは速く仕上がるが、アーキテクチャ上の制約にぶつかったときの設計判断は人間が受け持つことになります。この構造を意識しておくと、AI 駆動開発での自分の役割がより明確になります。
Discussion