들어가면서

오늘 하루 동안 한 API 응답을 여러 번 들여다봤다.

가격을 한 번에 계산해 내려주는 가격 산정 API. 어떤 결제 수단이 어떤 할인과 짝이 되고, 그 짝이 적용 가능한지 / 선택되었는지 / 얼마가 깎이는지 — 이 모든 게 하나의 응답에 들어 있다. 이 위에 상품 상세, 장바구니, 주문서가 올라간다.

대부분의 시간 동안 잘 돌아간다. 다만 오늘 하나 깨달은 게 있다.

이 응답은 타입상의 예외는 충분히 갖추고 있다. 그런데 예외 없이 돌아간다.

즉, FE 와 BE 가 세워 둔 가정에 빈틈없이 들어맞을 때만 깔끔하게 돌아간다. 정책이 안 바뀌는 것은 아니다. 하지만 가격 영역은 — 다른 영역과 비교했을 때 — 살짝 위태롭다. 가정 한 칸이 어긋나면, 화면 한 칸이 망가진다.

살짝 무너지면… 상상하기 싫다.

오늘 다시 본 가정 네 가지를 정리해 둔다. 모두 같은 API 의, 같은 결의 이야기다.

가정 1 — “빈 배열이면 그 영역은 미노출이다”

상황. 상품 상세의 가격 영역에 “특정 결제수단 전용 할인” 섹션이 있다. 어떤 브랜드에 한해 이 섹션만 숨기고 싶다. 서버에서 응답의

preferredCard.cardList = []

빈 배열로 내려주면, FE 가 자연스럽게 섹션을 안 그릴 거라고 가정한다.

실제로는.

  • 빈 배열 → 섹션은 사라진다. ✅
  • 그런데 하단 띠 배너에 표시되는 discountAmount: 30000 은 그대로 내려온다.
  • 결과: “섹션은 사라졌는데, 하단 배너에는 3만원 할인이 남아 있는” 부정합 한 칸 발생.

왜 그렇게 되어 있나. 섹션 노출 / 가격 합산 / 배너 노출 — 이 세 화면 요소가 각자 다른 필드를 본다. 한 곳에 빈 배열을 넣어도, 나머지 두 곳에는 신호가 가지 않는다. “빈 배열이면 다 사라진다” 는 가정은 한 화면에서만 참이다.

최종 결정. 선택 모드를 enum 으로 새로 추가했다.

preferredCard.selectionMode: 'HIDDEN_BY_POLICY' | ...

그리고 선택 자체를 서버에서 막는다 (isSelected: false). 위에 FE 에는 짧은 주석을 한 줄 남겼다.

// preferredCard.selectionMode === 'HIDDEN_BY_POLICY' 일 때
// 섹션·뱃지·하단 배너의 할인 표시를 함께 감춘다.

“빈 배열이면 자동으로 사라진다” 는 가정은 한 화면에서만 참이다. 옆 화면이 같은 데이터를 다른 필드를 통해 보고 있으면, 그 가정은 옆 화면에서 깨진다.

가정 2 — “이 필드는 운영에서는 항상 값이 있다”

상황. 첫 결제 할인을 적용할지 판단할 때 응답의

payMethod: string | null

을 본다. 보통은 "CCXX" 같은 결제수단 코드가 들어 있다. 타입은 nullable 이지만, 운영에서는 사실상 항상 값이 있다고 알고 있었다.

실제로는.

  • 어떤 사용자 / 어떤 시점에는 payMethod: null 이 내려온다.
  • 그러면 첫 결제 할인 / 즉시 할인 / 적립 — 결제수단 키로 매칭되는 모든 분기가 한꺼번에 비활성된다.
  • 타입은 null 을 허용하니 컴파일은 통과한다. 다만 이 분기는 안 갖춰져 있었다.

원인. 카드사 쪽 사용자 적립 정보 일시 누락, 결제 게이트웨이 일시 장애 — 드물지만 실재하는 상황. 운영에서 거의 본 적이 없어 코드에는 가정만 박혀 있었다.

오늘의 결정.

“운영에서 진짜 본 적은 없는 것 같으니, 일단 그대로 둘까요?” “네, 코드 수정 최소화하는 게 좋을 것 같아서요.”

서버 쪽에서 null 을 내리는 케이스를 좁히는 방향으로 갔고, FE 는 일단 그대로 뒀다. 옳았는지 아닌지는, 다음에 한 번 더 들여다봐야 안다.

타입에 null 이 있다는 것은, 운영에서 한 번도 본 적 없어도 한 번은 들어올 수 있다는 뜻이다. “본 적 없다” 가 “안 들어온다” 의 증거가 되지는 않는다.

가정 3 — “선택 상태는 클라이언트가 보냈다”

