6.2 KiB
6.2 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| react-rsc-server-actions-deep | RSC + Server Actions — 데이터 / 변형 / 캐시 | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
RSC + Server Actions
서버에서만 실행되는 컴포넌트 (RSC) + 서버 함수 직접 호출 (Server Actions). Client bundle 0 — DB / API 직접.
'use server' / 'use client'경계 + revalidate.
📖 핵심 개념
- RSC: 서버 렌더, JS bundle 0, async OK.
- Client component:
'use client'— 상호작용. - Server Action: 서버 함수, 클라가 form / RPC 처럼 호출.
- revalidatePath / revalidateTag: cache 무효화.
💻 코드 패턴
기본 RSC (Next.js App Router)
// app/posts/page.tsx — 서버 컴포넌트
async function Page() {
const posts = await db.posts.findMany(); // DB 직접
return (
<ul>
{posts.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
);
}
Client + Server 조합
// app/posts/page.tsx (server)
import { LikeButton } from './LikeButton';
async function Page() {
const posts = await db.posts.findMany();
return posts.map(p => (
<article key={p.id}>
<h1>{p.title}</h1>
<LikeButton postId={p.id} initialCount={p.likes} />
</article>
));
}
// LikeButton.tsx
'use client';
import { useState } from 'react';
import { likePost } from './actions';
export function LikeButton({ postId, initialCount }: ...) {
const [count, setCount] = useState(initialCount);
return (
<button onClick={async () => {
setCount(c => c + 1); // optimistic
await likePost(postId);
}}>{count}</button>
);
}
Server Action
// app/posts/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { z } from 'zod';
const Like = z.object({ postId: z.string().uuid() });
export async function likePost(postId: string) {
Like.parse({ postId });
const userId = await getUser();
if (!userId) throw new Error('UNAUTHORIZED');
await db.posts.update({ where: { id: postId }, data: { likes: { increment: 1 } } });
revalidatePath(`/posts/${postId}`);
revalidateTag('posts');
}
Form action
// app/new-post/page.tsx
import { createPost } from './actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="body" required />
<button>Save</button>
</form>
);
}
// actions.ts
'use server';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const NewPost = z.object({ title: z.string().min(1), body: z.string().min(1) });
export async function createPost(formData: FormData) {
const data = NewPost.parse(Object.fromEntries(formData));
const post = await db.posts.create({ data });
redirect(`/posts/${post.id}`);
}
useFormStatus / useActionState (React 19)
'use client';
import { useFormStatus } from 'react-dom';
import { useActionState } from 'react';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>;
}
function Form() {
const [state, formAction] = useActionState(createPost, { error: null });
return (
<form action={formAction}>
<input name="title" />
{state.error && <p>{state.error}</p>}
<SubmitButton />
</form>
);
}
Cache 제어 (Next 15 — 명시 opt-in)
// 'use cache' (실험)
async function getPosts() {
'use cache';
return db.posts.findMany();
}
// 또는 fetch 옵션
fetch(url, { next: { revalidate: 60, tags: ['posts'] } });
Streaming + Suspense
import { Suspense } from 'react';
async function SlowPanel() {
await new Promise(r => setTimeout(r, 2000));
return <p>slow data</p>;
}
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<Skeleton />}>
<SlowPanel />
</Suspense>
</>
);
}
각 Suspense 경계가 별도 stream — 빠른 부분 먼저.
Server Action 보안
'use server';
export async function deletePost(id: string) {
const user = await getUser();
if (!user) throw new Error('UNAUTHORIZED');
const post = await db.posts.findUnique({ where: { id } });
if (post?.userId !== user.id) throw new Error('FORBIDDEN');
await db.posts.delete({ where: { id } });
revalidatePath('/posts');
}
⚠️ Server Action 은 public endpoint — 자동 권한 검사 X.
useOptimistic
'use client';
import { useOptimistic } from 'react';
function Likes({ initial, postId }: ...) {
const [opt, setOpt] = useOptimistic(initial);
return (
<button onClick={async () => {
setOpt(c => c + 1);
await likePost(postId);
}}>{opt}</button>
);
}
🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| 데이터 fetch | RSC (서버) |
| Form / mutation | Server Action |
| 인터랙션 (state, event) | 'use client' |
| 차트 / 라이브러리 (browser only) | 'use client' |
| 큰 데이터 stream | Suspense 분할 |
| External API mutation 그대로 | Server Action |
| RPC 일관성 필요 | tRPC (server actions 위 생산성) |
❌ 안티패턴
- Server Action 인증 검사 누락: public endpoint — 임의 호출 가능.
- Client component 안에 큰 server import: bundle 폭발.
- Server Action 안 redirect 후 코드: 실행 안 됨 (throw).
- revalidate 안 함: cache 가 stale.
'use client'도배: bundle 큼. 가능한 RSC.- Server function 의 input 검증 X: 위험. zod.
- Promise 직접 prop 으로 client 에: serialize 안 됨. 데이터만.
- Server / client 코드 한 파일 (export 둘 다): 모듈 분리.
🤖 LLM 활용 힌트
- Data fetch = RSC, Mutation = Server Action.
- Server Action = public — 권한 + 검증 매번.
- revalidatePath / revalidateTag 필수.