본문 바로가기

TypeScript로 API 구축하기: 프로덕션급 타입 기반 API 만드는 법

스캐폴딩부터 배포까지, 타입 기반 DTO, 검증, 클라이언트 및 프로덕션 베스트 프랙티스를 활용하여 TypeScript로 API를 구축하는 방법을 알아보세요.

TypeScript로 API 구축하기: 프로덕션급 타입 기반 API 만드는 법

출시 당일 여러분의 TypeScript API는 완벽해 보였을 것입니다. 라우트는 컴파일되었고, 프론트엔드는 공유 타입을 가져왔으며, 에디터에는 '배포해도 안전함'을 의미하는 깔끔한 초록색 표시가 가득했을 테니까요.

하지만 백엔드에서 응답 필드 하나를 변경하거나, 예상치 못한 곳에서 nullable 값이 나타나거나, 모바일 클라이언트가 이전 페이로드 형식을 계속 호출하는 순간 문제가 발생합니다. 바로 여기서 대부분의 TypeScript API 작업이 중단됩니다. 문법 때문이 아니라, 드리프트(drift) 때문입니다.

목차

런칭 후 타입 기반 API가 실패하는 이유와 방지 방법

타입이 지정된 API는 일반적인 배포 과정에서 깨지기 쉽습니다. 한 팀이 응답 필드 이름을 바꾸거나, 다른 팀이 부분 마이그레이션을 위해 nullable 분기를 추가할 수 있습니다. 모바일 업데이트는 웹보다 늦기 때문에 구버전 클라이언트는 이전 페이로드를 계속 보냅니다. 로컬 타입을 업데이트한 모든 레포지토리에서는 TypeScript 컴파일이 성공하겠지만, 프로덕션 환경의 계약(contract)은 이미 깨진 상태입니다.

이러한 실패를 '계약 드리프트(contract drift)'라고 부릅니다.

타입 기반 API가 초기 런칭 후 프로덕션 환경에서 실패하는 세 가지 주요 원인을 설명하는 다이어그램입니다.

TypeScript 덕분에 API 개발이 더 즐거워졌지만, 동시에 취약한 계약을 과신하게 만들기도 했습니다. 공유 인터페이스, 라우트 제네릭, 타입 기반 fetch 래퍼(wrapper)는 개발 단계에서는 도움이 됩니다. 하지만 두 번째 혹은 열 번째 배포 이후에도 네트워크를 통해 전달되는 JSON이 여전히 해당 타입과 일치한다는 것을 보장하지는 못합니다.

프로덕션에서 통용되는 규칙은 간단합니다.

검증되지 않은 JSON이 애플리케이션 로직으로 바로 흘러 들어간다면, 여러분의 TypeScript 타입은 현실이 아닌 의도만을 설명하고 있는 것입니다.

해결책은 화려한 타입 기술(type gymnastics)이 아니라, '진실'이 어디에 머무느냐에 달려 있습니다.

  • 경계(boundary)에서 검증하세요. 나머지 code 로직이 데이터에 접근하기 전에 request body, params, headers, 그리고 다운스트림 서비스의 응답까지 모두 파싱하세요.
  • DTO를 도메인 모델로 매핑하세요. transport shape은 비즈니스 객체와 분리해 API 변경이 코드베이스 전체로 새어 나가지 않게 하세요.
  • 컨트랙트에서 타입을 생성하세요. OpenAPI, JSON Schema 또는 schema-first 프레임워크를 쓰면 클라이언트와 서버가 같은 source of truth를 공유할 수 있습니다.
  • breaking change는 공개 이벤트로 취급하세요. 필드 shape이 바뀌면 의도적으로 버전을 매기고, 다른 외부 컨트랙트 변경처럼 알리세요.

DTO 매핑은 팀이 가장 자주 건너뛰는 부분입니다. 처음엔 불필요해 보이지만, 몇 번의 릴리스를 거치면 이 계층이 string | null 모든 서비스와 프론트엔드 화면으로 레거시 필드 별칭이 퍼지는 것을 막아줍니다. 경계에서의 작은 변환 단계가 나중의 대규모 리팩터링보다 저렴합니다.

Typed API가 실패하는 것도 대개 에러 컨트랙트가 뒷전이기 때문입니다. 성공 페이로드는 신경 쓰지만, 실패 페이로드는 그날 던져진 예외가 직렬화되는 대로 됩니다. 그러면 클라이언트는 설계된 적 없는 shape를 기반으로 재시도 로직, 사용자 메시지, 모니터링을 구축합니다. 결국 같은 문제가 다른 형태로 나타납니다. Drift.

