revalidateTag('posts')
그 데이터를 쓰는 모든 캐시를 무효화한다. 가장 흔한 선택.
읽기가 서버로 갔다면, 쓰기는?
읽기는 서버 컴포넌트에서 await db.query().
쓰기는 Server Function이다.
fetch('/api/...')를 손으로 쓰지 않아도 된다'use server' // 이 파일의 export는 모두 서버 함수가 된다
import { revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) { const title = formData.get('title') as string await db.post.create({ data: { title } }) revalidateTag('posts')}// app/new/page.tsx — 서버 컴포넌트. 'use client' 없이 동작한다!import { createPost } from '../actions'
export default function NewPost() { return ( <form action={createPost}> <input name="title" /> <button type="submit">저장</button> </form> )}sequenceDiagram
participant B as 브라우저
participant S as 서버
B->>S: form submit → POST (현재 라우트로)
Note over S: createPost 실행<br/>DB 쓰기 + revalidateTag
S-->>B: 갱신된 RSC 페이로드
Note over B: 화면이 새 데이터로 갱신됨<br/>페이지 이동 없음
마지막 항목이 Server Function의 가장 저평가된 특징이다.
| 상황 | <form action={fn}> |
onSubmit + fetch |
|---|---|---|
| 느린 3G에서 JS가 아직 안 옴 | 동작한다 | 아무 일도 안 일어난다 |
| JS 번들 로드 실패 | 동작한다 | 아무 일도 안 일어난다 |
| JS 비활성 환경 | 동작한다 | 아무 일도 안 일어난다 |
onSubmit 방식에서는 이 셋 모두 사용자가 버튼을 계속 누르게 된다.
useActionState'use server'
export async function createPost(prevState: State, formData: FormData) { const title = formData.get('title') as string if (!title || title.length < 3) return { error: '제목은 3자 이상이어야 합니다' } await db.post.create({ data: { title } }) revalidateTag('posts') return { ok: true }}'use client'import { useActionState } from 'react'
export function PostForm() { const [state, formAction, isPending] = useActionState(createPost, {}) return ( <form action={formAction}> <input name="title" aria-invalid={!!state.error} /> {state.error && <p className="text-sm text-destructive">{state.error}</p>} <Button disabled={isPending}>{isPending ? '저장 중…' : '저장'}</Button> </form> )}aria-invalid를 붙이는 것에 주목한다 — shadcn/ui의 Input은 그 속성 하나로
테두리가 자동으로 destructive 색이 된다. 접근성 속성이 곧 스타일 훅이다. (15장·18장)
useOptimistic서버 응답을 기다리지 않고 먼저 화면을 바꾼다. 실패하면 자동으로 되돌아간다.
'use client'import { useOptimistic } from 'react'
export function LikeButton({ post, likeAction }) { const [optimisticLikes, addOptimistic] = useOptimistic( post.likes, (current, delta: number) => current + delta )
return ( <form action={async () => { addOptimistic(1) // 즉시 반영 await likeAction(post.id) }}> <button>♥ {optimisticLikes}</button> </form> )}이건 “서버에서 실행되니 안전한 함수”가 아니다. 누구나 호출할 수 있는 HTTP 엔드포인트다.
proxy.ts의 matcher는 Server Function 호출을 보장해 주지 않는다 (3장)'use server'import { z } from 'zod'import { auth } from '@/lib/auth'
const UpdatePostSchema = z.object({ id: z.string().uuid(), title: z.string().min(3).max(200),})
export async function updatePost(_: State, formData: FormData) { // 1. 인증 — 누구인가 const session = await auth() if (!session) return { error: '로그인이 필요합니다' }
// 2. 검증 — 입력이 형식에 맞는가 const parsed = UpdatePostSchema.safeParse(Object.fromEntries(formData)) if (!parsed.success) return { error: '입력이 올바르지 않습니다' }
// 3. 인가 — 이 사람이 이 리소스를 바꿔도 되는가 const post = await db.post.findUnique({ where: { id: parsed.data.id } }) if (post?.authorId !== session.user.id) return { error: '권한이 없습니다' }
// 4. 실행 await db.post.update({ where: { id: parsed.data.id }, data: parsed.data }) revalidateTag('posts') return { ok: true }}flowchart LR
R["Server Function 호출"] --> A{"1. 인증<br/>누구인가"}
A -->|"없음"| X1["거부 ❌"]
A -->|"있음"| V{"2. 검증<br/>형식이 맞는가"}
V -->|"실패"| X2["거부 ❌"]
V -->|"통과"| Z{"3. 인가<br/>이 리소스를 바꿔도 되나"}
Z -->|"아니오"| X3["거부 ❌"]
Z -->|"예"| E["4. 실행 + 재검증 ✅"]
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class E ok
class X1,X2,X3 bad
class R,A,V,Z mute
인증 → 검증 → 인가 → 실행. 이 네 단계를 모든 Server Function에서 반복한다.
export function authedAction<S extends z.ZodType, R>( schema: S, handler: (input: z.infer<S>, user: User) => Promise<R>) { return async (formData: FormData) => { const session = await auth() if (!session) throw new Error('UNAUTHORIZED') const parsed = schema.safeParse(Object.fromEntries(formData)) if (!parsed.success) throw new Error('INVALID_INPUT') return handler(parsed.data, session.user) }}직접 만들어도 되고, next-safe-action이나 zsa 같은 라이브러리를 써도 된다. 중요한 건 **“검사를 빼먹을 수 없는 구조”**를 만드는 것이다.
revalidateTag('posts')
그 데이터를 쓰는 모든 캐시를 무효화한다. 가장 흔한 선택.
revalidatePath('/posts')
특정 경로만 갱신한다. 영향 범위가 좁을 때.
redirect('/posts/123')
생성 후 상세 페이지로 이동할 때.
반환값 + useOptimistic
즉시 반영이 중요한 소규모 변경.
| Server Function | Route Handler | |
|---|---|---|
| 호출 주체 | 내 앱의 폼·버튼 | 외부 시스템, 모바일 앱 |
| 형태 | 함수 호출 | HTTP 요청 |
| 타입 | 양 끝이 자동으로 이어짐 | 직접 맞춰야 함 |
| 점진적 향상 | 됨 | 안 됨 |
| 웹훅 수신 | 부적합 | 적합 |
| 파일 업로드 | 됨 (FormData) | 됨 |
기준은 하나다 — “이걸 우리 앱 밖에서도 부를 일이 있는가?” 없다면 Server Function이다.
'use server'로 클라이언트에서 직접 부르는 서버 함수를 만든다<form action={fn}>은 JS 없이도 동작한다 (점진적 향상)useActionState로 로딩·에러, useOptimistic으로 즉시 반영revalidateTag / revalidatePath / redirect 중 하나를 한다