Loopers

Resilience와 보상 트랜잭션: 장애에 대응하는 방법

그zi운아이 2025. 8. 24. 11:48

이번 주는 외부 결제(PG) 연동 안정성을 주제로,

  1. Resilience4j로 장애를 제어 가능한 범위로 줄이고,
  2. 보상 트랜잭션으로 외부 자원과 로컬 DB의 정합성을 맞추며,
  3. Spring Events로 도메인 사건을 분리하는 방식을 학습했다.

1. Resilience4j — 회복탄력성을 위한 첫 번째 생명선

Resilience4j란 무엇인가?

  • 자바 기반 회복탄력성(Resilience) 라이브러리
  • 외부 시스템 호출 실패나 지연을 서비스 전반으로 확산되지 않도록 제어하는 다양한 패턴 제공

왜 필요한가?

  • 네트워크·외부 API는 언제든 장애가 발생할 수 있다.
  • 단순 try-catch는 개별 호출만 잡아줄 뿐, 서비스 전체 안정성은 지켜주지 못한다.
  • 핵심은 “장애를 없애는 게 아니라, 장애를 제어 가능한 형태로 축소하는 것”.

주요 동작 패턴

  1. TimeLimiter: 일정 시간 이상 응답 없으면 조기 종료 → 무한 대기 차단
  2. Retry: 일시적 장애라면 제한된 횟수/간격으로 재시도
  3. Circuit Breaker: 연속 실패율이 일정 기준을 넘으면 회로를 열어 추가 호출 차단 → Half-Open을 거쳐 복구
  4. Bulkhead: 호출 자원을 격리(스레드풀 분리)해, 특정 자원 고갈이 전체로 전파되지 않도록

내부적으로 어떻게 동작하나?

  • Resilience4j는 함수형 데코레이터처럼 동작한다.
  • 실제 호출을 감싼 래퍼(wrapper)에서 호출 횟수·실패율·지연 시간 등을 집계하고, 상태 머신(CLOSED ↔ OPEN ↔ HALF_OPEN)으로 제어한다.
  • 따라서 서비스 로직을 크게 바꾸지 않고, 어댑터 레벨에서 장애 제어 로직을 쉽게 추가할 수 있다.

2. 보상 트랜잭션 (Compensating Transaction)

보상 트랜잭션이란?

  • DB 트랜잭션은 ACID 보장으로 Commit/Rollback이 가능하지만,
    외부 자원(PG, 포인트, 쿠폰 등)은 같은 트랜잭션 경계에 묶을 수 없다.
  • 따라서 실패 시 **“반대 방향으로 되돌리는 트랜잭션”**을 별도로 실행해 정합성을 맞춘다.

왜 필요한가?

  • 예: PG 결제 실패 → 이미 차감된 포인트/쿠폰을 돌려줘야 함.
  • 하나의 DB 트랜잭션으로 묶을 수 없으므로, 후속 보상 작업이 필수.

동작 원리

  1. 메인 트랜잭션에서 결제 요청
  2. 실패 시 “보상 이벤트” 발행
  3. 이벤트 핸들러에서 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를 몰라도 됨)
  • 도메인 사건 중심 코드 작성 가능 → 가독성과 유지보수성 향상
  • 트랜잭션 경계 맞춤 제어 가능

단점

  1. JVM 내부 범위 한정 → 분산 환경에선 브로커(Kafka, RabbitMQ) 필요
  2. 기본은 동기 호출 → 리스너 처리 지연이 발행자까지 영향을 줄 수 있음
  3. 에러 전파 불투명 → 리스너에서 발생한 예외 관리가 까다로움
  4. 실행 순서 보장 없음 → 여러 리스너 순서 의존성이 있으면 직접 제어 필요(@Order)
  5. 운영 모니터링 취약 → 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 보장: 메시지 중복 소비/재처리 대응