BE-commerce 프로젝트
약 9분 분량 테스트·검증데이터베이스

문서와 테스트가 맞아도 배포를 믿을 수 없었다: 실행하지 않은 경로의 비용

목차

문서와 테스트가 통과했다는 이유만으로 배포를 믿을 수 있을까요? 정산을 별도 서비스로 떼어 내는 실험에서 나온 지적과, “데이터가 없어 못 고른다”며 미뤄 둔 후보를 실제 코드·스키마·장애 경로에 대입했습니다.

결론부터

  • 문서에 적힌 원장 잔액은 실제 조회 코드가 없었고, 아웃박스 복구 옵션은 프로세스를 죽여 보기 전까지 검증된 것이 아니었습니다.
  • 이관 절차는 전환 중 들어온 변경을 놓칠 수 있었고, enum 값은 코드에는 있어도 MySQL 스키마가 거부했습니다.
  • 모든 문제에 서비스 분리가 필요한 것은 아니었습니다. 배포 단위만 나누면 해결되는 결합과, 실제 데이터·스키마 검증이 필요한 결합을 구분했습니다.

정리 대상은 아홉 건이었습니다. 여덟은 실측이나 코드로 닫고, 하나는 설계만 남겼으며, 하나는 범위 밖으로 분리했습니다.

닫아 보니 절반이 같은 모양이었습니다.

  • 원장 문서에는 “잔액은 엔트리의 합으로 파생된다”고 적혀 있었는데, 그 합을 읽는 쿼리가 아무 데도 없었습니다
  • 아웃박스 복구 설정은 켜져 있었는데, 프로세스를 죽여 본 적이 없었습니다
  • 이관 → 대조 → 전환 절차는 있었는데, 이관 중 들어온 변경을 넣어 본 적이 없었습니다
  • 계정 enum 값은 코드에 있었는데, 그 값을 MySQL에 저장해 본 적이 없었습니다

문서에 적힌 설계와, 테스트가 통과한 코드와, 켜져 있는 복구 옵션을 검증된 것으로 취급하고 있었습니다. 이 글은 그 경로들을 실제로 돌려본 기록입니다. 그중 넷(1절 배포 단위 분리, 2절 잔액 조회, 3절 커밋 후 발행, 4절 이관 중 델타)과, 재는 과정에서 코드와 스키마가 어긋난 자리 둘(5절)을 다룹니다.

#미뤄 둔 후보 / 지적성격답어디서
1원장 잔액을 매번 셀까, 스냅샷을 둘까트레이드오프SUM+커버링 인덱스 채택, 스냅샷은 약 60만~130만 행을 넘으면이 편 2절 · ADR-025
2이벤트 스키마를 바꿔야 할 때(추가·삭제)트레이드오프추가는 소비자 배포 없이 안전, 삭제는 소비자→발행자 순서 강제ADR-026
3큰 가맹점 하나가 전체를 늦출 때(처리량↔공정성)트레이드오프(사업)공정성 정책을 만들되 기본 off정산 공정성 편 · ADR-027
4FDS 임계값을 운영에서 어떻게 움직일까트레이드오프(사업)라벨(차지백)이 없어 숫자는 안 만들고 설계만ADR-028
5실행 단위만 분리 vs 저장소까지 분리트레이드오프배포 단위만 나눔 — 두 결합 0이 편 1절 · ADR-029
6이관 중 들어온 변경(델타)을 어떻게 할까트레이드오프대조 기준을 전환 시점 원본으로이 편 4절 · ADR-024
7커밋은 됐는데 발행이 안 된 구간해소(검증)재기동 재발행 8초, 멱등 구멍 막음이 편 3절 · 성능 17절
8격리·발견·복구·검증을 따로 만든다해소(보강)재처리 멱등 가드 + 복구 후 요약 표면ADR-030
9무중단 스키마 변경과 되돌릴 수 없는 지점범위 밖BE-commerce 소재가 아니라 기록만—

1. 저장소를 나누기 전에 배포 단위만 나눠 봤다

앞선 실험은 결합 두 개를 재현했습니다. 하나는 수평 확장하면 정산 스케줄러도 같이 복제되는 것, 다른 하나는 무관한 모듈의 배포가 결제를 멈추는 것입니다. 그리고 이 둘이 서로를 막아 출구가 없다고 적었습니다. 롤링으로 배포 중단을 없애려면 인스턴스를 늘려야 하는데, 그러면 스케줄러 중복이 터지니까요.

