代表的な面接トピック

フロントエンド面接:Cookie、localStorage、sessionStorage、IndexedDBをどのように使い分けるか?

フロントエンド難しい
Offer.cc 編集チーム公開日 更新日

質問

SSRとオフライン利用をサポートするマルチテナント型プロジェクト管理Webアプリがあります。ログインセッション、テーマと言語の設定、タブスコープのフォーム下書き、および画像添付ファイルを含む10,000件の構造化されたオフラインレコードを保存する必要があります。これらのデータをCookie、localStorage、sessionStorage、IndexedDBに割り振り、セキュリティ、同期、クォータ、アップグレード、およびテスト計画について説明してください。

質問とユースケース

SSR(サーバーサイドレンダリング)とオフライン編集の両方をサポートするマルチテナント型プロジェクト管理Webアプリがあります。このアプリには4種類の状態が存在します。サーバーはタブをまたいでログインセッションを認識する必要があり、テーマと言語の設定は再訪問時にも維持される必要があり、マルチステップフォームの下書きは現在のタブのみに属しそのタブが閉じられたときに破棄されてもよく、画像添付ファイルを含む最大10,000件の構造化されたタスクレコードは再接続後のオフラインクエリ、編集、および同期をサポートする必要があります。

これらのデータをCookie、localStoragesessionStorage、IndexedDBに割り振ってください。また、この設計がXSSとCSRF、オリジン分離、一貫した初回SSRレンダリング、同時実行タブ、クォータと退避(eviction)、データベースのバージョンアップグレード、ログアウトとローカルクリーンアップ、およびオフライン同期をどのように処理するかを説明してください。

10,000件というレコード数は、構造化クエリ、非同期アクセス、および同期プロトコルについての議論を促すためのシナリオ上の制約です。ブラウザの容量保証ではありません。中核となるスキルは、Webプラットフォームのセマンティクスに基づいてフロントエンドの永続化の意思決定を行うことであるため、カテゴリはfrontendです。

面接官が評価しているポイント

第1に、候補者がAPIを選択する前に、誰がデータを読み取るのか、データの生存期間はどれくらいか、データの形状はどのようなものか、情報漏洩のコストはどれくらいか、どのシステムが権威(authoritative)を持つのかを尋ねているかどうかです。暗記した容量一覧表だけでは、SSR、認証、またはオフラインの一貫性を説明できません。

第2に、候補者が永続性とセキュリティを切り離して考えられるかどうかです。永続化できるからといって、localStorageがセッション識別子に適しているわけではありません。同一オリジンのスクリプトが読み取りや変更を行えるため、XSSペイロードによってデータが盗まれたり改ざんされたりする可能性があります。HttpOnly CookieはJavaScriptによるセッション識別子の読み取りを防ぎますが、ページ内で既に実行されているコードが認証済みリクエストを発行することを防ぐわけではありません。

第3に、候補者がCookieの挙動の両面を理解しているかどうかです。ブラウザは一致するCookieをリクエストとともに送信するため、サーバーレンダリングや認証に役立ちます。その一方で、この自動送信という特性があるため、CSRF対策が必要になります。SameSiteは防御策の1つですが、オリジン検証、CSRFトークン、または機密アクションに対する再認証の代わりになるわけではありません。

第4に、候補者がIndexedDBをエラーが発生し得るローカルレプリカとして扱えるかどうかです。IndexedDBは非同期の構造化オブジェクト、インデックス、Blobをサポートしますが、サーバーと自動的に同期するわけではありません。クォータ超過エラー、退避、クリーンアップ、ブロックされたアップグレード、同時実行の競合はアプリケーション側で管理する必要があります。

第5に、候補者が「リロード後もデータが残る」ことだけをテストとするのではなく、実行可能な障害マトリクスを提示できるかどうかです。

