인터넷 은행 수동수령 건에 송금 상태와 파트너 푸시 알림 추가
목차
일부 인터넷 은행으로 보낸 송금 건이 자동 수령이 안 된다는 게 처음 올라왔을 때, 처음엔 간헐적 네트워크 이슈인 줄 알았다. 근데 케이스를 모아보니 패턴이 있었다. 특정 인터넷 은행 계좌로 보낸 건만 반복해서 걸렸고, 결제대행사 응답 자체는 정상이었다. 수령인이 앱에 들어가서 직접 받기 버튼을 눌러야 입금이 완료되는 구조, 그게 문제였다.
시스템은 그걸 몰랐다. 아니, 정확히는 그 상황을 표현할 언어가 없었다. 입금 큐는 SUCCESS 아니면 FAIL. 대행사에서 넘어온 수동수령 응답 코드는 FAIL 버킷에 쓸려 들어갔고, 운영팀은 재시도해야 하는 건지 파트너한테 연락해야 하는 건지 매번 판단을 직접 해야 했다. 규모가 작을 땐 버텼지만 케이스가 쌓이니까 수작업 비용이 가시화됐다.
상태 하나를 추가한다는 것의 무게
해결책 자체는 단순했다. MANUAL_REQUIRED라는 상태를 하나 추가하고, 결제대행사 응답 코드를 여기에 매핑한 뒤, 큐에 따로 적재하고, 파트너한테 푸시를 보내는 것. 말하면 세 줄이지만 실제로는 영향 범위가 생각보다 넓었다.
| 단계 | 이전 | 이후 |
|---|---|---|
| 송금 시도 결과 | SUCCESS / FAIL 2종 | SUCCESS / FAIL / MANUAL_REQUIRED 3종 |
| 큐 적재 | 실패만 따로 분리 | 수동수령 건도 별도 상태로 적재 |
| 푸시 알림 | 실패 시 운영팀에만 | 파트너 단말로 "직접 받아주세요" 발송 |
| 디바이스 토큰 조회 | 사용자 단위 일괄 | 활성 디바이스 dedupe 후 발송 |
enum을 하나 늘리는 건 쉬운데, 그 enum을 소비하는 곳을 다 찾아야 한다. 큐 컨슈머, 정산 집계, 상태 조회 API, 운영 어드민 필터, 알림 발송 로직. 여러 군데가 switch나 if 분기로 묶여 있었고, "모르는 상태는 무시"로 막아둔 컨슈머가 없었다면 배포 순서를 굉장히 조심스럽게 잡아야 했을 거다. 다행히 큐 컨슈머 쪽은 unknown case를 그냥 skip하게 돼 있어서 신규 상태 먼저 배포하고 컨슈머 핸들러를 후배포해도 중간에 메시지 유실이 없었다. 이게 그냥 운이 아니라 설계가 얼마나 중요한지를 다시 실감한 부분이었다.
코드 흐름은 이런 모양으로 정리됐다.
송금 응답 → 결제대행사 코드 매핑
├─ 정상 입금: SUCCESS
├─ 수동수령 필요 (인터넷 은행): MANUAL_REQUIRED → 큐 적재 + 파트너 푸시
└─ 그 외 오류: FAIL → 재시도 큐
결제대행사 응답 코드를 내부 상태로 한 번 추상화하는 레이어가 핵심이었다. 대행사 코드를 그대로 로직에 흘려보내면 대행사가 스펙 변경할 때마다 컨슈머 전체를 뒤져야 한다. 매핑 테이블 한 군데만 고치면 끝나도록 격리해두는 게 이번에도 잘 먹혔다.
삽질 포인트 두 가지
FAIL로 떨구고 운영팀이 보정하게 하는 방안을 먼저 검토했었다. 신규 상태를 안 만들어도 되니까 빠르게 땜질할 수 있는 방법이었다. 근데 운영팀 입장에서 FAIL 리스트에 섞여 있으면 "이게 자동 실패인지, 파트너한테 연락하면 되는 수동대기인지" 구분이 불가능하다. 결국 Slack에서 개발팀한테 매건 물어보게 되고, 그 커뮤니케이션 비용이 상태 하나 추가하는 비용보다 훨씬 크다. 단기 편의를 위해 상태 모델을 뭉개면 결국 사람이 그 모호함을 메우는 구조가 된다.
푸시 알림 dedupe는 소소하지만 의외로 CS 임팩트가 있었다. 처음에 토큰을 사용자 단위로 묶어서 보냈더니 폰 두 대 쓰는 파트너는 알림을 두 번 받았다. "왜 두 번 오냐"는 문의가 들어왔고, 활성 디바이스 기준으로 dedupe 한 줄 추가하니 바로 해결됐다. 알림 품질을 올리는 게 메시지 문구를 다듬는 게 아니라 보낼 대상을 줄이는 쿼리에서 결정된다는 걸 이번에도 확인했다.
상태 모델을 처음부터 잘 잡는다는 게 뭔지
도메인 이벤트를 이진값으로 모델링하면 초기엔 단순하지만 시간이 지날수록 그 사이 어딘가에 끼어드는 케이스가 반드시 생긴다. 성공/실패 사이에 "성공하기 전 대기 중"이 끼어들었고, 그걸 기존 모델로 억지로 표현하려다 오히려 운영 복잡도가 올라갔다.
처음부터 명시적인 상태 기계(state machine)를 그리고 시작하면 이런 추가가 조금 더 자연스러워진다. 모든 전이(transition)를 열거하고, "현재 이 상태는 다음 어떤 상태로만 이동 가능한가"를 코드 레벨에서 제약하면 나중에 상태가 늘어나도 어디를 건드려야 하는지가 명확해진다.
# 예시 - 전이 허용 테이블을 명시적으로 관리하는 방식
ALLOWED_TRANSITIONS = {
TransferStatus.PENDING: {
TransferStatus.SUCCESS,
TransferStatus.FAIL,
TransferStatus.MANUAL_REQUIRED,
},
TransferStatus.MANUAL_REQUIRED: {
TransferStatus.SUCCESS,
TransferStatus.FAIL,
},
TransferStatus.SUCCESS: set(),
TransferStatus.FAIL: {TransferStatus.PENDING}, # 재시도 허용
}
def transition(current: TransferStatus, next: TransferStatus) -> TransferStatus:
if next not in ALLOWED_TRANSITIONS[current]:
raise InvalidTransitionError(current, next)
return next
이걸 다 처음부터 완벽하게 하기는 어렵다. 근데 "그 외" 케이스를 위한 자리 하나를 설계 초기에 비워두는 습관 정도는 들여놓는 게 낫겠다 싶다.
운영팀이 "이게 어떤 건지 모르겠다"고 말하는 순간, 보통 그게 신규 상태 하나 추가할 시점이다. 그 신호를 받고 바로 움직이는 게 쌓이면 나중에 훨씬 덜 고생한다.
댓글 0
첫 댓글 달아줘.