Next.js 15 서버 액션으로 폼 데이터 처리하기 완벽 가이드
튜토리얼

Next.js 15 서버 액션으로 폼 데이터 처리하기 완벽 가이드

2026년 03월 12일 조회 25 댓글 0

Next.js 15 서버 액션으로 폼 처리하면 API 라우트 없이도 백엔드 로직을 구현할 수 있다는 거, 알고 계셨나요?

안녕하세요! 오늘은 Next.js 15 서버 액션으로 폼 데이터 처리하는 방법을 완전 쉽게 알려드릴게요. 저도 처음에는 "서버 액션이 뭔데?"라고 생각했거든요. 근데 막상 써보니까 정말 편하더라고요. API 라우트 만들 필요도 없고, 클라이언트-서버 통신을 신경 쓸 필요도 없어요. 2026년 현재 Next.js 15에서 서버 액션은 정말 게임 체인저예요. 폼 제출부터 데이터 검증, 에러 처리까지 한 번에 해결할 수 있거든요. 제가 실제 프로젝트에서 사용해본 노하우를 모두 공유해드릴게요!

? 이 글의 내용
→ Next.js 15 서버 액션 기본 개념과 작동 원리 → 서버 액션으로 폼 데이터 처리하는 기본 방법 → Zod를 활용한 폼 데이터 검증 가이드 → 서버 액션 에러 처리와 사용자 피드백 구현 → 실전 활용! 파일 업로드와 복잡한 폼 처리 → Next.js 15 서버 액션 사용 시 주의사항과 베스트 프랙티스

? Next.js 15 서버 액션 기본 개념과 작동 원리

서버 액션이 뭔지부터 알아볼까요? 쉽게 말해서요, 컴포넌트 안에서 직접 서버 코드를 실행할 수 있게 해주는 기능이에요. 진짜 신기하죠? 예전에는 폼 데이터를 처리하려면 API 라우트를 만들고, fetch 요청 보내고, 응답 처리하고... 이런 과정을 다 거쳐야 했거든요.

근데 서버 액션을 사용하면요? 그냥 함수 하나 만들고 'use server' 지시어만 추가하면 끝이에요. 정말 간단하죠. Next.js가 알아서 클라이언트-서버 통신을 처리해주거든요. 제가 처음 써봤을 때 "이게 되네?"라고 놀랐던 기억이 나요.

? 서버 액션의 핵심 장점
  • API 라우트 불필요: 별도의 엔드포인트 없이도 서버 로직 실행 가능해요
  • 타입 안정성: TypeScript와 완벽하게 통합되어 타입 추론이 자동으로 돼요
  • 자동 재검증: 데이터 변경 후 revalidatePath로 캐시 갱신이 쉬워요
  • 프로그레시브 인핸스먼트: JavaScript 없이도 폼이 작동해요

서버 액션은 크게 두 가지 방식으로 만들 수 있어요. 첫 번째는 인라인 서버 액션이고, 두 번째는 별도 파일로 분리하는 방식이죠. 인라인은 간단한 작업에 좋고, 파일 분리는 재사용이 필요할 때 유용해요. 저는 주로 파일을 분리하는 편인데요, 나중에 유지보수하기가 훨씬 편하더라고요.

참고로요, Next.js 15에서는 서버 액션 성능이 엄청 개선됐어요. 병렬 처리도 가능하고, 에러 핸들링도 더 명확해졌죠. 2026년 현재 프로덕션 환경에서도 안정적으로 사용할 수 있는 수준이 됐어요. 제가 운영 중인 서비스에서도 문제없이 잘 작동하고 있거든요.

? Next.js 15 폼 환경 셋업하기

server action coding
Photo by Bernd ? Dittrich on Unsplash

자, 이제 본격적으로 Next.js 15 서버 액션으로 폼 데이터를 처리하는 환경을 만들어볼게요. 사실 Next.js 15에서는 폼 처리가 이전 버전보다 훨씬 간단해졌거든요. 제가 2026년에 여러 프로젝트를 진행하면서 느낀 건데요, 초기 셋업만 제대로 해두면 나중에 정말 편해요.

근데 처음 시작할 때 뭐부터 해야 할지 막막하잖아요? 걱정 마세요. 하나씩 차근차근 해볼게요.

프로젝트 초기 설정 및 필수 패키지

먼저 Next.js 15 프로젝트가 없다면 새로 만들어야겠죠? 터미널을 열고 다음 명령어를 실행해보세요.

? 프로젝트 생성 코드
npx create-next-app@latest my-form-app
cd my-form-app
npm install zod react-hook-form @hookform/resolvers

여기서 중요한 건, zod라는 패키지예요. 이건 폼 데이터 검증을 위해 꼭 필요한 라이브러리거든요. 솔직히 말하자면 처음엔 이게 왜 필요한지 몰랐는데요, 써보니까 완전 달라요. 타입 안정성도 보장되고, 에러 메시지도 깔끔하게 관리할 수 있어요.

? 팁

react-hook-form은 선택사항이에요. 서버 액션만 쓴다면 없어도 되는데, 클라이언트 사이드 검증도 같이 하고 싶다면 설치하는 게 좋아요. 저는 두 가지를 같이 쓰는 걸 추천드려요!

디렉토리 구조 잡기

프로젝트 구조를 어떻게 잡느냐가 정말 중요해요. 제가 여러 번 시행착오를 겪어봤는데요, 다음 구조가 가장 관리하기 편했어요.

? 추천 디렉토리 구조
app/
├── actions/           # 서버 액션 모음
│   └── form-actions.ts
├── components/        # 재사용 컴포넌트
│   └── forms/
│       └── ContactForm.tsx
├── lib/              # 유틸리티 함수
│   ├── validations.ts  # Zod 스키마
│   └── types.ts       # TypeScript 타입
└── (routes)/         # 페이지 라우트
    └── contact/
        └── page.tsx

사실은요, 처음엔 모든 걸 한 파일에 때려박았었어요. 근데 프로젝트가 커지면서 완전 난장판이 됐죠. 그래서 이렇게 역할별로 분리해놨더니 관리가 엄청 편해졌거든요.

기본 폼 컴포넌트 vs 서버 액션 폼 비교

여러분 혹시 궁금하지 않으세요? 전통적인 폼 처리 방식과 Next.js 15 서버 액션을 사용한 방식이 뭐가 다른지요. 테이블로 한번 정리해볼게요.