같은 jar 하나가 API(스케줄러 OFF)와 워커(스케줄러 ON)로 갈라지고, 스케줄러 중복과 배포 중단 두 결합이 각각 0이 되는 배포 단위 분리

저장소를 나누지 않고 배포 단위만 나눠서 같은 실험을 다시 돌렸더니, 두 결합이 0이 됐습니다. 같은 jar를 프로파일 하나로 갈랐습니다. 배치 실행 주체(워커)로 뜨면 스케줄러가 켜지고, 아무 프로파일 없이 뜨면(API) 스케줄러가 전부 꺼집니다. 저장소도 브로커도 그대로라 트랜잭션과 조인은 남습니다.

인스턴스 2개로 확장해도 정산은 중복 0건(API 2 + 워커 1)이었습니다. 부하를 깔고 워커만 재배포한 실험은 결제 API가 멈추지 않았고 실패가 0/10,174(0.00%)였습니다 — 같은 조건의 모놀리스에서는 270/9,870(2.7%)가 9초 중단으로 실패했습니다. 재기동한 워커는 같은 날짜를 다시 집계하려다 “재실행 감지”로 건너뛰었습니다. 멱등이 워커 재배포까지 덮습니다. 실험 조건과 표는 정산 배치의 실행 주체 나누기에 있습니다.

저장소까지 나눠서 추가로 얻는 것은 트랜잭션 없는 완전 독립이고, 그 대가는 메시지 중복·순서·poison·스키마 진화·이관이라는 실패 모드가 새로 생기는 것입니다. 이건 공짜가 아니라 별도 결정이라, 기준선을 만들고 그 위에서 따로 판단하도록 남겼습니다.

문제를 재현했다고 해서 가장 큰 경계를 잘라야 하는 것은 아니었습니다. 문제를 만든 결합만 먼저 잘랐습니다.


2. 원장 잔액은 매번 SUM해도 될까? 1,200만 행까지 넣어봤다

원장 모듈 문서에는 처음부터 이렇게 적혀 있었습니다.

잔액 조회 p95가 원장 행 수에 비례해 늘어나는 모습. 계정별 전체 조회는 인덱스를 붙여도 O(N) 직선을 그리고, 200ms 임계선을 약 60만 행에서 넘는다

잔액은 엔트리의 합으로 파생된다.

그런데 그 선언을 쓰는 코드가 없었습니다. 잔액을 읽는 쿼리가 아무 데도 없었고, 계정에 인덱스도 없었습니다. “SUM이 느려진다”는 예상일 뿐이었고, 그 대가를 한 번도 치른 적이 없었습니다. 조회를 실제로 만들고 행 수를 늘려 가며 쟀습니다.

원장 행수인덱스한 계정 조회 p95계정별 전체 조회 p95
240,692있음32.6ms82.0ms
1,616,948있음235.2ms564.4ms
12,626,996있음1,722.8ms4,812.6ms
12,626,996없음4,469.8ms8,056.8ms

커버링 인덱스를 붙이니 1000만 행에서 한 계정 조회가 2.6배, 계정별 전체가 1.7배 빨라졌습니다. 다만 O(N)을 벗어나지는 못합니다. 인덱스는 훑는 폭을 줄일 뿐 합계를 상수 시간으로 만들지 않습니다.

쓰기 비용도 함께 쟀습니다. 인덱스는 공짜가 아니니까요. 20만 행을 넣는 데 인덱스 없이 1,906.8ms, 커버링 인덱스를 더하니 2,149.5ms로 +12.7% 였습니다.

그래서 스냅샷 컬럼을 지금 만들지 않았습니다. 대신 언제 필요해지는지를 숫자로 남겼습니다. 잔액 조회 p95를 200ms로 두고 구간을 선형보간하면, 계정별 전체 조회는 약 60만 행, 한 계정 조회는 약 130만 행에서 그 선을 넘습니다(잰 행 수가 24만·162만·1,263만 세 구간뿐이라 그 사이는 선형으로 가정했습니다). 이 저장소는 아직 그 아래라, 지금 스냅샷을 넣으면 원장과 갈라졌을 때 알아챌 대조 배치만 늘고 얻는 것이 없습니다.