回答前の確認質問

  • サーバーが初回のレンダリングに必要な値はどれか? SSRが正しいログイン状態、言語、またはテーマに応じたHTMLを出力する必要がある場合、それらの値にはサーバーが読み取り可能なソースが必要になります。そうでない場合、クライアントでのマウント後に画面が切り替わる挙動をプロダクトとして許容しなければなりません。
  • どのような認証モデルを使用しているか? この回答ではサーバーサイドセッションを想定しています。純粋なAPIトークン設計の場合は、明示的な発行、ローテーション、失効、およびクロスサイトリクエストのルールも必要になります。
  • 下書きは本当にタブスコープであるべきか? タブを閉じた後の復元や別デバイスでの継続をユーザーが期待している場合、sessionStorageは要件を満たしません。IndexedDBまたはサーバーを使用する必要があります。
  • オフラインデータに機密情報は含まれているか? 共有デバイス、XSS、ブラウザプロファイル、ローカルバックアップの存在によって、ディスクに書き込んでよい内容が変わります。
  • 10,000件のレコードはどのようにクエリされ、更新されるか? プロジェクト別、更新時刻順、同期ステータス別のクエリでは、単純なキー・バリューアクセスよりもインデックスとトランザクションが重要になります。
  • サーバーが最終的な信頼できる唯一の情報源(Source of Truth)か? このシナリオでは、権威あるサーバーと再構築可能なローカルコピーを想定しています。オフラインで作成された代替不能なデータには、より強力な永続性、エクスポート機能、および競合制御が必要です。
  • 何をもって競合とみなすか? 複数デバイスでの同時編集では、バージョンによる拒否、フィールド単位のマージ、またはビジネス上の優先度が適用される場合があります。ストレージAPIがプロダクトに代わってそのポリシーを選択することはできません。
  • ログアウト時にすべてのテナントのデータを削除する必要があるか? 複数のアカウントがブラウザを共有する場合、次のユーザーが前のユーザーのレプリカを見ることができないよう、クリーンアップにはユーザーおよびテナントの名前空間が必要です。

30秒の回答フレームワーク

「私は、読み取り主体、有効期間、データ形状、機密性、および信頼できる唯一の情報源に基づいてデータをマッピングします。認証にはサーバーセッションと、SecureHttpOnly、適切なSameSite値を設定した__Host- Cookieを使用し、さらにCSRF対策を講じます。機密性のない小規模な設定にはlocalStorageを使用し、SSRが必要とする場合はサーバーが読み取り可能な初期値を持たせます。使い捨ての単一タブの下書きにはsessionStorageを使用します。構造化レコードと画像には、インデックス、トランザクション、バージョン管理されたスキーマ、および送信トレイ(outbox)を備えたIndexedDBを使用します。サーバーが権威を持ち続け、同期にはバージョンとべき等性キーを使用して競合を明示的に解決します。ローカルストレージはクリア可能でありクォータ制限によるエラーが起こり得るものとして扱い、XSS、CSRF、マルチタブ、退避、アップグレードのブロック、再接続、およびログアウト時のクリーンアップをテストします。」

ステップごとの詳細解説

ステップ1: 5つの質問で決定マトリクスを構築する

各値について、次の5つの質問を投げかけます。読み取り主体はサーバーか、現在のタブか、それともすべての同一オリジンのページか。有効期間は1回のレンダリングか、1つのタブか、1つのブラウザセッションか、それとも複数セッションか。データは小さな文字列か、構造化オブジェクトか、Blobか。漏洩や改ざんによる被害はどれくらいか。そしてサーバーとローカルデバイスのどちらに権威があるか。

これにより、このシナリオに対する初期の割り振りが導き出されます。

text
Login session       -> Server session + random identifier in an HttpOnly cookie
Theme and language  -> localStorage; add a server-readable initial value when SSR requires it
Single-tab draft    -> sessionStorage
Offline data/images -> IndexedDB + an application-level synchronization protocol

これは容量による順位付けではありません。ここでのCookieの決定的な能力は、リクエストとともにサーバーに届くことです。sessionStorageの決定的な境界は、最上位のブラウジングコンテキストです。IndexedDBは非同期トランザクション、構造化オブジェクト、およびインデックスを提供します。

ステップ2: 認証の権威をサーバーに持たせる

