"어? 화면이 안 보여요. 왜 이런 거죠? 이거 얼른 해결해주세요, 얼른!"
저희가 만든 어드민 웹을 쓰던 사용자에게서 이런 연락이 온 게 시작이었습니다. 화면은 하얗게 멈춰 있었고, 이유는 API 반환 값이 문서화된 명세와 미묘하게 다른 필드 하나 때문이었습니다.
더 깊이 파보면 원인은 프론트엔드 코드가 아니었습니다. API 반환 값이 명세와 다르거나, 사전 공지 없이 DB 값이 바뀌는 등 프론트엔드가 미리 대응할 수 없는 지점에서 발생한 문제였습니다. 다만 그 값이 어디서 바뀌었든, 실제로 화면이 깨지는 지점은 늘 프론트엔드였고 장애 대응도 저희 몫이었습니다.
같은 유형의 장애가 반복되자, 웹 프론트엔드 팀에서 나온 결론은 이거였습니다. "API 반환 값이 명세와 다를 수 있는 경우는 언제든 있다. 그러니 우리는 Pessimistic 개발론을 적용해서 모든 케이스에 대비해야 한다. 어떤 에러가 발생하든, 사용자는 서비스를 계속 정상적으로 쓸 수 있어야 한다."
여기서 원칙을 두 가지 더 세웠습니다.
- 명세와 다른 값은
undefined로 처리하고, 타입도undefined로 추론되도록 강제한다. 그래야 개발자가 예외 처리를 빼먹지 않고 코드를 짜게 되고, 실제로 에러가 발생해도 화면이 대응할 수 있습니다. - 명세와 반환 값이 다르다는 것 자체를 로그로 남긴다. 값이 다르다는 건 그 필드를 화면에 정확하게 보여줄 수 없다는 뜻이니, 어떤 필드가 왜 명세와 달랐는지 반드시 기록해야 합니다.
그러면 저희는 무엇을 해야 했을까요? 필요한 건 API 응답을 무조건 신뢰하는 대신, 반환값이 명세와 맞는지 실제로 검증하는 계층이었습니다. 문제는 이 검증 계층을 무엇으로 만드느냐였습니다.
무엇을 비교했고 왜 Typia였나
런타임에 API 반환 값을 확인할 수 있는 방법으로 yup, zod, Typia 세 가지를 검토했습니다.
- yup: 타입 시스템과 친화적이지 않았습니다. TypeScript 타입을 그대로 활용하기보다, yup만의 방식으로 스키마를 다시 정의해야 하는 구조였습니다.
- zod: 스키마 기반 검증이라는 점에서 유력한 후보였지만, 스키마를 별도로 새로 작성해야 한다는 점이 걸렸습니다. 저희 팀은 백엔드 API 명세에 맞춰
ServerModel이라는 타입을 정확하게 작성하는 규칙을 이미 갖고 있었는데, zod를 쓰면 이ServerModel과 zod 스키마 두 개를 나란히 관리해야 했습니다. API 반환 값이 바뀔 때마다ServerModel과 zod 스키마를 둘 다 고쳐야 했기 때문에, 업무 비효율을 초래할 게 뻔했고 휴먼 에러가 발생할 게 뻔했습니다. - Typia: 기존 TypeScript 타입을 그대로 분석해서 런타임 검증 코드를 생성해줍니다. 이미 작성해두던
ServerModel하나만 있으면 됐고, 별도로 스키마를 관리할 필요가 없었습니다.
yup과 Typia의 처리 속도도 직접 비교해봤습니다. 검증에 실패했을 때는 단순히 통과/실패만 가리는 게 아니라, 명세와 다른 필드를 찾아 안전한 값(undefined)으로 바꿔치기하는 작업까지 포함해서 측정했습니다.
| 측정 케이스 | API 응답이 명세와 일치할 때 (ms) | API 응답이 명세와 달라 값을 고쳐야 할 때 (ms) |
|---|---|---|
| API 검증 없음 (기존) | 0.000977 | 일치할 때와 동일 |
Typia is | 0.095947 | 0.033936 |
Typia validate | 0.125000 | 0.280029 |
Typia validate (값 보정) | 0.117920 | 1.315947 |
Typia is + validate (값 보정) | 0.092041 | 1.313965 |
| yup schema | 1.071045 | 측정 불가 (실패 시 예외를 던져 시간을 잴 수 없었음) |
| yup schema (값 보정) | 1.106934 | 2.655029 |
API 응답이 명세와 일치할 때 기준으로 Typia가 yup보다 약 11배 빨랐고, 명세와 달라 값을 고쳐야 하는 경우에도 2배 가까이 빨랐습니다. 여기에 ServerModel 하나만 관리하면 된다는 편의성까지 더해, Typia를 선택했습니다.
물론 검증 자체가 없는 경우(0.000977ms)와 비교하면 몇 ms의 오버헤드는 그대로 남습니다. 하지만 이 정도 차이는 감수할 만하다고 판단했습니다. 서비스 안정성을 높이려면 어쩔 수 없이 치러야 하는 비용이었습니다.
1차 문제: 모노레포 전체에 적용한 뒤 터진 로그 폭탄
앞서 세운 원칙대로, 검증에 실패한 필드만 골라 undefined로 치환하고 이걸 타입에도 그대로 반영하기로 했습니다. 검증에 실패했을 때 반환 타입이 원본 타입과 달라야, 컴포넌트 쪽에서 undefined 처리를 놓치지 않고 컴파일러가 강제로 잡아주기 때문입니다.
export type RecursiveUndefined<T> =
T extends Array<infer U>
? Array<RecursiveUndefined<U>> | undefined
: T extends object
? { [K in keyof T]: RecursiveUndefined<T[K]> } | undefined
: T | undefined;내부적으로는 이 타입을 "Pessimistic 타입"이라고 부릅니다. 모든 필드가 undefined일 수 있다고 가정하기 때문에, 이 타입을 쓰는 컴포넌트는 옵셔널 체이닝 없이는 컴파일이 되지 않습니다.
저희는 여러 어드민 서비스를 Turborepo 모노레포 하나로 관리하고 있었는데, 그중 10개 서비스에 순서대로 적용했습니다. 그런데 절반쯤 진행했을 때 예상 못한 문제가 나타났습니다. 그중 4개 서비스는 API 명세와 실제 반환 값의 괴리가 워낙 커서, 검증을 켜는 순간 로그가 폭발적으로 쏟아졌습니다.
- 원인: 문제가 발생한 4개 서비스는 연동된 API가 오래돼 명세 관리가 느슨했고, 이미 프론트에서 관행적으로 방어 코드를 두껍게 쌓아 우회해온 이력이 있었습니다.
- 해결: 서비스 정보에
needTypeMismatchAlert플래그를 추가해, 값 보정은 10개 서비스 전체에 동일하게 적용하되 로그 발생 여부만 서비스 단위로 껐습니다.
전체를 한 번에 밀어붙이는 대신, "값은 항상 안전하게 보정하되 로그는 실제로 조치 가능한 서비스에서만 남기게" 타협한 셈입니다.
2차 문제: 로컬 개발 서버가 88% 느려졌습니다
모노레포 내 모든 프로젝트에 적용을 마친 뒤, 개발자들 사이에서 "로컬 실행이 너무 느려졌다"는 이야기가 나오기 시작했습니다. 체감이 아니라 실제로 그런지 확인해보니, A 서비스는 개발 서버 첫 화면이 뜨는 데 54초 가까이 걸리고 있었습니다. 원인은 명확했습니다. Typia는 is, validate 호출부를 실제 검증 코드로 바꿔주는 Vite 플러그인으로 동작하는데, 이 컴파일 타임 변환이 로컬 개발 서버를 실행할 때마다 그대로 돌고 있었습니다.
프로덕션 안전성을 위해 넣은 기능이 개발 생산성을 깎아먹고 있었던 겁니다. 해결책은 검증 자체를 없애는 게 아니라, 필요할 때만 켜는 것이었습니다.
분기 기준으로 삼은 건 실행한 스크립트 이름이었습니다.
// packages/utils/vite.ts
const needTypia = (command: ScriptTypes) =>
command.includes("typia") || command.includes("build");// package.json
{
"scripts": {
"dev": "pnpm run env-dev && vite --mode development",
"dev-typia": "pnpm run env-dev && vite --mode development"
}
}두 스크립트의 명령어 자체는 같습니다. 다른 건 이름뿐이고, Vite 설정이 needTypia로 그 이름을 확인해 Typia 플러그인을 붙일지 말지 결정합니다. 평소 개발할 땐 dev로 검증을 끄고, 실제로 API 명세 불일치를 확인해야 할 때만 dev-typia로 켜도록 분기한 겁니다. 빌드는 스크립트 이름과 무관하게 항상 검증이 켜지므로 프로덕션 안전성은 그대로 유지됩니다. pnpm run dev 실행 시점부터 첫 화면이 그려질 때까지 걸리는 시간(FCP end-to-end)을 기준으로 적용 전후를 다시 측정했습니다.
| 서비스 | Typia 상시 실행 (기존) | 분기 적용 후 | 개선율 |
|---|---|---|---|
| A 서비스 | 53,794 ms | 6,312 ms | -88% |
| B 서비스 | 7,676 ms | 2,017 ms | -74% |
결과적으로 A 서비스의 개발 서버 로딩 시간이 88% 줄었고, 개발자 경험도 그만큼 개선됐습니다.
3차 문제: 10개 서비스에 반복되는 보일러플레이트
10개 서비스에 적용하고 나니, API 함수마다 거의 같은 코드가 반복되고 있었습니다.
// 서비스마다, API마다 반복되던 코드
const isChecked = is<T>(data);
if (!isChecked) {
const response = validate<T>(data);
return sanitizeTypiaErrors<T>({
data,
errors: response.errors,
needTypeMismatchAlert: SERVICE_INFO.SERVICE_A.needTypeMismatchAlert,
});
}
return data;Typia의 is, validate 함수 자체는 제네릭을 그대로 받아주지 않아서 함수 형태로 팩토리화하기 어려웠고, 결국 모든 GET API 함수마다 이 패턴을 손으로 복붙해야 했습니다. 실제로 모노레포 전체에서 이 코드가 200곳 넘게 반복되고 있었습니다. 찾아보니 Typia 저장소에도 같은 제약을 다룬 이슈가 있었는데, 메인테이너는 제네릭 지원 자체는 어렵다고 하면서 대신 createIs, createValidate로 타입을 미리 고정한 검증기를 만들어두는 커링(currying) 패턴을 추천했습니다. 저희도 같은 방식으로 팩토리 함수(createTypiaValidator)를 만들어, 호출부는 is와 validate만 넘기면 되도록 코드를 줄였습니다.
마지막으로 개발 중 어떤 API의 어떤 필드가 불일치했는지 바로 확인할 수 있도록, 검증 로그를 쌓고 지우는 setTypiaLog, clearTypiaLog 함수를 만들어 전 서비스에 적용했습니다. 런타임 에러가 터진 뒤 원인을 추적하던 기존 방식에서, 앱이 살아있는 상태로 불일치를 먼저 발견하는 방식으로 바뀌었습니다.
종합 결과
결과적으로 API 응답이 명세와 달라도 화면이 하얗게 멈추는 장애 없이 서비스가 계속 정상 동작하게 됐고, 불일치가 생기면 어떤 필드가 왜 명세와 다른지 로그로 바로 확인할 수 있어 원인 파악과 대응 속도도 빨라졌습니다.
- API 명세와 실제 값이 어긋나도 해당 필드만
undefined로 격리되고 나머지 화면은 정상 동작 — 서비스 중단 없음 - 검증 로그로 불일치 필드를 즉시 특정할 수 있어, 런타임 에러가 터진 뒤에야 원인을 추적하던 방식에서 벗어남
- 10개 서비스 전원에 API 응답 검증 적용, 그중 실제 조치 가능한 4개 서비스에서만 로그 활성화
- 개발 서버 로딩 시간도 최대 88% 단축 (A 서비스 기준)
배운 점
Typia 도입 여정을 통해 저희가 얻은 교훈은 이랬습니다. 비슷한 고민을 하고 계신 분들께 도움이 되었으면 합니다.
- 적용 자체를 목표로 삼지 맙시다. 런타임 안전장치를 켜두는 순간 로컬 개발 경험이 나빠질 수 있다는 걸, 저희는 개발자들의 불만이 쌓인 뒤에야 알았습니다. 프로덕션 안전성과 개발 생산성은 다른 축의 문제이고, 둘 다 잡으려면 켜고 끌 수 있는 스위치가 필요했습니다.
- 완벽한 일관성보다 감당할 수 있는 범위를 먼저 봅시다. 4개 서비스는 로그만 끄고 값 보정은 그대로 뒀습니다. 모노레포 전체 일괄 적용이 "전부 아니면 전무"일 필요는 없습니다.
- 반복이 세 번째 나오면 그때 팩토리로 뽑읍시다. 10개 서비스에 걸쳐 거의 동일한 코드가 반복되는 걸 보고 나서야
createTypiaValidator를 만들었는데, 처음부터 팩토리 형태로 설계했으면 마이그레이션 자체가 더 빨랐을 겁니다.
남은 이슈: 확정되지 않은 채 넘어가는 fallback 값
Typia와 RecursiveUndefined로 API 불일치는 격리했지만, 그 뒷정리는 아직 깔끔하지 않습니다. select()는 res &&로 null만 방어할 뿐, 필드 값 자체는 fallback 없이 그대로 통과시키는 게 지금 컨벤션입니다. 그러다 보니 "undefined를 확정하는 책임"이 컴포넌트까지 그대로 넘어가고, 화면마다 ?., ??, !가 산발적으로 붙습니다. 코드베이스를 스캔해보니 fallback 값만 ?? 0, ?? "", ?? "-", ?? []로 제각각인 곳이 500건 넘게 있었고, 방어 자체를 무력화하는 !(non-null assertion)도 곳곳에 남아 있었습니다.
Google TypeScript 스타일 가이드도 조회 지점에서 null이 그대로 추론되어 넘어가지 않게 하라고 권장합니다. 저희가 다음에 해야 할 일은 이렇습니다.
select()에서 모든 필드의 fallback을 확정한다.res.mobileNo를 그대로 넘기지 않고,res.mobileNo ?? "-"처럼 이 지점에서 값을 확정합니다. 다만 "값이 없다는 사실 자체가 의미 있는" 필드는 fallback으로 뭉개지 않고null로 명시적으로 남겨둡니다.- fallback 값을 타입별로 고정한다. 문자열은
"-"(빈 문자열""은 쓰지 않습니다 — 실제로 빈 값인지, 없어서 대체한 값인지 화면에서 구분이 안 되기 때문입니다), 숫자는0, 배열은[]로 통일합니다. - ClientModel에서
RecursiveUndefined래핑을 걷어낸다. 이 타입은 검증 시점(ServerModel)에는 필요하지만,select()를 거쳐 나온 ClientModel까지 그대로 물려받으면 컴포넌트가 옵셔널 체이닝에서 벗어날 방법이 없습니다.
이 규칙이 sanitizeTypiaErrors가 하던 일(명세 위반 감지·알림)을 대체하는 건 아닙니다. fallback은 화면이 안전하게 그려지도록 값을 확정하는 것이고, 명세 위반을 알리는 역할은 그대로 남겨둘 생각입니다. 10개 앱에 걸친 작업이라, 리스크가 낮은 것부터 먼저 정리하고 기존 코드는 도메인 단위로 점진적으로 옮겨갈 계획입니다.
이번 작업으로 API 명세 불일치가 서비스를 멈추는 일은 없앴지만, "그 값을 화면에 어떻게 안전하게 보여줄 것인가"라는 마지막 단계는 아직 진행형입니다. 여러분의 서비스도 API 명세와 실제 응답이 종종 어긋나시나요? 그렇다면 값을 막는 데서 멈추지 말고, 그 값이 화면에 어떤 모습으로 보일지까지 팀 차원에서 미리 정해두시길 권합니다.