아키텍처 가드·이벤트·운영
목차
결제 시스템 시리즈: 아키텍처 가드·이벤트·운영. 원 연재 여러 편을 한 챕터로 묶었고, 각 절이 원래 한 편이다.
아키텍처를 CI가 지키게: 모듈 경계를 깨면 빌드가 깨진다
0. 문서로 적은 규칙은 무너진다
모듈 경계 검증을 GitHub Actions에 얹었다. 이제 허용되지 않은 모듈 의존이 섞인 PR은 머지 전에 빨간불이 뜬다. 그 배선의 기록이다.
첫 편에서 이 프로젝트를 모듈형 모놀리스로 짰다고 했다. order, payment, point, ledger처럼 모듈마다 경계가 있고, 서로 정해진 통로(공개 API·이벤트)로만 대화한다.
문제는 이 경계가 시간이 지나면 무너진다는 것. 급하게 기능을 넣다 보면 “payment에서 ledger 내부 클래스 하나만 잠깐 쓰자” 같은 유혹이 생긴다. 한 번 뚫리면 다음은 더 쉽다. 반년 뒤엔 경계가 이름만 남는다. 아키텍처 문서에 “모듈 간 직접 참조 금지”라고 적어둬도 그건 강제되지 않는 약속일 뿐이다.
1. 경계를 테스트로 검증한다
이 프로젝트는 처음부터 경계를 테스트로 박아뒀다. Spring Modulith의 ModularityTests다.
@Testvoid verifiesModularStructure() { ApplicationModules.of(PayApplication.class).verify();}이 한 줄이 전체 모듈 그래프를 훑어서, 허용되지 않은 모듈 간 의존이 있으면 실패한다. 각 모듈은 자기가 의존해도 되는 모듈을 명시적으로 선언한다.
@ApplicationModule(allowedDependencies = { "shared", "payment", "point" })package com.beomsu.pay.order;order는 shared·payment·point에만 의존할 수 있다. 누군가 order에서 ledger 내부를 참조하면 verify()가 “허용되지 않은 의존”으로 잡아낸다. 경계가 실행 가능한 규칙이 된 것이다.
2. 그런데 테스트는 안 돌리면 소용없다
여기에 구멍이 하나 있다. 테스트로 만들어놨어도 누가 그 테스트를 돌려야 의미가 있다. 로컬에서 깜빡하고 안 돌리거나 “이번엔 급하니까” 건너뛰면 경계 위반이 그대로 머지된다.
이걸 GitHub Actions에 얹었다. PR을 올리거나 main에 푸시하면 CI가 자동으로 전체 테스트를 돌리고, 그 안에 ModularityTests도 있다.
- name: Run tests run: ./gradlew clean test --console=plain흐름은 이렇게 된다.
누군가 payment에서 ledger 내부를 몰래 참조하는 PR을 올린다 → CI가
./gradlew test를 돈다 →ModularityTests가 “허용되지 않은 의존”으로 실패 → PR에 빨간불이 뜬다 → 머지 못 함.
경계를 지키는 일이 사람의 기억력이나 리뷰어의 눈썰미에서 자동화된 게이트로 옮겨갔다. 리뷰어가 놓쳐도 CI는 안 놓친다.
3. 결제 시스템이라 더 그렇다
결제 도메인에서 이게 특히 중요한 이유가 있다. 결제 시스템은 실패·정합성 처리가 핵심이고, 그 정합성은 모듈 경계가 지켜질 때 성립한다.
예를 들어 원장(ledger)은 결제 이벤트를 구독해 분개를 기록한다. payment가 ledger 내부를 직접 호출하는 지름길이 생기면 이벤트 기반의 느슨한 결합이 깨지고, 보상 트랜잭션이나 재처리 로직이 은근슬쩍 어긋나기 시작한다. 경계가 무너지면 결제와 회계가 따로 노는 최악의 상황으로 간다.
경계 검증을 CI 게이트로 두는 건 그래서 정합성의 방어선이다. 코드 스타일 차원의 일로 보면 무게를 놓친다.
4. CI 구성에서 신경 쓴 것
몇 가지 소소한 결정이 있었다.
- Docker 불필요: 테스트 스위트를 단위·슬라이스·H2(인메모리)로 짜뒀더니 CI 러너에 DB나 Testcontainers가 필요 없다.
ubuntu-latest에서 JDK 21만 있으면 결정적으로 돈다. 락 비교 테스트를 H2로 짠 게 여기서도 값을 했다. - 동시성 취소: 같은 브랜치에 새 푸시가 오면 진행 중인 실행을 취소한다(
concurrency). 러너 시간 낭비를 줄인다. - 실패 리포트 보존: 실패해도 테스트 리포트를 아티팩트로 남겨, 뭐가 깨졌는지 바로 볼 수 있게 했다.
첫 실행이 초록불이 되는 것까지 확인했다. 223개 테스트에 모듈 경계 검증까지 CI에서 통과했다.
마치며
화려한 기능은 아니다. 하지만 “아키텍처를 어떻게 유지하느냐”에 대한 답이라 개인적으로 꽤 아끼는 작업이다. 경계를 문서에 적으면 무너지고, 리뷰에 맡기면 샌다. 경계를 테스트로 만들고 그 테스트를 CI가 매번 돌리게 하면 규율이 시스템의 일부가 된다. 깨지면 빨간불이 뜬다는 것만큼 확실한 규칙은 없었다.
모놀리스 안에서만 울리던 이벤트를 밖으로: Kafka로 결제 이벤트를 외부화한 이유
0. 이벤트는 이 프로세스 안에서만 울렸다
모듈 경계를 CI가 지키게 만들고 나니, 그 경계 안에서만 울리는 이벤트의 사거리가 눈에 들어왔다. 결제 이벤트에 @Externalized를 붙여 Kafka로 흘려보내게 했고, 그 과정에서 만난 dual-write 유실 함정은 outbox로 막았다.
이 프로젝트는 처음부터 이벤트로 모듈을 이었다. 결제가 승인되면 PaymentConfirmedEvent가 울리고, 원장이 분개하고, 정산이 집계하고, 현금영수증이 발급된다. 모듈끼리 직접 호출하지 않고 이벤트로만 대화하니 결합이 느슨하고 새 소비자를 붙이기도 쉬웠다.
그런데 이 이벤트엔 한계가 하나 있었다. 이 프로세스 안에서만 울린다.
Spring Modulith의 @ApplicationModuleListener는 이벤트를 event_publication 테이블(outbox)에 기록하고, 같은 JVM 안의 리스너에게 비동기로 전달한다. 잘 동작하지만 JVM 경계를 못 넘는다. 이런 소비자가 생긴다면 어떨까.
- 결제 데이터를 실시간으로 빨아들이는 분석/데이터웨어하우스가 생기면?
- FDS(이상거래탐지)를 별도 서비스로 떼어내면?
- 다른 팀이 결제 완료를 구독해 자기 워크플로를 돌리고 싶으면?
이 소비자들은 우리 event_publication 테이블을 들여다볼 수 없다. 모놀리스 안에 갇힌 이벤트다.
1. 선택지: 이벤트 모델을 갈아엎을 것인가
가장 안이한 답은 “필요해지면 그때 REST API를 뚫어주자”다. 하지만 그건 이벤트 기반 설계를 버리고 폴링·동기 호출로 돌아가는 길이다. 이미 잘 만든 이벤트 모델이 있는데.
더 나은 방향은 지금 있는 이벤트를 그대로 두고 프로세스 밖으로도 흘려보내는 것. 이벤트를 발행하는 payment 모듈의 코드는 한 줄도 안 바꾸고, “이 이벤트는 밖에서도 필요하다”고 선언만 한다. 그러면 나중에 서비스를 분리해도 이벤트 모델을 다시 짤 필요가 없다.
Spring Modulith가 이걸 위한 도구를 준다. @Externalized다.
2. @Externalized: 애노테이션 하나로 밖에 싣기
이벤트 타입에 애노테이션 하나만 붙이면 된다.
@Externalized("payment.confirmed::#{orderNo}")public record PaymentConfirmedEvent(String orderNo, Long paymentId, long amount, Instant approvedAt) {}“이 이벤트가 발행되면 payment.confirmed 토픽으로, orderNo를 메시지 키로 Kafka에 실어라”는 뜻이다. PaymentCanceledEvent도 payment.canceled 토픽으로 똑같이 붙였다.
::#{orderNo} 부분, 메시지 키를 orderNo로 잡은 게 중요하다.
Kafka는 파티션 안에서만 순서를 보장한다. 같은 키는 항상 같은 파티션으로 가니, orderNo를 키로 쓰면 같은 주문의 이벤트(confirmed → canceled)가 순서 역전 없이 도착한다. 필요한 건 “한 주문 안에서의 순서”뿐이라 이걸로 충분하다. 전역 순서까지는 필요 없다. 서로 다른 주문은 병렬로 흘려도 되니 처리량도 산다.
3. 진짜 함정: dual-write 유실
여기서 조심할 게 있다. “이벤트를 DB에도 저장하고 Kafka에도 발행”하는 건 전형적인 dual-write 문제다.
DB 저장은 성공했는데 Kafka 발행이 실패하면? 또는 그 반대라면? 두 시스템에 각각 쓰는 순간 “하나는 됐고 하나는 안 된” 불일치가 생긴다. 결제 도메인에서 이건 “원장엔 있는데 분석엔 없는” 데이터 유실로 이어진다.
Modulith 외부화의 미덕은 이걸 outbox로 봉합한다는 점이다.
결제 승인 트랜잭션 ├─ payments 테이블 저장 └─ event_publication(outbox)에 이벤트 기록 ← 같은 로컬 트랜잭션! ─────────── 커밋 ─────────── (커밋 후) 외부화 리스너가 Kafka로 발행 ├─ 성공 → outbox에서 완료 마킹 └─ 실패/브로커 다운 → 미완료로 남음 → 재기동 시 재발행핵심은 이벤트 기록이 결제 저장과 같은 트랜잭션이라는 것. 결제가 커밋됐으면 이벤트도 반드시 outbox에 있다. Kafka 발행은 그 뒤에 일어나고, 실패하면 outbox에 남아 재시도된다. “결제는 됐는데 이벤트는 증발”하는 일이 구조적으로 안 생긴다. 대신 재발행 때문에 같은 이벤트가 두 번 갈 수 있으니(at-least-once), 프로세스 밖 소비자도 인프로세스 소비자처럼 멱등하게 짜야 한다.
producer 쪽도 acks=all에 멱등 producer로 뒀다. 브로커가 확실히 받았을 때만 성공으로 치고, 재시도 중복을 억제한다.
4. 브로커가 없어도 앱은 떠야 한다
운영 현실도 하나 챙겼다. 로컬 개발이나 테스트에선 Kafka를 안 띄우는 경우가 많다. 그런데 @Externalized가 걸린 이벤트를 발행할 때마다 브로커가 없다고 부팅이나 결제가 깨지면 곤란하다.
외부화를 프로퍼티로 게이트한 이유다. 기본은 꺼두고(externalization.enabled: false), Kafka가 있는 환경에서만 kafka 프로파일로 켠다.
- 기본(off):
@Externalized애노테이션은 그대로 있지만 Kafka로는 아무것도 안 나간다. outbox 기반 인프로세스 소비는 100% 그대로 동작하고, 브로커 없이도 부팅·테스트가 다 통과한다. - kafka 프로파일(on): 외부화 리스너가 활성화돼 실제로 Kafka에 싣는다.
기능을 넣되 기존 동작은 하나도 안 건드렸다. 개선이 회귀를 만들면 안 되니까.
마치며
당장 필요한 기능이라기보다 미래를 위한 여지다. 지금은 모놀리스 하나지만, 이벤트를 프로세스 밖으로 흘릴 수 있게 해두면 나중에 분석 파이프라인을 붙이든 FDS를 서비스로 떼어내든 이벤트 모델을 다시 짜지 않고 소비자만 늘리면 된다. 이벤트를 밖으로 내보내는 일에서 정작 어려웠던 건 보내는 방법보다 안 잃는 방법이었고, 그 답이 outbox였다.
자동 복구가 포기한 순간, 운영은 무엇을 할 수 있나: 손댈 수 있는 어드민 만들기
0. 자동으로 다 되는데, 안 되면?
경계는 CI가 지키고 이벤트는 밖으로 흐른다. 그런데 자동화가 포기하는 순간은 반드시 온다. 그 순간 사람이 집을 손잡이로 보상 재처리·미확정 복구·정산 불일치 조회, 세 개의 운영 어드민을 만들었다.
지금까지 실패 처리를 꽤 공들여 자동화했다.
다 자동 루프다. 그런데 어느 순간 깨달았다. 이 자동이 실패하면, 운영은 뭘 할 수 있지?
보상 태스크는 지수 백오프로 재시도하다 5번을 소진하면 FAILED로 남는다. 그게 “사람이 개입하라”는 신호인데, 정작 그 FAILED 태스크를 볼 방법도 다시 시도할 방법도 없었다. 복구 배치는 만들어놨지만 호출할 엔드포인트도 스케줄러도 없어 사실상 죽은 코드였다. 정산 불일치는 DB를 직접 쿼리하지 않는 한 확인조차 안 됐다.
자동화의 99%는 만들었는데, 나머지 1%(자동이 포기한 지점)를 사람이 손댈 손잡이가 없었던 것이다.
1. “자동이 실패한다”를 전제로 설계하기
결제 시스템의 철학은 처음부터 “장애는 실제로 일어난다”였다. 같은 철학을 자동 복구에도 적용해야 했다. 자동 복구도 실패한다. PG가 오래 죽어 있으면 망취소 재시도가 다 소진되고, 복구 배치도 PG 조회가 안 되면 확정하지 못한다.
방향은 명확했다. 각 자동 루프마다 “관측 + 수동 개입” 어드민을 붙인다. 이미 DLQ 재처리 어드민을 만들어둔 패턴이 있으니 같은 결로 확장했다.
만든 건 세 가지다. 데모 콘솔의 운영 콘솔 탭이 이 손잡이들을 한데 모아둔 모습이다.

