API가 요청을 받을 때마다 검증한다. 함수가 외부 시스템으로부터 인자를 받을 때마다 확인한다. 이를 수동으로 하면, 단일 엔드포인트에 비즈니스 로직보다 더 많은 검증 코드가 쌓일 수 있다.
이것이 runtime contracts의 숨겨진 비용이다. 타입 시스템이 거짓말을 하기 때문에 필요하다. HTTP를 통한 JSON, 데이터베이스 필드, 환경 변수는 모두 런타임에 untyped data로 도착한다. 하지만 수작업으로 만든 if 문으로 이를 구현하면 간단한 함수가 읽을 수 없는 방어 코드의 벽으로 변한다.
더 나은 방법이 있다.
runtime contracts란 무엇인가
runtime contract는 함수가 입력과 출력에 대해 제공하는 보장이다. 유효한 데이터를 넣으면 유효한 데이터가 나온다. contract를 위반하면 함수는 명확한 오류와 함께 빠르게 실패한다.
이것이 Design by Contract이며, Eiffel 때부터 존재해 왔다. 아이디어 자체는 새롭지 않다. 새로운 것은 모든 필드에 대한 validator를 작성하지 않고도 contracts를 표현할 수 있게 해주는 도구이다.
핵심 통찰: contracts는 코드가 아니라 schemas이다. schema는 형태, 제약 조건, 관계를 기술한다. 이를 수동으로 확인하는 코드는 잘못된 언어로 작성된 schema일 뿐이다.
boilerplate의 함정
실제로 수동 검증이 어떤 모습인지 보자. 사용자 가입 요청을 처리하는 함수가 있다:
function createUser(payload: unknown) {
if (!payload || typeof payload !== "object") {
throw new Error("Invalid payload");
}
if (!("email" in payload) || typeof (payload as any).email !== "string") {
throw new Error("email is required and must be a string");
}
if (!(payload as any).email.includes("@")) {
throw new Error("email must be valid");
}
if (!("age" in payload) || typeof (payload as any).age !== "number") {
throw new Error("age is required and must be a number");
}
if ((payload as any).age < 13 || !Number.isInteger((payload as any).age)) {
throw new Error("age must be an integer of at least 13");
}
if (
"tags" in payload &&
(!Array.isArray((payload as any).tags) ||
!(payload as any).tags.every((t: any) => typeof t === "string"))
) {
throw new Error("tags must be an array of strings");
}
// ... finally, the actual work
}
이것은 지치고, 오류가 발생하기 쉬우며, 불완전하다. email이 실제 이메일 형식과 일치하는지 확인했는가? age가 NaN이 아닌지? tags의 요소가 빈 문자열이 아닌지? 아마 확인하지 않았을 것이다. 지쳤기 때문이다.
이것은 단 하나의 함수일 뿐이다. 시스템의 모든 boundary에 이를 적용해 본다.
선언적 schemas가 boilerplate를 대체하는 방법
validator를 작성하는 대신, 형태를 기술하고 라이브러리가 이를 강제하도록 한다.
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(13),
tags: z.array(z.string().min(1)).optional(),
});
function createUser(payload: unknown) {
const user = UserSchema.parse(payload);
// user is typed as { email: string; age: number; tags?: string[] }
// actual business logic goes here
}
schema는 문서이다. 또한 validator이고, 타입 정의이며, 오류 메시지 생성기이다. 한 곳에서 schema를 변경하면, contract가 사용되는 모든 곳에서 업데이트된다.
이것은 마법이 아니다. 라이브러리는 런타임에 schema를 순회하며 검사를 적용한다. 차이점은 검사가 매번 수작업으로 작성되는 대신, 작고 재사용 가능한 primitives로 구성된다는 것이다.
함수 boundary에서의 contracts
진정한 힘은 schema를 함수의 입력과 출력에 직접 연결하는 데서 나온다. contract는 boundary에 존재해야 하며, 구현으로 새어 나가서는 안 된다.
function withContract<TInput, TOutput>(
inputSchema: z.ZodSchema<TInput>,
outputSchema: z.ZodSchema<TOutput>,
handler: (input: TInput) => TOutput
) {
return (rawInput: unknown): TOutput => {
const input = inputSchema.parse(rawInput);
const output = handler(input);
return outputSchema.parse(output);
};
}
const UserResponseSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
});
const createUser = withContract(
UserSchema,
UserResponseSchema,
(user) => {
// zero validation code here
return db.users.create(user);
}
);
이제 함수 본문에는 검증 코드가 전혀 없다. contract는 edge에서 강제되며, schema가 타입을 추론하기 때문에 컴파일러는 내부의 타입을 여전히 알고 있다.
한계가 드러나는 지점: 성능, 추론, 과잉 검증
선언적 schemas는 공짜가 아니다. 도입하기 전에 트레이드오프를 알아야 한다.
성능은 분명한 우려 사항이다. 복잡한 schema를 파싱하는 것은 몇 개의 if 검사보다 느리다. 실제로 대부분의 API에서 이 차이는 무시할 수 있다. Zod는 간단한 객체에 대해 수작업 검사보다 대략 10배 느리다. 대부분의 HTTP handler에서는 이는 마이크로초 단위의 차이다. hot loop에서 수백만 개의 레코드를 검증한다면, 먼저 벤치마크를 합시다.
Type inference의 공백도 문제를 일으킬 수 있다. schema에서 추론된 타입은 수작업으로 작성한 것보다 때로 더 엄격하거나 느슨하다. 커스텀 타입을 정의하고 schema가 이를 따르도록 해야 할 수도 있다. 반대 방향이 아니라.
과잉 검증은 조용한 킬러이다. 모든 함수가 runtime contract가 필요한 것은 아니다. API boundary에서 이미 검증된 데이터를 받는 내부 함수는 재검증할 필요가 없다. contracts는 시스템 boundary에 적용하고, 모든 helper function에는 적용하지 마라.
codebase를 다시 작성하지 않고 구현하는 방법
big-bang 마이그레이션이 필요하지 않다. API 진입점부터 시작하라.
- 외부에 노출된 함수나 route handler 하나를 고른다.
- 입력에 대한 schema를 작성한다.
- 수동 검증을 schema 파싱으로 대체한다.
- schema가 TypeScript 타입을 추론하도록 한다. 수작업으로 작성한 interface를 삭제한다.
하나의 엔드포인트에, 그다음 하나의 서비스에, 그리고 하나의 도메인에 이를 적용하라. 일주일 안에 테스트 스위트가 줄어드는 것을 알게 될 것이다. schema가 이미 커버하는 검증 엣지 케이스를 더 이상 테스트할 필요가 없기 때문이다.
TypeScript를 사용하지 않더라도 이 패턴은 적용된다. Python에는 Pydantic이 있다. Go에는 go-playground/validator가 있다. Rust에는 validator가 있다. 원칙은 동일하다. contract를 기술하고, 검사를 생성한다.
FAQ
runtime contracts가 unit tests를 대체하는가?
아니다. 이들은 입력 검증을 확인하는 unit tests의 부분집합을 대체한다. 비즈니스 로직, 오류 처리, 통합 동작은 여전히 테스트해야 한다.
커스텀 검증 규칙은 어떤가?
모든 제대로 된 schema 라이브러리는 custom refinements를 지원한다. 이를 아껴서 사용하라. 커스텀 규칙이 다섯 줄보다 길다면, 아마 schema가 아니라 함수 본문에 있어야 한다.
출력 시에도 검증해야 하는가?
중요한 함수라면 그렇다. output contract는 비즈니스 로직이 잘못된 데이터를 생성하는 버그를 잡는다. 잘못된 데이터가 다른 서비스로 전파되도록 내버려 두는 것보다, 원천에서 실패하는 것이 훨씬 저렴하다.
핵심
untyped data를 다루는 시스템에서 runtime contracts는 필수불가결하다. 유일한 선택은 boilerplate로 비용을 지불할 것인지, 아니면 schema 라이브러리로 지불할 것인지이다.
schema 라이브러리는 하나의 의존성과 약간의 성능 오버헤드를 요구한다. boilerplate는 유지보수성, 정확성, 그리고 정신 건강을 요구한다.
의존성을 선택하라.