구분 전통적인 방식 서버 액션 방식
API 엔드포인트 별도 /api 라우트 필요 불필요 (자동 생성)
코드 양 많음 (클라이언트+서버) 적음 (통합 관리)
타입 안정성 수동 관리 필요 자동 타입 추론
보안 직접 구현 필요 내장 보안 기능
JavaScript 비활성화 폼 작동 안 됨 정상 작동 (Progressive Enhancement)
에러 처리 복잡한 상태 관리 간단한 리턴값 처리

진짜 차이가 느껴지시죠? 제가 처음 서버 액션으로 바꿨을 때 놀란 게, 코드가 거의 절반으로 줄었어요. API 라우트 파일도 안 만들어도 되고, fetch 코드도 필요 없고요.

필수 환경 변수 설정하기

폼 데이터를 처리할 때 데이터베이스나 외부 API를 사용할 거잖아요? 그럼 환경 변수 설정이 필수예요. 프로젝트 루트에 .env.local 파일을 만들어주세요.

? 환경 변수 예시
# 데이터베이스
DATABASE_URL="your-database-url"

# 이메일 전송 (예: Resend)
RESEND_API_KEY="your-api-key"

# 기타 필요한 설정
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
⚠️ 주의사항

클라이언트에서 접근해야 하는 환경 변수만 NEXT_PUBLIC_ 접두사를 붙이세요. 서버 액션에서만 쓰는 건 접두사 없이 사용하면 돼요. 이거 처음엔 저도 헷갈렸는데, 보안상 정말 중요해요!

TypeScript 설정 최적화

Next.js 15에서 서버 액션을 쓸 때 TypeScript 설정도 중요해요. tsconfig.json 파일을 열어서 다음 옵션들이 제대로 설정됐는지 확인해보세요.

? 권장 TypeScript 설정
{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "moduleResolution": "bundler",
    "target": "ES2022",
    "lib": ["ES2022", "dom"]
  }
}

특히 strict 모드는 꼭 켜두세요. 처음엔 에러가 많이 나서 짜증날 수 있는데요, 나중에 보면 이게 진짜 큰 도움이 돼요. 런타임 에러를 미리 잡아주거든요.

개발 도구 및 확장 프로그램 추천

개발 환경을 좀 더 편하게 만들어줄 도구들도 소개할게요. VS Code 쓰시는 분들이 대부분이실 텐데요, 다음 확장 프로그램들을 설치해보세요.

확장 프로그램 용도 필수 여부
ES7+ React/Redux/React-Native snippets 컴포넌트 자동완성 필수
Prettier - Code formatter 코드 포맷팅 필수
ESLint 코드 품질 검사 필수
Error Lens 에러 즉시 표시 권장
Tailwind CSS IntelliSense Tailwind 자동완성 권장

Error Lens는 개인적으로 진짜 강추예요. 에러가 생기면 바로바로 줄 옆에 표시해주거든요. 디버깅 시간이 엄청 줄어들었어요.

✅ 셋업 완료 체크리스트
  • Next.js 15 프로젝트 생성 완료
  • 필수 패키지 설치 (zod, react-hook-form)
  • 디렉토리 구조 정리
  • 환경 변수 설정
  • TypeScript 설정 최적화
  • VS Code 확장 프로그램 설치

자, 이제 기본적인 폼 환경 셋업은 다 끝났어요! 생각보다 간단하죠? 2026년 현재, Next.js 15의 서버 액션은 정말 잘 다듬어져 있어서 초기 설정만 제대로 해두면 나머지는 술술 풀려요. 다음 단계에서는 본격적으로 서버 액션을 만들어볼 건데요, 지금까지 준비한 환경이 빛을 발하게 될 거예요.

?️ 데이터 유효성 검증 전략

web form development
Photo by Safar Safarov on Unsplash

Next.js 15 서버 액션에서 폼 데이터 처리할 때 가장 중요한 게 바로 데이터 유효성 검증이에요. 사실 클라이언트에서 검증했다고 해서 안심하면 안 되거든요. 누군가 개발자 도구로 조작할 수도 있잖아요? 그래서 서버 측 검증이 진짜 핵심이에요.

2026년 현재 가장 많이 쓰이는 방법들을 하나씩 살펴볼게요.

Zod로 스키마 기반 검증하기

제가 가장 추천하는 방법이에요. Zod는 타입스크립트와 찰떡궁합이거든요. 스키마를 한 번 정의하면 타입 안정성까지 보장받을 수 있어요.

? Zod 스키마 예시
import { z } from 'zod';

const userSchema = z.object({
  email: z.string().email('올바른 이메일을 입력해주세요'),
  password: z.string()
    .min(8, '비밀번호는 최소 8자 이상이어야 해요')
    .regex(/[A-Z]/, '대문자를 하나 이상 포함해야 해요')
    .regex(/[0-9]/, '숫자를 하나 이상 포함해야 해요'),
  age: z.number()
    .min(14, '14세 이상만 가입할 수 있어요')
    .max(120, '나이를 확인해주세요'),
  terms: z.boolean().refine(val => val === true, {
    message: '이용약관에 동의해주세요'
  })
});

export async function createUser(formData: FormData) {
  'use server';
  
  const rawData = {
    email: formData.get('email'),
    password: formData.get('password'),
    age: Number(formData.get('age')),
    terms: formData.get('terms') === 'on'
  };
  
  try {
    const validatedData = userSchema.parse(rawData);
    // 검증 통과! 이제 DB에 저장하면 돼요
    await db.user.create({ data: validatedData });
    return { success: true };
  } catch (error) {
    if (error instanceof z.ZodError) {
      return { 
        success: false, 
        errors: error.flatten().fieldErrors 
      };
    }
    throw error;
  }
}

진짜 편한 게요, 에러 메시지를 필드별로 정확하게 받을 수 있다는 거예요.

커스텀 검증 로직 구현하기

물론 Zod 없이도 검증할 수 있어요. 간단한 폼이라면 직접 만드는 게 더 가벼울 수도 있거든요.

✅ 검증 함수 예시
function validateEmail(email: string) {
  const errors = [];
  
  if (!email) {
    errors.push('이메일은 필수예요');
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.push('이메일 형식이 아니에요');
  } else if (email.length > 255) {
    errors.push('이메일이 너무 길어요');
  }
  
  return errors;
}

