테마 전환

Next.js Server Actions로 폼 처리와 검증 구현하기

Easton editorial illustration: API gateway workstation

컴퓨터 앞에 앉아 사용자 가입 폼 코드를 보고 있습니다. 폴더에는 이미 폼 컴포넌트, API Route, 타입 정의, 오류 처리 등 네 개의 파일이 쌓였습니다. 간단한 폼 제출 하나를 처리하려고 거의 200줄의 코드를 작성한 셈입니다.

더 간단한 방법은 없을까요?

답은 Server Actions입니다. Next.js App Router의 이 기능을 사용하면 폼 처리 과정을 80% 줄일 수 있습니다. API Route를 작성할 필요도, 직접 fetch할 필요도, 번거로운 상태 관리를 할 필요도 없습니다. 듣기에는 좋지만 이런 의문이 생길 수 있습니다. 정말 안전할까요? 검증은 어떻게 할까요? 로딩 상태는 어떻게 처리할까요?

처음 사용할 때 저도 같은 점이 걱정됐습니다. 몇 달 동안 사용하며 시행착오를 겪고 얻은 경험을 바탕으로, 기본 제출부터 Zod 검증, 보안, 사용자 경험 개선까지 Next.js Server Actions로 폼을 처리하는 방법을 실제 코드와 함께 살펴보겠습니다.

Server Actions 기초

Server Actions란?

Server Actions는 서버에서 실행되는 비동기 함수입니다. 함수에 'use server'를 표시하면 폼의 action 속성에서 바로 사용할 수 있습니다. 폼을 제출하면 이 함수가 자동으로 호출되며 데이터 처리, 데이터베이스 작업, 캐시 갱신 등이 모두 서버에서 이뤄집니다.

주요 특징은 다음과 같습니다.

  • 타입 안전성: TypeScript가 전체 흐름의 타입을 검사합니다.
  • 설정 불필요: /api 폴더를 만들 필요가 없습니다.
  • 자동 처리: FormData가 자동으로 전달됩니다.

Server Action은 컴포넌트 안에 직접 작성하거나(인라인) 별도 파일에 둘 수 있습니다(모듈 단위).

// 방법 1: 컴포넌트 안에 인라인으로 작성
export default function Page() {
  async function createUser(formData: FormData) {
    'use server' // Server Action으로 표시
    const name = formData.get('name')
    // 데이터 처리...
  }

  return <form action={createUser}>...</form>
}

// 방법 2: 별도 파일(권장)
// app/actions.ts
'use server' // 파일 단위 표시

export async function createUser(formData: FormData) {
  const name = formData.get('name')
  // 데이터 처리...
}

Server Actions와 기존 API Routes의 차이는 무엇이고, 각각 언제 사용해야 할까요?

표로 비교해 보겠습니다.

특징Server ActionsAPI Routes
용도폼 제출, 데이터 변경RESTful API, 외부 호출
HTTP 메서드POST만 지원GET/POST/PUT/DELETE 등 지원
타입 안전성기본으로 제공타입을 직접 정의해야 함
호출 방식함수 직접 호출fetch 요청
적합한 상황내부 로직, 폼공개 API, 서드파티 연동
코드 양적음상대적으로 많음

간단히 말해 내부에는 Server Actions를, 외부에는 API Routes를 사용합니다. 애플리케이션 내부의 폼만 처리한다면 Server Actions로 충분합니다. 다른 시스템에 인터페이스를 제공하거나 GET 요청이 필요하다면 API Routes를 사용해야 합니다.

Vercel의 2025년 조사에 따르면 개발자의 63%가 이미 프로덕션 환경에서 Server Actions를 사용하고 있습니다. 더 이상 실험적인 기능이 아닙니다.

"개발자의 63%가 이미 프로덕션 환경에서 Server Actions를 사용하고 있습니다."

첫 번째 Server Actions 예제

가장 간단한 로그인 폼부터 코드로 살펴보겠습니다.

