1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発アプリのレスポンス速度が遅いと言われて、計測・改善した話:実際に効いた5つの対策

1
Posted at

個人開発アプリのレスポンス速度が遅いと言われて、計測・改善した話:実際に効いた5つの対策

はじめに

個人で宅建試験のAI対策アプリ「takkenai.jp」を開発・運営しています。1250問以上の問題を収録し、AIによる解説生成を組み合わせたNext.js製のWebアプリです。

ある日、ユーザーからこんんフィードバックが届きました。

「問題ページの表示が遅くて、テンポよく解けない」

正直なところ、開発中は自分のマシンと高速回線で触っているので気づきませんでした。しかし計測してみると、想像以上にひどい数字が出てきました。

この記事では、実際にフィードバックを受けてから計測し、改善するまでの流れと、本当に効果があった5つの対策を具体的な数字とコード付きで紹介します。

まず計測した:現実を直視する

改善の前にまず現状把握です。使ったツールは以下の3つです。

  • Lighthouse(Chrome DevTools)
  • Web Vitals Extension(実機でのCore Web Vitals計測)
  • Vercel Analytics(実ユーザーデータ)

改善前の数字(問題一覧ページ)

指標 改善前 目安(Good)
TTFB 1,800ms < 800ms
LCP 4,200ms < 2,500ms
FCP 2,900ms < 1,800ms
CLS 0.18 < 0.1

TTFBが1.8秒。ページが表示され始めるまでに2秒近くかかっていては、ユーザーが「遅い」と感じるのは当然です。

ここから、ボトルネックを一つずつ潰していきました。

対策1:Next.js Imageコンポーネントの最適化

問題

問題一覧ページでカテゴリアイコンやOG画像のサムネイルを表示していましたが、元画像をそのまま配信していました。1枚200KB〜500KBのPNGが10枚以上並ぶ状態です。

対策

// ❌ Before:サイズ指定なし、最適化なし
<img src={category.image} alt={category.name} />

// ✅ After:Next.js Imageで適切なサイズ・フォーマット指定
import Image from 'next/image'

<Image
  src={category.image}
  alt={category.name}
  width={280}
  height={160}
  sizes="(max-width: 768px) 50vw, 280px"
  placeholder="blur"
  blurDataURL={category.blurDataUrl}
  quality={75}
/>

next/imageを使うことで、WebP/AVIF自動変換、リサイズ、遅延読み込みが一括で適用されます。placeholder="blur"によりCLSも改善しました。

効果

  • 画像の合計転送量:約4.2MB → 約380KB(約91%削減)
  • CLS:0.18 → 0.04

対策2:Suspense境界の適切な配置

問題

問題詳細ページでは、問題文の表示・AI解説の取得・関連問題の取得をすべて直列で待っていました。AI解説の生成に時間がかかると、ページ全体が真っ白のままになります。

対策

// app/questions/[id]/page.tsx
import { Suspense } from 'react'

export default async function QuestionPage({ params }: { params: { id: string } }) {
  // 問題データは即座に取得(DBから高速に返る)
  const question = await getQuestion(params.id)

  return (
    <main>
      {/* 問題文は即座にレンダリング */}
      <QuestionBody question={question} />

      {/* AI解説は非同期で遅延表示 */}
      <Suspense fallback={<ExplanationSkeleton />}>
        <AIExplanation questionId={params.id} />
      </Suspense>

      {/* 関連問題も非同期で遅延表示 */}
      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedQuestions categoryId={question.categoryId} />
      </Suspense>
    </main>
  )
}

ユーザーにとって最も重要な「問題文」を最速で表示し、付随情報はストリーミングで後から届けます。体感速度が劇的に変わりました。

効果

  • LCP(問題文の表示):4,200ms → 1,800ms
  • ユーザーが「操作可能」と感じるまでの時間が大幅短縮

対策3:DBクエリのインデックス追加

問題

Vercel Analyticsで確認すると、TTFBのボトルネックはDBクエリでした。1250問以上ある問題テーブルに対して、カテゴリ別絞り込み+ソートのクエリにインデックスが効いていませんでした。

対策

-- Before:フルテーブルスキャンが発生していた
-- EXPLAIN ANALYZEで確認 → Seq Scan, 実行時間 320ms

