Next.js Server Actions 완벽 가이드: 보안, 유효성 검사, 실전 패턴 총정리

Next.js App Router의 Server Actions를 제대로 활용하는 법을 다룹니다. Zod 유효성 검사, 인증/권한 패턴, useActionState, 낙관적 업데이트, 캐시 무효화 전략까지 — 프로덕션 수준의 실전 코드와 보안 체크리스트를 포함한 종합 가이드입니다.

Server Actions, 대체 뭔가요?

Next.js App Router를 쓰다 보면 자연스럽게 마주치게 되는 게 바로 Server Actions입니다. 간단히 말하면, 서버에서 실행되는 비동기 함수인데 클라이언트 컴포넌트에서 마치 일반 자바스크립트 함수처럼 호출할 수 있어요. Next.js가 알아서 POST 엔드포인트를 만들어주고, 네트워크 요청도 자동으로 처리해주니까 별도의 API 라우트 없이도 서버 로직을 바로 실행할 수 있죠.

솔직히, 처음 접했을 때 "이게 진짜 되나?" 싶었습니다.

기존 REST API 방식과 비교하면 타입 안전성을 유지하면서도 코드량이 확 줄어듭니다. 클라이언트-서버 경계를 자연스럽게 넘나들 수 있어서, 풀스택 개발 생산성이 눈에 띄게 좋아져요.

Server Actions 기본 설정과 사용법

'use server' 지시어 이해하기

Server Actions를 정의하려면 'use server' 지시어를 사용합니다. 적용 방식은 크게 두 가지가 있어요.

방법 1: 함수 상단에 선언 — 서버 컴포넌트 안에서 인라인으로 Server Action을 정의할 때 씁니다.

// app/posts/page.tsx (서버 컴포넌트)
export default function PostsPage() {
  async function createPost(formData: FormData) {
    'use server'

    const title = formData.get('title') as string
    const content = formData.get('content') as string

    // 데이터베이스에 게시물 저장
    await db.post.create({
      data: { title, content }
    })
  }

  return (
    <form action={createPost}>
      <input name="title" placeholder="제목" />
      <textarea name="content" placeholder="내용" />
      <button type="submit">게시하기</button>
    </form>
  )
}

방법 2: 파일 상단에 선언 — 여러 Server Actions를 별도 파일에서 모듈로 관리할 때 사용합니다. 프로덕션에서는 이 방식을 쓰는 게 훨씬 낫습니다. 코드 관리도 편하고, 번들 사이즈 최적화에도 유리하거든요.

// app/actions/posts.ts
'use server'

import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  await db.post.create({
    data: { title, content }
  })

  revalidatePath('/posts')
}

export async function deletePost(postId: string) {
  await db.post.delete({
    where: { id: postId }
  })

  revalidatePath('/posts')
}

클라이언트 컴포넌트에서 Server Actions 호출하기

클라이언트 컴포넌트에서도 별도 파일에 정의된 Server Actions를 import해서 쓸 수 있습니다. 이게 바로 Server Actions의 핵심 강점 중 하나예요.

// app/components/PostForm.tsx
'use client'

import { createPost } from '@/app/actions/posts'
import { useActionState } from 'react'

export default function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null)

  return (
    <form action={formAction}>
      <input name="title" placeholder="제목" disabled={isPending} />
      <textarea name="content" placeholder="내용" disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? '저장 중...' : '게시하기'}
      </button>
      {state?.error && <p className="error">{state.error}</p>}
    </form>
  )
}

Zod를 활용한 입력 유효성 검사

여기서 정말 중요한 포인트가 하나 있습니다. Server Actions는 공개 HTTP POST 엔드포인트입니다. 즉, 'use server'로 선언된 함수 하나하나가 누구든 어떤 데이터로든 호출할 수 있는 엔드포인트를 만들어냅니다. 그래서 모든 입력값에 대한 서버 측 유효성 검사는 선택이 아니라 필수예요.

Zod는 TypeScript 우선 스키마 선언 및 유효성 검사 라이브러리로, Server Actions 생태계에서 사실상 표준처럼 쓰이고 있습니다. 클라이언트와 서버에서 같은 스키마를 공유할 수 있다는 게 큰 장점이죠.

// lib/validations/post.ts
import { z } from 'zod'

