---
id: security-input-validation
title: 입력 검증 — 경계에서 정규화 + 도메인 타입화
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [security, validation, zod, vibe-coding]
tech_stack: { language: "TypeScript / zod", applicable_to: ["Backend", "Web"] }
applied_in: []
aliases: [zod, schema validation, parse don't validate, sanitize]
---

# 입력 검증

> "Parse, don't validate". 경계에서 **schema → 도메인 타입** 으로 변환. 이후 코드는 안전 가정. 클라이언트 검증은 UX 만, 서버에서 항상 재검증.

## 📖 핵심 개념
- 모든 외부 입력 = 적. (HTTP body, query, headers, env, file, 다른 서비스 응답)
- 검증 = "조건 통과"가 아니라 "정확한 타입으로 변환".
- 검증 후 타입 = brand 또는 nominal.

## 💻 코드 패턴

### Zod schema (Express)
```ts
import { z } from 'zod';

const SignupSchema = z.object({
  email: z.string().email().toLowerCase().max(254),
  password: z.string().min(8).max(128),
  age: z.coerce.number().int().min(13).max(120).optional(),
  inviteCode: z.string().regex(/^[A-Z0-9]{6,8}$/).optional(),
}).strict(); // 추가 키 거부

type Signup = z.infer<typeof SignupSchema>;

app.post('/api/signup', async (req, res) => {
  const parsed = SignupSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(422).json({ error: 'validation', issues: parsed.error.flatten() });
  }
  const data: Signup = parsed.data; // 타입 안전, 정규화 됨
  await createUser(data);
  res.status(201).end();
});
```

### Query / params 검증
```ts
const ListUsersQ = z.object({
  page: z.coerce.number().int().positive().default(1),
  pageSize: z.coerce.number().int().min(1).max(100).default(20),
  q: z.string().max(200).optional(),
});

app.get('/api/users', (req, res) => {
  const q = ListUsersQ.parse(req.query);
  // q.page is number now, not string
});
```

### Env 검증 — 부팅 시
```ts
const Env = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  NODE_ENV: z.enum(['development', 'production', 'test']),
  PORT: z.coerce.number().default(3000),
});
export const env = Env.parse(process.env); // 부팅 시 fail-fast
```

### 외부 API 응답 — 신뢰하지 마라
```ts
const StripeCustomer = z.object({
  id: z.string().startsWith('cus_'),
  email: z.string().email().nullable(),
  metadata: z.record(z.string()).default({}),
});

const customer = StripeCustomer.parse(await stripe.customers.retrieve(id));
// 외부 API 가 형식 바뀌면 즉시 알림
```

### Brand 결합
```ts
const Email = z.string().email().toLowerCase().brand<'Email'>();
type Email = z.infer<typeof Email>;

function send(to: Email, body: string) { ... }
const e: Email = Email.parse(req.body.email);
send(e, '...'); // 검증된 Email 만 통과
```

## 🤔 의사결정 기준
| 입력 종류 | 위치 |
|---|---|
| HTTP body / query / params | route handler 입구 |
| 환경 변수 | 부팅 시점 (실패 즉시 종료) |
| 파일 업로드 (mime, size) | 업로드 미들웨어 |
| WebSocket 메시지 | 메시지 라우터 입구 |
| 외부 API 응답 | client wrapper |
| 큐 메시지 | worker 입구 |

## ❌ 안티패턴
- **`req.body.x` 그대로 사용**: 형식 / 타입 / 길이 보장 X.
- **클라이언트 검증만**: 우회 가능. 서버 = 진실.
- **`as Type` 캐스팅**: 검증 없음. parse 사용.
- **regex 만으로 이메일 검증**: 너무 엄격 / 느슨. zod email 사용.
- **schema 가 너무 느슨 (`z.any()`)**: 의미 없음.
- **error 메시지에 입력 그대로 echo**: XSS. 일반 메시지.
- **HTML / SQL 검증 없이 통과**: 별도 escape 단계 (output encoding) 도 필요.
- **strict() 안 씀**: 추가 필드 무시 — 클라이언트 의도 파악 어려움 / 잠재 보안 사고.

## 🤖 LLM 활용 힌트
- 새 endpoint: zod schema 먼저 작성, 핸들러는 parse 결과 사용.
- env 도 zod parse — 부팅 시 fail-fast.
- 외부 API 응답도 schema.

## 🔗 관련 문서
- [[Security_Output_Encoding_XSS]]
- [[TypeScript_Branded_Types]]
- [[Security_CSRF_Patterns]]