버전 관리에도 같은 원칙이 필요합니다. 팀이 클라이언트를 깨뜨리는 건 한 번의 대대적인 재작성 때문인 경우가 드뭅니다. 대신 합리적인 로컬 변경이 쌓여 비호환성이 되는 식으로 깨뜨립니다. 명확한 API 컨트랙트 진화를 위한 버전 관리 전략은 해당 변경 사항을 소비자에게 도달하기 전에 미리 볼 수 있게 해줍니다.

목표는 모든 곳에 TypeScript를 사용하는 것이 아닙니다. 출시 후 여러 번의 배포, 다양한 클라이언트, 그리고 실제 프로덕션 데이터가 유입되면서 초기 설계했던 깔끔한 타입들이 무너지기 시작할 때, 데이터 규약(contract)을 얼마나 정확하게 유지하느냐가 핵심입니다.

TypeScript API 프로젝트를 올바르게 스캐폴딩하는 방법

타입이 지정된 API는 보통 첫날에는 깔끔해 보입니다. 하지만 6개월 뒤에는 어떤 라우트는 검증되지 않은 입력을 받고, 다른 라우트는 원시 데이터를 읽으며, process.env세 번째 라우트는 클라이언트가 예상치 못한 형태의 데이터를 반환하게 됩니다. 스캐폴딩이 한꺼번에 무너지는 일은 드뭅니다. 대신 일반적인 기능 개발 과정에 규약이 어긋나는 현상이 서서히 스며들게 만듭니다.

규약을 우회하기 어렵게 만드는 프로젝트 구조부터 시작하세요.

IDE 터미널에 TypeScript 에러가 표시된 노트북 화면을 보며 code를 타이핑하는 개발자.

팀의 성격에 맞는 프레임워크 선택하기

TypeScript 기반 API를 구축할 때, 첫 번째 프레임워크 결정은 문법보다는 규약(contract)에 대한 엄격함을 어디에 둘 것인가에 관한 것입니다.

  • Express 추상화를 최소화하고 미들웨어 모델에 익숙한 팀에게 적합합니다. 간섭이 적어 편리하지만, 모든 라우트가 각자만의 유효성 검사, 에러 형태, 응답 컨벤션을 만들기 시작하면 관리가 어려워질 수 있습니다.
  • Fastify 소규모 및 중규모 백엔드 팀을 위한 강력한 기본 선택지입니다. 플러그인 시스템이 깔끔하며, 스키마 작업을 라우트 레이어에 가깝게 밀어붙여 런타임 동작과 타입이 일치하도록 유지하는 데 도움을 줍니다.
  • Nest 많은 기여자가 참여하고, 모듈을 공유하며, 명확한 소유권 경계가 필요한 대규모 codebase에는 적합합니다. 다만 그만큼 절차가 복잡해지며, 서비스 규모가 작다면 이 비용이 부담될 수 있습니다.

저는 보통 팀이 실제로 사용할 것보다 과한 framework를 도입하는 것을 피합니다. Fastify와 validation library, 그리고 생성된 contract type만 갖춘 가벼운 서비스가, 일관성 없는 컨벤션이 겹겹이 쌓인 무거운 스택보다 리팩터링 시 더 잘 버텨내는 경우가 많습니다.

경계를 보호하는 폴더 구조를 사용하세요

폴더 이름보다 중요한 것은 import pressure입니다. 만약 route에서 database model에 직접 접근하거나, service가 ORM entity를 클라이언트에 그대로 반환한다면, 이미 구조가 무너질 준비가 된 것입니다.

프로덕션 환경에서도 견고한 레이아웃은 보통 transport 관심사와 application 관심사를 분리합니다:

  • src/routes HTTP 연결 설정 전용
  • src/schemas request 및 response schema 전용
  • src/dto transport type 및 code 매핑 전용
  • src/services use case 및 orchestration 전용
  • src/domain 단일 endpoint의 수명보다 오래 유지되어야 하는 business model 전용
  • src/clients downstream integration 전용
  • src/errors 공통 에러 타입 및 타입 좁히기(narrowing) 헬퍼용
  • src/config 앱 시작 시 설정 파싱용

그 src/dto 해당 레이어는 단순 반복 작업이 아닙니다. 도메인 로직에 영향을 주거나 무관한 엔드포인트로 외부 변경 사항이 유출되지 않도록 API가 외부 변화를 흡수하는 완충 지대 역할을 합니다.