export const createPostSchema = z.object({
  title: z
    .string()
    .min(2, '제목은 최소 2글자 이상이어야 합니다')
    .max(100, '제목은 100글자를 초과할 수 없습니다'),
  content: z
    .string()
    .min(10, '내용은 최소 10글자 이상이어야 합니다')
    .max(10000, '내용은 10,000글자를 초과할 수 없습니다'),
  categoryId: z
    .string()
    .uuid('유효하지 않은 카테고리입니다'),
  tags: z
    .array(z.string())
    .max(5, '태그는 최대 5개까지 가능합니다')
    .optional(),
})

export type CreatePostInput = z.infer<typeof createPostSchema>

이제 이 스키마를 Server Action에 적용해서 입력값을 검증해봅시다.

// app/actions/posts.ts
'use server'

import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { createPostSchema } from '@/lib/validations/post'

type ActionState = {
  error?: string
  fieldErrors?: Record<string, string[]>
  success?: boolean
}

export async function createPost(
  prevState: ActionState | null,
  formData: FormData
): Promise<ActionState> {
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
    categoryId: formData.get('categoryId'),
    tags: formData.getAll('tags'),
  }

  const validated = createPostSchema.safeParse(rawData)

  if (!validated.success) {
    return {
      error: '입력값을 확인해주세요.',
      fieldErrors: validated.error.flatten().fieldErrors,
    }
  }

  try {
    await db.post.create({
      data: validated.data,
    })
  } catch (error) {
    return {
      error: '게시물 저장에 실패했습니다. 다시 시도해주세요.',
    }
  }

  revalidatePath('/posts')
  return { success: true }
}

인증과 권한 검사 패턴

Server Actions가 공개 엔드포인트라는 점, 다시 한번 강조합니다. 모든 액션에서 사용자 인증과 권한을 반드시 확인해야 해요. 미들웨어만으로는 충분하지 않습니다. 실제로 2025년에 발견된 CVE-2025-29927 취약점이 미들웨어에만 의존하는 인증 방식이 얼마나 위험한지 여실히 보여줬죠.

기본 인증 패턴

// lib/auth.ts
import { auth } from '@/auth'
import { redirect } from 'next/navigation'

export async function requireAuth() {
  const session = await auth()

  if (!session?.user) {
    redirect('/login')
  }

  return session.user
}

export async function requireAdmin() {
  const user = await requireAuth()

  if (user.role !== 'admin') {
    throw new Error('관리자 권한이 필요합니다')
  }

  return user
}
// app/actions/posts.ts
'use server'

import { requireAuth } from '@/lib/auth'
import { db } from '@/lib/db'

export async function deletePost(postId: string) {
  const user = await requireAuth()

  // 게시물 소유권 확인 (권한 검사)
  const post = await db.post.findUnique({
    where: { id: postId },
  })

  if (!post) {
    throw new Error('게시물을 찾을 수 없습니다')
  }

  if (post.authorId !== user.id) {
    throw new Error('이 게시물을 삭제할 권한이 없습니다')
  }

  await db.post.delete({
    where: { id: postId },
  })

  revalidatePath('/posts')
}

next-safe-action으로 보안을 체계적으로 관리하기

next-safe-action은 Server Actions의 유효성 검사, 인증, 에러 처리를 한 곳에서 일관되게 처리할 수 있는 래퍼 라이브러리입니다. 개인적으로 프로덕션 앱에서는 이 라이브러리를 거의 필수로 쓰고 있어요. 코드가 훨씬 깔끔해지거든요.

// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { auth } from '@/auth'

// 기본 액션 클라이언트 (인증 불필요)
export const publicAction = createSafeActionClient()

// 인증 필수 액션 클라이언트
export const authAction = createSafeActionClient({
  async middleware() {
    const session = await auth()

    if (!session?.user) {
      throw new Error('인증이 필요합니다')
    }

    return { user: session.user }
  },
})

// 관리자 전용 액션 클라이언트
export const adminAction = createSafeActionClient({
  async middleware() {
    const session = await auth()

    if (!session?.user || session.user.role !== 'admin') {
      throw new Error('관리자 권한이 필요합니다')
    }

    return { user: session.user }
  },
})
// app/actions/posts.ts
'use server'

import { authAction } from '@/lib/safe-action'
import { createPostSchema } from '@/lib/validations/post'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export const createPost = authAction
  .schema(createPostSchema)
  .action(async ({ parsedInput, ctx }) => {
    const post = await db.post.create({
      data: {
        ...parsedInput,
        authorId: ctx.user.id,
      },
    })

    revalidatePath('/posts')
    return { post }
  })

