API부터 UI까지 스키마 검증으로 타입 안전성 보장하기
Emily Parker
Product Engineer · Leapcell

소개: 백엔드-프론트엔드 간극 해소
현대 웹 개발의 복잡한 세계에서 백엔드 API와 프론트엔드 애플리케이션 간의 원활한 상호 작용은 매우 중요합니다. 그러나 이러한 상호 작용은 종종 상당한 문제를 야기하는데, 바로 이 간극을 넘어서 데이터 일관성과 타입 안전성을 유지하는 것입니다. 백엔드 API는 진화할 수 있고, 전달되는 데이터 구조는 예측 불가능하거나 오류가 있을 수 있으며, 강력한 검증 없이는 가정을 기반으로 구축된 프론트엔드 컴포넌트가 런타임 오류, 예상치 못한 UI 상태, 사용자에게 좌절감을 안겨주는 경험으로 이어질 수 있습니다.
전통적인 접근 방식은 종종 프론트엔드에서 인터페이스 또는 타입을 수동으로 정의하는 것을 포함하며, 이는 백엔드의 데이터 계약과 일치해야 합니다. 이 수동 동기화는 인적 오류에 취약하며 애플리케이션이 확장됨에 따라 유지 관리 악몽이 됩니다. 데이터가 백엔드에서 프론트엔드로 흐르는 데이터가 엄격한 스키마를 준수할 뿐만 아니라 정적 타입 추론을 제공하는 철저한 메커니즘을 구축할 수 있다면 어떻게 될까요? 이 블로그 게시물은 Zod 및 Valibot과 같은 스키마 검증 라이브러리가 엔드투엔드 타입 안전성과 강력한 데이터 검증을 달성하기 위한 우아하고 강력한 솔루션을 어떻게 제공하여 잠재적인 데이터 불일치를 컴파일 타임 또는 초기 런타임 인사이트로 변환하는지 자세히 살펴볼 것입니다.
원활한 데이터 흐름의 기둥
실무에 들어가기 전에 논의의 기반이 되는 핵심 개념에 대한 공통된 이해를 확립해 봅시다.
핵심 용어
- 스키마 검증: 데이터의 구조, 타입 및 콘텐츠에 대한 규칙을 공식화하고 시행하는 프로세스입니다. 데이터가 미리 정의된 청사진을 준수하도록 보장합니다.
- 타입 추론: 프로그래밍 언어나 도구가 명시적인 타입 주석 없이 변수나 표현식의 타입을 자동으로 추론하는 능력입니다. 우리의 맥락에서 이는 검증 스키마에서 직접 TypeScript 타입을 파생하는 것을 의미합니다.
- 엔드투엔드 타입 안전성: 백엔드 API의 데이터 계약부터 해당 데이터를 소비하는 프론트엔드 UI 컴포넌트까지 애플리케이션 스택 전체에 타입 안전성 보장을 확장합니다.
- Zod: TypeScript 우선 스키마 선언 및 검증 라이브러리입니다. 추론 기능과 개발자 친화적인 API로 유명합니다.
- Valibot: 더 새롭고 가벼우며 Zod에서 영감을 받은 스키마 검증 라이브러리로, 번들 크기와 성능을 우선시하면서 유사한 타입 추론 기능을 제공합니다.
검증되지 않은 데이터의 문제점
백엔드 API가 사용자 목록을 반환한다고 가정해 봅시다. 사용자 객체에 name
필드가 없거나 age
필드가 예상과 달리 숫자 대신 문자열인 경우, user.name
이 문자열이고 user.age
가 숫자라고 예상하는 프론트엔드 컴포넌트는 충돌하거나 잘못된 정보를 표시할 가능성이 높습니다. 모든 API 응답의 모든 필드에 대해 수동으로 검사를 추가하는 것은 지루하고 오류가 발생하기 쉽습니다. 스키마 검증이 필요한 곳입니다. 예상되는 사용자 객체에 대한 스키마를 정의함으로써 들어오는 데이터를 검증할 수 있으며, 심지어 렌더링 로직에 도달하기 전에 예상과 벗어나는 경우 즉시 피드백을 받을 수 있습니다.
엔드투엔드 타입 안전성 구현
핵심 아이디어는 데이터 구조에 대한 단일 진실 공급원, 즉 스키마를 정의하고 이 스키마를 검증과 TypeScript 타입 파생 모두에 사용하는 것입니다. 이를 통해 런타임 검증이 항상 정적 타입 정의와 일치하도록 보장합니다.
Zod와 Valibot 모두를 사용하여 실용적인 예제를 통해 이를 설명해 보겠습니다.
1. 스키마 정의
먼저 데이터 스키마를 정의합니다. API에서 Product
객체를 가져온다고 가정해 봅시다.
Zod 사용:
// schemas/productSchema.ts import { z } from 'zod'; export const productSchema = z.object({ id: z.string().uuid(), name: z.string().min(3), price: z.number().positive(), description: z.string().optional(), category: z.enum(['Electronics', 'Books', 'Clothing']), tags: z.array(z.string()).default([]), }); // 스키마에서 TypeScript 타입 추론 export type Product = z.infer<typeof productSchema>;
Valibot 사용:
// schemas/productSchema.ts import { object, string, number, optional, enumType, array, uuid, minLength, minValue } from 'valibot'; export const productSchema = object({ id: string([uuid()]), name: string([minLength(3)]), price: number([minValue(0.01)]), description: optional(string()), category: enumType(['Electronics', 'Books', 'Clothing']), tags: array(string()), }); // 스키마에서 TypeScript 타입 추론 import type { Input, Output } from 'valibot'; export type ProductInput = Input<typeof productSchema>; // 검증 전 타입 (원본 데이터) export type Product = Output<typeof productSchema>; // 성공적인 검증 후 타입 (정제된 데이터)
두 라이브러리 모두 Product
객체의 구조와 제약 조건을 설명할 수 있다는 점에 주목해 주세요. 중요한 것은 또한 이러한 스키마에서 TypeScript 타입을 자동으로 파생하는 유틸리티 (z.infer
for Zod, Output
for Valibot)를 제공한다는 것입니다. 이는 TypeScript 타입이 항상 검증 규칙과 동기화된다는 것을 의미합니다.
2. API 응답 유효성 검사
이제 API에서 데이터를 가져올 때 이러한 스키마를 사용하여 들어오는 페이로드를 즉시 검증할 수 있습니다.
Zod 사용:
// utils/api.ts import { productSchema, Product } from '../schemas/productSchema'; async function fetchProducts(): Promise<Product[]> { const response = await fetch('/api/products'); if (!response.ok) { throw new Error('Failed to fetch products'); } const rawData = await response.json(); // 스키마에 대해 원본 데이터 검증 const validatedProducts = z.array(productSchema).parse(rawData); // validatedProducts는 이제 Product[] 타입이 보장됩니다 return validatedProducts; } // 컴포넌트 또는 데이터 가져오기 훅에서의 예제 사용 // const products = await fetchProducts(); // products는 Product[] 입니다 // console.log(products[0].name); // 타입 안전한 접근
Valibot 사용:
// utils/api.ts import { productSchema, Product } from '../schemas/productSchema'; import { parse, array } from 'valibot'; async function fetchProducts(): Promise<Product[]> { const response = await fetch('/api/products'); if (!response.ok) { throw new Error('Failed to fetch products'); } const rawData = await response.json(); // 스키마에 대해 원본 데이터 검증 const validatedProducts = parse(array(productSchema), rawData); // validatedProducts는 이제 Product[] 타입이 보장됩니다 return validatedProducts; } // 컴포넌트 또는 데이터 가져오기 훅에서의 예제 사용 // const products = await fetchProducts(); // products는 Product[] 입니다 // console.log(products[0].name); // 타입 안전한 접근
두 예제 모두 rawData
가 productSchema
를 준수하지 않으면 parse()
는 오류를 발생시킵니다. 이렇게 하면 데이터 불일치가 가능한 한 가장 빠른 단계에서 즉시 감지되어 애플리케이션에 더 이상 전파되지 않습니다. 검증이 성공하면 TypeScript는 validatedProducts
가 Product[]
타입임을 알게 되어 프론트엔드 컴포넌트 전체에 강력한 타입 안전성을 제공합니다.
3. 프론트엔드 컴포넌트 통합
검증되고 타입이 지정된 데이터를 사용하면 프론트엔드 컴포넌트는 이제 TypeScript의 정적 검사를 활용하여 일반적인 오류를 방지하면서 안전하게 소비할 수 있습니다.
// components/ProductList.tsx import React, { useEffect, useState } from 'react'; import type { Product } from '../schemas/productSchema'; // 추론된 타입 가져오기 import { fetchProducts } from '../utils/api'; const ProductList: React.FC = () => { const [products, setProducts] = useState<Product[]>([]); const [error, setError] = useState<string | null>(null); const [isLoading, setIsLoading] = useState<boolean>(true); useEffect(() => { const getProducts = async () => { try { const fetchedProducts = await fetchProducts(); setProducts(fetchedProducts); } catch (err) { if (err instanceof Error) { setError(err.message); } else { setError('An unknown error occurred.'); } } finally { setIsLoading(false); } }; getProducts(); }, []); if (isLoading) return <div>Loading products...</div>; if (error) return <div style={{ color: 'red' }}>Error: {error}</div>; return ( <div> <h1>Our Products</h1> <ul> {products.map((product) => ( <li key={product.id}> <h2>{product.name}</h2> {/* product.name은 보장된 문자열입니다 */} <p>Price: ${product.price.toFixed(2)}</p> {/* product.price는 보장된 숫자입니다 */} {product.description && <p>{product.description}</p>} {/* 선택 사항 확인 */} <p>Category: {product.category}</p> {product.tags.length > 0 && <p>Tags: {product.tags.join(', ')}</p>} </li> ))} </ul> </div> ); }; export default ProductList;
ProductList
내부의 products.map((product) => ...)
가 API 계층에서 이미 데이터 유효성을 검사하고 타입 가드를 적용했기 때문에 product.id
, product.name
, product.price
에 런타임에 undefined
또는 잘못된 타입에 대한 두려움 없이 자신 있게 접근할 수 있다는 점에 주목하세요. 이를 통해 진정한 엔드투엔드 타입 안전 경험을 제공합니다.
애플리케이션 시나리오
이 패턴은 매우 다재다능하며 다양한 시나리오에 적용할 수 있습니다.
- API 응답 유효성 검사: 입증된 주요 사용 사례로, 외부 서비스에서 들어오는 데이터가 항상 유효한지 확인합니다.
- 폼 입력 유효성 검사: 백엔드로 보내기 전에 스키마에 대해 사용자 입력을 검증합니다. 이는 정확히 동일한 스키마를 활용하여 일관성을 향상시킬 수 있습니다.
- 구성 파일 유효성 검사: 애플리케이션 구성 파일이 특정 구조를 준수하도록 보장합니다.
- 데이터 변환: 스키마에는 데이터 변환 논리(예: 날짜 구문 분석, 타입 강제)가 포함될 수도 있어 소비에 원하는 형식으로 데이터를 보장합니다.
- Node.js 백엔드 미들웨어: 동일한 Zod/Valibot 스키마는 백엔드에서 요청 본문 유효성 검사에 사용할 수 있으며, 프론트엔드와 백엔드 개발자 간의 공유 계약을 설정합니다.
결론: 데이터 무결성에 대한 확신
스키마 유효성 검사 및 타입 추론을 위해 Zod 또는 Valibot과 같은 라이브러리를 채택하는 것은 프론트엔드 개발자가 백엔드 API와 상호 작용하는 방식을 근본적으로 변화시킵니다. 데이터 구조에 대한 강력한 단일 진실 공급원을 구축함으로써 엔드투엔드 타입 안전성을 확보하고, 데이터 불일치 관련 런타임 오류를 크게 줄이며, 개발자 확신을 극적으로 향상시킵니다. 이 접근 방식은 개발 및 디버깅을 간소화할 뿐만 아니라 더 탄력적이고 유지 관리 가능하며 예측 가능한 웹 애플리케이션을 구축하기 위한 강력한 기반을 제공합니다. 타입 안전성을 데이터 흐름의 초석으로 삼고 개발 경험이 번창하는 것을 지켜보세요.