function validatePassword(password: string) {
  const errors = [];
  
  if (!password) {
    errors.push('비밀번호는 필수예요');
  } else {
    if (password.length < 8) {
      errors.push('최소 8자 이상 입력해주세요');
    }
    if (!/[A-Z]/.test(password)) {
      errors.push('대문자를 포함해주세요');
    }
    if (!/[0-9]/.test(password)) {
      errors.push('숫자를 포함해주세요');
    }
    if (!/[!@#$%^&*]/.test(password)) {
      errors.push('특수문자를 포함해주세요');
    }
  }
  
  return errors;
}

export async function signUp(formData: FormData) {
  'use server';
  
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;
  
  const emailErrors = validateEmail(email);
  const passwordErrors = validatePassword(password);
  
  if (emailErrors.length > 0 || passwordErrors.length > 0) {
    return {
      success: false,
      errors: {
        email: emailErrors,
        password: passwordErrors
      }
    };
  }
  
  // 검증 통과!
  return { success: true };
}

실전 검증 체크리스트

제가 프로젝트할 때마다 확인하는 항목들이에요. 이것만 지켜도 대부분의 보안 이슈를 막을 수 있어요.

  • 데이터 타입 검증 - 숫자가 와야 할 곳에 문자열이 오면 안 되죠
  • 길이 제한 - 최소/최대 길이를 반드시 체크해요 (DB 컬럼 크기 고려)
  • 포맷 검증 - 이메일, 전화번호, URL 등 정규식으로 확인
  • 필수값 체크 - null이나 undefined 방지
  • 범위 검증 - 나이, 가격 같은 숫자는 최소/최대값 설정
  • 중복 체크 - 이메일이나 닉네임 같은 유니크 값 확인
  • XSS 방지 - HTML 태그나 스크립트 입력 필터링
  • SQL Injection 방지 - ORM 사용하거나 파라미터화된 쿼리 사용
? 팁

검증 로직은 재사용 가능하게 만들어두세요. 같은 검증을 여러 곳에서 써야 할 때가 많거든요. 저는 보통 lib/validations 폴더에 모아둬요.

에러 메시지 사용자 친화적으로 만들기

검증은 잘 했는데 에러 메시지가 불친절하면 사용자 경험이 안 좋아져요. 이런 식으로 작성하면 좋아요.

나쁜 예 좋은 예
Invalid email 올바른 이메일 주소를 입력해주세요
Password too short 비밀번호는 최소 8자 이상 입력해주세요
Field required 이름을 입력해주세요
Value out of range 가격은 0원에서 1,000,000원 사이로 입력해주세요

비동기 검증 처리하기

DB에서 중복 체크해야 하는 경우도 있잖아요? 이메일이나 닉네임 같은 거요. 이럴 땐 비동기로 처리해야 해요.

? 비동기 검증 예시
async function checkEmailExists(email: string): Promise {
  const existing = await db.user.findUnique({
    where: { email },
    select: { id: true }
  });
  return !!existing;
}

export async function registerUser(formData: FormData) {
  'use server';
  
  const email = formData.get('email') as string;
  
  // 1단계: 기본 포맷 검증
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return { 
      success: false, 
      error: '올바른 이메일을 입력해주세요' 
    };
  }
  
  // 2단계: DB 중복 체크
  const exists = await checkEmailExists(email);
  if (exists) {
    return { 
      success: false, 
      error: '이미 사용 중인 이메일이에요' 
    };
  }
  
  // 검증 완료!
  return { success: true };
}
⚠️ 주의사항

비동기 검증은 시간이 걸려요. 그래서 사용자에게 로딩 상태를 보여주는 게 좋아요. useFormStatus 훅을 사용하면 쉽게 구현할 수 있어요.

복잡한 조건부 검증

때로는 한 필드의 값에 따라 다른 필드의 검증 규칙이 바뀔 때가 있어요. 예를 들면요.

  • 배송 방법이 '직접 수령'이면 주소는 필수가 아니지만, '택배'면 필수
  • 결제 방법이 '카드'면 카드 번호가 필수, '무통장 입금'이면 불필요
  • 나이가 만 19세 미만이면 보호자 동의 필수

이런 경우 Zod의 refine이나 superRefine을 쓰면 돼요.

? 조건부 검증 예시
const orderSchema = z.object({
  deliveryMethod: z.enum(['delivery', 'pickup']),
  address: z.string().optional(),
  age: z.number(),
  parentConsent: z.boolean().optional()
}).refine(data => {
  // 배송이면 주소 필수
  if (data.deliveryMethod === 'delivery' && !data.address) {
    return false;
  }
  return true;
}, {
  message: '배송 주소를 입력해주세요',
  path: ['address']
}).refine(data => {
  // 미성년자면 보호자 동의 필수
  if (data.age < 19 && !data.parentConsent) {
    return false;
  }
  return true;
}, {
  message: '보호자 동의가 필요해요',
  path: ['parentConsent']
});

진짜 깔끔하죠? 저도 처음엔 이거 몰라서 if문 엄청 많이 썼는데요. 근데 이렇게 하니까 코드가 훨씬 읽기 좋아졌어요.


데이터 검증은 귀찮을 수 있지만요, 한 번 제대로 구축해두면 나중에 정말 편해요. 특히 보안 이슈나 데이터 정합성 문제를 미리 막을 수 있거든요. 여러분도 꼭 서버 측 검증을 잊지 마세요!

? 서버 액션 에러 처리 완벽 정복하기

서버 액션에서 에러 처리는 정말 중요한 부분이에요. 폼 데이터 처리할 때 검증 실패, 데이터베이스 오류, 네트워크 문제 등 다양한 에러가 발생할 수 있거든요. Next.js 15에서는 이런 에러를 우아하게 처리할 수 있는 여러 방법을 제공하는데요, 하나씩 살펴볼게요!

기본적인 에러 핸들링 패턴

서버 액션에서 에러를 처리하는 가장 기본적인 방법은 try-catch 구문을 사용하는 거예요. 근데 단순히 에러만 잡는 게 아니라, 사용자에게 의미 있는 피드백을 주는 게 중요하죠.

? 기본 에러 처리 예시
'use server'

export async function submitForm(formData: FormData) {
  try {
    const name = formData.get('name') as string
    const email = formData.get('email') as string
    
    // 기본 검증
    if (!name || name.length < 2) {
      return {
        success: false,
        error: '이름은 최소 2자 이상이어야 해요'
      }
    }
    
    // 데이터베이스 작업
    const result = await db.user.create({
      data: { name, email }
    })
    
    return {
      success: true,
      data: result
    }
    
  } catch (error) {
    console.error('Form submission error:', error)
    
    return {
      success: false,
      error: '처리 중 문제가 발생했어요. 다시 시도해주세요.'
    }
  }
}

이렇게 하면 에러가 발생해도 앱이 터지지 않고, 사용자에게 친절한 메시지를 보여줄 수 있어요.

Zod를 활용한 고급 에러 처리

사실 진짜 강력한 에러 처리는 Zod 같은 검증 라이브러리를 사용할 때 빛을 발하는데요. 필드별로 세밀한 에러 메시지를 보여줄 수 있거든요.

