일기 slecs

직접받기 콜백 분기 지옥을 정규화 단계 분리로 해소

목차

금융 파트너 콜백은 언제나 이 모양이다. 처음엔 코드 서너 개, 분기 두세 줄. 런칭 때는 깔끔했다고 기억하는데, 반년쯤 지나면 파트너가 응답 코드를 하나씩 추가하면서 가지가 늘기 시작한다. 그리고 어느 순간 보면 if 안에 if 안에 try 안에 elif가 있고, 각 가지 끝에서 로그를 찍거나 안 찍거나를 또 분기하고 있다. 이번 직접받기 콜백이 정확히 그 상태였다.

6단 깊이 분기가 실제로 어떻게 생겼는지 안 봤으면 상상하기 어려울 수도 있는데, 핵심은 외부에서 넘어오는 상태와 내부에서 판정하는 상태가 같은 변수를 공유하고 있었다는 거다. 파트너가 RC_0000이라고 보내면 우리 코드가 그걸 status = "success"로 덮어쓰고, 같은 변수를 그대로 DB 저장과 알림 발송에 넘겼다. 이러다 보니 두 가지 문제가 생긴다.

첫째, raw 응답 코드가 어느 순간 사라진다. 로그에 찍지 않으면 운영팀에서 "파트너가 뭘 보냈냐"고 물어볼 때 대답이 없다. 둘째, 새 케이스가 추가될 때 이 변수가 어느 시점에 어떤 값을 갖는지 추적이 안 돼서, 기존 분기를 깨뜨리지 않으려고 가장 끝 가지에 붙이게 된다. 그게 반복되면서 6단이 됐다.

정규화 단계를 별도 레이어로 뺀 이유

수정 방향은 단순했다. 콜백 처리를 세 단계로 강제 분리하는 것.

raw 응답  정규화 (enum)  도메인 액션  부수효과 실행

정규화 단계의 역할은 딱 하나다. 파트너가 보낸 raw 코드를 우리 내부 enum 값으로 바꾸는 것. 이 단계에서는 DB 호출도, 알림도 없다. switch 하나, 리턴 하나.

def normalize_callback_code(raw_code: str) -> CallbackResult:
    mapping = {
        "RC_0000": CallbackResult.SUCCESS,
        "RC_1001": CallbackResult.USER_CANCEL,
        "RC_1002": CallbackResult.TIMEOUT,
        "RC_2001": CallbackResult.INSUFFICIENT_BALANCE,
        "RC_2002": CallbackResult.LIMIT_EXCEEDED,
        # ... 나머지
    }
    return mapping.get(raw_code, CallbackResult.UNKNOWN)

이 함수는 side effect가 없으니 단위 테스트가 trivial하다. RC_0000 넣으면 SUCCESS 나오는지, 모르는 코드 넣으면 UNKNOWN 나오는지. 케이스별로 한 줄씩 검증 가능하다.

도메인 판정 단계는 enum만 받는다. raw 코드를 직접 보지 않는다.

def handle_callback(result: CallbackResult, context: PaymentContext) -> None:
    if result == CallbackResult.SUCCESS:
        finalize_payment(context)
    elif result == CallbackResult.USER_CANCEL:
        mark_cancelled(context)
        notify_user_cancel(context)
    elif result == CallbackResult.UNKNOWN:
        safe_fail(context)  # 재시도 안내 + 알림
    # ...
단계 책임 부수효과
정규화 raw 코드 → 내부 enum 없음
판정 enum → 도메인 액션 결정 없음
실행 액션 → DB / 알림 / 리다이렉트 여기서만

이렇게 나누고 나면 새 응답 코드 추가는 정규화 mapping 한 줄 추가가 전부다. 도메인 판정 쪽은 같은 enum 값으로 묶이면 건드릴 필요도 없다.

로그 트레이싱이 공짜로 따라온다

부수 효과인데, 오히려 이게 더 값졌다. 세 단계가 분리되니까 각 단계 진입·종료 시점에 로그를 일관되게 찍을 수 있다.

[CALLBACK] raw=RC_0000 payment_id=P-38291
[NORMALIZE] raw=RC_0000  result=SUCCESS
[ACTION] result=SUCCESS  finalize_payment
[EXEC] finalize_payment done, db_updated=True, notified=True

기존엔 로그가 분기마다 있거나 없거나였다. 어떤 가지는 찍고, 어떤 가지는 예전에 급하게 추가하면서 빼먹었다. 운영팀에서 특정 결제 건 추적 요청이 오면 코드 읽으면서 "이 케이스는 로그가 없네..."를 확인하는 시간이 생겼다. 지금은 payment_id 하나로 grep하면 네 줄이 나온다.

리팩터링 전에 케이스 표부터 그려야 하는 이유

이번에 수정 들어가기 전에 기존 분기를 전부 표로 정리했다. 코드에 흩어진 케이스가 몇 개인지 세어봤을 때 12개였는데, 정리하고 보니까 두 개는 실제로 같은 처리를 하면서 다른 가지에 있었다. 중복이 숨어 있었던 거다.

코드 먼저 손댔으면 이 중복을 발견 못 하고 그대로 두 갈래 유지했을 가능성이 높다. 표로 먼저 그리면 "이 두 케이스, 처리가 같은데 왜 다른 가지야?" 가 보인다. 이번엔 그걸 하나로 합쳤다.

리팩터링 체크리스트 같은 게 따로 있는 건 아닌데, 콜백/이벤트 처리 건에서 이 순서는 거의 공식처럼 쓰게 됐다.

  • 기존 케이스 전수 목록화 (코드 읽으면서 표 채우기)
  • 중복·누락 발견 후 논리적으로 먼저 정리
  • 그 다음 코드 변경

이렇게 안 하면 기존 분기 한두 개 빠뜨리거나, 새 구조에서 엣지 케이스 누락이 난다.

UNKNOWN 처리가 생각보다 중요한 자리를 차지한다

현재 정규화 enum에 아직 미분류로 남은 코드 두 개가 있다. 결제대행사 운영팀에 케이스 의미 확인 요청을 보내놨고 답 오면 채워 넣을 예정인데, 그때까지 UNKNOWN으로 떨어지는 건 안전한 실패 경로로 보내고 있다. 사용자한테 재시도 안내를 보여주고, 내부 알림을 띄운다.

UNKNOWN을 명시적으로 다루는 게 처음엔 오버처럼 보일 수 있다. 근데 이걸 안 하면 두 가지 중 하나가 된다. 파트너가 예상 외 코드를 보냈을 때 예외로 터지거나, 아무 처리 없이 조용히 넘어가거나. 전자는 운영팀 문의, 후자는 미처리 건 누적. 어느 쪽이든 나중에 손이 더 간다.

"당장 안 터지는 코드"와 "다음 사람이 못 고치는 코드"는 다른 문제라는 걸 이번에 다시 느꼈다. 콜백 분기 지옥은 한 달이 더 가도 배포는 됐을 거다. 그냥 새 케이스 추가할 때마다 5분씩 노려보는 게 반복됐을 거고, 언젠가 잘못 끼운 분기 하나가 다른 케이스를 덮는 버그로 터졌을 거다. 이번 스프린트에 밀어 넣은 게 맞는 타이밍이었다.

댓글 0

첫 댓글 달아줘.