ブラウザには、エントロピーが高く有効期間の短いセッション識別子のみを保存します。実際の権限、有効期限、および失効処理はサーバー上に存在します。SecureHttpOnly、適切なSameSite値を設定し、できる限りDomainを持たずルートパスを指定した__Host- Cookieを使用します。ログアウト、パスワード変更、およびリスクイベントによってサーバーセッションは無効化されます。ブラウザの値を削除するだけでは不十分です。

HttpOnlyはセッション識別子への直接のスクリプトアクセスを減らしますが、同一オリジンのXSSはアクティブなページから認証済みのアクションを発行することができます。出力エンコーディング、CSP、およびその他のXSS対策は引き続き必須であり、リスクの高い操作には再認証を要求できます。一致するCookieは自動的に送信されるため、状態を変更するリクエストではオリジンの検証を行い、CSRFトークンも使用する必要があります。SameSiteを唯一の制御策にすべきではありません。

セッション識別子をlocalStorageに配置すると、正常に実行された同一オリジンのスクリプトから読み取り可能になります。クライアント側の暗号化は自動的な解決策にはなりません。ページのJavaScriptが復号キーを取得できるか、または復号パスを呼び出すことができる場合、通常は同一オリジンのXSSも同様に実行できるためです。

ステップ3: 機密性のない小さな設定にはlocalStorageを使用する

localStorageはオリジンスコープで、文字列のキーと値を保存し、ブラウザセッションをまたいで永続化し、同期APIを公開します。テーマ、言語、テーブルの表示密度などの小さな設定が適しています。これらは永続的ではありません。サイトデータの消去、プライベートブラウジングセッションの終了、またはブラウザのポリシーによって削除される可能性があります。

サーバーはlocalStorageを直接読み取ることはできません。初回のSSRフレームで正しい言語やテーマを使用する必要がある場合は、検証済みの設定をサーバーが読み取り可能なCookieまたはユーザープロファイルに同期させ、どちらのコピーを優先するかを定義します。値がクライアント専用である場合は、安定したデフォルト値を出力し、マウント後に切り替えることで、サーバーのHTMLとクライアントの初回レンダリングで不一致(ハイドレーションエラー等)が起きないようにします。

大規模なレコード配列をlocalStorageに保存し、JSON値全体を繰り返しパースすることは避けてください。同期的なシリアライズとメインスレッドでのアクセスはデータセットに応じて負荷が増大し、IndexedDBのようなトランザクションやインデックスも備えていません。

ステップ4: sessionStorageには使い捨てのタブ状態のみを配置する

sessionStorageはオリジンおよび最上位ブラウジングコンテキストごとに分離されています。同じタブ内でのリロード後も維持され、タブまたはウィンドウが閉じられるとクリアされます。そのため、現在のタブのステップ番号や使い捨ての下書きには適していますが、タブをまたぐカート、長期的な復元、またはデバイスをまたぐ状態管理には適していません。

オープナー(opener)の境界は見落とされがちです。新しく開かれたページは、最初にオープナーのsessionStorageのコピーを受け取ることがあります。その後、2つのコピーは独立して変更されます。下書きが絶対にコピーされてはならない場合は、オープナー関係を削除するか、下書きにタブごとのインスタンスIDを含めて復元時に検証します。

同一オリジンのスクリプトはsessionStorageの読み書きも可能です。「閉じるとクリアされる」からといって、長期間有効な認証情報を保存してよい理由にはなりません。下書きを永続化する前に、パスワード、決済データ、医療情報など、ディスクに書き込むべきではないフィールドを除外してください。

ステップ5: IndexedDBで再構築可能なオフラインレプリカを構築する

IndexedDBは、非同期リクエスト、トランザクション、オブジェクトストア、キー、インデックス、およびBlobストレージを提供します。これは、このシナリオの構造化レコードと画像に適しています。データをtenantId + userIdごとにパーティション分割し、タスク、添付ファイル、送信トレイ(outbox)用のオブジェクトストアを作成し、プロジェクト、更新時刻、同期状態など、実際のクエリに必要なインデックスのみを追加します。

