TL;DR — 발행 지점 여섯 곳을 한 곳으로 모으고 아웃박스로 유실을 막는 설계입니다. 코드를 쓰기 전에 설계가 네 번 무너졌고, 네 번 다 리뷰가 아니라 다음 문서를 한 칸 더 구체적으로 적던 중에 무너졌습니다. 네 번째는 이 글이 앞에서 내린 결론 하나를 그대로 뒤집었습니다.
지난 글에서 증상 스무 개가 사실 하나였다는 결론에 도달했습니다. "거래가 끝났다"를 판정하는 주체가 여섯 명이었다는 것. 그래서 이번에는 그 하나를 고치려고 했습니다. 발행 지점을 한 곳으로 모으고, 그 한 곳이 아웃박스에 기록하고, 릴레이가 Kafka로 내보내는 구조입니다.
이 글엔 고쳐진 코드가 나오지 않습니다. 아직 한 줄도 안 고쳤기 때문입니다. 지금까지 한 것은 진단과 설계 문서와 구현 계획, 세 개의 문서뿐입니다.
그런데 코드를 시작하기도 전에 설계가 네 번 무너졌습니다. 무너진 시점이 공교롭습니다. 네 번 다 **리뷰가 아니라 "다음 산출물을 쓰던 중"**이었습니다.
| 언제 | 무엇이 깨졌나 | 왜 그때 보였나 |
|---|---|---|
| 아이디어 → 설계 문서 | "취소는 아웃박스에서 환율을 읽는다" | 문서에 아웃박스 정리 주기를 적다가, 환불 가능 기간 180일이 바로 옆 문단에 놓였다 |
| 설계 → 구현 계획 | payload의 NOT NULL 필드 4개 | "이후 태스크에서 채운다"고 썼는데, 그 태스크가 무슨 일을 하는지 적는 순간 채우는 곳이 없다는 게 드러났다 |
| 계획 → 검토 | "환불도 취소와 같은 포트를 쓴다" | 파일 경로를 적다가, 하위 공통 모듈이 상위 모듈을 의존할 수 없다는 것을 마주쳤다 |
| 검토 → 도메인 확인 | "환율을 재현해서라도 취소를 보낸다" | 재현값을 무엇과 대조할지 적으려다, 대조할 원본이 없다는 게 드러났다 |
네 번 다 다이어그램에서는 안 보이던 제약입니다. 데이터의 수명, 값의 출처, 모듈 의존 방향, 검증 가능성. 화이트보드에서는 전부 화살표 하나로 그려지는 것들입니다.
1️⃣ 30일 뒤 지워지는 테이블에서 180일 전 환율을 읽으려 했다
가장 먼저 손대야 할 것은 1편에서 다룬 의존 역전이었습니다. 해외 지갑 결제사 A의 취소 전문을 만들 때 USD 금액을 통합거래내역 테이블에서 조회하고 있었습니다. 결제 취소라는 동기 경로가 다운스트림 리포트를 기다리는 구조입니다.
질문을 다시 놓아봤습니다. 필요한 것은 "USD 금액을 어디서 읽을 것인가"가 아니라 "그 거래에 쓴 환율을 어디에 남길 것인가"입니다. 환율만 남으면 원화 금액은 원장에 있으니 언제든 다시 계산할 수 있습니다.
다만 최종적으로는 환율과 환산 금액을 함께 남기기로 했습니다. 같은 환율로 다시 나누면 같은 값이 나오니 이론적으로는 환율만으로 충분합니다. 그런데 절사가 한 번 더 들어가는 순간 계산 순서에 따라 끝자리가 갈릴 여지가 생깁니다. 원금·세금·봉사료를 각각 환산하고 합계를 맞추는 과정이 특히 그렇습니다. 저장된 값을 그대로 복사하면 승인과 취소의 금액이 비교가 아니라 동일해집니다. 어차피 통합거래내역 스펙에 환산 금액 컬럼이 이미 있어서 추가 비용도 없었습니다.
왜 취소 시점에 환율을 다시 뽑으면 안 되는가
여기서 한 번 짚고 갈 것이 있습니다. "취소할 때 환율 고시 테이블에서 그날 환율을 읽으면 되지 않나"는 자연스러운 반문입니다. 결제 도메인에서는 안 됩니다.
10,986원짜리 거래를 매매기준율 1,380.50으로 환산해 7.95 USD로 승인을 보냈다고 하겠습니다. 며칠 뒤 취소일의 환율이 1,385.20이면 같은 원화 금액이 7.93 USD가 됩니다.
| 시점 | 환율 | USD 환산 (소수점 2자리 절사) |
|---|---|---|
| 승인 | 1,380.50 | 7.95 |
| 취소 | 1,385.20 | 7.93 |
결제사는 이 취소 전문을 원거래와 대조합니다. 금액이 다르면 거절하거나, 부분 취소로 받아들여 0.02 USD가 미정산으로 남습니다. 금액만 보면 사소하지만, 정산 마감에서 사람이 원인을 찾아야 하는 종류의 잔액입니다. 그리고 이건 거래 한 건당 발생합니다.
승인에 쓴 환율은 그 거래의 취소까지 따라가야 합니다. 환율은 거래에 귀속된 불변값이지, 조회 시점에 평가되는 시세가 아닙니다.
1차 안과 2차 안
처음엔 환율 스냅샷 전용 테이블을 새로 만들자고 했습니다. 목적은 분명하지만 테이블 하나와 그것을 채우는 경로, 정리 정책, 마이그레이션이 통째로 딸려옵니다.
그러다 2차 안이 나왔습니다. 어차피 아웃박스를 도입하니까, 아웃박스 payload에 환율이 이미 들어갑니다. 취소는 아웃박스에서 원거래번호로 찾아 읽으면 된다. 새 테이블도 필요 없고, 이미 만들 것을 재사용하는 것이라 깔끔해 보였습니다.
붕괴 — 정리 주기를 적는 순간
설계 문서에 아웃박스 운영 정책을 적기 시작했습니다. 전송 완료된 행을 언제까지 두느냐는 흔한 질문이라 "전송 완료 후 N일 경과 시 정리"라고 적었습니다.
그런데 그 문단 바로 위에 환불 정책이 이미 적혀 있었습니다. 환불 가능 기간 180일.
두 숫자가 나란히 놓이자 끝이었습니다. 30일 뒤 지우는 테이블에서 180일 전 거래의 환율을 읽을 수 없습니다. 정리 주기를 180일로 늘리는 것은 답이 아닙니다. 아웃박스는 전송 큐이고, 전송 큐가 반년치 행을 이고 있으면 미전송 행을 찾는 폴링 쿼리가 그만큼 느려집니다.
진짜 원인은 정리 주기가 아니었습니다. 수명이 다른 두 데이터를 한 테이블에 두려고 했다는 것입니다.
| 데이터 | 성격 | 수명 |
|---|---|---|
| 아웃박스 행 | 전송 큐 | 전송 완료 시점까지 |
| 환율 스냅샷 | 거래 귀속 불변값 | 거래 원장과 동일 |
아웃박스는 "아직 못 보낸 것"의 목록입니다. 보내고 나면 존재 이유가 사라집니다. 환율 스냅샷은 정반대로, 거래가 살아 있는 한 같이 살아 있어야 합니다. 이 둘을 한 테이블에 두면 정리 주기를 어느 쪽에 맞춰도 다른 쪽이 틀립니다.
Before — 붕괴한 2차 안. 아웃박스를 조회 대상으로 삼았고, 그 테이블은 전송이 끝나면 정리됩니다.
최종안 — 정본은 원장 쪽에 두고, 아웃박스는 지연 구간만 덮는다
방향을 뒤집었습니다. 환율의 정본은 통합거래내역 테이블의 환율 컬럼입니다. 그 테이블은 거래 원장과 수명이 같고, 애초에 환율 컬럼이 스펙에 있습니다. 새 테이블을 만들지 않기로 한 이유가 여기 있습니다. 저장할 곳이 이미 있었는데 채우지 않고 있었을 뿐입니다.
다만 정본 하나로는 구멍이 남습니다. 승인 직후 즉시 취소하면 컨슈머가 아직 적재하지 않았을 수 있습니다. 1편에서 지적한 바로 그 레이스입니다. 그래서 한 단 더 내려갑니다.
After — 2단 폴백. ①이 정본, ②는 적재 지연 구간을 덮는 창입니다. 둘 다 없으면 거절합니다.
// 폴백 순서가 곧 신뢰도 순서다. 두 단계 다 "승인 때 확정된 값"이고, 추정은 없다.
return loadedLedger(originTxId) // ① 적재된 원장 — 정본
.or(() -> pendingOutbox(originTxId)) // ② 아직 전송 안 된 payload
.orElseThrow(RateUnavailable::new); // 0을 보내느니 실패한다
마지막 줄이 중요합니다. 1편에서 이 값은 null이거나 0으로 나가고 있었습니다. 폴백이 전부 실패할 수 있고, 그때는 조용히 0을 보내는 대신 실패로 끝내야 합니다. 틀린 금액으로 성공한 취소보다 실패한 취소가 낫습니다. 실패는 다시 시도할 수 있지만, 잘못 나간 취소는 결제사 쪽 원장에 남습니다.
한 가지 더 있습니다. ①에서 행은 있는데 환율 컬럼이 비어 있는 경우는 ②로 내려가지 않습니다. 그건 "아직 모른다"가 아니라 "환산 대상이 아니었다"는 확정이기 때문입니다. 내려가면 원래 없던 환율을 만들어내게 됩니다. 폴백은 모를 때 내려가는 것이지, 없을 때 내려가는 게 아닙니다.
2️⃣ "정확히 한 번"은 Kafka가 주지 않는다
설계 논의 중에 "카프카의 exactly-once를 따르자"는 요청이 있었습니다. 1편의 유실·중복 문제를 생각하면 자연스러운 요구입니다.
문제는 Kafka의 exactly-once가 그 exactly-once가 아니라는 것입니다.
Kafka EOS는 토픽 → 처리 → 토픽 경로에서 성립하는 보장입니다. 컨슈머 오프셋 커밋과 프로듀서 전송이 하나의 Kafka 트랜잭션에 묶이기 때문에 가능합니다. 그런데 우리 컨슈머가 하는 일은 외부 DB에 INSERT 입니다. DB 쓰기는 Kafka 트랜잭션에 참여하지 않습니다. processing.guarantee를 켜도 DB 쪽 정확히 한 번은 어디서도 보장되지 않고, 트랜잭션 코디네이터 왕복만큼 처리량이 떨어집니다.
필요한 것은 exactly-once가 아니라 effectively-once였습니다. 세 층으로 나눠 적으면 이렇게 됩니다.
| 층 | 무엇을 보장하나 | 수단 |
|---|---|---|
| 발행 | 중복 발행을 만들지 않는다 | 멱등 프로듀서 |
| 전달 | at-least-once를 그대로 받아들인다 | — |
| 소비 | 몇 번 와도 결과가 같다 | 거래번호 UNIQUE + 중복키 예외 흡수 |
즉 "카프카가 한 번만 보내게" 만드는 대신 "몇 번 와도 결과가 같게" 만듭니다. 애초에 아웃박스 패턴이 존재하는 이유도 이쪽입니다. 아웃박스는 DB 커밋과 메시지 발행을 원자적으로 묶어주지만, 그 대가로 전달은 at-least-once가 됩니다.
설정 파일을 열었더니 이미 되어 있었습니다
여기서도 한 칸 더 내려간 덕을 봤습니다. 구현 계획에 "멱등 프로듀서 설정" 태스크를 넣으려고 기존 Kafka 설정 파일을 열었습니다.
acks=all, enable.idempotence=true, max.in.flight.requests.per.connection 제한까지 이미 전부 되어 있었습니다. 1편에서 확인한 문제는 프로듀서 설정이 아니라 전송 실패 시 로그만 남기는 콜백이었는데, 설정과 실패 처리를 뭉뚱그려 "Kafka 신뢰성"이라고 부르는 동안 이 구분이 흐려져 있었습니다.
그대로 계획에 넣었다면 아무것도 하지 않는 태스크가 하나 생겼을 겁니다. 리뷰에서는 잡히지 않았을 겁니다. 맞는 설정을 하겠다는 항목이었으니까요.
멱등 소비를 "조회 후 없으면 INSERT"로 쓸 뻔했습니다
소비 쪽 멱등을 적으면서 처음 쓴 것은 이 형태였습니다.
if (repository.existsByTxId(txId)) return; // 이미 있으면 스킵
repository.save(entity);
읽기에는 자연스럽지만 동시에 2건이 들어오면 둘 다 조회를 통과합니다. 재전달은 대개 컨슈머 리밸런싱 직후에 몰려 오므로, 두 인스턴스가 같은 메시지를 거의 같은 시각에 처리하는 상황은 예외적이지 않습니다. 전형적인 TOCTOU입니다.
진짜 방어선은 DB의 UNIQUE 제약이고, 코드가 할 일은 그 제약 위반을 장애가 아니라 정상 흐름으로 흡수하는 것입니다.
try {
repository.save(entity);
} catch (DataIntegrityViolationException e) {
log.info("중복 전달 흡수 — 이미 적재된 거래 {}", txId); // 정상 종료
}
선조회는 지워도 되고 남겨도 됩니다. 남긴다면 그건 최적화이지 방어가 아니라는 것을 알고 남기는 것입니다. 1편에서 "정상적인 중복 전달이 장애로 처리된다"고 적었던 부분이 여기서 뒤집힙니다.
3️⃣ 계획을 쓰다가 설계의 구멍을 봤다
이 블록이 이 글의 중심에 가장 가깝습니다.
설계 문서는 통과했습니다. 발행 지점 하나, 아웃박스, 릴레이, 멱등 소비 — 구조에 이견이 없었습니다. 그다음 구현 계획을 쓰기 시작했습니다. 태스크마다 어떤 파일을 만들고 무엇을 넣는지 적는 문서입니다.
단일 발행 지점이 만드는 payload를 적다가 이렇게 썼습니다.
.txDateUtc(null) // TODO: 이후 태스크에서 채운다
.txTimeUtc(null)
.txDateKst(null)
.txTimeKst(null)
거래일시 4개입니다. UTC 일자·시각, KST 일자·시각. 이 값들은 기존 매핑 로직 어딘가에서 이미 만들어지고 있었고, 새 payload 구조에서도 같은 방식으로 채우면 된다고 생각했습니다.
그리고 그 "이후 태스크"에 번호를 붙이고, 그 태스크가 무슨 일을 하는지 한 줄 적으려고 했습니다.
그 태스크는 환율 서비스를 다른 모듈로 옮기는 작업이었습니다. 거래일시와 아무 관련이 없었습니다. 앞뒤 태스크를 훑어도 거래일시를 채우는 태스크는 없었습니다. 어디에도 없었습니다.
그리고 통합거래내역 테이블에서 이 4개 컬럼은 NOT NULL입니다.
그대로 갔다면 컴파일은 통과합니다. 단위 테스트도 payload 생성만 검증하면 통과합니다. 실패는 컨슈머가 INSERT를 시도하는 순간, 전건에서 납니다. 그것도 1편에서 확인한 대로 DLT 리스너가 없는 토픽에서요. 개편 후 첫 배포에서 승인 건까지 포함해 전부 유실됐을 겁니다. 지금 상태보다 나빠지는 개편입니다.
같은 방식으로 하나 더 나왔습니다. 원거래일자 필드에도 "다른 태스크에서 채운다"고 적어뒀는데, 그 태스크는 국가 코드 변환기였습니다. 원거래일자는 1편에서 "취소·환불에서 원거래를 잇는 유일한 단서"라고 적었던 바로 그 필드입니다.
왜 설계 리뷰에서는 안 보였나
설계 문서에는 필드 매핑 규칙 표가 있었습니다. 55개 컬럼을 훑으면서 무엇을 어떻게 바꿀지 적은 표입니다. 거래일시 항목에는 이렇게 적혀 있었습니다.
| 필드 | 변경 |
|---|---|
| 거래일시 (UTC/KST × 일자/시각) | 현행 유지 |
맞는 말이었습니다. 포맷을 바꿀 이유가 없었으니까요. 리뷰에서 이 줄을 보면 누구든 넘어갑니다. "현행 유지"는 안전한 항목처럼 읽힙니다.
문제는 그 아래에 있었습니다. payload 구조 자체를 새로 만들면서, 그 값이 어디서 오는지는 아무도 적지 않았습니다. 기존에는 매핑 코드가 도메인 객체에서 직접 뽑아 쓰고 있었고, 새 구조에서는 발행 지점이 payload를 만들어 넘깁니다. "현행 유지"라는 말은 포맷의 현행 유지였지 출처의 현행 유지가 아니었습니다. 출처는 통째로 바뀌는 중이었습니다.
매핑 표는 변환 규칙을 검증하지, 출처의 존재 여부를 검증하지 않습니다.
이건 리뷰어의 잘못이 아닙니다. 표에 없는 열을 리뷰할 수는 없습니다. 잡아낸 것은 "이 태스크는 무슨 일을 하는가"를 한 줄 적어야 했던 문서 형식이었습니다. "이후 태스크"라는 말은 추상 수준이 한 칸 높은 동안에는 아무 문제가 없어 보입니다. 그 태스크에 제목을 붙이는 순간 거짓말이 됩니다.
4️⃣ 모듈 경계에 막혀서 설계가 나아졌다
세 번째 붕괴는 조금 다릅니다. 이건 막힌 덕에 좋아진 경우입니다.
원거래 환율 스냅샷을 필요로 하는 곳이 두 군데였습니다.
- 취소 전문 생성 — 결제사 A로 나갈 전문에 USD 금액을 실어야 합니다.
- 환불 적재 — 환불 거래도 통합거래내역에 적재되고, 그 행에도 같은 환율이 들어가야 합니다.
계획 초안은 조회 포트를 게이트웨이 모듈에 두고, 환불 쪽에는 값을 인자로 전달받게 했습니다. 다이어그램에서는 화살표 두 개였습니다.
그리고 파일 경로를 적었습니다. 환불 서비스는 공통 모듈에 있고, 조회 포트를 두려던 곳은 게이트웨이 모듈입니다. 이 프로젝트의 의존 방향은 게이트웨이 → 공통입니다. 하위 모듈이 상위 모듈을 import할 수 없습니다. 재사용은 애초에 불가능했습니다.
손쉬운 해법이 더 나빴습니다
가장 빠른 회피는 공통 모듈에 같은 폴백 로직을 한 벌 더 만드는 것입니다. 코드 몇 줄 복사입니다.
그런데 그 순간 무엇이 생기는지 보면 이렇습니다. "취소 전문에 실린 환율"을 정하는 로직과 "적재되는 환율"을 정하는 로직이 두 벌이 됩니다. 지금은 같지만 한쪽만 고쳐지는 날이 옵니다. 폴백 순서가 갈리거나, 조건이 한쪽에서만 바뀌거나 하는 식으로요.
그리고 그 결과는 결제사에 보낸 금액과 우리 원장에 적힌 금액이 다른 상태입니다. 1편에서 다룬 문제들과 정확히 같은 계열입니다 — 같은 판정을 두 곳에서 각자 하고, 어긋나도 아무도 모릅니다. 1편 결론이 "판정 주체가 여섯 명이었다"였는데, 그걸 고치겠다면서 판정 주체를 둘로 만들 뻔했습니다.
폴백 전체를 아래로 내렸습니다
그래서 폴백 컴포넌트 자체를 공통 모듈로 내리기로 했습니다. 양쪽이 같은 컴포넌트를 주입받는 그림입니다.
결과를 정리하면 이렇습니다.
| 초안 | 최종 | |
|---|---|---|
| 환율 판정 지점 | 2곳 | 1곳 |
| 게이트웨이의 조회 포트 | 신설 예정이었음 | 불필요 — 계획에서 제외 |
| 환불 쪽 전달 인자 | 값을 넘겨받음 | 직접 조회 |
제약에 막혀서 원래 안보다 나아졌습니다. 모듈 의존 방향은 제가 정한 규칙이 아니고 협상할 수도 없는 것인데, 그 협상 불가능성이 "둘 중 옳은 쪽"을 강제로 골라줬습니다. 만약 두 모듈이 서로 자유롭게 참조 가능한 구조였다면 저는 초안대로 갔을 겁니다. 그게 더 빠르니까요.
부수로 발견한 것
이 컴포넌트를 공통 모듈에서 만들려면 공용 라이브러리의 리포지토리를 써야 했습니다. 열어보니 거래번호로 조회하는 메서드가 없었습니다. 이 테이블을 거래번호로 찾는 코드가 지금까지 없었다는 뜻입니다.
이 라이브러리는 별도 배포 아티팩트라 이번 작업의 수정 대상이 아닙니다. 고치면 재배포 전까지 빌드가 깨집니다. 그래서 쿼리 DSL로 우회하기로 했습니다. 우회는 우회라고 적어두고, 라이브러리 쪽 정리는 별건으로 남겼습니다.
5️⃣ 네 번째 붕괴 — 이 글이 앞에서 내린 결론이 뒤집혔다
앞의 셋은 설계가 정교해지는 과정이었습니다. 네 번째는 성격이 다릅니다. 제가 옳다고 적어둔 것이 틀렸습니다.
초안의 폴백은 3단이었습니다. ① 통합거래내역 → ② 아웃박스 payload → ③ 환율 고시 테이블에서 원거래일자로 재현. ③을 넣은 근거는 이랬습니다.
개편 이전에 쌓인 거래들은 ①에도 ②에도 값이 없고, 이 단계를 빼면 그 거래들은 취소 자체가 불가능해집니다. 추정값으로 시도해서 결제사가 거절하면 그때 사람이 개입할 수 있지만, 시도조차 못 하면 개입할 지점도 없습니다.
읽으면 그럴듯합니다. "부정확한 값을 쓰는 것과 아무 값도 못 쓰는 것 중에 후자가 더 나쁠 때가 있다"고까지 썼습니다.
무엇으로 대조할지를 적으려다 막혔다
③단계의 검증 방법을 문서에 적으려던 참이었습니다. 재현한 환율이 맞는지 어떻게 아느냐. 한 줄 적으려고 보니 답이 없었습니다.
승인 당시 실제로 보낸 USD 금액은 요청 본문에만 있었고, 시스템은 응답만 저장합니다. 대조할 원본이 존재하지 않습니다. 환율 고시 배치가 늦어 소급 적용된 경우, 지금 같은 날짜로 조회하면 다른 값이 나오는데 그 차이를 알아낼 방법이 없습니다.
그래서 ③은 "정확도가 낮은 폴백"이 아니었습니다. 정확도를 측정할 수 없는 폴백이었습니다. 둘은 다릅니다. 전자는 위험을 감수하는 결정이고, 후자는 위험의 크기를 모르는 채 감수하는 결정입니다.
여기에 도메인 쪽 확인이 겹쳤습니다. 결제사에 나간 취소는 되돌릴 수 없습니다. 틀린 금액으로 취소가 접수되면 그건 결제사 원장에 남고, 정정하려면 사람이 결제사와 협의해야 합니다. "시도라도 해보고 거절당하면 개입하자"는 시나리오의 전제 — 틀리면 거절당한다 — 부터 보장되지 않습니다. 결제사가 받아줄 수도 있습니다. 그러면 0.02 USD 차이가 조용히 미정산으로 남습니다. 1편에서 지적한 "조용한 실패"를 제 손으로 하나 더 만드는 셈입니다.
그래서 ③을 뺐습니다
| 3단 (초안) | 2단 (최종) | |
|---|---|---|
| ③ 환율 재현 | 있음 | 제거 |
| 스냅샷 없을 때 | 추정값으로 취소 시도 | 취소 거절 |
| 개편 이전 거래 | 자동 처리 | 수동 처리로 넘김 |
"개편 이전 거래는 취소가 불가능해진다"는 문제는 사라지지 않았습니다. 처리 주체가 코드에서 사람으로 옮겨갔을 뿐입니다. 다만 그게 맞는 자리입니다 — 검증할 수 없는 값을 쓸지 말지는 코드가 조용히 결정할 일이 아닙니다.
앞에서 "부정확한 값이 아무 값도 없는 것보다 나을 때가 있다"고 썼는데, 그 문장에는 조건이 빠져 있었습니다. 부정확한 정도를 알 때만 그렇습니다.
이 붕괴가 앞의 셋과 다른 점은, 잡아준 게 문서의 구체성이 아니라 도메인 지식이었다는 것입니다. "결제사에 나간 취소는 되돌릴 수 없다"는 코드에도 문서에도 안 적혀 있습니다. 다만 발견 경로는 같았습니다 — "이걸 무엇과 대조하지?"라는 한 칸 더 구체적인 질문에서 나왔습니다.
6️⃣ 지금 고치지 않기로 한 것
설계하다 보면 "이왕 손대는 김에"가 계속 나옵니다. 두 건은 명시적으로 미루기로 했습니다. 미루는 것도 결정이라 근거를 적어둡니다.
(가) 공유 Kafka 리스너 팩터리는 건드리지 않습니다
1편에서 컨슈머 에러 핸들러가 FixedBackOff(0L, 0L)이라고 적었습니다. 재시도 0회. DB 락 대기 한 번에 곧바로 DLT로 갑니다.
고치는 방향은 명확합니다. 재시도를 성격별로 나누는 것입니다.
| 실패 종류 | 처리 |
|---|---|
| 일시적 오류 (락 대기, 커넥션 부족) | 백오프 후 재시도 |
| 비즈니스 오류 (스펙 위반 값, 매핑 불가) | 재시도 없이 즉시 DLT |
문제는 그 리스너 팩터리를 다른 컨슈머들도 공유한다는 것입니다. 재시도 정책을 바꾸면 통합거래내역과 무관한 컨슈머들의 동작까지 같이 바뀝니다. 그중에 "재시도되면 곤란한" 처리가 있는지는 전부 읽어봐야 알고, 잘못 건드렸을 때 그 영향은 빌드에 안 잡히고 운영에서만 드러납니다.
그래서 공유 팩터리는 한 줄도 건드리지 않고, 전용 팩터리를 신설해 이 리스너에만 지정하기로 했습니다. 코드는 조금 늘어나지만 블라스트 반경이 이 컨슈머 하나로 닫힙니다. 공유 설정을 고쳐 모두를 개선하는 것보다, 각 컨슈머가 서로에게 영향을 주지 않는 쪽을 택했습니다.
(나) 수동 트랜잭션 구조는 미룹니다
D-MPM 콜백 처리는 @Transactional 없이 트랜잭션 매니저를 직접 받아 열고 닫습니다. 해외 결제사 B 승인 디스패처는 비동기만 걸려 있고 트랜잭션 경계가 없습니다.
처음 봤을 때는 정리 안 된 레거시로 보였습니다. 아웃박스 등록은 원장 저장과 같은 트랜잭션에 들어가야 하니, 이 구조부터 정리해야 하는 것 아닌가 싶었습니다.
코드를 읽으니 의도가 셋 있었습니다.
- 결제사 외부 호출을 트랜잭션 밖에 두기. 외부 응답을 기다리는 동안 커넥션과 락을 잡고 있지 않겠다는 것입니다.
- 커밋 이후에 후처리를 발행하기. 커밋 전에 발행하면 롤백된 거래의 이벤트가 나갑니다.
- 롤백한 뒤에 다시 실패 상태를 기록하기.
세 번째가 결정적이었습니다. 이 메서드는 실패 시 트랜잭션을 롤백하고, 그다음 새 트랜잭션으로 실패 상태를 남깁니다. 선언적 트랜잭션 하나로 통째로 감싸면 이 보상 쓰기까지 같은 트랜잭션에 들어가 함께 롤백됩니다. 실패했다는 흔적이 사라집니다. 1편에서 "실패가 조용하다"고 썼는데, 여기서 잘못 정리하면 조용한 실패를 하나 더 만드는 셈입니다.
선언적으로 바꾸려면 메서드를 최소 넷으로 쪼개고 별도 빈으로 추출해야 합니다. 같은 클래스 안에서 호출하면 프록시를 타지 않아 트랜잭션 애노테이션이 조용히 무시되기 때문입니다. 예외도 로그도 없이, 그냥 트랜잭션이 안 걸립니다.
그런데 이번 목표에는 그 개편이 필요 없었습니다. 아웃박스 등록을 커밋 직전 한 줄로 넣으면, 수동 트랜잭션이어도 "같은 커밋"은 성립합니다. 아웃박스 패턴이 요구하는 것은 선언적 트랜잭션이 아니라 원자성이고, 원자성은 이미 있습니다.
판단 기준은 **"이 변경이 틀렸을 때 어디서 드러나는가"**였습니다.
| 아웃박스 등록 한 줄 | 트랜잭션 구조 개편 | |
|---|---|---|
| 틀렸을 때 드러나는 곳 | 컴파일 / 적재 결과 | 런타임에만 |
| 대표적 실패 양상 | — | 프록시 미적용, 롤백 범위 변화 |
| 블라스트 반경 | 적재 경로 | D-MPM 승인 주 경로 전체 |
현재 저는 빌드까지만 확인 가능한 환경에서 작업합니다. 실행 검증은 개발망에 올려야 합니다. 프록시 미적용과 롤백 범위 변화는 둘 다 컴파일에 안 잡히고 런타임에만 드러나는 종류입니다. 검증 수단이 없는 곳에서 검증이 필요한 변경을 하는 것은, 변경이 옳은지와 무관하게 나쁜 선택입니다.
그래서 리팩토링 백로그에 착수 조건과 함께 등록했습니다 — 이 작업과 섞지 말 것, 단독으로 진행할 것, 실행 검증 환경 확보 후에 시작할 것.
정직하게 덧붙이면, 어차피 그 파일을 이번에 건드리므로 두 번 손대는 비용은 있습니다. 다만 "한 줄 추가"와 "네 조각으로 분해"는 성격이 다른 작업이라, 나중에 분해할 때 이번에 추가한 한 줄이 방해가 되지는 않는다고 봤습니다.
네 번 다 리뷰가 아니라 다음 문서가 잡았다
네 번의 붕괴를 다시 놓고 보면 공통점이 하나 있습니다. 아무도 지적해서 깨진 게 아닙니다.
- 아웃박스 정리 주기를 적다가 180일이 옆에 놓였습니다.
- 태스크에 제목을 붙이다가 채우는 곳이 없다는 걸 봤습니다.
- 파일 경로를 적다가 의존 방향에 막혔습니다.
- 검증 방법을 적다가 대조할 원본이 없다는 걸 알았습니다.
넷 다 추상 수준을 한 칸 내리는 행위 자체가 잡아냈습니다. 한 칸 위에서는 전부 통과하던 것들입니다. "아웃박스에서 읽는다", "이후 태스크에서 채운다", "조회 포트를 만들어 재사용한다", "없으면 재현한다" — 문장으로 보면 넷 다 멀쩡합니다. 다이어그램으로 그리면 화살표 하나씩입니다.
깨진 이유도 성격이 같습니다. 다이어그램이 표현하지 않는 것들이었습니다.
| 붕괴 | 다이어그램에 없던 것 |
|---|---|
| 아웃박스 환율 조회 | 데이터의 수명 |
| NOT NULL 필드 4개 | 값의 출처 |
| 포트 재사용 | 모듈 의존 방향 |
| 3단 폴백 | 값의 검증 가능성 |
화살표는 "A가 B를 읽는다"만 말합니다. B가 언제까지 존재하는지, A가 B를 import할 수 있는지, B의 값이 애초에 어디서 오는지, 그 값이 맞는지 확인할 수단이 있는지는 말하지 않습니다. 그래서 화이트보드에서는 넷 다 통과했습니다.
그래서 설계 → 계획 → 구현으로 내려가는 단계가 관료제가 아니라 결함 탐지 장치로 작동했습니다. 문서를 한 단계 더 쓰는 비용은 실제로 들었지만, 그 비용으로 산 것은 문서가 아니라 네 번의 조기 발견입니다. 특히 두 번째 것 — NOT NULL 4개 — 은 구현 후에 발견됐다면 개편 첫 배포에서 전건 적재 실패로 나타났을 겁니다.
한 가지 덧붙이면, 이건 "문서를 열심히 씁시다"와는 다릅니다. 네 번 다 잡아낸 것은 문서의 분량이 아니라 구체성 등급이었습니다. 정리 주기에 숫자를 적어야 했고, 태스크에 제목을 붙여야 했고, 클래스에 경로를 붙여야 했고, 검증 방법에 대조 대상을 적어야 했습니다. 숫자·이름·경로·대조 대상을 적는 칸이 있는 문서 형식이면 되고, 없으면 아무리 길게 써도 같은 것을 잡지 못합니다.
이 설계가 틀릴 수 있는 지점
1편 끝에 "이 진단이 틀릴 수 있는 지점"을 적었으니 이번에도 적습니다. 이번엔 더 필요합니다. 아직 구현 전이라 검증된 것이 하나도 없습니다.
- 아웃박스 릴레이를 단일 인스턴스 전제로 설계했습니다. 그래서 분산 락을 넣지 않았습니다. 스케줄러 모듈이 다중 인스턴스로 뜨는 순간 같은 아웃박스 행을 두 인스턴스가 집어 중복 발행이 시작됩니다. 소비 쪽 멱등이 이걸 흡수하긴 하지만, 그건 방어선이지 설계 의도가 아닙니다. 배포 형상이 바뀌면 설계도 다시 봐야 합니다.
- ③을 빼면서 생긴 수동 처리 부담을 측정하지 못했습니다. 개편 이전 거래 중 앞으로 취소가 들어올 건이 몇 건인지 세어보지 않았습니다. 많으면 이 결정은 다시 봐야 합니다.
- "현행 유지"로 적힌 나머지 필드들을 같은 눈으로 다시 보지 않았습니다. 거래일시 4개와 원거래일자에서 같은 패턴이 두 번 나왔다면, 표에 "현행 유지"라고 적힌 다른 항목도 출처 관점에서 재검토해야 맞습니다. 55개 컬럼 전부를 그렇게 훑지는 못했습니다.
- 아웃박스 정리 주기의 실제 값을 정하지 못했습니다. ②단계 폴백이 덮는 창의 폭이 곧 이 값인데, 컨슈머 지연 분포를 측정한 적이 없어 근거 있는 숫자를 못 냈습니다. 지금은 넉넉하게 잡아두고 운영 데이터로 조정하겠다는 상태입니다.
- 전용 리스너 팩터리의 재시도 횟수도 근거가 약합니다. 관측이 아니라 감입니다. DLT에 무엇이 쌓이는지 확인하지 못했다는 1편의 한계가 그대로 이어집니다.
정정 기록
이 글의 초판은 폴백을 3단으로 서술했습니다. ③단계(환율 고시 테이블에서 재현)를 옹호하는 절이 따로 있었고, "부정확한 값이 아무 값도 없는 것보다 나을 때가 있다"는 결론까지 실었습니다.
그 판단은 도메인 확인 과정에서 뒤집혔고, 최종 설계는 2단입니다. 초판을 그대로 두면 코드와 다른 설계가 공개된 채로 남으므로 본문을 고쳤습니다. 다만 뒤집힌 과정 자체를 5️⃣로 남겼습니다 — 결론만 갈아끼우면 이 글에서 가장 쓸모 있는 부분이 사라지기 때문입니다.
같은 이유로 1편에도 정정을 달았습니다. 그쪽은 성격이 더 나쁩니다. 진단 계기를 각색했고, 설계 문서만 보고 코드를 확인하지 않은 채 없는 결함을 있다고 적었습니다.
여기까지가 설계입니다. 코드는 여전히 한 줄도 고치지 않았습니다.
네 번 무너진 게 이득이었다고 쓰긴 했지만, 그건 구현 전에 무너졌기 때문에 이득이었던 것입니다. 다섯 번째가 구현 중에 나오지 않는다는 보장은 없습니다. 오히려 지금까지 나온 넷이 전부 "한 칸 더 구체적으로 적을 때" 나왔다는 것은, 코드를 적는 것이 그다음 칸이라는 뜻이기도 합니다.
구현에서 또 무너지면 3편에 씁니다.