“언젠가 느려질 것”은 지금 복잡도를 추가할 근거가 아니었습니다. 대신 언제 다시 결정할지를 숫자로 남겼습니다. 선언만 있고 그 선언을 쓰는 코드가 없으면 그 선언의 대가는 아직 안 치른 것이고, 실측 없이 고른 값은 상한이 아니라 그냥 천장입니다.


3. DB는 커밋됐는데 Kafka가 죽었다면, 재기동 후 정말 복구될까?

이벤트 발행은 아웃박스를 탑니다. DB 커밋과 발행 기록이 같은 트랜잭션이라, 커밋은 됐는데 발행이 안 된 구간이 남을 수 있습니다. 그 구간을 재기동이 메우는 설정도 켜져 있었습니다. 설계는 있었고, 그 경로를 장애로 검증한 적이 없었습니다. “아웃박스를 썼으니 됐다”와 “죽여 봤더니 됐다”는 다릅니다.

Kafka를 내린 채 결제를 승인해 커밋은 되고 발행은 안 된 상태를 만들고, 프로세스를 강제 종료한 뒤 Kafka를 올리고 재기동했습니다.

  • 재기동 후 8초 만에 이벤트가 토픽에 나타났습니다. 미완료 0건.
  • 미완료로 남아 있던 두 건 중 하나는 Kafka 발행이었고, 다른 하나는 강제 종료로 중단된 인-프로세스 리스너였습니다. 재기동 재발행이 발행뿐 아니라 처리 중에 죽은 소비까지 되살린다는 뜻입니다.

부수 발견도 있습니다. 앱을 죽이기 전에 Kafka가 먼저 살아나면 재기동 없이 프로듀서 버퍼 재시도가 메웁니다(재기동 없이 14초). 방어선이 두 겹이고, 프로세스가 죽어 하나가 사라져도 다른 하나가 남습니다.

멱등을 확인하다 구멍도 찾았습니다. 재발행은 같은 이벤트를 두 번 배달할 수 있는데, 사람이 보는 심사 큐는 중복 적재를 막는 장치가 없었습니다. 거래 이력은 유니크로 막고 있었는데 큐는 아니었습니다. 재배달이 오면 같은 주문이 두 줄로 쌓입니다. 유니크 제약과 선검사를 넣어 막았습니다.

발행 복구만 확인하고 끝냈다면 at-least-once를 설계했다고 말할 수 없었을 것입니다. 재배달을 받는 소비자까지 실행해 보고 나서야 그 말이 성립했습니다.


4. 데이터 이관 검증이 성공했는데 한 건이 사라졌다

정산을 새 저장소로 옮기는 절차는 이관 → 대조 → 전환 순서입니다. 그런데 이관을 시작한 시각과 끝난 시각 사이에 들어온 승인·취소·환불을 다루지 않습니다. 복사에 10분이 걸렸다면 그 10분의 변경분이 새 저장소에 없습니다.

목적지 스키마를 그대로 만들어 절차를 실제로 돌렸습니다. 옛 저장소에서 미확정 항목을 스냅샷하고, 그 사이에 새 승인 한 건이 들어오게 한 뒤, 대조를 두 가지 기준으로 해 봤습니다.

대조목적지기준차이
스냅샷과 맞춤 (기존 절차)330 — 델타를 놓친다
전환 시점 원본과 맞춤34-1 — 델타가 드러난다
델타 재복사 후440

기존 대조는 통과합니다. 새 저장소에는 스냅샷이 들어 있으니 스냅샷과는 맞습니다. 그런데 원본은 한 건이 더 많습니다. 조용히 한 건이 사라진 상태로 전환하게 됩니다.

고칠 것은 대조의 기준입니다. 스냅샷이 아니라 전환 시점의 원본과 맞추면 델타가 드러나고, 워터마크로 델타를 다시 복사하면 0이 됩니다. 정지가 허용되는 짧은 창이면 쓰기를 멈추는 쪽이 가장 단순합니다.

복사한 데이터와 복사본을 비교하면 복사가 잘 됐다는 것만 알 수 있습니다. 전환해도 되는지는 현재 원본과 비교해야 알 수 있었습니다.

여기서도 컬럼 매핑이 필요했습니다. 목적지 스키마에 판매자 컬럼이 없어서 그대로 넣으면 실패합니다. 계약(이벤트)이 같아도 소비자 내부가 다를 수 있다는 것을, 예행이 두 번 확인해 준 셈입니다.


