---
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]]
