기능을 다 만들고 Playwright로 화면 전체를 훑어보던 중, 이상한 장면을 발견했습니다. 가격 데이터가 아직 수집되지 않은 리전(tokyo)으로 견적을 냈는데, 금액 칸은 전부 -로 비어 있으면서도 AWS 행에 "최저가" 배지가 붙어 있고, 아래 차트에는 빈 막대가 그려지고 있었습니다.
단위 테스트는 전부 통과한 상태였습니다. 이 문제는 브라우저에서 실제 API 응답을 받아 화면을 그려봐야만 보이는 종류였습니다.
서버의 application.yml에는 이런 설정이 있었습니다.
spring:
jackson:
default-property-inclusion: non_null
이 설정은 응답 JSON에서 값이 null인 필드를 아예 생략합니다. 예를 들어 가격을 매칭하지 못한 견적 항목은 서버 쪽에서 이렇게 만들어집니다.
QuoteItemResponse.builder()
.vendor(vendor)
.note("조건을 만족하는 가격 데이터가 없습니다.")
// monthlyCostUsd는 설정하지 않음 → null
.build();
monthlyCostUsd가 null이면, non_null 설정 때문에 이 필드는 응답 JSON에 아예 나타나지 않습니다. 그런데 프론트엔드 타입은 이렇게 정의되어 있었습니다.
export interface QuoteItem {
vendor: VendorType;
monthlyCostUsd: number | null; // "null일 수 있다"고만 명시
// ...
}
TypeScript는 monthlyCostUsd: number | null이라고 정의했지만, 실제로 필드가 응답에서 아예 빠지면 자바스크립트 런타임에서는 null이 아니라 undefined가 됩니다. 그런데 이 항목이 최저가인지 판단하는 코드는 이렇게 되어 있었습니다.
const priced = quote.results.filter((r) => r.monthlyCostUsd !== null);
const cheapest = priced.length > 0 ? priced[0].vendor : null;
undefined !== null은 true입니다. 즉 가격이 없는 항목도 이 필터를 그대로 통과했습니다. 그리고 이 배열을 정렬해서 첫 번째를 "최저가"로 표시하는데, undefined가 섞인 상태로 정렬을 돌리니 원래 순서가 거의 그대로 유지되면서 AWS가 우연히 맨 앞에 남았던 것입니다.
백엔드 테스트는 "서버가 올바른 JSON을 반환하는가"만 검증했고, 프론트엔드 테스트는 "주어진 데이터로 컴포넌트가 올바르게 렌더링되는가"만 검증했습니다. 두 계층의 경계, 즉 "서버가 실제로 보낸 바이트가 클라이언트에서 어떤 타입으로 해석되는가"를 검증하는 테스트가 없었습니다.
TypeScript의 타입은 컴파일 타임에만 존재합니다. axios.get<QuoteResponse>(...)처럼 제네릭으로 타입을 지정해도, 그건 "이 모양일 것"이라는 선언일 뿐이고 런타임에 실제로 그 모양인지 검사해주지 않습니다. 서버가 계약과 다른 값(이 경우 null 대신 필드 생략)을 보내면, 타입 시스템은 그걸 알아채지 못한 채 조용히 통과시킵니다.
먼저 타입을 실제 런타임과 맞췄습니다. 값이 없을 수 있는 필드는 optional로 선언합니다.
export interface QuoteItem {
vendor: VendorType;
monthlyCostUsd?: number | null; // optional로 수정: undefined로 올 수 있음을 명시
}
그리고 "가격이 있는 항목인가"라는 판단을, 매번 비교 코드를 흩어놓는 대신 타입 가드 함수 하나로 모았습니다.
export const hasPrice = (item: QuoteItem): item is QuoteItem & { monthlyCostUsd: number } =>
typeof item.monthlyCostUsd === 'number';
item is QuoteItem & { monthlyCostUsd: number }는 TypeScript의 타입 가드 문법입니다. 이 함수를 filter에 쓰면, 통과한 배열의 타입이 자동으로 "monthlyCostUsd가 확실히 숫자인 배열"로 좁혀집니다.
const priced = quote.results.filter(hasPrice);
!== null 같은 산발적인 비교 대신 typeof === 'number'로 검사하면 null과 undefined를 모두 걸러내고, 호출부에서는 더 이상 이 값이 왜 없을 수 있는지 신경 쓰지 않아도 됩니다.
같은 시나리오를 다시 돌려봤습니다.
TypeScript를 쓴다고 해서 API 경계를 넘어온 값의 타입이 보장되는 건 아니라는 것을 실제 버그로 체감했습니다. 서버 설정(Jackson의 null 생략) 하나가 클라이언트 코드의 암묵적 가정(null이면 이렇게, 아니면 저렇게)을 조용히 무너뜨릴 수 있습니다.
그리고 이런 종류의 버그는 화면을 실제로 띄워봐야만 드러난다는 것도 다시 확인했습니다. 단위 테스트는 "내가 예상한 입력에 대해 코드가 맞게 동작하는가"를 검증하지만, 이 버그는 "내가 예상하지 못한 형태의 입력이 들어왔다"는 문제였습니다. Playwright로 실제 API 응답을 받아 화면을 그려보는 과정이 없었다면 발견하지 못했을 겁니다.
다음 편(마지막)에서는 이 프로젝트를 배포 가능한 상태로 만들면서 정리한, 아직 검증하지 못하고 정직하게 남겨둔 것들을 다룹니다.