프론트엔드 구현의 끝은 복잡한 폼이라는 말을 들은 적이 있습니다..
이번 프로젝트를 진행하면서 그 말이 자연스럽게 떠올랐습니다.
다행히도 프로젝트 초반부터 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를 단순한 입력 검증 도구로 한정하지 않고, 애플리케이션 전체의 데이터 신뢰성을 높이는 방향으로 활용해볼 예정입니다!