明示的なデータベースバージョンと段階的なマイグレーションを使用します。新しいバージョンを開く処理は、古いタブからの接続によってブロックされる可能性があります。それらの古いタブはversionchangeをリッスンし、古い接続を閉じて、ユーザーにリフレッシュを促す必要があります。新しいページは、起動中に無期限にハングするのを防ぐため、blockedを適切に処理する必要があります。

IndexedDBはローカルデータベースであり、同期サービスではありません。書き込みが成功したということは、ローカルトランザクションがコミットされたことを意味します。アプリケーション側でローカルおよびサーバーのバージョン、操作ID、同期状態を記録し、再試行がべき等になるように設計する必要があります。

ステップ6: オフライン同期とタブ間の競合を明示的に設計する

オフラインでのビジネス上の変更と、それに対応する送信トレイのエントリを、1つのIndexedDBトランザクションでコミットします。再接続後、ワーカーが操作IDに基づいてアップロードを行います。サーバーはべき等性キーを使用して重複を排除し、レコードのバージョンを比較します。承認(ACK)を受け取った後にのみ、別のトランザクションでサーバーバージョンを更新し、送信トレイのアイテムを削除します。これにより、保留中の操作を失いながらレコードだけが変更されてしまう状態を防ぎます。

競合ポリシーはビジネス要件によって決まります。リスクの低い設定であれば最終書き込み優先(Last-Writer-Wins)を使用できます。タスクのステータスであればバージョンの不一致を拒否してユーザーにマージを求めることができます。財務や権限の変更であればオフラインでのコミットを禁止することもできます。storageイベントやBroadcastChannelを使用して他のタブにデータの再読み込みを通知できますが、通知はロック(排他制御)ではなく、IndexedDBのトランザクションやサーバーのバージョンチェックの代わりにはなりません。なお、storageイベントは、書き込みを実行したドキュメント自体では発生しません。

ステップ7: クォータ、退避、クリーンアップを通常の障害として処理する

ブラウザのクォータは、ブラウザ、デバイス、およびモードによって異なります。IndexedDBは一般的にベストエフォート型のストレージを使用するため、ユーザーによる手動クリアや、ストレージ容量の逼迫時にブラウザによって退避(自動削除)される可能性があります。また、クォータ不足により書き込みが失敗することもあります。ストレージの見積もりAPIを使用して使用状況を監視し、代替不能なデータに対しては慎重に永続化(persistent storage)を要求し、トランザクションやクォータの失敗を常に処理してください。

ポリシーには、添付ファイルのサイズ制限、LRU(最も使われていないものを優先)によるクリーンアップ、サーバーで確認済みの古いバージョンの圧縮または削除、および回復可能な「ローカル容量不足」状態のハンドリングを含める必要があります。ログアウト時には、まずサーバーセッションを失効させ、その後データベース接続を閉じ、該当するユーザーおよびテナントのIndexedDBデータ、設定、および下書きを削除します。他のタブにも通知を行います。ローカルでの削除はサーバーでの失効の代わりにはなりません。

ステップ8: 障害マトリクスで境界を検証する

初回のSSRレンダリングとハイドレーション、タブのリロード・複製・新規オープン・クローズ、別タブからのログアウト、XSS環境下でスクリプトが読み取り可能なスコープ、クロスサイトでの状態変更リクエスト、プライベートブラウジング、サイトデータの消去、クォータ枯渇、古いタブによるDBアップグレードのブロック、2つのタブでの同時編集、オフライン再試行・重複および順不同のレスポンス・競合、古いデータを露出させないテナント切り替えなどをテストします。

合格基準は「データが残っている」こと以上の意味を持ちます。認証が失効可能であること、機密性の高い値がJavaScriptに露出していないこと、下書きがタブの境界を守っていること、オフライン書き込みで送信トレイのエントリが失われないこと、重複同期によってビジネス上の処理が二重実行されないこと、アップグレードから回復できること、クォータエラーに対するフォールバックがあること、そしてサーバーからローカルレプリカを再構築できることが求められます。

