next.js 서버 액션과 Zod: 데이터 검증 완벽 마스터

1 min read

이전 글에서 우리는 서버 액션을 이용해 프론트엔드와 백엔드를 간단하게 연결하는 법을 배웠습니다. 하지만 만약 사용자가 폼에 엉뚱한 값을 입력하고 ‘저장’ 버튼을 누른다면 어떻게 될까요? 서버 액션이 그대로 실행되어 데이터베이스에 잘못된 정보가 저장될 수 있습니다.

이를 막기 위해 서버 액션에는 반드시 데이터 검증(Data Validation)이라는 ‘똑똑한 문지기’가 필요합니다. 이번 글에서는 서버 액션 내에서 직접 데이터 검증을 수행하고, 검증 결과를 사용자에게 친절하게 보여주는 가장 현대적이고 안전한 방법을 배워보겠습니다.

📚 주요 개념 설명 (서버 액션 ver.)

  • 서버에서의 최종 검증 (Authoritative Validation on Server): 가장 중요한 원칙입니다. 클라이언트(브라우저)에서의 검증은 사용자 편의를 위한 것이지만, 얼마든지 우회될 수 있습니다. 오직 서버에서의 검증만이 100% 신뢰할 수 있습니다. 서버 액션은 서버에서 직접 실행되므로, 이곳이야말로 데이터를 검증할 가장 완벽하고 안전한 장소입니다.
  • Zod: 데이터 규칙서 (Schema Definition): 이전과 마찬가지로 Zod를 사용해 데이터의 구조와 규칙을 정의합니다. 이 규칙서는 이제 서버 액션의 문지기가 사용할 ‘출입 명부’가 됩니다.
  • useFormState 훅: 서버와의 소통 창구 (Communication Hook): React의 최신 훅입니다. 서버 액션이 실행된 후, 그 결과(성공 메시지, 에러 메시지 등)를 다시 클라이언트 컴포넌트로 “말해주는” 역할을 합니다. 서버와 클라이언트 간의 실시간 소통을 위한 핵심 도구입니다.
  • useFormStatus 훅: 자동 로딩 상태 감지기 (Pending UI Hook): 폼이 제출되어 서버 액션이 처리 중일 때, 로딩 상태(pending)를 자동으로 감지해 주는 편리한 훅입니다. 이 훅 덕분에 ‘저장’ 버튼을 ‘저장 중…’으로 바꾸는 로직을 매우 쉽게 구현할 수 있습니다.

💡 실제 사용 예제: 서버 액션과 통합된 검증 폼

이제 사용자가 제출한 폼 데이터를 서버 액션에서 직접 검증하고, 그 결과를 다시 폼에 피드백하는 전체 과정을 코드로 살펴보겠습니다.

1단계: 문지기 로직을 품은 서버 액션 만들기 (app/actions/registerUser.ts)

사용자 등록을 처리할 서버 액션입니다. 이제 이 함수는 데이터베이스 작업뿐만 아니라, Zod를 이용한 데이터 검증 책임까지 함께 가집니다.

// app/actions/registerUser.ts
'use server';

import { z } from 'zod';

// 1. 데이터 규칙서(스키마) 정의
const UserSchema = z.object({
  name: z.string().min(2, '이름은 2자 이상이어야 합니다.'),
  email: z.string().email('올바른 이메일 주소를 입력해주세요.'),
  age: z.coerce.number().min(18, '만 18세 이상이어야 합니다.').optional(), // coerce: 문자열을 숫자로 강제 변환
  website: z.string().url('올바른 URL을 입력해주세요.').optional().or(z.literal('')), // 빈 문자열도 허용
});

// 2. 액션의 결과를 담을 타입 정의 (성공 또는 에러)
type FormState = {
  message: string;
  errors?: Record<string, string>; // 필드별 에러 메시지
};

// 3. 서버 액션 함수 정의
// useFormState 훅과 함께 사용하기 위해 prevState와 formData를 인자로 받습니다.
export async function registerUser(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  // 폼 데이터를 일반 객체로 변환
  const data = Object.fromEntries(formData.entries());

  // Zod의 safeParse로 안전하게 검증 (에러를 던지는 대신 결과 객체를 반환)
  const validatedFields = UserSchema.safeParse(data);

  // 4. 검증 실패 시, 에러 메시지를 포맷하여 클라이언트로 반환
  if (!validatedFields.success) {
    const fieldErrors: Record<string, string> = {};
    for (const issue of validatedFields.error.issues) {
      const path = issue.path[0] as string;
      fieldErrors[path] = issue.message;
    }
    return {
      message: '입력값을 다시 확인해주세요.',
      errors: fieldErrors,
    };
  }

  // 5. 검증 성공 시, 데이터베이스 작업 수행 (예시)
  try {
    console.log('검증 성공! DB에 저장될 데이터:', validatedFields.data);
    // await db.user.create({ data: validatedFields.data });
  } catch (e) {
    return { message: '서버 오류가 발생했습니다.' };
  }

  // 모든 작업 성공 시, 성공 메시지 반환
  return { message: '사용자가 성공적으로 등록되었습니다!' };
}

2단계: 서버와 소통하는 똑똑한 폼 컴포넌트 (ValidatedForm.tsx)