? Zod 에러 처리 예시
'use server'

import { z } from 'zod'

const formSchema = z.object({
  name: z.string()
    .min(2, '이름은 최소 2자 이상이어야 해요')
    .max(50, '이름은 50자를 넘을 수 없어요'),
  email: z.string()
    .email('올바른 이메일 형식이 아니에요'),
  age: z.number()
    .min(18, '만 18세 이상만 가입할 수 있어요')
    .max(120, '나이를 다시 확인해주세요')
})

export async function submitForm(formData: FormData) {
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    age: Number(formData.get('age'))
  }
  
  // Zod 검증
  const result = formSchema.safeParse(rawData)
  
  if (!result.success) {
    // 필드별 에러 메시지 추출
    const fieldErrors = result.error.flatten().fieldErrors
    
    return {
      success: false,
      errors: fieldErrors
    }
  }
  
  // 검증 통과 후 처리
  try {
    const data = await db.user.create({
      data: result.data
    })
    
    return { success: true, data }
  } catch (error) {
    return {
      success: false,
      error: '서버 오류가 발생했어요'
    }
  }
}
? 핵심 팁

safeParse를 사용하면 에러가 발생해도 예외가 던져지지 않아요. 대신 result.success로 성공 여부를 체크할 수 있죠. 이게 서버 액션에서는 훨씬 안전한 방식이에요!

클라이언트에서 에러 표시하기

서버에서 에러를 잘 처리했다면, 이제 클라이언트에서 예쁘게 보여줘야겠죠? useFormState를 사용하면 정말 간단해요.

? 클라이언트 에러 표시
'use client'

import { useFormState } from 'react-dom'
import { submitForm } from './actions'

export default function MyForm() {
  const [state, formAction] = useFormState(submitForm, null)
  
  return (
    <form action={formAction}>
      <div>
        <input 
          type="text" 
          name="name"
          className="border rounded px-3 py-2"
        />
        {state?.errors?.name && (
          <p className="text-red-500 text-sm mt-1">
            {state.errors.name[0]}
          </p>
        )}
      </div>
      
      <div>
        <input 
          type="email" 
          name="email"
          className="border rounded px-3 py-2"
        />
        {state?.errors?.email && (
          <p className="text-red-500 text-sm mt-1">
            {state.errors.email[0]}
          </p>
        )}
      </div>
      
      {state?.error && (
        <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
          {state.error}
        </div>
      )}
      
      <button type="submit">제출하기</button>
    </form>
  )
}

에러 유형별 처리 전략

실제로 서버 액션을 운영하다 보면 다양한 유형의 에러를 만나게 되는데요, 각 상황에 맞는 처리 방법이 있어요.

에러 유형 발생 원인 처리 방법 사용자 메시지
검증 에러 입력값이 규칙에 맞지 않음 Zod로 검증 후 필드별 에러 반환 "이메일 형식이 올바르지 않아요"
데이터베이스 에러 중복 키, 연결 실패 등 에러 타입별로 분기 처리 "이미 가입된 이메일이에요"
권한 에러 인증되지 않은 사용자 세션 체크 후 리다이렉트 "로그인이 필요해요"
네트워크 에러 외부 API 호출 실패 재시도 로직 구현 "잠시 후 다시 시도해주세요"
타임아웃 처리 시간 초과 타임아웃 설정 및 백그라운드 처리 "처리에 시간이 걸려요. 이메일로 알려드릴게요"

데이터베이스 에러 세밀하게 처리하기

Prisma나 다른 ORM을 쓸 때 발생하는 에러를 잘 구분해서 처리하면, 사용자 경험이 엄청 좋아져요. 예를 들어 중복 키 에러는 명확히 알려줘야 하잖아요?

? 데이터베이스 에러 처리
'use server'

import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'

export async function createUser(formData: FormData) {
  try {
    const email = formData.get('email') as string
    
    const user = await db.user.create({
      data: { email, name: formData.get('name') as string }
    })
    
    return { success: true, data: user }
    
  } catch (error) {
    // Prisma 에러인 경우
    if (error instanceof PrismaClientKnownRequestError) {
      // 중복 키 에러 (P2002)
      if (error.code === 'P2002') {
        const field = error.meta?.target as string[]
        return {
          success: false,
          error: `이미 사용 중인 ${field[0]}이에요`
        }
      }
      
      // 외래 키 제약 위반 (P2003)
      if (error.code === 'P2003') {
        return {
          success: false,
          error: '연결된 데이터를 찾을 수 없어요'
        }
      }
    }
    
    // 기타 에러
    console.error('Unexpected error:', error)
    return {
      success: false,
      error: '문제가 발생했어요. 잠시 후 다시 시도해주세요'
    }
  }
}
⚠️ 주의사항

에러 메시지에 너무 많은 기술적 정보를 담지 마세요. 해커가 시스템 구조를 파악할 수 있거든요. "데이터베이스 연결 실패: localhost:5432" 이런 식의 메시지는 절대 안 돼요!

전역 에러 바운더리 활용하기

Next.js 15에서는 error.tsx를 사용해서 예상치 못한 에러를 우아하게 처리할 수 있어요. 서버 액션에서 발생한 에러도 여기서 잡을 수 있죠.

? error.tsx 예시
'use client'

import { useEffect } from 'react'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // 에러 로깅 서비스로 전송
    console.error('Error boundary caught:', error)
  }, [error])

  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="text-center">
        <h2 className="text-2xl font-bold mb-4">
          앗, 문제가 발생했어요!
        </h2>
        <p className="text-gray-600 mb-6">
          예상치 못한 오류가 발생했어요. 다시 시도해주세요.
        </p>
        <button
          onClick={reset}
          className="bg-indigo-600 text-white px-6 py-2 rounded-lg"
        >
          다시 시도하기
        </button>
      </div>
    </div>
  )
}

이렇게 에러 처리를 탄탄하게 해두면, 사용자는 훨씬 안정적인 경험을 할 수 있어요. 무엇보다 개발자인 여러분도 디버깅하기가 훨씬 쉬워지죠!

? 실전 팁

개발 환경에서는 상세한 에러 정보를 보여주고, 프로덕션에서는 일반적인 메시지만 보여주세요. process.env.NODE_ENV로 환경을 체크해서 분기 처리하면 돼요. 그리고 모든 에러는 Sentry 같은 서비스로 로깅하는 걸 강력 추천드려요!

? 고급 패턴과 실무 활용법