useActionState와 useFormStatus로 UX 향상하기

React 19에서 도입된 useActionState 훅은 Server Actions와 함께 쓸 때 폼 상태 관리를 정말 단순하게 만들어줍니다. 액션의 결과값, 폼 액션 디스패처, 대기 상태 — 이 세 가지를 한 번에 돌려받을 수 있어요.

useActionState 실전 활용

// app/components/ContactForm.tsx
'use client'

import { useActionState } from 'react'
import { submitContact } from '@/app/actions/contact'

const initialState = {
  error: null as string | null,
  fieldErrors: {} as Record<string, string[]>,
  success: false,
}

export default function ContactForm() {
  const [state, formAction, isPending] = useActionState(
    submitContact,
    initialState
  )

  if (state.success) {
    return (
      <div className="success-message">
        <h3>문의가 접수되었습니다</h3>
        <p>빠른 시일 내에 답변 드리겠습니다.</p>
      </div>
    )
  }

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="name">이름</label>
        <input id="name" name="name" disabled={isPending} />
        {state.fieldErrors?.name && (
          <p className="error">{state.fieldErrors.name[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">이메일</label>
        <input id="email" name="email" type="email" disabled={isPending} />
        {state.fieldErrors?.email && (
          <p className="error">{state.fieldErrors.email[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="message">문의 내용</label>
        <textarea id="message" name="message" disabled={isPending} />
        {state.fieldErrors?.message && (
          <p className="error">{state.fieldErrors.message[0]}</p>
        )}
      </div>

      <SubmitButton />

      {state.error && <p className="error">{state.error}</p>}
    </form>
  )
}

useFormStatus로 제출 버튼 상태 표시하기

useFormStatusreact-dom에서 제공하는 훅인데, 폼의 제출 상태를 추적합니다. 한 가지 주의할 점은 이 훅을 <form> 요소의 자식 컴포넌트 안에서 써야 한다는 거예요. 부모 컴포넌트에서 호출하면 동작하지 않습니다.

// app/components/SubmitButton.tsx
'use client'

import { useFormStatus } from 'react-dom'

export default function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? (
        <span className="flex items-center gap-2">
          <LoadingSpinner />
          전송 중...
        </span>
      ) : (
        '문의하기'
      )}
    </button>
  )
}

캐시 무효화 전략: revalidatePath vs revalidateTag

Server Actions로 데이터를 변경했다면, 캐시를 적절히 무효화해서 사용자에게 최신 데이터를 보여줘야 합니다. Next.js는 이를 위해 두 가지 함수를 제공하는데, 각각의 쓰임새가 다릅니다.

revalidatePath — 경로 기반 무효화

revalidatePath는 특정 페이지나 레이아웃의 캐시를 통째로 무효화합니다. 해당 경로에 연관된 모든 데이터를 다시 가져옵니다.

'use server'

import { revalidatePath } from 'next/cache'

export async function updateProfile(formData: FormData) {
  // 프로필 업데이트 로직...

  // 특정 페이지 무효화
  revalidatePath('/profile')

  // 레이아웃 레벨에서 무효화 (하위 페이지 모두 영향)
  revalidatePath('/dashboard', 'layout')

  // 전체 사이트 무효화 (주의: 성능 영향 있음)
  revalidatePath('/', 'layout')
}

revalidateTag — 태그 기반 무효화

revalidateTag는 특정 태그가 지정된 데이터만 골라서 무효화합니다. 좀 더 세밀한 캐시 제어가 가능하고, 여러 페이지에 걸친 동일 태그의 데이터를 한 번에 날릴 수 있어요.

// 데이터 가져오기 시 태그 지정
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] },
  })
  return res.json()
}

async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    next: { tags: ['posts', `post-${id}`] },
  })
  return res.json()
}

// Server Action에서 태그 기반 무효화
'use server'

import { revalidateTag } from 'next/cache'

export async function updatePost(postId: string, formData: FormData) {
  // 게시물 업데이트 로직...

  // 특정 게시물만 무효화
  revalidateTag(`post-${postId}`)

  // 또는 모든 게시물 목록 무효화
  revalidateTag('posts')
}

