모노레포 전반에 걸쳐 Zod로 타입 및 유효성 검사 공유하기
James Reed
Infrastructure Engineer · Leapcell

공유 스키마로 프론트엔드와 백엔드 연결
최신 풀스택 애플리케이션을 구축할 때에는 종종 Next.js와 같은 프론트엔드 프레임워크와 Fastify와 같은 백엔드 프레임워크가 사용됩니다. 이러한 설정에서 흔히 발생하는 문제는 애플리케이션의 양쪽 끝에서 사용되는 데이터 구조와 유효성 검사 규칙 간의 일관성을 유지하는 것입니다. 통합된 접근 방식이 없으면 개발자들은 종종 타입 정의와 유효성 검사 로직을 중복하게 되어, 불일치, 유지보수 오버헤드 증가, 버그 위험 증가로 이어집니다. 이 문제는 여러 패키지가 협업하는 모노레포에서 더욱 두드러지며, 인터페이스와 데이터 유효성 검사를 위한 공유된 진실 공급원(single source of truth)이 중요해집니다.
이것이 바로 Zod와 같은 도구가 빛을 발하는 지점입니다. Zod는 TypeScript 우선 스키마 선언 및 유효성 검사 라이브러리로, 정적 타입을 추론할 수 있어 공유 데이터 계약 정의에 이상적인 후보입니다. 모노레포 워크스페이스에 스키마 정의를 중앙 집중화함으로써, Next.js 프론트엔드와 Fastify 백엔드 모두 API 요청 본문부터 데이터베이스 모델까지 데이터에 대한 동일한 이해를 바탕으로 작동하도록 할 수 있습니다. 이 글에서는 모노레포 내에서 Zod를 사용하여 원활한 타입 및 유효성 검사 공유를 달성하는 실질적인 구현 방법을 자세히 알아보고, 개발자 경험과 애플리케이션의 견고성을 크게 향상시킬 것입니다.
빌딩 블록 이해하기
구현에 뛰어들기 전에, 논의에 중요한 몇 가지 핵심 개념을 명확히 해 보겠습니다.
- 모노레포(Monorepo): 모노레포는 종종 관련된 여러 개의 독립적인 프로젝트 또는 "패키지"를 포함하는 단일 리포지토리입니다. Yarn Workspaces 또는 Lerna와 같은 도구는 모노레포 내에서 종속성 및 빌드 프로세스를 관리하는 데 자주 사용됩니다. 이 구조는 애플리케이션 생태계의 여러 부분에 걸쳐 코드 공유 및 일관성을 촉진합니다.
- Next.js: 서버 렌더링, 정적 및 클라이언트 측 웹 애플리케이션을 구축하는 데 인기 있는 React 프레임워크입니다. 풀스택 애플리케이션의 프론트엔드 구축에 자주 사용됩니다.
- Fastify: Node.js를 위한 매우 성능이 뛰어나고 개발 친화적인 웹 프레임워크로, 일반적으로 백엔드 API 구축에 사용됩니다.
- Zod: TypeScript 우선 스키마 선언 및 유효성 검사 라이브러리입니다. 이를 통해 다양한 데이터 유형(객체, 문자열, 숫자 등)에 대한 스키마를 정의하고 해당 스키마에 대해 데이터를 유효성 검사할 수 있습니다. Zod의 핵심 기능 중 하나는 수동 타입 선언 없이 스키마 정의에서 직접 TypeScript 타입을 추론하는 기능입니다.
중복의 문제
새로운 게시물을 생성할 수 있는 간단한 애플리케이션을 생각해 보겠습니다. 공유 스키마 없이 프론트엔드와 백엔드 모두에서 Post 인터페이스와 해당 유효성 검사 규칙을 독립적으로 정의할 수 있습니다.
프론트엔드 (Next.js):
// pages/posts/new.tsx interface CreatePostRequest { title: string; content: string; tags?: string[]; } // 클라이언트 측 유효성 검사 로직...
백엔드 (Fastify):
// src/routes/posts.ts interface CreatePostInput { title: string; content: string; tags?: string[]; } // Joi, Yup 또는 수동 유효성 검사 로직...
이 중복은 취약합니다. authorId와 같은 새 필드를 추가하거나 유효성 검사 규칙을 변경하는 경우(예: title은 최소 5자여야 함), 두 곳 모두에서 업데이트하는 것을 잊지 않아야 합니다. 이것은 흔히 버그가 발생하는 원인입니다.
모노레포 및 Zod 솔루션
저희 접근 방식은 모노레포 내에 Zod 스키마 정의 전용 공유 패키지를 만드는 것을 포함합니다. Next.js 프론트엔드와 Fastify 백엔드 모두 이 공유 패키지에 의존하여 정확히 동일한 데이터 계약을 사용하도록 보장합니다.
가상의 모노레포 구조를 설정해 보겠습니다.
my-monorepo/
├── packages/
│ ├── api/ # Fastify 백엔드
│ ├── web/ # Next.js 프론트엔드
│ └── common/ # 공유 Zod 스키마 및 타입
├── package.json
├── yarn.lock
└── tsconfig.json
1. common 패키지에 스키마 정의:
먼저 common 패키지에 Zod를 설치합니다: yarn add zod.
이제 packages/common/src에 파일을 만들어(예: schemas.ts) Zod 스키마를 정의합니다.
// packages/common/src/schemas.ts import { z } from 'zod'; export const createPostSchema = z.object({ title: z.string().min(5, { message: "Title must be at least 5 characters long" }), content: z.string().min(10, { message: "Content must be at least 10 characters long" }), tags: z.array(z.string()).optional(), authorId: z.string().uuid("Invalid author ID format").optional(), // 새 필드의 예시 }); // 스키마에서 직접 TypeScript 타입 추론 export type CreatePostInput = z.infer<typeof createPostSchema>;
2. Fastify 백엔드에서 스키마 사용:
Fastify용 @fastify/zod 플러그인을 설치하여 요청 본문, 쿼리 문자열 및 매개변수 유효성 검사를 위해 Zod를 통합합니다. api 패키지에서 Zod와 @fastify/zod를 설치합니다: yarn add zod @fastify/zod.
그런 다음 Fastify 라우트에서 common 패키지의 스키마를 사용합니다.
// packages/api/src/routes/posts.ts import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { createPostSchema, CreatePostInput } from '@my-monorepo/common/schemas'; // common 패키지에서 가져오기 export default async function (fastify: FastifyInstance) { fastify.post( '/posts', { schema: { body: createPostSchema, // 유효성 검사를 위해 Zod 스키마 사용 }, }, async (request: FastifyRequest<{ Body: CreatePostInput }>, reply: FastifyReply) => { const { title, content, tags, authorId } = request.body; // `CreatePostInput` 덕분에 `request.body` 타입이 자동으로 추론됩니다. // Fastify의 스키마 유효성 검사와 함께, 여기에 도달하기 전에 데이터가 유효함이 보장됩니다. // 게시물 저장 시뮬레이션 console.log('유효한 post 수신:', { title, content, tags, authorId }); return reply.status(201).send({ message: 'Post created successfully', data: { title, content, tags, authorId } }); } ); }
설명:
common패키지에서createPostSchema를 직접 가져옵니다.schema.body에createPostSchema를 전달하면@fastify/zod가 자동으로 유효성 검사를 처리합니다. 들어오는 요청 본문이 스키마를 준수하지 않으면 Fastify는 자동으로 400 Bad Request 오류를 반환합니다.CreatePostInput타입은request.body가 올바르게 타입화되었음을 보장하여 유효한 데이터에 대한 런타임 타입 오류를 제거합니다.
3. Next.js 프론트엔드에서 스키마 사용:
web 패키지에서 Zod를 설치합니다: yarn add zod. 그런 다음 클라이언트 측 양식 및 API 호출에 대해 common 패키지의 스키마를 사용합니다.
// packages/web/pages/create-post.tsx import React, { useState } from 'react'; import { createPostSchema, CreatePostInput } from '@my-monorepo/common/schemas'; // common 패키지에서 가져오기 const CreatePostPage: React.FC = () => { const [formData, setFormData] = useState<CreatePostInput>({ title: '', content: '', tags: [], }); const [errors, setErrors] = useState<Record<string, string | undefined>>({}); const [loading, setLoading] = useState(false); const [message, setMessage] = useState<string | null>(null); const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); // 사용자가 입력하는 동안 해당 필드의 오류를 지웁니다. if (errors[name]) { setErrors((prev) => ({ ...prev, [name]: undefined })); } }; const handleCreatePost = async (e: React.FormEvent) => { e.preventDefault(); setMessage(null); setErrors({}); setLoading(true); // 공유 Zod 스키마를 사용한 클라이언트 측 유효성 검사 const validationResult = createPostSchema.safeParse(formData); if (!validationResult.success) { const fieldErrors: Record<string, string | undefined> = {}; validationResult.error.errors.forEach((err) => { if (err.path.length > 0) { fieldErrors[err.path[0]] = err.message; } }); setErrors(fieldErrors); setLoading(false); return; } try { const response = await fetch('http://localhost:3001/posts', { // Fastify가 3001에서 실행된다고 가정 method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(validationResult.data), // 유효성 검사를 거친 데이터 전송 }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'Failed to create post'); } setMessage('Post created successfully!'); setFormData({ title: '', content: '', tags: [] }); // 양식 초기화 } catch (error: any) { setMessage(`Error: ${error.message}`); } finally { setLoading(false); } }; return ( <div> <h1>Create New Post</h1> <form onSubmit={handleCreatePost}> <div> <label htmlFor="title">Title:</label> <input type="text" id="title" name="title" value={formData.title} onChange={handleChange} /> {errors.title && <p style={{ color: 'red' }}>{errors.title}</p>} </div> <div> <label htmlFor="content">Content:</label> <textarea id="content" name="content" value={formData.content} onChange={handleChange} /> {errors.content && <p style={{ color: 'red' }}>{errors.content}</p>} </div> <div> <label htmlFor="tags">Tags (comma-separated):</label> <input type="text" id="tags" name="tags" value={formData.tags?.join(',') || ''} onChange={(e) => { const value = e.target.value.split(',').map(tag => tag.trim()).filter(Boolean); setFormData((prev) => ({ ...prev, tags: value })); if (errors.tags) { setErrors((prev) => ({ ...prev, tags: undefined })); } }} /> {errors.tags && <p style={{ color: 'red' }}>{errors.tags}</p>} </div> <button type="submit" disabled={loading}> {loading ? 'Submitting...' : 'Create Post'} </button> </form> {message && <p>{message}</p>} </div> ); }; export default CreatePostPage;
설명:
createPostSchema와CreatePostInput은common패키지에서 가져옵니다.CreatePostInput타입은formData상태에 대한 타입 안전성을 자동으로 제공합니다.createPostSchema.safeParse(formData)를 사용하여 클라이언트 측 유효성 검사를 수행합니다. 이는 정의된 스키마를 준수하는 데이터가 백엔드로 전송되도록 보장하여, 서버 왕복 없이 로컬에서 오류를 감지함으로써 더 나은 사용자 경험을 제공합니다.- Zod에서 추론된 타입은 백엔드로 잘못된 형식의 데이터를 보내거나 백엔드로부터 받은 데이터를 잘못 해석할 가능성을 크게 줄여줍니다.
애플리케이션 이점
Zod를 사용하여 common 패키지 내에서 타입 및 유효성 검사를 중앙 집중화하면 다음과 같은 이점이 있습니다.
- 단일 진실 공급원: 모든 데이터 계약이 한 곳에서 정의되어 중복을 줄이고 일관성을 보장합니다.
- 모든 곳에서의 타입 안전성: Zod의
z.infer는 프론트엔드와 백엔드 모두에 대한 TypeScript 타입을 자동으로 제공하여 개발 중 자동 완성 및 오류 검사를 향상시킵니다. - 중복 감소: 유효성 검사 로직을 각 환경별로 다시 작성할 필요가 없습니다.
- 향상된 개발자 경험: 개발자는 공유 스키마를 확인하여 필요한 필드, 타입 및 유효성 검사 규칙을 쉽게 볼 수 있습니다.
- 강력함 향상: 흔히 버그의 원인이 되는 일관되지 않은 데이터 구조 또는 유효성 검사 규칙이 사실상 제거됩니다.
- 쉬운 리팩토링: 데이터 구조 변경 시
common에서 스키마만 업데이트하면 되며, TypeScript는 프론트엔드와 백엔드 모두에서 영향을 받는 부분을 강조 표시하여 도움을 줍니다. - 클라이언트 측 유효성 검사 이점: 프론트엔드는 백엔드와 동일한 규칙으로 유효성 검사를 수행할 수 있어 사용자에게 즉각적인 피드백을 제공하고 잘못된 데이터에 대한 불필요한 네트워크 요청을 줄입니다.
결론
모노레포 전반에 걸쳐 타입과 유효성 검사를 공유하는 것은 풀스택 애플리케이션의 개발 및 유지보수를 크게 향상시키는 모범 사례입니다. 전용 common 패키지에서 Zod를 활용함으로써 데이터 계약에 대한 강력한 단일 진실 공급원을 구축하고, Next.js 프론트엔드 양식부터 Fastify 백엔드 API 엔드포인트까지 귀중한 타입 안전성과 일관된 유효성 검사 로직을 제공합니다. 이 접근 방식은 개발 워크플로우를 간소화할 뿐만 아니라 더 안정적이고 유지보수 가능한 소프트웨어로 이어져 궁극적으로 애플리케이션 계층 간의 더 강력한 연결을 구축합니다.

