🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Next.js Server Actions 폼 처리 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 10. 30. · 16 Views

Next.js Server Actions 폼 처리 완벽 가이드

Next.js 14+에서 도입된 Server Actions를 활용한 폼 처리 방법을 단계별로 알아봅니다. 클라이언트 사이드 JavaScript 없이도 안전하고 효율적인 폼 처리를 구현하는 방법을 실전 예제와 함께 제공합니다.


목차

  1. Server Actions 기초 - 서버에서 직접 처리하는 폼
  2. 폼 컴포넌트에서 Server Actions 사용 - 직접 연결하기
  3. useFormState로 폼 상태 관리 - 에러와 성공 처리
  4. useFormStatus로 제출 상태 표시 - 로딩 인디케이터
  5. Zod를 활용한 서버 검증 - 타입 안전한 폼 처리
  6. 파일 업로드 처리 - 이미지와 첨부 파일 다루기
  7. 낙관적 업데이트 구현 - 즉각적인 UI 반응
  8. 에러 처리와 재시도 로직 - 견고한 폼 만들기
  9. 인증과 권한 검증 - 보안 강화하기

1. Server Actions 기초 - 서버에서 직접 처리하는 폼

시작하며

여러분이 회원가입 폼을 만들 때 이런 상황을 겪어본 적 있나요? API 엔드포인트를 만들고, fetch 함수로 요청을 보내고, 로딩 상태를 관리하고, 에러 처리를 따로 구현하고...

생각보다 복잡한 과정이 많습니다. 이런 문제는 실제 개발 현장에서 자주 발생합니다.

폼 하나를 처리하기 위해 클라이언트와 서버 코드를 오가며 작성해야 하고, 보안 문제도 신경 써야 합니다. 코드가 여러 파일에 분산되어 유지보수가 어려워지죠.

바로 이럴 때 필요한 것이 Next.js Server Actions입니다. 폼 처리 로직을 서버에서 직접 실행하면서도 마치 클라이언트 함수를 호출하듯 간단하게 사용할 수 있습니다.

개요

간단히 말해서, Server Actions는 서버에서 실행되는 비동기 함수로, 클라이언트 컴포넌트에서 직접 호출할 수 있는 Next.js의 혁신적인 기능입니다. 왜 이 개념이 필요할까요?

기존에는 폼을 처리하기 위해 API 라우트를 만들고, fetch로 요청을 보내고, CSRF 토큰을 관리해야 했습니다. 예를 들어, 사용자 프로필 업데이트 폼을 만들 때 클라이언트 검증, 서버 API, 데이터베이스 업데이트까지 코드가 3-4개 파일에 흩어지는 경우가 많습니다.

기존에는 클라이언트에서 이벤트 핸들러를 작성하고 fetch를 호출했다면, 이제는 'use server' 지시문을 사용해 서버 함수를 직접 폼 action에 연결할 수 있습니다. 별도의 API 엔드포인트가 필요 없습니다.

Server Actions의 핵심 특징은 크게 세 가지입니다. 첫째, 서버에서만 실행되므로 데이터베이스 접근이나 민감한 로직을 안전하게 처리할 수 있습니다.

둘째, 자동으로 직렬화되어 클라이언트-서버 간 데이터 전송이 간편합니다. 셋째, Progressive Enhancement를 지원해 JavaScript가 비활성화되어도 폼이 작동합니다.

이러한 특징들이 개발 생산성과 사용자 경험을 동시에 향상시킵니다.

코드 예제

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

export async function updateProfile(formData: FormData) {
  // FormData에서 값 추출
  const name = formData.get('name') as string
  const email = formData.get('email') as string

  // 서버 사이드 검증
  if (!name || name.length < 2) {
    return { error: '이름은 2글자 이상이어야 합니다' }
  }

  // 데이터베이스 업데이트 (예시)
  await db.user.update({
    where: { id: getCurrentUserId() },
    data: { name, email }
  })

  // 캐시 재검증
  revalidatePath('/profile')
  return { success: true }
}

설명

이것이 하는 일: Server Actions는 서버에서만 실행되는 안전한 함수를 정의하고, 클라이언트 컴포넌트의 폼에서 직접 호출할 수 있게 해줍니다. 첫 번째로, 파일 상단의 'use server' 지시문이 이 파일의 모든 export된 함수를 Server Actions로 만듭니다.

이렇게 하면 Next.js가 자동으로 이 함수들을 서버 엔드포인트로 변환하고, 클라이언트에서는 안전하게 호출할 수 있는 래퍼 함수를 생성합니다. 코드가 번들에 포함되지 않아 클라이언트 번들 크기도 줄어듭니다.

그 다음으로, updateProfile 함수가 실행되면서 FormData 객체를 받아옵니다. 브라우저의 기본 폼 제출 메커니즘을 활용하기 때문에 JavaScript 없이도 작동하며, 점진적 향상(Progressive Enhancement)을 지원합니다.

formData.get()으로 각 필드 값을 추출하고, 타입을 명시적으로 캐스팅합니다. 검증 로직은 서버에서 실행되므로 클라이언트에서 우회할 수 없습니다.

데이터베이스 업데이트도 서버에서 직접 수행하므로 API 키나 데이터베이스 연결 정보가 클라이언트에 노출되지 않습니다. 마지막으로, revalidatePath를 호출해 해당 경로의 캐시를 무효화하고 최신 데이터를 보여줍니다.

여러분이 이 코드를 사용하면 API 라우트를 별도로 만들 필요가 없고, fetch 호출이나 에러 처리 로직도 간소화됩니다. 보안적으로도 더 안전하고, TypeScript 타입 안정성도 유지됩니다.

무엇보다 코드가 한 곳에 모여 있어 유지보수가 훨씬 쉬워집니다.

실전 팁

💡 'use server'는 파일 최상단에 작성하거나, async 함수 내부 첫 줄에 작성할 수 있습니다. 파일 단위로 사용하면 여러 Server Actions를 한 곳에서 관리하기 편리합니다.

💡 FormData 대신 일반 객체를 받고 싶다면 Zod 같은 스키마 검증 라이브러리와 함께 사용하세요. 타입 안정성과 검증을 동시에 해결할 수 있습니다.

💡 에러가 발생하면 객체를 반환하는 대신 throw를 사용할 수도 있지만, 에러 바운더리 처리가 필요합니다. 명시적인 에러 객체 반환이 더 예측 가능한 코드를 만듭니다.

💡 민감한 작업(결제, 삭제 등)은 반드시 서버에서 사용자 권한을 다시 확인하세요. 클라이언트의 인증 정보만 믿지 마세요.

💡 revalidatePath 대신 revalidateTag를 사용하면 더 세밀한 캐시 관리가 가능합니다. 여러 페이지에 영향을 주는 데이터는 태그 기반으로 관리하세요.


2. 폼 컴포넌트에서 Server Actions 사용 - 직접 연결하기

