개발 slecs

수수료·쿠폰·정산 배치를 한 PR로 묶어 잔액 정합성 확보

목차

정산 도메인을 한 PR로 묶는 건 매번 결심이 필요하다. 파일 수가 늘어나고, 리뷰어한테 "왜 이걸 같이 넣었냐"는 소리 들을 각오를 해야 한다. 그런데 수수료·결제대행사 설정·정산 배치·쿠폰 상태 API — 이 네 덩어리를 따로 PR 올렸다면 중간 상태에서 잔액이 깨졌을 거다. 각 조각이 독립적으로 보일 뿐이지, 사실은 "파트너가 돈을 내고 받는 흐름" 한 줄로 이어져 있기 때문이다.

쿠폰 사용 한 건이 PENDING 3건을 동시에 만들어야 하는 이유

쿠폰 상태 API를 정산 쪽과 같이 묶은 게 외부에서 보면 어색할 수 있다. 근데 내부 흐름을 보면 당연하다. 쿠폰이 사용 처리되는 순간 세 건의 PENDING이 동시에 떨어져야 한다.

  • 충전 수수료 PENDING
  • 결제 수수료 PENDING
  • 판매대금 PENDING

세 건이 원자적으로 만들어지지 않으면 나중에 환불 요청이 들어왔을 때 한 건만 남아서 잔액이 어긋난다. 이건 재현도 어렵다 - 타이밍 문제가 아니라 로직 자체가 부분적으로만 실행된 결과니까. DDL에 상태 컬럼을 추가한 것도 이 세 건의 PENDING 생성을 강제하고, 각 단계별 상태 전이를 추적하기 위해서였다.

정산 도메인의 특성이 이렇다. 쿠폰이 수수료를 트리거하고, 수수료 CONFIRMED가 파트너 잔액을 움직이고, 잔액이 정산 배치에서 집계된다. 어느 한 고리를 따로 고치면 전체 루프의 정합성 검증이 불가능해진다.

DDL 스키마 결정 — 나중에 후회할 것들을 미리 정리

이번에 컬럼 늘리면서 가장 오래 고민한 건 상태 컬럼 타입과 인덱스 구성이었다.

항목 결정 이유
상태 컬럼 enum 대신 varchar 추후 상태 추가 시 ALTER 부담
인덱스 (status, created_at) 배치 조회 패턴이 상태→시간 순
정산일 날짜 컬럼 분리 월말 집계 쿼리 단순화

enum을 안 쓴 이유는 MySQL에서 enum 컬럼에 값 추가하면 테이블 리빌드가 발생할 수 있다는 것 때문이다. 상태 종류가 지금 당장 고정돼 있어도, 정산 도메인은 사업 규칙이 바뀔 때마다 중간 상태가 끼어드는 경우가 많다. 지금은 PENDING → CONFIRMED → SETTLED 세 단계지만, 6개월 뒤에 분쟁 중인 건에 대한 DISPUTED 상태가 생길 수도 있다. 그때 가서 ALTER TABLE 부담을 안 지려면 varchar로 두는 게 낫다고 판단했다. 맞는 결정인지는 6개월 뒤에 봐야 안다.

인덱스는 배치 쿼리 패턴을 먼저 써보고 결정했다. 배치가 매일 새벽에 WHERE status = 'PENDING' AND created_at < ? 형태로 때리는데, status 단독 인덱스로는 선택도가 낮아서 풀스캔 각이었다. (status, created_at) 복합으로 묶으니 실행 계획이 인덱스 레인지 스캔으로 바뀌었다.

컨트롤러 4개로 쪼갠 배경 — 인증 경계가 다르다

한 컨트롤러에 다 때려박는 방법도 있었다. 파일 수도 줄고, 라우터 설정도 단순해진다. 근데 이번엔 분리했다.

수수료 조회   → 파트너 본인 + 관리자
결제대행사 설정 → 관리자만
정산 배치    → 시스템 호출 (내부 스케줄러)
쿠폰 상태    → 외부 API 키 인증

