「本番環境向けセキュリティチェックリスト」では、本番環境にユーザーを迎え入れる前に何をロックダウンすべきかを解説しました。セキュリティは合格・不合格のゲートです。作業を終えていなければ、脆弱なままです。一方、パフォーマンスは異なります。ダッシュボードの読み込みに4秒かかっても「壊れている」わけではありません。ただ静かにユーザーを失うだけで、誰もその件でチケットを起票しないのです。

これは Full Stack SaaS Masterclass の一環で、「SaaSに最適な技術スタックの選び方」から始まり、空のフォルダから本番環境へ向かう一つのアプリを追った「実際に作る」シリーズです。パフォーマンスチューニングは、このモジュールを締めくくる準備チェックリストの直前の最後のステップです。

ここでの視点について最初に明言しておきます。ローンチ時のほとんどのSaaS製品には、本当に遅いパスがわずかにあり、残りの大部分はまだ重要ではありません。ローンチ前にやるべきことは、そのわずかな部分を見つけ、修正し、推測で残りの部分を最適化しようとする衝動を抑えることです。1ミリ秒単位まで絞り込む作業は後回しで構いません。

何かに手を付ける前に計測する

ローンチ前に見られる最大のパフォーマンス上の誤りは、データではなく勘で最適化することです。エンジニアが「ORMが遅い」「フロントエンドのバンドルが大きすぎる」「Redisで全部キャッシュすべきだ」と判断し、一週間を費やします。正しい場合もありますが、実際のボトルネックは誰もチェックしようとしなかったテーブルの欠落インデックスだった、というケースも少なくありません。

コードに手を付ける前に、主要なユーザー フローの3つの数値を取得してください。サーバーの応答時間、データベースクエリの実行時間、そしてフロントエンドにおける最初の意味のある描画までの時間です。ローンチ時に高機能なAPMは必要ありません。期間フィールドを含む構造化リクエストログと PostgreSQL の pg_stat_statements を組み合わせるだけで、ほぼすべての情報が得られます。

-- 1回有効化すれば、いつでもクエリして最も遅いクエリを特定できる
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT
  calls,
  round(mean_exec_time::numeric, 2) AS avg_ms,
  round(total_exec_time::numeric, 2) AS total_ms,
  query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Enter fullscreen mode Exit fullscreen mode

この1つのビューは、推測によるチューニングのどれよりも、ローンチ前のパフォーマンスに寄与します。クエリを総実行時間で順位付けするため、本当に遅いクエリと、1時間に1万回実行されるほど高速ではないクエリの両方が明らかになります。修正はその順序で行いましょう。

データベースはほぼ常に最初のボトルネック

Postgresをバックエンドに持つ典型的なSaaSの場合、応答時間が実際に消費される場所はデータベースです。その大半を占める2つのパターンは、N+1クエリと、外部キーとフィルター列に欠けているインデックスです。

N+1クエリは生のSQLよりもORMで潜り込みやすく、抽象化によってループが隠蔽されるためです。TypeORMやPrismaを使ったマルチテナントのNestJSアプリで頻出する形は次のとおりです。

// 悪い例: 所有者を取得するために組織ごとにクエリを実行
async function getOrganizationsWithOwners(orgIds: string[]) {
  const orgs = await this.orgRepository.findByIds(orgIds);

  return Promise.all(
    orgs.map(async (org) => {
      const owner = await this.userRepository.findOne({ where: { id: org.ownerId } });
      return { ...org, owner };
    }),
  );
}

Enter fullscreen mode Exit fullscreen mode

// 改善例: 結合またはリレーション読み込みを使った1回のクエリ
async function getOrganizationsWithOwners(orgIds: string[]) {
  return this.orgRepository.find({
    where: { id: In(orgIds) },
    relations: { owner: true },
  });
}

Enter fullscreen mode Exit fullscreen mode

修正はたいてい凝ったものではありません。通常は「すでに必要だとわかっているリレーションをループで取得するのではなく、一括で読み込む」だけです。これを特にローンチ前に確認する価値があるのは、N+1パターンが10行では見えず、1万行で痛みを伴うためです。ステージングデータはほぼ確実に十分な行数を持っていないため、最初の本物の顧客が既存データをインポートした直後にこの問題が表面化します。

インデックスは2番目のレバーであり、経験則は単純です。フィルタリングや結合を行う外部キーと、行数が数千行を超えるテーブルで WHERE 句でフィルタリングする列にはすべてインデックスを作成してください。マルチテナントスキーマの場合、ほぼすべてのクエリが tenant_id または organization_id でフィルタリングするため、これらを含めるのが通例です。

-- マルチテナントテーブルにおける最も一般的なクエリ形状のための複合インデックス
CREATE INDEX idx_invoices_org_created_at
  ON invoices (organization_id, created_at DESC);

Enter fullscreen mode Exit fullscreen mode