그래서 뭘 써야 할까요? 일반적으로 revalidateTag가 더 정밀한 캐시 제어를 해주니까 성능 면에서 유리합니다. 다만, fetch 없이 직접 DB를 호출하는 경우에는 revalidatePath를 써야 해요. 그리고 revalidatePath는 데이터가 캐시되지 않은 경우(예: cache: 'no-store' 사용 시)에는 아무 효과가 없다는 점도 기억해두세요.

useOptimistic을 활용한 낙관적 업데이트

낙관적 업데이트는 서버 응답을 기다리지 않고 UI를 먼저 업데이트해주는 기법입니다. 사용자 입장에서는 앱이 훨씬 빠르게 반응하는 것처럼 느껴지죠. React의 useOptimistic 훅을 Server Actions와 조합하면 생각보다 간단하게 구현할 수 있습니다.

// app/components/TodoList.tsx
'use client'

import { useOptimistic } from 'react'
import { toggleTodo, deleteTodo } from '@/app/actions/todos'

type Todo = {
  id: string
  text: string
  completed: boolean
}

export default function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (state: Todo[], update: { type: string; id: string }) => {
      switch (update.type) {
        case 'toggle':
          return state.map((todo) =>
            todo.id === update.id
              ? { ...todo, completed: !todo.completed }
              : todo
          )
        case 'delete':
          return state.filter((todo) => todo.id !== update.id)
        default:
          return state
      }
    }
  )

  async function handleToggle(id: string) {
    addOptimistic({ type: 'toggle', id })
    await toggleTodo(id)
  }

  async function handleDelete(id: string) {
    addOptimistic({ type: 'delete', id })
    await deleteTodo(id)
  }

  return (
    <ul>
      {optimisticTodos.map((todo) => (
        <li key={todo.id} className={todo.completed ? 'completed' : ''}>
          <button onClick={() => handleToggle(todo.id)}>
            {todo.completed ? '✓' : '○'}
          </button>
          <span>{todo.text}</span>
          <button onClick={() => handleDelete(todo.id)}>삭제</button>
        </li>
      ))}
    </ul>
  )
}

점진적 향상(Progressive Enhancement) 활용하기

Server Actions의 또 다른 멋진 점은 점진적 향상을 기본으로 지원한다는 겁니다. 폼에 Server Action을 연결해두면, 자바스크립트가 아직 로드되지 않았거나 비활성화된 상태에서도 폼이 그냥 동작합니다. (이게 은근히 대단한 거예요.)

// 점진적 향상을 지원하는 검색 폼
// app/components/SearchForm.tsx
export default function SearchForm() {
  async function search(formData: FormData) {
    'use server'
    const query = formData.get('query') as string
    redirect(`/search?q=${encodeURIComponent(query)}`)
  }

  return (
    <form action={search}>
      <input
        name="query"
        type="search"
        placeholder="검색어를 입력하세요..."
        required
      />
      <button type="submit">검색</button>
    </form>
  )
}

위 코드에서 <form action={search}>는 자바스크립트 없이도 HTML 폼의 기본 동작으로 서버에 요청을 보냅니다. 자바스크립트가 로드되면 Next.js가 이를 가로채서 페이지 새로고침 없는 부드러운 전환을 제공하죠.

에러 처리 베스트 프랙티스

Server Actions에서의 에러 처리는 일반 API 라우트와는 좀 다른 패턴을 따릅니다. 핵심은 예상 가능한 에러와 예기치 않은 에러를 구분해서 처리하는 거예요.

구조화된 에러 반환 패턴

// app/actions/users.ts
'use server'

import { z } from 'zod'
import { db } from '@/lib/db'
import { requireAuth } from '@/lib/auth'
import { redirect } from 'next/navigation'

const updateUserSchema = z.object({
  name: z.string().min(1, '이름을 입력해주세요'),
  email: z.string().email('유효한 이메일 주소를 입력해주세요'),
  bio: z.string().max(500, '자기소개는 500자를 초과할 수 없습니다').optional(),
})

type UpdateUserState = {
  error?: string
  fieldErrors?: Record<string, string[]>
  success?: boolean
}

