TypeScript 타입만으로 API 경계를 믿으면 안 되는 이유
컴파일 타입과 런타임 입력 사이의 간극을 줄이기 위해 스키마 검증, 오류 응답, 로깅을 어떻게 둘지 설명한다. TypeScript·Runtime Validation·API 관점에서 기본 개념부터 구현 순서, 검증 방법, 문제가 생겼을 때 되돌리는 절차까지 설명한다.
핵심 요약
TypeScript의 타입 검사는 빌드 시점에 끝난다. HTTP 요청, 웹훅, 메시지 큐, 캐시, 환경 변수처럼 런타임에 들어오는 값은 모두 unknown으로 보고 스키마 검증을 통과시킨 뒤에만 도메인 코드로 넘겨야 한다. 검증 실패 응답, 관측 지표, 스키마 변경 절차까지 함께 설계해야 API 경계가 실제 통제로 작동한다.
TypeScript를 쓰면 함수 내부의 실수는 크게 줄어든다. 그러나 타입 주석은 JavaScript로 변환될 때 대부분 사라지므로, 외부에서 들어온 JSON이 선언한 인터페이스와 같다는 보장은 생기지 않는다. as User 같은 단언은 검증이 아니라 컴파일러에게 질문을 멈추라고 지시하는 문법이다.
따라서 API 경계의 목표는 “올바른 타입이라고 믿는 것”이 아니라 “검증되지 않은 값이 핵심 로직에 도달하지 못하게 하는 것”이어야 한다. 이 기준은 사용자 요청뿐 아니라 신뢰하는 파트너 API, 데이터베이스에서 읽은 오래된 레코드, 큐에 남아 있던 이벤트에도 동일하게 적용된다.
런타임 검증이 필요한 경계
다음 값은 소스가 내부 시스템이더라도 기본적으로 신뢰하지 않는 편이 안전하다.
- HTTP 요청의 본문, 쿼리, 경로 파라미터, 헤더
- 결제·인증·CRM 공급자의 웹훅과 API 응답
- 메시지 큐, 이벤트 버스, 배치 파일, CSV 업로드
- 환경 변수와 원격 설정 값
- 캐시와 데이터베이스의 구버전 레코드
- 브라우저 저장소에서 복원한 상태
한 번 검증한 값을 여러 계층에서 반복 검증할 필요는 없다. 대신 경계에서 검증한 뒤 ValidatedOrder, VerifiedWebhook처럼 의미가 드러나는 도메인 타입으로 변환하고, 내부 함수는 검증된 타입만 받게 만드는 것이 좋다.
입력 형식이 정상이어도 반복 호출로 비용·계정·데이터를 남용할 수 있으므로 API 레이트 리밋은 트래픽 제한이 아니라 남용 방어 장치다의 행위 기반 제한을 별도 계층으로 둔다.
실패 모드부터 설계한다
| 실패 모드 | 흔한 원인 | 운영 영향 | 필요한 통제 |
|---|---|---|---|
| 필수 필드 누락 | 클라이언트 구버전, 공급자 스키마 변경 | 예외·부분 저장 | 필수/선택 필드 명시, 계약 테스트 |
| 타입은 맞지만 범위가 틀림 | 음수 수량, 과도한 페이지 크기 | 비용 증가·데이터 오염 | 최소·최대·열거형 검증 |
| 알 수 없는 필드 주입 | 대량 할당, 잘못된 객체 병합 | 권한 필드 변조 | 기본 거부 또는 허용 목록 |
| 중첩 객체·배열 과대 | 자동 생성 요청, 악의적 입력 | CPU·메모리 고갈 | 본문 크기·깊이·항목 수 제한 |
| 날짜·숫자 변환 오류 | 문자열 자동 변환 | 시간대·금액 계산 오류 | 명시적 파싱과 단위 고정 |
| 공급자 응답 드리프트 | 외부 API 변경 | 장애가 내부로 전파 | 응답 검증, 격리 큐, 대체 경로 |
검증은 보안 필터이면서 데이터 품질 통제다. 형식만 맞는지 확인하고 끝내면 role: "admin", quantity: 999999, callbackUrl: "file://..." 같은 값이 통과할 수 있다. 스키마에는 형식, 길이, 범위, 허용 값, 상호 필드 관계까지 포함해야 한다.
구현 패턴: unknown에서 도메인 타입으로
아래 코드는 특정 라이브러리 채택을 강제하는 예제가 아니다. 현재 스택에 맞는 런타임 스키마 도구를 사용하되, 입력 타입을 unknown으로 시작하고 성공·실패 경로를 분리하는 구조가 핵심이다.
import { z } from "zod";
const CreateUserInput = z
.object({
email: z.string().email().max(254),
displayName: z.string().trim().min(1).max(80),
age: z.number().int().min(14).max(120).optional(),
})
.strict();
export type CreateUserInput = z.infer<typeof CreateUserInput>;
export function parseCreateUser(body: unknown): CreateUserInput {
const result = CreateUserInput.safeParse(body);
if (!result.success) {
throw new InputValidationError({
code: "INVALID_REQUEST_BODY",
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
});
}
return result.data;
}
strict()처럼 알 수 없는 필드를 거부할지, 호환성을 위해 제거할지는 API별로 결정해야 한다. 관리·결제·권한 변경 API는 거부가 안전한 경우가 많다. 공개 읽기 API는 일정 기간 알 수 없는 필드를 무시할 수 있지만, 그 선택도 문서화하고 지표로 남겨야 한다.
오류 응답은 상세하되 내부 구조를 노출하지 않는다
클라이언트에는 안정적인 오류 코드와 수정 가능한 필드 정보만 제공한다. 정규식, 데이터베이스 컬럼명, 스택 트레이스, 원본 토큰은 응답에 넣지 않는다.
{
"error": {
"code": "INVALID_REQUEST_BODY",
"fields": [
{ "path": "email", "reason": "invalid_format" }
],
"requestId": "req_..."
}
}
서버 로그에는 요청 ID, 엔드포인트, 스키마 버전, 실패한 규칙 코드, 클라이언트 버전 정도를 남긴다. 원문 입력은 개인정보·비밀값이 섞일 수 있으므로 기본 저장하지 말고, 꼭 필요하면 마스킹과 짧은 보존 기간을 별도로 적용한다.
스키마 변경과 호환성 운영
런타임 검증을 도입하면 스키마 변경이 곧 배포 위험이 된다. 다음 순서를 기본값으로 두면 갑작스러운 차단을 줄일 수 있다.
- 새 필드를 먼저 선택 사항으로 추가하고 생산자와 소비자 버전을 관측한다.
- 구버전 요청 비율과 누락 필드 비율을 대시보드로 확인한다.
- 생산자 배포가 완료된 뒤 경고 모드에서 강제 모드로 전환한다.
- 필드 제거는 읽기 호환 기간을 둔 뒤 진행한다.
- 웹훅·이벤트에는 명시적
schemaVersion과 재처리 전략을 둔다.
외부 공급자 응답이 검증에 실패했을 때 전체 요청을 500으로 끝내는 것이 항상 정답은 아니다. 결제 상태처럼 안전하게 추정할 수 없는 값은 실패 폐쇄가 적절하지만, 추천 문구처럼 부가 기능은 격리하고 기본값을 제공하는 편이 사용자 영향이 작다.
탐지 신호와 알림 기준
다음 지표를 엔드포인트와 스키마 버전별로 관측한다.
- 검증 실패율과 이전 배포 대비 증가폭
- 필드별 실패 상위 항목과 알 수 없는 필드 출현율
- 요청 본문 크기·배열 길이·중첩 깊이의 상위 백분위
- 특정 클라이언트 버전 또는 공급자에서만 발생하는 실패
- 파싱 실패 뒤 재시도 폭증, 격리 큐 적체, 처리 지연
- 검증을 우회한 코드 경로 수와
as unknown as사용 증가
단순 실패 건수보다 정상 트래픽 대비 비율과 변화량이 중요하다. 새 앱 버전 배포 직후 특정 필드 실패가 급증하면 공격보다 계약 불일치일 가능성이 높고, 다양한 IP에서 과대 배열이 반복되면 자원 고갈 시도를 의심할 수 있다.
테스트와 배포 체크리스트
- 모든 외부 입력이
unknown에서 시작하는가. - 성공 사례뿐 아니라 누락, 초과, 잘못된 열거형, 경계값을 테스트했는가.
- 본문 크기와 컬렉션 길이 제한이 프레임워크·프록시·애플리케이션에 일관되게 적용됐는가.
- OpenAPI 또는 이벤트 계약과 실제 스키마 구현의 차이를 CI에서 검사하는가.
- 검증 실패 로그에 원문 비밀값과 개인정보가 남지 않는가.
- 공급자 응답 실패 시 차단, 격리, 기본값 중 어떤 동작을 할지 정했는가.
- 강제 모드 전환과 롤백 조건이 수치로 정의됐는가.
사고 대응 순서
검증 실패가 급증하면 먼저 공격으로 단정하지 말고 최근 배포, 클라이언트 버전, 공급자 변경을 함께 확인한다. 잘못된 강제 규칙이면 이전 스키마를 임시 허용하되 만료 시간을 둔다. 악의적 입력이면 WAF나 게이트웨이에서 크기·속도 제한을 강화하고, 영향을 받은 저장 데이터가 있는지 역추적한다. 이미 오염된 데이터는 단순 코드 롤백으로 복구되지 않으므로 정정 스크립트와 감사 로그가 필요하다.
참고 기준
- TypeScript Handbook: The Basics
- OWASP API Security Project
- OWASP API10:2023 Unsafe Consumption of APIs
- OpenAPI Specification
결론
TypeScript는 내부 코드의 계약을 강하게 만들지만 외부 세계를 정직하게 만들지는 않는다. 안전한 API는 타입 선언의 양이 아니라, 검증되지 않은 값이 들어오는 지점을 얼마나 명확히 찾고 실패를 얼마나 예측 가능하게 처리하는지로 평가해야 한다. 첫 작업은 스키마 라이브러리 비교가 아니라 현재 서비스의 모든 입력 경계를 목록으로 만드는 것이다.
전체 댓글 0개