// app/login/page.tsx
export default function LoginPage() {
  async function handleLogin(formData: FormData) {
    'use server' // 서버 함수로 표시

    // 폼에서 데이터 가져오기
    const email = formData.get('email') as string
    const password = formData.get('password') as string

    // 로그인 로직 처리(여기서는 단순화한 예시)
    console.log('로그인 시도:', email)

    // 실제 프로젝트에서는 여기서 사용자를 검증하고 token 등을 생성합니다.
  }

  return (
    <form action={handleLogin}>
      <input
        type="email"
        name="email"
        placeholder="이메일"
        required
      />
      <input
        type="password"
        name="password"
        placeholder="비밀번호"
        required
      />
      <button type="submit">로그인</button>
    </form>
  )
}

이게 전부입니다. 핵심은 세 가지입니다.

  1. 'use server': 이 함수를 서버에서 실행하라고 Next.js에 알립니다.
  2. formData.get(): 필드의 name 속성으로 값을 가져옵니다.
  3. action={handleLogin}: 폼을 제출할 때 함수를 자동으로 호출합니다.

제출 버튼을 누르면 브라우저가 페이지를 새로고침하지 않고 데이터를 서버로 보내 처리합니다. 기존 방식에서 필요했던 여러 fetch, useState, 오류 처리 코드를 줄일 수 있습니다.

하지만 이는 가장 기본적인 예시일 뿐입니다. 실제 프로젝트에서는 검증, 오류 표시, 로딩 상태 처리도 필요합니다. 이어서 살펴보겠습니다.

폼 검증 실전

Zod로 폼 검증하기

클라이언트의 required 속성만 믿으면 안 됩니다. 사용자는 브라우저 개발자 도구만 열어도 이 검증을 우회할 수 있습니다. 서버 검증은 필수입니다.

이때 Zod를 사용할 수 있습니다. 서버에서 데이터 형식을 검증하고 문제가 있으면 즉시 오류를 반환해 잘못된 데이터가 데이터베이스에 들어가는 것을 막습니다.

먼저 Zod를 설치합니다.

npm install zod

그다음 검증 규칙을 정의합니다.

// app/actions.ts
'use server'

import { z } from 'zod'

// 검증 schema 정의
const SignupSchema = z.object({
  name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
  email: z.string().email('이메일 형식이 올바르지 않습니다'),
  password: z.string().min(8, '비밀번호는 8자 이상이어야 합니다'),
})

export async function signup(formData: FormData) {
  // FormData에서 데이터 추출
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  }

  // 데이터 검증
  const result = SignupSchema.safeParse(rawData)

  // 검증 실패 시 오류 반환
  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors, // 필드 단위 오류
    }
  }

  // 검증을 통과하면 비즈니스 로직 처리
  const { name, email, password } = result.data

  // 사용자 생성, 데이터베이스 저장 등...
  console.log('사용자 생성:', { name, email })

  return {
    success: true,
    message: '가입이 완료되었습니다!',
  }
}

핵심은 다음과 같습니다.

  1. safeParse는 예외를 던지지 않습니다: 실패 시 { success: false, error: ... }를 반환하므로 오류를 깔끔하게 처리할 수 있습니다.
  2. flatten().fieldErrors: 검증 오류를 { name: ['오류1'], email: ['오류2'] } 형식으로 바꿔 표시하기 쉽게 만듭니다.
  3. 구조화된 데이터 반환: success 플래그와 오류 정보를 반환하면 클라이언트가 결과에 맞게 화면을 표시할 수 있습니다.

아직 한 가지 문제가 남았습니다. 폼에 이 오류를 어떻게 표시할까요? useActionState가 필요합니다.

검증 오류 표시: useActionState

useActionState는 React 19에서 도입된 Hook으로, 이전 이름은 useFormState였습니다. Server Actions가 반환하는 상태를 처리하도록 만들어졌습니다. 이 Hook은 다음 기능을 제공합니다.

  • 서버가 반환한 데이터를 컴포넌트 상태에 저장합니다.
  • 감싼 action 함수를 제공합니다.
  • 폼이 제출 중인지 알려 줍니다.

코드부터 보겠습니다.

// app/signup/page.tsx
'use client' // Hook을 사용하려면 클라이언트 컴포넌트로 표시해야 합니다.

import { useActionState } from 'react'
import { signup } from '@/app/actions'

