Firebase Auth + DynamoDB のDB分断地獄から抜け出した話|write-throughで段階移行する設計パターン

目次

「write-through効いてますね!」って急に言われた話

ある日、Next.jsで作っているプロジェクトの大規模リファクタリングをしていた時のこと。認証DBと業務DBが分断していて不具合だらけになっていた本番システムを、DynamoDBに統一する作業を完了した直後、レビュアーからこんなコメントが返ってきました。

write-through効いてますね!emailがDB保存されたので次回以降はFirebase不要

確かに、Firebase Authで認証していたユーザー情報を、初回ログインのタイミングでDynamoDBにも書き込むことで、2回目以降はFirebaseを通さずに認証が成立する設計にはした。bcryptでパスワードハッシュ化もして、CRUD処理も全部DynamoDB側に統一してスッキリした。

でも、それを「write-through」と呼ぶのを今初めて知ったのです。「は?write-through?なんやねん」と心の中でツッコミました。

結論から言うとこういうことです。

  • write-through=書き込み時に「速い/一時的な保存先」と「永続的な保存先」の両方に同期で書き込むパターン
  • 今回の文脈では、Firebaseが「外部認証サービス」、DynamoDBが「自前の永続化層」
  • Firebase経由で認証が通った瞬間に、DynamoDBにもemail等を書き込んでおく
  • 2回目以降、DynamoDBにデータがあるからFirebaseに問い合わせなくていい

この記事では、「Firebase Auth + DynamoDB のDB分断問題に詰んでいた本番システムを、DynamoDB統一に持っていった実体験」を通して、write-throughパターンの正体と段階移行の設計を解説します。同じような構成で困っているエンジニア向けの内容です。

何が起きていたか:認証DBと業務DBが分断していた本番

本題のwrite-throughに入る前に、なぜDBの統一作業をすることになったのか、起きていた問題を整理しておきます。これが分かると、write-throughが「単なる用語」ではなく「現場の課題への解答」として腹落ちしやすいので。

Before:Firebase Auth + DynamoDB の二刀流構成

引き継いだ本番システムはこんな構成でした。

[Next.jsアプリ]
   ├─ ユーザー認証データ → Firebase Auth
   │   (uid, email, パスワード管理など)
   │
   └─ 業務データ → DynamoDB
       (注文、投稿、設定など)

紐付けは Firebase の uid を DynamoDB のレコードに保存する形

一見、責務が分かれてて美しく見える構成。でも、これが地獄の入り口でした。

起きていた具体的な不具合

新機能でCRUD処理を入れようとした時、ありとあらゆる場面でDBが分かれていることが牙を剥いてきました。

  • JOIN相当の処理ができない:「ユーザー一覧 × その人の投稿数」みたいな単純なクエリですら、Firebase側のユーザー一覧を取ってきてDynamoDB側を別途叩く必要あり
  • 整合性のズレが頻発:Firebaseでユーザー作成→DynamoDB保存失敗、みたいな部分失敗で「認証はあるけど業務データなし」の幽霊ユーザーが量産
  • ID変換ロジックの増殖:Firebase UID ↔ メールアドレス ↔ DynamoDBのパーティションキー、と毎回変換するヘルパー関数が乱立
  • 削除処理の難易度爆上がり:ユーザー削除時、Firebase側とDynamoDB側の両方を確実に削除する保証が難しく、孤児データが発生
  • ローカル開発が辛い:FirebaseはエミュレータあるけどDynamoDBは別途Local用意…と環境構築が複雑化

つまり、「責務分離」のつもりが「責務分断」になっていた状態。新機能追加のたびに「あれ、これどっち触ればいい?両方?整合性は?」と悩むのが日常になっていました。

解決方針:DynamoDBにDBを統一する

選択肢を検討した結果、シンプルにDBを一本化する方針に。

  • 認証情報もDynamoDBに集約(emailをパーティションキー、bcryptハッシュを属性として保存)
  • Firebase Authは段階的に廃止(最終的に外部依存ゼロを目指す)
  • パスワードハッシュ化は bcrypt を採用

でも、既存ユーザーが普通にいる本番システム。「明日からDynamoDBに切り替えます!全員パスワード再設定してください!」みたいな乱暴な移行はできません。ここで活躍したのが、後で名前を知ることになる「write-through」というパターンでした。

改めて:write-throughとは何者か

write-through(ライトスルー)は、もともとCPUキャッシュの世界で生まれた用語ですが、現代ではWebアプリケーションのキャッシュ戦略として広く使われています。

ひと言定義はこれ。

👉 「キャッシュ層と永続化層に、同期的に同時に書き込むパターン」

教科書的なwrite-throughの図

[アプリ]
   ↓ write
[キャッシュ層(例:Redis)]
   ↓ 同時に書き込み(同期)