기본적인 서버 액션 폼 처리는 이제 충분히 익히셨죠? 이제부터는 실무에서 정말 많이 쓰이는 고급 패턴들을 알려드릴게요. Next.js 15 서버 액션으로 폼 데이터 처리할 때 이런 패턴들을 알고 있으면 코드 품질이 완전 달라져요.

솔직히 말하자면 저도 처음엔 이런 패턴들이 좀 오버엔지니어링 같았거든요. 근데요, 실제 프로덕션에서 유저들이 몰리고 예상치 못한 에러들이 터지기 시작하면... 진짜 이런 패턴들 없이는 못 버텨요.

낙관적 업데이트 (Optimistic Updates) 구현하기

낙관적 업데이트는 서버 응답을 기다리지 않고 UI를 먼저 업데이트하는 패턴이에요. 사용자 경험이 엄청 좋아지거든요. 특히 좋아요 버튼이나 간단한 토글 같은 경우에 완전 빛을 발해요.

? 낙관적 업데이트 예시
'use client';

import { useOptimistic } from 'react';
import { likePost } from './actions';

export default function LikeButton({ postId, initialLikes }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, newLike) => state + newLike
  );

  async function handleLike(formData) {
    // UI 먼저 업데이트 (낙관적)
    addOptimisticLike(1);
    
    // 실제 서버 액션 실행
    await likePost(formData);
  }

  return (
    <form action={handleLike}>
      <input type="hidden" name="postId" value={postId} />
      <button type="submit">
        ? 좋아요 ({optimisticLikes})
      </button>
    </form>
  );
}

useOptimistic 훅이 2026년 현재 정말 안정적으로 동작하더라고요. 서버에서 에러가 나면 자동으로 원래 상태로 롤백되는 게 진짜 신기해요.

멀티 스텝 폼 처리하는 고급 패턴

회원가입이나 결제 같은 복잡한 폼은 여러 단계로 나눠져 있잖아요. 이걸 서버 액션으로 깔끔하게 처리하는 방법이 있어요. 근데 이거 설계를 잘못하면 나중에 유지보수가 진짜 지옥이에요.

? 멀티 스텝 폼 서버 액션
'use server';

import { cookies } from 'next/headers';

// 스텝별 데이터를 세션에 저장
export async function saveStepData(formData) {
  const step = formData.get('step');
  const data = Object.fromEntries(formData);
  
  const cookieStore = await cookies();
  const existingData = cookieStore.get('signup_data')?.value || '{}';
  const stepData = JSON.parse(existingData);
  
  // 현재 스텝 데이터 병합
  stepData[step] = data;
  
  cookieStore.set('signup_data', JSON.stringify(stepData), {
    httpOnly: true,
    secure: true,
    maxAge: 3600 // 1시간
  });
  
  return { success: true, nextStep: parseInt(step) + 1 };
}

// 최종 제출
export async function submitMultiStepForm() {
  const cookieStore = await cookies();
  const allData = JSON.parse(
    cookieStore.get('signup_data')?.value || '{}'
  );
  
  // 모든 스텝 데이터 검증
  if (!validateAllSteps(allData)) {
    return { error: '일부 단계의 데이터가 유효하지 않아요' };
  }
  
  // DB 저장 로직
  await saveToDatabase(allData);
  
  // 세션 정리
  cookieStore.delete('signup_data');
  
  return { success: true };
}

참고로 이 방식의 장점은 사용자가 중간에 나갔다가 다시 돌아와도 진행 상황이 유지된다는 거예요. 완전 편하죠?

파일 업로드 최적화 패턴

파일 업로드는 서버 액션에서 조금 까다로운 부분이에요. 특히 큰 파일을 다룰 때는 더더욱 그렇고요. 제가 직접 써보면서 찾은 최적화 패턴을 공유해드릴게요.

? 파일 업로드 팁

서버 액션에서 직접 파일을 처리하지 말고, presigned URL을 사용해서 클라이언트가 S3 같은 스토리지에 직접 업로드하게 하세요. 서버 부하가 확 줄어들어요. 그니까요, 서버 액션은 URL만 생성하고 메타데이터만 저장하는 역할만 하는 거죠.
? 파일 업로드 최적화 예시
'use server';

import { S3Client } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';

// presigned URL 생성
export async function getUploadUrl(filename, fileType) {
  const s3Client = new S3Client({ region: 'ap-northeast-2' });
  
  const { url, fields } = await createPresignedPost(s3Client, {
    Bucket: process.env.S3_BUCKET,
    Key: `uploads/${Date.now()}-${filename}`,
    Conditions: [
      ['content-length-range', 0, 10485760], // 10MB
      ['starts-with', '$Content-Type', fileType],
    ],
    Expires: 600, // 10분
  });
  
  return { url, fields };
}

// 업로드 완료 후 메타데이터 저장
export async function saveFileMetadata(formData) {
  const fileUrl = formData.get('fileUrl');
  const filename = formData.get('filename');
  
  await db.files.create({
    data: {
      url: fileUrl,
      filename: filename,
      uploadedAt: new Date(),
    }
  });
  
  return { success: true };
}

병렬 처리와 성능 최적화

여러 개의 폼 데이터를 처리해야 할 때가 있잖아요. 예를 들어 대량의 데이터를 임포트한다거나, 여러 사용자의 정보를 한꺼번에 업데이트한다거나. 이럴 때 병렬 처리를 잘 활용하면 속도가 엄청 빨라져요.

처리 방식 장점 단점 추천 상황
순차 처리 에러 추적 쉬움, 메모리 효율적 느림 데이터 간 의존성 있을 때
Promise.all 빠름, 코드 간단 하나 실패시 전체 실패 모든 작업이 필수일 때
Promise.allSettled 개별 성공/실패 처리 가능 에러 핸들링 복잡 부분 성공도 허용할 때
배치 처리 (청크) 메모리 안전, 안정적 구현 복잡, 전체 시간 김 대량 데이터 처리시
? 병렬 처리 예시
'use server';

// Promise.allSettled로 안전한 병렬 처리
export async function bulkUpdateUsers(formData) {
  const userIds = formData.getAll('userId');
  const newStatus = formData.get('status');
  
  const results = await Promise.allSettled(
    userIds.map(async (userId) => {
      return await db.user.update({
        where: { id: userId },
        data: { status: newStatus }
      });
    })
  );
  
  // 성공/실패 분류
  const successful = results.filter(r => r.status === 'fulfilled').length;
  const failed = results.filter(r => r.status === 'rejected').length;
  
  return {
    success: true,
    message: `${successful}명 성공, ${failed}명 실패`,
    details: results
  };
}