설정(Configuration)도 마찬가지입니다. 시작 시 환경 변수를 한 번만 파싱하고, 잘못된 값은 즉시 에러를 발생시키며, 타입이 지정된 설정 객체를 앱의 나머지 부분으로 내보내세요. 핸들러 내부에서 계속해서 process.env 데이터를 읽는 팀은 결국 TypeScript가 도와줄 수 없는 복잡한 런타임 분기 로직을 마주하게 됩니다. 이 환경 설정 가이드는 해당 패턴을 표준화해야 할 때 좋은 참고 자료가 될 것입니다.

기능을 추가하기 전에 컴파일러를 더 엄격하게 설정하세요

프로덕션 환경의 API라면 안전하지 않은 code 작성이 번거로울 정도로 설정되어 있어야 합니다.

유용한 기본 설정은 다음과 같습니다:

  • strict 활성화됨
  • useUnknownInCatchVariables 활성화됨
  • noUncheckedIndexedAccess 팀이 추가적인 규율을 준수할 수 있다면 활성화
  • Node, 테스트, 번들링, 툴링이 모두 동일하게 해석하지 않는 한 path alias 사용 금지
  • 분리 build, typecheck, CI 내 lint 스크립트

느슨한 설정은 tsconfig 의도치 않은 drift가 쌓이게 만듭니다. 엄격한 설정은 불일치를 프로덕션 동작으로 이어지기 전에 눈에 보이는 작업으로 전환해 줍니다.

Lint 규칙도 도움이 됩니다. 특히 다음을 방지하는 규칙이 유용합니다. any, floating promises, 그리고 public contract 모듈에서의 실수로 인한 export를 방지합니다. 이것이 runtime validation을 대체할 수는 없지만, contract 실수가 숨어들 수 있는 지점을 줄여줍니다.

초기에 결정해야 할 스캐폴딩 선택지가 하나 더 있습니다. OpenAPI spec을 어디서 가져올지 결정하고, 그 결정을 route layer와 가깝게 유지하세요. 어떤 팀은 code 우선 스키마에서 이를 생성합니다. 다른 팀은 spec에서 먼저 server stub와 type을 생성하기도 합니다. 두 방식 모두 가능합니다. 진짜 문제는 spec을 첫 릴리스 이후 아무도 확인하지 않는 부수적인 결과물로 취급하는 것입니다.

초기 스캐폴딩 이후에는, 타입이 지정된 contract가 일반적인 request-response 서비스 외부에서 어떻게 동작하는지 비교해 보는 것이 좋습니다. Streamkap Flink TypeScript 가이드 단순한 HTTP 핸들러를 넘어, 긴 파이프라인 전반에서 계약 불일치(contract drift)가 발생하는 스트림 기반 또는 이벤트 중심 시스템을 다루는 팀에게 유용합니다.

경계에서의 DTO 설계 및 입력값 검증

타입이 지정된 API는 첫날에는 아무 문제 없어 보입니다. 하지만 6개월 뒤, 경계 지점에서 버그가 나타납니다. 모바일 클라이언트가 여전히 예전 필드를 보내거나, 파트너사가 프론트엔드에서 필수라고 가정했던 속성을 누락하기도 합니다. 리팩터링 과정에서 내부 ORM 컬럼이 공개 응답에 노출될 수도 있죠. TypeScript는 코드베이스 내부에서 제 역할을 다했지만, 계약(contract)은 여전히 어긋나 있었습니다.

이것이 DTO 설계가 중요한 이유입니다. 단순히 요청 본문(request body)을 깔끔하게 만드는 것이 아니라, 첫 출시 이후에도 공개 타입의 신뢰성을 유지하기 위함입니다.

공개 계약(Public contracts)과 내부 모델은 동일해서는 안 됩니다

일치하는 DTO 는 네트워크를 통해 전달되는 데이터를 정의합니다. 반면 도메인 모델(domain model) 은 애플리케이션이 실제 비즈니스 로직을 수행하는 데 필요한 것을 정의합니다. 이 두 가지를 하나로 합치면 초기에는 코드가 몇 줄 줄어들지 몰라도, 나중에는 비용이 많이 드는 결합(coupling) 문제를 야기합니다.

보안 시스템 경계를 유지하고 API 계약을 관리하기 위한 DTO 설계 및 검증 프로세스 다이어그램.

라우트에서 다음과 같은 데이터를 받는다면:

type CreateOrderRequestDto = {
  customerId: string
  items: Array<{ sku: string; quantity: number }>
  note?: string | null
}