export default function SignupPage() {
  // 초기 상태 정의
  const initialState = { success: false, errors: {}, message: '' }

  // useActionState는 Server Action과 초기 상태를 받습니다.
  const [state, formAction, isPending] = useActionState(signup, initialState)

  return (
    <form action={formAction}> {/* 원래 action 대신 formAction 사용 */}
      <div>
        <label>이름</label>
        <input
          type="text"
          name="name"
          required
        />
        {/* 필드 오류 표시 */}
        {state.errors?.name && (
          <p className="error">{state.errors.name[0]}</p>
        )}
      </div>

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

      <div>
        <label>비밀번호</label>
        <input
          type="password"
          name="password"
          required
        />
        {state.errors?.password && (
          <p className="error">{state.errors.password[0]}</p>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? '제출 중...' : '가입'}
      </button>

      {/* 성공 메시지 표시 */}
      {state.success && (
        <p className="success">{state.message}</p>
      )}
    </form>
  )
}

처리 흐름은 다음과 같습니다.

  1. 사용자가 폼 제출 → signup 호출
  2. 서버 검증 실패 → { success: false, errors: {...} } 반환
  3. useActionState가 이 결과를 state에 저장
  4. 컴포넌트가 다시 렌더링되어 오류 정보 표시

isPending은 폼 제출 중에 true이고 완료되면 false가 됩니다. 이 값으로 버튼을 비활성화하고 로딩 문구를 표시할 수 있습니다.

검증에 실패하면 사용자가 입력한 내용이 사라진다는 점을 눈치챘을 수 있습니다. 폼 데이터를 유지하려면 반환값에 values 필드를 추가하고 입력 필드의 defaultValue로 설정하면 됩니다. 여기서는 자세히 다루지 않겠습니다. 중요한 점은 useActionState클라이언트 컴포넌트와 Server Actions를 연결해 상태 관리를 단순화한다는 것입니다.

사용자 경험 개선

로딩 상태와 중복 제출 방지

앞에서는 isPending으로 로딩 상태를 표시했습니다. 하지만 useFormStatus라는 Hook도 있습니다. 두 Hook은 헷갈리기 쉽고 저도 처음에는 그랬습니다.

간단히 구분하면 다음과 같습니다.

  • useActionStateisPending: 폼 컴포넌트 안에서 사용하기 좋습니다.
  • useFormStatuspending: 폼의 자식 컴포넌트(예: 제출 버튼) 안에서 사용하기 좋습니다.

useFormStatus에는 제약이 있습니다. <form>의 자식 컴포넌트에서 호출해야 하며 폼 컴포넌트 자체에서는 직접 사용할 수 없습니다. 다소 번거롭게 들리지만 버튼을 독립 컴포넌트로 분리해 재사용할 수 있다는 장점이 있습니다.

제출 버튼을 분리한 예시입니다.

// components/SubmitButton.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus() // 폼 제출 상태 가져오기

  return (
    <button
      type="submit"
      disabled={pending}
      className={pending ? 'loading' : ''}
    >
      {pending ? '제출 중...' : children}
    </button>
  )
}

폼에서는 바로 사용할 수 있습니다.

// app/signup/page.tsx
'use client'

import { useActionState } from 'react'
import { signup } from '@/app/actions'
import { SubmitButton } from '@/components/SubmitButton'

export default function SignupPage() {
  const [state, formAction] = useActionState(signup, { success: false, errors: {} })

  return (
    <form action={formAction}>
      {/* 폼 필드... */}

      <SubmitButton>가입</SubmitButton> {/* 로딩 상태 자동 처리 */}

      {state.errors?.general && (
        <p className="error">{state.errors.general}</p>
      )}
    </form>
  )
}

이렇게 하면 버튼의 로딩 로직이 완전히 캡슐화됩니다. 제출 중에는 다음과 같이 동작합니다.

  • 버튼이 자동으로 비활성화되어 중복 제출을 막습니다.
  • 문구가 ‘제출 중…’으로 바뀝니다.
  • 회전 애니메이션도 추가할 수 있습니다.

pendingisPending은 무엇이 다를까요?

특징useActionStateisPendinguseFormStatuspending
호출 위치폼 컴포넌트 내부폼의 자식 컴포넌트 내부
적합한 상황폼 전체 상태에 접근해야 할 때제출 상태만 필요한 독립 버튼
유연성state와 pending을 함께 가져올 수 있음pending만 가져올 수 있음

