충전 잔액 만료 배치를 원장 구조로 개선한 과정
목차
충전 잔액에 만료일을 붙이는 건 단순히 "유효기간 지나면 0으로 만들면 끝" 같지만, 실제로 짜보면 의외로 결정해야 할 게 많다. 더 정확히는, 처음엔 결정해야 할 게 많다는 걸 모른다는 게 문제다. 조용히 짜다 보면 어느 순간 "이 구조론 안 되겠다"가 연속으로 오는 유형의 작업이었음.
만료를 "언제" 깎느냐 문제
처음엔 단순하게 만료일 지난 잔액을 매일 새벽 배치로 0 처리하려 했다. 어차피 새벽에는 트래픽도 없으니 괜찮겠지 싶었는데, 두 가지가 깨진다는 걸 코드 짜면서 깨달음.
- 사용자가 23:59:59에 결제 시도 → 동시에 배치가 돌면 잔액을 두 번 건드리는 경합
- 만료 직전에 부분 사용 → 어떤 충전 건의 잔액이 얼마 남았는지 추적 안 되면 부분 만료 자체가 불가능
두 번째 문제가 더 본질적이었음. 잔액을 단일 컬럼으로 관리하면 "A 충전분에서 1,500원 쓰고 B 충전분에서 800원 써서 총 2,300원 차감" 같은 구조가 DB에 남질 않는다. 그냥 "2,300원 줄었음"만 보임. 이 상태에서 만료 배치를 돌리면 "어떤 충전 건이 아직 살아 있고 얼마가 남았는지"를 역산해야 하는데, 그 역산 로직이 곧 버그 온상이 됨.
결국 충전 단위로 원장(ledger)을 쌓고, 사용 시점에 FIFO로 차감하는 구조로 방향을 잡았다. 결제 시스템에서 꽤 검증된 패턴이기도 함. 모든 잔액 변화를 트랜잭션 한 줄로 쌓고, 잔액 컬럼은 거기서 파생되는 캐시로만 쓰는 방식. 이렇게 가면 만료 배치가 하는 일도 "만료일 지난 충전 건의 남은 잔액만큼 만료 트랜잭션을 음수로 박는다"가 전부가 됨.
-- 만료 배치 핵심 (의사 코드)
INSERT INTO charge_transactions (charge_id, type, amount, created_at)
SELECT
c.id,
'EXPIRE',
-(c.remain_amount),
NOW()
FROM charges c
WHERE c.expired_at < CURRENT_DATE
AND c.remain_amount > 0
AND c.status = 'ACTIVE';
UPDATE charges
SET status = 'EXPIRED'
WHERE expired_at < CURRENT_DATE
AND remain_amount > 0;
잔액 컬럼을 직접 안 건드리니까 동시성 걱정도 줄고, 나중에 감사가 들어와도 트랜잭션 테이블 한 줄씩 다 보여줄 수 있음. 원장 구조의 핵심 장점이 여기 있다. "왜 이렇게 됐는지"를 항상 설명할 수 있다는 것.
조회가 무거워지는 시점이 반드시 온다
원장 구조로 가면 잔액 산출이 트랜잭션 SUM이 된다. 충전 이력이 적을 때는 괜찮은데, 파트너 한 명당 충전 이력이 수만 건 쌓인 케이스가 생기면 상세 페이지 들어갈 때마다 DB가 비명을 지름. 이 지점에서 캐시 전략이 필요해진다.
| 항목 | 처음 설계 | 바꾼 뒤 |
|---|---|---|
| 잔액 산출 | 매번 트랜잭션 SUM | 충전 건별 remain_amount 컬럼 캐시 |
| 정렬 | 만료일 ASC | 만료일 ASC + 충전일 ASC tiebreak |
| 페이지 | offset 페이징 | 커서(충전 ID) 페이징 |
충전 건마다 remain_amount 컬럼을 두고 트랜잭션 발생 시 같이 갱신하는 방식으로 바꿨다. 원장의 진실은 트랜잭션 테이블에 있고, remain_amount는 읽기 캐시라는 관점으로 설계함. 정합성은 야간 정합 배치가 다시 SUM 해서 비교하는 식으로 보장.
정렬에서 tiebreak를 추가한 것도 사소해 보이지만 중요한 부분이었음. 같은 날 만료인 충전 건이 여러 개 있을 때 정렬이 비결정적이면 FIFO 차감이 의도대로 작동하지 않는다. 충전일까지 묶어서 tiebreak로 잡아줘야 "먼저 충전한 것부터 소진"이 보장됨. offset 페이징을 커서로 바꾼 것도 이 정렬 보장이 전제. 유니크한 충전 ID로 커서를 잡으면 중간에 데이터가 삽입되거나 삭제돼도 페이지가 꼬이질 않음.
사용자한테 "왜 빠졌는지" 보여주는 게 생각보다 큰 작업
쿠폰을 쓰면 어떤 충전 건에서 얼마가 빠졌는지 사용자한테 안 보여주던 게 CS 컴플레인 1순위였음. 사용자 입장에선 "3,000원 썼는데 잔액이 왜 갑자기 0이야?"가 되는 거니까. 이걸 설명 못하면 CS가 다 떠안는다.
[2026-03-17 14:22] 쿠폰사용 -3,000원
└ 2026-02-01 충전분 -1,500
└ 2026-02-15 충전분 -1,500 (잔액 0, 만료처리됨)
어떤 충전 건이 소진됐는지, 만료가 같이 일어났는지까지 한 줄에 묶어서 보여주니까 문의가 확 줄었음. 백엔드 입장에선 트랜잭션 한 건이 충전 N개를 건드릴 수 있다는 걸 모델에 반영해야 해서, 응답 DTO를 1:N으로 바꾸는 작업이 제일 귀찮았다. 기존 API가 단일 차감 요약만 내려주던 구조라서, 클라이언트와 스키마 합의부터 다시 해야 했고 구버전 앱 호환 처리도 같이 딸려 옴. 이런 호환성 처리가 구현 자체보다 더 손이 가는 경우가 많음.
정합성 배치는 따로 돌리는 게 정답
만료 배치랑 정합성 배치를 같은 잡에 묶었다가 한 번 사고가 났음. 만료 처리가 실패하면 정합성 검증도 같이 죽어서, 다음 날 새벽까지 이상 징후를 알려주는 채널이 아예 사라져버린 것. 알람이 한 번도 안 울렸음.
단일 책임 원칙은 배치에도 그대로 적용된다는 걸 이때 몸으로 배웠다. 배치를 쪼갤 때 판단 기준은 단순함. "이 단계가 실패해도 저 단계는 돌아야 하는가?" - yes면 분리. 만료 배치가 죽어도 정합성 배치는 살아 있어서 "어디가 틀렸다"를 슬랙으로 쏴줘야 한다. 한쪽이 죽어도 다른 쪽이 진실을 알려주는 구조.
분리한 뒤 정합성 배치는 차이가 있을 때만 슬랙 메시지가 옴. 정상이면 아무것도 안 옴. 이게 노이즈 관리 측면에서도 훨씬 낫다. 배치 성공 알림이 매일 오는 구조는 결국 무시당하게 돼 있음. "정상이면 조용한" 감시가 더 오래 신뢰받는다.
작업 자체는 충전 만료라는 꽤 좁은 도메인이었는데, 뜯어보면 동시성 제어, 원장 설계, 쿼리 최적화, API 스키마 변경, 배치 아키텍처까지 건드리는 작업이었음. 세 가지 감각은 이후 유사한 도메인 설계에서도 계속 기준점이 됨.
- 잔액은 컬럼이 아니라 원장이다. 컬럼은 캐시일 뿐
- 배치는 하나의 책임만. 묶으면 장애 나면 같이 죽음
- 사용자 화면에 "왜 이만큼 빠졌는지" 안 보이면 CS가 다 떠안음
댓글 0
첫 댓글 달아줘.