Skip to content

アーキテクチャ設計の意思決定

アーキテクチャ設計の意思決定

Section titled “アーキテクチャ設計の意思決定”

シニアエンジニアとして、Next.jsアプリケーションの適切なアーキテクチャを選択するためには、トレードオフを理解し、コンテキストに応じた判断が必要です。この章では、Next.jsアプリケーションのアーキテクチャ設計における重要な意思決定ポイントについて深く解説します。

各戦略の本質的な違い:

// 1. SSR (Server-Side Rendering)
// リクエストごとにサーバーでHTMLを生成
export default async function Page() {
const data = await fetch('https://api.example.com/data', {
cache: 'no-store' // キャッシュしない
});
return <div>{data.title}</div>;
}
// 適用範囲:
// - ユーザーごとに異なるコンテンツ
// - リアルタイム性が重要
// - SEOが必要だが、頻繁に更新される
// トレードオフ:
// - メリット: 常に最新のデータ、SEOに強い
// - デメリット: サーバー負荷が高い、TTFBが遅い可能性
// 2. SSG (Static Site Generation)
// ビルド時にHTMLを生成
export default async function Page() {
const data = await fetch('https://api.example.com/data', {
next: { revalidate: false } // キャッシュを無効化しない
});
return <div>{data.title}</div>;
}
// 適用範囲:
// - コンテンツが静的な、または更新頻度が低い
// - 最高のパフォーマンスが必要
// - SEOが必要
// トレードオフ:
// - メリット: 最高のパフォーマンス、サーバー負荷なし
// - デメリット: 動的なコンテンツには不向き、ビルド時間が長くなる
// 3. ISR (Incremental Static Regeneration)
// ビルド時に生成し、定期的に再生成
export default async function Page() {
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // 1時間ごとに再生成
});
return <div>{data.title}</div>;
}
// 適用範囲:
// - 更新頻度が中程度
// - パフォーマンスと最新性のバランスが必要
// - 大量のページがある
// トレードオフ:
// - メリット: SSGのパフォーマンス + 定期的な更新
// - デメリット: 再生成までの間は古いデータが表示される可能性
// 4. CSR (Client-Side Rendering)
// クライアントでレンダリング
'use client';
export default function Page() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('https://api.example.com/data')
.then(res => res.json())
.then(setData);
}, []);
return <div>{data?.title}</div>;
}
// 適用範囲:
// - ユーザー固有のコンテンツ
// - リアルタイム性が最重要
// - SEOが不要
// トレードオフ:
// - メリット: サーバー負荷が低い、インタラクティブ
// - デメリット: 初期表示が遅い、SEOに弱い

意思決定フレームワーク:

観点SSRSSGISRCSR
更新頻度リアルタイム低い中程度リアルタイム
パフォーマンス最高低(初期)
SEO強い強い強い弱い
サーバー負荷高いなし低い低い
ユーザー固有可能困難困難可能

実践的な選択例:

// 例1: ブログ記事 → SSG
// 理由: 更新頻度が低く、SEOが重要、パフォーマンス優先
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map(post => ({ slug: post.slug }));
}
// 例2: ダッシュボード → SSR
// 理由: ユーザーごとに異なる、リアルタイム性が必要
export default async function Dashboard() {
const user = await getCurrentUser();
const data = await getUserData(user.id);
return <DashboardContent data={data} />;
}
// 例3: 商品一覧 → ISR
// 理由: 更新頻度が中程度、大量のページ、パフォーマンス重要
export default async function Products() {
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // 1時間ごとに更新
});
return <ProductList products={products} />;
}
// 例4: 管理画面 → CSR
// 理由: SEO不要、リアルタイム性が最重要、認証が必要
'use client';
export default function AdminPanel() {
// クライアントサイドでレンダリング
}

サーバーコンポーネント vs クライアントコンポーネント

Section titled “サーバーコンポーネント vs クライアントコンポーネント”

判断基準:

// サーバーコンポーネントを選ぶべき場合:
// 1. データフェッチが必要
// 2. 機密情報へのアクセス(APIキー、データベース)
// 3. 大きな依存関係をサーバー側に保持
// 4. SEOが重要
// app/products/page.tsx(サーバーコンポーネント)
export default async function ProductsPage() {
// サーバー側でデータを取得
const products = await getProducts();
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// クライアントコンポーネントを選ぶべき場合:
// 1. インタラクティブな機能(onClick、onChange)
// 2. ブラウザAPIの使用(localStorage、window)
// 3. 状態管理(useState、useEffect)
// 4. イベントリスナー
// components/AddToCartButton.tsx(クライアントコンポーネント)
'use client';
export default function AddToCartButton({ productId }: { productId: string }) {
const [isAdding, setIsAdding] = useState(false);
const handleClick = async () => {
setIsAdding(true);
await addToCart(productId);
setIsAdding(false);
};
return (
<button onClick={handleClick} disabled={isAdding}>
{isAdding ? '追加中...' : 'カートに追加'}
</button>
);
}

境界の設計パターン:

app/products/[id]/page.tsx
// パターン1: サーバーコンポーネントがデータを取得し、クライアントコンポーネントに渡す
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
// サーバーコンポーネントからクライアントコンポーネントにpropsで渡す
return (
<div>
<ProductDetails product={product} /> {/* サーバーコンポーネント */}
<AddToCartButton productId={product.id} /> {/* クライアントコンポーネント */}
</div>
);
}
// パターン2: クライアントコンポーネントが必要なデータを取得
// 問題のあるパターン: サーバーコンポーネントの境界を越えてデータを取得
'use client';
export default function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
// 問題: クライアント側でデータを取得(SSRの利点を失う)
fetch('/api/products').then(res => res.json()).then(setProducts);
}, []);
return <div>{/* ... */}</div>;
}
// 解決: サーバーコンポーネントでデータを取得
export default async function ProductList() {
const products = await getProducts(); // サーバー側で取得
return (
<ClientProductList products={products} /> // クライアントコンポーネントに渡す
);
}