상황. 페어링 규칙이 있다. 즉시 할인 A 와 첫 결제 할인 B 는 보통 함께 켜지고 함께 꺼져야 한다. 이 페어링 룰은 — 일부는 FE 가 알고, 일부는 BE 가 안다.

실제로는.

  • 클라이언트가 isSelected: false 로 보냈는데,
  • 서버 응답에는 isSelected: true 가 그대로 박혀 내려온다.
  • 서버가 페어링 룰을 자기 식으로 다시 계산해 보낸 결과다.
  • 화면 위에서는 체크가 풀리지 않고 다시 박힌다.

문제의 본질. 페어링 룰이 양쪽 어디에 산다고 단언할 수 없는 상태였다. FE 도 일부 알고, BE 도 일부 안다. 그래서 어느 한쪽도 “원래의 의도” 를 끝까지 책임지지 못한다.

정리한 룰. 결국 페어링 6 가지 케이스를 표로 적은 다음, 한 줄로 줄였다.

선택할 때만 페어링한다. 해제할 때는 해제한 것만 해제한다.

이 룰을 FE 에서 끝까지 책임지기로 했다. 서버는 받은 selection 을 그대로 신뢰한다.

양쪽이 같은 룰을 알고 있는 상태보다, 한쪽이 끝까지 책임지는 상태가 더 안전하다. 두 쪽이 안다는 건, 두 쪽이 다르게 알 수 있다는 뜻이다.

가정 4 — “프로모션과 결제수단은 1:1 로 매칭된다”

상황. 한 결제수단이 선택되면 그에 맞는 프로모션 ID 하나가 내려온다는 가정이 있었다. FE 는 카드 선택 시 카드 코드 하나와 프로모션 ID 하나를 같이 보낸다.

실제로는.

  • 같은 결제수단이 여러 개의 프로모션과 매칭되는 상품이 있다 (n : 1).
  • 같은 카드여도 프로모션 ID 가 바뀌면 할인이 달라진다.
  • 이 매핑이 한 곳에서 관리되지 않는다. FE 도 일부 들고 있고, BE 도 일부 들고 있다.

오늘의 결정. “서버가 최대 할인가에 해당하는 프로모션 ID 를 계산해 내려준 값” 을 정답으로 삼기로 다시 합의했다. FE 는 그 값을 그대로 다시 돌려보낸다. 계산은 한 쪽에서만 한다.

n : 1 관계를 1 : 1 로 취급하면, 어디선가 1 개가 사라진다.

공통 패턴 — 가정의 모양

오늘 본 네 가지를 같은 줄에 세워 보면, 모양이 거의 같다.

가정 타입은 운영에서는 무너지는 자리
“빈 배열 = 자동 미노출” T[] (가능) 보통 채워져 있음 옆 화면의 다른 필드
“이 필드는 값이 있다” T \| null 거의 안 비어 있음 카드사·게이트웨이 장애
“선택은 클라가 보냈다” boolean 보통 클라 = 서버 서버가 룰을 다시 적용할 때
“프로모션 1:1” id: number 단일 매칭 사례 다수 n:1 케이스

전부 같은 결이다.

타입은 예외를 표현하고 있다. 운영은 그 예외를 거의 안 보여준다. 그래서 코드는 예외 없이 작성된다. 가정은 운영의 빈도에 기대어 산다.

마치며 — 가정에 기대지 않는 두 가지 반, 그리고 한 가지

가정에 기댄 시스템은 보통 두 가지 중 하나를 한다.

  1. 가정을 코드로 만든다 — enum, 타입, 단일 책임으로 옮겨 적는다. (오늘 우리는 1번을 했다 — selectionMode enum 추가, 페어링 룰 단일화)
  2. 가정을 검증한다 — 응답이 가정대로인지 짧게 가드하고, 아니면 빠르게 보고한다.

오늘 글을 쓰면서 깨달은 건, 세 번째 가벼운 선택지 가 있었다는 것이다.

  1. 가정을 글로 적어 둔다. 주석 한 줄, 채널 한 줄, 회고 한 줄. 가정에 이름을 붙여 두는 가장 가벼운 방법.

이 글이 결국 그 세 번째다. 가정에 이름이 붙으면, 다음 사람이 같은 자리에서 한 번 더 멈춘다.

빈틈없이 돌아간다는 말은, 빈틈을 본 적 없다는 말과 같다. 빈틈을 본 적 없는 코드는, 다음 빈틈을 못 본다.

오늘 한 일은 빈틈을 하나 줄인 일이고, 이 글은 그 빈틈에 이름을 붙인 일이다. 다음에 같은 자리를 만나면, 세 번째 선택지 — 가정을 글로 적어 두는 일 — 부터 해 봐야겠다.