ローンチ前に pg_stat_statements の上位5クエリに対して EXPLAIN ANALYZE を実行してください。本番規模のデータ量を扱うテーブルでのシーケンシャルスキャンは、インデックスが必要であることを示す最も明確なシグナルです。

コネクションプーリングについてもここで触れておきます。これはスロークエリというより、ローンチ週の障害モードとして現れます。各Postgres接続はサーバー上で実際のメモリを消費し、制限なく各リクエストが独自の接続を開くと、負荷下のNestJSアプリはデフォルトプールをすぐに使い果たします。ORMのデフォルトに頼るのではなく、明示的で妥当なプールサイズ(TypeORMとPrismaはいずれも公開しています)を設定してください。バックエンドインスタンスが複数になったら、PgBouncerをPostgresの前に配置し、接続を共有してプロセスごとに増やさないようにしましょう。

習慣ではなく理由があってキャッシュを追加する

Redisが 技術スタック に含まれるのは、まさにこのような場面のためです。完璧な鮮度を必要としない、高コストで頻繁に読み取られるクエリ。これが探すべきプロファイルです。書き込みのたびに変わるものや、ほとんど誰も読まないものをキャッシュすると、実際の利得なく無効化の複雑さが増すだけです。

キャッシュアサイドパターンは、ローンチ前のキャッシュ要件の大部分をカバーし、プレッシャー下でも理解しやすいものです。

async function getDashboardSummary(orgId: string): Promise<DashboardSummary> {
  const cacheKey = `dashboard:summary:${orgId}`;
  const cached = await this.redis.get(cacheKey);

  if (cached) {
    return JSON.parse(cached) as DashboardSummary;
  }

  const summary = await this.computeDashboardSummary(orgId);

  await this.redis.set(cacheKey, JSON.stringify(summary), 'EX', 60);

  return summary;
}

Enter fullscreen mode Exit fullscreen mode

短いTTL(この例では60秒)は、正確性を重視した無効化戦略を要求することなく、ほとんどの効果をもたらします。基になるデータが変化し、ダッシュボードが1分間古くなったとしても、サマリービューとしては許容できるトレードオフです。アカウント残高には適さないでしょう。

直接指摘すべき落とし穴は、キャッシュは遅いクエリや欠落インデックスの修正の代わりにはならないということです。インデックスなしのスキャンを実行するクエリをキャッシュしたチームを見たことがあります。キャッシュヒット時には問題を隠し、キャッシュミス時やデプロイ後のコールドスタート時には完全に露呈します。まずクエリを修正し、その後、修正後もキャッシュする価値がある場合にキャッシュしてください。

フロントエンドのパフォーマンスは、主に実行速度ではなく、配信内容について

Next.js側では、ローンチ前の最大の改善点は、ブラウザに送信される内容を減らすことであり、レンダリングパフォーマンスの細かな最適化ではありません。3つのウィジェットがあるページに400KBのJavaScriptを配信するダッシュボードは、中程度のスペックラップトップでは遅く感じられ、モバイルでは明らかに悪い体験になります。Reactコンポーネントがどれだけうまく書かれていても同じです。

ローンチ前に一貫して重要となるチェック事項をいくつか挙げます。

  • デフォルトでServer Components。 App Routerを使用している場合、インタラクティブなクライアントコンポーネントはできるだけ小さく、ツリーの低い位置に配置してください。1つのボタンに onClick が必要だからといって、ページの先頭に 'use client' を置く必要はありません。
  • 重くて重要でないものは動的インポート。 チャートライブラリ、リッチテキストエディタ、PDFジェネレータはよくある例です。初期バンドルではなく、オンデマンドで読み込んでください。
  • ユーザー向けコンテンツには next/image を使用。 レスポンシブサイズ調整とレイジーロードをデフォルトで処理するため、マーケティングページとダッシュボードページの両方で体感的な読み込み時間を大幅に改善します。
  • データが許す限りStaticまたはISRを使用。 マーケティングページ、料金ページ、パブリックドキュメントは、ほぼすべてのリクエストでサーバーレンダリングする必要はありません。再検証を伴う静的生成により、クリティカルパスからリクエスト-レスポンスサイクル全体を排除できます。
// 重くて重要でないコンポーネントを事前バンドルではなく後回しにする
import dynamic from 'next/dynamic';

const BillingChart = dynamic(() => import('./billing-chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false,
});

Enter fullscreen mode Exit fullscreen mode

これらはいずれもパフォーマンス専門家を必要としません。実際のユーザーが最も頻繁にアクセスするページ(通常はログインフロー、メインのダッシュボード、コアバリュープロポジションを駆動するページ)で、ネットワークタブを実際に開く人が必要です。

実際のユーザーが行う前に負荷テストを実施する

