[React-Hook-Form + Zod] 안전성있게 폼과 데이터 처리하기

프론트엔드 구현의 끝은 복잡한 폼이라는 말을 들은 적이 있습니다..

이번 프로젝트를 진행하면서 그 말이 자연스럽게 떠올랐습니다.

 

다행히도 프로젝트 초반부터 react-hook-form과 zod를 도입해 사용하고 있었기 때문에, 데이터 구조가 중간에 수정되더라도 즉시 인지할 수 있었고 보다 안정적으로 구현을 이어가고 있습니다.

 

이번 프로젝트는 BaaS 기반 프론트엔드 관점으로 기능을 관리했던 방식이 아니라, 백엔드 개발자분과 함께 협업하고 있습니다. 앞으로도 다양한 어려움이 생길 수 있겠지만, 그중 가장 먼저 마주한 어려움은 데이터 타입의 차이였습니다.

예를 들어 사용자가 퀴즈를 생성할 수 있는 폼을 만들고 해당 데이터를 서버에 전달해야 하는데, API 명세서에서 요구하는 타입과 제가 폼을 만들기 위해 정의한 타입 간에 차이가 있었습니다.

 

같은 의미의 데이터라도 사용자 입력용 타입과 API 요청/응답용 타입이 다를 수 있다는 점을 깨닫게 되었고, react-hook-form과 zod를 더욱 효율적으로 활용할 방법을 정리해보고자 합니다.

 

사용자 입력 데이터 검증하기

예제는 간단한 형태의 Todo로 만들어보겠습니다!

Form 컴포넌트에서 사용할 타입과 zod 스키마

// types/todo.ts
export type TodoForm = {
  title: string;
  completed: boolean;
};

// schemas/todoFormSchema.ts
import { z } from 'zod';

export const todoFormSchema = z.object({
  title: z.string().min(1, '제목을 입력하세요'),
  completed: z.boolean(),
});

export type TodoFormSchema = z.infer<typeof todoFormSchema>;

 

컴포넌트에서 사용하기

// components/TodoForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { todoFormSchema, TodoFormSchema } from '@/schemas/todoFormSchema';

const TodoForm = () => {
  const { register, handleSubmit } = useForm<TodoFormSchema>({
    resolver: zodResolver(todoFormSchema),
  });

  const onSubmit = (data: TodoFormSchema) => {
    // API 요청 처리
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('title')} />
      <input type="checkbox" {...register('completed')} />
      <button type="submit">추가</button>
    </form>
  );
};

 

API 요청/응답 및 검증에 활용하기

앞서 이야기한 것처럼 사용자가 폼에서 입력하는 값과 서버와 주고받는 값은 서로 다를 수 있습니다.

 

데이터 구조와함께 고려해야할 점은 네이밍케이스입니다.

프론트엔드에서는 보통 camelCase로 변수명을 작성하지만, 백엔드나 데이터베이스에서는 snake_case를 쓰는 경우가 많습니다.

이런 차이를 고려해서 스키마를 각각 정의하고, camelCase ↔ snake_case 형태로 값을 변환해주는 매핑함수를 만들어두면 깔끔하면서도 안정적으로 데이터를 관리할 수 있습니다!

 

요청/응답용 스키마 정의하기

// schemas/todoApiSchema.ts
import { z } from 'zod';

export const todoRequestSchema = z.object({
  title: z.string(),
  completed: z.boolean(),
});

export const todoResponseSchema = z.object({
  id: z.number(),
  title: z.string(),
  completed: z.boolean(),
  created_at: z.string().datetime(),
});

export type TodoRequest = z.infer<typeof todoRequestSchema>;
export type TodoResponse = z.infer<typeof todoResponseSchema>;

 

camelCase, snake_case 변환하기

// utils/transform.ts
export const toSnakeCase = (form: TodoForm): TodoRequest => ({
  title: form.title,
  completed: form.completed,
});

export const toCamelCase = (response: any): TodoResponse => ({
  id: response.id,
  title: response.title,
  completed: response.completed,
  created_at: response.created_at,
});

 

API 요청/응답 데이터 검증하기

zod의 메서드를 활용하면 데이터를 쉽게 검증할 수 있습니다.

요청이든 응답이든 모두 데이터가 잘못 들어올 가능성이 있기 때문에 검증을 통해 예외 상황을 줄이고 서비스의 안정성을 높일 수 있습니다.

// API 요청 전 데이터 검증하기
import { todoRequestSchema } from '@/schemas/todoApiSchema';

const sendTodo = async (data: TodoForm) => {
  const snakeData = toSnakeCase(data);
  const parsed = todoRequestSchema.safeParse(snakeData);

  if (!parsed.success) {
    throw new Error('유효하지 않은 요청 데이터');
  }

  await fetch('/api/todo', {
    method: 'POST',
    body: JSON.stringify(parsed.data),
    headers: { 'Content-Type': 'application/json' },
  });
};
// API 응답 데이터 검증하기
import { todoResponseSchema } from '@/schemas/todoApiSchema';

const fetchTodo = async (id: number) => {
  const res = await fetch(`/api/todo/${id}`);
  const json = await res.json();

  const parsed = todoResponseSchema.safeParse(json);
  if (!parsed.success) {
    throw new Error('서버 응답이 올바르지 않음');
  }

  return parsed.data;
};

 

지금까지는 zod를 단순히 폼 입력값을 검증하는 도구로만 사용해왔고, '런타임 안정성'이라는 말도 막연하게 느껴졌습니다.

그런데 이번 프로젝트를 하면서 폼에대한 유효성 검사를 포함해, 요청·응답 데이터 검증, 스토리지에 저장되는 값의 구조 확인 등 전반적인 데이터 흐름에 폭넓게 활용할 수 있는 지점들이 눈에 들어오기 시작했습니다.

앞으로는 zod를 단순한 입력 검증 도구로 한정하지 않고, 애플리케이션 전체의 데이터 신뢰성을 높이는 방향으로 활용해볼 예정입니다!