런타임에 터진 버그
"타입 정의 완벽하게 했는데 왜 undefined 에러가...?"
type User = {
name: string;
age: number;
};
// 컴파일은 통과하지만...
const data: User = JSON.parse(response);
// response가 { name: 123, age: "스물" }이면?
API 응답이 예상과 달랐다. TypeScript는 아무 경고 없이 통과시켰다.
TypeScript는 컴파일 타임에만 동작한다. API 응답, 폼 입력, 외부 데이터는 런타임에 들어오기 때문에 타입을 아무리 정교하게 정의해도 런타임에는 무력하다.
결국 if (!data.name || typeof data.age !== 'number') 같은 검증 코드를 직접 작성하게 되는데, 이건 타입 정의와 중복이고 유지보수가 어렵다.
Zod란
Zod는 스키마 기반 런타임 검증 라이브러리다.
핵심 특징:
- 스키마를 정의하면 런타임 검증과 TypeScript 타입이 동시에 생성된다
- 검증 실패 시 상세한 에러 메시지 제공
- 체이닝으로 복잡한 조건도 표현 가능
npm install zod
기본 사용법
import { z } from 'zod';
// 스키마 정의
const UserSchema = z.object({
name: z.string().min(1, '이름은 필수입니다'),
age: z.number().positive('나이는 양수여야 합니다'),
email: z.string().email('올바른 이메일 형식이 아닙니다'),
});
// TypeScript 타입 자동 추론
type User = z.infer<typeof UserSchema>;
// { name: string; age: number; email: string; }
스키마 하나로 런타임 검증과 타입 정의가 동시에 해결된다. 타입을 별도로 정의할 필요가 없다.
검증 방식: parse vs safeParse
// parse: 실패 시 에러 throw
try {
const user = UserSchema.parse(unknownData);
} catch (error) {
// ZodError
}
// safeParse: 에러를 throw하지 않고 결과 객체 반환
const result = UserSchema.safeParse(unknownData);
if (!result.success) {
console.log(result.error.errors);
// [{ path: ['age'], message: '나이는 양수여야 합니다' }]
} else {
console.log(result.data); // 검증된 데이터
}
parse는 간단한 경우에, safeParse는 에러 핸들링이 필요할 때 사용한다.
에러 핸들링 심화
safeParse의 에러 객체는 여러 방식으로 가공할 수 있다.
const result = UserSchema.safeParse({
name: '',
age: -5,
email: 'invalid',
});
if (!result.success) {
// flatten(): 필드별로 에러 메시지 그룹화
const flattened = result.error.flatten();
// {
// fieldErrors: {
// name: ['이름은 필수입니다'],
// age: ['나이는 양수여야 합니다'],
// email: ['올바른 이메일 형식이 아닙니다']
// }
// }
// format(): 중첩 객체 구조 유지
const formatted = result.error.format();
// {
// name: { _errors: ['이름은 필수입니다'] },
// age: { _errors: ['나이는 양수여야 합니다'] },
// ...
// }
}
폼 검증에서는 flatten()이, 중첩된 객체에서는 format()이 유용하다.
타입 강제 변환: coerce
URL 쿼리 파라미터나 폼 데이터는 모두 문자열로 들어온다. z.coerce로 자동 변환할 수 있다.
// 일반 스키마: "123"은 number가 아니므로 실패
z.number().parse("123"); // ❌ ZodError
// coerce 스키마: 자동 변환 후 검증
z.coerce.number().parse("123"); // ✅ 123
z.coerce.boolean().parse("true"); // ✅ true
z.coerce.date().parse("2024-01-01"); // ✅ Date 객체
API Route에서 쿼리 파라미터를 다룰 때 특히 유용하다.
const QuerySchema = z.object({
page: z.coerce.number().positive().default(1),
limit: z.coerce.number().min(1).max(100).default(20),
});
// ?page=2&limit=50 → { page: 2, limit: 50 }
실전 패턴
1. API 응답 스키마
// 공통 에러 응답
const ErrorResponseSchema = z.object({
error: z.string(),
code: z.string().optional(),
statusCode: z.number().optional(),
});
// 공통 성공 응답
const SuccessResponseSchema = z.object({
success: z.literal(true),
});
2. Discriminated Union
서로 다른 타입을 구분해야 할 때 유용하다.
// Figma에서 가져온 레이어
const FigmaLayerSchema = z.object({
source: z.literal('figma'),
nodeId: z.string(),
imageUrl: z.string(),
});
// 스크린샷 레이어
const SnapshotLayerSchema = z.object({
source: z.literal('snapshot'),
deviceId: z.string(),
imageUrl: z.string(),
});
// 통합 스키마
const LayerSchema = z.discriminatedUnion('source', [
FigmaLayerSchema,
SnapshotLayerSchema,
]);
type Layer = z.infer<typeof LayerSchema>;
// { source: 'figma'; nodeId: string; ... } | { source: 'snapshot'; deviceId: string; ... }
source 필드 값에 따라 다른 타입으로 좁혀진다. TypeScript의 타입 가드가 자동으로 작동한다.
3. Request/Response 분리
// 요청 스키마
export const CreateProjectRequestSchema = z.object({
url: z.string().url('Invalid URL format'),
});
export type CreateProjectRequest = z.infer<typeof CreateProjectRequestSchema>;
// 응답 스키마
export const ProjectResponseSchema = z.object({
id: z.string(),
name: z.string(),
updatedAt: z.string(),
layers: z.array(LayerSchema),
});
export type ProjectResponse = z.infer<typeof ProjectResponseSchema>;
스키마와 타입을 함께 export하면 프론트엔드와 백엔드에서 동일한 타입을 공유할 수 있다.
스키마 재사용
스키마를 조합하고 변형해서 재사용할 수 있다.
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
createdAt: z.string(),
});
// pick: 특정 필드만 선택
const UserPublicSchema = UserSchema.pick({ id: true, name: true });
// { id: string; name: string; }
// omit: 특정 필드 제외
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
// { name: string; email: string; role: 'admin' | 'user'; }
// partial: 모든 필드를 optional로
const UpdateUserSchema = UserSchema.partial();
// { id?: string; name?: string; ... }
// extend: 필드 추가
const UserWithTokenSchema = UserSchema.extend({
accessToken: z.string(),
});
// merge: 두 스키마 합치기
const AuditSchema = z.object({ updatedBy: z.string() });
const UserWithAuditSchema = UserSchema.merge(AuditSchema);
CRUD API를 만들 때 기본 스키마 하나로 Create, Update, Response 스키마를 파생시킬 수 있다.
strict vs passthrough
외부 데이터에 예상치 못한 필드가 있을 때의 동작을 제어한다.
const Schema = z.object({ name: z.string() });
const data = { name: 'Kim', extra: 'field' };
// 기본 동작: 알 수 없는 필드 무시 (strip)
Schema.parse(data); // { name: 'Kim' }
// strict: 알 수 없는 필드가 있으면 에러
Schema.strict().parse(data); // ❌ ZodError
// passthrough: 알 수 없는 필드 유지
Schema.passthrough().parse(data); // { name: 'Kim', extra: 'field' }
보안이 중요한 API에서는 strict()로 예상치 못한 데이터를 차단하고, 프록시 패턴에서는 passthrough()로 데이터를 그대로 전달한다.
API Route에서 사용
// app/api/projects/route.ts
import { CreateProjectRequestSchema } from '@pixeldiff/api-contracts';
export async function POST(request: NextRequest) {
const body = await request.json();
// 검증
const result = CreateProjectRequestSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: result.error.errors[0].message },
{ status: 400 }
);
}
// result.data는 이미 타입이 보장됨
const { url } = result.data;
// ...
}
검증을 통과한 result.data는 TypeScript가 정확한 타입으로 추론한다. 추가 타입 캐스팅이 필요 없다.
유용한 메서드들
// 기본값
z.string().default('unnamed')
// 변환
z.string().transform((val) => val.toLowerCase())
// 조건부 검증
z.number().refine((n) => n % 2 === 0, '짝수여야 합니다')
// nullable vs optional
z.string().nullable() // string | null
z.string().optional() // string | undefined
// enum
z.enum(['draft', 'published', 'archived'])
// 범위 제한
z.number().min(0).max(100)
z.string().min(1).max(255)
z.array(z.string()).min(1).max(10)
Zod의 핵심은 Single Source of Truth다.
스키마 하나로 런타임 검증과 TypeScript 타입을 동시에 관리한다. 타입 정의와 검증 로직이 분리되어 싱크가 안 맞는 문제를 원천 차단한다.
API 경계에서 z.infer로 타입을 추출하고, safeParse로 검증하면 된다. 외부 데이터를 다루는 모든 곳에 적용할 수 있다.
다음 단계
Zod는 다양한 라이브러리와 통합된다:
- React Hook Form +
@hookform/resolvers: 폼 검증을 Zod 스키마로 처리 - tRPC: API 엔드포인트의 입출력을 Zod로 정의하고 타입 안전한 API 호출
- Next.js Server Actions: 서버 액션의 입력값 검증
외부 데이터를 다루는 곳이라면 어디든 Zod를 적용할 수 있다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd > TypeScript' 카테고리의 다른 글
| 타입스크립트 any 다루기 (0) | 2024.01.18 |
|---|