실제 프로젝트에서는 대체로 다음과 같이 사용합니다.

  • 폼 로직이 복잡하고 여러 상태를 처리해야 함 → useActionState
  • 범용 제출 버튼만 필요함 → useFormStatus

점진적 향상

Server Actions에는 흥미로운 특징이 하나 있습니다. 점진적 향상을 지원합니다. 사용자의 브라우저에서 JavaScript가 비활성화되어 있어도 폼을 제출할 수 있다는 뜻입니다.

Server Actions가 브라우저의 기본 <form> 제출 방식을 바탕으로 동작하기 때문입니다. JavaScript가 있으면 Next.js가 제출 과정을 가로채 AJAX 요청으로 처리하고, JavaScript가 없으면 기존 폼 제출 방식으로 전환됩니다.

솔직히 실제 적용 사례는 많지 않습니다. 요즘 JavaScript 없이 제대로 사용할 수 있는 사이트가 얼마나 될까요? 하지만 접근성과 크롤러 친화성에는 도움이 되며 별도 설정 없이 Next.js가 자동으로 처리합니다.

보안과 권장 방식

Server Actions의 보안

가장 놓치기 쉬운 부분입니다. Server Actions가 서버에서 실행된다는 이유만으로 자동으로 안전하다고 생각하는 경우가 많습니다. 그렇지 않습니다.

Server Actions는 본질적으로 공개 API 엔드포인트입니다. Next.js가 추측하기 어려운 ID를 생성하지만 이는 난독화일 뿐, 실제 보안 조치가 아닙니다. 기술을 아는 사람이라면 브라우저 개발자 도구에서 네트워크 요청을 확인해 Action ID를 찾고 직접 호출할 수 있습니다.

Next.js는 몇 가지 기본 보호 기능을 제공합니다.

  1. CSRF 보호: Server Actions는 POST 요청으로만 호출할 수 있으며 Origin과 Host 헤더가 일치하는지 검사합니다. 교차 사이트 요청은 거부됩니다.
  2. 안전한 Action ID: 각 Action에는 무작위 대입으로 알아내기 어려운 암호화된 ID가 있습니다.
  3. 클로저 변수 암호화: Action에서 외부 변수를 사용하면 Next.js가 이를 암호화합니다.

하지만 이것만으로는 부족합니다. 다음 조치를 반드시 직접 해야 합니다.

1. 입력 검증

클라이언트 데이터는 절대 그대로 신뢰하지 마세요. 앞에서 살펴본 Zod 검증은 필수입니다.

2. 인증

사용자가 로그인했는지 확인합니다. 권한이 필요한 모든 Action에서 신원을 검증해야 합니다.

3. 권한 검증

로그인했다고 해서 모든 권한이 있는 것은 아닙니다. 예를 들어 사용자 A가 사용자 B의 데이터를 삭제하지 못하도록 작업 권한을 검사해야 합니다.

실제 예시를 보겠습니다.

// app/actions.ts
'use server'

import { cookies } from 'next/headers'
import { z } from 'zod'

const DeletePostSchema = z.object({
  postId: z.string().min(1),
})

export async function deletePost(formData: FormData) {
  // 1. 입력 검증
  const rawData = {
    postId: formData.get('postId'),
  }

  const result = DeletePostSchema.safeParse(rawData)
  if (!result.success) {
    return { success: false, error: '올바르지 않은 요청입니다' }
  }

  const { postId } = result.data

  // 2. 인증: 사용자 로그인 여부 확인
  const cookieStore = await cookies()
  const sessionToken = cookieStore.get('session')?.value

  if (!sessionToken) {
    return { success: false, error: '먼저 로그인하세요' }
  }

  // 3. 현재 사용자 가져오기
  const currentUser = await getUserFromSession(sessionToken)
  if (!currentUser) {
    return { success: false, error: '세션이 만료되었습니다' }
  }

  // 4. 권한 검증: 이 글이 현재 사용자의 글인지 확인
  const post = await getPost(postId)
  if (!post) {
    return { success: false, error: '글이 존재하지 않습니다' }
  }

  if (post.authorId !== currentUser.id) {
    return { success: false, error: '이 글을 삭제할 권한이 없습니다' }
  }

  // 5. 작업 실행
  await deletePostFromDB(postId)

  return { success: true, message: '삭제했습니다' }
}