2. 보상 태스크: 소진된 걸 다시 무장하기
제일 중요한 건 보상 태스크 재처리였다.
GET /api/v1/admin/compensations?status=FAILED → 소진된 태스크 목록POST /api/v1/admin/compensations/{id}/retry → 재시도핵심은 retry가 하는 일이다. FAILED 태스크는 재시도 예산(5회)을 다 쓴 상태라, 스케줄러가 다시 집어도 소용없다. 그래서 도메인 메서드 reopen()을 만들었다.
/** 소진(FAILED)된 태스크를 근본 원인 수정 후 재무장 — 재시도 예산을 리셋한다. */public void reopen() { this.status = CompensationStatus.PENDING; this.retryCount = 0; // 새 예산으로 다시 this.nextAttemptAt = Instant.now();}시나리오는 이렇다. PG가 두 시간 죽어서 어떤 망취소가 재시도를 다 소진하고 FAILED가 됐다. PG가 복구되면 운영자가 어드민에서 그 태스크를 retry한다. reopen()이 재시도 예산을 리셋하고 즉시 한 번 시도한다. 이번엔 PG가 살아 있으니 성공한다.
여기서 지난 편의 트랜잭션 함정이 또 나온다. 재시도 실행은 태스크별 트랜잭션에 위임하고, 실패는 별도 트랜잭션으로 기록해야 한다. 그래서 어드민 서비스엔 @Transactional을 안 걸고, reopen() 저장 → executor.attempt()(자기 트랜잭션) → 실패 시 executor.recordFailure()(별도 트랜잭션) 순으로 위임했다. 한 건의 롤백이 어드민 호출 전체를 오염시키지 않게 하기 위해서다.
3. 복구·정산: 죽어있던 손잡이를 켜기
나머지 둘은 더 단순하다. 이미 있는 로직을 부를 수 있게 했다.
POST /api/v1/admin/payments/recover → UNKNOWN 결제 복구 온디맨드 실행GET /api/v1/admin/payments/unknown → 미확정 결제 목록GET /api/v1/admin/reconciliations/mismatches → 사람 확인 필요한 불일치복구 배치는 로직(recoverUnknownPayments())은 있었지만 트리거가 없었다. “미확정 결제가 쌓인 것 같다” 싶을 때 운영자가 즉시 돌릴 수 있게 POST를 열었다. 정산 불일치는 ReconStatus.PENDING(자동 종결 못 하고 사람 확인이 필요한 건)만 골라 보여준다. 금액이 안 맞거나 한쪽에만 있는 기록이다.
4. 엔티티를 그대로 던지지 않기
작은 결정 하나. 어드민 응답으로 JPA 엔티티를 그대로 직렬화하고 싶은 유혹이 있는데, 참았다. 대신 뷰 record를 따로 뒀다.
record CompensationTaskView(Long id, String orderNo, long amount, CompensationStatus status, int retryCount, String lastError, Instant nextAttemptAt) {}이유는 두 가지다.
(1) 유출 방지: 엔티티를 직렬화하면 지연로딩 컬렉션(예: 결제 이력)이 딸려 나오거나, 내부 상태머신 필드·낙관적 락
version같은, 운영 표면에 불필요한 것까지 새어나간다.
(2) 계약 분리: API 응답 스펙을 엔티티 스키마 변화로부터 떼어놓는다. 엔티티에 컬럼을 하나 추가해도 어드민 API 계약은 안 흔들린다.
어드민이라고 대충 엔티티를 던지면, 나중에 그게 외부 계약이 돼서 스키마를 못 바꾸게 된다.
5. 모듈 경계는 여기서도 지킨다
세 어드민을 각각 자기 모듈 안에 뒀다. 보상 어드민은 order, 복구 어드민은 payment, 정산 어드민은 reconciliation. 각자 자기 모듈의 package-private 리포지토리·서비스에만 접근하니 모듈 경계를 넘지 않는다. “어드민”이라는 이유로 아무 모듈이나 들쑤시는 God 컨트롤러를 만들지 않은 것이다. ModularityTests도 그대로 통과한다.
인가는 SecurityConfig가 /api/v1/admin/**에 ROLE_ADMIN을 요구해 한 곳에서 강제하고, 상태를 바꾸는 재처리는 호출자(principal)를 감사 로그로 남긴다.
마치며
새 도메인 기능은 아니지만, 실서비스에선 기능만큼 중요한 운영 가능성(operability)을 채운 편이다. 자동화에 100%는 없다. 남은 1%는 사람이 손대야 하고, 그때 손댈 손잡이가 있느냐 없느냐가 장애 대응 시간을 가른다. 이제 “자동으로 안 되면 어떡하지?”라는 질문에 FAILED 목록을 열어 retry를 누른다고 답할 수 있다.
장애가 나도 괜찮다”는 말을 증명하기: 서킷브레이커 단위 테스트와 Toxiproxy 카오스
0. 복원력을 “만들기”만 했다
운영 손잡이까지 달았지만, 정작 그 앞단의 안전장치들이 실제 장애에서 동작한다는 증거가 없었다. 그래서 장애를 직접 일으켰다. 서킷브레이커는 결정적인 단위 테스트로 못박고, 네트워크 장애는 Toxiproxy로 실제로 끊어서 확인했다.
지금까지 만든 복원력 장치는 꽤 많다.
- 서킷브레이커로 PG 장애가 전체로 번지는 걸 막고,
- 승인 타임아웃을 UNKNOWN(미확정)으로 보존하고(성급히 실패로 단정하지 않는다),
- 멱등키로 재시도가 이중결제로 이어지지 않게 했다.
그런데 문득 이상했다. 이것들이 진짜 장애가 났을 때 동작한다는 걸 어떻게 알지? 전부 “이렇게 동작하도록 코드를 짰다”는 주장이었지, 장애를 실제로 일으켜서 확인한 적은 없었다.
결제 시스템에서 이건 특히 위험하다. 복원력 코드는 평소엔 안 돈다. 장애가 나야 발동한다. 그 코드에 버그가 있으면 가장 필요한 순간에 안전장치가 없는 셈이다. 그래서 복원력은 장애를 주입해서 검증해야 한다. 두 층위로 나눠 증명했다.
1. 층위 ①: 서킷브레이커를 결정적으로 못박기
첫 번째는 서킷브레이커 자체의 동작이다. 컨테이너나 네트워크 없이, 결정적인 단위 테스트로 잡을 수 있다.
ResilientPgClient는 PG 호출을 서킷브레이커·재시도로 감싼다. 여기에 장애를 주입하는 가짜 PgClient를 넣고 감싸면, 실제 PG 없이 데코레이터 동작만 검증할 수 있다.
검증한 세 가지가 이 시스템 복원력의 핵심 규칙이다.
(1) 승인은 재시도하지 않는다.
// faulty delegate가 예외를 던져도 —approve() → delegate 호출 횟수 == 1, 결과 == TIMEOUT(UNKNOWN)결제에서 제일 중요한 규칙이다. 멱등키 없이 승인을 재시도하면 이중결제가 난다. 그래서 승인은 실패해도 재시도하지 않고, 대신 UNKNOWN으로 돌려 복구 배치가 나중에 조회로 확정하게 한다. “실패로 단정하지 않는다”는 3-상태 모델을, delegate를 1번만 부르는 걸로 못박았다.
(2) 서킷이 열리면 아예 부르지 않는다.
반복 실패로 서킷이 OPEN 되면, 이후 승인은 delegate를 호출조차 안 하고 즉시 UNKNOWN으로 폴백한다. 테스트에선 서킷을 연 뒤 몇 번 더 호출해도 delegate 호출 횟수가 늘지 않는지 확인했다. “PG 장애가 우리 스레드를 고갈시키지 않는다”의 증거다. 죽은 PG를 계속 기다리지 않고 빠르게 포기하니까.
(3) 조회는 재시도하되, 무한은 아니다.
조회(query)는 읽기라 재시도가 안전하다. 몇 번 실패 후 성공하면 재시도로 흡수한다. 단, maxAttempts(3)에서 멈추고 예외를 전파한다. 무한 재시도로 장애를 키우지 않고, 다음 배치 주기에 다시 잡는다.
이 단위 테스트들은 컨테이너가 없어서 CI 기본 스위트에서 매번 결정적으로 돈다. 서킷브레이커 동작이 회귀하면 즉시 빨간불이 뜬다.
2. 층위 ②: Toxiproxy로 진짜 네트워크를 끊기
단위 테스트는 서킷브레이커 “로직”을 검증하지만, 진짜 네트워크 장애는 재현하지 못한다. 커넥션이 5초씩 늘어지거나, 쿼리 중간에 끊기거나, 커넥션 풀이 마르는 상황은 실제 TCP를 방해해야 나온다.
그래서 Toxiproxy를 썼다. 앱과 MySQL 사이에 프록시를 끼워, 그 구간에 장애(toxic)를 주입하는 도구다.
앱 ──> [Toxiproxy] ──> MySQL ↑ 여기에 latency 5s / 커넥션 cut 주입테스트 시나리오는 이렇다.
- 정상 상태에서 체크아웃(주문 생성 → 승인) 1건을 성공시킨다.
- Toxiproxy에
latency(UPSTREAM, 5초)toxic을 주입한다. DB 응답이 5초씩 늘어지게. - 장애 중에 체크아웃을 시도한다. 이때 검증하는 건 “무한 hang 없이 깨끗한 타임아웃으로 끝나는가”다.
- toxic을 제거하고, 같은 Idempotency-Key로 재시도한다. 이중 결제가 없는지 확인한다.
여기서 핵심: 짧은 타임아웃
3번이 중요하다. JDBC socketTimeout과 Hikari connectionTimeout을 짧게(2~3초) 둬야 한다.
타임아웃이 무한이면, DB가 느려질 때 요청 스레드가 영원히 매달린다. 그럼 스레드 풀이 마르고, 장애가 결제 지연을 넘어 서비스 전체 마비로 번진다. 짧은 타임아웃은 “느린 건 차라리 빨리 실패시킨다”는 원칙이다. Toxiproxy로 latency를 주입했을 때, 요청이 5초 내내 매달리지 않고 3초에 타임아웃 예외(5xx)로 깔끔하게 끝나고 부분 커밋도 없이 트랜잭션이 롤백되는 걸 확인했다.
그리고: 멱등이 장애를 건너 생존하는가
4번은 멱등성이 장애를 관통해서 유지되는지를 본다. 장애로 한 번 실패한 요청을 같은 멱등키로 재시도했을 때, 첫 응답이 그대로 재반환되고 이중 결제·이중 차감이 없어야 한다. “네트워크가 끊겼다 붙어도 결제는 정확히 한 번”이라는 걸 실제로 끊어보고 확인했다.
3. 왜 기본 스위트에서 뺐나
Toxiproxy 테스트는 컨테이너 2개(MySQL + Toxiproxy)에 앱 부팅까지 필요해서 무겁다. 그래서 @Tag("chaos")를 붙여 기본 테스트에서 제외했다.
./gradlew test # 기본: 카오스 제외 — 빠르고 결정적, 컨테이너 안 뜸./gradlew chaosTest # 카오스만: 컨테이너 2개 + 부트, 전용 환경에서CI가 매 PR마다 도는 기본 스위트는 결정성과 속도가 생명이다. 컨테이너에 의존하는 무거운 테스트가 끼면 환경에 따라 불안정해지고 느려진다. 그래서 결정적인 것(서킷브레이커 단위)은 항상 돌리고, 무거운 것(Toxiproxy)은 네트워크가 준비된 전용 환경에서만 돌리게 나눴다.
이 분리 자체가 하나의 판단이다. 모든 테스트를 항상 돌리는 대신, 테스트의 성격에 따라 언제 돌릴지를 정했다.
마치며
복원력을 주장에서 검증으로 옮긴 편이다. 서킷브레이커도 멱등도, 만들어놓고 “잘 되겠지” 하면 그건 희망에 그친다. 보장은 확인한 다음에야 생긴다. delegate를 몇 번 부르는지 세어보고 네트워크를 끊어본 뒤에야 “장애가 나도 이중결제는 안 난다”고 말할 수 있었다. 카오스 엔지니어링이라는 이름이 거창하지, 한 일은 안전장치가 동작하는지 장애를 일으켜 확인한 것뿐이다.
돈을 바로 주지 않는다: 구매확정 전까지 판매자 정산을 보류하는 에스크로
0. 승인되면 곧장 판매자 돈이 된다
장애 검증까지 마치고 마지막으로 손댄 건 돈이 넘어가는 타이밍이다. escrow 모듈을 새로 붙여, 판매자 정산이 가능해지는 시점을 결제 승인 즉시에서 구매자가 구매확정한 뒤로 미뤘다.
지금까지의 흐름을 보면, 결제가 승인되는 순간 그 돈은 정산 대상으로 쌓였다. 하루치 배치가 집계해서 판매자에게 지급될 돈으로.
단일 판매자(자사몰)라면 문제없다. 그런데 마켓플레이스(제3자 판매자가 입점)를 생각하면 위험이 보인다.
결제 승인 = 판매자에게 정산이라면, 구매자가 물건을 받기도 전에 판매자에게 돈이 넘어간다. 판매자가 배송을 안 하거나, 물건이 하자거나, 사기라면? 이미 정산된 돈을 회수하기는 매우 어렵다. 판매자가 출금해버리면 끝이다.
실서비스 마켓플레이스가 에스크로(escrow, 자금 보류)를 쓰는 이유가 이거다. “구매확정” 버튼을 누르기 전까지 결제금은 중립 지대에 묶인다. 판매자도 아직 못 받고, 구매자는 문제가 있으면 돌려받을 수 있다.
1. 생명주기: HELD → RELEASED / REFUNDED
새로 붙인 escrow 모듈에서 홀드(hold)의 상태는 셋이다.
결제 승인 → HELD (자금 보류 — 판매자 미정산)구매확정 → RELEASED (판매자에게 정산 가능)전액취소 → REFUNDED (구매자에게 환불, 판매자 미정산)기존 모듈을 하나도 안 건드리고 붙일 수 있었다. 이벤트로 이어져 있으니까.
PaymentConfirmedEvent를 구독 → 결제금을 HELD로 보류 생성.PaymentCanceledEvent(전액)를 구독 → 아직 보류 중이면 REFUNDED.- 구매확정 API 호출 → RELEASED +
EscrowReleasedEvent발행(정산 파이프라인이 이걸 구독하면 그때 판매자에게 지급).
에스크로는 결제·정산 모듈이 자기를 구독하는지도 모른다. 결제 이벤트를 듣고 홀드를 만들 뿐이다.
데모 콘솔에서 승인까지 마치면, 주문 PAID와 결제 DONE과 함께 에스크로가 HELD로 잡히는 걸 볼 수 있다(오른쪽 상태 카드).

2. 판단 ①: 구매확정은 누가 할 수 있나, 경계 설계
구매확정은 돈을 판매자에게 풀어주는(RELEASED) 행위다. 아무나 하면 안 되고, 그 주문의 구매자 본인만 할 수 있어야 한다(IDOR 방지).
문제는 소유권 검증을 주문(order) 모듈이 한다는 점이다(userId ↔ 주문 매핑을 order가 소유). 그런데 릴리스는 에스크로 모듈의 일이다. 에스크로가 소유권까지 신경 쓰면 order에 의존해야 하고, 경계가 지저분해진다.
그래서 이렇게 나눴다.
구매확정 진입점(
PurchaseConfirmationService)을 order 모듈에 뒀다. order가 주문을 로드하고 →verifyOwner로 소유권을 확인하고 → PAID 상태인지 보고 → 그다음에escrowService.release(orderNo)를 호출한다. 에스크로는 “이미 검증된 orderNo”만 받아서 상태만 전이하니, 소유권(IDOR)을 전혀 신경 쓰지 않는다.
의존 방향은 order → escrow 단방향이다. 에스크로는 order를 모르니 순환도 없다. “누가 무엇을 책임지는가”를 모듈 경계로 그은 것이다. order는 사용자·소유권, escrow는 자금 보류 상태를 맡는다.
3. 판단 ②: 부분취소는 홀드를 어떻게 하나
이번에 제일 고민한 지점이다. 전액취소는 명확하다. 홀드를 통째로 REFUNDED로 환불하면 된다. 그런데 부분취소는?
PaymentCanceledEvent에는 fullyCanceled 플래그가 있다. 처음엔 이걸 무시하고 “취소 이벤트 오면 무조건 환불”로 짰는데, 실기동에서 버그가 드러났다. 1만 원 중 3천 원만 부분취소했는데 에스크로 전체(1만 원)가 REFUNDED로 풀려버린 것이다.
틀렸다. 에스크로 홀드는 주문 단위의 all-or-nothing 보류다. 3천 원만 돌려줬으면 나머지 7천 원은 여전히 판매자에게 갈 돈으로 HELD 상태를 유지해야 한다. 부분 금액 환불이 홀드 전체 회수로 이어지면 안 된다.
리스너에 가드를 넣었다.
@ApplicationModuleListenervoid onCanceled(PaymentCanceledEvent event) { // 전액 취소만 홀드를 환불한다. 부분취소는 잔여 결제가 살아 있으므로 홀드를 유지한다. if (event.fullyCanceled()) { escrowService.refundIfHeld(event.orderNo()); }}실기동으로 세 시나리오를 다시 확인했다.
전액취소(1만) → 주문 CANCELED, 결제 CANCELED, 에스크로 REFUNDED부분취소(3천) → 주문 PAID, 결제 PARTIAL_CANCELED, 에스크로 HELD 유지 ← 고쳐짐구매확정 → 주문 PAID, 결제 DONE, 에스크로 RELEASED이벤트가 오면 어떤 종류의 이벤트인지 먼저 본다. 무조건 처리부터 하고 봤다가 이 버그를 만났다. 이 구분이 도메인 정확성을 갈랐다. 부분취소에 담긴 fullyCanceled=false를 존중하는 게 핵심이었다.
4. 판단 ③: 구매자가 확정을 안 하면
현실에서 구매자는 “구매확정” 버튼을 잘 안 누른다. 물건을 받고도 그냥 둔다. 그럼 판매자 돈이 영원히 묶일까?
실서비스는 자동 구매확정을 둔다. 배송완료 후 N일이 지나면 자동으로 RELEASED. 그래서 홀드에 autoReleaseAt(예: 승인 + 7일)을 두고, 스케줄러가 기한이 지난 HELD 홀드를 자동 릴리스하게 했다.
public int autoReleaseDue() { // autoReleaseAt이 지난 HELD 홀드를 릴리스. 한 건 실패가 배치를 안 멈추게 per-item 처리. ...}스케줄러는 보상 태스크와 같은 방식으로 프로퍼티 게이트(app.escrow.auto-release.enabled)로 켜고 끈다. 기본은 꺼둬서 테스트·로컬에 부작용이 없다.
5. 이걸 붙이다 큰 버그를 하나 잡았다
에스크로를 붙이고 실기동으로 검증하다가, 원래 있던 치명적인 버그를 발견했다. 구매확정이 자꾸 “결제 완료 주문만 확정할 수 있습니다”(409)로 막히길래 DB를 봤더니, 결제 승인 응답은 PAID인데 DB에는 주문이 PENDING_PAYMENT로 남아 있었다. 결제가 실제로는 DB에 확정되지 않고 있었던 것이다.
에스크로와 별개인, 훨씬 근본적인 문제였다. 그 이야기는 다음 편에서 따로 다룬다. 하나만 미리 적자면, 기능을 끝까지 눌러보는 E2E 실기동이 단위 테스트가 못 잡는 버그를 드러낸다. 에스크로가 “주문이 PAID여야 한다”는 조건을 실제로 요구했기 때문에, 그동안 숨어 있던 버그가 튀어나왔다.
마치며
에스크로는 돈을 언제 최종적으로 넘길 것인가를 다루는 기능이다. 승인은 “돈을 받았다”까지만 뜻한다. “판매자에게 줘도 된다”는 별개의 판단이고, 그 사이에 구매확정이라는 관문을 두는 게 마켓플레이스 신뢰의 핵심이다.
수확은 둘이다. 경계를 잘 그으니(소유권은 order, 보류는 escrow) 새 기능이 기존을 안 건드리고 얹혔고, 이벤트의 플래그 하나(fullyCanceled)를 무시했다가 부분취소에 홀드 전체가 풀리는 버그를 만나고서 그 플래그의 무게를 알았다.
전체 코드는 Spring Modulith 기반 결제 시스템에 있다. CI가 매 PR·푸시마다 전체 테스트와 모듈 경계를 검증하고, 에스크로의 HELD→RELEASED/REFUNDED 전 흐름은 실 MySQL로 검증했다.
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.