高品質な回答例

「私は容量ではなく、読み取り主体、有効期間、データモデル、信頼境界、および信頼できる唯一の情報源(Source of Truth)から検討を始めます。サーバーとすべてのタブがログインセッションを必要とするため、実際のセッションはサーバー上に保持し、ブラウザはSecureHttpOnly、適切なSameSite値を設定した__Host- Cookieを保持します。これによりJavaScriptからのトークンアクセスを抑止できますが、XSSによるなりすまし操作のリスクは残るため、Cookieの自動送信特性に対応してCSRFトークン、オリジン検証、および機密アクションに対する再認証を併用します。

テーマと言語は機密性のない小さな設定であるため、localStorageを使用します。初回のSSRフレームでこれらが必要な場合は、検証済みのサーバー読み取り可能な設定と同期させ、どちらが正となるコピーかを定義します。現在のタブの使い捨てフォーム下書きにはsessionStorageを使用します。リロードで復元され、タブを閉じると削除されます。また、新規ページが最初にオープナーの値をコピーする挙動も考慮します。

10,000件のタスクと画像にはIndexedDBを使用します。データベースはユーザーとテナントごとにパーティション分割され、バージョン管理されたスキーマ、インデックス、トランザクションを使用します。ビジネス上の編集と送信トレイのエントリは常に同一トランザクションでコミットします。再接続時、クライアントはべき等な操作IDとともにアップロードし、サーバーはレコードのバージョンを比較した上で確認応答または競合エラーを返します。タブ間通知はデータの再読み込みをトリガーするだけに留め、正確性はローカルトランザクションとサーバーのバージョン管理によって担保します。

すべてのローカルストレージを、クリア可能で、クォータ制限で失敗する可能性があり、同一オリジンのスクリプトからアクセス可能なものとして扱います。クォータエラーを適切に処理し、再構築可能な添付ファイルをクリーンアップし、アップグレード時には古い接続を閉じ、ログアウト時にはサーバーセッションを失効させてからユーザーのローカル名前空間をクリアします。私のテストマトリクスは、SSRとハイドレーション、タブのコピー挙動、XSS、CSRF、クォータ、退避、アップグレードのブロック、同時編集、オフライン再試行、およびテナント切り替えを網羅します。」

よくある間違い

  • 容量一覧表から機械的に選択する → サーバーの読み取り主体、タブ境界、権威の所在を見落とす → まずは5次元のマトリクスを使用してください。
  • セッション識別子をlocalStorageに保存する → 同一オリジンのXSSによって読み取られ外部送信される可能性がある → サーバーセッションに裏打ちされた保護されたCookieを使用し、XSS対策を継続してください。
  • HttpOnlyでXSSを完全に防げると過信する → 悪意のあるスクリプトは認証済みのアクションを実行できる → トークン窃取の防御と不正アクションの防御を区別してください。
  • CSRF対策としてSameSiteのみに依存する → ブラウザのポリシー、リクエストタイプ、ビジネスフローには依然として境界が存在する → トークン、オリジン検証、再認証を組み合わせてください。
  • サーバーがlocalStorageに依存する設計にする → SSRはブラウザストレージを読み取れない → サーバーが読み取り可能な初期値を提供するか、マウント後の変更を許容してください。
  • 新しいタブのsessionStorageが常に空であると思い込む → オープナーが初期コピーを提供する可能性がある → オープナー関係を削除するか、タブインスタンスIDを追加してください。
  • 大きな配列をlocalStorageに保存する → 同期的なパース、値全体の再書き込みが必要になり、インデックス付きトランザクションも持たない → IndexedDBを使用してください。
  • IndexedDBが自動的に同期されると思い込む → ローカルコミットとサーバーの確認応答は異なるイベントである → 送信トレイ、べき等性、バージョン管理、競合ポリシーを実装してください。
  • タブ間通知を分散ロックとして扱う → メッセージは遅延する可能性があり、サーバーの競合を判定することはできない → 通知後は再読み込みを行い、正確性の担保にはトランザクションとバージョンを使用してください。
  • ローカルデータが永続的であると思い込む → クリーンアップ、退避、プライベートモード、クォータ超過によって削除される可能性がある → 再構築可能にし、書き込みエラーを処理してください。
  • ログアウト時にCookieのみをクリアする → IndexedDBや設定が次のアカウントに漏洩する可能性がある → サーバー側で失効させ、その後すべてのユーザーおよびテナントのローカルレプリカをクリーンアップしてください。

