안녕하세요! 오늘은 결제 시스템 연동 개발 중 겪었던 고민과 해결 과정을 공유해보려고 합니다.
외부 PG 시스템과 연동하면서 "장애 전파를 어떻게 막을까?" 라는 문제에 직면했고, Resilience4j를 활용한 Circuit Breaker 패턴을 활용한 경험을 담았습니다.
문제 정의
결제 시스템을 운영하다 보면 자연스럽게 다음과 같은 고민에 직면하게 됩니다.
"외부 PG 시스템 장애 시, 우리 서비스는 어떻게 대응해야 할까?"
겪을수 있는 문제들
- PG 서비스가 일시적으로 다운되면 우리 서비스도 함께 멈춰버림
- 결제 실패 시 사용자에게 명확한 안내 부족
- 콜백 데이터 동기화 실패로 인한 데이터 불일치
- 장애 전파로 인한 전체 서비스 영향
이런 문제들을 해결하기 위해 Resilience4j를 활용한 안정성 확보 전략을 도입하게 되었습니다.
Circuit Breaker 도입 전략
핵심 설계 원칙
"경계에서만 보호하고, 계층별 책임을 명확히 분리하자"
// 모든 계층에 CB 적용 (과도한 보호)
PaymentFacade [@CircuitBreaker]
→ PaymentService [@CircuitBreaker]
→ LoopersPgProcessor [@CircuitBreaker]
// 외부 경계에서만 CB 적용 (명확한 책임)
PaymentFacade
→ PaymentService
→ LoopersPgProcessor [@CircuitBreaker] // 외부 PG와의 경계
왜 LoopersPgProcessor에만 적용했을까?
1. 명확한 장애 지점 식별
- 외부 PG 시스템과의 연동 지점만 보호
- 어디서 장애가 발생했는지 한눈에 파악 가능
2. 비즈니스 로직 순수성 유지
- 상위 계층은 비즈니스 로직에만 집중
- 인프라 관심사와 비즈니스 관심사 분리
3. 디버깅 용이성
- 장애 발생 지점을 명확하게 추적 가능
- 로그 분석이 훨씬 간단해짐
계층별 책임 분리 설계
Retry 전략: 왜 PaymentFacade에만?
// PaymentFacade - 비즈니스 관점의 재시도
@Retry(name = "payment-callback-sync", fallbackMethod = "fallbackProcessCallback")
public PaymentInfo processCallback(PaymentCommand.CallbackRequest command) {
// 콜백 데이터 동기화 실패 시 재시도
}
// LoopersPgProcessor - 인프라 관점의 보호
@CircuitBreaker(name = "pgPayment", fallbackMethod = "paymentFallback")
public ExternalPaymentResponse payment(ExternalPaymentRequest request) {
// PG 시스템 장애 시 Circuit Breaker 작동
}
설계 이유
- PaymentFacade: "콜백 동기화가 실패했으니 다시 시도해보자" (비즈니스 판단)
- LoopersPgProcessor: "PG 시스템이 장애 상태니 더 이상 호출하지 말자" (인프라 보호)
중복 재시도 방지
// 만약 둘 다 retry를 적용한다면?
PaymentFacade: 3회 재시도
└─ LoopersPgProcessor: 3회 재시도
= 총 9회 호출 --> 과도한 부하
이런 상황을 방지하기 위해 계층별로 역할을 명확히 분리했습니다.
실제 구현과 고민들
1. 결제와 상태조회의 차별화
resilience4j:
retry:
instances:
pgPayment: # 결제용
max-attempts: 1 # 중복 결제 방지
payment-callback-sync: # 콜백용
max-attempts: 3 # 동기화 재시도 허용
exponential-backoff-multiplier: 2
고민 포인트: 결제는 절대 재시도하면 안 되지만, 상태 조회는 재시도가 가능합니다. 하지만 PaymentFacade에서 이미 재시도를 처리하고 있어 하위 레이어에서는 재시도를 금지했습니다.
2. Fallback 전략의 차별화
// PaymentFacade - 비즈니스 Fallback
private PaymentInfo fallbackProcessCallback(PaymentCommand.CallbackRequest command, Exception ex) {
log.error("콜백 데이터 동기화 최종 실패", ex);
notificationService.sendPaymentSyncFailureAlert(command.transactionKey()); // 알림 발송
throw new CoreException(ErrorType.PAYMENT_FAIL, "동기화 최종 실패");
}
// LoopersPgProcessor - 인프라 Fallback
public ExternalPaymentResponse paymentFallback(ExternalPaymentRequest request, Exception ex) {
log.error("PG 서비스 Circuit Breaker 작동", ex);
throw new PgServiceTemporarilyUnavailableException("PG 서비스 일시 중단", ex);
}
각 계층에서 서로 다른 관점의 Fallback을 처리하도록 설계했습니다.
3. 데이터 동기화 전략
PG와의 데이터 불일치를 해결하기 위해 스케줄러를 도입했습니다:
@LoopersScheduled(cron = "0 * * * * *") // 매분 실행
public void syncPendingPayments() {
List<Payment> pendingPayments = paymentService.findPendingPayments();
for (Payment payment : pendingPayments) {
// 10분 이상 PENDING이면 복구 처리
if (isOverTenMinutes(payment.getCreatedAt())) {
paymentEventPublisher.publish(PaymentEvent.PaymentFailedRecovery.of(payment.getOrder().getId()));
notificationService.sendPaymentSyncFailureAlert(payment.getTransactionId());
}
// PG 상태와 동기화
boolean syncResult = paymentService.hasSyncPaymentStatus(payment);
// ...
}
}
회고 및 결론
잘했던 점
- 각 계층이 자신의 역할에만 집중
- 비즈니스 로직과 인프라 관심사 분리
- 과도하지 않고 필요한 곳에만 적용하도록 고민
아쉬웠던 점
- CB 상태를 실시간으로 확인할 대시보드 필요
- 메트릭 수집 체계 보강 필요
- 장애 상황 시뮬레이션 테스트 보강 필요
핵심 학습
"모든 곳에 Circuit Breaker를 적용하는 것이 정답이 아니다"
- 외부 시스템과의 경계에서만 보호
- 계층별 책임을 명확히 분리
- 비즈니스 로직과 인프라 보호 로직을 구분
이번 경험을 통해 안정성과 단순성의 균형을 맞추는 것이 얼마나 중요한지 다시 한번 깨달았습니다.
완벽한 보호보다는 적절한 수준의 보호를, 복잡한 구조보다는 명확한 책임 분리를 선택한 것이 올바른 판단이었다고 생각합니다.
'Spring' 카테고리의 다른 글
| Kafka와 아웃박스 패턴으로 이벤트 파이프라인 구축하기 (0) | 2025.09.05 |
|---|---|
| ApplicationEvent로 비즈니스 경계 나누기: 고민과 선택 (3) | 2025.08.28 |
| DB 인덱스 설계와 Redis 캐싱(Port-Adapter) 적용기 (2) | 2025.08.15 |
| Spring 트랜잭션 핵심 정리 (2) | 2025.08.08 |
| 도메인 검증 책임은 어디에 둘까요? – Service vs Facade, 저는 이렇게 선택했습니다 (2) | 2025.08.01 |