SaaS 구독 관리 시스템을 만들고 있다. 4월 주문서를 생성했는데, 뭔가 이상하다.

주문서 목록 — 금액이 2배

모든 고객의 청구 금액이 정확히 2배다. 52,000원이어야 할 구독료가 104,000원, 69,000원이어야 할 금액이 146,000원.

상세 화면을 열어보면 더 기가 막힌다:

주문서 상세 — 같은 상품이 2번씩

“프리미엄 구독”이 두 번, “클라우드 스토리지”도 두 번 들어가 있다. 한 번만 구독하는 상품이 두 번 청구된 것이다.

그런데 맨 아래 결제 링크를 보면:

결제 링크는 정상

결제 금액은 69,000원으로 정상이다. 같은 화면에서 위에는 146,000원, 아래에는 69,000원. 대체 무슨 일이 일어난 걸까?

먼저 범인 후보를 좁히자

이 시스템은 세 개의 층으로 되어 있다:

[데이터베이스] → [서버(Java)] → [화면(React)]
   저장소          중간 처리          표시

데이터베이스는 실제 데이터가 저장된 곳이다. 엑셀 시트라고 생각하면 된다. 서버는 데이터를 꺼내서 가공하는 곳이다. 엑셀 시트에서 데이터를 읽어서 정리하는 사람이다. 화면은 정리된 데이터를 예쁘게 보여주는 곳이다. 정리된 데이터를 출력하는 프린터다.

버그가 어디서 생겼는지 순서대로 확인했다.

1단계: 엑셀 시트(데이터베이스) 확인

데이터베이스를 직접 열어봤다.

DB 조회 결과

딱 2개. 데이터베이스에는 중복이 없다. 범인이 아니다.

2단계: 프린터(화면) 확인

화면 코드를 봤다. 데이터 목록을 하나씩 출력해라라는 단순한 코드다. 데이터를 받은 그대로 찍고 있을 뿐이다. 프린터도 범인이 아니다.

3단계: 범인은 중간 처리(서버)

데이터베이스에는 2개인데 화면에 4개가 나왔다면, 중간에서 데이터를 꺼내는 과정에서 뭔가 꼬인 거다.

범인: 두 장의 엑셀 시트를 동시에 합친 것

여기서부터 좀 기술적인 이야기가 나오는데, 최대한 쉽게 설명해보겠다.

데이터베이스는 엑셀 시트 여러 장이다

데이터베이스는 하나의 거대한 표가 아니라, 여러 장의 엑셀 시트로 이루어져 있다.

이 주문 시스템에는 이런 시트들이 있다:

시트 1: 주문 항목

시트 2: 적용된 쿠폰

“한 번에 다 가져와”가 문제였다

서버가 데이터베이스에게 이렇게 요청했다:

“369번 주문서의 주문 항목이랑 적용된 쿠폰한꺼번에 줘”

데이터베이스는 두 시트를 합쳐서 한 장으로 만들어야 한다. 그런데 여기서 문제가 생긴다.

시트 1에는 2줄, 시트 2에도 2줄이 있다. 데이터베이스는 이 두 시트를 합칠 때, 모든 가능한 조합을 만든다:

합쳐진 결과 — 모든 조합

2줄 × 2줄 = 4줄이 됐다!

이걸 수학에서는 곱집합(Cartesian Product)이라고 부른다. 르네 데카르트(Descartes)의 이름을 딴 것인데, 두 집합의 모든 조합을 만드는 것이다.

쉽게 비유하면:

상의 2벌(흰색, 검정)과 하의 2벌(청바지, 슬랙스)이 있다면, 가능한 코디 조합은 2 × 2 = 4가지다.

(흰색+청바지), (흰색+슬랙스), (검정+청바지), (검정+슬랙스)

데이터베이스도 똑같이 동작한다. 두 시트를 합치면 모든 조합을 만들어버린다.

쿠폰은 괜찮은데 주문 항목은 왜 뻥튀기?

4줄짜리 결과를 받은 서버는 이걸 다시 원래 형태로 분리해야 한다:

쿠폰 목록: 신규가입 할인, VIP 할인, 신규가입 할인, VIP 할인

쿠폰은 중복 자동 제거 바구니(Set)에 담긴다. 같은 쿠폰이 여러 번 들어와도 하나만 남는다. → 결과: 신규가입 할인, VIP 할인 ✅ 정상!

주문 항목: 프리미엄 구독, 프리미엄 구독, 클라우드 스토리지, 클라우드 스토리지

주문 항목은 순서 있는 목록(List)에 담긴다. 들어온 순서대로 그냥 쌓는다. 중복 검사를 하지 않는다. → 결과: 프리미엄 구독, 프리미엄 구독, 클라우드 스토리지, 클라우드 스토리지 ❌ 뻥튀기!

결제 링크는 왜 정상이었나?

결제 링크의 69,000원은 주문서를 처음 만들 때 계산해서 저장해둔 값이다. 그때는 데이터베이스에서 항목을 제대로 2개만 가져왔기 때문에 정상이었다.

반면 화면에 표시되는 146,000원은 매번 데이터베이스에서 새로 꺼내온 값이다. 꺼내올 때마다 곱셈 버그가 발생하는 것이다.

해결: “한꺼번에 가져오지 마라”

원인을 알았으니 해결은 간단하다.

Before (한꺼번에 요청):

“주문 항목이랑 쿠폰을 동시에 줘” → 2 × 2 = 4줄 (곱셈 발생)

After (따로 요청):

요청 1: “주문 항목 줘” → 2줄 요청 2: “쿠폰 줘” → 2줄 → 총 4줄이지만 각각 정확함

코드로는 딱 두 줄 바꿨다:

// 쿠폰을 한꺼번에 가져오지 말고, 나중에 따로 가져오도록 설정
@BatchSize(size = 50)  // "50개씩 묶어서 따로 가져와라"
private Set<OrderAppliedDiscount> appliedDiscounts;

그리고 “한꺼번에 줘”라고 하던 요청에서 쿠폰 부분을 제거했다.

데이터베이스에 요청을 1번 대신 2번 보내게 됐지만, 정확한 데이터를 받는 게 더 중요하다.

교훈

  1. 화면이 이상하면 화면만 보지 말자. 데이터가 어디서 꼬이는지 추적해야 한다.
  2. “한꺼번에 가져오면 빠르겠지”는 함정이다. 관련 없는 데이터를 동시에 합치면 곱셈이 일어난다.
  3. 결제 금액은 맞는데 표시 금액이 틀리다는 단서가 결정적이었다. “저장된 값 vs 매번 계산하는 값”의 차이를 알면 버그 위치를 빠르게 좁힐 수 있다.

이 버그 때문에 구독료가 2배로 청구될 뻔했다. 발행 전에 발견해서 다행이다.