정지 파트너 결제 메시지를 컨트롤러 입구에서 즉시 차단
목차
이커머스 결제 플랫폼을 운영하다 보면 외부 결제대행사와의 연동 포인트가 조용히 발목을 잡는 경우가 생긴다. 이번도 그랬음. 파트너를 정지시켰는데 결제대행사 쪽 모니터링 메시지가 계속 들어오고 있었고, 그 메시지들이 우리 비즈니스 로직 안쪽까지 파고들어가서 이력에 흔적을 남기고 있었다.
정지 사유가 사기 의심이든 계약 해지든 상관없이, 정지 걸린 시점부터는 어떤 입금/결제 이벤트도 시스템 안쪽으로 흘러들면 안 됐다. 원칙은 단순한데 구현이 그걸 못 따라가고 있었던 것.
왜 입구 차단이 늦어졌나
문제의 핵심은 컨트롤러 설계 순서였다. 메시지 수신 → 파싱 → 파트너 매칭 → 잔액 반영이 하나의 흐름으로 이어져 있었고, 정지 여부 확인은 잔액 반영 직전에야 끼어들어 있었음. 그러니까 파싱은 이미 됐고, 파트너 매칭도 됐고, 각종 전처리 로직도 다 돌아간 다음에야 "아 이 파트너 정지됐네"를 알게 되는 구조.
이 시점에서 되돌아가면 이미 여러 군데 부작용이 남는다. 로그에는 한 줄씩 쌓이고, 일부 캐시나 상태값에도 영향이 갔을 수 있다. 뒷정리가 늘어난다.
이런 구조가 생기는 이유는 대부분 "파트너 정지" 기능이 나중에 추가되기 때문이다. 처음 설계할 때는 정상 파트너만 있다는 전제로 흐름을 짠 다음, 정지 기능이 생기면서 기존 흐름 어딘가에 조건을 끼워 넣게 된다. 그 "어딘가"가 잔액 직전이었던 것.
차단 위치를 앞으로 당김
가장 단순한 수정은 입구에서 막는 것. 컨트롤러에서 파트너 식별이 끝나는 순간, 다른 무엇보다 먼저 정지 여부를 확인하고 거기서 끊으면 된다.
// 파트너 식별 직후, 다른 로직 진입 전
Partner partner = resolvePartner(request);
if (partner == null) {
return ResponseEntity.status(401).body("UNKNOWN_PARTNER");
}
if (partner.isSuspended()) {
suspensionLog.record(partner.getId(), request.getTransactionRef());
return ResponseEntity.status(403).body("SUSPENDED");
}
// 이 아래부터 기존 흐름
processPaymentMessage(partner, request);
응답 본문을 굳이 상세 JSON으로 만들지 않은 이유가 있다. 결제대행사 모니터링 시스템은 상태코드 + 코드 문자열만 보고 재시도 정책을 결정하는 구조였음. 오히려 JSON 키를 늘리면 우리 쪽이랑 결제대행사 쪽이랑 양쪽 다 스펙 변경이 생긴다. 간결하게 403 + "SUSPENDED" 한 줄로 계약을 맺는 게 관리 비용이 훨씬 낮다.
외부 연동 엔드포인트에서 에러 응답을 설계할 때 종종 "정보를 많이 주면 좋지 않을까"라는 생각이 드는데, 받는 쪽이 어차피 상태코드 기반으로 분기하는 시스템이라면 오히려 역효과다. 필요한 정보는 그쪽이 읽을 수 있는 형태로만 주면 된다.
차단할 때 고민했던 것들
재시도 폭주 가능성. 403을 내리면 일부 클라이언트가 계속 재시도하는 경우가 있다. 이번에는 SUSPENDED라는 명시적 코드를 응답에 박아두니까 결제대행사 측에서 dead letter로 분류해서 재시도 없이 처리해줬다. 상태코드만으로 재시도 여부를 결정하는 구현이었다면 다른 대응이 필요했을 것.
차단 이력은 반드시 남긴다. 정지된 파트너 메시지를 막았다고 해서 흔적을 0줄 남기면 나중에 "왜 이 기간에 입금 이력이 없냐"는 질문이 들어왔을 때 추적이 불가능해진다. 차단 자체를 별도 로그 테이블에 기록해둔 이유가 여기 있음. 비즈니스 흐름 로그랑 분리해서 관리하는 게 나중에 쿼리하기도 편하다.
정지 직전 in-flight 메시지. 정지를 걸기 직전에 이미 큐에 들어온 메시지는 어떻게 할 것인가. 이건 정책 문제라서 코드로 해결하려 하면 오히려 복잡해진다. 이번에는 "정지 시각 기준으로 컷"이 명확하게 정의되어 있었고, 그 시각 이전에 큐 진입한 건은 처리해도 된다는 합의가 이미 있었음. 그래서 in-flight 처리는 그대로 두고 진행했다. 정책이 없었다면 이 부분을 먼저 정리했어야 했을 것.
이번 작업을 요약하면 아래 표 한 줄로 정리된다.
| 항목 | 처음 구조 | 변경 후 |
|---|---|---|
| 차단 위치 | 잔액 반영 직전 | 컨트롤러 입구 |
| 응답 코드 | 400 | 403 + SUSPENDED |
| 로그 처리 | 기록 없음 | 별도 차단 로그 테이블 |
| 응답 본문 | 상세 사유 JSON 고려 | 코드 문자열만 |
이 패턴, 어디에 더 쓸 수 있나
막을 거면 가장 앞에서 막는다는 원칙은 이 컨트롤러에만 해당하는 게 아니다. 외부에서 메시지가 들어오는 엔드포인트라면 다 비슷한 구조를 가지고 있고, 파트너 정지 체크가 비즈니스 로직 안쪽에 묻혀 있을 가능성이 있다.
실제로 이번 작업 끝에 다른 외부 인입 엔드포인트를 훑어봤더니 정지 체크 위치가 제각각이었다. 어떤 건 파싱 직후, 어떤 건 저장 직전. 이걸 공통 가드 미들웨어나 인터셉터로 올려서 한 곳에서 관리하면 훨씬 깔끔하다. 코드 레벨에서도 "이 컨트롤러는 정지 파트너 체크를 어디서 하지?"라는 질문을 할 필요가 없어지고, 모니터링 쪽에서도 차단 로그가 한 곳에서 나오니까 추적 비용이 줄어든다.
다음 작업으로 공통 가드 만드는 걸 메모해뒀다. 이런 류의 전처리 조건이 컨트롤러마다 따로 구현되어 있으면 나중에 정책이 하나 바뀌었을 때 열 군데를 다 찾아야 한다. 입구를 단일화해두면 정책 변경이 한 줄 수정으로 끝난다.
댓글 0
첫 댓글 달아줘.