개발 slecs

쿠폰 매입 신청·정산 흐름을 상태머신으로 안정화

목차

수기 처리의 한계는 사고가 터지기 전까지 잘 안 보인다. 파트너 정산 흐름에서 쿠폰 잔량 매입을 메일+엑셀로 굴리던 구조가 그랬다. 누락·중복 신청 사고 두 건이 연달아 터지고 나서야 시스템화 작업이 일정에 올라왔다. 운영팀 월별 처리량이 늘어나면 늘어날수록 수기 흐름은 반드시 어디선가 터진다. 규모가 작을 때 버텼다는 게 앞으로도 버틴다는 근거가 되지 않는다.

이 작업의 핵심은 단순히 화면 만들고 DB 테이블 붙이는 게 아니었다. 흐름 자체를 세 덩어리로 분리하고, 각 덩어리 사이에 상태 전이 규칙을 명확히 박는 것이었다.

컨트롤러를 쪼개는 이유

초안에서는 발행 이력 화면과 매입 신청 화면을 한 컨트롤러 안에 넣었다. 얼핏 보면 같은 "쿠폰 도메인"이라 묶어두는 게 자연스러워 보인다. 문제는 권한 레벨이 다르고, 필터 파라미터가 다르고, 상태 전이 로직도 다르다는 것. 한 컨트롤러에 우겨넣으니 GET 파라미터가 충돌했고 두 번 갈아엎었다.

두 번째 설계부터는 처음부터 분리했다.

  • 파트너 쪽: 매입 신청 접수 전용 컨트롤러
  • 어드민 쪽: 매입 검토/승인 컨트롤러, 발행 이력 컨트롤러 각각

세 흐름이 공유하는 데이터는 있어도, 진입 조건·권한·허용 액션이 제각각이다. 화면 단에서 섞이기 시작하면 정책 변경 시 어디를 고쳐야 하는지 추적이 안 된다. 관리자가 매입 승인 화면에서 발행 이력도 같이 보고 싶다는 요청이 오면, 그건 별도 조회 API를 붙이면 된다. 컨트롤러를 합쳐서 해결할 문제가 아니다.

상태머신 먼저, 화면은 나중에

상태 전이 규칙을 코드 박기 전에 표로 먼저 정리했다. 이게 생각보다 효과가 컸다.

상태 설명 파트너 잔액 변동 허용 전이
PENDING 신청 접수, 검토 대기 없음 APPROVED, REJECTED, CANCELLED
APPROVED 승인, 정산 대기 없음 SETTLED, REJECTED
SETTLED 정산 완료 매입가 지급 -
REJECTED 반려 없음 -
CANCELLED 신청 취소 없음 -

여기서 가장 중요한 결정은 잔액 변동 시점이었다. PENDING이나 APPROVED 단계에서 잔액에 손대고 싶은 유혹이 생긴다. "어차피 승인될 건데 미리 예약 차감하자"는 식의 논리. 하지만 이전 프로젝트에서 즉시 차감을 뒀다가 반려·취소 롤백이 누락되면서 정산 차이가 났던 경험이 있었다. 그 이후로는 잔액 변동은 최종 확정 상태, 즉 SETTLED 한 곳에서만 일으키는 걸 원칙으로 굳혔다.

상태를 코드 enum으로 관리하고, 허용되지 않은 전이를 요청받으면 예외를 던지게 해뒀다. 조건문으로 곳곳에서 막는 것보다 전이 자체를 막는 게 훨씬 안전하다.

ALLOWED_TRANSITIONS = {
    "PENDING":  {"APPROVED", "REJECTED", "CANCELLED"},
    "APPROVED": {"SETTLED", "REJECTED"},
    "SETTLED":  set(),
    "REJECTED": set(),
    "CANCELLED": set(),
}

def transition(request, target_status):
    if target_status not in ALLOWED_TRANSITIONS[request.status]:
        raise InvalidTransitionError(
            f"{request.status} -> {target_status} 전이 불가"
        )
    request.status = target_status
    if target_status == "SETTLED":
        apply_settlement(request)

이렇게 두면 나중에 상태 추가도 ALLOWED_TRANSITIONS 한 곳만 건드리면 된다. 컨트롤러 여러 곳에서 if status == 'X' and ... 형태로 분기 박는 것보다 훨씬 추적하기 쉽다.

도메인 로직 유틸 분리

발행 수량 검증이나 매입 가능 수량 계산, 단가 라운딩 같은 로직은 컨트롤러 안에 두지 않았다. 컨트롤러는 요청 파싱과 응답 직렬화만 하고, 계산 로직은 별도 유틸 모듈로 뺐다.

def calc_buybackable(total_issued, total_redeemed, already_buyback):
    return total_issued - total_redeemed - already_buyback

def calc_amount(buybackable, unit_price_at_issue):
    return buybackable * unit_price_at_issue

단가를 발행 시점 단가로 고정한 것도 중요한 결정이었다. 현재 단가 기준으로 계산하면 가격 정책이 바뀔 때마다 이미 신청된 건과 분쟁 소지가 생긴다. 파트너 입장에서도 신청할 때 얼마를 받는지 예측이 안 된다. 발행 시점 단가로 못박으면 계약 당시 조건 기준이라 논란의 여지가 없다.

이 정책은 코드 박기 전에 운영팀과 한 줄짜리 합의를 문서로 받아뒀다. 구두 합의로 갔다가 1차 리뷰에서 "현재 단가 기준 아니었나요?"로 뒤집혔던 경험이 있어서, 이번엔 슬랙 스레드에 확인 메시지를 남기고 OK를 받은 뒤 작업을 진행했다. 단가 계산 정책처럼 "이렇게도 볼 수 있고 저렇게도 볼 수 있는" 영역은 구현자가 판단해서 박으면 안 된다.

상태머신을 먼저 그린 덕에 컨트롤러 코드가 체감 30% 정도 줄었다. 잔액 변동 포인트가 SETTLED 하나로 수렴되니 회계팀 검수도 빨라졌다. 어느 신청 건이 언제 어떤 상태를 거쳤는지 로그 추적도 명확하다. 흐름이 복잡할수록 상태머신을 먼저 그리고 화면을 짜는 순서가 맞다. 반대로 가면 화면 다 만들고 나서 예외 케이스 막느라 코드가 누더기가 된다.

댓글 0

첫 댓글 달아줘.