이 예시는 입력 검증 → 인증 → 권한 검증 → 작업 실행으로 이어지는 전체 보안 검사 과정을 보여 줍니다. 어느 하나도 빠뜨리면 안 됩니다.

유용한 도구로 next-safe-action 라이브러리도 추천합니다. 검증, 인증, 오류 처리를 공통으로 적용할 수 있는 미들웨어 기능을 제공합니다.

import { createSafeActionClient } from 'next-safe-action'

// 인증이 적용된 action 클라이언트 생성
const actionClient = createSafeActionClient({
  // 미들웨어: 사용자 로그인 상태 확인
  async middleware() {
    const session = await getSession()
    if (!session) {
      throw new Error('로그인되지 않았습니다')
    }
    return { userId: session.userId }
  },
})

// 사용할 때 인증 검사가 자동 적용됨
export const deletePost = actionClient
  .schema(DeletePostSchema)
  .action(async ({ parsedInput, ctx }) => {
    const { postId } = parsedInput
    const { userId } = ctx // 미들웨어에서 사용자 ID 가져오기

    // 삭제 작업 실행...
  })

이렇게 하면 인증이 필요한 모든 Action에서 같은 로직을 재사용할 수 있어 코드가 훨씬 간결해집니다.

기억해야 할 점은 Server Actions는 마법이 아니라 API 엔드포인트라는 사실입니다. 필요한 보안 조치를 빠짐없이 적용해야 합니다.

실전 사례: 인증이 적용된 폼

로그인한 사용자만 제출할 수 있는 댓글 폼을 전체 예제로 만들어 보겠습니다.

// app/actions.ts
'use server'

import { cookies } from 'next/headers'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'

const CommentSchema = z.object({
  postId: z.string(),
  content: z.string().min(1, '댓글 내용을 입력하세요').max(500, '댓글은 최대 500자까지 입력할 수 있습니다'),
})

export async function addComment(formData: FormData) {
  // 1. 입력 검증
  const rawData = {
    postId: formData.get('postId'),
    content: formData.get('content'),
  }

  const result = CommentSchema.safeParse(rawData)
  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }

  // 2. 인증
  const cookieStore = await cookies()
  const sessionToken = cookieStore.get('session')?.value

  if (!sessionToken) {
    return {
      success: false,
      error: '로그인한 뒤 댓글을 작성하세요',
    }
  }

  const user = await getUserFromSession(sessionToken)
  if (!user) {
    return {
      success: false,
      error: '세션이 만료되었습니다. 다시 로그인하세요',
    }
  }

  // 3. 댓글 저장
  const { postId, content } = result.data

  await saveComment({
    postId,
    content,
    authorId: user.id,
    authorName: user.name,
    createdAt: new Date(),
  })

  // 4. 댓글이 바로 표시되도록 페이지 캐시 재검증
  revalidatePath(`/posts/${postId}`)

  return {
    success: true,
    message: '댓글을 등록했습니다',
  }
}

클라이언트 컴포넌트입니다.

// app/posts/[id]/CommentForm.tsx
'use client'

import { useActionState } from 'react'
import { addComment } from '@/app/actions'
import { SubmitButton } from '@/components/SubmitButton'

export function CommentForm({ postId }: { postId: string }) {
  const [state, formAction] = useActionState(addComment, {
    success: false,
    errors: {},
  })

  return (
    <form action={formAction}>
      {/* 숨김 필드로 postId 전달 */}
      <input type="hidden" name="postId" value={postId} />

      <textarea
        name="content"
        placeholder="댓글을 입력하세요..."
        rows={4}
        required
      />

      {state.errors?.content && (
        <p className="error">{state.errors.content[0]}</p>
      )}

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

      {state.success && (
        <p className="success">{state.message}</p>
      )}

      <SubmitButton>댓글 등록</SubmitButton>
    </form>
  )
}

이 예시는 앞에서 설명한 핵심을 모두 포함합니다.

  • Zod로 입력 검증
  • 사용자 로그인 상태 확인
  • useActionState로 상태 처리
  • revalidatePath로 캐시 갱신
  • 제출 버튼에 로딩 상태 적용