export async function updateUser(
  prevState: UpdateUserState | null,
  formData: FormData
): Promise<UpdateUserState> {
  // 1. 인증 확인
  const currentUser = await requireAuth()

  // 2. 입력 유효성 검사
  const validated = updateUserSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    bio: formData.get('bio'),
  })

  if (!validated.success) {
    return {
      error: '입력값을 확인해주세요.',
      fieldErrors: validated.error.flatten().fieldErrors,
    }
  }

  // 3. 비즈니스 로직 에러 처리
  try {
    // 이메일 중복 확인
    const existingUser = await db.user.findUnique({
      where: { email: validated.data.email },
    })

    if (existingUser && existingUser.id !== currentUser.id) {
      return {
        error: '이미 사용 중인 이메일 주소입니다.',
        fieldErrors: {
          email: ['이미 사용 중인 이메일 주소입니다.'],
        },
      }
    }

    await db.user.update({
      where: { id: currentUser.id },
      data: validated.data,
    })
  } catch (error) {
    // 예기치 않은 에러 (DB 연결 실패 등)
    console.error('사용자 업데이트 실패:', error)
    return {
      error: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
    }
  }

  // 4. 성공 시 redirect는 try/catch 밖에서 호출
  redirect('/profile')
}

여기서 꼭 기억할 점: redirect()는 반드시 try/catch 블록 밖에서 호출해야 합니다. redirect가 내부적으로 에러를 throw해서 리다이렉트를 수행하기 때문에, catch 블록에 잡혀버리면 제대로 동작하지 않아요. 이거 때문에 한참 삽질한 적이 있는데, 꽤 흔한 실수입니다.

데이터베이스 연동: Prisma와 Drizzle ORM

Server Actions는 데이터베이스와 직접 상호작용할 때 특히 빛을 발합니다. 별도 API 계층 없이 타입 안전한 DB 작업을 바로 수행할 수 있으니까요.

Prisma를 활용한 패턴

// app/actions/articles.ts
'use server'

import { prisma } from '@/lib/prisma'
import { revalidateTag } from 'next/cache'
import { requireAuth } from '@/lib/auth'
import { z } from 'zod'

const articleSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  published: z.boolean().default(false),
})

export async function createArticle(formData: FormData) {
  const user = await requireAuth()

  const validated = articleSchema.parse({
    title: formData.get('title'),
    content: formData.get('content'),
    published: formData.get('published') === 'true',
  })

  const article = await prisma.article.create({
    data: {
      ...validated,
      authorId: user.id,
      slug: validated.title
        .toLowerCase()
        .replace(/[^a-z0-9가-힣]+/g, '-')
        .replace(/(^-|-$)/g, ''),
    },
  })

  revalidateTag('articles')
  return { article }
}

Drizzle ORM을 활용한 패턴

Drizzle ORM은 SQL에 좀 더 가까운 인터페이스를 제공합니다. 서버리스 환경에서 콜드 스타트 시간이 짧다는 게 실질적인 장점이에요. 최근 프로젝트에서는 Drizzle을 더 선호하게 되더라고요.

// app/actions/articles-drizzle.ts
'use server'

import { db } from '@/lib/drizzle'
import { articles } from '@/lib/drizzle/schema'
import { eq } from 'drizzle-orm'
import { revalidateTag } from 'next/cache'
import { requireAuth } from '@/lib/auth'

export async function createArticle(formData: FormData) {
  const user = await requireAuth()

  const [article] = await db
    .insert(articles)
    .values({
      title: formData.get('title') as string,
      content: formData.get('content') as string,
      authorId: user.id,
    })
    .returning()

  revalidateTag('articles')
  return { article }
}

export async function updateArticle(id: string, formData: FormData) {
  const user = await requireAuth()

  const [existing] = await db
    .select()
    .from(articles)
    .where(eq(articles.id, id))

  if (!existing || existing.authorId !== user.id) {
    throw new Error('권한이 없습니다')
  }

  const [updated] = await db
    .update(articles)
    .set({
      title: formData.get('title') as string,
      content: formData.get('content') as string,
      updatedAt: new Date(),
    })
    .where(eq(articles.id, id))
    .returning()

  revalidateTag(`article-${id}`)
  revalidateTag('articles')
  return { article: updated }
}

Server Actions vs API Routes: 언제 뭘 써야 할까?

Server Actions가 나오고 나서 가장 많이 받는 질문이 "그럼 API Routes는 이제 안 써도 되나요?"인 것 같습니다. 결론부터 말하면, 둘 다 각자의 영역이 있어요.