시작하며

여러분이 로그인 폼을 만들 때마다 onSubmit 핸들러를 작성하고, preventDefault를 호출하고, fetch를 작성하는 것이 반복 작업처럼 느껴진 적 있나요? 매번 비슷한 보일러플레이트 코드를 작성하는 것은 비효율적입니다.

이런 문제는 특히 여러 폼을 다루는 대시보드나 관리자 페이지에서 두드러집니다. 각 폼마다 로딩 상태, 에러 처리, 성공 메시지를 관리하다 보면 코드가 금세 복잡해집니다.

useState와 useEffect가 난무하고, 코드 가독성도 떨어지죠. 바로 이럴 때 필요한 것이 폼의 action 속성에 Server Actions를 직접 연결하는 방법입니다.

HTML의 기본 기능을 활용하면서도 React의 강력함을 그대로 유지할 수 있습니다.

개요

간단히 말해서, 폼의 action 속성에 Server Actions 함수를 직접 전달하면, Next.js가 자동으로 폼 제출을 처리하고 서버 함수를 실행합니다. 왜 이 방법이 강력할까요?

전통적인 React 폼 처리에서는 onSubmit 이벤트를 수동으로 처리해야 했습니다. 예를 들어, 댓글 작성 폼을 만들 때 이벤트 핸들러, fetch 호출, 로딩 상태 관리, 에러 처리까지 모두 직접 구현해야 했죠.

코드가 길어지고 실수하기도 쉬웠습니다. 기존에는 e.preventDefault()로 기본 동작을 막고 fetch를 직접 호출했다면, 이제는 폼의 action 속성에 서버 함수를 바로 넣기만 하면 됩니다.

Next.js가 나머지를 알아서 처리해줍니다. 이 방식의 핵심 특징은 세 가지입니다.

첫째, Progressive Enhancement가 자동으로 적용되어 JavaScript가 로드되기 전에도 폼이 작동합니다. 둘째, useFormState나 useFormStatus 같은 React 훅으로 상태 관리가 간편합니다.

셋째, 서버 컴포넌트에서도 클라이언트 컴포넌트에서도 모두 사용 가능합니다. 이러한 특징들이 개발자 경험을 크게 향상시킵니다.

코드 예제

// app/components/CommentForm.tsx
import { createComment } from '@/app/actions/comments'

export default function CommentForm({ postId }: { postId: string }) {
  return (
    <form action={createComment} className="space-y-4">
      {/* hidden input으로 추가 데이터 전달 */}
      <input type="hidden" name="postId" value={postId} />

      <textarea
        name="content"
        placeholder="댓글을 입력하세요"
        required
        className="w-full p-2 border rounded"
      />

      <button type="submit" className="px-4 py-2 bg-blue-500 text-white rounded">
        댓글 작성
      </button>
    </form>
  )
}

설명

이것이 하는 일: 폼 컴포넌트에서 Server Actions를 action 속성으로 연결하면, 사용자가 제출 버튼을 클릭할 때 자동으로 서버 함수가 실행됩니다. 첫 번째로, createComment 함수를 import해서 폼의 action 속성에 직접 전달합니다.

이것이 가능한 이유는 Next.js가 빌드 타임에 Server Actions를 특별한 엔드포인트로 변환하기 때문입니다. 클라이언트에서는 이 함수가 실제로는 POST 요청을 보내는 래퍼 함수로 작동하지만, 개발자는 그냥 함수처럼 사용할 수 있습니다.

그 다음으로, hidden input으로 postId를 전달합니다. Server Actions는 FormData를 받기 때문에, 함수 매개변수로 전달하고 싶은 값들을 hidden input으로 포함시킬 수 있습니다.

이 방식은 클로저를 사용하는 것보다 더 명시적이고 관리하기 쉽습니다. 폼이 제출되면 브라우저는 자동으로 FormData 객체를 생성하고, Next.js는 이를 서버로 전송합니다.

JavaScript가 활성화되어 있으면 페이지 새로고침 없이 비동기로 처리되고, 비활성화되어 있으면 전통적인 폼 제출 방식으로 동작합니다. 이것이 Progressive Enhancement의 핵심입니다.

여러분이 이 패턴을 사용하면 코드가 훨씬 간결해집니다. onSubmit 핸들러도, preventDefault도, fetch 호출도 필요 없습니다.

TypeScript를 사용하면 Server Actions의 타입이 자동으로 추론되어 타입 안정성도 보장됩니다. 무엇보다 코드를 읽는 사람이 "이 폼이 무엇을 하는지" 바로 이해할 수 있습니다.

실전 팁

💡 여러 매개변수를 전달하려면 bind() 메서드를 사용하세요. action={createComment.bind(null, postId)} 형태로 작성하면 함수에 미리 인자를 바인딩할 수 있습니다.

💡 폼 검증은 HTML5 기본 속성(required, pattern, min, max)을 최대한 활용하세요. 클라이언트 검증과 서버 검증을 모두 구현해야 하지만, HTML 속성은 공짜입니다.

💡 action 속성은 문자열 URL도 받을 수 있지만, Server Actions를 사용하면 타입 안정성과 자동 완성의 이점을 누릴 수 있습니다.

💡 formAction 속성을 사용하면 하나의 폼에서 버튼별로 다른 액션을 실행할 수 있습니다. "저장"과 "저장 후 발행" 같은 시나리오에 유용합니다.


3. useFormState로 폼 상태 관리 - 에러와 성공 처리

시작하며

여러분이 폼을 제출한 후 "저장 중입니다"라는 메시지를 보여주고, 성공하면 "저장되었습니다"를, 실패하면 에러 메시지를 보여주고 싶을 때 어떻게 하시나요? useState를 여러 개 만들고 복잡한 로직을 작성하시나요?

이런 상황은 모든 폼에서 반복됩니다. 로딩 상태, 에러 상태, 성공 메시지, 각각을 관리하다 보면 컴포넌트가 금세 복잡해집니다.

특히 여러 필드에서 개별 에러를 보여줘야 할 때는 상태 관리가 더욱 까다로워집니다. 바로 이럴 때 필요한 것이 useFormState 훅입니다.

Server Actions의 반환값을 자동으로 상태로 관리하고, 폼 제출 전후의 상태를 우아하게 처리할 수 있습니다.

개요

간단히 말해서, useFormState는 Server Actions의 실행 결과를 React 상태로 자동 관리해주는 React 19의 새로운 훅입니다. 왜 이 훅이 필요할까요?

폼을 제출하고 나면 사용자에게 피드백을 줘야 합니다. 예를 들어, 회원가입 폼에서 이메일이 중복되었다는 에러를 보여주거나, 프로필 업데이트가 성공했다는 메시지를 보여줘야 합니다.

