description: "Next.js 서버 컴포넌트 우선 원칙 및 데이터 페칭 패턴" paths:
Next.js App Router에서는 서버 컴포넌트를 기본으로 사용한다. 'use client'는 다음 중 하나에 해당할 때만 붙인다:
onClick, onChange, onSubmit)가 필요할 때useState, useEffect, useRef, useQuery 등) 사용localStorage, window, document) 사용| 컨텍스트 | 사용할 클라이언트 | 이유 |
|---|---|---|
| 서버 컴포넌트 (views/page.tsx) | serverFetch (fetch 기반) | Next.js Data Cache + ISR 활용 |
| 클라이언트 컴포넌트 (features/ui/) | apiClient (axios 인스턴스) | interceptor, 에러 변환 |
| 서버 액션 / Route Handler | serverFetch 또는 직접 fetch | 서버 환경 |
이 규칙을 지켜야 캐시가 의도대로 작동하고, 브라우저 네트워크 탭에 백엔드 URL 이 노출되지 않는다.
serverFetch 사용 (axios 금지)Next.js의 캐싱은 fetch를 기반으로 동작한다. axios는 Data Cache 를 우회하므로 서버 컴포넌트에서 사용하지 않는다.
// ✅ good — app/companies/page.tsx
import { serverFetch } from "@/src/shared/api/server-fetch";
export default async function Page() {
const companies = await serverFetch<CompanyType[]>("/companies", {
revalidate: 300,
tags: ["companies"],
});
return <CompanyList companies={companies} />;
}
shared/api/server-fetch.ts 헬퍼 재사용서버 컴포넌트 전용 fetch 헬퍼를 두고, 일관된 에러 처리와 캐싱을 적용한다.
// src/shared/api/server-fetch.ts
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "";
interface ServerFetchOptions {
revalidate?: number;
tags?: string[];
cache?: "no-store"; // Data Cache 완전 우회 (실시간 데이터용)
}
export async function serverFetch<T>(
path: string,
options: ServerFetchOptions = {},
): Promise<T> {
const { revalidate = 300, tags, cache } = options;
const fetchInit: RequestInit =
cache === "no-store"
? { cache: "no-store" }
: { next: { revalidate, tags } };
const res = await fetch(`${API_BASE_URL}${path}`, fetchInit);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(body.message ?? `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}
Promise.all서버 컴포넌트에서 여러 데이터가 필요하면 병렬로 호출한다.
const [companies, groups] = await Promise.all([
serverFetch<CompanyType[]>("/companies", { tags: ["companies"] }),
serverFetch<GroupType[]>("/groups", { tags: ["groups"] }),
]);
차트, 인터랙션이 필요한 컴포넌트도 데이터 fetch는 서버에서 한다.
// ✅ app/chart/page.tsx (서버 컴포넌트)
export default async function Page({ searchParams }: { searchParams: Promise<{ date?: string }> }) {
const { date } = await searchParams;
const data = await serverFetch<TimelineType>(`/rankings/timeline?date=${date}`);
return <ChartView data={data} />;
}
// ✅ src/views/chart/chart-view.tsx
'use client';
export function ChartView({ data }: { data: TimelineType }) {
// Nivo 차트, 호버, 탭 전환 등 인터랙션
}
<Link> 사용 (router.push 지양)단순 네비게이션은 next/link로 처리하면 서버 컴포넌트로 유지 가능.
// ✅ 서버 컴포넌트 유지
import Link from "next/link";
export function CompanyCard({ company }: Props) {
return <Link href={`/companies/${company.id}`}>...</Link>;
}
// ❌ 'use client' 강제
function CompanyCard({ company }: Props) {
const router = useRouter();
return <button onClick={() => router.push(...)}>...</button>;
}
| 데이터 특성 | 캐시 옵션 | 설명 |
|---|---|---|
| 불변 데이터 (과거 기록, 아카이브) | revalidate: 300~3600 | 한번 확정되면 변하지 않음. 긴 TTL 안전 |
| 저빈도 변경 (목록, 프로필, 설정) | revalidate: 60~300 | 수분 단위로 갱신되어도 충분 |
| 고빈도 변경 (현재 시점 데이터) | cache: 'no-store' | stale-while-revalidate 함정 회피 |
| 실시간 (채팅, 알림, 라이브 상태) | cache: 'no-store' | 매번 fresh. SSE/WebSocket 권장 |
핵심 원칙:
cache: 'no-store' 로 회피Server Action이나 Route Handler에서:
import { revalidateTag, revalidatePath } from "next/cache";
revalidateTag("companies"); // 해당 태그 걸린 캐시 전부 무효화
revalidatePath("/companies"); // 경로 단위 무효화
apps/admin은 CRUD 위주라 TanStack Query + 클라이언트 컴포넌트 유지한다. 서버 컴포넌트는 공개 서비스(web) 위주로 적용.
페이지를 만들 때 스스로 묻는다:
'use client'로 분리, 상위는 서버'use client' 달고 useQuery로 fetchuseRouter().push()로 단순 링크 이동 (<Link> 사용)process.env.API_KEY 같은 민감 변수 접근아직 피드백이 없어요. 첫 번째로 의견을 남겨보세요!