같은 주문 상태를 두고 화면은 paid, BFF는 success, 운영 문서는 “완료”라고 부르던 시기가 있었습니다. 기능은 돌아갔고 테스트도 통과했습니다. 다만 회의에서 “결제된 주문”이라는 말이 나올 때마다 세 사람이 서로 다른 것을 떠올렸습니다.
이 글은 그 어긋남이 실제 버그로 터진 뒤, 한 건의 풀 리퀘스트 리뷰가 어떻게 설계 리뷰로 번졌는지에 대한 기록입니다. 리뷰 스레드에 남은 지적들을 순서대로 따라가 보겠습니다.
발단 — 주문 한 건이 화면마다 다르게 보였습니다
고객센터에서 올라온 문의는 짧았습니다. “주문 목록에서는 결제완료인데, 상세로 들어가면 결제대기라고 나옵니다.” 처음에는 캐시 문제로 넘겼습니다. 새로고침하면 사라진다는 보고도 있었기 때문입니다.
그런데 재현이 됐습니다. 그것도 아주 안정적으로, 특정 조건에서만 그랬습니다. 부분 취소가 한 번이라도 있었던 주문에서만 목록과 상세의 표시가 갈렸습니다. 데이터는 하나인데 화면 두 곳이 다른 결론을 내리고 있었습니다.
아래 상황은 여러 팀에서 반복적으로 관찰되는 계약 불일치 패턴을 하나의 사례로 엮은 것입니다. 특정 기업의 실제 시스템이나 사건을 옮긴 것이 아니며, 코드와 로그는 설명을 위해 단순화한 예시입니다.
리뷰 코멘트 1 — “BFF가 가끔 이상한 값을 보내는 것 같습니다”
제가 처음 단 코멘트가 이것이었습니다. 가설은 명확했습니다. 부분 취소가 얽히면 BFF가 상태 값을 잘못 계산해서, 어떤 요청에는 success를, 어떤 요청에는 pending을 내려 준다는 것입니다. 그렇다면 고칠 곳은 BFF 한 곳입니다.
확인 방법은 무식했습니다. 스테이징에서 주문 상세와 주문 목록 응답을 하루치 전부 수집한 뒤, 스키마 검증기를 돌려 규격 위반과 값 분포를 봤습니다. 같은 order_id에 대해 두 엔드포인트가 서로 다른 상태를 준 적이 있는지를 세는 것이 핵심이었습니다.
응답 전수 검증 결과 (스테이징 1일치)
$ node scripts/audit-contract.mjs --day=1
수집: /orders 4,120건 · /orders/:id 3,977건
스키마 위반: 0건
status 허용값 밖의 문자열: 0건
동일 order_id 두 엔드포인트 응답 불일치: 0건
status 값 분포
"pending" 812
"success" 2,904
"partially_refunded" 218
"refunded" 186
불일치 0건. 가설이 죽었습니다. BFF는 한 번도 규격을 벗어난 적이 없었고, 같은 주문에 대해 항상 같은 문자열을 내려 주고 있었습니다. 문제는 서버가 아니라, 그 문자열을 받는 쪽에 있었습니다.
여기서 한 가지를 배웠습니다. “가끔 잘못된 값이 온다”는 의심은 검증하기 쉬운데도 대개 검증하지 않고 넘어갑니다. 전수 수집은 반나절이면 끝나고, 그 결과가 음성이면 탐색 범위가 절반으로 줄어듭니다.
리뷰 코멘트 2 — “이 매핑, 여기 말고 또 있습니다”
프런트엔드를 status 문자열로 검색했더니 매핑이 세 곳에서 나왔습니다. 목록 컴포넌트, 상세 컴포넌트, 그리고 알림 배너 유틸이었습니다. 서로 다른 시기에 다른 사람이 작성했고, 다루는 값의 집합이 달랐습니다.
before — 두 곳에 흩어져 있던 매핑
// components/OrderList.tsx
const label = (status: string) =>
status === "success" ? "결제완료"
: status === "refunded" ? "환불완료"
: "결제대기"; // 그 외는 전부 여기로 떨어집니다
// components/OrderDetail.tsx
const LABELS: Record<string, string> = {
success: "결제완료",
pending: "결제대기",
refunded: "환불완료",
partially_refunded: "부분환불",
};
const label = LABELS[order.status] ?? "결제대기";
범인은 partially_refunded였습니다. 목록 쪽 삼항 연산자에는 이 값이 없어서 기본 분기인 “결제대기”로 떨어졌고, 상세 쪽에는 있어서 “부분환불”로 표시됐습니다. 부분 취소가 있는 주문에서만 재현되던 이유가 이것이었습니다.
타입 관점에서 보면 이 코드의 진짜 문제는 오타가 아니라 string입니다. 상태가 string인 한 컴파일러는 어떤 값이 빠졌는지 알 방법이 없습니다. BFF가 새 상태를 하나 추가해도 프런트는 조용히 잘못된 라벨을 그립니다. 이 계열의 실수를 타입으로 막는 방법은 TypeScript 기초 가이드의 유니온 타입 절에 예제로 정리해 두었습니다.
리뷰 코멘트 3 — “빠뜨리면 빌드가 깨지게 만듭시다”
고친 방향은 두 가지였습니다. 첫째, 상태의 허용 집합을 한 곳에 두고 그것을 유일한 출처로 삼는 것. 둘째, 새 상태가 늘어났을 때 처리를 빠뜨리면 런타임이 아니라 타입 검사에서 걸리게 만드는 것입니다. 후자는 never를 이용한 전수 검사로 해결했습니다.
after — 계약을 한 곳에 두고 누락을 컴파일 타임에 잡습니다
// contracts/order.ts — 프런트와 BFF가 함께 참조하는 유일한 출처
export const ORDER_STATUSES = [
"pending",
"success",
"partially_refunded",
"refunded",
] as const;
export type OrderStatus = (typeof ORDER_STATUSES)[number];
// ui/orderLabel.ts
function assertNever(value: never): never {
throw new Error(`처리하지 않은 주문 상태: ${String(value)}`);
}
export function orderLabel(status: OrderStatus): string {
switch (status) {
case "pending": return "결제대기";
case "success": return "결제완료";
case "partially_refunded": return "부분환불";
case "refunded": return "환불완료";
default: return assertNever(status);
}
}
이 구조에서 BFF가 "disputed" 같은 상태를 하나 추가하면, ORDER_STATUSES에 값을 넣는 순간 orderLabel이 컴파일되지 않습니다. default로 넘어온 값이 더 이상 never가 아니기 때문입니다. 화면 개발자는 배포 후 사용자 문의로 알게 되는 대신, 브랜치를 만드는 시점에 알게 됩니다.
경계에서 들어오는 데이터에 대해서는 런타임 검증도 한 겹 붙였습니다. 타입은 컴파일 타임 약속일 뿐이므로, 네트워크 응답이 실제로 그 약속을 지키는지는 별도로 확인해야 합니다. 이 두 층을 어떻게 나누는지는 TypeScript 오류 처리 가이드에서 다룹니다.
리뷰 코멘트 4 — “그런데 이 이름들이 맞습니까”
여기서 리뷰가 설계 리뷰로 넘어갔습니다. 목록을 한 줄로 세워 놓고 보니, 상태 이름 자체가 뒤섞여 있었습니다. success는 결제 시도의 결과를 가리키는 말이고, refunded는 주문의 생애주기를 가리키는 말입니다. 두 축이 하나의 문자열 필드에 눌려 들어가 있었습니다.
부분 환불된 주문은 결제에 성공한 주문이기도 합니다. 그래서 “결제완료 목록”을 뽑는 쿼리가 화면마다 다르게 작성되어 있었고, 리포트 숫자가 팀마다 달랐던 것도 같은 뿌리였습니다. 버그는 하나였지만 원인은 어휘였습니다.
“유니온이 자꾸 길어진다면 화면 요구가 늘어난 것이 아니라, 서로 다른 축 두 개를 한 필드에 밀어 넣고 있다는 신호입니다.”
결론은 축을 나누는 것이었습니다. 결제 결과(payment)와 이행 상태(fulfillment)를 별도 필드로 분리하고, 화면이 필요로 하는 “표시용 상태”는 그 둘에서 파생시키는 순수 함수로 두었습니다. 판별 유니온을 쓰면 조합 중 실제로 존재할 수 없는 상태를 타입 수준에서 배제할 수도 있습니다.
축을 분리한 뒤의 계약
export type Order =
| { kind: "awaiting"; paidAmount: 0 }
| { kind: "paid"; paidAmount: number; refundedAmount: 0 }
| { kind: "partiallyRefunded"; paidAmount: number; refundedAmount: number }
| { kind: "fullyRefunded"; paidAmount: number; refundedAmount: number };
// 화면 라벨은 계약에서 파생시킵니다. 저장하지 않습니다.
export const displayLabel = (o: Order) => orderLabel(toStatus(o));
이 형태에서는 “환불 금액이 있는데 결제되지 않은 주문” 같은 불가능한 조합을 애초에 만들 수 없습니다. 검증 코드를 한 줄 덜 쓰는 것보다, 그런 값이 존재할 수 없다는 사실을 코드가 스스로 말해 준다는 점이 컸습니다.
두 가지 방식을 놓고 팀이 정한 기준
도메인 타입 우선
장점 제품 문서의 어휘와 코드가 같이 움직이고, 새 상태가 생기면 영향 범위가 컴파일러에 드러납니다.
주의점 초기 설계에 반나절 정도가 더 듭니다. 축을 잘못 나누면 되돌리는 비용도 큽니다.
화면별 임시 타입
장점 한 화면만 볼 때는 가장 빠릅니다. 실험적인 기능에는 여전히 이쪽을 씁니다.
주의점 화면이 셋을 넘어가는 순간 이번 사고와 같은 형태로 갈라집니다. 갈라진 것은 조용해서 늦게 발견됩니다.
기준은 단순하게 잡았습니다. 두 개 이상의 화면이 같은 개념을 읽는다면 도메인 타입으로 올리고, 한 화면 안에서만 쓰이면 그대로 둡니다. 세 번째 화면이 생길 때가 승격 시점입니다.
리뷰가 끝나고 남긴 것
수정 자체는 이틀이었고, 그중 코드를 고친 시간은 반나절이었습니다. 나머지는 상태 이름을 다시 정하고, 운영 문서와 리포트 쿼리의 표현을 맞추는 데 썼습니다. 그 시간이 아깝지 않았던 이유는 이후 회의에서 “결제완료”가 무엇을 뜻하는지 되묻는 일이 사라졌기 때문입니다.
- “서버가 이상한 값을 보낸다”는 가설은 전수 수집으로 반나절이면 검증됩니다. 먼저 죽이고 시작합니다.
- 같은 개념을 세 곳에서 매핑하고 있다면 이미 계약이 깨진 상태입니다. 매핑 개수를 세는 것만으로 문제를 찾을 수 있습니다.
- 상태를
string으로 두면 컴파일러는 아무것도 도와주지 못합니다. 리터럴 유니온과never전수 검사를 함께 씁니다. - 유니온이 길어지는 것은 축이 섞였다는 신호입니다. 값을 늘리기 전에 필드를 나눌지 먼저 봅니다.
- 타입은 컴파일 타임 약속이므로, 네트워크 경계에서는 런타임 검증을 따로 둡니다.
다음에 같은 상황을 만나면 순서를 바꿀 생각입니다. 이번에는 버그를 고치다가 어휘 문제를 발견했지만, 어휘 정리를 먼저 했다면 버그 자체가 생기지 않았을 것입니다. 옮길 만한 원칙 하나를 고르라면 이것입니다. 타입 선언은 코드가 지켜야 할 규칙이기 이전에, 팀이 같은 단어를 같은 뜻으로 쓰기로 한 합의문입니다. 합의가 없는 상태에서 타입만 촘촘하게 만들면 촘촘한 오해가 생깁니다. 다른 언어에서 같은 문제를 어떻게 다루는지는 심층 가이드에 언어별로 모아 두었습니다.