프로덕션에서도 사용할 수 있는 전체 폼 처리 과정입니다.

고급 기법

추가 매개변수 전달

폼 필드 이외의 매개변수를 전달해야 할 때가 있습니다. 글을 수정할 때 폼 내용뿐 아니라 글 ID도 함께 전달해야 하는 경우가 그 예입니다.

한 가지 방법은 숨김 필드를 사용하는 것입니다.

<input type="hidden" name="postId" value={postId} />

더 깔끔한 방법은 JavaScript의 bind 메서드를 사용하는 것입니다.

// app/actions.ts
'use server'

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

  // 글 업데이트...
  await updatePostInDB(postId, { title, content })

  return { success: true }
}

클라이언트에서는 다음과 같이 호출합니다.

// app/posts/[id]/edit/page.tsx
'use client'

import { updatePost } from '@/app/actions'

export default function EditPost({ postId }: { postId: string }) {
  // bind로 postId 매개변수 연결
  const updatePostWithId = updatePost.bind(null, postId)

  return (
    <form action={updatePostWithId}>
      <input type="text" name="title" required />
      <textarea name="content" required />
      <button type="submit">업데이트</button>
    </form>
  )
}

bind(null, postId)postId를 첫 번째 매개변수로 고정한 새 함수를 만듭니다. 폼을 제출하면 FormData가 두 번째 매개변수로 전달됩니다.

수정이나 삭제처럼 ID 전달이 필요한 작업에 적합합니다.

데이터 재검증

Server Actions가 데이터를 처리하고 나면 관련 페이지의 캐시가 오래된 상태일 수 있습니다. Next.js는 캐시를 갱신하는 함수 두 가지를 제공합니다.

1. revalidatePath

경로 단위로 갱신합니다.

import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  // 글 생성...

  // 홈페이지 글 목록 갱신
  revalidatePath('/')
  // 글 상세 페이지 갱신
  revalidatePath(`/posts/${newPostId}`)

  return { success: true }
}

2. revalidateTag

태그 단위로 갱신합니다. 먼저 fetch할 때 태그를 지정해야 합니다.

// 데이터를 가져올 때 태그 지정
fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] }
})

// Server Action에서 'posts' 태그가 있는 모든 캐시 갱신
import { revalidateTag } from 'next/cache'

export async function createPost(formData: FormData) {
  // 글 생성...

  revalidateTag('posts') // 관련된 모든 캐시 갱신

  return { success: true }
}

언제 어떤 함수를 사용해야 할까요?

  • 경로가 고정되어 있고 개수가 적음revalidatePath
  • 데이터가 여러 페이지에 분산되어 있음revalidateTag

저는 간단하고 직접적인 revalidatePath를 우선 사용합니다. 하나의 작업이 여러 페이지에 영향을 줄 때만 태그 사용을 고려합니다.

낙관적 업데이트

좋아요나 저장처럼 거의 실패하지 않는 작업이 있습니다. 이런 경우에는 낙관적 업데이트를 사용할 수 있습니다. UI에 먼저 성공 결과를 표시하고 백그라운드에서 제출하는 방식입니다.

React 19는 useOptimistic Hook을 제공합니다.

'use client'

import { useOptimistic } from 'react'
import { likePost } from '@/app/actions'

export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [optimisticLikes, setOptimisticLikes] = useOptimistic(initialLikes)

  async function handleLike() {
    // UI 즉시 업데이트(낙관적 업데이트)
    setOptimisticLikes(optimisticLikes + 1)

    // 백그라운드 제출
    await likePost(postId)
  }

  return (
    <button onClick={handleLike}>
      👍 {optimisticLikes}
    </button>
  )
}

사용자가 버튼을 누르면 서버 응답을 기다리지 않고 숫자가 즉시 1 증가합니다. 반응이 빠르게 느껴집니다.

다만 성공률이 매우 높은 작업에서만 사용해야 합니다. 실패하면 UI를 롤백해야 하므로 오히려 더 복잡해질 수 있습니다.

결론

