1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SSRと axios.defaults まわりの挙動メモ

1
Posted at

概要

  • SSR 中に axios.defaults.headers を書き換え、同時に処理されている別リクエストと状態が共有される実装を目にした。発生原因や関係要素を備忘録のためメモする。

前提: SSR

  • SSR は Server-Side Rendering の略で、HTML の生成をブラウザではなくサーバー側で行う方式。Next.js の getServerSideProps は、その実行場所のひとつにあたる。

  • ブラウザからリクエストが来るたびに、Node.js サーバーがデータ取得と HTML 生成を行い、その結果を返す。複数ユーザーから同時にアクセスがあれば、同じ Node.js プロセスが複数のリクエストを並行してさばく。

前提: Node.jsのシングルスレッドモデル

  • Node.jsのJavaScript 実行スレッドは基本的に1本。

  • await でI/O を待っている間、そのリクエストの処理は中断される。その間にイベントループが別のコールバックや別リクエストの処理を進める。JavaScript は同時に2本走らなくても、処理の切り替えは発生する。

  • 処理の切り替えの前後で共有状態を読むコードがあると、axios.defaults はプロセス全体で共有されるため、SSR 中にここへ Cookie などのユーザー固有情報を書くと、別リクエストからも見えてしまうことがある。

前提: getServerSideProps

  • getServerSideProps は、リクエスト時点でしか確定しない情報を使ってページを描画したい場合に使用する。例として次のようなものがある。

    • リクエストヘッダや Cookie に応じて表示内容を変える
    • ログイン済みユーザー向けのページを描画する
    • リクエスト時点の最新データをサーバー側で取得する
  • ビルド時に決められるデータや、キャッシュ前提でよいデータには向かない。そうした用途では getStaticProps やクライアント側取得の方が適する。

getServerSideProps のアンチパターン

  • ユーザー固有情報をグローバル変数や module scope に退避する
  • 機密情報をそのまま props に入れてクライアントへ渡す
  • 毎リクエストで不要な重い処理を行う

前提: axios.defaults

  • axios.defaults は、毎回同じ設定を繰り返さないためのデフォルト設定置き場。例として次のようなものがある。

    • API の baseURL
    • 全リクエスト共通のタイムアウト
    • アプリ全体で共通のヘッダ
  • axios.defaults はアプリケーション全体で共有してよい設定に向いている。ユーザーごと、リクエストごとに変わる値を入れる場所には向かない。

axios.defaults のアンチパターン

  • Cookie や Authorization ヘッダのようなユーザー固有値をグローバル defaults に入れる
  • リクエストごとに axios.defaults を上書きする
  • 複数の API ドメインに対して同じグローバル認証ヘッダを使い回す
  • SSR とクライアントで同じ共有インスタンスに動的値を混在させる

シーケンス

  • await などの非同期境界をまたいだあとで共有状態を参照すると、その値が別リクエストによって上書きされていることがある。

※ 図は一般化した例で、直後の Promise.all(...) のコードそのものの逐次実行順を表したものではない。

該当するパターン

Next.js の getServerSideProps を例にすると、次のような形になる。

axios.defaults を SSR 内で書き換える

...
// lib/api.ts
import axios from "axios";

// グローバルなaxiosインスタンスのデフォルト設定を使っている
export const fetchUser = () => axios.get("/api/user");
export const fetchOrders = () => axios.get("/api/orders");
export const fetchNotifications = () => axios.get("/api/notifications");
...
// pages/dashboard.tsx
import type { GetServerSideProps } from "next";
import axios from "axios";
import { fetchUser, fetchOrders, fetchNotifications } from "@/lib/api";

export const getServerSideProps: GetServerSideProps = async (ctx) => {
  // グローバル状態の書き換え
  axios.defaults.headers.common.Cookie = ctx.req.headers.cookie ?? "";

  // 共有された defaults を使って API を呼ぶ
  const [user, orders, notifications] = await Promise.all([
    fetchUser(),
    fetchOrders(),
    fetchNotifications(),
  ]);

  return {
    props: { user: user.data, orders: orders.data, notifications: notifications.data },
  };
};
  • ローカルで動く場合もあるが、本番で複数ユーザーが同時アクセスすると、axios.defaults.headers.common.Cookie が別リクエストに上書きされ、意図しない Cookie で API を呼ぶ可能性がある。

  • この例では Promise.all([...]) に渡した fetchUser() などは await に入る前に評価されるため、この関数呼び出し単体で順番が崩れるわけではない。別リクエストの getServerSideProps も同じランタイム上で並行して走り、共有された defaults を書き換える点を見ている。

再現しにくい理由

  • 共有状態の読み書きが短時間で終わることも多く、タイミング依存の不具合として表に出にくい
  • 手動で2つのブラウザから同時アクセスしても、ミリ秒単位のタイミングを合わせるのは困難
  • 共有状態を参照する箇所が増えたり、その前後に非同期処理が入るほど露見しやすくなる

修正したパターン

axios.create() でリクエストスコープのインスタンスを作る

...
// lib/api.ts
import axios, { type AxiosInstance } from "axios";

// リクエストごとにインスタンスを生成する関数
export function createApiClient(cookie?: string): AxiosInstance {
  return axios.create({
    baseURL: process.env.API_BASE_URL,
    headers: {
      Cookie: cookie ?? "",
    },
  });
}
// pages/dashboard.tsx
import type { GetServerSideProps } from "next";
import { createApiClient } from "@/lib/api";

export const getServerSideProps: GetServerSideProps = async (ctx) => {
  // リクエストごとに独立したインスタンスを生成
  const api = createApiClient(ctx.req.headers.cookie);

  // このインスタンスは他のリクエストと共有されない
  const [user, orders, notifications] = await Promise.all([
    api.get("/api/user"),
    api.get("/api/orders"),
    api.get("/api/notifications"),
  ]);

  return {
    props: { user: user.data, orders: orders.data, notifications: notifications.data },
  };
};
項目 axios.defaults を書き換える場合 axios.create() を使う場合
インスタンス axios(グローバル) axios.create()(リクエストスコープ)
Cookie設定 axios.defaults.headers.common.Cookie = ... コンストラクタで注入
スコープ プロセス全体で共有 リクエストごとに独立
影響範囲 同一ランタイム内の他リクエストにも影響しうる 当該リクエスト内に閉じる

条件の整理

  • SSRを使っている(Next.js の getServerSidePropsなど)
  • 実行環境がリクエストをまたいで再利用される(Node.js サーバーなど)
  • axios.defaults やグローバル変数を SSR 内で書き換えている
  • 複数の await や非同期処理の前後で共有状態を参照している
  • Cookie やトークンなどのユーザー固有情報をグローバル状態経由で渡している

これらに当てはまる場合、構造上リクエスト間で状態が混ざる余地がある。

Axios以外も同様

この事象は Axios に限らず、Node.js の SSR 内でグローバル変数を書き換え、await を挟んで参照するパターンであれば同じ構造になり得る。

// 同じ構造になる例
global.currentUser = getUserFromRequest(ctx.req);
const data = await fetchSomething(); // ← await中に global.currentUser が上書きされうる

まとめ

観点 内容
原因 Node.jsのシングルスレッド + イベントループ + グローバルステート書き換え
起こること await 中に別リクエストがグローバルステートを上書きし、意図しない値が使われる
対策 axios.create() でリクエストスコープのインスタンスを使う
判断基準 発生確率 (その不具合がどの程度の頻度で起きるかという量的な見積もり) ではなく発生可能性 (その不具合が構造上起こりうるかどうか) で設計判断する

発生確率の見積もりは難しいが、共有状態にユーザー固有情報を入れている時点で、発生可能性は消えない。設計判断で後者を見る。

参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?