기존에는 이런 상태들을 수동으로 관리해야 했죠. 기존에는 useState로 error와 success 상태를 따로 만들고, try-catch로 에러를 잡아서 setState를 호출했다면, 이제는 useFormState가 Server Actions의 반환값을 자동으로 상태에 저장해줍니다.

코드가 훨씬 선언적으로 변합니다. useFormState의 핵심 특징은 네 가지입니다.

첫째, Server Actions의 반환값을 자동으로 상태로 관리합니다. 둘째, 초기 상태를 설정할 수 있어 타입 안정성이 보장됩니다.

셋째, 폼이 제출되지 않은 초기 상태와 제출 후 상태를 명확히 구분할 수 있습니다. 넷째, TypeScript와 완벽하게 통합되어 타입 추론이 자동으로 됩니다.

이러한 특징들이 폼 상태 관리를 간소화합니다.

코드 예제

// app/components/SignupForm.tsx
'use client'
import { useFormState } from 'react-dom'
import { signup } from '@/app/actions/auth'

const initialState = { message: '', errors: {} }

export default function SignupForm() {
  const [state, formAction] = useFormState(signup, initialState)

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <input name="email" type="email" placeholder="이메일" />
        {state.errors?.email && (
          <p className="text-red-500 text-sm">{state.errors.email}</p>
        )}
      </div>

      <div>
        <input name="password" type="password" placeholder="비밀번호" />
        {state.errors?.password && (
          <p className="text-red-500 text-sm">{state.errors.password}</p>
        )}
      </div>

      <button type="submit">가입하기</button>

      {state.message && (
        <p className="text-green-500">{state.message}</p>
      )}
    </form>
  )
}

설명

이것이 하는 일: useFormState는 Server Actions를 래핑하여 실행 결과를 컴포넌트 상태로 만들고, 폼에서 사용할 수 있는 새로운 action 함수를 반환합니다. 첫 번째로, useFormState를 호출하면서 signup Server Actions와 초기 상태를 전달합니다.

이 훅은 [state, formAction] 튜플을 반환하는데, state는 가장 최근 실행 결과이고, formAction은 폼의 action 속성에 전달할 함수입니다. 초기 상태를 명시하면 TypeScript가 state의 타입을 정확히 추론할 수 있습니다.

그 다음으로, 폼의 action 속성에 원본 signup 함수가 아닌 formAction을 전달합니다. 이렇게 하면 Next.js가 폼 제출 시 자동으로 signup을 실행하고, 반환값을 state에 저장합니다.

여러분은 setState를 직접 호출할 필요가 없습니다. Server Actions인 signup 함수는 { message: string, errors: Record<string, string> } 형태의 객체를 반환합니다.

에러가 있으면 errors 객체에 필드별 에러 메시지를 담고, 성공하면 message에 성공 메시지를 담습니다. 이 반환값이 자동으로 state가 되어, 조건부 렌더링으로 UI에 표시할 수 있습니다.

여러분이 이 패턴을 사용하면 폼 상태 관리 코드가 극적으로 줄어듭니다. useState, useEffect, try-catch가 모두 사라지고, 선언적인 코드만 남습니다.

에러 처리도 명시적이고 예측 가능해집니다. Server Actions의 반환 타입을 일관되게 유지하면, 여러 폼에서 재사용 가능한 패턴을 만들 수 있습니다.

실전 팁

💡 Server Actions는 반드시 첫 번째 매개변수로 prevState를 받아야 합니다. 타입은 (prevState: State, formData: FormData) => Promise<State> 형태여야 useFormState와 호환됩니다.

💡 initialState의 구조를 일관되게 유지하세요. { success: boolean, message: string, errors: Record<string, string> } 같은 표준 형태를 프로젝트 전체에서 사용하면 좋습니다.

💡 Zod 스키마 검증과 함께 사용하면 타입 안정성과 검증을 동시에 해결할 수 있습니다. error.flatten()으로 필드별 에러를 쉽게 추출할 수 있습니다.

💡 낙관적 업데이트(Optimistic Update)가 필요하면 useOptimistic 훅과 함께 사용하세요. UI가 즉시 반응하고, 서버 응답을 기다립니다.

💡 state가 변경되면 컴포넌트가 리렌더링되므로, 에러 메시지가 자동으로 나타나고 사라집니다. 별도의 타이머 로직이 필요 없습니다.


4. useFormStatus로 제출 상태 표시 - 로딩 인디케이터

시작하며

여러분이 "저장" 버튼을 클릭했는데 아무 반응이 없으면 어떤 느낌이 드나요? 정말 요청이 전송되고 있는지, 버튼이 고장난 건지 불안해집니다.

사용자 경험에서 피드백은 필수입니다. 이런 문제는 특히 네트워크가 느린 환경에서 더 두드러집니다.

사용자가 버튼을 여러 번 클릭하거나, 페이지를 떠나버리기도 합니다. 로딩 상태를 제대로 보여주지 않으면 사용자는 불안감을 느낍니다.

바로 이럴 때 필요한 것이 useFormStatus 훅입니다. 폼이 제출 중인지 자동으로 감지하고, 버튼을 비활성화하거나 로딩 스피너를 보여줄 수 있습니다.

개요

간단히 말해서, useFormStatus는 현재 폼의 제출 상태를 알려주는 React 19의 훅으로, 로딩 인디케이터를 쉽게 구현할 수 있게 해줍니다. 왜 이 훅이 중요할까요?

폼이 제출되는 동안 사용자에게 시각적 피드백을 줘야 합니다. 예를 들어, 결제 폼에서 "결제 중..." 메시지를 보여주고 버튼을 비활성화하지 않으면, 사용자가 여러 번 클릭해서 중복 결제가 발생할 수 있습니다.

이런 상황을 방지하려면 정확한 로딩 상태 관리가 필수입니다. 기존에는 useState로 isSubmitting 같은 상태를 만들고, 폼 제출 시작과 끝에 수동으로 setState를 호출했다면, 이제는 useFormStatus가 자동으로 폼의 제출 상태를 추적합니다.

폼이 제출되면 pending이 true가 되고, 완료되면 false로 돌아옵니다. useFormStatus의 핵심 특징은 세 가지입니다.

첫째, 폼의 제출 상태를 자동으로 추적하므로 수동 상태 관리가 불필요합니다. 둘째, 폼 내부의 어떤 컴포넌트에서든 호출할 수 있어 컴포넌트 분리가 쉽습니다.

셋째, pending, data, method, action 등 다양한 정보를 제공합니다. 이러한 특징들이 사용자 경험을 크게 개선합니다.

코드 예제