Server Actions가 딱 맞는 경우

  • 폼 제출과 데이터 변경(Mutation): 사용자 입력을 받아 데이터를 생성하거나 수정, 삭제하는 작업
  • 인증된 사용자의 작업: 로그인한 사용자가 수행하는 CRUD 작업
  • 내부 데이터 조작: 외부 시스템에 노출할 필요 없는 서버 측 로직
  • 점진적 향상이 필요한 폼: JS 없이도 동작해야 하는 폼

API Routes를 써야 하는 경우

  • 외부 시스템 연동: 웹훅 수신, 외부 API에서의 호출
  • 데이터 조회(GET 요청): Server Actions는 POST만 지원하므로, 캐시 가능한 GET 엔드포인트가 필요할 때
  • 서드파티 통합: Stripe 웹훅, OAuth 콜백 같은 것들
  • 파일 스트리밍: 대용량 파일 업로드나 다운로드

실전 프로젝트: 댓글 시스템 구축

자, 이제 지금까지 배운 걸 전부 합쳐서 실전에서 쓸 수 있는 댓글 시스템을 만들어봅시다. Zod 유효성 검사, 인증, 낙관적 업데이트, 캐시 무효화를 전부 담았습니다.

// lib/validations/comment.ts
import { z } from 'zod'

export const commentSchema = z.object({
  content: z
    .string()
    .min(1, '댓글 내용을 입력해주세요')
    .max(1000, '댓글은 1,000글자를 초과할 수 없습니다'),
  postId: z.string().uuid(),
})

export type CommentInput = z.infer<typeof commentSchema>
// app/actions/comments.ts
'use server'

import { db } from '@/lib/db'
import { requireAuth } from '@/lib/auth'
import { commentSchema } from '@/lib/validations/comment'
import { revalidateTag } from 'next/cache'

type CommentActionState = {
  error?: string
  fieldErrors?: Record<string, string[]>
  success?: boolean
}

export async function addComment(
  prevState: CommentActionState | null,
  formData: FormData
): Promise<CommentActionState> {
  const user = await requireAuth()

  const validated = commentSchema.safeParse({
    content: formData.get('content'),
    postId: formData.get('postId'),
  })

  if (!validated.success) {
    return {
      error: '입력값을 확인해주세요.',
      fieldErrors: validated.error.flatten().fieldErrors,
    }
  }

  try {
    await db.comment.create({
      data: {
        content: validated.data.content,
        postId: validated.data.postId,
        authorId: user.id,
      },
    })
  } catch (error) {
    console.error('댓글 작성 실패:', error)
    return { error: '댓글 작성에 실패했습니다.' }
  }

  revalidateTag(`comments-${validated.data.postId}`)
  return { success: true }
}

export async function deleteComment(commentId: string) {
  const user = await requireAuth()

  const comment = await db.comment.findUnique({
    where: { id: commentId },
  })

  if (!comment || comment.authorId !== user.id) {
    throw new Error('이 댓글을 삭제할 권한이 없습니다')
  }

  await db.comment.delete({
    where: { id: commentId },
  })

  revalidateTag(`comments-${comment.postId}`)
}
// app/components/CommentSection.tsx
'use client'

import { useActionState, useOptimistic, useRef } from 'react'
import { addComment, deleteComment } from '@/app/actions/comments'

type Comment = {
  id: string
  content: string
  author: { name: string; id: string }
  createdAt: string
}

export default function CommentSection({
  comments,
  postId,
  currentUserId,
}: {
  comments: Comment[]
  postId: string
  currentUserId?: string
}) {
  const formRef = useRef<HTMLFormElement>(null)

  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (state: Comment[], newComment: Comment) => [newComment, ...state]
  )

  const [state, formAction, isPending] = useActionState(
    async (prevState: any, formData: FormData) => {
      // 낙관적 업데이트 추가
      addOptimisticComment({
        id: 'temp-' + Date.now(),
        content: formData.get('content') as string,
        author: { name: '나', id: currentUserId || '' },
        createdAt: new Date().toISOString(),
      })

      formRef.current?.reset()
      return addComment(prevState, formData)
    },
    null
  )

  return (
    <section>
      <h3>댓글 ({optimisticComments.length})</h3>

      {currentUserId && (
        <form ref={formRef} action={formAction}>
          <input type="hidden" name="postId" value={postId} />
          <textarea
            name="content"
            placeholder="댓글을 작성해주세요..."
            required
            disabled={isPending}
          />
          <button type="submit" disabled={isPending}>
            {isPending ? '작성 중...' : '댓글 작성'}
          </button>
          {state?.error && <p className="error">{state.error}</p>}
        </form>
      )}

      <ul>
        {optimisticComments.map((comment) => (
          <li key={comment.id}>
            <div className="comment-header">
              <strong>{comment.author.name}</strong>
              <time>{new Date(comment.createdAt).toLocaleDateString('ko-KR')}</time>
            </div>
            <p>{comment.content}</p>
            {currentUserId === comment.author.id && (
              <button onClick={() => deleteComment(comment.id)}>삭제</button>
            )}
          </li>
        ))}
      </ul>
    </section>
  )
}