서비스 레이어는 다음과 같이 더 좁고 깔끔한 타입을 받아야 합니다. OrderDraft 정규화된 문자열, 검증된 수량, 그리고 한 곳에서 적용된 기본값을 포함합니다.

경계 지점에서는 보통 다음과 같은 단계가 필요합니다:

  1. 인바운드 페이로드 파싱
  2. 데이터 구조 및 필드별 제약 조건 검증
  3. DTO를 도메인 객체로 매핑
  4. 도메인 객체에 비즈니스 로직 실행
  5. 결과를 응답 DTO로 매핑
  6. 전송 전 아웃바운드 응답 검증

6단계는 자주 생략되곤 합니다. 하지만 이 단계는 프라이빗 필드, 안정적인 응답으로 유출된 nullable 값, 그리고 리팩터링 중 발생한 의도치 않은 스키마 변경을 잡아내는 중요한 단계입니다.

비즈니스 로직이 데이터에 접근하기 전에 검증하세요

컴파일 타임 타입은 네트워크에서 오는 JSON을 검증하지 못합니다. 또한 다른 서비스가 여전히 다음 조건을 만족하는 구조를 반환할 때로부터 보호해주지도 못합니다. unknown 런타임에서 예상치 못한 오류를 발생시킵니다.

TypeScript에서 API을(를) 제대로 구현하려면 Zod를 주로 사용합니다. Zod는 런타임에 데이터를 파싱하고 나머지 code의 타입을 추론해주기 때문입니다. Valibot이나 io-ts 같은 라이브러리도 가능하지만, 중요한 것은 라이브러리 종류가 아니라 규칙입니다. 신뢰할 수 없는 데이터는 다른 로직이 사용하기 전에 반드시 먼저 파싱해야 합니다.

리팩토링에도 견고한 패턴은 다음과 같습니다:

  • Inbound 스키마 잘못된 형식의 요청 데이터 차단
  • Dependency 스키마 외부 API 및 내부 서비스의 응답 검증
  • Outbound 스키마 API이(가) 발행할 응답 데이터 검증

많은 타입 기반 API가 출시 후 실패하는 지점이 바로 이 중간 계층입니다. 팀들은 요청(request)은 검증하면서도 하위 서비스의 응답(response) 검증은 건너뛰곤 합니다. 그러다 외부 벤더의 필드명이 바뀌면 곧바로 운영 장애로 이어지게 됩니다.

제가 사용하는 실무 원칙은 이렇습니다. 가공되지 않은 JSON은 라우트 계층에서 멈춰야 합니다.

code 매핑은 낭비가 아닙니다. 데이터 불일치를 발견할 수 있는 핵심 지점입니다.

많은 팀이 DTO 매핑을 반복 작업처럼 느껴 기피합니다. 하지만 저는 프로덕션에서 정반대를 봤습니다. 얇은 매핑 계층이야말로 contract 변경이 눈에 보이고, 리뷰 가능하며, 국소화되는 지점입니다.

예를 들어:

  • transport는 허용 note?: string | null
  • 도메인 모델은 저장할 수 있음 note: string 함께 "" 기본값으로
  • 응답 DTO에서는 생략할 수 있음 note 비어 있으면 아예

이는 서로 다른 세 대상에게 각각 다른 세 가지 진실입니다. 이를 하나의 공유 인터페이스로 취급하면 클라이언트가 깨지기 전까지 그 차이를 숨깁니다.

webhook은 소비자가 여러분의 페이로드 구조를 수년간 유지할 수 있어 이 점을 더 분명하게 보여줍니다. 팀에서 이 문제를 해결 중이라면, 이 webhook 페이로드 설계 예시가 유용한 참고 자료가 될 것입니다.

공유 타입은 Single Source of Truth가 명확할 때만 유용합니다

백엔드 인터페이스를 프론트엔드로 복사하는 방식은 결국 데이터 불일치를 초래합니다. 공유 패키지가 도움이 될 수 있지만, 의도적으로 공개된 타입에만 한정됩니다.

대규모 코드베이스에서 더 잘 버티는 구조는 다음과 같습니다:

  • 공개용 Request/Response 스키마를 영속성 모델(Persistence models)과 분리하여 정의합니다
  • 해당 공개 스키마로 OpenAPI를 생성하거나, OpenAPI를 기반으로 서버 타입을 먼저 생성합니다
  • 생성된 Contract 타입을 핸들러 및 클라이언트와 가까운 곳에 둡니다
  • 도메인 타입과 ORM 모델은 내부적으로만 유지합니다
  • 호환성이 중요한 경우 공개 DTO의 버전을 의도적으로 관리합니다

