Remix 및 Next.js에서 Zod-form-data를 사용하여 강력하고 타입 안전한 폼 구축하기
Grace Collins
Solutions Engineer · Leapcell

소개
끊임없이 진화하는 웹 개발 환경에서 사용자 친화적이고 신뢰할 수 있는 폼을 구축하는 것은 여전히 기본적인 과제입니다. 애플리케이션이 복잡해짐에 따라 데이터 무결성을 보장하고, 의미 있는 사용자 피드백을 제공하며, 원활한 개발자 경험을 유지하는 것이 무엇보다 중요해집니다. 기존의 접근 방식은 종종 클라이언트 측 유효성 검사, 서버 측 유효성 검사, 수동 타입 단언을 혼합하여 사용하게 되는데, 이는 단편적이고 오류가 발생하기 쉬운 프로세스로 이어집니다. 이는 미묘한 버그, 클라이언트와 서버 간의 불일치, 기능 구축보다는 타입과 씨름하는 것처럼 느껴지는 개발자 경험으로 이어질 수 있습니다.
Remix 및 Next.js와 같은 최신 풀스택 프레임워크와 강력한 스키마 유효성 검사 라이브러리의 등장은 강력한 솔루션을 제공합니다. 특히 zod-form-data를 통합하면 폼 처리에 새로운 수준의 세련됨을 더하여 종단간 타입 안전성과 점진적 향상을 즉시 구현할 수 있습니다. 이 접근 방식은 개발을 간소화할 뿐만 아니라 웹 애플리케이션의 견고성과 신뢰성을 크게 향상시킵니다. 이 글에서는 Remix와 Next.js 내에서 zod-form-data를 활용하여 이러한 바람직한 상태를 달성하는 방법을 알아보고, 일반적인 클라이언트 측 유효성 검사를 넘어 진정한 타입 안전하고 점진적으로 향상된 폼 경험을 제공합니다.
핵심 구성 요소 이해하기
구현 세부 사항에 들어가기 전에 논의의 핵심이 되는 주요 기술과 개념을 간략하게 정의해 보겠습니다.
- Remix / Next.js: 이것은 풀스택 React 프레임워크입니다.
- Remix: 웹 표준, 서버 측 렌더링(SSR), 중첩 라우팅을 강조하며 폼 제출 및 데이터 변환을 위한 내장 메커니즘을 제공합니다. 액션/로더 패러다임은 폼 데이터를 처리하는 데 특히 적합합니다.
- Next.js: SSR, 정적 사이트 생성(SSG), API 라우트와 같은 강력한 기능을 제공하여 다양한 애플리케이션 아키텍처에 유연하게 사용할 수 있습니다. API 라우트는 폼 제출을 처리하는 훌륭한 백엔드 역할을 합니다.
- 점진적 향상: 웹 개발 전략으로, 모든 사용자에게 액세스 가능한 핵심 콘텐츠 및 기능의 기준선을 구축한 다음, 보다 강력한 브라우저를 사용하는 사용자에게 프레젠테이션 및 기능 계층을 점진적으로 추가합니다. 폼의 맥락에서 이는 폼이 JavaScript 없이도 작동해야 하지만 JavaScript가 사용 가능한 경우 향상된 기능(예: 즉각적인 유효성 검사)을 제공해야 함을 의미합니다.
- 종단간 타입 안전성: 사용자 인터페이스(클라이언트 측)부터 백엔드(서버 측) 및 데이터베이스에 이르기까지 애플리케이션의 모든 계층에서 데이터 유형이 일관되게 적용되고 유효성 검사되도록 보장합니다. 이는 유형 관련 오류를 최소화하고, 코드 유지 관리성을 향상시키며, 데이터 일관성에 대한 강력한 보증을 제공합니다.
- Zod: TypeScript 우선 스키마 선언 및 유효성 검사 라이브러리입니다. 개발자는 모든 데이터 구조에 대해 스키마를 정의할 수 있으며, 이를 사용하여 들어오는 데이터를 유효성 검사하고 TypeScript 유형을 추론할 수 있습니다. Zod의 강력한 추론 기능은 종단간 타입 안전성의 초석입니다.
zod-form-data:FormData객체를 특별히 처리하는 Zod 사전 처리기입니다. Zod 스키마를 정의하여 HTML 폼 제출을 통해 데이터를 유효성 검사하고 변환할 수 있으며, 파일 업로드, 체크박스, 다중 선택 항목을 원활하게 처리합니다. 중요한 것은FormData에서 Zod 스키마에 따라 문자열 값을 적절한 유형(예: 숫자, 부울)으로 자동 변환할 수 있다는 것입니다.
점진적 향상 및 종단간 타입 안전성 달성
핵심 아이디어는 폼 데이터의 예상 구조와 유형을 나타내는 단일의 권위 있는 Zod 스키마를 정의하는 것입니다. 이 스키마는 클라이언트에서 즉각적인 피드백을 제공하고 서버에서 들어오는 제출을 엄격하게 유효성 검사하는 데 사용됩니다. zod-form-data는 이 Zod 스키마가 HTML 폼 제출에서 직접 FormData 객체를 처리할 수 있도록 함으로써 격차를 해소합니다.
Remix와 Next.js 모두에서 실제 예제를 통해 이를 설명해 보겠습니다.
Remix 예제
Remix의 action 함수는 폼 제출을 처리하는 데 자연스럽게 적합합니다. Zod 스키마를 한 번 정의하고 액션 내에서 사용할 수 있습니다.
// app/routes/newsletter.tsx import { ActionFunctionArgs, json } from "@remix-run/node"; import { Form, useActionData } from "@remix-run/react"; import { z } from "zod"; import { zfd } from "zod-form-data"; // 1. 폼 데이터에 대한 Zod 스키마 정의 const newsletterSchema = zfd.formData({ email: zfd.text(z.string().email("잘못된 이메일 주소입니다.")), source: zfd.text(z.string().optional()), acceptTerms: zfd.checkbox(), }); type NewsletterData = z.infer<typeof newsletterSchema>; export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData(); try { // 2. 스키마를 사용하여 폼 데이터 구문 분석 및 유효성 검사 const data = newsletterSchema.parse(formData); // 3. 유효성 검사 통과 시 데이터 처리 console.log("뉴스레터 가입 데이터:", data); // 실제 앱에서는 데이터베이스에 저장하거나 이메일을 보내는 등의 작업을 할 것입니다. return json({ success: true, message: "가입해주셔서 감사합니다!" }); } catch (error) { // 4. 유효성 검사 실패 시 오류 반환 if (error instanceof z.ZodError) { const errors = error.flatten(); return json({ success: false, errors: errors.fieldErrors }, { status: 400 }); } return json({ success: false, message: "예상치 못한 오류가 발생했습니다." }, { status: 500 }); } } export default function NewsletterSignup() { const actionData = useActionData<typeof action>(); return ( <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md"> <h1 className="text-2xl font-bold mb-4">뉴스레터 구독</h1> <Form method="post" className="space-y-4"> <div> <label htmlFor="email" className="block text-sm font-medium text-gray-700">이메일:</label> <input type="email" id="email" name="email" required className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> {actionData?.errors?.email && ( <p className="mt-1 text-sm text-red-600">{actionData.errors.email[0]}</p> )} </div> <div> <label htmlFor="source" className="block text-sm font-medium text-gray-700">어떻게 저희를 알게 되셨나요? (선택 사항)</label> <input type="text" id="source" name="source" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> </div> <div className="flex items-center"> <input type="checkbox" id="acceptTerms" name="acceptTerms" required className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" /> <label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-900"> 이용 약관에 동의합니다. </label> {actionData?.errors?.acceptTerms && ( <p className="ml-2 text-sm text-red-600">{actionData.errors.acceptTerms[0]}</p> )} </div> <button type="submit" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > 구독 </button> </Form> {actionData?.success && ( <p className="mt-4 text-green-600">{actionData.message}</p> )} {actionData?.success === false && !actionData.errors && ( <p className="mt-4 text-red-600">{actionData.message}</p> )} </div> ); }
설명:
- 스키마 정의:
zfd.formData를 사용하여newsletterSchema를 정의합니다.zfd.text는 텍스트 입력에 사용되고,zfd.checkbox는 체크박스에 사용되는 것을 주목하세요.zfd.checkbox는 체크박스 값을 부울로 올바르게 구문 분석합니다. - 서버 측 유효성 검사 (Remix
action):action함수 내에서 요청에서formData를 가져옵니다.newsletterSchema.parse(formData)는FormData를 정의된 유형으로 유효성 검사하고 변환하려고 시도합니다. 유효성 검사에 실패하면ZodError가 발생하며, 이를 캡처하여 특정 필드 오류를 반환합니다. - 점진적 향상: JavaScript가 비활성화된 경우 폼이 액션 엔드포인트로 직접 제출되고 서버 측 유효성 검사가 계속 작동하여 적절한 HTTP 상태 코드와 오류 메시지를 반환합니다.
- 클라이언트 측 피드백 및 타입 안전성:
useActionData는 서버의 유효성 검사 결과를 UI에 다시 제공합니다.action의 반환 유형이 알려져 있으므로actionData는 완전히 타입화되어 오류를 특정 필드에 자신 있게 표시할 수 있습니다.
Next.js 예제
Next.js의 경우 일반적으로 API 라우트 또는 서버 액션(Next.js 13.4+에서 도입됨)을 사용하여 폼 제출을 처리합니다. API 라우트를 사용하는 예제를 보여 드리겠습니다. 이는 더 광범위하게 적용됩니다.
// pages/api/newsletter.ts (API 라우트) import type { NextApiRequest, NextApiResponse } from 'next'; import { z } from 'zod'; import { zfd } from 'zod-form-data'; // 1. 폼 데이터에 대한 Zod 스키마 정의 (Remix와 동일) const newsletterSchema = zfd.formData({ email: zfd.text(z.string().email("잘못된 이메일 주소입니다.")), source: zfd.text(z.string().optional()), acceptTerms: zfd.checkbox(), }); export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { return res.status(405).json({ message: '허용되지 않는 메서드입니다.' }); } try { // Next.js API 라우트는 application/x-www-form-urlencoded의 req.body에 FormData를 직접 노출하지 않습니다. // 간단한 예제를 위해 formData를 시뮬레이션하지만, 실제 시나리오에서는 실제 FormData의 경우 // 'next-connect' 또는 'formidable'과 같은 미들웨어가 필요할 수 있으며, // 또는 클라이언트가 파일을 사용하지 않는 경우 'application/json'을 보내도록 해야 합니다. // 이 예제에서는 application/x-www-form-urlencoded에서 직접 파싱된 객체처럼 req.body를 가정합니다. // 실제 FormData를 처리하는 더 강력한 방법: // 일반적으로 'formidable' 또는 'multer'와 같은 라이브러리를 사용하여 multipart/form-data를 파싱합니다. // application/x-www-form-urlencoded의 경우 req.body는 이미 파싱되어 있습니다. // 이 데모를 위해 req.body가 FormData처럼 작동하도록 시뮬레이션합니다. const formDataLikeObject = new FormData(); for (const key in req.body) { // 여러 체크박스 또는 선택 옵션과 같은 배열과 유사한 값 처리 if (Array.isArray(req.body[key])) { req.body[key].forEach((item: string) => formDataLikeObject.append(key, item)); } else { formDataLikeObject.append(key, req.body[key]); } } // 2. 스키마를 사용하여 폼 데이터 구문 분석 및 유효성 검사 const data = newsletterSchema.parse(formDataLikeObject); // 3. 유효성 검사 통과 시 데이터 처리 console.log("뉴스레터 가입 데이터:", data); // 데이터베이스에 저장, 이메일 보내기 등 return res.status(200).json({ success: true, message: "가입해주셔서 감사합니다!" }); } catch (error) { // 4. 유효성 검사 실패 시 오류 반환 if (error instanceof z.ZodError) { const errors = error.flatten(); return res.status(400).json({ success: false, errors: errors.fieldErrors }); } return res.status(500).json({ success: false, message: "예상치 못한 오류가 발생했습니다." }); } }
// pages/newsletter-signup.tsx (클라이언트 측 페이지) import { useState } from 'react'; import { z } from 'zod'; // 클라이언트 측 유효성 검사 힌트를 위해 Zod를 가져옵니다. // 일관성을 보장하기 위해 클라이언트 측 유효성 검사를 위해 동일한 스키마를 사용합니다. const newsletterClientSchema = z.object({ email: z.string().email("잘못된 이메일 주소입니다."), source: z.string().optional(), // 참고: zfd.checkbox는 'on'/'off' 또는 누락을 암묵적으로 처리합니다. // 순수한 클라이언트 측 Zod의 경우 부울 값을 직접 확인합니다. acceptTerms: z.boolean().refine(val => val === true, "이용 약관에 동의해야 합니다."), }); export default function NewsletterSignupPage() { const [status, setStatus] = useState<{ success: boolean; message?: string; errors?: Record<string, string[]> } | null>(null); const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); setStatus(null); const formData = new FormData(event.currentTarget); const formObject = Object.fromEntries(formData.entries()); // 즉각적인 피드백을 위한 클라이언트 측 사전 유효성 검사 try { newsletterClientSchema.parse({ ...formObject, acceptTerms: formData.get('acceptTerms') === 'on' // 체크박스 값 변환 }); } catch (error) { if (error instanceof z.ZodError) { setStatus({ success: false, errors: error.flatten().fieldErrors }); return; } } try { const response = await fetch('/api/newsletter', { method: 'POST', // 파일에 가장 적합한 FormData를 직접 사용하는 경우, 텍스트 필드만 있는 경우 // `application/x-www-form-urlencoded` 또는 `application/json`도 일반적입니다. // 이 예제에서는 `formData`가 간접적으로 `multipart/form-data`로 전송됩니다. // 명시적으로 `application/x-www-form-urlencoded`를 원한다면 formData를 URLSearchParams로 변환해야 합니다. body: formData, }); const data = await response.json(); setStatus(data); } catch (error) { setStatus({ success: false, message: "네트워크 오류입니다. 다시 시도해주세요." }); } }; return ( <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md"> <h1 className="text-2xl font-bold mb-4">뉴스레터 구독</h1> <form onSubmit={handleSubmit} className="space-y-4"> <div> <label htmlFor="email" className="block text-sm font-medium text-gray-700">이메일:</label> <input type="email" id="email" name="email" required className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> {status?.errors?.email && ( <p className="mt-1 text-sm text-red-600">{status.errors.email[0]}</p> )} </div> <div> <label htmlFor="source" className="block text-sm font-medium text-gray-700">어떻게 저희를 알게 되셨나요? (선택 사항)</label> <input type="text" id="source" name="source" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> </div> <div className="flex items-center"> <input type="checkbox" id="acceptTerms" name="acceptTerms" required className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" /> <label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-900"> 이용 약관에 동의합니다. </label> {status?.errors?.acceptTerms && ( <p className="ml-2 text-sm text-red-600">{status.errors.acceptTerms[0]}</p> )} </div> <button type="submit" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > 구독 </button> </form> {status?.success && ( <p className="mt-4 text-green-600">{status.message}</p> )} {status?.success === false && !status.errors && ( <p className="mt-4 text-red-600">{status.message}</p> )} </div> ); }
설명:
- 스키마 정의:
newsletterSchema는 동일합니다. 종단간 타입 안전성을 위해 이것이 중요합니다. - 서버 측 유효성 검사 (Next.js API 라우트):
NextApiRequest의req.body는multipart/form-data요청에 대해FormData객체를 자동으로 제공하지 않습니다. 간단한application/x-www-form-urlencoded제출의 경우req.body가 객체로 파싱됩니다.zfd.formData가 원활하게 작동하도록req.body에서FormData객체를 수동으로 다시 구성합니다. 실제 프로덕션 시나리오(예: 파일 업로드)에서는multipart/form-data스트림을 파싱하기 위해formidable과 같은 미들웨어를 사용한 다음 파싱된 필드를zfd.formData에 전달해야 합니다.- 오류 처리는 Remix와 유사하게 JSON 응답을 반환합니다.
- 클라이언트 측 유효성 검사 및 점진적 향상:
- 즉각적인 피드백을 위해 클라이언트 측 유효성 검사에 동일한
z스키마 모양을 사용하며, 체크박스 값을 적절하게 변환합니다. - 폼이 제출될 때
fetch는 API 라우트로FormData를 보냅니다. - JavaScript가 비활성화된 경우 브라우저는 정상적으로 폼 제출로 대체되지만, 클라이언트 측 라우트이므로 폼이 실제로 동일한 라우트로 포스트되는 Remix와 같은 '점진적 향상' 방식으로 작동하지 않습니다. JavaScript 서버 액션 없이 Next.js에서 진정한 점진적 향상을 위해서는 별도의
POST요청 페이지나 오류 시 전체 페이지 새로고침이 필요할 수 있습니다. Next.js 서버 액션은 이를 크게 단순화하여 Remix와 매우 유사하게 작동합니다. - 클라이언트 측 유효성 검사는 즉각적인 사용자 피드백을 제공하는 반면, 서버 측 유효성 검사는 최종적인 안전망 역할을 합니다.
- 즉각적인 피드백을 위해 클라이언트 측 유효성 검사에 동일한
결론
zod-form-data를 Zod와 통합함으로써 Remix와 Next.js에서 폼을 처리하기 위한 강력한 패턴을 확립했습니다. 이 접근 방식은 폼 스키마 정의를 중앙 집중화하고, UI에서 백엔드까지 종단간 타입 안전성을 보장하며, 본질적으로 점진적 향상을 지원합니다. 개발자는 코드 중복 감소, 컴파일 타임 오류 확인, 애플리케이션 전반의 일관된 유효성 검사 스토리를 통해 이점을 얻어 궁극적으로 더 안정적이고 유지 관리 가능한 폼을 만들 수 있습니다. 이 강력한 조합은 개발자 경험과 폼과의 사용자 상호 작용 품질을 크게 향상시킵니다.