フォローアップの質問と回答

フォローアップ1: トークンを暗号化してlocalStorageに保存できますか?

ページのJavaScriptがキーを取得できるか復号パスを呼び出すことができる場合、正常に実行された同一オリジンの悪意あるスクリプトも通常は同様の操作が可能です。このシナリオにおけるXSSからのセッション窃取への対策にはなりません。より強固な境界は、HttpOnly Cookieを使用したサーバーセッションに加え、XSS防止、CSRF保護、ローテーション、および失効処理を組み合わせることです。クライアント側の暗号化が脅威に対応できるのは、その暗号キーが同じ攻撃対象領域の外にある場合に限られます。

フォローアップ2: 下書きがタブを閉じても保持される必要がある場合はどうしますか?

有効期間の要件が変わるため、sessionStorageは適さなくなります。ローカルでの復元のみが必要な機密性のない下書きにはIndexedDBを使用できます。デバイスをまたぐ必要がある下書きや、消失が許されない下書きはサーバーに同期する必要があります。どちらのオプションでも、保持期間、ユーザーとテナントの分離、および機密フィールドを永続化から除外するルールが必要になります。

フォローアップ3: 2つのタブが同じオフラインタスクを編集した場合はどうなりますか?

各レコードにサーバーバージョンとローカルリビジョンを保存し、書き込みごとにIndexedDBトランザクションを使用します。BroadcastChannelやデータ変更通知で他のタブに再読み込みを促しますが、サーバーは依然としてバージョンに対する条件付き更新を実行します。競合が発生した場合、ビジネス要件に基づいてフィールドのマージ、ユーザーへの確認、または拒否を選択します。最後にメッセージを受信したタブが信頼できる情報源になるわけではありません。

フォローアップ4: 古いタブがIndexedDBのアップグレードをブロックした場合はどうなりますか?

古い接続はversionchangeをリッスンし、接続を閉じて、ユーザーにリフレッシュを促します。新しいページは、無期限に待機するのではなく、回復可能な状態を伴ってblockedを処理します。マイグレーションは段階的かつべき等であり、部分的な過去データを許容するように設計します。リリース前に、古いバージョンのタブを接続したまま新しいバージョンを開き、接続の切断、メッセージング、およびマイグレーションが正しく動作することを検証します。

フォローアップ5: ブラウザのストレージがいっぱいになった場合、アプリはどのようにグレースフルデグラデーション(機能縮退)すべきですか?

クォータエラーとトランザクションエラーを処理し、まず新しい添付ファイルのキャッシュを停止し、サーバー確認済みの古い添付ファイルや再構築可能なバージョンを削除します。未同期の送信トレイは、ダウンロード可能なキャッシュよりも高い優先度を持ちます。それでも容量が不足している場合は、ユーザーに再接続して同期するか容量を空けるよう促します。サイレントに保存が成功したかのように見せることは絶対に避けてください。

フォローアップ6: 正しいログアウトの手順はどうあるべきですか?

まずサーバーに現在のセッションの失効を要求し、コピーされたCookieやまだ開いているタブもアクセス権を失うようにします。次に他のタブに通知し、同期処理を停止し、IndexedDB接続を閉じ、該当ユーザーおよびテナントのデータベース、下書き、設定を削除し、最後にログアウト状態のUIに遷移します。ネットワークリクエストが失敗した場合は、ローカルだけの偽のログアウトを完了として見せるのではなく、以降の操作を制限し、失効処理が保留中であることを明示します。

公開情報ソース

関連する質問