// app/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={`px-4 py-2 rounded ${
        pending
          ? 'bg-gray-400 cursor-not-allowed'
          : 'bg-blue-500 hover:bg-blue-600'
      } text-white`}
    >
      {pending ? (
        <span className="flex items-center gap-2">
          <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
            <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
            <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
          </svg>
          처리 중...
        </span>
      ) : (
        children
      )}
    </button>
  )
}

설명

이것이 하는 일: useFormStatus는 가장 가까운 부모 폼의 제출 상태를 감지하고, pending, data, method 등의 정보를 제공하여 UI를 동적으로 변경할 수 있게 합니다. 첫 번째로, useFormStatus를 호출하면 현재 폼의 상태를 담은 객체가 반환됩니다.

가장 중요한 속성은 pending으로, 폼이 제출 중이면 true, 아니면 false입니다. 이 훅은 반드시 폼 내부의 컴포넌트에서 호출해야 작동하며, 폼 외부에서 호출하면 항상 기본값을 반환합니다.

그 다음으로, pending 값에 따라 버튼의 disabled 속성과 스타일을 조건부로 변경합니다. disabled={pending}으로 설정하면 제출 중에는 버튼이 비활성화되어 중복 제출을 방지할 수 있습니다.

배경색도 회색으로 바꾸고 커서를 not-allowed로 변경해서 시각적으로 명확하게 만듭니다. 마지막으로, children을 조건부 렌더링으로 교체합니다.

제출 중이 아닐 때는 원래 버튼 텍스트(예: "저장")를 보여주고, 제출 중일 때는 로딩 스피너와 "처리 중..." 텍스트를 보여줍니다. SVG 애니메이션으로 회전하는 스피너를 만들어 사용자에게 명확한 피드백을 제공합니다.

여러분이 이 패턴을 사용하면 재사용 가능한 SubmitButton 컴포넌트를 만들 수 있습니다. 모든 폼에서 이 컴포넌트를 사용하면 일관된 UX를 제공하고, 중복 제출 문제도 자동으로 해결됩니다.

코드도 간결하고 선언적이어서 유지보수가 쉽습니다. useState로 수동 관리할 때보다 훨씬 적은 코드로 더 나은 경험을 만들 수 있습니다.

실전 팁

💡 useFormStatus는 반드시 폼 내부 컴포넌트에서 호출해야 합니다. SubmitButton을 별도 컴포넌트로 분리하면 여러 폼에서 재사용할 수 있습니다.

💡 pending 외에도 data 속성으로 제출되는 FormData를 확인할 수 있습니다. 특정 필드 값에 따라 다른 로딩 메시지를 보여줄 수 있습니다.

💡 여러 제출 버튼이 있는 폼에서는 action 속성으로 어떤 액션이 실행 중인지 확인할 수 있습니다. "저장"과 "삭제" 버튼을 구분해서 처리하세요.

💡 접근성을 위해 aria-busy={pending} 속성을 추가하고, 스크린 리더를 위한 시각적으로 숨겨진 텍스트를 제공하세요.

💡 로딩 스피너 대신 Skeleton UI나 Progress Bar를 사용하면 더 세련된 UX를 만들 수 있습니다. 특히 오래 걸리는 작업에 유용합니다.


5. Zod를 활용한 서버 검증 - 타입 안전한 폼 처리

시작하며

여러분이 폼 데이터를 검증할 때 if문을 여러 개 나열하고 있나요? "이메일 형식이 맞는지", "비밀번호가 8자 이상인지", "필수 필드가 비어있지 않은지" 일일이 체크하는 것은 지루하고 실수하기 쉽습니다.

이런 문제는 폼이 복잡해질수록 더 심각해집니다. 검증 로직이 여기저기 흩어지고, 에러 메시지도 일관성이 없어집니다.

새로운 필드를 추가할 때마다 검증 코드를 수정하는 것도 번거롭죠. TypeScript를 사용해도 런타임 검증은 따로 작성해야 합니다.

바로 이럴 때 필요한 것이 Zod 스키마 검증입니다. 타입 정의와 런타임 검증을 하나로 통합하고, Server Actions와 완벽하게 결합하여 안전한 폼 처리를 구현할 수 있습니다.

개요

간단히 말해서, Zod는 TypeScript 우선 스키마 검증 라이브러리로, 런타임 타입 체크와 상세한 에러 메시지를 제공합니다. 왜 Zod가 Server Actions와 찰떡궁합일까요?

FormData는 기본적으로 모든 값이 string이나 File 타입입니다. 예를 들어, 나이를 입력받는 필드에서 formData.get('age')를 호출하면 문자열 "25"를 받습니다.

이를 숫자로 변환하고, 유효한 범위인지 검증하고, 에러 메시지를 생성하는 모든 과정을 수동으로 작성하는 것은 비효율적입니다. 기존에는 각 필드마다 수동으로 타입 변환하고, if문으로 검증하고, 에러 객체를 수동으로 만들었다면, 이제는 Zod 스키마를 정의하면 한 번에 해결됩니다.

z.object()로 스키마를 정의하고, parse()를 호출하기만 하면 타입 변환, 검증, 에러 생성이 자동으로 처리됩니다. Zod의 핵심 특징은 네 가지입니다.

첫째, TypeScript 타입을 스키마에서 자동으로 추론할 수 있어 타입 중복 정의가 필요 없습니다. 둘째, 체이닝 방식으로 복잡한 검증 규칙을 읽기 쉽게 작성할 수 있습니다.

셋째, 커스텀 에러 메시지를 각 규칙마다 설정할 수 있습니다. 넷째, transform과 refine으로 복잡한 비즈니스 로직도 우아하게 처리할 수 있습니다.

이러한 특징들이 Server Actions의 안전성을 크게 높입니다.

코드 예제

// app/actions/product.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'

// Zod 스키마 정의
const ProductSchema = z.object({
  name: z.string().min(2, '상품명은 2글자 이상이어야 합니다'),
  price: z.coerce.number().positive('가격은 0보다 커야 합니다'),
  category: z.enum(['electronics', 'clothing', 'food'], {
    errorMap: () => ({ message: '올바른 카테고리를 선택하세요' })
  }),
  stock: z.coerce.number().int().nonnegative('재고는 0 이상이어야 합니다'),
  description: z.string().max(500, '설명은 500자 이하여야 합니다').optional()
})

export async function createProduct(prevState: any, formData: FormData) {
  // FormData를 객체로 변환
  const rawData = Object.fromEntries(formData)

  // Zod로 검증
  const result = ProductSchema.safeParse(rawData)

  if (!result.success) {
    // 검증 실패 시 필드별 에러 반환
    return {
      errors: result.error.flatten().fieldErrors,
      message: '입력값을 확인해주세요'
    }
  }

  // 검증 성공 - 타입 안전한 데이터 사용
  const product = result.data
  await db.product.create({ data: product })

  revalidatePath('/products')
  return { message: '상품이 등록되었습니다' }
}

설명

이것이 하는 일: Zod 스키마를 정의하고 Server Actions에서 safeParse()를 호출하면, 타입 변환, 검증, 에러 수집이 자동으로 처리되어 타입 안전한 데이터를 얻을 수 있습니다. 첫 번째로, ProductSchema를 z.object()로 정의합니다.

각 필드는 체이닝 메서드로 상세한 검증 규칙을 설정할 수 있습니다. z.coerce.number()는 문자열을 자동으로 숫자로 변환하고, min(), max(), positive() 같은 메서드로 제약 조건을 추가합니다.

각 규칙마다 커스텀 에러 메시지를 설정할 수 있어 사용자 친화적인 피드백을 제공할 수 있습니다. 그 다음으로, FormData를 Object.fromEntries()로 일반 객체로 변환합니다.

Zod는 일반 JavaScript 객체를 검증하므로, FormData를 직접 전달할 수는 없습니다. 변환된 객체를 ProductSchema.safeParse()에 전달하면, 검증이 실행되고 성공 여부와 함께 결과가 반환됩니다.

검증이 실패하면 result.success가 false가 되고, result.error에 상세한 에러 정보가 담깁니다. error.flatten().fieldErrors를 호출하면 { name: ['에러메시지'], price: ['에러메시지'] } 형태의 객체를 얻을 수 있어, useFormState와 함께 사용하기 완벽합니다.

검증이 성공하면 result.data에는 타입이 완벽히 보장된 데이터가 들어있습니다. TypeScript가 자동으로 product의 타입을 추론하므로, 자동 완성과 타입 체크가 작동합니다.

여러분이 이 패턴을 사용하면 검증 코드가 선언적이고 읽기 쉬워집니다. 새로운 필드를 추가할 때도 스키마만 수정하면 되고, 타입 안정성도 자동으로 유지됩니다.

에러 메시지도 일관되고 명확합니다. 무엇보다 런타임에 발생할 수 있는 타입 관련 버그를 사전에 방지할 수 있습니다.

Zod와 Server Actions의 조합은 현대적인 폼 처리의 표준이라고 할 수 있습니다.

실전 팁

💡 z.coerce를 사용하면 문자열을 자동으로 다른 타입으로 변환합니다. FormData의 모든 값이 문자열이므로 coerce는 필수입니다.

💡 refine()과 superRefine()으로 복잡한 커스텀 검증을 추가할 수 있습니다. 예를 들어, "비밀번호 확인" 필드가 "비밀번호"와 일치하는지 검증할 수 있습니다.

💡 스키마에서 타입을 추출하려면 z.infer<typeof ProductSchema>를 사용하세요. 스키마와 타입이 항상 동기화됩니다.

💡 parse() 대신 safeParse()를 사용하면 에러가 throw되지 않아 더 안전합니다. 프로덕션에서는 항상 safeParse()를 권장합니다.

💡 환경 변수나 API 응답 검증에도 Zod를 사용하세요. 런타임 타입 안정성을 프로젝트 전체에 적용할 수 있습니다.


6. 파일 업로드 처리 - 이미지와 첨부 파일 다루기

시작하며

여러분이 프로필 사진 업로드 기능을 만들 때 얼마나 많은 것을 신경 써야 하나요? 파일 크기 제한, 이미지 포맷 검증, 스토리지 저장, URL 생성까지...

생각보다 복잡한 작업이 많습니다. 이런 문제는 파일 업로드가 포함된 모든 폼에서 발생합니다.

단순히 파일을 받는 것만으로는 부족하고, 보안 검증, 파일 크기 체크, 적절한 저장 위치 선택 등을 모두 고려해야 합니다. 잘못 처리하면 서버 용량이 부족해지거나 보안 문제가 발생할 수 있습니다.

바로 이럴 때 필요한 것이 Server Actions를 활용한 안전한 파일 업로드 처리입니다. FormData에서 File 객체를 추출하고, 검증하고, 클라우드 스토리지에 저장하는 전체 과정을 서버에서 안전하게 처리할 수 있습니다.

개요

간단히 말해서, Server Actions에서는 FormData의 get() 메서드로 File 객체를 받아서, 서버 사이드에서 안전하게 검증하고 저장할 수 있습니다. 왜 파일 업로드를 서버에서 처리해야 할까요?

클라이언트에서 파일을 직접 클라우드 스토리지로 업로드하는 방법도 있지만, 보안 문제가 있습니다. 예를 들어, 사용자가 악의적인 파일을 업로드하거나, 거대한 파일로 서버 용량을 채울 수 있습니다.

클라이언트는 쉽게 조작될 수 있으므로, 서버에서 반드시 재검증해야 합니다. 기존에는 multer 같은 미들웨어를 설정하고, API 라우트를 만들고, multipart/form-data를 파싱하는 복잡한 과정이 필요했다면, 이제는 Server Actions에서 FormData를 직접 받아서 File 객체를 추출할 수 있습니다.

Next.js가 파일 파싱을 자동으로 처리해줍니다. 파일 업로드의 핵심 단계는 다섯 가지입니다.

첫째, FormData에서 File 객체를 추출합니다. 둘째, 파일 타입과 크기를 서버에서 검증합니다.

셋째, 고유한 파일명을 생성하여 충돌을 방지합니다. 넷째, 클라우드 스토리지나 로컬 파일 시스템에 저장합니다.

다섯째, 접근 가능한 URL을 생성하여 데이터베이스에 저장합니다. 이러한 단계들을 Server Actions 내부에서 모두 처리하면 안전하고 간단합니다.

코드 예제

// app/actions/upload.ts
'use server'
import { put } from '@vercel/blob'
import { revalidatePath } from 'next/cache'

const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']

export async function uploadProfileImage(prevState: any, formData: FormData) {
  const file = formData.get('avatar') as File

  // 파일 존재 확인
  if (!file || file.size === 0) {
    return { error: '파일을 선택해주세요' }
  }

  // 파일 크기 검증
  if (file.size > MAX_FILE_SIZE) {
    return { error: '파일 크기는 5MB 이하여야 합니다' }
  }

  // 파일 타입 검증
  if (!ALLOWED_TYPES.includes(file.type)) {
    return { error: 'JPG, PNG, WebP 파일만 업로드 가능합니다' }
  }

  try {
    // Vercel Blob Storage에 업로드
    const blob = await put(file.name, file, {
      access: 'public',
      addRandomSuffix: true // 파일명 충돌 방지
    })

    // 데이터베이스에 URL 저장
    await db.user.update({
      where: { id: getCurrentUserId() },
      data: { avatarUrl: blob.url }
    })

    revalidatePath('/profile')
    return { success: true, url: blob.url }
  } catch (error) {
    return { error: '업로드 중 오류가 발생했습니다' }
  }
}

설명

이것이 하는 일: Server Actions에서 FormData의 File 객체를 받아 서버 사이드 검증을 수행하고, 클라우드 스토리지에 업로드한 후 URL을 반환합니다. 첫 번째로, formData.get('avatar')로 File 객체를 추출합니다.

FormData에서 파일은 File 타입으로 제공되며, name, size, type 같은 메타데이터를 포함합니다. as File로 타입을 캐스팅하면 TypeScript가 File의 속성에 접근할 수 있게 됩니다.

file.size === 0 체크는 사용자가 파일을 선택하지 않은 경우를 감지합니다. 그 다음으로, 파일 크기와 타입을 검증합니다.

클라이언트에서도 검증할 수 있지만, 서버 검증은 필수입니다. 클라이언트 검증은 쉽게 우회될 수 있기 때문입니다.

MAX_FILE_SIZE와 ALLOWED_TYPES를 상수로 정의하면 유지보수가 쉽고, 여러 곳에서 재사용할 수 있습니다. 검증에 실패하면 명확한 에러 메시지와 함께 바로 반환합니다.

검증을 통과하면 실제 업로드를 수행합니다. Vercel Blob Storage의 put() 함수는 File 객체를 직접 받아서 업로드하고, 공개 URL을 반환합니다.

addRandomSuffix 옵션은 같은 이름의 파일이 업로드되어도 충돌이 발생하지 않도록 랜덤 접미사를 추가합니다. 업로드가 성공하면 반환된 URL을 데이터베이스에 저장하고, 프로필 페이지의 캐시를 재검증합니다.

여러분이 이 패턴을 사용하면 파일 업로드가 안전하고 간단해집니다. 모든 검증과 저장 로직이 서버에서 실행되므로 클라이언트를 신뢰할 필요가 없습니다.

try-catch로 에러를 처리하면 네트워크 문제나 스토리지 오류에도 우아하게 대응할 수 있습니다. 클라우드 스토리지를 사용하면 서버 디스크 용량 걱정도 없고, CDN을 통해 빠른 이미지 로딩도 가능합니다.

실전 팁

💡 이미지 파일은 Sharp 라이브러리로 서버에서 리사이징하세요. 원본 파일을 그대로 저장하면 대역폭과 스토리지가 낭비됩니다.

💡 파일명에 타임스탬프나 UUID를 추가하면 캐싱 문제를 방지할 수 있습니다. 같은 URL이라도 새로 업로드된 파일을 즉시 볼 수 있습니다.

💡 여러 파일을 업로드하려면 formData.getAll('files')을 사용하세요. File 배열을 반환하므로 Promise.all()로 병렬 업로드할 수 있습니다.

💡 S3, Cloudinary, Vercel Blob 등 다양한 스토리지 옵션이 있습니다. 프로젝트 규모와 비용을 고려해 선택하세요.

💡 업로드 전에 파일의 MIME 타입뿐만 아니라 실제 파일 시그니처(Magic Bytes)도 검증하면 보안이 더욱 강화됩니다.


7. 낙관적 업데이트 구현 - 즉각적인 UI 반응

시작하며

여러분이 좋아요 버튼을 클릭했을 때 서버 응답을 기다리는 1-2초가 답답하게 느껴진 적 있나요? 현대 웹 애플리케이션에서 사용자는 즉각적인 반응을 기대합니다.

클릭하면 바로 UI가 변해야 자연스럽습니다. 이런 문제는 특히 네트워크가 느린 환경에서 두드러집니다.

사용자가 버튼을 여러 번 클릭하거나, "이게 작동하는 건가?" 하고 의심하게 됩니다. 서버 응답을 기다리는 동안 UI가 멈춰있으면 사용자 경험이 크게 저하됩니다.

바로 이럴 때 필요한 것이 낙관적 업데이트(Optimistic Update)입니다. 서버 응답을 기다리지 않고 UI를 먼저 업데이트하고, 백그라운드에서 서버와 동기화하는 방식으로 즉각적인 반응을 제공할 수 있습니다.

개요

간단히 말해서, 낙관적 업데이트는 서버 요청이 성공할 것이라고 "낙관적으로" 가정하고 UI를 먼저 업데이트한 후, 실제 서버 응답을 받아 최종 상태를 확정하는 UX 패턴입니다. 왜 이 패턴이 중요할까요?

사용자는 자신의 행동에 즉각적인 피드백을 원합니다. 예를 들어, 트위터에서 좋아요를 누르면 하트가 즉시 빨갛게 변합니다.

서버 응답을 기다리지 않습니다. 이런 즉각성이 앱을 빠르고 반응적으로 느끼게 만듭니다.

대부분의 경우 서버 요청은 성공하므로, 미리 UI를 업데이트해도 문제가 없습니다. 기존에는 서버 응답을 받은 후에야 setState를 호출해서 UI를 업데이트했다면, 이제는 React의 useOptimistic 훅을 사용해 서버 요청 전에 UI를 먼저 업데이트할 수 있습니다.

요청이 실패하면 자동으로 롤백됩니다. 낙관적 업데이트의 핵심 특징은 네 가지입니다.

첫째, UI가 즉시 반응하여 사용자 경험이 크게 향상됩니다. 둘째, 네트워크 지연이 체감되지 않습니다.

셋째, 요청이 실패하면 자동으로 이전 상태로 롤백됩니다. 넷째, Server Actions와 완벽하게 통합되어 구현이 간단합니다.

이러한 특징들이 최신 웹 앱의 표준 UX를 만듭니다.

코드 예제

// app/components/LikeButton.tsx
'use client'
import { useOptimistic } from 'react'
import { toggleLike } from '@/app/actions/likes'

export function LikeButton({ postId, initialLikes, isLiked }: {
  postId: string
  initialLikes: number
  isLiked: boolean
}) {
  const [optimisticState, addOptimistic] = useOptimistic(
    { likes: initialLikes, isLiked },
    (state, newIsLiked: boolean) => ({
      likes: state.likes + (newIsLiked ? 1 : -1),
      isLiked: newIsLiked
    })
  )

  async function handleLike() {
    // UI를 즉시 업데이트 (낙관적)
    addOptimistic(!optimisticState.isLiked)

    // 백그라운드에서 서버 요청
    await toggleLike(postId)
  }

  return (
    <button
      onClick={handleLike}
      className={`flex items-center gap-2 ${
        optimisticState.isLiked ? 'text-red-500' : 'text-gray-500'
      }`}
    >
      <span>{optimisticState.isLiked ? '❤️' : '🤍'}</span>
      <span>{optimisticState.likes}</span>
    </button>
  )
}

설명

이것이 하는 일: useOptimistic 훅은 서버 요청 전에 UI를 미리 업데이트할 수 있는 임시 상태를 제공하고, 실제 서버 응답을 받으면 최종 상태로 전환합니다. 첫 번째로, useOptimistic을 호출하면서 초기 상태와 업데이트 함수를 전달합니다.

초기 상태는 서버에서 받아온 실제 데이터(initialLikes, isLiked)입니다. 두 번째 인자는 낙관적 업데이트를 어떻게 계산할지 정의하는 함수입니다.

좋아요를 누르면 카운트를 1 증가시키고, 취소하면 1 감소시킵니다. 그 다음으로, handleLike 함수에서 addOptimistic()을 호출합니다.

이것이 핵심입니다. 이 함수를 호출하는 순간, UI가 즉시 업데이트됩니다.

하트 아이콘이 빨갛게 변하고, 숫자가 증가합니다. 사용자는 0.001초의 지연도 느끼지 못합니다.

그 다음 줄에서 await toggleLike(postId)로 실제 서버 요청을 보냅니다. 서버 요청이 완료되면 컴포넌트가 리렌더링되면서 서버의 실제 데이터로 상태가 업데이트됩니다.

대부분의 경우 낙관적 상태와 실제 상태가 일치하므로 사용자는 아무것도 느끼지 못합니다. 만약 서버 요청이 실패하면(네트워크 오류, 권한 없음 등), React가 자동으로 이전 상태로 롤백하고, 에러 바운더리나 에러 토스트로 사용자에게 알릴 수 있습니다.

여러분이 이 패턴을 사용하면 앱이 네이티브 앱처럼 빠르게 느껴집니다. 좋아요, 북마크, 팔로우 같은 간단한 토글 액션에 완벽합니다.

코드도 매우 간결하고 선언적입니다. useOptimistic이 모든 복잡한 상태 관리를 처리해주므로, 여러분은 비즈니스 로직에만 집중할 수 있습니다.

사용자는 즉각적인 피드백을 받고, 개발자는 간단한 코드를 유지할 수 있습니다.

실전 팁

💡 낙관적 업데이트는 실패 확률이 낮은 작업에만 사용하세요. 결제나 삭제 같은 중요한 작업은 서버 응답을 기다리는 것이 안전합니다.

💡 서버 요청이 실패하면 토스트 메시지로 사용자에게 알리세요. "좋아요 실패, 다시 시도해주세요" 같은 메시지가 필요합니다.

💡 useOptimistic의 업데이트 함수는 순수 함수여야 합니다. 외부 상태를 변경하거나 사이드 이프팩트를 일으키면 안 됩니다.

💡 여러 사용자가 동시에 수정하는 데이터는 낙관적 업데이트가 적합하지 않을 수 있습니다. 충돌 해결 전략이 필요합니다.

💡 낙관적 업데이트와 함께 useTransition을 사용하면 더 세밀한 로딩 상태 관리가 가능합니다.


8. 에러 처리와 재시도 로직 - 견고한 폼 만들기

시작하며

여러분이 폼을 제출했는데 네트워크 오류로 실패했다면 어떻게 하시나요? 사용자가 모든 내용을 다시 입력해야 한다면 매우 좌절스러울 것입니다.

입력한 데이터가 날아가는 것만큼 짜증나는 일도 없습니다. 이런 문제는 실제 프로덕션 환경에서 자주 발생합니다.

네트워크가 불안정하거나, 서버가 일시적으로 과부하 상태이거나, API 제한에 걸릴 수 있습니다. 사용자에게 명확한 에러 메시지를 보여주고, 재시도할 수 있는 옵션을 제공하지 않으면 이탈률이 높아집니다.

바로 이럴 때 필요한 것이 체계적인 에러 처리와 재시도 로직입니다. Server Actions에서 다양한 에러 상황을 처리하고, 사용자에게 명확한 피드백을 제공하며, 필요시 자동 재시도를 구현할 수 있습니다.

개요

간단히 말해서, 에러 처리는 Server Actions에서 발생할 수 있는 다양한 실패 상황을 포착하고, 사용자가 이해할 수 있는 메시지로 변환하여 적절히 대응하는 것입니다. 왜 에러 처리가 중요할까요?

프로덕션 환경에서는 예상치 못한 일들이 항상 발생합니다. 예를 들어, 데이터베이스 연결이 끊기거나, 외부 API가 응답하지 않거나, 사용자가 이미 삭제된 데이터를 수정하려고 할 수 있습니다.

이런 상황에서 "Error: Network Error" 같은 기술적 메시지를 보여주면 사용자는 무엇을 해야 할지 모릅니다. 기존에는 try-catch를 사용하지만 에러를 제대로 분류하지 않아 모든 에러에 "오류가 발생했습니다" 같은 동일한 메시지를 보여줬다면, 이제는 에러 타입별로 적절한 메시지와 복구 방법을 제공해야 합니다.

네트워크 에러는 재시도 버튼을, 권한 에러는 로그인 안내를, 검증 에러는 수정 가이드를 보여줘야 합니다. 에러 처리의 핵심 패턴은 다섯 가지입니다.

첫째, try-catch로 모든 에러를 포착합니다. 둘째, 에러 타입을 분류하여 적절한 메시지를 생성합니다.

셋째, 에러 정보를 구조화된 객체로 반환하여 클라이언트에서 처리할 수 있게 합니다. 넷째, 일시적 오류는 자동으로 재시도합니다.

다섯째, 심각한 에러는 로깅하여 모니터링합니다. 이러한 패턴들이 사용자 경험과 시스템 안정성을 동시에 향상시킵니다.

코드 예제

// app/actions/order.ts
'use server'
import { revalidatePath } from 'next/cache'

// 재시도 유틸리티 함수
async function retryOperation<T>(
  operation: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await operation()
    } catch (error) {
      if (i === maxRetries - 1) throw error
      await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)))
    }
  }
  throw new Error('Max retries exceeded')
}

export async function createOrder(prevState: any, formData: FormData) {
  try {
    const productId = formData.get('productId') as string

    // 재시도 가능한 작업
    const result = await retryOperation(async () => {
      // 재고 확인
      const product = await db.product.findUnique({
        where: { id: productId }
      })

      if (!product) {
        throw new Error('PRODUCT_NOT_FOUND')
      }

      if (product.stock < 1) {
        throw new Error('OUT_OF_STOCK')
      }

      // 주문 생성 (외부 결제 API 호출)
      const order = await paymentAPI.createOrder({
        productId,
        userId: getCurrentUserId()
      })

      return order
    })

    revalidatePath('/orders')
    return { success: true, orderId: result.id }

  } catch (error: any) {
    // 에러 타입별 처리
    if (error.message === 'PRODUCT_NOT_FOUND') {
      return { error: '상품을 찾을 수 없습니다' }
    }

    if (error.message === 'OUT_OF_STOCK') {
      return { error: '재고가 부족합니다' }
    }

    if (error.code === 'ECONNREFUSED') {
      return {
        error: '서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요',
        retryable: true
      }
    }

    // 예상치 못한 에러 - 로깅
    console.error('Order creation failed:', error)
    return { error: '주문 처리 중 오류가 발생했습니다' }
  }
}

설명

이것이 하는 일: Server Actions에서 발생하는 다양한 에러를 체계적으로 처리하고, 사용자 친화적인 메시지로 변환하며, 필요시 자동 재시도를 수행합니다. 첫 번째로, retryOperation 유틸리티 함수를 정의합니다.

이 함수는 네트워크 오류나 일시적인 서버 문제처럼 재시도하면 성공할 수 있는 작업을 자동으로 재시도합니다. 지수 백오프(exponential backoff) 패턴을 사용해서 첫 번째 재시도는 1초 후, 두 번째는 2초 후, 세 번째는 3초 후에 실행됩니다.

이렇게 하면 서버에 과부하를 주지 않으면서도 일시적 오류를 복구할 수 있습니다. 그 다음으로, 비즈니스 로직을 실행하면서 예상 가능한 에러는 명시적으로 throw합니다.

예를 들어, 상품이 없으면 'PRODUCT_NOT_FOUND', 재고가 없으면 'OUT_OF_STOCK' 같은 커스텀 에러 메시지를 던집니다. 이렇게 하면 catch 블록에서 에러 타입을 쉽게 구분할 수 있습니다.

일반적인 Error 객체에 커스텀 message를 설정하는 것만으로도 충분합니다. catch 블록에서는 에러 메시지나 코드를 검사해서 적절한 사용자 메시지를 반환합니다.

기술적인 에러 메시지("ECONNREFUSED")를 사용자가 이해할 수 있는 메시지("서버에 연결할 수 없습니다")로 변환합니다. retryable: true 플래그를 추가하면 클라이언트에서 "다시 시도" 버튼을 보여줄 수 있습니다.

예상치 못한 에러는 콘솔에 로깅하고, 일반적인 에러 메시지를 반환합니다. 여러분이 이 패턴을 사용하면 앱이 훨씬 견고해집니다.

일시적인 네트워크 문제로 인한 실패율이 크게 감소하고, 사용자는 명확한 피드백을 받습니다. 에러 모니터링도 쉬워져서 어떤 에러가 자주 발생하는지 추적할 수 있습니다.

프로덕션 환경에서 에러 처리는 선택이 아닌 필수입니다.

실전 팁

💡 Sentry나 LogRocket 같은 에러 모니터링 도구를 연동하세요. 사용자가 겪는 에러를 실시간으로 파악할 수 있습니다.

💡 재시도 로직은 멱등성(idempotency)이 보장되는 작업에만 사용하세요. 결제 같은 작업은 재시도하면 중복 처리될 수 있습니다.

💡 에러 메시지에는 사용자가 취할 수 있는 행동을 포함하세요. "네트워크를 확인하고 다시 시도해주세요" 같은 구체적인 안내가 좋습니다.

💡 민감한 에러 정보(스택 트레이스, 데이터베이스 정보)는 절대 클라이언트에 반환하지 마세요. 보안 위험이 있습니다.

💡 에러 타입별로 다른 HTTP 상태 코드를 반환하면 API 모니터링과 디버깅이 쉬워집니다.


9. 인증과 권한 검증 - 보안 강화하기

시작하며

여러분이 만든 폼을 악의적인 사용자가 조작한다면 어떻게 되나요? 다른 사람의 프로필을 수정하거나, 관리자 권한이 필요한 작업을 수행할 수 있다면 큰 보안 문제가 됩니다.

이런 문제는 클라이언트 사이드 검증만으로는 절대 막을 수 없습니다. 브라우저 개발자 도구를 열면 클라이언트 코드를 쉽게 수정할 수 있습니다.

사용자 ID를 다른 값으로 바꾸거나, 권한 체크를 우회하는 것은 기술적으로 어렵지 않습니다. 서버에서 반드시 재검증해야 합니다.

바로 이럴 때 필요한 것이 Server Actions 내부의 인증과 권한 검증입니다. 모든 민감한 작업을 수행하기 전에 서버에서 사용자의 신원과 권한을 확인하여 보안을 강화할 수 있습니다.

개요

간단히 말해서, 인증(Authentication)은 "당신이 누구인지"를 확인하는 것이고, 권한 검증(Authorization)은 "당신이 이 작업을 할 수 있는지"를 확인하는 것입니다. 왜 Server Actions에서 이런 검증이 필요할까요?

Server Actions는 클라이언트에서 직접 호출할 수 있기 때문에, 누구나 함수를 호출할 수 있습니다. 예를 들어, deleteUser Server Actions를 만들었다면, 악의적인 사용자가 브라우저 콘솔에서 직접 호출을 시도할 수 있습니다.

클라이언트의 UI에서 버튼을 숨기는 것만으로는 충분하지 않습니다. 기존에는 API 라우트에서 미들웨어로 인증을 처리했다면, Server Actions에서는 각 함수 내부에서 직접 인증 상태를 확인해야 합니다.

세션, JWT, OAuth 등 어떤 인증 방식을 사용하든, Server Actions 실행 전에 반드시 검증 단계를 거쳐야 합니다. 인증과 권한 검증의 핵심 패턴은 네 가지입니다.

첫째, 모든 Server Actions 시작 부분에서 인증 상태를 확인합니다. 둘째, 작업을 수행할 권한이 있는지 역할 기반으로 검증합니다.

셋째, 리소스 소유권을 확인하여 다른 사용자의 데이터를 수정하지 못하게 합니다. 넷째, 실패 시 명확한 에러 메시지를 반환하되, 보안에 민감한 정보는 노출하지 않습니다.

이러한 패턴들이 애플리케이션의 보안을 보장합니다.

코드 예제

// lib/auth.ts
import { cookies } from 'next/headers'

export async function getCurrentUser() {
  const sessionCookie = cookies().get('session')
  if (!sessionCookie) return null

  // 세션 검증 및 사용자 정보 조회
  const user = await db.user.findUnique({
    where: { sessionToken: sessionCookie.value },
    select: { id: true, email: true, role: true }
  })

  return user
}

// app/actions/post.ts
'use server'
import { getCurrentUser } from '@/lib/auth'
import { revalidatePath } from 'next/cache'

export async function deletePost(postId: string) {
  // 1. 인증 확인
  const user = await getCurrentUser()
  if (!user) {
    return { error: '로그인이 필요합니다' }
  }

  // 2. 리소스 조회
  const post = await db.post.findUnique({
    where: { id: postId },
    select: { authorId: true }

#Next.js#ServerActions#FormHandling#useFormState#Validation#React

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

마이크로서비스 배포 완벽 가이드

Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.

Application Load Balancer 완벽 가이드

AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.

고객 상담 AI 시스템 완벽 구축 가이드

AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.

에러 처리와 폴백 완벽 가이드

AWS API 호출 시 발생하는 에러를 처리하고 폴백 전략을 구현하는 방법을 다룹니다. ThrottlingException부터 서킷 브레이커 패턴까지, 실전에서 바로 활용할 수 있는 안정적인 에러 처리 기법을 배웁니다.

AWS Bedrock 인용과 출처 표시 완벽 가이드

AWS Bedrock의 Citation 기능을 활용하여 AI 응답의 신뢰도를 높이는 방법을 배웁니다. 출처 추출부터 UI 표시, 검증까지 실무에서 바로 사용할 수 있는 완전한 가이드입니다.