"분명 타입스크립트는 에러가 없었는데요?"
금요일 오후 5시, 평화로운 퇴근 직전이었습니다. 갑자기 슬랙 알림이 울렸습니다. "사용자 목록 페이지가 안 켜져요. 흰 화면만 나와요." "어? 제가 로컬에서 돌렸을 땐 멀쩡한데요?"
부랴부랴 로그를 확인해 보니 이런 에러가 찍혀 있었습니다.
Uncaught TypeError: Cannot read properties of undefined (reading 'map')
코드 상으로는 문제가 없었습니다. TypeScript 컴파일러도 초록색(성공)이었고요.
원인을 파헤쳐 보니, 백엔드 개발자분이 API 응답 포맷을 살짝 바꾸셨더군요.
users: [] 배열이 와야 할 자리에, 데이터가 없으면 users: null을 보내도록 변경된 것이었습니다.
제 프론트엔드 코드는 users가 무조건 배열이라고 믿고(Type Inference) users.map()을 돌렸는데, 런타임에 null이 들어오니 폭발해버린(Map on null) 것이죠.
처음엔 뭐가 이해가 안 갔나? (TypeScript의 배신)
저는 "TypeScript를 쓰면 이런 타입 에러는 다 막아주는 거 아니었나?"라고 오해했습니다.
interface UserResponse { users: User[] }라고 타입을 정의해 뒀으니까, 당연히 배열이 올 거라고 믿었습니다.
하지만 깨달았습니다. TypeScript는 컴파일 타임(Compile Time)에만 존재합니다.
브라우저에서 코드가 실행되는 순간(Runtime), 타입스크립트는 전부 사라지고 순수 자바스크립트만 남습니다.
서버에서 실제로 무슨 데이터(null인지 undefined인지)가 날아오는지는 TypeScript가 알 방법이 없습니다.
그저 제가 정의한 인터페이스를 "믿어줄" 뿐이죠.
즉, 엔드포인트(API, DB, 사용자 입력)의 경계에서는 TypeScript가 무용지물이 됩니다.
어떤 포인트에서 이해가 됐나? (공항 검색대 비유)
이걸 "입국 심사대(Immigration Check)"에 비유하니 이해가 됐습니다.
- TypeScript: "여권 사진이 훈남이시네요." (서류상 검사 - 컴파일 타임)
- Runtime Data: 실제 비행기에서 내린 사람. (런타임 데이터)
- 문제 상황: 서류엔 훈남인데, 실제로 내린 사람은 테러리스트일 수 있습니다. 서류만 믿고 들여보내면 공항(내 앱)이 폭파됩니다.
- Zod: 비행기에서 내리자마자 실물을 엑스레이로 검사하는 보안 요원입니다. "가방 열어보세요. 서류랑 짐 내용물이 다르네요? 입국 거부(Error Throw)합니다."
우리는 "외부 데이터는 모두 오염되었다(Tainted)"고 가정해야 합니다. 무조건 검증하고, 검증된 데이터만 앱 내부로 들여보내야 합니다.
해결 과정 - Zod 도입하기
처음엔 if (data.users && Array.isArray(data.users)) 같은 방어 코드를 덕지덕지 발랐습니다.
하지만 필드가 100개라면? 코드가 너무 지저분해집니다.
그래서 Zod(Schema Validation Library)를 도입했습니다.
1단계 - 스키마 정의 (보안 수칙 만들기)
TypeScript 인터페이스 대신 Zod 스키마를 먼저 정의합니다. 이것이 우리의 '데이터 헌법'이 됩니다.
import { z } from "zod";
// 1. 스키마 정의 (Runtime Validation Logic)
const UserSchema = z.object({
id: z.number(),
name: z.string().min(2, "이름은 2글자 이상이어야 합니다"),
email: z.string().email(),
// 백엔드가 가끔 null을 보낸다고? nullable()로 처리!
role: z.enum(["admin", "user"]).nullable(),
});
// 2. 타입 추출 (Compile Time Type)
// interface User extends... 할 필요 없이 Zod가 알아서 타입을 만들어줍니다.
type User = z.infer<typeof UserSchema>;
이 User 타입은 TypeScript 컴파일러가 쓰고, UserSchema 객체는 자바스크립트 런타임이 씁니다.
일석이조(One Source of Truth)입니다.
2단계 - 데이터 검증 (입국 심사)
API로부터 데이터를 받았을 때, 바로 쓰지 말고 Zod에게 검사를 맡깁니다.
/* 기존 방식 (위험) */
// const user: User = await response.json() as User; // 'as'는 거짓말쟁이
/* Zod 방식 (안전) */
const json = await response.json();
try {
// parse: 데이터가 스키마와 다르면 즉시 에러(Throw)를 던집니다.
const user = UserSchema.parse(json);
console.log(user.name); // 여기까지 왔다면 100% 안전한 데이터임이 보장됨.
} catch (error) {
// 스키마 불일치! 프론트가 죽는 대신, 우아하게 에러 처리
console.error("서버 형식이 변경되었습니다:", error);
toast.error("데이터 형식이 올바르지 않습니다.");
}
이제 서버가 email 필드를 빠뜨리거나 id를 문자열로 보내면, Zod가 즉시 "잠깐! id는 숫자여야 하는데 문자열이 왔어"라고 막아섭니다.
앱이 undefined 참조 에러로 흰 화면이 되는 대신, 우리가 제어 가능한 에러 상태가 됩니다.
3단계 - safeParse (조용한 처리)
에러를 throw 하는 게 부담스럽다면 safeParse를 씁니다.
const result = UserSchema.safeParse(json);
if (!result.success) {
// result.error에 상세한 에러 내용이 담겨있음
console.log(result.error.format());
return null;
}
// result.data는 검증된 User 타입
return result.data;
깊이 파고들기 - Zod가 DX(개발자 경험)를 구원한다
Zod를 쓰면서 얻은 의외의 수확은 "백엔드 API 문서가 거짓말을 해도 내가 알 수 있다"는 것입니다.
스웨거(Swagger) 문서에는 '필수(Required)'라고 되어 있는데, 실제로는 null이 오는 경우가 허다합니다.
Zod 없이 개발할 땐 "왜 안 되지?" 하고 제 코드를 의심하며 3시간을 디버깅했습니다. Zod를 쓴 뒤로는 3초 만에 알 수 있습니다.
ZodError: Expected string, received null at "address.city"
이 로그를 캡처해서 백엔드 개발자분께 보내드리면 됩니다. "서버 응답이 문서랑 다르네요." 책임 소재가 명확해지고, 디버깅 시간이 획기적으로 줄어듭니다.
Transform API (데이터 정제)
데이터를 검증하면서 동시에 변환할 수도 있습니다.
const PriceSchema = z.string()
// "1,000원" -> 1000 (숫자)으로 변환
.transform((val) => parseInt(val.replace(/,/g, ""), 10));
const result = PriceSchema.parse("1,000"); // result는 숫자 1000
서버에서 오는 Date 문자열을 JS Date 객체로 자동 변환하거나, 빈 문자열을 null로 바꾸는 등의 전처리를 깔끔하게 처리할 수 있습니다.
Application: 폼 유효성 검사 (feat. React Hook Form)
API 응답뿐만 아니라, 사용자 입력(Form) 검증에도 Zod가 최고입니다.
react-hook-form과 zod-resolver를 함께 쓰면 환상의 짝꿍입니다.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
username: z.string().min(2, "너무 짧아요"),
age: z.number().min(18, "미성년자는 가입 불가"),
});
const { register, handleSubmit } = useForm({
resolver: zodResolver(schema), // Zod 스키마를 폼 검증 규칙으로 사용!
});
복잡한 if (value.length < 2) 로직을 짤 필요 없이, 스키마 한 줄로 폼 유효성 검사가 끝납니다.
한 줄 요약
TypeScript는 런타임에 당신을 지켜주지 못한다. 외부 데이터(API)는 무조건 Zod로 검문검색해라. '그냥 타입 단언(as)'을 쓰는 건 시한폭탄을 안고 코딩하는 것과 같다.