-- After:複合インデックスを追加
CREATE INDEX idx_questions_category_order
  ON questions (category_id, sort_order);

CREATE INDEX idx_user_answers_user_question
  ON user_answers (user_id, question_id);

-- EXPLAIN ANALYZEで確認 → Index Scan, 実行時間 8ms

Prismaを使っている場合は、スキーマに@@indexを追加します。

model Question {
  id         String   @id @default(cuid())
  categoryId String
  sortOrder  Int
  body       String
  // ...

  @@index([categoryId, sortOrder])
}

効果

  • 問題一覧のDBクエリ:320ms → 8ms(約97%短縮)
  • TTFB全体への寄与:約300ms改善

対策4:Edge Runtimeへの移行

問題

Vercelのデフォルトではサーバーレス関数がus-east-1(バージニア)で動きます。日本のユーザーがアクセスすると、物理的な距離によるレイテンシが加算されます。

対策

// app/questions/[id]/page.tsx
export const runtime = 'edge'

データベース側もEdgeに対応させる必要があります。自分の場合はPrisma Accelerateを導入し、Edge Runtimeからの接続を可能にしました。

// lib/prisma.ts
import { PrismaClient } from '@prisma/client/edge'
import { withAccelerate } from '@prisma/extension-accelerate'

export const prisma = new PrismaClient().$extends(withAccelerate())
// Edge対応のクエリ(キャッシュ付き)
const questions = await prisma.question.findMany({
  where: { categoryId },
  orderBy: { sortOrder: 'asc' },
  cacheStrategy: { ttl: 300 }, // 5分キャッシュ
})

効果

  • TTFB:1,500ms → 650ms(Edge化+DB最適化の複合効果)
  • 東京リージョンのEdgeノードから配信されるため、物理的レイテンシが大幅減

対策5:キャッシュ戦略の見直し

問題

問題データは頻繁に更新されるものではないのに、毎回DBにフェッチしていました。1250問のデータは基本的に静的です。

対策

Next.js App Routerのrevalidateとfetchキャッシュを組み合わせました。

// 問題一覧:5分間キャッシュ(ISR的な挙動)
export const revalidate = 300

// 個別の問題ページ:静的生成 + オンデマンド再検証
export async function generateStaticParams() {
  const questions = await prisma.question.findMany({
    select: { id: true },
  })
  return questions.map((q) => ({ id: q.id }))
}

さらに、AI解説のような動的データにはRoute Handlerレベルでキャッシュヘッダーを設定しました。

// app/api/explanation/[id]/route.ts
export async function GET(request: Request, { params }: { params: { id: string } }) {
  const explanation = await generateExplanation(params.id)

  return Response.json(explanation, {
    headers: {
      'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
    },
  })
}

一度生成されたAI解説は1時間キャッシュされ、裏側で再検証されます。

効果

  • 2回目以降のアクセスでTTFB:650ms → 120ms
  • Vercelのエッジキャッシュからの配信(HIT率約85%)

改善結果まとめ

すべての対策を適用した後の数字です。

指標 改善前 改善後 変化
TTFB 1,800ms 120ms(キャッシュHIT時) -93%
LCP 4,200ms 1,400ms -67%
FCP 2,900ms 800ms -72%
CLS 0.18 0.04 -78%

Lighthouseのパフォーマンススコアは47 → 94になりました。

学んだこと

  1. 「自分の環境では速い」は信じるな。 必ず計測ツールで数字を見る
  2. ボトルネックは1つではない。 ネットワーク・DB・レンダリング、複合的に遅い
  3. 体感速度と実測値は別物。 Suspenseでの段階表示は数字以上に体感を改善する
  4. 個人開発でもインデックスを舐めるな。 1000件程度でも複合インデックスの有無で40倍違う
  5. キャッシュは最強の高速化。 変更頻度の低いデータはキャッシュ前提で設計する

おわりに

パフォーマンス改善はやればやるほど効果が見えるので、個人開発のモチベーション維持にも良いです。ユーザーからの「速くなった」という声が一番嬉しいフィードバックでした。

今回改善したアプリは takkenai.jp で実際に使えます。宅建試験の対策をしている方は、1250問以上の問題をAI解説付きで解けるので、ぜひ試してみてください。速度改善の成果も体感していただけるはずです。

1
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?