判断基準:

// 1. サーバー状態 vs クライアント状態
// サーバー状態: React Query / SWR
// - APIから取得したデータ
// - キャッシュ、再取得、同期が必要
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5分間は新鮮とみなす
});
if (isLoading) return <Loading />;
return <div>{data.name}</div>;
}
// クライアント状態: Zustand / Jotai / Redux
// - UIの状態(モーダルの開閉、フォームの入力値)
// - グローバルなUI状態
import { create } from 'zustand';
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
}
const useCartStore = create<CartStore>((set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
}));

状態管理ライブラリの選択判断:

ライブラリ適用範囲学習コストボイラープレートデバッグ
React Queryサーバー状態低い少ない容易
Zustandクライアント状態非常に低い非常に少ない容易
Redux複雑な状態管理高い多い優秀
Jotaiアトミックな状態低い少ない容易

実践的な選択例:

// 小規模プロジェクト: Zustand + React Query
// - シンプルで学習コストが低い
// - ボイラープレートが少ない
// 中規模プロジェクト: Zustand + React Query + Context API
// - 機能ごとに状態を分離
// - グローバル状態はZustand、機能固有はContext
// 大規模プロジェクト: Redux Toolkit + React Query
// - 厳格な状態管理が必要
// - タイムトラベルデバッグが必要
// - 複雑な状態遷移がある

データフェッチングパターンの選択

Section titled “データフェッチングパターンの選択”

パターン1: サーバーコンポーネントでのフェッチ(推奨)

// メリット: サーバー側で実行、SEOに強い、パフォーマンスが良い
export default async function ProductsPage() {
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }
}).then(res => res.json());
return <ProductList products={products} />;
}

パターン2: Route Handlerでのフェッチ

app/api/products/route.ts
export async function GET() {
const products = await getProductsFromDB();
return Response.json(products);
}
// メリット: APIキーを隠蔽できる、データ変換が可能
// デメリット: 追加のネットワークリクエスト

パターン3: クライアントコンポーネントでのフェッチ

'use client';
export default function ProductsPage() {
const { data } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(res => res.json()),
});
return <ProductList products={data} />;
}
// メリット: リアルタイム更新、キャッシュ制御
// デメリット: 初期表示が遅い、SEOに弱い

判断基準:

// サーバーコンポーネントでフェッチを選ぶべき場合:
// 1. 初回表示が重要
// 2. SEOが必要
// 3. データがユーザー固有でない
// Route Handlerでフェッチを選ぶべき場合:
// 1. APIキーを隠蔽する必要がある
// 2. データ変換が必要
// 3. 複数のデータソースを統合
// クライアントコンポーネントでフェッチを選ぶべき場合:
// 1. リアルタイム更新が必要
// 2. ユーザー操作に応じてデータを取得
// 3. SEOが不要

キャッシングの階層:

1. Request Memoization (同一リクエスト内での重複リクエストを防ぐ)
2. Data Cache (fetchの結果をキャッシュ)
3. Full Route Cache (レンダリング結果をキャッシュ)
4. Router Cache (クライアント側のルーターキャッシュ)

実践的な設定:

// 1. 常に最新のデータが必要 → キャッシュしない
export default async function Dashboard() {
const data = await fetch('https://api.example.com/dashboard', {
cache: 'no-store' // キャッシュしない
});
return <Dashboard data={data} />;
}
// 2. 定期的に更新 → ISR
export default async function Products() {
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // 1時間ごとに再検証
});
return <ProductList products={data} />;
}
// 3. ビルド時に生成 → SSG
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
next: { revalidate: false } // キャッシュを無効化しない
});
return <Article post={post} />;
}
// 4. 動的キャッシュ → タグベース
export default async function Product({ params }: { params: { id: string } }) {
const product = await fetch(`https://api.example.com/products/${params.id}`, {
next: {
revalidate: 3600,
tags: ['products'] // タグでキャッシュを管理
}
});
return <ProductDetails product={product} />;
}
// タグベースの再検証
// app/api/revalidate/route.ts
export async function POST(request: Request) {
const { tag } = await request.json();
revalidateTag(tag); // 特定のタグのキャッシュを無効化
return Response.json({ revalidated: true });
}

Next.jsアプリケーションのアーキテクチャ設計において重要なポイント:

  1. レンダリング戦略: SSR、SSG、ISR、CSRの使い分け
  2. サーバー/クライアントコンポーネント: 適切な境界の設計
  3. 状態管理: サーバー状態とクライアント状態の分離
  4. データフェッチング: 最適なフェッチパターンの選択
  5. キャッシング: 多層的なキャッシング戦略の活用

シニアエンジニアとして考慮すべき点:

  • コンテキストに応じた選択: プロジェクトの要件に応じて最適な戦略を選択
  • トレードオフの理解: パフォーマンス、SEO、開発体験のバランス
  • 計測に基づく判断: 実際のパフォーマンスデータに基づいて最適化
  • 長期的な保守性: 短期的な最適化ではなく、長期的に保守可能な設計

適切なアーキテクチャの選択は、アプリケーションの成功に大きく影響します。コンテキストを理解し、トレードオフを分析した上で、最適な選択を行いましょう。