핵심을 세 가지로 정리할 수 있습니다.

  1. Server Actions는 폼 처리를 단순화하지만 만능은 아닙니다. 내부 폼에는 Server Actions를 사용하고 외부 API에는 Route Handlers를 사용해야 합니다. 모든 작업을 Server Actions로 처리하려고 하지 마세요.

  2. 보안은 직접 보장해야 합니다. 프레임워크가 제공하는 것은 기본 보호 기능뿐입니다. 입력 검증, 인증, 권한 검사 중 어느 하나도 빠뜨리면 안 됩니다. Next.js가 모두 알아서 처리해 줄 것으로 기대하지 마세요.

  3. 사용자 경험의 세부 사항이 중요합니다. 로딩 상태, 오류 안내, 낙관적 업데이트 같은 작은 차이가 애플리케이션의 완성도를 좌우합니다. useActionStateuseFormStatus를 함께 활용해 제대로 처리하세요.

가장 간단한 폼부터 시작해 보세요. Server Action을 만들고 Zod 검증과 로딩 표시를 추가하면 사용법의 80%를 익힌 셈입니다. 나머지 20%인 캐시 갱신과 낙관적 업데이트 등은 실제로 필요할 때 공식 문서를 찾아보면 됩니다.

Next.js와 React는 빠르게 발전하고 있어 Server Actions API도 바뀔 수 있습니다. 공식 문서의 업데이트를 확인해 이 글의 코드가 너무 빨리 낡지 않도록 하세요.

이제 프로젝트에서 직접 사용해 보세요. 다음에 폼 제출 코드를 작성할 때 생각보다 훨씬 간단하다는 점을 발견할 수 있을 것입니다.

Server Actions로 폼을 처리하는 전체 과정

Server Action 생성부터 검증 추가와 상태 처리까지의 전체 단계

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Server Action 생성

    app/actions.ts 파일에 Server Action을 만듭니다.

    1. 파일 단위 표시: 파일 맨 위에 'use server' 추가
    2. 함수 정의: export async function actionName(formData: FormData)
    3. 데이터 가져오기: formData.get('fieldName')으로 폼 필드 가져오기
    4. 결과 반환: { success: boolean, errors?: {}, message?: string } 형식 반환

    예시:
    ```typescript
    'use server'
    export async function signup(formData: FormData) {
    const name = formData.get('name') as string
    // 처리 로직...
    return { success: true, message: '가입 완료' }
    }
    ```
  2. 2

    Step 2: Zod 검증 추가

    Zod로 서버에서 데이터를 검증합니다.

    1. Zod 설치: npm install zod
    2. Schema 정의: const SignupSchema = z.object({ name: z.string().min(2), email: z.string().email() })
    3. 데이터 검증: const result = SignupSchema.safeParse(rawData)
    4. 오류 처리: if (!result.success) return { success: false, errors: result.error.flatten().fieldErrors }

    핵심:
    • safeParse는 예외를 던지지 않고 { success, data/error }를 반환합니다.
    • flatten().fieldErrors는 오류를 { field: ['error1'] } 형식으로 바꿉니다.
    • 검증에 실패하면 클라이언트에서 표시할 수 있는 구조화된 오류를 반환합니다.
  3. 3

    Step 3: useActionState로 상태 처리

    클라이언트 컴포넌트에서 useActionState를 사용합니다.

    1. Hook 가져오기: import { useActionState } from 'react'
    2. 초기 상태 정의: const initialState = { success: false, errors: {} }
    3. Hook 사용: const [state, formAction, isPending] = useActionState(action, initialState)
    4. 폼 연결: <form action={formAction}>
    5. 오류 표시: {state.errors?.field && <p>{state.errors.field[0]}</p>}
    6. 로딩 표시: <button disabled={isPending}>{isPending ? '제출 중...' : '제출'}</button>

    처리 흐름:
    • 사용자가 제출 → action 호출 → 결과 반환 → state 갱신 → 컴포넌트 다시 렌더링
  4. 4

    Step 4: 인증과 권한 검증 추가

    Server Action에 보안 검사를 추가합니다.

    1. 입력 검증: Zod로 모든 입력 검증
    2. 인증: session token 확인
    ```typescript
    const cookieStore = await cookies()
    const sessionToken = cookieStore.get('session')?.value
    if (!sessionToken) return { success: false, error: '먼저 로그인하세요' }
    ```
    3. 권한 검증: 작업 권한 확인
    ```typescript
    const post = await getPost(postId)
    if (post.authorId !== currentUser.id) {
    return { success: false, error: '권한 없음' }
    }
    ```
    4. 작업 실행: 모든 검사를 통과한 뒤 실제 비즈니스 로직 실행

    Server Actions는 마법이 아니므로 보안 검사를 직접 수행해야 합니다.
  5. 5

    Step 5: 사용자 경험 개선

    로딩 상태와 오류 처리를 추가합니다.

    1. useFormStatus 사용(버튼 컴포넌트에서):
    ```typescript
    'use client'
    import { useFormStatus } from 'react-dom'
    export function SubmitButton() {
    const { pending } = useFormStatus()
    return <button disabled={pending}>...</button>
    }
    ```
    2. revalidatePath로 캐시 갱신:
    ```typescript
    import { revalidatePath } from 'next/cache'
    revalidatePath('/posts')
    ```
    3. 낙관적 업데이트(선택 사항, 성공률이 높은 작업에 사용):
    ```typescript
    const [optimisticState, setOptimisticState] = useOptimistic(initialState)
    ```

    권장 방식:
    • 폼 로직이 복잡함 → useActionState 사용
    • 독립 버튼 컴포넌트 → useFormStatus 사용
    • 작업 성공 후 → 관련 페이지 캐시 갱신

