이전 글에서 우리는 서버 액션을 이용해 프론트엔드와 백엔드를 간단하게 연결하는 법을 배웠습니다. 하지만 만약 사용자가 폼에 엉뚱한 값을 입력하고 ‘저장’ 버튼을 누른다면 어떻게 될까요? 서버 액션이 그대로 실행되어 데이터베이스에 잘못된 정보가 저장될 수 있습니다.
이를 막기 위해 서버 액션에는 반드시 데이터 검증(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
)
이제 클라이언트 폼이 위에서 만든 서버 액션과 어떻게 ‘소통’하는지 보겠습니다. useFormState
와 useFormStatus
훅이 핵심적인 역할을 합니다.
// 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>
);
}
🎓 주니어 개발자를 위한 핵심 정리
이 새로운 패턴의 가장 강력한 점은 무엇일까요?
- 단일 진실 공급원 (Single Source of Truth): 모든 유효성 검사 로직이 오직 서버 액션 안에만 존재합니다. 클라이언트에는 검증 로직이 전혀 없습니다. 코드가 중복되지 않고, 보안이 훨씬 강력해집니다.
- 간결해진 클라이언트: 클라이언트는 오직 서버가 보내준
state
를 화면에 어떻게 보여줄지만 고민하면 됩니다. 복잡한handleSubmit
함수,fetch
로직,try-catch
구문이 모두 사라졌습니다. - 최신 React 훅의 위력:
useFormState
: 서버와 클라이언트 사이의 데이터 흐름을 믿을 수 없을 만큼 간단하게 만들어 줍니다.useFormStatus
: 폼의 로딩 상태를 자동으로 관리해주어, 개발자는 로딩 UI에만 집중할 수 있습니다.
- 점진적 향상(Progressive Enhancement)의 완벽한 예시: 만약 사용자의 브라우저에서 자바스크립트가 동작하지 않더라도, 이 폼은 전통적인 방식으로 서버에 데이터를 보내고 정상적으로 작동합니다.
useFormState
와useEffect
는 자바스크립트가 가능한 환경에서 사용자 경험을 더욱 향상시키는 역할을 합니다.