codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
노트›security

입력 검증 — 경계에서 다듬는다

2026-04-28 게시· 2026-05-18 갱신·0회 조회

입력 검증 — 경계에서 다듬는다

신뢰할 수 없는 입력은 시스템의 가장자리에서 들어옵니다. 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.refine async).

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 을 참고합니다.

security 카테고리의 다른 글

카테고리 전체 보기 →
  • 공개 라우트 화이트리스트 — 신규 도메인 도입 시 같이 갱신
  • 익명 폼 — 최소한의 안전망
  • 보안 헤더와 CORS
  • 비밀번호 해싱 — bcrypt · scrypt · Argon2
  • 레이트 리밋 — 알고리즘과 구현
  • OAuth — state · PKCE · OIDC