출시·고도화 중
Next.js 안내서 · 4/6
Next.js에서는 데이터를 어디서 읽고 어디서 바꾸는지가 설계의 중심입니다. 읽기는 주로 Server Component에서, 쓰기는 Server Action이나 Route Handler에서 하고, 브라우저에만 필요한 화면 상태는 Client Component가 맡습니다. 이 장에서는 데이터 가져오기, 캐시와 재검증, Route Handler, 폼 처리, 클라이언트 상태를 차례로 다룹니다.
Server Component는 async 함수로 만들 수 있으므로, 컴포넌트 안에서 바로 await로 데이터를 가져옵니다. fetch로 외부 API를 부르거나 ORM · 데이터베이스 클라이언트를 직접 써도 됩니다. 이 코드는 서버에서만 실행되므로 연결 문자열이나 API 키가 브라우저에 노출되지 않습니다.
// app/products/page.tsx
import { db } from "@/lib/db";
export default async function ProductsPage() {
const [products, categories] = await Promise.all([
db.product.findMany({ take: 20 }),
fetch("https://api.example.com/categories").then((res) => res.json()),
]);
return (
<main>
<p>분류 {categories.length}개</p>
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</main>
);
}서로 관계없는 요청은 위처럼 Promise.all로 동시에 시작해야 합니다. await를 하나씩 이어 쓰면 앞의 요청이 끝날 때까지 다음 요청이 기다리는 폭포(waterfall) 가 생깁니다. 데이터 접근 코드는 lib/ 아래 함수로 모아 두면 여러 페이지에서 재사용하고 권한 확인도 한곳에서 처리하기 쉽습니다.
캐시 동작은 Next.js에서 버전에 따라 가장 많이 바뀐 부분입니다. 최근 버전에서는 fetch 결과가 기본으로 캐시되지 않으며, 필요한 곳에서 명시적으로 캐시를 켭니다. 널리 쓰이는 fetch 옵션은 다음과 같습니다.
// 결과를 캐시해서 재사용
await fetch("https://api.example.com/menu", { cache: "force-cache" });
// 한 시간마다 새로 가져오도록 시간 기반 재검증
await fetch("https://api.example.com/news", { next: { revalidate: 3600 } });
// 태그를 붙여 두고 나중에 그 태그만 무효화
await fetch("https://api.example.com/posts", { next: { tags: ["posts"] } });데이터를 바꾼 뒤에는 캐시를 무효화해 새 내용이 보이게 합니다. next/cache의 revalidatePath는 특정 경로의 캐시를, revalidateTag는 태그가 붙은 데이터를 무효화합니다(최근 버전에서는 revalidateTag에 두 번째 인수로 재검증 방식을 넘깁니다).
최신 버전에는 cacheComponents 설정을 켜고 함수나 컴포넌트 맨 위에 "use cache" 지시어를 적어 캐시하는 새 모델도 있습니다. 이 모델에서는 cacheLife로 유지 시간을, cacheTag로 태그를 정합니다. 프로젝트가 어느 모델을 쓰는지 먼저 확인하고 그 버전의 공식 문서를 따르는 것이 안전합니다.
화면이 아니라 JSON 같은 HTTP 응답이 필요하면 route.ts 파일에 HTTP 메서드 이름으로 함수를 내보냅니다. 웹 표준 Request와 Response를 그대로 쓰며, 모바일 앱이나 외부 서비스가 부르는 API, 웹훅 수신에 적합합니다. 같은 폴더에 page.tsx와 route.ts를 함께 둘 수는 없습니다.
// app/api/posts/route.ts
import { db } from "@/lib/db";
export async function GET() {
const posts = await db.post.findMany();
return Response.json(posts);
}
export async function POST(request: Request) {
const body = await request.json();
const post = await db.post.create({ data: { title: body.title } });
return Response.json(post, { status: 201 });
}자기 앱의 Server Component에서 데이터를 읽으려고 자기 Route Handler를 다시 fetch할 필요는 없습니다. 같은 데이터 접근 함수를 직접 호출하면 왕복 요청 하나를 줄일 수 있습니다.
폼 제출과 데이터 변경에는 Server Action이 잘 맞습니다. 액션을 별도 파일에 모으면 Client Component에서도 가져다 쓸 수 있습니다. React의 useActionState 훅을 쓰면 액션이 돌려준 결과(오류 메시지 등)와 처리 중 여부를 함께 받습니다.
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
export async function createPost(prev: { error?: string }, formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
if (!title) return { error: "제목을 입력하세요." };
await db.post.create({ data: { title } });
revalidatePath("/posts");
redirect("/posts");
}// app/posts/new/form.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions";
export function NewPostForm() {
const [state, formAction, pending] = useActionState(createPost, {});
return (
<form action={formAction}>
<input name="title" />
{state.error && <p role="alert">{state.error}</p>}
<button disabled={pending}>{pending ? "저장 중…" : "저장"}</button>
</form>
);
}입력 검증에는 Zod 같은 스키마 라이브러리를 자주 씁니다. 검증과 권한 확인은 반드시 서버 쪽 액션 안에서 해야 합니다.
열린 메뉴, 입력 중인 값, 탭 선택처럼 화면에만 필요한 상태는 Client Component의 useState나 useReducer로 다룹니다. 여러 컴포넌트가 함께 써야 하면 Context 공급자를 Client Component로 만들고 루트 레이아웃에서 감쌉니다. 레이아웃 자체는 Server Component로 남습니다.
// app/providers.tsx
"use client";
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext<{ dark: boolean; toggle: () => void } | null>(null);
export function Providers({ children }: { children: React.ReactNode }) {
const [dark, setDark] = useState(false);
return <ThemeContext value={{ dark, toggle: () => setDark(!dark) }}>{children}</ThemeContext>;
}
export const useTheme = () => useContext(ThemeContext);검색어나 정렬 순서처럼 공유하거나 북마크할 만한 상태는 URL 검색 매개변수에 두는 것이 좋습니다. 페이지는 searchParams로, Client Component는 useSearchParams로 읽습니다. 브라우저에서 자주 갱신되는 서버 데이터가 필요하다면 SWR이나 TanStack Query를, 복잡한 전역 상태에는 Zustand 같은 라이브러리를 함께 쓸 수 있습니다.
async Server Component에서 하고, 독립적인 요청은 Promise.all로 동시에 시작합니다.fetch 옵션 또는 "use cache")을 확인합니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.