이제 클라이언트 폼이 위에서 만든 서버 액션과 어떻게 ‘소통’하는지 보겠습니다. useFormStateuseFormStatus 훅이 핵심적인 역할을 합니다.

// components/Form/ValidatedForm.tsx
'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { Paper, Box, Typography, TextField, Button, Alert, CircularProgress } from '@mui/material';
import { registerUser } from '@/app/actions/registerUser';
import { useEffect, useState } from 'react';

// 초기 상태값
const initialState = { message: '', errors: {} };

// 로딩 상태를 자동으로 감지하는 제출 버튼
function SubmitButton() {
  const { pending } = useFormStatus(); // 폼 처리 중인지 여부 (true/false)

  return (
    <Button type="submit" variant="contained" disabled={pending} sx={{ mt: 3 }}>
      {pending ? <CircularProgress size={24} /> : '등록'}
    </Button>
  );
}

export function ValidatedForm() {
  // useFormState 훅으로 서버 액션과 폼의 상태를 연결!
  // state: 서버 액션이 반환한 최신 결과
  // formAction: form 태그의 action에 전달할 함수
  const [state, formAction] = useFormState(registerUser, initialState);

  // 서버에서 받은 에러 메시지를 화면에 표시하기 위한 별도의 상태
  const [errors, setErrors] = useState(initialState.errors);

  // 서버 액션의 결과(state)가 바뀔 때마다 실행
  useEffect(() => {
    // 성공적으로 제출되었으면 에러를 초기화
    if (state.message.includes('성공')) {
      setErrors({});
    } else {
      // 실패했으면 서버가 보내준 에러 메시지로 업데이트
      setErrors(state.errors || {});
    }
  }, [state]); // state가 변경될 때마다 이 효과를 다시 실행

  return (
    <Paper elevation={3} sx={{ p: 3 }}>
      <Typography variant="h6" gutterBottom>사용자 등록 (서버 액션)</Typography>

      {/* 성공/실패 메시지 표시 */}
      {state.message && (
        <Alert severity={state.errors ? 'error' : 'success'} sx={{ mb: 2 }}>
          {state.message}
        </Alert>
      )}

      {/* formAction을 action 속성에 전달 */}
      <Box component="form" action={formAction} noValidate>
        <TextField
          fullWidth
          margin="normal"
          label="이름"
          name="name" // name 속성은 필수!
          error={!!errors.name}
          helperText={errors.name}
          required
        />
        <TextField
          fullWidth
          margin="normal"
          label="이메일"
          name="email"
          type="email"
          error={!!errors.email}
          helperText={errors.email}
          required
        />
        {/* ... (나이, 웹사이트 필드도 동일한 패턴) */}

        <SubmitButton />
      </Box>
    </Paper>
  );
}

🎓 주니어 개발자를 위한 핵심 정리

이 새로운 패턴의 가장 강력한 점은 무엇일까요?

  1. 단일 진실 공급원 (Single Source of Truth): 모든 유효성 검사 로직이 오직 서버 액션 안에만 존재합니다. 클라이언트에는 검증 로직이 전혀 없습니다. 코드가 중복되지 않고, 보안이 훨씬 강력해집니다.
  2. 간결해진 클라이언트: 클라이언트는 오직 서버가 보내준 state를 화면에 어떻게 보여줄지만 고민하면 됩니다. 복잡한 handleSubmit 함수, fetch 로직, try-catch 구문이 모두 사라졌습니다.
  3. 최신 React 훅의 위력:
    • useFormState: 서버와 클라이언트 사이의 데이터 흐름을 믿을 수 없을 만큼 간단하게 만들어 줍니다.
    • useFormStatus: 폼의 로딩 상태를 자동으로 관리해주어, 개발자는 로딩 UI에만 집중할 수 있습니다.
  4. 점진적 향상(Progressive Enhancement)의 완벽한 예시: 만약 사용자의 브라우저에서 자바스크립트가 동작하지 않더라도, 이 폼은 전통적인 방식으로 서버에 데이터를 보내고 정상적으로 작동합니다. useFormStateuseEffect는 자바스크립트가 가능한 환경에서 사용자 경험을 더욱 향상시키는 역할을 합니다.

next.js 클라이언트 상태 관리 웹 캐싱 전략의 모든 것…

매번 똑같은 책을 빌리기 위해 도서관에 가는 것과, 자주 보는 책 몇 권은 아예 책상 위에 꺼내두고 보는 것 중 어느 쪽이 더 빠를까요?...
eve
1 min read

React 클라이언트 상태 관리 (전역 상태 관리와 Context API)

우리가 큰 건물을 짓는다고 상상해 봅시다. 건물 전체의 ‘중앙 난방 온도’나 ‘비상벨 작동 여부’ 같은 정보는 1층 로비, 10층 사무실, 지하 주차장 등 건물의...
eve
1 min read

 React 19 자동 메모이제이션 (Automatic Memoization)

혹시 개발 공부를 하다가 useMemo, useCallback 같은 훅(Hook)들을 만나고 “이건 왜 써야 하지? 너무 복잡해…”라고 생각했던 적 있으신가요? 이런 훅들은 React의 성능을 최적화하기 위해,...
eve
46 sec read