QR 결제 중계 플랫폼에는 망취소라는 배치가 있습니다. 승인은 났는데 단말이 결과를 받지 못한 거래처럼, 양쪽 원장이 어긋난 건을 찾아 결제사에 취소를 거는 일입니다.
승인 통지는 이미 결제사로 나간 상태입니다. 취소 통지가 나가지 못하면 상계가 깨집니다. 결제사 원장에는 결제 완료가 그대로 남고 우리 쪽만 취소로 남습니다. 그런데 이 배치에서 특정 결제사만 취소가 되지 않았습니다.
로그 한 줄만 남기고 사라지는 실패
증상은 이랬습니다.
[망 취소 배치] 건별 처리 실패 - 거래번호: xxx | ClassNotFoundException
호출 경로를 따라가 보니 이렇게 흐르고 있었습니다.
이 플랫폼의 환불은 두 방식이 공존합니다. 오래된 결제사는 클래스명을 문자열로 조립해 리플렉션으로 찾고, 새로 붙이는 결제사는 전략 패턴으로 처리합니다. 레거시를 걷어내는 중이라 과도기입니다.
문제의 결제사는 전략만 있고 레거시 구현은 이미 지운 상태였습니다. 그런데 배치에서는 전략을 못 찾으니 리플렉션으로 흘렀고, 찾을 클래스가 없어 예외가 났습니다.
여기서 나쁜 건 실패 그 자체가 아니라 실패하는 방식이었습니다. 이 예외는 재시도 횟수를 적재하기 전에 터집니다. 그래서 누적 실패 카운터에 잡히지 않고, 카운터를 보고 뜨는 경보도 영원히 뜨지 않습니다. 상계가 깨지는 유일한 경로인데 아무도 모릅니다.
첫 번째 발견: 구현체가 클래스패스에 아예 없었습니다
전략을 못 찾는 이유는 단순했습니다. 배치 모듈의 빌드 설정에 구현체가 있는 모듈이 없었습니다.
빌드 산출물로 확인했습니다.
unzip -l module-scheduler/build/libs/module-scheduler-x.x.x.jar | grep -c "gateway"
# 0
155MB짜리 부트 jar 안에 게이트웨이 모듈의 클래스가 한 건도 없었습니다. 인터페이스는 공통 모듈에, 구현체는 게이트웨이 모듈에 있었고, 배치는 공통 모듈만 의존했습니다.
Spring은 이 상황에서 조용합니다. List<RefundStrategy>를 주입받는 팩토리는 빈 리스트를 받고 정상적으로 생성됩니다. 아무 경고도 없습니다. 그리고 isSupported()는 항상 false를 반환합니다.
배치에서 전략 패턴은 고장 난 게 아니라 애초에 존재하지 않았습니다.
두 번째 발견: 그건 실수가 아니라 규칙이었습니다
여기서 저는 "의존성 한 줄 추가하면 되겠다"고 생각했습니다. 실제로 그렇게 권했습니다.
그리고 아키텍처 가이드를 열어보고 틀렸다는 걸 알았습니다.
| 모듈 | 의존 가능 대상 |
|---|---|
| API 서버 | common, external, gateway |
| 소켓 서버 | common, external, gateway |
| 배치 | common, external |
배치만 게이트웨이 의존이 명시적으로 빠져 있었습니다. 실제 코드도 그대로였고요. 누가 빠뜨린 게 아니라 의도된 경계였습니다.
배경을 짐작하면 "배치는 결제를 하지 않는다"는 가정이었을 겁니다. 배치는 정산·대사를 하지 승인을 내지 않으니까요.
망취소는 그 가정을 이미 깨고 있었습니다. 이름만 배치일 뿐 하는 일은 결제 취소입니다.
세 번째 발견: 그래서 어떤 결제사는 Kafka로 우회하고 있었습니다
같은 배치에서 결제사 H는 취소가 잘 됐습니다. 코드를 보니 전략을 부르기 전에 먼저 빠져나가고 있었습니다.
if (isMessageBasedTarget(payment)) {
abortProducer.sendAbort(payment); // Kafka 로 발행하고 종료
return;
}
// 아래로 내려가야 전략/리플렉션
그리고 그 프로듀서 클래스의 주석에 답이 있었습니다.
"배치에서도 사용을 하기에 공통 모듈에서 처리한다"
임시방편이 아니었습니다. 배치가 게이트웨이를 의존할 수 없으니, 전략을 직접 부르는 대신 메시지로 넘긴 것입니다. 모듈 경계를 지키기 위한 설계였습니다.
결제사 H는 Kafka 반대편에 상대 시스템이 있어서 이게 성립합니다. 반면 문제의 결제사 M은 우리가 직접 HTTP를 호출하는 구조라 넘길 상대가 없습니다. 그래서 우회로가 없었고, 조용히 실패하고 있었습니다.
진짜 문제는 소비자 집합의 불일치였습니다
여기까지 와서야 문제의 이름을 제대로 붙일 수 있었습니다. 파일 위치가 아니라 누가 쓰느냐였습니다.
| 능력 | 쓰는 배포 모듈 |
|---|---|
| 결제 승인 | API, 소켓 [2개] |
| 취소·환불 | API, 소켓, 배치 [3개] |
게이트웨이 모듈 안에는 승인·POS·조회·취소가 함께 있었습니다. 그중 셋은 소비자가 같고, 취소만 하나 더 많았습니다.
소비자 집합이 다른 두 능력이 한 모듈에 있으면, 좁은 쪽에 맞춰 넓은 쪽이 잘려 나가거나 넓은 쪽에 맞춰 좁은 쪽이 오염됩니다. 여기서는 전자였습니다.
이건 결제사마다 재발합니다
이게 "결제사 M 하나 살리면 되는 버그"가 아니라는 걸 보여준 표입니다. 전략 이관 단계별로 배치가 어떻게 되는지 그려봤습니다.
| 단계 | API·소켓 | 배치 | 결과 |
|---|---|---|---|
| 레거시만 있음 | 리플렉션 | 리플렉션 | 동일하게 동작 |
| 전략 추가됨 | 전략 | 리플렉션 | 같은 결제사가 모듈마다 다른 코드 |
| 레거시 제거됨 | 전략 | 없음 | 조용한 실패 |
결제사를 전략으로 옮길 때마다 2단계를 지나고, 레거시를 지우면 3단계에 들어갑니다. 실제로 다음 결제사의 전략 클래스가 이미 빈 껍데기로 자리를 잡고 있었습니다. 채우는 순간 2단계가 됩니다. 전략 통일이 목표인 이상, 모듈 구조를 고치지 않으면 이관을 완주할 수 없었습니다.
어떻게 나눌지 세 가지 안을 놓고 비교했습니다
| 안 | 대가 |
|---|---|
| 배치가 게이트웨이를 의존 | 한 줄이면 끝나지만 배치가 승인 코어까지 받습니다. 가이드 위반이고 "게이트웨이엔 결제 코어만"이라는 의도를 포기합니다 |
| 구현을 공통 모듈로 이동 | 공통 모듈은 배포 모듈 4개 전부가 의존합니다. 취소가 필요 없는 모듈까지 강제로 받습니다. 영향 범위가 오히려 2배가 됩니다 |
| 취소 구현만 별도 모듈로 | 이관 작업량은 있지만 소비자 집합이 정확히 맞습니다 |
두 번째 안이 직관적으로 끌립니다. "다 같이 쓰는 거니까 공통 모듈에." 하지만 공통 모듈은 모두가 의존하므로, 거기 넣는 건 "모두에게 강제 배포"입니다. 취소 경로가 없는 모듈까지 결제사 전략과 Kafka 컨슈머를 떠안게 됩니다. 세 번째를 골랐습니다.
계획을 검증하다 제 설계서가 틀린 걸 발견했습니다
설계서를 쓰고 구현 계획으로 넘어가기 전에, 계획이 실제로 실행 가능한지 코드로 검증했습니다. 여기서 제가 쓴 설계서의 전제 하나가 틀렸다는 걸 알았습니다.
설계서에는 "환불 패키지 17개 파일을 통째로 옮긴다"고 썼습니다. 그런데 그 패키지 안에는 환불 전용이 아닌 것들이 섞여 있었습니다.
| 클래스 | 경로상 위치 | 실제 소비자 |
|---|---|---|
| 메시지 응답 DTO | refund/.../dto/out/ | 승인 결과 노티 · 콜백 응답 · 동적 QR 응답 · 결제 응답 — 승인 쪽이 더 많음 |
| 전송계층 예외 2개 | refund/.../exception/ | 승인 클라이언트도 동일하게 사용 |
이대로 옮겼으면 승인 전용 코드가 취소 모듈을 의존하는 그림이 됐을 겁니다. 모듈을 나누는 이유(소비자 집합 분리)를 스스로 깨뜨리는 상태로요.
패키지 이름이 refund라고 해서 그 안이 전부 refund인 건 아니었습니다. 그래서 이 셋을 먼저 공통 모듈로 올리는 단계를 앞에 넣었습니다. 덤으로 죽은 코드 2개(빈 인터페이스와 빈 구현체, 참조 0건)도 발견해 이동 대신 삭제로 바꿨습니다.
모듈은 기능이 아니라 소비자로 가르기로 했습니다
이 작업에서 모듈 하나보다 값진 산출물은 기준이었습니다. 논의 중에 이런 질문을 받았습니다. "기능 단위로 모듈을 만들면 확장성이 안 좋지 않을까?"
타당한 우려입니다. 기능마다 모듈을 만들면 모듈이 폭발합니다. 하지만 제 제안은 기능 단위가 아니었습니다.
| 기준 | 결과 |
|---|---|
| 계층(controller/service/repo) | 모든 배포 모듈이 전부 필요 — 경계 역할을 못 함 |
| 기능(취소, 승인, 조회…) | 기능마다 모듈 → 폭발 |
| 소비자 집합(누가 쓰는가) | 쓰는 쪽이 같으면 한 모듈 |
게이트웨이 안의 승인·POS·조회는 기능이 셋인데 한 모듈에 있고 아무 문제가 없었습니다. 소비자가 같기 때문입니다. 문제는 취소 하나였습니다.
실제로 세어보니 소비자 집합이 갈리는 건 취소 하나뿐이었습니다. 늘어나는 모듈은 1개입니다. 모듈 개수보다 중요한 건 의존 그래프의 모양이었습니다.
지금 의존 그래프는 이렇습니다.
취소 모듈을 떼어내면 이렇게 됩니다.
깊이는 3에서 3으로 그대로, 순환은 여전히 0입니다. 새 모듈이 잎으로 붙을 뿐입니다. 반대로 배치가 게이트웨이를 의존하는 안은 배치가 승인 코어 수십 개 파일을 끌고 옵니다. 그래프상으로도 더 무겁습니다.
가이드에는 새 모듈을 만드는 세 조건을 넣었습니다. 셋을 모두 만족할 때만 만듭니다.
- 소비자 집합이 기존 모듈과 다르다 (가장 중요)
- 그 차이가 구조적이다 — 일시적 편의가 아니다
- 떼어낼 수 있다 — leaf이거나 의존이 얕다
하나라도 어긋나면 기존 모듈에 둡니다. 만들지 않는 쪽이 기본값입니다.
조립이 보이지 않았던 게 근본 원인이었습니다
모듈을 나누는 것만으로는 부족했습니다. 이 사고의 뿌리는 "어느 모듈이 무엇을 조립했는지 아무도 몰랐던 것"이니까요. 그래서 세 가지를 함께 넣기로 했습니다.
기동 로그에 팩토리가 등록한 전략 목록을 남깁니다. 모듈별 조립 결과가 기동 즉시 보입니다.
[Refund] 등록된 전략: 결제사 M, 결제사 H ← API·소켓
[Refund] 등록된 전략: 결제사 M ← 배치
[Refund] 등록된 전략: 없음 … ← 의존이 빠진 상태
폴백 경고는 전략이 없어 레거시로 흐를 때 남깁니다. 레거시가 남아 있는 동안은 정상 경로라 예외가 아니라 경고이고, 레거시를 걷어낼 때 예외로 바꿉니다. 참고로 코드에는 이미 이런 TODO가 있었습니다. "없는 간편결제사면 Exception 을 응답 해야함" — 알고 있었지만 미뤄둔 자리였습니다.
ArchUnit으로 규칙을 문서가 아니라 빌드에 둡니다. 취소 모듈이 게이트웨이를 참조하면 빌드가 실패합니다. 문서는 잊히지만 빌드는 잊히지 않습니다.
아쉬운 점
아직 코드는 한 줄도 옮기지 않았습니다. 설계서와 8개 태스크짜리 계획서까지가 지금 시점입니다. 이 글에 쓴 "결과"는 전부 검증된 진단과 결정이지, 동작하는 코드가 아닙니다.
확인 못 한 게 하나 남았습니다. 망취소 진입점이 두 개인데, 그중 하나에는 결제사 H의 Kafka 우회가 없습니다. 그 경로는 결제사 H조차 레거시 리플렉션으로 흘러 실패할 겁니다. 그런데 그 메서드가 실제로 언제 호출되는지 확인하지 못했습니다. 죽은 경로일 수도 있어서 단정하지 않고 "확인 필요"로 남겼습니다.
이관 비용을 과소평가했습니다. 처음엔 "17개 파일 이동"이라고 봤는데, 검증하고 나니 공통 모듈로 올릴 클래스 5개와 그걸 참조하는 파일 30개 가까이가 딸려왔습니다. 망분리 환경이라 문서로 손이관하는 걸 감안하면 작지 않아, 계획을 동작 불변 단계와 기능 변경 단계로 쪼개 각각 독립적으로 넘길 수 있게 했습니다.
남는 것
두 번 틀렸습니다. 의존성 한 줄이면 된다고 권했다가 그게 규칙 위반임을 알고 되물렀고, 환불 패키지를 통째로 옮기면 된다고 설계서에 썼다가 그 안이 환불 전용이 아니라는 걸 계획 단계에서 발견했습니다.
둘 다 "이름을 보고 내용을 추측한 것"이 원인이었습니다. 모듈 의존이 빠진 걸 보고 실수라고 추측했고, 패키지 이름이 refund인 걸 보고 안이 전부 환불이라고 추측했습니다. 둘 다 실제로 열어보니 달랐습니다.
그래서 이번 작업에서 가장 값진 산출물은 분리된 모듈이 아니라, **"이름 말고 소비자를 세어보라"**는 기준이었다고 생각합니다.
2편 — 빌드에 규칙을 뒀는데, 빌드가 안 보는 곳이 있었습니다
이 글의 계획대로 실제로 64개 파일을 옮기고, 곧바로 다음 결제사를 그 구조 위에 붙여봤습니다. 새로 세운 규칙은 이관 전부터 있던 결함을 세우자마자 잡았지만, 정작 가장 위험한 누락 두 건은 빌드가 통과하는 채로 지나갔습니다. 위의 마지막 문장을 그 글에서 고쳐 씁니다.
