-
서킷 브레이커 완전 정복 가이드Develop/Infra 2025. 8. 22. 16:05
잠깐, 왜 이게 필요하죠?
이커머스에서 결제는 정말 비즈니스의 심장 같은 존재잖아요? 근데 외부 PG(결제 대행사)랑 통신할 땐 별의별 일이 다 생겨요. 갑자기 네트워크가 먹통이 되거나, PG사가 점검 중이거나, 응답이 한참 늦어지거나... 이런 작은 문제 하나가 우리 서비스 전체를 마비시킬 수도 있다니까요!
그래서 준비했습니다! 외부 서비스가 말썽을 부려도 우리 시스템은 끄떡없게 만드는 방어막, 서킷 브레이커(Circuit Breaker) 패턴! 실제 결제 시스템에 어떻게 써먹었는지 그 경험과 꿀팁을 싹 다 알려드릴게요.
1. 서킷 브레이커, 그게 뭔데요?
이름 한번 직관적이죠? 맞아요, 그 전기 회로 차단기에서 아이디어를 따온 거예요. 특정 서비스에 요청을 보냈는데 자꾸 실패하면 "아, 여긴 지금 맛이 갔구나!" 하고 연결을 딱 끊어버려서 우리 시스템을 보호하는 거죠.
서킷 브레이커는 딱 세 가지 상태만 기억하면 돼요.
- CLOSED (정상!): 평소 상태예요. 모든 요청은 외부 서비스로 잘 넘어가고, 혹시 문제가 생기진 않는지 조용히 지켜보고 있죠.
- OPEN (차단!): 문제가 터진 상태! 실패가 계속 쌓여서 "이건 아니다" 싶으면 서킷이 딱 열려요. 이때부터는 모든 요청을 바로 차단하고, 미리 준비해둔 플랜 B, 즉 폴백(Fallback) 로직을 실행해요. 덕분에 문제 있는 서비스에 계속 요청 보내느라 힘 뺄 필요도 없고, 우리 시스템 자원도 아낄 수 있죠.
- HALF_OPEN (괜찮나..?): 회복 체크 상태. OPEN 상태로 좀 시간이 지나면 "이제 괜찮아졌나?" 하고 슬쩍 떠보는 단계예요. 테스트 요청 몇 개만 살짝 보내보고, 성공하면 다시 CLOSED로! 실패하면 가차 없이 OPEN으로 돌아가요.
2. 직접 만들어봐요: Spring Cloud & Resilience4j
자, 그럼 Resilience4j 라이브러리로 스프링 부트에서 서킷 브레이커를 어떻게 만드는지 한번 뜯어볼까요?
1단계: 라이브러리 추가
build.gradle 파일에 요거 한 줄만 쓱 추가해주세요.
api("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")2단계: 설정값 세팅하기 (application.yml)
이제 서킷 브레이커가 어떻게 움직일지 세세하게 정해줄 차례예요.
resilience4j: circuitbreaker: configs: default: registerHealthIndicator: true slidingWindowSize: 10 # 최근 요청 10개를 보고 판단할게! minimumNumberOfCalls: 5 # 최소 5번은 요청이 와야 서킷을 열지 말지 고민 시작! permittedNumberOfCallsInHalfOpenState: 3 # 회복 체크할 땐 3번만 테스트 요청 보내자! automaticTransitionFromOpenToHalfOpenEnabled: true waitDurationInOpenState: 5s # 일단 차단하면 5초는 유지! (그 후에 회복 체크) failureRateThreshold: 50 # 실패율 50% 넘으면 바로 차단이야! recordExceptions: # 이런 에러는 실패로 칠 거야 - org.springframework.web.client.HttpServerErrorException - java.util.concurrent.TimeoutException - java.io.IOException ignoreExceptions: # 요런 에러는 실패로 안 칠게~ - co.m.loopers.support.error.CoreException retry: instances: pgClient: maxAttempts: 3 # 일단 3번까지는 재시도 해보고! waitDuration: 300ms # 재시도할 땐 0.3초 기다렸다가! enableExponentialBackoff: true # 재시도할수록 더 오래 기다리면서! exponentialBackoffMultiplier: 2.0 # 기다리는 시간을 2배씩 늘리자!3단계: 코드에 착! 붙여주기 (@CircuitBreaker & @Retry)
PG사 부르는 코드에 어노테이션 두 개만 딱 붙이면 끝! 정말 간단하죠?
@Slf4j @Component @RequiredArgsConstructor public class PgClientImpl implements PgClient { private final PgFeignClient pgFeignClient; // 일단 @Retry로 재시도부터 해보고, 그래도 안되면 @CircuitBreaker가 나선다! @CircuitBreaker(name = "pgClient", fallbackMethod = "requestFallback") @Retry(name = "pgClient") @Override public PaymentInfo.transaction request(PgClientDto.PgPaymentRequest request) { ApiResponse<PgClientDto.PgPaymentTransaction> response = pgFeignClient.request(request); return PaymentInfo.transaction.toData(response.data()); } @CircuitBreaker(name = "pgClient", fallbackMethod = "findOrderFallback") @Retry(name = "pgClient") @Override public PaymentInfo.order findOrder(final String orderId) { ApiResponse<PgClientDto.PgPaymentOrderResponse> response = pgFeignClient.findOrder(orderId); return PaymentInfo.order.toData(response.data()); } // ... (findTransaction 메서드도 똑같이!) }3. 플랜 B를 준비하는 자세: Fallback 전략
자, 그럼 서킷이 딱! 하고 열리면 어떡할까요? 그냥 에러만 퉤 뱉으면 안 되겠죠? 이럴 때를 대비한 '플랜 B'가 바로 폴백(Fallback) 로직이에요. 상황에 맞게 센스 있는 대처가 필요하답니다.
// 결제 요청이 실패했을 때의 플랜 B public PaymentInfo.transaction requestFallback(PgClientDto.PgPaymentRequest request, Exception e) { log.warn("PG 결제 요청 실패! 주문 ID: {}, 에러: {}", request.orderId(), e.getMessage()); // 고객한테는 "결제 실패했어요!"라고 확실하게 알려주기 return new PaymentInfo.transaction( null, request.orderId(), request.amount(), request.cardNo(), request.cardType(), TransactionStatus.FAILED, "PG사와의 통신에 실패했어요." ); } // 주문 조회 실패 시 플랜 B public PaymentInfo.order findOrderFallback(String orderId, Exception e) { log.warn("PG 주문 조회 실패! 주문 ID: {}, 에러: {}", orderId, e.getMessage()); // 일단 빈 값이라도 줘서 다른 기능이 멈추지 않게 하기 return new PaymentInfo.order(orderId, List.of()); } // 거래내역 조회 실패 시 플랜 B public PaymentInfo.transaction findTransactionFallback(String transactionKey, Exception e) { log.warn("PG 거래내역 조회 실패 transactionKey: {}, 에러: {}", transactionKey, e.getMessage()); // 성공인지 실패인지 지금은 알 수 없으니, "확인 중" 상태로 두기 (나중에 다시 조회해야 하니까!) return new PaymentInfo.transaction( transactionKey, null, null, null, null, TransactionStatus.PENDING, "PG사와의 통신에 실패했어요." ); }상황별 플랜 B, 한눈에 보기!
기능플랜 B이렇게 하는 이유!
결제 요청 FAILED 상태로 응답 고객이 결제가 안 된 걸 바로 알고 다시 시도하거나 다른 방법을 찾을 수 있게! 주문 조회 빈 리스트([]) 주기 이 기능 하나 안된다고 서비스 전체가 멈추면 안 되니까! 거래내역 조회 PENDING(확인 중) 상태 유지 지금 당장 결제 결과를 모르니까, 나중에 다시 확인해볼 수 있게 여지를 남겨두는 센스! 4. 실전에서 써먹는 꿀팁!
- 설정값은 계속 만져주세요: slidingWindowSize는 우리 서비스 트래픽에 맞게, failureRateThreshold는 "이 정도 실패율은 괜찮아" 하는 기준에 맞춰서요. waitDurationInOpenState는 장애가 보통 몇 분 안에 해결되는지 보고 정하면 좋아요. 정답은 없으니 계속 튜닝하는 게 중요해요!
- 모든 에러를 실패로 보지 마세요: 5xx 서버 에러나 타임아웃처럼 진짜 문제 있는 상황만 실패로 잡아야 해요. 사용자가 카드 번호를 잘못 입력해서 나는 4xx 에러 같은 것까지 실패로 잡으면, 서킷이 너무 쉽게 열려버리겠죠? 이런 건 ignoreExceptions에 넣어서 쿨하게 무시해주세요.
- 항상 지켜보고, 문제 생기면 바로 알람!: registerHealthIndicator: true 이거 켜두면 서킷 상태를 쉽게 확인할 수 있어요. Grafana 같은 걸로 예쁘게 대시보드 만들어두고 "실패율 급증!" 또는 "서킷 열림!" 같은 상황에 슬랙 알람 오게 설정해두면 장애 났을 때 바로 알고 대처할 수 있겠죠?
마무리하며
서킷 브레이커는 외부 서비스가 문제를 일으켜도 우리 시스템 전체가 흔들리지 않게 꽉 붙잡아주는 정말 든든한 친구예요. 특히 돈이 오가는 결제 시스템에서는 더더욱 그렇죠. 잠깐의 실패는 @Retry로 버텨보고, 상황이 심각해지면 서킷 브레이커로 일단 방어하는 이 조합, 이제 선택이 아니라 필수랍니다!
가장 중요한 건, 우리 비즈니스에 딱 맞는 플랜 B(폴백)를 짜는 것과 계속 지켜보면서 설정값을 최적화하는 거예요. 이것만 잘해도 사용자는 더 만족하고, 시스템은 훨씬 안정적으로 돌아갈 거예요!