TL;DR: 장애 전파 방지는 단순히 에러를 잡는 게 아니다. 단일 요청에서는 try-catch로 충분해 보이지만, 동시에 수많은 요청이 들어올 때는 스레드가 묶이면서 서버 전체가 멈출 수 있다. Circuit Breaker는 "이미 죽은 거 알았으니까 시도도 하지 마"라는 접근으로, 호출 자체를 막아서 스레드를 보호한다.
장애 전파 방지 = 에러 핸들링이라고 생각했다
외부 API를 호출하는 코드를 작성할 때, 나는 당연히 try-catch로 감싸야 한다고 생각했다. PG사 결제 API를 호출하는데 에러가 나면? catch에서 잡아서 적절한 에러 메시지를 반환하면 된다. 이렇게 하면 PG사에서 문제가 생겨도 우리 서비스가 터지지 않는다. 장애 전파를 막은 거다. 적어도 나는 그렇게 생각했다.
그래서 Circuit Breaker라는 패턴을 처음 접했을 때 솔직히 의문이 들었다. try-catch로 에러 잡고 fallback 실행하면 끝 아닌가? 왜 굳이 상태 관리까지 하면서 복잡하게 가야 하지? CLOSED, OPEN, HALF_OPEN 같은 상태 전환이 대체 왜 필요한 건지 납득이 안 됐다.
하지만 이번 프로젝트에서 직접 적용해보면서, 내가 "장애 전파"라는 개념을 너무 단순하게 이해하고 있었다는 걸 깨달았다.
try-catch로 에러 잡으면 끝 아닌가?
일단 내가 처음 작성했던 방식을 보자
@Service
class PaymentService(private val pgClient: PgClient) {
fun processPayment(request: PaymentRequest): PaymentResult {
return try {
pgClient.requestPayment(request)
} catch (e: Exception) {
PaymentResult.failed("결제 시스템 오류가 발생했습니다.")
}
}
}
이 코드는 꽤 괜찮아 보인다. 예외가 발생하면 catch에서 잡히고, 사용자에게는 적절한 에러 메시지가 내려간다. PG사가 500 에러를 뱉든, 타임아웃이 나든, 우리 서비스는 죽지 않는다. 장애 전파를 막은 것처럼 보인다.
나도 처음엔 이게 충분하다고 생각했다. 에러가 나면 잡고, fallback을 실행하고, 끝. 뭐가 더 필요하지?
진짜 문제는 에러가 아니라 "대기"였다
문제는 단일 요청이 아니라 동시에 수많은 요청이 들어올 때 발생한다. 이걸 이해하는 데 시간이 좀 걸렸다.
상황을 가정해보자. PG사 API에 네트워크 문제가 생겨서 응답이 5초씩 걸린다고 하자. 요청이 1개라면 어떻게 될까? 5초 동안 대기하다가 타임아웃이 발생하고, catch에서 처리된다. 사용자는 에러 메시지를 받고, 서버는 정상 동작한다. 아무 문제 없다.
근데 동시에 100개 요청이 들어오면 어떻게 될까? 100개의 스레드가 전부 PG사 응답을 기다리면서 5초씩 묶여버린다. 1000개가 들어오면? 스레드 풀이 고갈된다.
여기서 내가 놓쳤던 포인트가 있다. try-catch는 예외가 "발생한 후에" 동작한다. 그런데 진짜 문제는 예외가 발생하기 "전에" 스레드가 묶여있다는 거다. PG사가 응답을 안 주는 동안, 우리 서버의 스레드들은 아무것도 못 하고 그냥 기다리고 있다.
결제 API 하나가 느려졌을 뿐인데, 스레드 풀이 고갈되면서 상품 조회도 안 되고, 주문 목록도 안 뜨고, 로그인도 안 된다. PG사의 장애가 우리 서비스 전체로 퍼진 거다. 이게 진짜 "장애 전파"였다.
나는 장애 전파를 "에러가 전달되는 것"이라고 생각했는데, 실제로는 "리소스가 고갈되는 것"이었다. 에러는 catch로 막을 수 있지만, 스레드 고갈은 try-catch로 막을 수 없다.
Circuit Breaker: "이미 죽은 거 알았으니까 시도도 하지 마"
Circuit Breaker는 이 문제를 근본적으로 다르게 접근한다. 핵심 아이디어는 간단하다. "어차피 실패할 거, 호출 자체를 하지 말자."
Circuit Breaker는 세 가지 상태를 가진다. CLOSED 상태는 정상 상태다. 요청이 외부 시스템으로 그대로 전달된다. 연속으로 실패가 쌓이면 OPEN 상태로 전환된다. OPEN 상태에서는 PG API 호출 자체를 하지 않는다. 요청이 들어오면 즉시 fallback을 실행하고 끝낸다. 스레드가 5초씩 묶이는 대신, 수 밀리초 만에 응답을 반환한다.
일정 시간이 지나면 HALF_OPEN 상태가 된다. 이 상태에서는 일부 요청만 실제로 PG에 보내서 복구됐는지 확인한다. 성공하면 CLOSED로 돌아가고, 또 실패하면 다시 OPEN으로 전환된다.
이 방식의 핵심은 스레드 점유 시간이 극적으로 줄어든다는 거다. OPEN 상태에서는 외부 호출을 아예 안 하니까, 스레드가 묶이지 않는다. 나머지 스레드들은 상품 조회나 주문 목록 같은 다른 정상적인 요청을 처리할 수 있다. 결제 API가 죽었어도, 우리 서비스 전체가 죽지는 않는다.
Spring Boot + Resilience4j로 적용하기
실제로 프로젝트에 적용할 때는 Resilience4j 라이브러리를 사용했다. Spring Boot와 함께 쓰면 어노테이션 하나로 Circuit Breaker를 적용할 수 있다.
의존성 추가
// build.gradle.kts
implementation("io.github.resilience4j:resilience4j-spring-boot3:2.1.0")
implementation("org.springframework.boot:spring-boot-starter-aop")
설정
application.yml에서 Circuit Breaker의 동작 방식을 설정한다.
resilience4j:
circuitbreaker:
instances:
pg-payment:
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 3
slow-call-duration-threshold: 3s
slow-call-rate-threshold: 80
각 설정이 의미하는 바를 정리하면 이렇다. sliding-window-size는 최근 몇 개의 요청을 기준으로 상태를 판단할지 정한다. 여기서는 최근 10개 요청을 본다. failure-rate-threshold는 실패율 임계치다. 10개 중 5개 이상 실패하면 OPEN 상태로 전환된다. wait-duration-in-open-state는 OPEN 상태를 얼마나 유지할지 정한다. 30초가 지나면 HALF_OPEN으로 전환되어 복구 여부를 확인한다.
slow-call 관련 설정도 중요하다. 응답이 3초를 넘으면 slow call로 간주하고, slow call 비율이 80%를 넘으면 OPEN 상태로 전환된다. 에러가 나지 않더라도 느린 응답이 계속되면 Circuit Breaker가 작동하는 거다. 이 부분이 try-catch와의 결정적인 차이다. try-catch는 에러가 나야 동작하지만, Circuit Breaker는 느린 응답만으로도 선제적으로 대응할 수 있다.
서비스 코드
@Service
class PaymentService(private val paymentFeignClient: PaymentFeignClient) {
@CircuitBreaker(name = "pg-payment", fallbackMethod = "fallbackPayment")
fun requestPayment(request: PaymentRequest): ApiResponse<PaymentResponse> {
return paymentFeignClient.requestPayment(request)
}
private fun fallbackPayment(
request: PaymentRequest,
ex: Exception
): ApiResponse<PaymentResponse> {
return ApiResponse.fail("결제 시스템 점검 중입니다.")
}
}
어노테이션 하나로 Circuit Breaker가 적용된다. fallback 메서드는 원본 메서드와 같은 파라미터를 받고, 마지막에 Exception을 추가로 받는다. 반환 타입도 동일해야 한다. OPEN 상태이거나 에러가 발생하면 fallbackPayment가 호출되고, 스레드는 바로 해제된다.
정리: 목적이 다르다
이번 프로젝트를 통해 배운 건, try-catch와 Circuit Breaker는 목적 자체가 다르다는 거다.
try-catch는 에러 핸들링이다. 예외가 발생한 후에 처리한다. 단일 요청 관점에서 보면 이것만으로도 충분해 보인다. 에러를 잡고, 적절한 응답을 반환하고, 끝이다.
Circuit Breaker는 장애 격리다. 장애가 감지되면 아예 호출을 막아서 스레드가 묶이는 것을 방지한다. 동시에 수많은 요청이 들어오는 상황에서, 하나의 외부 서비스 장애가 전체 시스템으로 퍼지는 걸 막는다.
처음에 "try-catch로 충분하지 않나?"라고 생각했던 건, 내가 장애 전파를 단일 요청 관점에서만 봤기 때문이다. 에러가 나면 잡으면 되지 않나? 하지만 진짜 문제는 에러가 나기 전에 스레드가 묶이는 것이었다. 그리고 그게 동시 요청이 많아지면 서버 전체를 멈추게 할 수 있다는 걸 이번에 제대로 이해했다.
try-catch가 있어도 Circuit Breaker가 필요한 이유는, 둘이 지키려는 것이 다르기 때문이다. try-catch는 개별 요청의 에러를 처리하고, Circuit Breaker는 전체 시스템의 가용성을 보호한다.