5. Testcontainers까지 돌았는데 MySQL에서 처음 실행하면 터지는 코드가 있었다

판단을 재기 전에 코드와 스키마가 어긋난 자리를 바닥부터 확인했고, 둘을 찾았습니다.

원장은 입금 분개를 저장할 수 없었습니다. 원장은 계정 네 개를 씁니다 —
PG_RECEIVABLE·SALES·CASH·PG_FEE. 그런데 DB의 enum에는 앞의 두 개만
있었습니다.
V1이 표를 만들 때 그렇게 만들었고, 뒤에 계정 두 개를 코드에 더하면서
마이그레이션을 안 붙였습니다. 그래서 정산 지급 확정(입금 분개)이 원장에 기록되는 순간
MySQL이 값을 거부합니다. 잔액은 “계정별 분개 합”으로 파생되는데, 입금 계정의 분개가
저장될 수 없다면 그 합은 영원히 틀립니다
— “미수금 = 승인합 − 취소합 − 입금합”이라는
검증식 자체가 성립하지 않습니다.

정산은 지급 보류를 저장할 수 없었습니다. 정산 상태에는 PAYOUT_HELD(지급 보류)가
있습니다. 제재 스크리닝에 걸린 판매자의 정산은 집계는 하되 돈은 안 나가야 하니까요.
그런데 DB의 settlements.status는 CREATED와 PAID_OUT뿐이었습니다. 지급 보류가
걸리는 순간 DB가 값을 거부합니다.

이 문장은 이 장애를 재현하던 당시의 스키마 상태입니다. 현재는 PAYOUT_HELD를 포함하도록
마이그레이션이 반영됐고, 지급 확정도 외부 지급 대사 결과가 MATCHED인지 확인한 뒤에만
PAID_OUT으로 전이합니다. 과거의 “두 상태뿐”이라는 사실을 현재 계약으로 읽으면 안 됩니다.

마이그레이션 주석이 이렇게 적혀 있었습니다.

status는 VARCHAR라 값 추가에 제약 변경이 필요 없다. PAYOUT_HELD가 늘어난다.

틀렸습니다. 그 컬럼은 앞선 마이그레이션이 만든 MySQL ENUM이었습니다. 주석을 믿고
제약 변경을 안 한 것이고, 그래서 보류 상태를 쓰는 코드 경로가 통째로 실패하게 되어
있었습니다.

왜 아무도 몰랐나. 두 자리 모두 단위 테스트가 통과합니다. 테스트는 H2로 도는데 H2는
enum을 강제하지 않아 값이 뭐든 들어갑니다. MySQL에서만 터집니다. 그런데 CI는
Testcontainers로 실 MySQL도 돕니다 — 문제는 이 값을 쓰는 시나리오가 어느 테스트에도
없었고, 그 경로가 미실행이었다는 것입니다.
지급 확정된 정산이 0건이라 입금 분개도 보류도
만들어 본 적이 없었습니다. 기능이 만들어진 것과 그 경로가 돌아 본 것은 다른 일인데,
테스트는 앞의 것만 확인하고 있었습니다.

고친 것은 마이그레이션 두 장입니다. 계정 enum을 네 값으로 늘리고, 정산 상태 enum에
보류를 넣었습니다. 이제 잔액을 세고, 심사에 걸린 지급을 멈출 수 있습니다. 테스트가
통과하는 것과 그 경로가 돌아 본 것은 다르고, 같은 종류의 버그는 한 번 나오면 더 나옵니다.
원장 enum을 고친 뒤 정산 enum을 같은 눈으로 보니 바로 나왔습니다. 이 잠복
버그(코드에는 있는데 DB enum에 없는 값)는 정산 공정성 편의
실험도 그대로 밟습니다. 이 편을 정본으로 둡니다.

마무리

아홉 건을 닫고 남은 기준은 하나였습니다. 문서에 적힌 설계와, 테스트가 통과한 코드와, 켜져 있는 복구 옵션은 검증된 것이 아닙니다. 실제 실패 조건을 만들어 그 경로를 끝까지 한 번 돌려 보고 나서야 사실로 취급할 수 있습니다.

남은 후보들도 답이 문서에 있는지가 아니라 그 경로를 돌려봤는지로 다시 봐야 합니다.

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.