// 배치 처리 (대량 데이터용)
export async function batchProcessData(formData) {
  const allData = JSON.parse(formData.get('data'));
  const BATCH_SIZE = 50;
  
  for (let i = 0; i < allData.length; i += BATCH_SIZE) {
    const batch = allData.slice(i, i + BATCH_SIZE);
    
    await Promise.all(
      batch.map(item => processItem(item))
    );
    
    // 배치 간 잠깐 쉬어가기 (DB 부하 분산)
    await new Promise(resolve => setTimeout(resolve, 100));
  }
  
  return { success: true, processed: allData.length };
}

재시도 로직과 멱등성 보장

네트워크가 불안정하거나 서버가 일시적으로 문제가 생기면 폼 제출이 실패할 수 있어요. 이럴 때 자동으로 재시도하는 로직이 있으면 좋은데요, 여기서 중요한 건 멱등성이에요.

멱등성이 뭐냐고요? 같은 요청을 여러 번 보내도 결과가 같아야 한다는 거예요. 예를 들어 결제 요청을 두 번 보냈다고 해서 두 번 결제되면 안 되잖아요?

? 재시도 + 멱등성 보장
'use server';

import { v4 as uuidv4 } from 'uuid';

// 멱등성 키를 사용한 서버 액션
export async function createOrderIdempotent(formData) {
  const idempotencyKey = formData.get('idempotencyKey') || uuidv4();
  
  // 이미 처리된 요청인지 확인
  const existing = await db.processedRequests.findUnique({
    where: { idempotencyKey }
  });
  
  if (existing) {
    return existing.result; // 기존 결과 반환
  }
  
  try {
    // 실제 주문 처리
    const order = await db.order.create({
      data: {
        userId: formData.get('userId'),
        amount: parseInt(formData.get('amount')),
        // ...
      }
    });
    
    // 처리 결과 저장 (24시간 보관)
    await db.processedRequests.create({
      data: {
        idempotencyKey,
        result: { orderId: order.id },
        expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000)
      }
    });
    
    return { success: true, orderId: order.id };
  } catch (error) {
    return { error: '주문 처리 실패' };
  }
}

// 클라이언트 재시도 로직
async function submitWithRetry(formData, maxRetries = 3) {
  let lastError;
  
  for (let i = 0; i < maxRetries; i++) {
    try {
      const result = await createOrderIdempotent(formData);
      if (result.success) return result;
      
      lastError = result.error;
    } catch (error) {
      lastError = error;
      // 지수 백오프 (1초, 2초, 4초)
      await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000));
    }
  }
  
  throw lastError;
}

실시간 검증과 디바운싱 패턴

사용자가 입력하는 동안 실시간으로 검증해주면 UX가 엄청 좋아지는데요, 서버 액션을 매번 호출하면 서버 부하가 심하잖아요. 그래서 디바운싱이 필요해요.

? 실시간 검증 with 디바운싱
'use client';

import { useState, useEffect } from 'react';
import { checkUsernameAvailability } from './actions';

export default function UsernameInput() {
  const [username, setUsername] = useState('');
  const [isChecking, setIsChecking] = useState(false);
  const [isAvailable, setIsAvailable] = useState(null);

  useEffect(() => {
    if (username.length < 3) return;

    setIsChecking(true);
    
    // 디바운싱: 500ms 후에 검증
    const timeoutId = setTimeout(async () => {
      const formData = new FormData();
      formData.append('username', username);
      
      const result = await checkUsernameAvailability(formData);
      setIsAvailable(result.available);
      setIsChecking(false);
    }, 500);

    return () => clearTimeout(timeoutId);
  }, [username]);

  return (
    <div>
      <input
        value={username}
        onChange={(e) => setUsername(e.target.value)}
        placeholder="사용자명 입력"
      />
      {isChecking && <span>확인 중...</span>}
      {isAvailable === true && <span>✅ 사용 가능</span>}
      {isAvailable === false && <span>❌ 이미 사용 중</span>}
    </div>
  );
}
⚠️ 주의사항

디바운싱 시간을 너무 짧게 설정하면 서버 부하가 크고, 너무 길게 하면 사용자가 답답해해요. 보통 300~500ms 정도가 적당하더라고요. 그리고 네트워크 상태에 따라 동적으로 조절하는 것도 좋은 방법이에요.

고급 패턴 비교표

지금까지 배운 고급 패턴들을 한눈에 비교해볼게요. 상황에 맞는 패턴을 선택하는 게 정말 중요하거든요.

패턴 복잡도 성능 향상 UX 개선 적용 우선순위
낙관적 업데이트 낮음 중간 ⭐⭐⭐⭐⭐ 높음
멀티 스텝 폼 높음 낮음 ⭐⭐⭐⭐ 중간
파일 업로드 최적화 중간 ⭐⭐⭐⭐⭐ ⭐⭐⭐ 높음
병렬 처리 중간 ⭐⭐⭐⭐⭐ ⭐⭐ 중간
재시도 + 멱등성 높음 중간 ⭐⭐⭐⭐ 중간
실시간 검증 낮음 낮음 ⭐⭐⭐⭐⭐ 높음

제 경험상 처음 프로젝트를 시작할 때는 낙관적 업데이트와 실시간 검증부터 적용하는 게 좋아요. 구현이 간단하면서도 효과가 확실하거든요. 나머지는 필요할 때 점진적으로 추가하세요.

아 그리고요, 이런 고급 패턴들을 무작정 다 적용하려고 하지 마세요. 프로젝트 규모와 요구사항에 맞춰서 선택적으로 적용하는 게 진짜 중요해요. 오버엔지니어링은 오히려 독이 될 수 있거든요!

✨ Next.js 서버 액션 베스트 프랙티스

Next.js 15 서버 액션으로 폼 데이터를 처리할 때 알아두면 좋은 베스트 프랙티스를 정리해봤어요. 솔직히 말하자면 저도 처음엔 이런 거 몰랐다가 삽질하면서 배웠거든요. 여러분은 저처럼 고생하지 마시라고 제가 직접 경험한 걸 다 정리해드릴게요!

? 보안 관련 베스트 프랙티스

서버 액션은 서버에서 실행되지만, 그렇다고 보안을 소홀히 하면 안 돼요. 이거 진짜 중요한데요.

⚠️ 보안 체크리스트
  • 항상 입력값 검증하기 - 클라이언트 검증만 믿으면 안 돼요
  • 민감한 데이터는 반환하지 않기 - 비밀번호 해시 같은 거요
  • 인증 상태 확인 - 매 서버 액션마다 체크하세요
  • CSRF 보호 - Next.js가 자동으로 해주지만 확인은 필수!
  • Rate Limiting 적용 - 무한 요청 공격 막기