이러한 분리 방식은 다음 가이드라인과도 일치합니다: Azure SDK 팀의 TypeScript 설계 가이드라인이 가이드라인은 안정적인 공개 인터페이스를 유지하고, 내부 구현 세부 사항이 Contract에 노출되지 않도록 강조합니다.

유지보수가 쉬운 경계는 의도적으로 단순해 보입니다

좋은 코드는 덜 복잡합니다.

이전에는 프론트엔드가 백엔드에서 직접 반환하는 ORM 객체를 fetch().json() 마치 진실인 양 그대로 신뢰했고, 하나의 공유 인터페이스가 모든 레이어를 대변하려 했습니다. 개선 후에는 각 경계에서 데이터를 파싱하고, DTO는 범위를 좁게 유지하며, 도메인 모델은 내부적으로 보호됩니다. 생성된 타입은 공개 계약(public contract)을 정의하고, code 매핑을 통해 변경 사항을 명확히 드러냅니다.

절차가 다소 늘어날 수 있습니다. 하지만 호출자가 문제를 발견하기 전에, 데이터 불일치를 미리 검토할 수 있는 지점을 갖게 됩니다.

타입이 완벽한 API 클라이언트 생성 및 사용하기

타입이 지정된 클라이언트는 출시 당일에는 완벽해 보입니다. 하지만 3개월 뒤, 한 엔드포인트에서 nullable 필드가 반환되기 시작하고, 다른 곳에서는 커서 기반 페이지네이션이 추가되며, 모바일 앱은 이전 버전의 계약을 유지하고 있다면 어떨까요? TypeScript 타입은 여전히 컴파일되지만, 호출부에서는 런타임 에러가 발생합니다.

이것이 바로 클라이언트 레이어의 역할입니다. 단순히 에디터의 자동 완성을 좋게 만드는 것이 아니라, 첫 출시 이후에도 공개된 계약이 실제 데이터와 일치하도록 보장해야 합니다.

타입 클라이언트 전략 선택하기

클라이언트 구조는 팀의 선호도가 아니라 API의 실제 복잡도에 맞춰야 합니다.

접근 방식 추천 상황 트레이드오프
직접 구현한 fetch wrapper 소규모 앱, 특이한 인증 흐름, 빠른 반복 개발 시작이 빠르고 시간이 지나도 호출부별로 분리하기 쉬움
OpenAPI code 생성 안정적인 스키마를 가진 직관적인 REST API 기본기는 탄탄함. 커스텀 인증, 스트리밍, 특이한 페이지네이션에는 추가 작업 필요
SDK 스타일의 타입 정의 클라이언트 다수 팀이 사용하는 플랫폼, 공개 API, 장기 유지보수 통합 환경 유지보수 비용이 가장 높음. API 자체가 제품인 경우 최상의 사용자 경험 제공

직접 구현한 클라이언트는 작은 규모의 서비스에 적합함

커스텀 fetch wrapper는 API이 내부용이거나, 서비스 규모가 작거나, 스키마 생성보다 전송 동작이 더 중요할 때 합리적인 선택입니다. 저도 관리자 도구나 초기 단계 서비스에는 여전히 이 방식을 사용합니다.

가장 큰 문제는 드리프트(drift) 현상입니다. 한 팀은 래퍼에 재시도 규칙을 추가하고, 다른 팀은 이를 우회하며, 또 다른 팀은 응답 타입을 프론트엔드로 복사해 타입을 확장해 버립니다. any 결국 첫 번째 불일치가 발생한 후에는, 서버의 실제 응답을 제대로 반영하지 못하는 '타입이 지정된' 호출만 남게 됩니다.

다음과 같은 상황이라면 직접 클라이언트를 구현하는 것이 좋습니다:

  • API 규모가 작고 내부용일 때
  • 컨트랙트가 너무 자주 바뀌어 code를 재생성하는 것이 오히려 번거로울 때
  • 커스텀 트랜스포트 동작 구현이 작업의 대부분을 차지할 때
  • TypeScript 어노테이션뿐만 아니라 클라이언트 측 런타임 파싱까지 직접 관리할 의향이 있을 때

마지막 항목이 매우 중요합니다. response.json() 함수 시그니처와 달리 런타임에서는 unknown 데이터를 반환하게 됩니다.

실무적인 기본값은 OpenAPI 생성입니다.

안정적인 REST API의 경우, 생성된 타입이 유지보수와 안정성 측면에서 가장 효율적입니다. 중복된 타입 작성을 줄여주고, 컨트랙트 변경 사항을 pull request에서 명확히 확인할 수 있게 해줍니다.

