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"); // 경로 단위 무효화
서버 컴포넌트(Next.js Data Cache)와 클라이언트(TanStack Query)가 공존할 때 일관성을 유지하는 패턴.
클라이언트에서 mutation 발생 → Route Handler에서 revalidateTag 호출 → 서버 캐시 무효화.
// app/api/items/route.ts (Route Handler)
import { revalidateTag } from "next/cache";
export async function POST(req: Request) {
const body = await req.json();
await fetch(`${process.env.API_BASE_URL}/items`, {
method: "POST",
body: JSON.stringify(body),
});
revalidateTag("items"); // 서버 캐시 무효화
return Response.json({ ok: true });
}
// 클라이언트 — useMutation 후 TanStack Query도 함께 무효화
export function useCreateItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input) => apiClient.post("/api/items", input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["items"] }); // 클라이언트 캐시 무효화
},
});
}
서버에서 fetch한 데이터를 TanStack Query의 초기값으로 넘겨 첫 로딩을 없앤다.
// app/items/page.tsx (서버 컴포넌트)
export default async function Page() {
const items = await serverFetch<Item[]>("/items", { tags: ["items"] });
return <ItemListClient initialData={items} />;
}
// src/widgets/item-list/ui/item-list-client.tsx (클라이언트 컴포넌트)
"use client";
export function ItemListClient({ initialData }: { initialData: Item[] }) {
const { data } = useQuery({
queryKey: ["items"],
queryFn: fetchItems,
initialData, // 서버에서 받은 데이터로 초기화 → 로딩 없음
staleTime: 60 * 1000,
});
}
| 데이터 | 캐시 위치 | 이유 |
|---|---|---|
| SEO 필요한 공개 데이터 | 서버 캐시 (revalidate) | 크롤러가 HTML에서 읽음 |
| 사용자별 개인화 데이터 | 클라이언트 (TanStack Query) | 서버 캐시 공유 불가 |
| 실시간 / 폴링 데이터 | 클라이언트 (TanStack Query) | 서버 캐시 의미 없음 |
| 정적 메타데이터 | 서버 캐시 (긴 TTL) | 변경 빈도 낮음 |
apps/admin은 CRUD 위주라 TanStack Query + 클라이언트 컴포넌트 유지한다. 서버 컴포넌트는 공개 서비스(web) 위주로 적용.
페이지를 만들 때 스스로 묻는다:
'use client'로 분리, 상위는 서버'use client' 달고 useQuery로 fetchuseRouter().push()로 단순 링크 이동 (<Link> 사용)process.env.API_KEY 같은 민감 변수 접근아직 피드백이 없어요. 첫 번째로 의견을 남겨보세요!