FAQ

Server Actions와 API Routes는 무엇이 다르며 언제 사용해야 하나요?
Server Actions는 내부 폼 제출과 데이터 변경에 적합하고 POST만 지원하며, 타입 안전성을 갖추면서 코드 양도 적습니다. API Routes는 외부에 RESTful API를 제공하거나 GET 요청 및 서드파티 연동이 필요한 상황에 적합합니다. 간단히 말해 내부에는 Server Actions를, 외부에는 API Routes를 사용합니다.
Server Actions는 안전한가요? 어떤 보안 조치가 필요한가요?
Server Actions는 서버에서 실행되지만 보안 검사를 직접 해야 합니다. 1) 입력 검증(Zod 사용), 2) 인증(session 확인), 3) 권한 검증(작업 권한 확인)이 필요합니다. 프레임워크는 기본적인 CSRF 보호만 제공하므로 보안을 자동으로 보장한다고 생각하면 안 됩니다.
useActionState와 useFormStatus는 무엇이 다른가요?
useActionState의 isPending은 폼 컴포넌트 내부에서 사용하기 좋고 state와 pending을 함께 가져올 수 있습니다. useFormStatus의 pending은 폼의 자식 컴포넌트(예: 버튼)에서 사용해야 하며 pending 상태만 가져옵니다. 폼 로직이 복잡하면 useActionState를, 독립 버튼 컴포넌트에는 useFormStatus를 사용합니다.
폼 필드 이외의 매개변수는 어떻게 전달하나요?
두 가지 방법이 있습니다. 1) 숨김 필드 <input type="hidden" name="postId" value={postId} /> 사용, 2) bind 메서드 사용: const actionWithId = action.bind(null, postId)로 만든 뒤 <form action={actionWithId}>에 전달합니다. 더 깔끔한 bind 방식을 권장합니다.
폼 제출 후 페이지 데이터는 어떻게 갱신하나요?
revalidatePath('/posts')처럼 revalidatePath로 경로 단위 갱신을 하거나 revalidateTag로 태그 단위 갱신을 할 수 있습니다(fetch 시 먼저 태그를 지정해야 합니다). 경로가 고정되어 있고 적으면 revalidatePath를, 데이터가 여러 페이지에 분산돼 있으면 revalidateTag를 사용합니다.
낙관적 업데이트는 언제 사용해야 하나요?
낙관적 업데이트는 좋아요나 저장처럼 성공률이 매우 높은 작업에 적합합니다. useOptimistic Hook으로 UI를 즉시 갱신한 뒤 백그라운드에서 제출합니다. 실패 가능성이 있는 작업에는 권장하지 않습니다. 실패하면 UI를 롤백해야 해 오히려 복잡해지기 때문입니다.

6분 읽기 · 게시일: 2025년 12월 19일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog