LESSON 40分

ストーリー

高橋アーキテクト
サーバーを3台に増やしたのに、ユーザーがログアウトされてしまいます

高橋アーキテクトが図を描いた。

高橋アーキテクト
セッション情報がサーバーのメモリに保存されているからだ。1回目のリクエストでServer Aにログインしても、2回目がServer Bに振り分けられたらセッションがない。これがステートフルの罠だ

ステートフルの問題

// ステートフルなサーバー(水平スケーリングに不向き)
class StatefulServer {
  private sessions = new Map<string, UserSession>(); // メモリに保存

  login(userId: string): string {
    const sessionId = generateSessionId();
    this.sessions.set(sessionId, { userId, createdAt: new Date() });
    return sessionId;
  }

  getUser(sessionId: string): UserSession | undefined {
    return this.sessions.get(sessionId); // このサーバーにしかない
  }
}

// 問題: Server Aでログイン → Server Bにリクエスト → セッションがない

ステートレスの原則

各リクエストが独立して処理でき、サーバーが状態を保持しない設計です。

// ステートレスなサーバー
class StatelessServer {
  constructor(
    private sessionStore: ExternalSessionStore, // Redis等の外部ストア
    private jwtVerifier: JWTVerifier,
  ) {}

  // 方法1: 外部セッションストア(Redis)
  async getUserBySession(sessionId: string): Promise<UserSession | null> {
    return this.sessionStore.get(sessionId); // どのサーバーからでもアクセス可能
  }

  // 方法2: JWT(トークンに情報を含める)
  getUserByToken(token: string): UserPayload {
    return this.jwtVerifier.verify(token); // サーバーに状態を持たない
  }
}

セッション管理の外部化

方法1: 外部セッションストア(Redis)

// Redis をセッションストアとして使用
class RedisSessionStore {
  constructor(private redis: RedisClient) {}

  async create(userId: string): Promise<string> {
    const sessionId = crypto.randomUUID();
    const session: Session = {
      userId,
      createdAt: new Date().toISOString(),
      data: {},
    };

    await this.redis.setex(
      `session:${sessionId}`,
      1800, // 30分
      JSON.stringify(session)
    );

    return sessionId;
  }

  async get(sessionId: string): Promise<Session | null> {
    const data = await this.redis.get(`session:${sessionId}`);
    return data ? JSON.parse(data) : null;
  }

  async destroy(sessionId: string): Promise<void> {
    await this.redis.del(`session:${sessionId}`);
  }
}

方法2: JWT(JSON Web Token)

// JWT を使ったステートレス認証
class JWTAuthService {
  constructor(private secret: string) {}

  generateToken(user: User): string {
    const payload = {
      userId: user.id,
      email: user.email,
      role: user.role,
      iat: Math.floor(Date.now() / 1000),
      exp: Math.floor(Date.now() / 1000) + 3600, // 1時間
    };

    return jwt.sign(payload, this.secret);
  }

  verifyToken(token: string): UserPayload {
    return jwt.verify(token, this.secret) as UserPayload;
  }
}

// JWTの特徴
// - サーバーに状態を保持しない(トークン自体に情報が含まれる)
// - DBやRedisへの問い合わせが不要
// - トークンの無効化が難しい(ブラックリスト管理が必要)

比較

観点Redis SessionJWT
状態の保存場所Redis(サーバーサイド)トークン(クライアントサイド)
スケーラビリティRedis依存完全ステートレス
セッション無効化即座に可能困難(ブラックリスト必要)
データ量大量のデータも可トークンサイズに制限
セキュリティデータがサーバーに保持ペイロードは読み取り可能

ファイルストレージの外部化

// ステートフル: ローカルファイルシステムに保存
class LocalFileStorage {
  async save(file: Buffer, filename: string): Promise<string> {
    const path = `/uploads/${filename}`;
    await fs.writeFile(path, file);
    return path; // このサーバーにしかない
  }
}

// ステートレス: オブジェクトストレージ(S3等)に保存
class S3FileStorage {
  constructor(private s3: S3Client) {}

  async save(file: Buffer, filename: string): Promise<string> {
    const key = `uploads/${Date.now()}-${filename}`;
    await this.s3.putObject({
      Bucket: 'my-app-uploads',
      Key: key,
      Body: file,
    });
    return `https://my-app-uploads.s3.amazonaws.com/${key}`;
    // どのサーバーからでもアクセス可能
  }
}

ステートレス設計のチェックリスト

const statelessChecklist = {
  // セッション: メモリではなく外部ストアに保存
  sessions: 'Redis or JWT',

  // ファイル: ローカルではなくオブジェクトストレージ
  files: 'S3 or Cloud Storage',

  // キャッシュ: ローカルメモリではなく分散キャッシュ
  cache: 'Redis or Memcached',

  // 設定: ファイルではなく環境変数 or 設定サービス
  config: 'Environment variables or Config service',

  // スケジュールタスク: 各サーバーではなく専用ワーカー
  scheduledTasks: 'Dedicated worker or cron service',
};

まとめ

ポイント内容
ステートフルの問題サーバー固有の状態が水平スケーリングを阻む
ステートレスの原則サーバーは状態を持たない。外部ストアを使用
セッションRedis(外部ストア)or JWT(クライアント保持)
ファイルS3等のオブジェクトストレージ
設計指針全ての状態をサーバー外部に出す

チェックリスト

  • ステートフルの問題点を説明できる
  • セッション管理の外部化方法を2つ知った
  • JWTとRedis Sessionの使い分けを理解した
  • ファイルストレージの外部化の必要性を把握した

次のステップへ

次は「データベーススケーリング」を学びます。アプリケーション層のスケーリングができても、データベースがボトルネックになるケースを解決しましょう。


推定読了時間: 40分