--- 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; 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; 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]]