ローンチ前は、実際の顧客に発見されることなく安全に限界点を見つけられる唯一のタイミングです。ステージング環境に対して、もっとも重要な2〜3個のエンドポイントを叩く基本的な負荷テストにより、システムがどこで落ちるか、そしてその上限が期待するローンチトラフィックを十分に超えているかを教えてくれます。

# autocannonを使ったステージングエンドポイントへの簡易負荷テスト
npx autocannon -c 50 -d 30 -m POST \
  -H "Content-Type: application/json" \
  -b '{"email":"[email protected]","password":"test1234"}' \
  https://staging.example.com/api/auth/login

Enter fullscreen mode Exit fullscreen mode

注目すべきは生のRPS値ではなく、結果の形状です。その値はインフラに完全に依存するため、ここでは具体的な数値を作りません。同時接続数が増えるにつれてレイテンシがほぼ一定に保たれるか、ある時点で急落するか。急落は、コネクションプール、レートリミッタ、またはイベントループのどこで余裕がなくなるかを正確に示します。その点を負荷テストで発見するのは、最初のトラフィックスパイクで発見するよりもはるかに安上がりです。

要点

  • 最適化する前に計測する。pg_stat_statements と基本的なリクエストタイミングは本物のボトルネックを見つけ、推測は通常誤ったものを見つける。
  • N+1クエリと欠落インデックスは、典型的なSaaSにおけるデータベースの遅延のほとんどを占め、いずれも実際のデータ量が露呈するまで見えない。
  • 明示的なコネクションプールサイズを設定し、バックエンドインスタンスが1つを超えたらPgBouncerをPostgresの前に配置する。
  • 高コストで頻繁に読み取られ、多少の古さを許容できるデータに対してRedisでキャッシュする。まず基になるクエリを修正し、その後キャッシュする。
  • フロントエンドでは、配信量の削減(動的インポート、Server Components、next/image、静的生成)が、レンダリングコードの細かな最適化よりも通常効果が高い。
  • ローンチ前にステージングに対して基本的な負荷テストを実行し、自分の条件で上限を見つける。

FAQ

ローンチ前にPostgresクエリが遅いかどうかをどう判断すればよいですか?
pg_stat_statements を有効にし、呼び出しごとの平均時間ではなく総実行時間でクエリしてください。適度に高速でも常時実行されるクエリは、まれにしか実行されない遅いクエリよりも全体のコストが高くなる可能性があります。上位の結果に対して EXPLAIN ANALYZE を実行し、インデックスが役立つ場面でシーケンシャルスキャンを実行しているかどうかを確認してください。

NestJSアプリでPostgresのコネクションプールサイズはどれくらいにすべきですか?
データベースの max_connections 設定や実行するアプリインスタンス数に依存するため、普遍的な数値はありません。控えめに始め、ORMのデフォルトに頼らず明示的にプールサイズを設定し、バックエンドインスタンスが複数になったらPgBouncerをトランザクションモードで追加して、プールされた接続を共有し、プロセスごとに増やさないようにしてください。

まだ必要ない場合でも、ローンチ前にRedisキャッシュを追加すべきですか?
いいえ。短い古さウィンドウを許容できる、高コストで頻繁に読み取られるクエリが特定できたら追加してください。それ以前にキャッシュを追加すると、測定された利得なく無効化の複雑さが増し、まだ修正が必要なクエリの問題を隠蔽する可能性があります。

ローンチ前に実際に必要なフロントエンドパフォーマンス作業はどれくらいですか?
ブラウザに配信される内容に焦点を当ててください。クライアントコンポーネントを小さく保ち、重くて重要でないウィジェットを動的にインポートし、next/image を使用し、リクエストごとのデータを必要としないページを静的に生成します。これは通常、コンポーネントのレンダリングパフォーマンスを細かく最適化するよりも、ローンチ前に重要です。

専任のパフォーマンスチームなしで、ローンチ前に負荷テストを行う合理的な方法は?
ステージング環境のトラフィック量上位2〜3個のエンドポイントに対して、autocannonやk6のようなツールを向けるだけで十分です。同時接続数が増えるにつれてレイテンシが徐々に上昇するのか、それとも崖から落ちるように急落するのか、その曲線の形状を確認してください。具体的な数値ではなく、曲線の形状を見ています。

ローンチ時に一部のページがまだ遅くても問題ですか?
必ずしも問題ではありません。ローンチ前の目標は、ログインやメインのダッシュボードなど、実際のユーザーが頻繁にアクセスするフローを修正することです。使用頻度の低い設定ページが1.5秒で読み込まれるのは、ローンチ週にそれを追いかける代わりに出荷することを優先する合理的なトレードオフです。

参考文献


著者について

こんにちは、Aman Singh です。スケーラブルなSaaS製品、分散システム、クラウドアーキテクチャ、AIを活用したアプリケーションを専門とするシニアフルスタックエンジニアです。

System Design、Full Stack Engineering、分散システム、Redis、PostgreSQL、AWS、Node.js、NestJSについて執筆しています。