Server Actions 보안 체크리스트

프로덕션에 배포하기 전에 꼭 한 번 점검해보세요. 하나라도 빠뜨리면 나중에 고생할 수 있습니다.

  1. 모든 입력값에 Zod 스키마 적용: Server Actions의 매개변수는 전부 외부 입력으로 취급하고, 유효성을 반드시 검사하세요.
  2. 인증 확인 필수: 공개 액션이 아니라면, 항상 세션부터 확인합니다.
  3. 권한(Authorization) 검사: 인증된 사용자라도 해당 리소스에 접근 권한이 있는지 확인해야 해요. 게시물 수정 시 작성자 본인인지 체크하는 게 대표적입니다.
  4. Rate Limiting 적용: 악의적인 대량 요청을 막기 위해 요청 빈도를 제한하세요.
  5. 민감한 데이터 노출 방지: Server Action에서 반환하는 데이터에 민감한 정보가 섞이지 않도록 합니다. 반환 객체는 클라이언트에서 그대로 볼 수 있거든요.
  6. SQL 인젝션 방지: ORM을 쓰거나, 직접 쿼리 시에는 파라미터 바인딩을 사용합니다.
  7. CSRF 보호: Next.js가 자동으로 CSRF 토큰을 처리해주지만, 커스텀 구현 시에는 직접 대응해야 합니다.
  8. 에러 메시지에 내부 정보 미포함: 스택 트레이스나 DB 구조 같은 정보가 사용자에게 노출되지 않게 주의하세요.

성능 최적화 팁

마지막으로, Server Actions 성능을 끌어올리기 위한 핵심 전략들을 정리해봤습니다.

  • 데이터 조회에 Server Actions 쓰지 마세요: Server Actions는 POST 요청을 사용하기 때문에 캐시가 안 됩니다. 데이터 조회는 서버 컴포넌트를 활용하세요.
  • 선택적 캐시 무효화: revalidatePath('/', 'layout')으로 전체 사이트를 날리는 대신, revalidateTag로 필요한 데이터만 정밀하게 무효화합니다.
  • 낙관적 업데이트 적극 활용: 서버 응답을 기다리지 않고 즉시 UI를 업데이트해서 체감 성능을 높이세요.
  • 병렬 처리: 서로 독립적인 작업은 Promise.all로 병렬 실행하면 전체 응답 시간이 확 줄어듭니다.
  • 파일 분리: Server Actions를 별도 파일로 분리하면 사용되지 않는 액션이 클라이언트 번들에 포함되지 않아서 번들 크기 최적화에 도움이 됩니다.

마무리

Next.js Server Actions는 풀스택 웹 개발 방식을 근본적으로 바꾸고 있습니다. 클라이언트와 서버의 경계를 자연스럽게 이어주면서도 타입 안전성과 보안을 지킬 수 있는 정말 강력한 도구예요.

이 가이드에서 다룬 핵심을 정리하면 이렇습니다:

  • 'use server' 지시어를 통한 Server Actions 정의와 파일 분리 전략
  • Zod를 활용한 서버 측 입력 유효성 검사의 중요성
  • 인증과 권한 확인을 위한 계층적 보안 패턴
  • useActionStateuseFormStatus로 UX 개선하기
  • revalidatePathrevalidateTag를 통한 정밀한 캐시 무효화
  • useOptimistic으로 낙관적 업데이트 구현하기
  • 점진적 향상의 기본 지원
  • 실전 댓글 시스템 프로젝트

Server Actions를 제대로 활용하면, 별도의 API 계층 없이도 안전하고 빠른 풀스택 앱을 만들 수 있습니다. 보안은 항상 최우선으로 두고, 위의 체크리스트를 참고해서 프로덕션 수준의 코드를 작성해보세요.

저자 소개 Editorial Team

Our team of expert writers and editors.