입력 검증 — 경계에서 다듬는다
입력 검증 — 경계에서 다듬는다
신뢰할 수 없는 입력은 시스템의 가장자리에서 들어옵니다. HTTP body · query · headers · 환경 변수 · 외부 API 응답 · 파일 내용. 입력을 안쪽 코드까지 그대로 흘리면 타입 · 의미가 흐려지고 보안 사고도 그 가장자리에서 자주 시작. 이 글은 경계 검증 (boundary validation) 의 의미 · zod 와 형제 라이브러리 · 클라이언트와 서버 검증의 분담.
1. 경계 검증
핵심 원칙 — 신뢰할 수 없는 입력은 시스템 경계에서 한 번 검증해 신뢰할 수 있는 타입으로 변환. 이후 안쪽 코드는 검증된 타입을 가정.
경계의 자리:
- HTTP 핸들러 — body · query · path · headers.
- 환경 변수 — 시작 시점에 한 번.
- JSON 파싱 직후 —
unknown→ 타입. - 외부 API 응답 — 응답 스키마는 변할 수 있음.
- 메시지 큐 컨슈머 — 옛 메시지 형식 가능성.
검증의 결과는 타입과 의미가 보장된 값, 실패의 결과는 명확한 에러 응답.
2. zod
Colin McDonnell 이 2020 년에 시작한 TypeScript 검증 라이브러리. "스키마 정의가 곧 타입" 이 핵심.
import { z } from 'zod';
const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
});
type User = z.infer<typeof UserSchema>;
const result = UserSchema.safeParse(input);
if (!result.success) {
// result.error 의 구조화된 메시지
} else {
const user = result.data; // type: User
}
- 합성 가능한 스키마 (
z.union·z.intersection·z.discriminatedUnion). - 변환 (
z.transform) — 검증 + 정규화. - 비동기 검증 (
z.refineasync).
3. TypeScript 진영의 다른 도구
| 라이브러리 | 출자 | 모델 | 메모 |
|---|---|---|---|
| zod | 2020 | 메서드 체인 | 타입 추론 강함 · 가장 널리 쓰임 |
| valibot | 2023 | 함수형 pipe | 작은 번들 |
| ArkType | 2023 | 타입 표현식 | 강한 추론 · 학습 곡선 |
| yup | 2014 | 메서드 체인 | 오래된 표준 |
| joi | 2012 | 빌더 | Node 친화 · 추론 약 |
| superstruct | 2018 | 함수형 | 가벼움 |
valibot 은 함수형 합성 (pipe(string(), email())) 으로 트리 셰이킹이 더 잘 된다는 보고. ArkType 은 타입 시스템 자체로 스키마를 표현, 추론이 좋지만 학습 곡선.
4. 다른 언어
Python:
- Pydantic (2017, Samuel Colvin) — 클래스 기반 데이터 검증. v2 (2023) 에서 Rust 로 핵심을 다시 쓰면서 성능 향상. FastAPI 의 기반.
- marshmallow (2014) — 스키마 / 직렬화. Flask 시대의 표준.
- attrs · dataclasses + 검증 — 표준 라이브러리 중심.
JVM:
- Bean Validation (JSR 380) —
@NotNull·@Size·@Email같은 어노테이션. - Spring
@Valid+Validated— Bean Validation 통합.
Rust — serde + validator (직렬화와 검증의 분리).
언어와 무관하게 패턴은 같음. 신뢰할 수 없는 모양 → 검증 → 타입.
5. 자리별 적용
HTTP body:
// Next.js Route Handler
export async function POST(req: Request) {
const json = await req.json();
const parsed = UserSchema.safeParse(json);
if (!parsed.success) return Response.json({ errors: parsed.error.issues }, { status: 400 });
// parsed.data 는 검증됨
}
Query · Path — 쿼리 파라미터는 모두 문자열. 숫자 · 불리언이 필요하면 명시적 변환:
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
Headers — Authorization · X-Api-Key 같은 자리. 형식 · 길이 명시.
환경 변수 — 시작 시점에 한 번 검증해 잘못 설정 시 즉시 실패:
const Env = z.object({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'staging', 'production']),
});
export const env = Env.parse(process.env);
빌드 시점 · 시작 시점에 통과해야 부팅됩니다.
JSON 파싱 직후 — JSON.parse(...) 결과는 any. 그대로 타입 단언 (as User) 하지 않고 검증.
외부 API 응답 — API 가 슬며시 변하면 옛 코드가 깨짐. 응답을 스키마로 검증하면 변경이 적재 단계에서 잡힘.
6. 클라이언트와 서버 분담
원칙:
- 서버 검증은 항상 — 보안의 결정적 자리.
- 클라이언트 검증은 UX — 빠른 피드백 · 서버 호출 절약.
- 공유 스키마 — 같은 zod 스키마를 클라이언트 · 서버 양쪽이 import 하는 흐름이 흔함.
// shared/user-schema.ts
export const UserSchema = z.object({...});
이 패턴은 모노레포 · 단일 코드베이스에서 가장 자연스러움.
폼 라이브러리:
- React Hook Form +
@hookform/resolvers/zod—useForm({ resolver: zodResolver(schema) }). - TanStack Form — 자체 검증과 zod 어댑터.
- Conform — 서버 액션 친화적 폼.
서버 액션 (Next.js) 도 같은 검증을 거쳐야 함. 클라이언트가 아닌 환경에서 호출 가능하므로 폼 라이브러리 검증으로는 부족.
7. 에러 응답 형식
검증 실패는 일관된 형식. RFC 9457 (Problem Details for HTTP APIs, 2023) 같은 표준 참고:
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"errors": [
{ "path": "email", "message": "Invalid email" }
]
}
8. 안전 기본값과 진화
부분 업데이트 — PATCH 는 .partial() 또는 .deepPartial() 로 모든 필드 옵셔널:
const UserUpdate = UserSchema.partial();
Strict mode — z.object(...) 의 추가 키는 기본 stripped. 알 수 없는 키를 거부하려면 .strict(). 추가 키 보존이 필요하면 .passthrough().
스키마 진화 — 저장소에 옛 형식 데이터가 남은 자리에서는 union 또는 변환으로 옛 형식을 새 형식으로 끌어올림:
const Legacy = z.object({ name: z.string() });
const Current = z.object({ firstName: z.string(), lastName: z.string() });
const Either = z.union([Current, Legacy.transform(({ name }) => ({ firstName: name, lastName: '' }))]);
9. 자주 걸리는 자리
as 타입 단언으로 검증 우회 — TypeScript 의 as User 는 런타임에서 의미 없음. 검증 후 타입만 신뢰.
부분 검증의 함정 — 한 객체만 검증하고 중첩 객체는 그대로 두면 깊이만큼 신뢰가 흩어짐. 깊은 스키마.
변환과 검증의 혼재 — transform 으로 값을 정규화하면 결과 타입이 입력과 다름. API 의 입력 · 출력 스키마 분리.
에러 메시지 노출 범위 — 내부 구현 (테이블 이름 · SQL) 이 사용자에게 보이지 않게.
너무 관대한 스키마 — z.unknown() 이 곳곳에 박히면 검증의 의미 사라짐.
외부 API 의 진화 무시 — 응답이 바뀌면 적재 단계에서 실패하지만, 그 실패가 사용자 응답까지 흘러가면 안 됨. 적재 · 표시 단계 분리.
국제화 메시지 누락 — zod 의 기본 영어 메시지가 사용자에게 그대로 가면 어색. 메시지 매핑.
번들 크기 — 클라이언트에 두꺼운 검증 라이브러리가 들어가면 초기 로드에 영향. 트리 셰이킹 · 런타임 분리.
하고픈 말
신뢰할 수 없는 입력을 시스템 경계에서 한 번 검증해 안쪽 코드는 검증된 타입을 가정 — 이 단순한 원칙이 보안 · 타입 안전성 · 디버깅 모두를 좋게 합니다. 클라이언트 검증은 UX, 서버 검증은 보안. 공유 스키마로 두 쪽의 정의가 어긋나지 않게.
Next
- password-hashing
- headers-and-cors
zod 공식 · valibot 공식 · ArkType 공식 · Pydantic 공식 · Bean Validation 사양 · RFC 9457 Problem Details · OWASP Input Validation Cheat Sheet 을 참고합니다.