TypeScript와 Zod로 안전하게 API 입력값 검증 설계하기 | 0xccffff Log
Article TypeScript와 Zod로 안전하게 API 입력값 검증 설계하기 Backend Feb 24, 2026 Views: 25 Updated: 2026-02-24 /posts/api-input-validation-typescript-zod
typeScript zod express nodejs api backend validation
Related posts 검증/에러처리/간단 인증을 추가해, 초보 단계에서 실전에서 자주 막히는 지점을 한 번에 정리합니다.
미션 해설을 핑계로 PATCH/DELETE를 추가해 CRUD를 완성합니다. req.params(주소창의 숫자), done 토글, 삭제까지 구현하고 PowerShell로 직접 테스트합니다.
지금까지 만든 API 서버 구조를 한 번에 정리하고, 초보자가 꼭 기억해야 할 실전 감각 3가지와 제작 비하인드를 전하며 시리즈를 마무리합니다.
TODO를 메모리 대신 todos.json 파일에 저장해, 서버를 재시작해도 데이터가 남도록 만듭니다.
실무에서 체감하는 API 입력값 검증: TypeScript + Zod로 경계를 정리하는 패턴#
이 글은 Zod 4 , Express 5 기준입니다.
Express 4를 쓰는 경우, async 핸들러의 에러가 전역 에러 핸들러로 자동 전달되지 않으므로
defineRoute 내부에 try-catch + next(err) 패턴을 추가해야 합니다.
API는 외부에서 들어오는 값을 받는 진입점입니다. 이 경계에서 입력을 한 번 확정해두면, 이후 레이어는 “검증된 형태”를 전제로 로직에 집중할 수 있습니다. 결과적으로 형태 체크가 여기저기 흩어지지 않고, 변경에도 대응하기 쉬워집니다.
아래에서는 TypeScript + Zod를 기준으로, 스키마에서 타입을 추론하고(z.infer) Express 컨트롤러까지 자연스럽게 이어지는 패턴을 정리해봅니다.
0) 패키지 설치#
이 글의 예시는 Express + TypeScript 환경입니다. 아래 패키지만 있으면 코드가 그대로 동작합니다.
npm install zod express@5
npm install -D typescript tsx @types/node @types/express
Express 5 버전에 따라 @types/express가 필요 없거나 충돌할 수 있습니다.
설치 후 타입 에러가 나면 @types/express를 제거해보세요.
Zod 4 를 기준으로 설명합니다.
프로젝트가 Zod 3 라면 본문에서 언급한 버전 차이(예: 이메일 스키마 API)만 맞추면 됩니다.
1) 클라이언트 검증과 서버 검증은 목적이 조금 다릅니다#
클라이언트의 폼 검증은 사용자가 빠르게 피드백을 받고 입력 실수를 줄이는 데 도움이 됩니다.
반면 서버 검증은 요청 경로에 관계없이 동일한 기준을 적용 하는 일입니다. 요청은 브라우저 폼만 거쳐 들어오지 않고, 모바일 앱/외부 파트너/스크립트/직접 호출 등 어떤 형태로든 들어올 수 있기 때문입니다.
저는 보통 이렇게 역할을 나눕니다.
클라이언트 검증은 UX를 위해 유지합니다.
서버는 스키마 검증을 통과한 값만 다음 단계로 전달합니다.
2) 경계에서 자주 하는 일: 검증 + 정규화 + 정책#
입력 처리에서 개념이 섞이면 설계가 금방 복잡해집니다. API 경계에서는 아래 3가지를 분리해두면 생각이 단순해집니다.
검증 : 타입과 제약 조건을 만족하는지 확인합니다.
정규화 : trim, 소문자화처럼 데이터를 “표준 형태”로 맞춥니다.
정책 : 문자열 숫자를 number로 받아줄지 같은 허용 범위를 정합니다.
정규화는 “값을 바꾸는” 동작이라서, 어디에서 어떤 규칙으로 바뀌는지가 중요합니다.
Zod 스키마에 정규화/정책을 함께 넣어두면, 입력이 들어오는 모든 경로에서 같은 기준으로 값이 정리되고 데이터 일관성이 유지됩니다.
다만 모든 필드가 정규화 대상은 아닙니다. 예를 들어 비밀번호는 “값 자체”가 의미를 가지므로, 서버에서 조용히 trim()으로 바꿔버리면 사용자가 의도한 값과 달라질 수 있습니다. 이런 경우는 정규화로 바꾸기보다 , 앞/뒤 공백을 허용하지 않겠다는 검증(정책)으로 막는 방식 이 더 안전합니다.
3) 스키마에서 타입을 뽑아 컨트롤러까지 연결하는 흐름#
Zod를 쓰면 런타임 검증과 타입을 같은 소스에서 가져올 수 있습니다.
아래 예시는 z.infer<typeof Schema>를 사용해 “검증이 끝난 이후”의 타입(= 파싱 결과)을 추출합니다.
스키마에 transform / coerce가 들어가면 입력 타입과 출력 타입이 달라질 수 있습니다.
이럴 때는 z.input / z.output를 구분해두면 더 명확해집니다. 이 글에서는 이해를 위해 z.infer로 통일합니다.
Copy// 예: age 필드에 z.coerce.number()가 있을 때
type Input = z . input < typeof SignupBodySchema>;
// → { age: unknown, ... } — 파싱 전, 문자열이 들어올 수 있음
type Output = z . output < typeof SignupBodySchema>;
// → { age: number, ... } — 파싱 후, number 확정
// z.infer는 z.output의 별칭입니다.
4) 예시: Express에서 Request 제네릭까지 자연스럽게 따라오게 만들기#
여기서는 “스키마 정의 → 타입 추출 → 공통 검증 헬퍼 → 컨트롤러” 순서로 보겠습니다.
설명과 코드가 섞이면 흐름을 잃기 쉬워서, 역할별로 코드 블록을 분리해두었습니다. 실제 프로젝트에서는 파일로 나누거나 한 파일에 모아도 좋습니다.
4-1) 스키마 정의 + z.infer로 타입 추출#
아래 스키마는 입력 제약 조건을 모아두고, 동시에 타입을 추출해 서비스 코드에서도 재사용할 수 있게 합니다.
email은 trim + 소문자화로 정규화해서 저장/비교의 기준을 하나로 맞춥니다.
password는 정규화로 값을 바꾸지 않고, 앞/뒤 공백을 허용하지 않는 검증으로 처리합니다.
age는 z.coerce.number()로 정책을 적용해 "14" 같은 문자열 입력도 받아서 number로 통일합니다.
Copyschemas/signup.ts import * as z from "zod" ;
// 이 글은 Zod 4 기준입니다.
// Zod 3를 쓰는 경우:
// - import 경로를 "zod/v3"로 바꾸거나(zod@3),
// - email 스키마의 `.pipe(z.email(...))` 부분을 `.email(...)` 체인으로 바꿔주세요.
export const SignupBodySchema =
4-2) 에러 응답 포맷 표준화#
검증 실패 응답을 한 형태로 맞추면, 클라이언트 처리와 로그 분석이 단순해집니다.
Copyhttp/validation-error.ts import type { ZodError } from "zod" ;
export function formatZodError ( error : ZodError ) {
return error.issues. map
검증 에러의 상세 내용(path, message)을 그대로 응답에 내리면,
내부 스키마 구조가 노출될 수 있습니다.
공개 API에서는 클라이언트용 메시지와 내부 로그용 상세를 분리하는 것도 고려해보세요.
4-3) as any 없이도 동작하는 defineRoute 패턴#
Express는 기본 타입에서 req.body가 any로 잡히는 경우가 많습니다.
아래 헬퍼는 스키마를 넘기면 Request 제네릭이 따라오도록 만들고, 검증 후 정제된 데이터를 req.body / req.query / req.params에 다시 넣어 컨트롤러에서 그대로 쓰게 하는 패턴입니다.
Copyhttp/define-route.ts import type { NextFunction, Request, RequestHandler, Response } from "express" ;
import * as z from "zod" ;
import type { ZodType } from "zod"
4-4) 컨트롤러에서 req.body 타입이 자동으로 따라옵니다#
아래 코드는 defineRoute({ body: SignupBodySchema }, ...)를 호출하는 순간, 컨트롤러의 req.body가 SignupBody로 추론됩니다.
컨트롤러에서 타입 선언을 반복하지 않아도 됩니다.
Copyroutes/signup.ts import { Router, type Router as ExpressRouter } from "express" ;
import { defineRoute } from "../http/define-route.js" ;
import { SignupBodySchema } from "../schemas/signup.js"
4-5) 진입점(index.ts)에서 라우터 조립하기#
이제 실제 앱을 구동하는 index.ts (또는 app.ts)에서 미들웨어와 라우터를 조립합니다. 실무에서 필수로 들어가는 JSON 파싱 에러 핸들링이나 글로벌 에러 핸들러도 이곳에 위치합니다.
Copyindex.ts import express, { type Request, type Response, type NextFunction } from "express" ;
import { signupRouter } from "./routes/signup.js" ;
const app
4-6) 직접 호출해 테스트 하기#
Copybash curl -X POST http://localhost:3000/api/signup \
-H "Content-Type: application/json" \
Expected result
{"ok":true,"email":"user@example.com","age":25,"marketingOptIn":false}
Copybash curl -X POST http://localhost:3000/api/signup \
-H "Content-Type: application/json" \
Warning
{"code":"INVALID_BODY","details":[{"path":["password"],"message":"비밀번호는 8자 이상이어야 합니다."}]}
5) 실무에서 종종 도움이 되는 디테일#
5-1) 스키마 검증과 도메인 규칙은 분리하는 게 자연스럽습니다#
스키마는 데이터의 형태와 기본 제약을 정리하는 데 강합니다.
반면 아래처럼 “상태”나 “비즈니스 정책”이 섞이는 규칙은 서비스 레이어에서 다루는 게 구현과 운영 모두에서 유리합니다.
이메일 중복 여부
가입 가능 조건(국가/상품/기간에 따라 달라질 수 있음)
특정 상태에서만 허용되는 전이 규칙
5-2) unknown key 정책을 먼저 정해두면 편합니다#
Zod에는 스키마에 없는 키를 다루는 옵션이 있습니다.
.strict() 모르는 키가 오면 실패
.strip() 모르는 키는 제거하고 진행
.passthrough() 모르는 키도 유지
어느 쪽을 선택할지는 도메인 성격과 팀 운영 방식에 따라 달라집니다. 다만 공개 API나 민감한 영역에서는 “받지 않는 키는 애초에 실패”로 두는 편이 추적과 대응이 단순합니다.
5-3) PATCH는 Create 스키마를 그대로 쓰지 않는 게 안전합니다#
부분 업데이트는 필수 조건이 다르고, “값을 바꾸지 않음”을 표현해야 합니다. 그래서 PATCH는 스키마를 분리하거나 .partial()을 활용합니다.
여기서 한 번 더 주의할 점이 있습니다. Create 스키마에 .default()가 들어가 있으면, .partial()을 해도 기본값이 그대로 적용됩니다.
예를 들어 아래처럼 marketingOptIn에 .default(false)가 있다면, 클라이언트가 해당 필드를 보내지 않아도 파싱 결과에 false가 채워집니다. 이러면 “변경 의도가 없음”과 “false로 변경”을 구분할 수 없습니다.
그래서 PATCH 스키마에서는 .default()가 있는 필드를 제거하고 다시 정의합니다.
Copyschemas/patch-user.ts import * as z from "zod" ;
import { SignupBodySchema } from "./signup.js" ;
export const PatchUserBodySchema = SignupBodySchema
Copy// 이렇게 하면 안 되는 이유
const Broken = SignupBodySchema. partial ();
Broken. parse ({ email: "a@b.com" });
// → { marketingOptIn: false } ← 안 보냈는데 false가 채워짐
// PatchUserBodySchema는 이걸 방지합니다
PatchUserBodySchema. parse ({ email: "a@b.com" });
// → { marketingOptIn: undefined } ← "변경 의도 없음"이 보존됨
5-4) 상태 코드는 역할별로 분리해두면 운영이 편합니다#
입력값 검증 실패(400), 인증 실패(401), 권한 부족(403)을 구분해두면 클라이언트 분기와 로그 집계에서 원인 파악이 쉬워집니다.
마무리#
입력값 검증은 요청을 거절하는 로직이라기보다, API 경계에서 데이터 형태를 확정하는 설계에 가깝습니다.
TypeScript + Zod 조합으로 스키마와 타입을 함께 가져가면, 검증 이후 레이어의 코드가 단순해지고 변경에도 대응하기 수월해집니다.
z
. object ({
// 정규화: 입력 단계에서 표준 형태로 맞춰두면 저장/비교가 단순해집니다.
email: z
. string ()
. trim ()
. toLowerCase ()
. max ( 255 )
// Zod 4: 포맷 검증은 `z.email()` 같은 top-level 스키마로도 제공됩니다.
// 정규화/길이 제한을 먼저 적용하고, 마지막에 email 포맷 검증을 파이프라인으로 붙입니다.
. pipe (z. email ( "유효한 이메일 형식이 아닙니다." )),
// 비밀번호는 "값" 자체가 의미가 있어서 서버에서 trim으로 조용히 바꾸지 않습니다.
// 대신, 앞/뒤 공백을 허용하지 않겠다는 정책이라면 검증으로 막습니다.
password: z
. string ()
. min ( 8 , { error: "비밀번호는 8자 이상이어야 합니다." })
. max ( 72 )
. refine (( v ) => v. trim () === v, { error: "비밀번호의 앞/뒤 공백은 허용되지 않습니다." }),
// 정책(coerce): query string이나 form에서 "14" 같은 문자열이 올 수 있어서,
// 숫자로 변환 가능한 입력은 number로 받아주는 정책을 적용합니다.
age: z.coerce. number (). int (). min ( 14 ). max ( 120 ),
marketingOptIn: z. boolean (). optional (). default ( false ),
})
. strict ();
export type SignupBody = z . infer < typeof SignupBodySchema>;
((
issue
)
=>
({
path: issue.path,
message: issue.message,
}));
}
;
import { formatZodError } from "./validation-error.js" ;
type RouteSchemas = {
body ?: ZodType ;
query ?: ZodType ;
params ?: ZodType ;
};
type BodyOf < S extends RouteSchemas > = S [ "body" ] extends ZodType ? z . infer < S [ "body" ]> : unknown ;
type QueryOf < S extends RouteSchemas > = S [ "query" ] extends ZodType ? z . infer < S [ "query" ]> : unknown ;
type ParamsOf < S extends RouteSchemas > = S [ "params" ] extends ZodType ? z . infer < S [ "params" ]> : Record < string , string >;
type TypedRequest < S extends RouteSchemas > = Request < ParamsOf < S >, unknown , BodyOf < S >, QueryOf < S >>;
type TypedHandler < S extends RouteSchemas > = (
req : TypedRequest < S >,
res : Response ,
next : NextFunction
) => void | Promise < void >;
export function defineRoute < S extends RouteSchemas >(
schemas : S ,
handler : TypedHandler < S >
) : RequestHandler < ParamsOf < S >, unknown , BodyOf < S >, QueryOf < S >> {
return async ( req , res , next ) => {
const { params : paramsSchema , query : querySchema , body : bodySchema } = schemas;
if (paramsSchema) {
const parsed = paramsSchema. safeParse (req.params);
if ( ! parsed.success) {
res. status ( 400 ). json ({ code: "INVALID_PARAMS" , details: formatZodError (parsed.error) });
return ;
}
req.params = parsed.data as ParamsOf < S >;
}
if (querySchema) {
const parsed = querySchema. safeParse (req.query);
if ( ! parsed.success) {
res. status ( 400 ). json ({ code: "INVALID_QUERY" , details: formatZodError (parsed.error) });
return ;
}
req.query = parsed.data as QueryOf < S >;
}
if (bodySchema) {
const parsed = bodySchema. safeParse (req.body);
if ( ! parsed.success) {
res. status ( 400 ). json ({ code: "INVALID_BODY" , details: formatZodError (parsed.error) });
return ;
}
req.body = parsed.data as BodyOf < S >;
}
return handler (req, res, next);
};
}
;
export const signupRouter : ExpressRouter = Router ();
signupRouter. post (
"/signup" ,
defineRoute ({ body: SignupBodySchema }, async ( req , res ) => {
const { email , password , age , marketingOptIn } = req.body;
// 여기서는 도메인 규칙(예: 중복 이메일 확인)과 저장 로직에 집중할 수 있습니다.
res. status ( 201 ). json ({ ok: true , email, age, marketingOptIn });
})
);
=
express
();
// 실무에서는 limit 설정과 JSON 파싱 에러 핸들링도 함께 고려합니다.
app. use (express. json ({ limit: "100kb" }));
// 분리해둔 라우터를 조립합니다.
app. use ( "/api" , signupRouter);
// 전역 에러 핸들러 (defineRoute에서 next(err)로 넘어온 에러들을 처리)
app. use (( err : unknown , _req : Request , res : Response , _next : NextFunction ) => {
console. error (err);
res. status ( 500 ). json ({ code: "INTERNAL_ERROR" });
});
app. listen ( 3000 , () => {
console. log ( "Server is running on http://localhost:3000" );
});
-d
'{"email":" user@example.com ","password":"securepass","age":"25"}'
-d
'{"email":"a@b.com","password":"short","age":20}'
.
omit
({ marketingOptIn:
true
})
. partial ()
. extend ({
// PATCH에서는 "안 보냄 = 변경하지 않음"이어야 하므로 default를 넣지 않습니다.
marketingOptIn: z. boolean (). optional (),
})
. strict ();
export type PatchUserBody = z . infer < typeof PatchUserBodySchema>;