[永続化層(例:PostgreSQL)]
   ↓
書き込み完了をアプリに返す

典型例としてよく挙げられるのは「Redisで高速読み出しを実現しつつ、PostgreSQLにも同時に保存しておく」というパターン。Redisで爆速読み込み、PostgreSQLで永続化、両方のいいとこ取りです。RedisについてはRedis入門!爆速データ処理の秘密と活用術に詳しいので、合わせてどうぞ。

ここで気づく違和感

「あれ?でも今回の話、Redisとか出てこなかったよね?」

そう、ここがポイントなんです。write-throughは「キャッシュ ⇔ DB」だけの話じゃなくて、「速い・一時的な層」と「遅い・永続的な層」の関係なら何にでも当てはまる概念。

今回のFirebase認証の例で言うと、こうなります。

抽象的な概念 今回の文脈
速い層/一時層 Firebase Auth(外部の認証プロバイダ)
永続化層 DynamoDB(自前の業務DB)
書き込みタイミング 初回ログイン時に「両方同時」に

初回ログインで Firebase が認証を通したその瞬間に、emailとbcryptハッシュをDynamoDBにも書き込む。これによって、次回以降はDynamoDBだけ見れば認証情報が完結する。まさにwrite-throughの概念そのままです。

DB統一で起きていた “write-through” の正体

もう少し具体的に、Next.jsで実装したコードと一緒に整理してみます。AWS SDK v3 のDynamoDB Documentクライアントを使った実装例です。

Before:Firebaseに毎回問い合わせ + 業務データ別管理

移行前は、認証のたびにFirebaseを叩いて、業務データはDynamoDBから取る、という二重構造でした。

// app/api/login/route.ts(Before)
import { signInWithEmailAndPassword } from 'firebase/auth'
import { auth } from '@/lib/firebase'
import { docClient } from '@/lib/dynamodb'
import { GetCommand } from '@aws-sdk/lib-dynamodb'

export async function POST(req: Request) {
  const { email, password } = await req.json()

  // Step 1: Firebase Authで認証
  const userCredential = await signInWithEmailAndPassword(
    auth,
    email,
    password
  )
  const firebaseUid = userCredential.user.uid

  // Step 2: DynamoDB側でユーザーの業務データを取得
  // → Firebase UID で紐付くデータを別途取得する必要があった
  const result = await docClient.send(new GetCommand({
    TableName: 'UserProfiles',
    Key: { firebaseUid },
  }))

  return Response.json({
    user: userCredential.user,
    profile: result.Item,
  })
}

これだと毎リクエストで「Firebaseに通信 + DynamoDBに通信」の2連続。レイテンシも高いし、Firebaseが落ちると認証ごと止まる単一障害点でもあります。

After:DynamoDB単体で認証完結 + write-throughで移行

移行後は、初回ログイン時に「Firebase認証 + DynamoDBにemail/bcryptハッシュ書き込み」を同時実行。2回目以降はDynamoDBだけで認証が完結します。

