sales-6

이지예·2026년 9월 17일

개발

목록 보기
28/58

series: 멀티클라우드 견적 자동화 시스템

증상: 데이터가 없는데 "최저가" 배지가 붙는다

기능을 다 만들고 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를 모두 걸러내고, 호출부에서는 더 이상 이 값이 왜 없을 수 있는지 신경 쓰지 않아도 됩니다.

수정 후 재검증

같은 시나리오를 다시 돌려봤습니다.

  • 가격 데이터가 없는 리전 → 배지 0개, "차트로 표시할 가격 데이터가 없습니다" 안내 표시
  • 가격 데이터가 있는 리전 → 배지 정확히 1개, 실제로 가장 싼 벤더에 표시

이 경험에서 남은 것

TypeScript를 쓴다고 해서 API 경계를 넘어온 값의 타입이 보장되는 건 아니라는 것을 실제 버그로 체감했습니다. 서버 설정(Jackson의 null 생략) 하나가 클라이언트 코드의 암묵적 가정(null이면 이렇게, 아니면 저렇게)을 조용히 무너뜨릴 수 있습니다.

그리고 이런 종류의 버그는 화면을 실제로 띄워봐야만 드러난다는 것도 다시 확인했습니다. 단위 테스트는 "내가 예상한 입력에 대해 코드가 맞게 동작하는가"를 검증하지만, 이 버그는 "내가 예상하지 못한 형태의 입력이 들어왔다"는 문제였습니다. Playwright로 실제 API 응답을 받아 화면을 그려보는 과정이 없었다면 발견하지 못했을 겁니다.

다음 편(마지막)에서는 이 프로젝트를 배포 가능한 상태로 만들면서 정리한, 아직 검증하지 못하고 정직하게 남겨둔 것들을 다룹니다.

profile
github: @dlwldP

0개의 댓글