? 보안 강화 예시
'use server'

import { auth } from '@/auth'
import { rateLimit } from '@/lib/rate-limit'

export async function updateProfile(formData: FormData) {
  // 1. 인증 확인
  const session = await auth()
  if (!session) {
    return { error: '로그인이 필요합니다' }
  }

  // 2. Rate Limiting
  const rateLimitResult = await rateLimit(session.user.id)
  if (!rateLimitResult.success) {
    return { error: '너무 많은 요청이 발생했습니다' }
  }

  // 3. 권한 확인
  const userId = formData.get('userId')
  if (session.user.id !== userId) {
    return { error: '권한이 없습니다' }
  }

  // 4. 입력값 검증 (서버 측)
  const name = formData.get('name')
  if (typeof name !== 'string' || name.length < 2) {
    return { error: '이름은 2글자 이상이어야 합니다' }
  }

  // 5. 안전하게 처리
  // ...
}

⚡ 성능 최적화 전략

서버 액션도 결국 서버 리소스를 쓰는 거잖아요. 그래서 성능 최적화가 진짜 중요해요.

  1. debounce 활용 - 검색 같은 기능엔 필수예요
  2. 낙관적 업데이트 - useOptimistic 훅으로 즉각적인 피드백 제공하기
  3. 배치 처리 - 여러 요청을 하나로 묶어서 보내기
  4. 캐싱 전략 - revalidatePath나 revalidateTag 적극 활용
  5. 병렬 처리 - Promise.all로 독립적인 작업은 동시에
? 낙관적 업데이트 패턴

낙관적 업데이트는 서버 응답을 기다리지 않고 UI를 먼저 업데이트하는 거예요. 사용자 입장에선 엄청 빠르게 느껴지죠.

'use client'

import { useOptimistic } from 'react'
import { likePost } from './actions'

export function LikeButton({ postId, initialLikes }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, newLike) => state + newLike
  )

  async function handleLike() {
    // 즉시 UI 업데이트
    addOptimisticLike(1)
    
    // 실제 서버 액션 실행
    await likePost(postId)
  }

  return (
    <button onClick={handleLike}>
      좋아요 {optimisticLikes}
    </button>
  )
}

?️ 코드 구조화 패턴

서버 액션 코드도 잘 정리해야 나중에 유지보수가 쉬워요. 제가 써보니까 이렇게 구조화하는 게 가장 좋더라고요.

? 추천 폴더 구조
app/
├── actions/
│   ├── auth/
│   │   ├── login.ts
│   │   └── register.ts
│   ├── posts/
│   │   ├── create.ts
│   │   ├── update.ts
│   │   └── delete.ts
│   └── index.ts (모든 액션 export)
├── lib/
│   ├── validations/
│   │   ├── auth.ts (Zod 스키마)
│   │   └── post.ts
│   └── db.ts
└── components/
    └── forms/
        ├── LoginForm.tsx
        └── PostForm.tsx

이렇게 기능별로 폴더를 나누면 나중에 찾기도 쉽고, 여러 명이서 작업할 때도 충돌이 적어요.

? 에러 처리 베스트 프랙티스

에러 처리는 진짜 중요한데요. 사용자한테 뭐가 잘못됐는지 명확하게 알려줘야 하거든요.

? 에러 처리 패턴
'use server'

export async function createPost(formData: FormData) {
  try {
    // 검증 에러 (사용자가 고칠 수 있음)
    const validated = postSchema.safeParse({
      title: formData.get('title'),
      content: formData.get('content')
    })

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

    // DB 작업
    const post = await db.post.create({
      data: validated.data
    })

    revalidatePath('/posts')
    
    return {
      success: true,
      data: post,
      message: '게시글이 작성되었습니다'
    }

  } catch (error) {
    // 시스템 에러 (개발자가 확인해야 함)
    console.error('Post creation failed:', error)
    
    return {
      success: false,
      message: '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요'
    }
  }
}

에러 타입을 구분해서 처리하는 게 핵심이에요. 검증 에러는 사용자가 고칠 수 있게 자세히 알려주고, 시스템 에러는 간단한 메시지만 보여주는 거죠.

? 타입 안정성 확보하기

TypeScript를 쓴다면 타입 안정성은 필수예요. 서버 액션도 마찬가지죠.

? 타입 정의 패턴
// types/actions.ts
export type ActionResult<T = void> = {
  success: boolean
  data?: T
  errors?: Record<string, string[]>
  message?: string
}

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

import { ActionResult } from '@/types/actions'
import { Post } from '@prisma/client'

export async function createPost(
  formData: FormData
): Promise<ActionResult<Post>> {
  // 구현...
}

// 클라이언트에서 사용
'use client'

export function PostForm() {
  async function handleSubmit(formData: FormData) {
    const result = await createPost(formData)
    
    if (result.success) {
      // result.data는 Post 타입으로 추론됨
      console.log(result.data?.title)
    }
  }
}

? 테스트 전략

서버 액션도 당연히 테스트해야 하는데요. 근데 좀 까다로워요. 제가 써본 방법 알려드릴게요.

  • 단위 테스트 - 검증 로직은 별도 함수로 분리해서 테스트하세요
  • 통합 테스트 - DB 모킹하고 전체 플로우 테스트하기
  • E2E 테스트 - Playwright로 실제 폼 제출 테스트
? 테스트 팁

서버 액션은 'use server' 지시어 때문에 일반적인 Jest 테스트가 어려울 수 있어요. 그래서 검증 로직이나 비즈니스 로직은 별도 함수로 분리하고, 그 부분만 단위 테스트하는 게 현실적이에요. 실제 서버 액션 호출은 E2E 테스트로 커버하는 거죠.

? 재사용 가능한 패턴 만들기

같은 코드를 계속 반복하면 귀찮잖아요. 재사용 가능한 패턴을 만들어두면 진짜 편해요.

? Higher-Order Function 패턴
// lib/action-wrapper.ts
import { z } from 'zod'
import { ActionResult } from '@/types/actions'

export function createAction<T extends z.ZodType>(
  schema: T,
  handler: (data: z.infer<T>) => Promise<any>
) {
  return async (formData: FormData): Promise<ActionResult> => {
    try {
      // 1. 자동 검증
      const validated = schema.safeParse(
        Object.fromEntries(formData)
      )

      if (!validated.success) {
        return {
          success: false,
          errors: validated.error.flatten().fieldErrors
        }
      }

      // 2. 핸들러 실행
      const result = await handler(validated.data)

      return {
        success: true,
        data: result
      }
    } catch (error) {
      console.error(error)
      return {
        success: false,
        message: '오류가 발생했습니다'
      }
    }
  }
}