// app/api/login/route.ts(After)
import { signInWithEmailAndPassword } from 'firebase/auth'
import bcrypt from 'bcrypt'
import { auth } from '@/lib/firebase'
import { docClient } from '@/lib/dynamodb'
import { GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb'

export async function POST(req: Request) {
  const { email, password } = await req.json()

  // Step 1: まずDynamoDBを見る(write-throughの恩恵)
  const result = await docClient.send(new GetCommand({
    TableName: 'Users',
    Key: { email },
  }))
  const localUser = result.Item

  if (localUser?.passwordHash) {
    // DynamoDBに既にデータがあるのでFirebase不要
    const isValid = await bcrypt.compare(password, localUser.passwordHash)
    if (!isValid) {
      return Response.json({ error: 'Invalid credentials' }, { status: 401 })
    }
    return Response.json({ user: localUser })
  }

  // Step 2: 初回はFirebaseで認証
  const userCredential = await signInWithEmailAndPassword(
    auth,
    email,
    password
  )

  // Step 3: ★ここがwrite-through★
  // Firebase認証成功と同時に、DynamoDBにも書き込み
  const passwordHash = await bcrypt.hash(password, 10)
  const newUser = {
    email,
    passwordHash,
    firebaseUid: userCredential.user.uid,
    migratedAt: new Date().toISOString(),
  }

  await docClient.send(new PutCommand({
    TableName: 'Users',
    Item: newUser,
  }))

  return Response.json({ user: newUser })
}

この実装の本質を分解すると、こんな流れになります。

[初回ログイン]
ユーザー → ログイン要求
   ↓
[アプリ] DynamoDB確認 → データなし
   ↓
[Firebase] 認証成功
   ↓
[アプリ] ★同時にDynamoDBへ書き込み(write-through)★
   ↓
レスポンス返却

[2回目以降]
ユーザー → ログイン要求
   ↓
[アプリ] DynamoDB確認 → データあり!
   ↓
[bcrypt] パスワード照合
   ↓
レスポンス返却(Firebaseは呼ばない)

これがレビュアーが言っていた「write-through効いてますね!emailがDB保存されたので次回以降はFirebase不要」の正体です。書き込み時にFirebaseだけじゃなくDynamoDBにも同時に保存することで、次回以降はFirebaseに依存しない高速・自前認証が成立する、という仕組み。

そして何より大きいのは、業務データと認証データが同じDynamoDBに集まったことで、CRUD処理が劇的にシンプルになったこと。ユーザー情報も投稿情報も同じテーブル設計の中で扱えるようになり、ID変換ヘルパーや整合性チェックの大半が不要になりました。

似た用語との違いを整理(混乱しがちなやつ)

キャッシュ戦略の用語って、とにかく似たような名前が多くて混乱しがち。せっかくなのでまとめて整理しておきます。

パターン名 動き 強み 弱み
write-through キャッシュとDBに同期で同時書き込み 整合性が高い/読み込みが速い 書き込みが少し遅い
write-back(write-behind) まずキャッシュに書き、DBには後で非同期書き込み 書き込みが超速い キャッシュ落ちでデータ喪失リスク
cache-aside(lazy loading) アプリが直接DB読み書き、キャッシュは必要時に手動で更新 シンプル/柔軟 キャッシュ更新漏れが起きがち
read-through キャッシュにないとき、キャッシュ層が自動でDBから読む アプリのコードがシンプル 初回読み込みが遅い

使い分けの判断基準

ざっくり判断するならこの順序。

  • 整合性が最優先 → write-through(認証、決済、アカウント情報など)
  • 書き込み速度が最優先 → write-back(ゲームのスコア更新、IoTセンサーログなど)
  • 読み込み中心の用途 → cache-aside(記事一覧、商品情報など)
  • キャッシュをアプリから隠したい → read-through(CDN風の透過キャッシュ)

今回の認証情報は「整合性が超重要」な領域なので、write-throughが正解だったわけです。

実装で押さえるべきポイント

write-throughは強力ですが、適当に実装するとハマるポイントがあります。Next.js × DynamoDB での実例で押さえておきましょう。

ポイント1:DynamoDBの条件付き書き込みで二重登録を防ぐ

「Firebase認証は成功したけど、DynamoDB書き込みは失敗」「複数リクエストが同時に来て同じユーザーが二重登録される」みたいな事故を防ぐため、ConditionExpression を使った条件付き書き込みを使います。

import { PutCommand } from '@aws-sdk/lib-dynamodb'
import { signOut } from 'firebase/auth'

try {
  const userCredential = await signInWithEmailAndPassword(
    auth,
    email,
    password
  )

  // emailが既に存在する場合は書き込まない(race condition防止)
  try {
    await docClient.send(new PutCommand({
      TableName: 'Users',
      Item: {
        email,
        passwordHash,
        firebaseUid: userCredential.user.uid,
        createdAt: new Date().toISOString(),
      },
      ConditionExpression: 'attribute_not_exists(email)',
    }))
  } catch (dbError) {
    // 整合性のため、Firebase側もサインアウト
    await signOut(auth)
    throw dbError
  }
} catch (error) {
  return Response.json({ error: '認証に失敗しました' }, { status: 500 })
}

attribute_not_exists(email) という条件を付けることで、既にレコードがある場合は上書きせずエラーを返します。同時アクセスでの二重登録防止にも、移行漏れの検知にも使える仕組みです。

ポイント2:bcryptのsaltRoundsは10が標準

パスワードハッシュ化のsaltRoundsを高くしすぎると、ログイン時のレイテンシが増えてユーザー体験が悪化します。一般的なWebアプリなら10で十分です。

import bcrypt from 'bcrypt'

// 標準的な設定
const SALT_ROUNDS = 10

// ハッシュ化
const hash = await bcrypt.hash(password, SALT_ROUNDS)

// 検証
const isValid = await bcrypt.compare(password, hash)

bcryptのより詳細な使い方はbcryptでハッシュ化する|初心者エンジニア向けセキュリティ実装入門で深く解説されているので、実装時に併せてチェック推奨。

ポイント3:移行期間の二重認証戦略

既存ユーザーが大量にいる本番システムだと、いきなり全部DynamoDB側に移行するのは無理。段階的にwrite-throughで移行する戦略が現実的です。

// 移行期間の認証ロジック
async function authenticate(email: string, password: string) {
  // 1. まずDynamoDBを見る
  const result = await docClient.send(new GetCommand({
    TableName: 'Users',
    Key: { email },
  }))
  const localUser = result.Item

  if (localUser?.passwordHash) {
    // 既に移行済み → DynamoDBで認証
    const isValid = await bcrypt.compare(password, localUser.passwordHash)
    return isValid ? localUser : null
  }

  // 2. DynamoDBに無ければFirebaseで認証
  try {
    const userCredential = await signInWithEmailAndPassword(
      auth,
      email,
      password
    )

    // 3. 認証成功時にwrite-throughでDynamoDBに保存(移行)
    const passwordHash = await bcrypt.hash(password, 10)
    const newUser = {
      email,
      passwordHash,
      firebaseUid: userCredential.user.uid,
      migratedAt: new Date().toISOString(),
    }

    await docClient.send(new PutCommand({
      TableName: 'Users',
      Item: newUser,
      ConditionExpression: 'attribute_not_exists(email)',
    }))

    return newUser
  } catch {
    return null
  }
}

このパターンの美しいところは、ユーザーが普通にログインするだけで自動的に移行が完了していくこと。一斉メンテも不要、データの再取得も不要。気づけば全員のデータがDynamoDB側に揃っているという、運用に優しい設計です。

移行が一定の閾値(例:90%のユーザーが移行済み)に達したら、Firebaseを完全に廃止して、新規登録もDynamoDB直接書き込みに切り替えれば、外部依存が完全に解消できます。

write-throughを採用するメリット・デメリット

体系的にまとめておきましょう。

メリット

  • 整合性が高い:両層に同時書き込みなのでデータの食い違いが起きにくい
  • 読み込みが速い:自前DB側にデータがあるので、外部依存なしで応答
  • 外部依存を減らせる:Firebaseが落ちても、初回認証済みユーザーは影響なし
  • 段階的移行が可能:既存ユーザーを徐々に新システムに引っ越せる
  • DBの一貫性が保てる:今回のように複数DBを統合する場面で特に有効

デメリット

  • 書き込みが遅い:両層に書くので、書き込みレイテンシが2倍程度になる
  • 実装が少し複雑:失敗時のロールバック処理が必須
  • 無駄なデータが入る可能性:実は読み込まれないユーザー情報まで全部DBに入る

真価を発揮するケース

write-throughが最も活きるのは、こんなシチュエーションです。

  • 外部サービスから自前管理に移行する過渡期(今回の事例)
  • 整合性が超重要なドメイン(認証・決済・残高など)
  • 読み込み頻度が圧倒的に多い(書き込み1:読み込み100など)
  • DBが分断していて統合したい場面
  • 外部API依存を減らしたい(コスト削減・障害耐性向上)

逆に、書き込みがめちゃくちゃ多くて整合性は緩くてもいいケース(ゲームのスコア、IoTセンサーログなど)では、write-back の方が向いています。

まとめ:用語は実装で出会うと一生忘れない

「write-through」、最初は「なんやねん」だったこの用語も、自分が書いたコードを言語化してくれる名前と知って、急に親近感が湧きました。整理するとこういうこと。

  • write-through=「速い/一時層」と「永続化層」に同時書き込みする設計パターン
  • キャッシュ ⇔ DB だけじゃなく、Firebase ⇔ DynamoDB の関係にも当てはまる
  • 初回ログイン時にemail+bcryptハッシュをDynamoDBに保存しておけば、次回以降はFirebase不要
  • 分断していたDBを統合する移行戦略としても有効
  • 整合性重視の領域(認証・決済)には特に効く

振り返ってみると、今回のリファクタリングが解いた課題はこんな感じです。

  • Firebase Auth + DynamoDB のDB分断によるCRUD不具合 → DynamoDBに統一して解消
  • 既存ユーザーの一斉移行リスク → write-throughで段階移行
  • Firebaseへの外部依存 → 移行完了次第切り離し可能

技術用語って、教科書で読んでも頭に入らないけど、自分が実装したコードに後から名前が付く瞬間に一気に腹落ちするものですよね。今回みたいに「無意識にやっていた設計に、ちゃんとした名前があった」と知れると、次回からは意図的に選択できるようになる。これがエンジニアの語彙力が増えるってことなんだと思います。

もしあなたも今、「Firebaseから移行したい」「Auth0を辞めたい」「DBが分断してて辛い」「SaaS依存を減らしたい」みたいな状況にいるなら、write-throughパターンを意識して移行ロジックを組んでみてください。気づけば自然に脱却できているはずです。

認証・キャッシュ設計をさらに深める関連記事

write-throughパターンを腹落ちさせたら、次は認証システム全体の設計力やキャッシュ戦略の引き出しを増やしていきましょう。実装の幅が広がる関連記事を厳選しました。

認証・セキュリティ実装を強化する

キャッシュ・データ処理の引き出しを増やす

用語の解像度を上げる

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次