결제 시스템에서 상태 기반 테스트를 설계하는 방법
2026-08-04 · DEV · 약 10분 읽기
구독 결제 로직을 상태 머신과 순수 함수로 정리하고, 실제 결제·DB 없이 156개 케이스를 0.92초에 검증한 과정을 정리했습니다.
7일 환불 정책에서 정확히 7일째는 환불이 가능할까요?
처음에는 실제 결제를 만들고, 일주일을 기다린 뒤 환불을 시도하는 것은 올바르지 않습니다. >를 >=로 바꾸는 작은 수정이 생기면 검증도 처음부터 다시 해야 하고, 경계값 하나를 확인하는 데 드는 비용이 너무 크기 때문입니다.
특히 결제 부분은 환불 판정, DB 조회, PG 호출, 현재 시각 확인이 한 함수에 섞여 있었기 때문에 더욱 복잡했습니다.
refund(request)
├─ DB에서 결제와 라이선스 조회
├─ 현재 시각 확인
├─ 환불 가능 여부 판단
├─ PG 취소 요청
└─ DB 갱신이 구조에서는 환불 규칙 한 줄을 확인하려 해도 DB, 네트워크, PG, 실제 시간이 필요합니다. 이때 저는 테스트 환경을 더 정교하게 만드는 일보다 의존성을 먼저 분리하는 것이 필요하다고 생각했습니다.
이 글은 코드를 분리하면서 구독 상태 전이를 다시 정의하고, 그 과정에서 기존 코드의 결제 버그 두 개를 발견한 기록입니다.
흩어진 규칙을 먼저 정리했어요
처음 살펴본 구독 로직은 한곳에 있지 않았어요.
| 위치 | 맡고 있던 일 |
|---|---|
| 청구 스케줄러 | 결제 성공·실패 후 상태 변경 |
| 구독 API | 즉시 해지와 해지 예약 |
| 리소스 삭제 API | 삭제 시 구독 취소, 복구 시 재개 |
각 코드만 보면 올바른 것 같지만, 전체 생애주기를 확인할 수 있는 방법은 없었습니다. 따라서 “해지 예약을 한 사용자의 결제가 실패하면 재시도해야 할까요?”라는 질문에 답하려면 바로 결론을 내리기 어려웠습니다.
이 상태에서 테스트 케이스만 늘려도 근본 문제는 해결되지 않습니다. 먼저 어떤 입력에서 어떤 상태가 되어야 하는지를 정해야 합니다. 테스트는 그 규칙을 실행 가능한 형태로 고정할 뿐, 비어 있는 규칙을 대신 결정해 주지는 않기 때문입니다.
그래서 구현을 바꾸기 전에 상태와 이벤트를 표로 정리했습니다.
구독 상태는 네 개로 정리했습니다.
| 상태 | 의미 | 운영상 조건 |
|---|---|---|
ACTIVE | 정상 청구 중 | 다음 청구일 존재, 재시도 0회 |
PAST_DUE | 결제 실패 후 재시도 대기 | 다음 재시도 시각 존재 |
SUSPENDED | 재시도를 모두 소진 | 다음 청구일 없음 |
CANCELLED | 종료 | 다음 청구일 없음, 해지 시각 존재 |
여기서 상태 문자열보다 더 중요한 값은 next_billing이라는 값입니다. 스케줄러는 next_billing <= now인 대상을 청구합니다. 따라서 이 값을 null로 바꾸는 것이 “앞으로 청구하지 않는다”를 실제로 보장하고, SUSPENDED와 CANCELLED가 모두 next_billing = null을 갖는 이유도 여기에 있어요.
이 상태들을 바꾸는 이벤트는 결제 성공·실패, 즉시 해지, 해지 예약·철회, 예약 해지 실행, 월간에서 연간으로의 전환, 결제 기간 안에 실행이었습니다. 전체 전이표는 4개 상태와 8개 이벤트로 이루어진 32칸이 됐고, 합법 전이 17개와 명시적으로 거부해야 하는 전이 15개를 구분했습니다.
거부는 false로 끝내지 않았어요. ChargeOnCancelled, ReserveOnTerminal처럼 거부 이유를 값으로 돌려줬습니다. 그래야 테스트가 “거부됐다”가 아니라 “취소된 구독에 청구하려 해서 거부됐다”까지 확인할 수 있어요. 나중에 전혀 다른 이유로 거부돼도 통과해 버리는 일을 막기 위해서입니다.
판정과 실행을 나눴어요
환불 처리도 같은 방식으로 나누었습니다.
facts = gatherFacts(db, request) // DB에서 사실을 모은다
decision = decideRefund(facts, now) // 환불 여부를 판단한다
result = execute(decision, db, pg) // PG와 DB에 반영한다가운데의 decideRefund에는 I/O가 없어요. DB를 조회하지 않고, PG를 호출하지 않으며, 함수 안에서 시간을 읽지도 않아요. 필요한 결제 상태, 승인 시각, 결제 목적, 라이선스 상태와 now를 모두 입력으로 받습니다.
같은 입력에는 언제나 같은 결과가 나오고, 이제 “정확히 7일째”는 7일을 기다리는 일이 아니라, 입력값 하나를 만드는 일이 됐어요.
facts.approved_at = now - 7 days
assert decideRefund(facts, now) == Approve이 작업에서 순수 함수로 분리한 것은 3개였습니다.
| 함수 | 역할 |
|---|---|
transition(snapshot, event, now) | 구독 상태 전이 또는 거부 사유 반환 |
decideRefund(facts, now) | 환불 실행 계획 또는 거절 사유 반환 |
classifyPending(pg_status) | PG 상태를 결제됨·미결제·판정 보류로 분류 |
시간도 입력으로 넘겼어요. now()를 함수 안에서 호출하는 순간, 그 함수의 경계 조건은 실제 시간에 묶여요. 반대로 시간을 주입하면 7일, 3년, 윤년 2월 29일도 한 번의 테스트에서 만들 수 있어요.
해지 예약은 상태가 아니었어요
해지 예약을 처음 표현하려 할 때는 ACTIVE_RESERVED, PAST_DUE_RESERVED 같은 상태를 추가할 수도 있습니다. 하지만 예약은 상태, 즉 배타적인 성질이 아니기 때문에 정상 청구 중인 사용자도 예약할 수 있고, 결제 재시도 중인 사용자도 예약할 수 있습니다.
예약까지 상태에 넣으면 상태 수가 빠르게 늘어납니다. 이벤트가 하나 추가될 때마다 전이표도 함께 커집니다.
그래서 해지 예약은 상태가 아니라 cancel_reserved_at 필드로 표현했어요.
USER_RESERVE_CANCEL
ACTIVE 또는 PAST_DUE → 상태는 유지
cancel_reserved_at → now만기일이 되면 스케줄러는 결제를 시도하는 대신 SCHEDULED_CANCELLATION 이벤트를 보내요. 사용자는 만기일까지 서비스를 사용할 수 있고, 그 전에는 예약을 철회할 수 있게 됩니다.
PAST_DUE와 SUSPENDED도 같은 기준으로 분리했습니다. 둘 다 결제 실패 상태처럼 보이지만 스케줄러 입장에서는 반대입니다. PAST_DUE는 다시 집어야 하고, SUSPENDED는 더 이상 집으면 안 돼요. 이를 하나로 합치면 재시도 여부를 retry_count로 매번 다시 판단해야 하고, 그 판단이 스케줄러와 조회 API, 환불 규칙에 흩어지기 때문입니다.
CHARGE_FAILED
재시도 횟수가 남음 → PAST_DUE, next_billing 설정
재시도 횟수 소진 → SUSPENDED, next_billing = null환불 표에서 드러난 버그
환불 판정을 매트릭스로 만들 때는 승인되는 기본 입력 하나를 두고, 각 테스트에서 조건 하나만 바꿨습니다.
baselineFacts()
payment_status = done
payment_key = 존재
approved_at = now - 1 hour
license_status = active
activation_count = 0예를 들어 7일 경계는 다음 세 케이스로 충분해요.
6일 23시간 전 → 승인
정확히 7일 전 → 승인
7일 1분 전 → WindowExpired각 테스트에서 바꾸는 조건은 하나뿐입니다. 입력 전체를 매번 새로 만들면 어떤 필드 때문에 거절됐는지 알기 어렵기 때문에 기준값에서 한 조건만 바꾸면 테스트 이름도 정책 규칙도 더 분명해집니다.
이 표를 만들면서 좌석 증설 결제의 환불 조건이 잘못되어 있다는 것을 발견했어요.
// 기존 조건
if license.activation_count != 0:
return AlreadyUsed최초 결제라면 라이선스 전체를 새로 구매한 것이므로 전체 등록 수가 0인지 보는 기준이 맞아요. 그러나 좌석 증설은 추가 슬롯만 구매하는 경우예요. 좌석 증설을 하는 사용자는 이미 기기를 등록해 두었을 가능성이 높고, 전체 등록 수를 보면 증설 환불은 항상 AlreadyUsed가 됩니다.
즉 “증설한 좌석이 사용됐는가”가 아니라 “라이선스에 등록된 기기가 하나라도 있는가”를 보고 있었어요.
// 수정한 조건
if activations_since_this_charge != 0:
return UpgradedSeatsUsed사용자에게는 그저 “환불 불가”라는 응답만 보였을 텐데, 결제 목적과 사용 기준을 한 표에 놓고 비교했기 때문에 문제를 찾을 수 있었어요.
개별 사례만으로는 부족했어요
전이표는 모든 단일 전이를 확인하는 데 유용하지만 실제 위험은 경로에도 있었습니다.
결제 실패 → 해지 예약 → 연간 전환 → 재개 → 결제 실패 → 자동 해지이런 조합을 사람이 모두 작성하는 것은 현실적이지 않아서 상태 머신에는 property-based testing을 사용했어요. 1~40개의 무작위 이벤트와 시간 간격을 만들고, 매 전이 뒤에 항상 참이어야 하는 규칙을 검사했어요.
SUSPENDED → next_billing == null
CANCELLED → next_billing == null && cancelled_at != null
ACTIVE → next_billing != null && retry_count == 0
거부된 전이 → 상태가 바뀌지 않음
결제 성공 → next_billing > now
같은 입력 → 같은 결과마지막에서 두 번째 규칙은 스케줄러 장애 상황에서 특히 중요했어요.
다음 청구일을 단순히 prev.next_billing + 1 cycle로 계산하면, 스케줄러가 한 달 이상 멈췄다가 복구된 경우 다음 청구일이 여전히 과거일 수 있어요. 그러면 스케줄러의 조건 next_billing <= now가 다음 틱에도 참이 되어 같은 사용자를 연속 청구하게 되는 문제가 있습니다.
anchor = max(prev.next_billing, now)
next_billing = anchor + 1 cycle무작위 시퀀스에서 결제 성공 후에도 next_billing이 과거인 상태가 만들어지면서 이 문제가 드러났어요. 사람이 “스케줄러가 40일 멈춘 뒤 복구된다”는 시나리오를 매번 떠올리기는 어렵지만, 불변식은 그런 상태가 생겼는지만 확인하기 때문입니다.
27개의 불변식 중 특히 유용했던 것은 거부 경로에 관한 것이었어요. 요청이 거절됐다면 필드 하나도 바뀌면 안 되며, 이 규칙 덕분에 거절 처리 중 예약 시각이나 재시도 횟수가 우연히 수정되는 회귀를 잡을 수 있었습니다.
PG 타임아웃은 실패가 아니에요
결제 스케줄러에서도 같은 종류의 문제가 있었어요.
1. payment_history에 requested 기록
2. PG 결제 요청
3. 응답에 따라 done 또는 failed 갱신2번에서 네트워크 타임아웃이 발생하면 결제가 실패한 것일까요? 그렇지 않아요. PG에서 결제는 승인됐지만 응답만 받지 못했을 수도 있어요.
이 상황을 곧바로 failed로 기록하면 다음 스케줄러 틱에서 새 주문번호로 다시 청구할 수 있고, PG의 중복 방지가 주문번호 단위라면 새 주문번호는 중복으로 보이지 않았습니다.
그래서 오류를 “성공과 실패”가 아니라 “PG가 확정적인 답을 줬는가”로 나눴어요.
설정 누락·명시적 PG 거절 → failed
네트워크·전송 오류 → unknownrequested와 unknown 상태는 재청구하지 않으며, 대신 기존 주문번호로 PG에 다시 조회하는 스윕에서 확정했어요.
PG 조회 결과가 결제됨 → done
PG 조회 결과가 미결제 → failed
결과를 알 수 없음 → 그대로 둠READY, IN_PROGRESS, 새로 추가된 알 수 없는 PG 상태는 성공도 실패도 아닌 Undetermined로 분류했어요. 결제에서는 모르는 값을 실패로 단정하기보다 보류하는 편이 안전하다고 생각했습니다. 다음 틱에 다시 조회할 수 있지만, 이중 청구는 환불과 사용자 신뢰 비용까지 잃어버리는 문제가 되기 때문입니다.
테스트도 검증했어요
테스트 156개가 통과했다는 사실만으로는 충분하지 않았습니다. 테스트가 규칙을 실제로 잡는지 보려면, 의도적으로 규칙을 깨 봐야 합니다.
두 가지 변이를 넣어 봤어요.
| 바꾼 코드 | 기대한 결과 |
|---|---|
환불 경계 >를 >=로 변경 | 정확히 7일째 환불 테스트 실패 |
| 좌석 증설 환불을 전체 등록 수 기준으로 변경 | 증설 환불 테스트 실패 |
두 테스트 모두 실패했어요. 특히 두 번째 변이는 기존 구현에 있던 실제 오류와 같은 형태였고, 정책의 차이를 잡고 있다는 확인이었습니다.
정리
판정과 실행을 분리하니 실제 돈, DB, 시간이 없어도 정책을 검증할 수 있었어요. 상태를 한곳에 모으니 서로 다른 모듈에 흩어져 있던 전이 규칙을 볼 수 있었습니다. 또한 전이표와 불변식을 작성하니, 정상 응답 뒤에 숨어 있던 증설 환불 버그와 스케줄러 복구 뒤의 이중 청구 가능성도 확인할 수 있었습니다.
테스트가 어려운 코드를 만났다면 mock을 더 만들기 전에 이 질문부터 해볼 수 있어요.
이 코드가 지금 판단해야 하는 사실과, 판단 결과를 외부 세계에 반영하는 일을 한꺼번에 하고 있지는 않을까요?
판단을 함수로 분리하고 시간과 상태를 입력으로 만들면, 무엇을 테스트해야 하는지도 선명해지는 것을 배웠습니다.
긴 글 읽어주셔서 감사합니다.
같은 카테고리의 글
DEV 글 더 읽기
이전 글
도메인 주도 프론트엔드 구조는 AI 개발 효율을 높일 수 있을까
다음 글
이어지는 글이 없습니다