리팩토링에도 견고하게 살아남는 패턴은 간단합니다. 공개 컨트랙트로부터 타입을 생성하고, 생성된 레이어는 얇게 유지하며, 사용자의 편의성이 필요한 곳에만 작은 래퍼를 추가하는 것입니다. OpenAPI TypeScript 생성 워크플로우 해당 모델에 잘 부합합니다.

유용한 분리 방식은 다음과 같습니다:

  • 생성된 code는 요청 및 응답 타입을 관리합니다
  • 가벼운 SDK 래퍼는 인증 주입, 재시도, 페이지네이션 헬퍼를 관리합니다
  • 런타임 검증은 여전히 서버 경계와 신뢰할 수 없는 입력이 시스템에 유입되는 모든 곳에서 수행됩니다
  • DTO 매핑을 명시적으로 유지하여 내부 모델 변경이 클라이언트 계약에 영향을 주지 않도록 합니다

이러한 하이브리드 접근 방식은 생성된 code를 단순하게 유지하며, 이는 장점입니다. 단순한 code은 재생성, 검토 및 교체가 더 쉽기 때문입니다.

애플리케이션 code가 생성된 클라이언트를 직접 사용하기 전에 래퍼로 감싸세요

생성된 함수는 보통 코드베이스 전반에서 사용하기에는 너무 로우 레벨입니다. 호출할 때마다 매번 학습해야 하는 전송 세부 사항을 노출하기 때문입니다.

가벼운 래퍼를 사용하면 정책을 일관되게 유지할 수 있는 단일 지점을 확보할 수 있습니다:

  • 기본 헤더 및 요청 ID 추가
  • 에러 형태(error shapes) 정규화
  • 페이지네이션을 iterator 또는 헬퍼 메서드로 제공
  • 멀티 테넌트 환경을 위한 요청별 인증 오버라이드 지원
  • 직접 작성하는 대신 생성된 요청 및 응답 타입 유지

예를 들어, 애플리케이션 code은 다음과 같이 호출해야 합니다, client.orders.listAll() 또는 client.orders.list({ cursor })매번 호출할 때마다 쿼리 스트링을 직접 조합하거나 페이지네이션 메타데이터를 파싱할 필요가 없습니다.

API이 제품인 경우 SDK 스타일의 클라이언트가 적합합니다

Public API와 공유 플랫폼 서비스에는 생성된 엔드포인트 함수 이상의 것이 필요합니다. 사용자는 일관된 네이밍, 예측 가능한 에러, 그리고 도메인에 맞는 메서드 뒤로 숨겨진 전송 세부 사항을 기대합니다.

훌륭한 클라이언트 사용성(ergonomics)은 보통 다음과 같습니다:

  • client.orders.list() 타입이 지정된 페이지 또는 async iterator 반환
  • client.files.stream() 저수준 fetch 설정이 노출되지 않도록 스트리밍 처리
  • auth는 전역으로 설정하거나 요청별로 재정의할 수 있습니다
  • 호출자는 임의로 던져진 페이로드 대신 안정적인 타입의 에러 객체를 받습니다

이는 유지보수 비용을 높입니다. 또한 각 팀이 경계 규칙을 조금씩 다르게 재구축하는 것을 방지하지 못하며, 이는 결국 contract drift를 유발합니다.

완벽한 타입의 클라이언트가 최종 목적지는 아닙니다. 진정한 목적지는 API가 진화한 후에도 타입이 실제와 일치하는 클라이언트입니다. 이는 generation이 public contract에서 시작되고, runtime validation이 경계를 보호하며, DTO mapping이 내부 변경 사항이 외부로 유출되는 것을 막아주기 때문입니다.

실질적인 도움이 되는 에러 핸들링, 테스트 및 Observability

대부분의 TypeScript API 예제는 지나치게 낙관적입니다. 요청은 성공하고, JSON은 interface와 일치하며, 실패는 throw new Error("something went wrong")상황이 발생합니다. 실제 운영 환경은 결코 그렇게 친절하게 동작하지 않습니다.

