[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
---
|
||||
id: react-rsc-server-actions-deep
|
||||
title: RSC + Server Actions — 데이터 / 변형 / 캐시
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [react, rsc, server-actions, next, vibe-coding]
|
||||
tech_stack: { language: "TS / Next.js / React", applicable_to: ["Frontend"] }
|
||||
applied_in: []
|
||||
aliases: [React Server Components, Server Actions, useFormStatus, revalidatePath, "use server"]
|
||||
---
|
||||
|
||||
# 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)
|
||||
```tsx
|
||||
// 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 조합
|
||||
```tsx
|
||||
// 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>
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// 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
|
||||
```ts
|
||||
// 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
|
||||
```tsx
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// 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)
|
||||
```tsx
|
||||
'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)
|
||||
```ts
|
||||
// 'use cache' (실험)
|
||||
async function getPosts() {
|
||||
'use cache';
|
||||
return db.posts.findMany();
|
||||
}
|
||||
|
||||
// 또는 fetch 옵션
|
||||
fetch(url, { next: { revalidate: 60, tags: ['posts'] } });
|
||||
```
|
||||
|
||||
### Streaming + Suspense
|
||||
```tsx
|
||||
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 보안
|
||||
```ts
|
||||
'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
|
||||
```tsx
|
||||
'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 필수.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[React_Server_Components]]
|
||||
- [[React_Form_State_Patterns]]
|
||||
- [[React_TanStack_Query_Advanced]]
|
||||
Reference in New Issue
Block a user