이번 주는 외부 결제(PG) 연동 안정성을 주제로,
- Resilience4j로 장애를 제어 가능한 범위로 줄이고,
- 보상 트랜잭션으로 외부 자원과 로컬 DB의 정합성을 맞추며,
- Spring Events로 도메인 사건을 분리하는 방식을 학습했다.
1. Resilience4j — 회복탄력성을 위한 첫 번째 생명선
Resilience4j란 무엇인가?
- 자바 기반 회복탄력성(Resilience) 라이브러리
- 외부 시스템 호출 실패나 지연을 서비스 전반으로 확산되지 않도록 제어하는 다양한 패턴 제공
왜 필요한가?
- 네트워크·외부 API는 언제든 장애가 발생할 수 있다.
- 단순 try-catch는 개별 호출만 잡아줄 뿐, 서비스 전체 안정성은 지켜주지 못한다.
- 핵심은 “장애를 없애는 게 아니라, 장애를 제어 가능한 형태로 축소하는 것”.
주요 동작 패턴
- TimeLimiter: 일정 시간 이상 응답 없으면 조기 종료 → 무한 대기 차단
- Retry: 일시적 장애라면 제한된 횟수/간격으로 재시도
- Circuit Breaker: 연속 실패율이 일정 기준을 넘으면 회로를 열어 추가 호출 차단 → Half-Open을 거쳐 복구
- Bulkhead: 호출 자원을 격리(스레드풀 분리)해, 특정 자원 고갈이 전체로 전파되지 않도록
내부적으로 어떻게 동작하나?
- Resilience4j는 함수형 데코레이터처럼 동작한다.
- 실제 호출을 감싼 래퍼(wrapper)에서 호출 횟수·실패율·지연 시간 등을 집계하고, 상태 머신(CLOSED ↔ OPEN ↔ HALF_OPEN)으로 제어한다.
- 따라서 서비스 로직을 크게 바꾸지 않고, 어댑터 레벨에서 장애 제어 로직을 쉽게 추가할 수 있다.
2. 보상 트랜잭션 (Compensating Transaction)
보상 트랜잭션이란?
- DB 트랜잭션은 ACID 보장으로 Commit/Rollback이 가능하지만,
외부 자원(PG, 포인트, 쿠폰 등)은 같은 트랜잭션 경계에 묶을 수 없다. - 따라서 실패 시 **“반대 방향으로 되돌리는 트랜잭션”**을 별도로 실행해 정합성을 맞춘다.
왜 필요한가?
- 예: PG 결제 실패 → 이미 차감된 포인트/쿠폰을 돌려줘야 함.
- 하나의 DB 트랜잭션으로 묶을 수 없으므로, 후속 보상 작업이 필수.
동작 원리
- 메인 트랜잭션에서 결제 요청
- 실패 시 “보상 이벤트” 발행
- 이벤트 핸들러에서 REQUIRES_NEW 트랜잭션으로 보상 실행
중요한 원칙
- 멱등성(Idempotency): 보상 로직은 여러 번 실행돼도 결과가 변하지 않아야 함.
- 독립성: 보상 트랜잭션은 메인 트랜잭션과 분리 → 메인 롤백이 보상까지 함께 롤백시키면 안 됨.
3. Spring Events — 도메인 사건을 분리하는 도구
Spring Events란 무엇인가?
- Spring이 제공하는 애플리케이션 내부 이벤트 발행/구독 시스템
- 내부적으로는 Observer 패턴으로 구현되어 있다.
- 발행자(Publisher)는 ApplicationEventPublisher로 이벤트를 발행하고,
리스너(Subscriber)는 @EventListener나 @TransactionalEventListener로 이벤트를 구독한다.
왜 필요한가?
- 관심사 분리:
“결제 실패”라는 사건만 발행 → 주문 상태 변경, 쿠폰 복원, 포인트 환불은 각각 리스너에서 처리. - 트랜잭션 안전성:
AFTER_COMMIT 단계에서 실행하도록 지정하면, 메인 트랜잭션이 성공적으로 커밋된 뒤에만 후처리 실행. - 유연성:
동기/비동기 실행 선택 가능.
내부 동작 원리
- 발행자가 이벤트 객체를 Publisher에 전달 → Spring ApplicationEventMulticaster가 리스너 목록을 탐색해 호출.
- 리스너는 Observer처럼 등록돼 있으며, 이벤트 타입 매칭 시 콜백 실행.
- @TransactionalEventListener는 트랜잭션의 특정 Phase(AFTER_COMMIT, AFTER_ROLLBACK 등)에 맞춰 실행된다.
장점
- 결합도 낮춤 (Publisher는 Subscriber를 몰라도 됨)
- 도메인 사건 중심 코드 작성 가능 → 가독성과 유지보수성 향상
- 트랜잭션 경계 맞춤 제어 가능
단점
- JVM 내부 범위 한정 → 분산 환경에선 브로커(Kafka, RabbitMQ) 필요
- 기본은 동기 호출 → 리스너 처리 지연이 발행자까지 영향을 줄 수 있음
- 에러 전파 불투명 → 리스너에서 발생한 예외 관리가 까다로움
- 실행 순서 보장 없음 → 여러 리스너 순서 의존성이 있으면 직접 제어 필요(@Order)
- 운영 모니터링 취약 → DLQ, 재처리 메커니즘 없음. 이벤트 사라지면 추적 어려움
4. 이번 주 핵심 배움
- Resilience4j: 장애를 완전히 없앨 수 없으므로, 제어 가능한 장애로 바꿔야 한다.
- 보상 트랜잭션: 외부 자원과 로컬 DB의 정합성을 맞추는 핵심 전략 → 멱등성과 독립성 보장이 필수.
- Spring Events: 도메인 사건을 드러내고 관심사를 분리하는 강력한 도구지만, JVM 내부 한계와 운영성 부족이라는 단점도 있다.
5. To-Study — 다음 단계
- Outbox / Inbox 패턴: DB Commit과 이벤트 발행 원자성 보장
- DLQ (Dead Letter Queue): 이벤트 소비 실패 메시지 격리 및 재처리
- 메시지 브로커 기반 이벤트(Kafka/RabbitMQ): Spring Events의 JVM 한계를 넘어 분산 환경 대응
- 이벤트 멱등성 / Exactly-Once 보장: 메시지 중복 소비/재처리 대응
'Loopers' 카테고리의 다른 글
"같은 주문이 두 번 결제됐습니다" 💸 - Kafka로 배운 분산 시스템의 잔혹한 현실 (1) | 2025.09.05 |
---|---|
🚨 “그냥 @EventListener면 끝?” — 이벤트, 언제·왜·어떻게 사용할 것인가 ⚙️ (3) | 2025.08.29 |
PG가 터져도 우리 서비스는 멀쩡해야 한다 🔥 (3) | 2025.08.22 |
인덱스를 걸었는데… 더 느려졌다? 🤯 (4) | 2025.08.15 |
락은 왜 느리고, MVCC는 무엇을 바꾸었나 (8) | 2025.08.10 |