// 사용 예시
'use server'

export const createPost = createAction(
  postSchema,
  async (data) => {
    return await db.post.create({ data })
  }
)

이렇게 하면 검증, 에러 처리 로직을 매번 반복할 필요가 없어요. 핵심 비즈니스 로직만 작성하면 되죠.

✅ 베스트 프랙티스 체크리스트
  • ✓ 모든 입력값을 서버에서 재검증하고 있나요?
  • ✓ 에러 메시지가 사용자 친화적인가요?
  • ✓ 로딩 상태를 명확하게 표시하고 있나요?
  • ✓ 캐시 무효화를 적절히 하고 있나요?
  • ✓ 타입 안정성이 확보되어 있나요?
  • ✓ 민감한 데이터를 클라이언트에 노출하지 않나요?
  • ✓ Rate Limiting이 적용되어 있나요?

이 체크리스트를 매번 확인하면서 개발하면 실수를 많이 줄일 수 있어요. 저도 처음엔 놓치는 게 많았는데, 이제는 자연스럽게 체크하게 되더라고요. 여러분도 습관처럼 만들어보세요!


❓ 자주 묻는 질문

Next.js 15 서버 액션에서 파일 업로드 용량 제한은 어떻게 설정하나요?

Next.js 15에서는 next.config.js에서 serverActions 설정을 통해 파일 업로드 제한을 조절할 수 있어요. bodySizeLimit 옵션을 사용하면 되는데요, 예를 들어 bodySizeLimit: '10mb'로 설정하면 10MB까지 허용돼요. 기본값은 1MB라서, 이미지나 문서 업로드를 다루는 폼 데이터 처리를 할 때는 꼭 늘려줘야 해요. 참고로 Vercel에 배포할 때는 플랜에 따라 최대 크기가 달라질 수 있으니 확인해보세요!

서버 액션에서 formData.getAll()과 formData.get()의 차이가 뭔가요?

완전 다른 용도로 쓰이거든요! get()은 첫 번째 값만 가져오는 반면, getAll()은 같은 이름의 모든 값을 배열로 반환해요. 예를 들어 체크박스에서 여러 개를 선택했을 때 폼 데이터 처리할 때는 getAll()을 써야 전체 선택값을 받을 수 있죠. 파일 업로드도 multiple 속성 쓸 때는 getAll()로 처리해야 해요. 반대로 이메일이나 이름처럼 단일 값은 get()으로 충분해요!

Next.js 서버 액션으로 처리한 폼에서 리다이렉트가 안 될 때는 어떻게 하죠?

Next.js 15 서버 액션에서 리다이렉트 할 때는 redirect() 함수를 사용해야 하는데요, try-catch 블록 안에서 사용하면 안 돼요! redirect()는 내부적으로 에러를 throw해서 동작하는 구조라서, catch 블록에서 잡히면 리다이렉트가 안 되거든요. 폼 데이터 처리 후 리다이렉트가 필요하면 try-catch 밖에서 redirect()를 호출하거나, catch에서 rethrow 해줘야 해요. 또는 revalidatePath() 쓰는 것도 좋은 대안이에요!

서버 액션에서 유효성 검사 에러를 여러 필드에 각각 표시하려면요?

useFormState 훅과 함께 필드별 에러 객체를 반환하는 게 제일 깔끔해요. 서버 액션에서 Zod 검증 실패 시 error.flatten().fieldErrors를 사용하면 필드명을 키로 하는 에러 메시지 객체를 얻을 수 있거든요. 이걸 상태로 반환하면 클라이언트에서 state.errors?.email 이런 식으로 각 필드 아래에 에러 메시지를 표시할 수 있어요. Next.js 15 폼 데이터 처리에서 사용자 경험을 확 높여주는 패턴이죠!

서버 액션 실행 중에 로딩 상태를 보여줄 수 있나요?

당연히 가능하죠! useFormStatus 훅을 사용하면 돼요. 이 훅은 form 태그 내부의 자식 컴포넌트에서만 작동하는데요, pending 상태를 통해 서버 액션이 실행 중인지 확인할 수 있어요. 보통 SubmitButton 같은 별도 컴포넌트를 만들어서 pending일 때 버튼을 비활성화하고 스피너를 보여주는 식으로 구현해요. Next.js 15 폼 데이터 처리에서 사용자가 중복 제출하는 걸 막을 수 있어서 진짜 유용해요!

서버 액션에서 처리한 데이터를 실시간으로 다른 컴포넌트에 반영하려면?

revalidatePath()revalidateTag()를 서버 액션 끝에 호출해주세요! 예를 들어 게시글 목록 페이지에서 새 글을 작성하면, 서버 액션에서 revalidatePath('/posts')를 호출하면 해당 경로의 캐시가 무효화되면서 자동으로 최신 데이터가 반영돼요. Next.js 15 서버 액션으로 폼 데이터 처리할 때 이 패턴을 쓰면, 별도의 상태 관리 없이도 UI가 자동으로 업데이트돼서 정말 편해요. 2026년 현재 가장 권장되는 방식이에요!


✨ 마무리하며

여기까지 Next.js 15 서버 액션으로 폼 데이터 처리하는 방법을 완벽하게 정리해봤어요. 처음엔 복잡해 보일 수 있는데, 직접 한번 써보시면 API 라우트 만들던 시절이 얼마나 번거로웠는지 느껴질 거예요. 저도 처음엔 반신반의했는데 지금은 폼 처리할 때 무조건 서버 액션부터 떠올리게 되더라고요.

특히 useFormState와 useFormStatus 조합은 정말 강력해요. 클라이언트 상태 관리 라이브러리 없이도 깔끔한 폼 UX를 만들 수 있거든요. Zod 유효성 검사까지 더하면 타입 안전성도 확보되고요. Next.js 15 서버 액션으로 폼 데이터 처리하는 이 패턴들, 여러분 프로젝트에도 꼭 적용해보세요!

혹시 적용하면서 막히는 부분이나 궁금한 게 있으면 댓글로 남겨주세요. 제가 겪었던 삽질들을 공유하면서 같이 해결해보면 좋을 것 같아요. 그럼 오늘도 즐거운 코딩 되시길 바랄게요! ?

#Next.js 15 #서버 액션 #폼 데이터 처리 #useFormState #Server Actions #Next.js 폼 #React Server Components #폼 유효성 검사 #Next.js 튜토리얼 #웹 개발

이 글 공유하기

Twitter Facebook

댓글 0개

첫 번째 댓글을 남겨보세요!

관련 글