첫 번째 해결책은 기계적인 접근입니다. TypeScript에서는 catch된 값을 다음과 같이 취급해야 합니다 unknown그 후 값을 읽기 전에 message, stack또는 응답 속성을 cause, 경계값 검증, non-Error throw의 정규화, 그리고 관측성을 위한 요청 컨텍스트 부착 ("TypeScript 에러 핸들링 가이드).

TypeScript 환경에서 탄력적인 프로덕션 code를 작성하기 위한 5가지 베스트 프랙티스를 소개합니다.

에러를 다루기 전 타입을 좁히세요

안전하지 않은 catch 블록은 여전히 흔합니다:

try {
  await client.orders.create(input)
} catch (error) {
  logger.error(error.message)
}

이는 너무 많은 것을 가정하고 있습니다. error 일 수도 있고 Error 일 수도 있습니다.

더 안전한 패턴:

try {
  await client.orders.create(input)
} catch (error: unknown) {
  if (error instanceof Error) {
    logger.error({ message: error.message, stack: error.stack })
    throw new OrderSyncError("Order sync failed", { cause: error })
  }

  logger.error({ error })
  throw new OrderSyncError("Order sync failed", { cause: new Error("Non-Error thrown") })
}

코드가 약간 길어 보일 수 있습니다. 하지만 서드파티 SDK 오류, JSON 파싱 실패, 예상치 못한 throw가 발생했을 때 훨씬 더 안정적으로 동작합니다.

일시적인 에러일 때만 재시도하세요

두 번째 핵심 개선 사항은 에러 분류입니다. TypeScript SDK 및 API 작업을 위한 가이드는 다음과 같은 명확한 규칙으로 수렴됩니다: 네트워크 에러나 HTTP 429, 503 응답 같은 일시적 실패는 재시도하고, 조기에 검증하며, 에러 컨텍스트를 보존하고, 비즈니스 로직 실패에 대한 재시도는 피하세요. 또한 다음과 같은 가이드를 권장합니다. Promise.all 실패 시 즉시 중단되는 병렬 작업의 경우, Promise.allSettled 부분적인 성공이 허용되는 경우에는 (SDK 에러 핸들링 패턴을 사용하세요.).

저는 세 가지 범주로 나누는 것을 선호합니다:

  • Validation errors (검증 에러) 프로세스를 떠나기 전 요청 자체가 잘못되었음을 의미합니다.
  • Transient errors (일시적 에러) Backoff를 적용한 재시도로 성공할 수 있습니다.
  • Permanent errors (영구적 에러) 비즈니스 로직, 권한, 리소스 누락 등을 나타내며 사용자에게 즉시 노출되어야 합니다.

이러한 분류는 단순한 “실패 시 재시도” 헬퍼보다 훨씬 더 나은 code 를 제공합니다.

필드 규칙: 재시도(Retry)는 도메인 로직의 불일치가 아닌, 전송 계층의 불확실성 문제로 다뤄야 합니다.

Observability는 단순히 실패를 기록하는 것이 아니라, 실패의 원인을 설명할 수 있어야 합니다.

컨텍스트 없는 로그는 Observability가 아닙니다. TypeScript에서 API를 구현할 때는 요청이 경계를 넘을 때마다 correlation ID, 라우트 이름, 요청 메타데이터, 그리고 정규화된 에러 형식을 반드시 포함하세요.

유용한 기준점:

  • Correlation ID 하나의 인바운드 요청을 하위 호출들과 연결합니다.
  • 구조화된 로그 단순 텍스트 덩어리가 아닌 필드 단위로 저장하세요.
  • 경계 로그(Boundary logs) 파싱 실패와 비즈니스 예외를 분리하여 캡처하세요.
  • Alerting 단순한 상태 코드가 아닌 에러 클래스와 라우트를 기준으로 code 볼륨을 관리하세요

모바일이나 클라이언트 앱이 이 API들을 사용한다면, 관측성(observability) 업데이트도 중요합니다. 릴리스 레이어에서 활용할 수 있는 실질적인 옵션은 Capgo이며, Capacitor 및 Electron 환경에서 Live Update를 배포하고 추적할 수 있는 타입 정의 API를 제공합니다. 이는 클라이언트 측 컨트랙트 수정 시, 앱 스토어의 업데이트를 무작정 기다리는 대신 제어된 롤아웃과 버전별 가시성을 확보해야 할 때 유용합니다. 전체 피드백 루프를 강화하려는 팀에게는 다음의 앱 관측성(app observability) 가이드가 서버 측 로깅과 함께 활용하기 좋습니다.

구현체가 아닌 컨트랙트를 테스트하세요

유닛 테스트만으로는 드리프트(drift)를 잡아낼 수 없습니다. 드리프트가 발생하는 지점에 테스트를 추가하세요.

  • 경계 검증(Boundary validation) 테스트: 잘못된 형식의 입력을 스키마에 넣고 실패 형태를 검증하세요.
  • 컨트랙트(Contract) 테스트: 실제 HTTP 응답이 정의된 컨트랙트와 일치하는지 확인하세요.
  • 타입이 지정된 에러 어설션: 일시적 및 영구적 실패가 올바르게 정규화되는지 확인합니다.
  • 클라이언트 통합 테스트: 생성되거나 래핑된 클라이언트가 실제 응답을 제대로 파싱하는지 확인합니다.

타입이 지정된 API를 위한 강력한 테스트 스위트는 단순히 code 경로를 증명하는 것이 아닙니다. 여러분의 컨트랙트가 여전히 유효함을 증명하는 것입니다.

신뢰와 제어력을 갖춘 프로덕션 배포

릴리스 품질은 영웅적인 노력(heroics)이 아닌, 반복 가능한 루프에서 나옵니다.

TypeScript 파이프라인에서 신뢰할 수 있는 API를 구축하려면 보통 몇 가지 필수 요소가 필요합니다: CI에서의 스키마 체크, 생성된 아티팩트에 대한 타입 체크, 머지 전 컨트랙트 diff 리뷰, 그리고 클라이언트 준비 상태에 따라 속도를 조절하거나 롤백할 수 있는 배포 경로입니다.

무너지지 않는 릴리스 루프

팀이 잘 따를 수 있도록 프로덕션 체크리스트를 간결하게 유지하는 것을 선호합니다:

  • 컨트랙트 드리프트 발생 시 CI 실패 처리: OpenAPI가 변경되면, 생성된 타입과 클라이언트도 동일한 변경 사항 내에서 업데이트되어야 합니다.
  • 공유 contract의 버전 관리 전략: Public DTO 패키지는 가벼운 리팩토링이 아닌 엄격한 릴리스 관리가 필요합니다.
  • 채널 또는 코호트별 배포: 모든 사용자가 한꺼번에 breaking change를 겪게 하지 마세요.
  • 간편한 롤백 유지: contract, client 또는 web bundle을 되돌리는 작업은 운영상 매우 단순해야 합니다.

인프라와 배포 워크플로우를 동시에 변경하는 팀을 위해, 이 개발자를 위한 클라우드 마이그레이션 가이드는 유용한 계획 참조가 됩니다. API 신뢰성은 code 변경뿐만 아니라 플랫폼 전환 과정에서도 저하될 수 있기 때문입니다.

정확성만큼 중요한 것은 제어력입니다

마지막 프로덕션 습관은 버전별 가시성 확보입니다. 어떤 client build가 어떤 contract를 호출하는지, 어떤 릴리스가 성공적으로 채택되었는지, 배포 후 실패가 어디에 집중되는지 알아야 합니다. 이는 모든 사용자가 즉시 업데이트하지 않는 모바일 및 edge-distributed 환경에서 특히 중요합니다.

스택에 Capacitor 또는 Electron이 포함되어 있다면, live update 툴링을 통해 contract 버그 수정부터 사용자에게 반영되기까지의 지연 시간을 줄일 수 있습니다. 중요한 것은 추상적인 의미의 '더 빠른 업데이트'가 아니라, 바로 채널 기반 롤아웃, 롤백 방지 및 버전별 관측성 계약(contract) 수정 사항을 안정적으로 제어할 수 있습니다.

스키마, 런타임 검증, 클라이언트 생성 및 릴리스 운영이 서로 맞물려 작동할 때 Typed API의 안정성이 유지됩니다. 어느 한 단계라도 누락되면 다른 단계에서 심각한 보완 작업이 필요하게 됩니다.


Capgo를 사용하면 팀은 Capacitor 및 Electron 앱에 대해 타입이 지정된 방식으로 웹 bundle 수정을 배포하고, 롤아웃 채널을 제어하며, 버전별 채택률과 오류를 모니터링할 수 있습니다. 스토어 심사를 기다리지 않고 API 계약 수정 사항을 클라이언트에 빠르게 전달해야 한다면 다음을 방문하세요. Capgo.

Capacitor 앱을 위한 Live Updates

웹 레이어 버그가 라이브 상태일 때, 앱 스토어 승인을 며칠씩 기다리지 말고 Capgo를 통해 수정 사항을 배포하세요. 네이티브 변경 사항은 정상적인 리뷰 절차를 따르는 동안, 사용자는 백그라운드에서 업데이트를 받습니다.

블로그 최신 글

Capgo는 진정한 프로급 모바일 앱을 만드는 데 필요한 최고의 인사이트를 제공합니다.