← 포스트 목록
TypeScriptTypiaAPI타입설계

"어? 화면이 안 보여요" API 명세 불일치를 서비스 중단 없이 막아낸 이야기

2026-07-09 · 약 15분 소요

요약

문제
API 반환 값이 명세와 미묘하게 달라질 때마다 화면이 하얗게 멈춤. 값이 어디서 바뀌었든 사용자에게 드러나는 건 화면이라, 프론트엔드에서 막을 방법이 필요했음
실행
API 응답을 무조건 신뢰하는 대신 검증 계층을 두기로 하고, yup·zod와 비교 끝에 이미 관리하던 API 응답 타입을 그대로 재사용할 수 있는 Typia 도입. RecursiveUndefined 타입 설계로 명세와 다른 필드만 골라내 안전한 값으로 바꾸고, 200곳 넘게 반복되던 검증 코드는 팩토리 패턴으로 정리. 일부 서비스에서 검증 로그가 쏟아지는 건 서비스 단위 플래그로, 느려진 개발 서버는 실행 스크립트 분기로 해결
결과
명세가 어긋나도 해당 필드만 격리되고 화면은 계속 동작. 런타임 에러가 터진 뒤 원인을 쫓던 방식에서, 어떤 필드가 왜 달랐는지 로그로 먼저 확인하는 방식으로 전환

"어? 화면이 안 보여요. 왜 이런 거죠? 이거 얼른 해결해주세요, 얼른!"

저희가 만든 어드민 웹을 쓰던 사용자에게서 이런 연락이 온 게 시작이었습니다. 화면은 하얗게 멈춰 있었고, 이유는 API 반환 값이 문서화된 명세와 미묘하게 다른 필드 하나 때문이었습니다.

더 깊이 파보면 원인은 프론트엔드 코드가 아니었습니다. API 반환 값이 명세와 다르거나, 사전 공지 없이 DB 값이 바뀌는 등 프론트엔드가 미리 대응할 수 없는 지점에서 발생한 문제였습니다. 다만 그 값이 어디서 바뀌었든, 실제로 화면이 깨지는 지점은 늘 프론트엔드였고 장애 대응도 저희 몫이었습니다.

같은 유형의 장애가 반복되자, 웹 프론트엔드 팀에서 나온 결론은 이거였습니다. "API 반환 값이 명세와 다를 수 있는 경우는 언제든 있다. 그러니 우리는 Pessimistic 개발론을 적용해서 모든 케이스에 대비해야 한다. 어떤 에러가 발생하든, 사용자는 서비스를 계속 정상적으로 쓸 수 있어야 한다."

여기서 원칙을 두 가지 더 세웠습니다.

그러면 저희는 무엇을 해야 했을까요? 필요한 건 API 응답을 무조건 신뢰하는 대신, 반환값이 명세와 맞는지 실제로 검증하는 계층이었습니다. 문제는 이 검증 계층을 무엇으로 만드느냐였습니다.

무엇을 비교했고 왜 Typia였나

런타임에 API 반환 값을 확인할 수 있는 방법으로 yup, zod, Typia 세 가지를 검토했습니다.

yup과 Typia의 처리 속도도 직접 비교해봤습니다. 검증에 실패했을 때는 단순히 통과/실패만 가리는 게 아니라, 명세와 다른 필드를 찾아 안전한 값(undefined)으로 바꿔치기하는 작업까지 포함해서 측정했습니다.

측정 케이스API 응답이 명세와 일치할 때 (ms)API 응답이 명세와 달라 값을 고쳐야 할 때 (ms)
API 검증 없음 (기존)0.000977일치할 때와 동일
Typia is0.0959470.033936
Typia validate0.1250000.280029
Typia validate (값 보정)0.1179201.315947
Typia is + validate (값 보정)0.0920411.313965
yup schema1.071045측정 불가 (실패 시 예외를 던져 시간을 잴 수 없었음)
yup schema (값 보정)1.1069342.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 명세와 실제 반환 값의 괴리가 워낙 커서, 검증을 켜는 순간 로그가 폭발적으로 쏟아졌습니다.

전체를 한 번에 밀어붙이는 대신, "값은 항상 안전하게 보정하되 로그는 실제로 조치 가능한 서비스에서만 남기게" 타협한 셈입니다.

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 ms6,312 ms-88%
B 서비스7,676 ms2,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)를 만들어, 호출부는 isvalidate만 넘기면 되도록 코드를 줄였습니다.

마지막으로 개발 중 어떤 API의 어떤 필드가 불일치했는지 바로 확인할 수 있도록, 검증 로그를 쌓고 지우는 setTypiaLog, clearTypiaLog 함수를 만들어 전 서비스에 적용했습니다. 런타임 에러가 터진 뒤 원인을 추적하던 기존 방식에서, 앱이 살아있는 상태로 불일치를 먼저 발견하는 방식으로 바뀌었습니다.

종합 결과

결과적으로 API 응답이 명세와 달라도 화면이 하얗게 멈추는 장애 없이 서비스가 계속 정상 동작하게 됐고, 불일치가 생기면 어떤 필드가 왜 명세와 다른지 로그로 바로 확인할 수 있어 원인 파악과 대응 속도도 빨라졌습니다.

배운 점

Typia 도입 여정을 통해 저희가 얻은 교훈은 이랬습니다. 비슷한 고민을 하고 계신 분들께 도움이 되었으면 합니다.

남은 이슈: 확정되지 않은 채 넘어가는 fallback 값

Typia와 RecursiveUndefined로 API 불일치는 격리했지만, 그 뒷정리는 아직 깔끔하지 않습니다. select()res &&로 null만 방어할 뿐, 필드 값 자체는 fallback 없이 그대로 통과시키는 게 지금 컨벤션입니다. 그러다 보니 "undefined를 확정하는 책임"이 컴포넌트까지 그대로 넘어가고, 화면마다 ?., ??, !가 산발적으로 붙습니다. 코드베이스를 스캔해보니 fallback 값만 ?? 0, ?? "", ?? "-", ?? []로 제각각인 곳이 500건 넘게 있었고, 방어 자체를 무력화하는 !(non-null assertion)도 곳곳에 남아 있었습니다.

Google TypeScript 스타일 가이드도 조회 지점에서 null이 그대로 추론되어 넘어가지 않게 하라고 권장합니다. 저희가 다음에 해야 할 일은 이렇습니다.

이 규칙이 sanitizeTypiaErrors가 하던 일(명세 위반 감지·알림)을 대체하는 건 아닙니다. fallback은 화면이 안전하게 그려지도록 값을 확정하는 것이고, 명세 위반을 알리는 역할은 그대로 남겨둘 생각입니다. 10개 앱에 걸친 작업이라, 리스크가 낮은 것부터 먼저 정리하고 기존 코드는 도메인 단위로 점진적으로 옮겨갈 계획입니다.

이번 작업으로 API 명세 불일치가 서비스를 멈추는 일은 없앴지만, "그 값을 화면에 어떻게 안전하게 보여줄 것인가"라는 마지막 단계는 아직 진행형입니다. 여러분의 서비스도 API 명세와 실제 응답이 종종 어긋나시나요? 그렇다면 값을 막는 데서 멈추지 말고, 그 값이 화면에 어떤 모습으로 보일지까지 팀 차원에서 미리 정해두시길 권합니다.