인증 방식이 네 개다. JWT 기반 파트너 세션, 관리자 세션, 시스템 내부 토큰, 외부 API 키. 이걸 한 컨트롤러에 묶으면 인터셉터에서 분기 로직이 늘어나고, 어느 엔드포인트에 어떤 권한이 걸려 있는지 추적이 어려워진다. 권한 검증 로직이 여러 곳에 흩어지는 순간 보안 리뷰도 힘들어진다. 컨트롤러 단에서 인증 경계를 명확히 나눠두면, 각 컨트롤러에 어떤 인터셉터가 붙는지 파일 하나만 봐도 알 수 있다.

분리 비용(파일 수 증가, 라우터 등록 반복)보다 인증 경계 명확화 이득이 크다고 판단했다.

정산 배치 삽질 — hold 기간이 결제수단마다 다르다는 걸 뒤늦게 반영

처음 배치를 짤 때 가장 크게 틀렸던 부분이다. 가상계좌 hold는 2시간, 카드 hold는 3일이다. 근데 처음엔 이걸 한 쿼리로 때렸다. 결과적으로 가상계좌가 카드 hold 조건에 묻혀서, 2시간이 지나도 PENDING에서 CONFIRMED로 넘어가지 않았다.

결제수단별로 쿼리를 쪼개고, 각 만료 조건을 분리하면서 해결했다. 쿼리를 나눈 것 외에 트랜잭션 구조도 바꿨다.

// 변경 전 - 한 트랜잭션에서 SELECT FOR UPDATE + UPDATE + 잔액 변동
BEGIN
  SELECT * FROM fee_pending WHERE status = 'PENDING' AND ... FOR UPDATE
  UPDATE fee_pending SET status = 'CONFIRMED' WHERE ...
  UPDATE partner_balance SET amount = amount + ? WHERE ...
COMMIT

// 변경 후
// 1. 만료된 PENDING 조회 및 상태 변경 (짧은 트랜잭션)
BEGIN
  SELECT * FROM fee_pending WHERE status = 'PENDING' AND payment_method = ? AND hold_expires_at < NOW() FOR UPDATE
  UPDATE fee_pending SET status = 'CONFIRMED' WHERE id IN (...)
COMMIT

// 2. 잔액 반영 (별도 트랜잭션)
BEGIN
  UPDATE partner_balance SET amount = amount + ? WHERE partner_id = ?
COMMIT

한 트랜잭션에 다 넣으니 락 경합으로 배치가 30초씩 늦어졌다. PENDING 건수가 많아질수록 SELECT FOR UPDATE가 잡는 레코드가 늘고, 잔액 UPDATE까지 같은 락 아래에서 처리하니 다른 트랜잭션이 대기하는 시간이 길어졌던 것. 트랜잭션을 쪼개고 나서 3초 안쪽으로 들어왔다.

트랜잭션 분리에서 생기는 위험은 중간 실패 케이스다. status는 CONFIRMED로 바뀌었는데 잔액 반영 트랜잭션이 실패하면 불일치가 생긴다. 이건 status 변경 후 잔액 미반영 건을 주기적으로 체크하는 별도 조정 배치로 처리했다. 완벽하진 않지만 락 경합으로 전체 배치가 밀리는 것보다는 낫다고 판단했다. 어차피 정산 배치가 멱등하게 설계돼 있어서 조정 배치가 재처리해도 중복 반영이 생기지 않는다.

이번 PR이 남긴 것

도메인 4개를 한 번에 건드리는 건 통상적인 PR 원칙으로 보면 나쁜 습관이다. 리뷰 범위가 넓고, 리뷰어가 맥락을 따라오기 힘들다. 근데 정산처럼 모든 끝단이 연결된 도메인에선 부분 PR이 오히려 더 위험하다. 쿠폰 상태 API만 먼저 올렸으면 PENDING 3건 동시 생성 로직 없이 배포됐을 것이고, 그 상태에서 실서비스를 타면 잔액 불일치가 어느 순간 조용히 쌓였을 거다. 그게 터지는 시점에 원인을 찾는 게 훨씬 고통스럽다.

묶어서 배포하는 결정이 맞았는지는 앞으로 잔액 정합성 알림이 얼마나 조용한지로 판단할 수 있다. 지금은 조용하다.

댓글 0

첫 댓글 달아줘.