Files
2nd/10_Wiki/Topics/Coding/React_RSC_Server_Actions_Deep.md
T
2026-05-09 21:08:02 +09:00

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
react
rsc
server-actions
next
vibe-coding
language applicable_to
TS / Next.js / React
Frontend
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)

// 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 필수.

🔗 관련 문서