내 서버는 죽지 않는다: Spring Boot와 Resilience4j로 서킷 브레이커 구현하기

SUNGKYUM KIM·2025년 8월 22일
12
post-thumbnail

"내 서비스는 멀쩡한데, 왜 장애 알림이 울리는 거지?"

현대 시스템은 서비스가 커지면 MSA로 각 서비스를 분리하곤 합니다. 뿐만 아니라 외부 PG사 연동, 공공 API 호출 등 하나의 서비스에서 외부 서비스를 호출하게 되는 일은 아주 흔한 일입니다.

그러나 외부 시스템과의 연동은 쉬운 이야기가 아닙니다. 이유는 물론 장애때문입니다. 쉽게 생각해봐도, 외부 서비스와 연동이 하나 늘어난다는 것은 장애 원인이 하나 늘어나는 것과 다름이 없습니다. 서비스별로 장애를 격리하고 전체 시스템 안정성을 위한 구조인 MSA 구조를 취했음에도 불구하고, 다른 서비스의 장애로 인해 우리 서비스까지 영향을 받게 된다면 본래 취지에서 어긋나게 됩니다.

사실 내부 서비스 장애라면 무슨 이유로 장애가 발생했는지 알기 쉬울 수 있습니다. 그런데 공공 API처럼 아예 외부 서비스의 API라면 원인을 알 수 없기에 당장 어떤 조치를 취해야 하는지 어려운 경우가 많죠. 또한 개발자가 항상 장애 상황에 바로 대응할 수 있는 것도 아닙니다. 잠을 자던 새벽에 장애가 발생하면 하다못해 서버를 롤백하는 것도 차마 할 수 없을지 모릅니다.

이처럼 예측 불가능하고 통제할 수 없는 외부 장애로부터 우리 시스템을 보호하기 위해, 우리는 시스템에 '안전장치'를 마련해야 합니다. 그것이 바로 오늘 소개할 서킷 브레이커(Circuit Breaker) 패턴입니다.

서킷 브레이커(Circuit Breaker)란?

이름이 꽤 직관적이죠? Circuit Breaker는 단어 그대로 '회로 차단기'를 의미합니다. 우리가 흔히 '두꺼비집'이라고 부르는 가정의 전기 차단기를 떠올리면 이해가 쉽습니다.

우리 집에서 헤어 드라이어에 문제가 생겨 과부하가 걸렸다고 상상해봅시다. 똑똑한 두꺼비집(차단기)은 이 위험을 감지하고 즉시 전기를 끊어버립니다. 덕분에 헤어 드라이어는 잠시 못 쓰게 되지만, 더 중요한 냉장고나 TV, 그리고 집 전체의 전기 시스템은 화재의 위험으로부터 안전하게 보호받습니다.

소프트웨어의 서킷 브레이커도 정확히 같은 역할을 합니다.

  • 전기 회로서비스 간의 네트워크 호출 흐름
  • 과부하 걸린 가전제품장애가 발생한 외부 서비스 (결제 API, 공공 API 등)
  • 집 전체의 정전 / 화재우리 서버 전체의 장애 (연쇄 실패)
  • 두꺼비집 (회로 차단기)소프트웨어 서킷 브레이커 (Resilience4j 등)

즉, 서킷 브레이커는 외부 서비스의 장애를 감지하면, 우리 서비스에서 나가는 요청의 흐름을 자동으로 차단하여 장애가 우리 시스템 전체로 전파되는 것을 막아주는 패턴입니다.

서킷 브레이커의 세 가지 상태

서킷 브레이커는 단순히 요청을 차단하기만 하는 기능이 아닙니다. 스스로 상태를 변경하며 지능적으로 동작합니다.

  1. CLOSED (닫힘 - 정상 상태)

    • 초록불 상태입니다. 평소에는 이 상태를 유지하며, 외부 서비스로 모든 요청을 정상적으로 전달합니다.
    • 동시에 계속해서 실패가 얼마나 발생하는지 감시합니다.
    • 실패율이 설정된 임계치를 넘어서면, "아, 이 서비스에 문제가 생겼구나"라고 판단하고 다음 상태로 전환됩니다.
  2. OPEN (열림 - 차단 상태)

    • 빨간불 상태입니다. 두꺼비집이 "내려간" 것과 같습니다.
    • 이 상태에서는 외부 서비스로 어떤 요청도 보내지 않고 즉시 에러를 반환합니다.
    • 이를 통해 이미 장애가 난 서비스에 불필요한 부하를 주지 않고, 우리 시스템의 자원(스레드 등)도 낭비하지 않게 됩니다. (Fail Fast)
    • 설정된 대기 시간이 지나면, "이제 서비스가 복구되었을까?"를 확인하기 위해 다음 상태로 넘어갑니다.
  3. HALF_OPEN (반-열림 - 탐색 상태)

    • 노란불 상태입니다. OPEN 상태에서 일정 시간이 지난 후, 복구를 확인하기 위한 상태입니다.
    • 모든 요청을 막는 대신, 미리 설정된 개수의 "탐색용" 테스트 요청만 조심스럽게 보냅니다.
    • 이 테스트 요청이 성공하면, "서비스가 복구되었구나"라고 판단하고 서킷을 다시 CLOSED 상태로 전환합니다.
    • 만약 테스트 요청마저 실패하면, "아직 복구가 안 됐구나"라고 판단하고 다시 OPEN 상태로 돌아가 대기합니다.

그래서 서킷 브레이커를 쓰면 뭐가 좋은데?

  • 빠른 실패: 이미 장애가 난 서비스의 응답을 기다리며 시간을 낭비하는 대신, 즉시 실패를 반환하여 시스템의 응답성을 유지합니다.
  • 장애격리: 장애를 해당 서비스에 격리시켜, 우리 시스템 전체로 장애가 퍼져나가는 것을 막습니다.
  • 우아한 성능 저하: 서킷이 열렸을 때, 단순히 에러만 반환하는 대신 미리 준비된 대안(Fallback)을 제공하여, 기능이 일부 제한되더라도 전체 서비스는 중단되지 않도록 할 수 있습니다.

예제 프로젝트로 직접 체감하는 서킷 브레이커

이론은 충분하지만, 가슴에 와닿지 않을 수 있습니다. 개발자에게 최고의 학습은 역시 코드로 직접 문제를 겪고 해결하는 것이겠죠.

그래서 서킷 브레이커의 필요성과 기능을 실제로 공감하실 수 있도록, 간단한 예시 프로젝트를 하나 준비했습니다. 지금부터 함께 프로젝트를 만들며 서킷 브레이커가 왜 필요한지 직접 체감해 봅시다.

💡 전체 코드와 직접 실행시켜볼 수 있는 프로젝트는 여기애서 확인할 수 있습니다.

예제 프로젝트: "부동산 종합 리포트 서비스"

오늘 우리가 만들어 볼 서비스는 사용자가 주소를 입력하면, 5개의 다른 API를 호출하여 데이터를 종합한 뒤 하나의 '부동산 리포트'를 제공하는 아주 간단한 어플리케이션입니다.

구현을 위해 아래와 같은 시나리오를 가정했습니다.

  • 우리 서버: Property-Report-Service (Spring Boot + Kotlin)
  • 연동 대상 API:
    1. 건축물 정보 API (내부 MSA): 빠르고 안정적
    2. 국토부 실거래가 API (공공 API): 항상 3초 이상 걸림
    3. 상업용 시세 API: 3번에 1번꼴로 간헐적 실패(503 에러)
    4. 지도 API: 빠르고 안정적
    5. 뉴스 검색 API: 비핵심 보조 API

이 시나리오를 바탕으로 점진적으로 시스템을 개선해나갈 겁니다.

안정적인 외부 API를 상대로는 서킷 브레이커의 동작을 테스트하기 어렵습니다. 그래서 우리는 위 API들의 불안정한 동작을 흉내 내는 '외부 API 시뮬레이터'도 함께 만들어서, 일부러 문제를 일으키는 통제된 환경을 구축할 것입니다.

1부: 순진한 구현

먼저, 아무런 방어 장치 없이 순수하게 5개 API를 순차적으로 호출하는 코드를 작성해 보겠습니다.

PropertyReportService.kt (순진한 버전)

@Service
class PropertyReportService(
    // ... 5개의 Feign Client 주입 ...
) {
    fun generateReport(address: String): PropertyReportResponse {
        log.info("리포트 생성 시작: $address")

        // 5개의 API를 순차적으로 호출
        val propertyInfo = propertyInfoClient.getPropertyInfo(address)
        val landRegistry = landRegistryClient.getLandRegistry(address) // 문제아 1: 3초 지연
        val marketPrice = marketPriceClient.getMarketPrice(address)   // 문제아 2: 간헐적 실패
        val amenities = amenitiesClient.getAmenities(address)
        val news = newsClient.searchNews(address)

        log.info("모든 API 호출 완료")
        return PropertyReportResponse(...)
    }
}

이제 외부 API 시뮬레이터(8081 포트)와 부동산 리포트 서비스(8080 포트)를 모두 실행하고, 터미널에서 curl 명령어로 우리 API를 호출하여 어떤 일이 벌어지는지 직접 확인해 보겠습니다.

상황 1: 국토부 API가 3초 지연될 때

먼저, 모든 외부 API가 정상적으로 응답하지만 국토부 API가 의도적으로 3초 느리게 응답하는 경우입니다.

# 터미널에 아래 명령어를 입력하고 실행합니다.
curl --get --data-urlencode "address=강남구" http://localhost:8080/api/v1/property-report

터미널에서 한참 동안 기다리면, 다음과 같은 성공적인 JSON 응답을 받게 됩니다.

{
  "propertyInfo": {
    "address": "강남구",
    "size": 110,
    "type": "아파트"
  },
  "landRegistry": {
    "officialPrice": 1000000000,
    "owner": "홍길동"
  },
  "marketPrice": {
    "marketPrice": 1200000000,
    "trend": "상승"
  },
  "amenities": {
    "subway": "강남역",
    "school": "역삼초등학교"
  },
  "news": {
    "news": [
      "강남구 인근 재개발 계획 발표",
      "GTX 노선 확정"
    ]
  }
}

응답 시간은 얼마나 걸렸을까요?

TOTAL: 3.049193s

겉보기엔 성공처럼 보이지만, 진짜 문제는 응답 시간입니다. 단 한 번의 요청에 3초가 넘게 걸렸습니다. 만약 동시 접속자가 10명만 되어도 우리 서버의 스레드는 모두 이 응답을 기다리느라 멈춰버리고, 새로운 요청은 처리할 수 없는 상태가 됩니다. 종국에는 서버 전체가 다운될 수 있습니다. 느린 외부 서비스 하나가 전체 시스템을 마비시키는 상황입니다.


상황 2: 시세 API가 503 에러를 반환할 때

이번에는 시뮬레이터의 marketPrice API가 503 에러를 반환하는 상황입니다. 다시 한번 요청을 보내봅시다.

# 동일한 명령어를 다시 입력합니다.
curl --get --data-urlencode "address=강남구" http://localhost:8080/api/v1/property-report

결과는 안타깝게도, 에러입니다.

{
  "timestamp": "2025-08-21T14:29:25.643+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/api/v1/property-report"
}

우리 서버 로그에는 feign.FeignException$ServiceUnavailable가 찍혔을 겁니다. marketPrice API 호출이 실패하자, 잘 받아온 나머지 4개의 정보는 모두 버려진 채 사용자에게는 500 에러 페이지만 보입니다. 단 하나의 실패가 전체를 실패로 만들었습니다.


이제 우리는 두 가지 명확한 문제에 직면했습니다.

  1. 느린 응답으로 인한 연쇄 장애
  2. 단일 실패로 인한 전체 장애

이 상황을 해결하기 위해 코드를 하나씩 개선해나가겠습니다.

2부: 1차 방어선 - Fail Fast를 위한 타임아웃 적용

우리 서버의 스레드(Thread)는 한정된 자원입니다. 마치 식당의 테이블과 같습니다. 한 손님(요청)이 자리를 떠나지 않고 계속 앉아있으면(느린 API 응답을 기다리면), 다른 손님(새로운 요청)들은 식당에 들어올 수조차 없게 됩니다.

이런 상황을 막기 위한 첫 번째 원칙은 "Fail Fast(빠른 실패)"입니다. 문제가 있는 요청은 가능한 한 빨리 실패 처리하여, 다른 정상적인 요청에 영향을 주지 않도록 하는 전략입니다.

느린 국토부 API를 더는 무작정 기다리지 않도록, 1초의 타임아웃을 설정해 봅시다.

application.yml에 아래 설정을 추가하거나 수정합니다.

spring:
  cloud:
    openfeign:
      client:
        config:
          land-registry: # Feign Client의 name 속성
            connectTimeout: 3000 # 연결 시도 타임아웃 (ms)
            readTimeout : 1000    # 데이터 읽기 타임아웃 (ms) - 이 값을 1초로 설정

이제 부동산 리포트 서비스를 재시작하고, 다시 터미널에서 API를 호출해 봅시다.

{"timestamp":"2025-08-22T02:04:04.751+00:00","status":500,"error":"Internal Server Error","path":"/api/v1/property-report"}

# 실행시간
TOTAL: 1.017274s

결과는 여전히 에러를 발생시키지만 실행시간은 눈에 띄게 줄어든 것을 볼 수 있습니다.

이전에는 3초 넘게 지연되던 요청이 이제 정확히 Read Timeout 값인 1초 만에 실패하고 있습니다. 서버 로그에는 SocketTimeoutException: Read timed out이 기록되었을 겁니다.

'Fail Fast' 전략은 문제가 있는 외부 호출을 무작정 기다려주지 않음으로써, 우리 서버의 소중한 스레드 자원을 지키고 다른 정상적인 요청을 처리할 수 있는 여력을 확보합니다. 느린 서비스 하나 때문에 시스템 전체가 마비되는 최악의 상황은 막은 셈입니다.

하지만 근본적인 문제는 여전합니다. 느린 서비스 하나 때문에 여전히 전체 기능이 실패하고 있습니다. 사용자는 여전히 500 에러를 보고 있는 상황입니다.

3부: 최종 방어선 구축 - 서킷 브레이커와 Fallback

여기서 우리는 두 가지 의문이 듭니다.

"장애가 발생한 것을 아는 서비스에 계속해서 실패할 요청을 보내야 할까?"

우리는 상대 서버의 상태를 알 수 없습니다. 만약 국토부 API 서버가 정말 다운되었다면, 들어오는 모든 요청은 1초의 타임아웃을 겪으며 계속 실패할 겁니다. 이는 우리 서버와 네트워크에 불필요한 부하를 유발합니다.

"왜 서비스의 일부(예: 시세 정보)가 실패했다는 이유만으로, 사용자는 나머지 유용한 정보(예: 건축물 정보)까지 모두 볼 수 없어야 할까?"

사용자 입장에서는 시세 정보가 안 나오더라도, 다른 정보라도 보는 것이 500 에러 페이지를 보는 것보다 훨씬 낫습니다.

서킷 브레이커와 Fallback은 두 가지 질문에 대한 해답이 됩니다.

서킷 브레이커는 실패의 패턴을 '자동'으로 감지합니다. 반복되는 실패를 개발자가 지정한 규칙를 바탕으로 특정 서비스가 현재 불안정하다고 스스로 판단합니다.

일단 장애라고 판단하면, 일정 시간 동안 해당 서비스로 가는 모든 요청을 즉시 차단합니다. 또한 단순히 요청을 차단하는 데서 그치지 않고, 그 대신 실행할 대안 경로(Fallback)를 제공햘 수 있습니다. 덕분에 우리는 전체를 500 에러로 실패시키는 대신, "죄송합니다, 현재 시세 정보는 확인할 수 없습니다"라는 메시지와 함께 나머지 정보는 정상적으로 보여주는 부분적인 성공을 만들어낼 수 있습니다.

1. 정책 수립 (application.yml)

각 API의 특성에 맞게 다른 정책을 설정해보겠습니다. land-registry느린 호출을 집중적으로 감지하고, market-price예외 발생을 집중적으로 감지하도록 만들어 봅시다.

실전 Tip:
실제 운영 환경에서는 slidingWindowType, minimumNumberOfCalls, permittedNumberOfCallsInHalfOpenState 등 훨씬 더 많은 요소들을 각 서비스의 특성(트래픽, 중요도, 예상 복구 시간 등)에 맞게 세밀하게 튜닝해야 합니다.

이 글에서는 이해를 돕고자 설정을 간소화했습니다.

resilience4j:
  circuitbreaker:
    instances:
      # 국토부 API
      land-registry:
        slowCallDurationThreshold: 700ms # 700ms 이상 걸리면 '느린 호출'로 간주
        slowCallRateThreshold: 50      # 느린 호출 비율이 50% 이상이면 서킷 OPEN
        slidingWindowSize: 10          # 최근 10번의 호출을 기준으로 집계
        minimumNumberOfCalls: 5        # 최소 5번은 호출되어야 계산 시작
        waitDurationInOpenState: 3m    # 서킷이 열리면 3분간 유지
      # 시세 API
      market-price:
        failureRateThreshold: 50       # 예외 발생률이 50% 이상이면 서킷 OPEN
        slidingWindowSize: 20          # 최근 20번의 호출을 기준으로 집계
        minimumNumberOfCalls: 10       # 최소 10번은 호출되어야 계산 시작
        waitDurationInOpenState: 1m    # 서킷이 열리면 1분간 유지

2. Fallback 구현 (실패했을 때의 대안)

서킷이 열렸을 때, 무작정 에러를 내뱉는 대신 미리 준비된 대안을 제공하여 사용자 경험이 완전히 망가지는 것을 막는 것이 바로 Fallback의 역할입니다. Fallback 전략은 서비스의 특성과 비즈니스 요구사항에 따라 다양하게 구사할 수 있습니다.

  1. 캐시된 데이터 반환: 가장 우아한 방법 중 하나입니다. Redis 같은 캐시에 마지막으로 성공했던 API 응답을 저장해두었다가, 장애 시 이 데이터를 대신 반환합니다. 사용자는 약간 오래된(stale) 정보일지라도 유의미한 데이터를 받아볼 수 있어 사용자 경험을 크게 향상시킬 수 있습니다.

  2. 정적인 기본값 반환: 가장 간단한 방법입니다. 미리 정의된 값(e.g. 한달 전 랭킹 데이터) 빈 리스트([]), 등 미리 하드코딩된 값을 반환합니다. 구현이 쉽고 빠르다는 장점이 있습니다.

  3. 다른 데이터 소스 활용: 1순위 API가 실패하면, 2순위로 신뢰도는 조금 낮지만 더 안정적인 다른 API를 호출하는 전략도 가능합니다. (e.g. PG사 교체)

이 글에서는 Fallback의 기본 개념에 집중하기 위해, 가장 간단한 방법인 '정적인 기본값 반환' 전략을 사용해 보겠습니다.

참고: Fallback 설정은 서킷 브레이커의 동작과 무관합니다. Fallback은 서킷 브레이커가 OPEN 되었을 때만 동작하는 것이 아니라 API 호출이 실패하는 경우 무조건 동작합니다.

LandRegistryClientFallback.kt

@Component
class LandRegistryClientFallback : LandRegistryClient {
    override fun getLandRegistry(address: String): LandRegistry {
        log.warn("LandRegistryClient Fallback 실행. 주소: $address")
        return LandRegistry(officialPrice = 0, owner = "정보 조회 불가") // 대안 데이터 반환
    }
    // ...
}

(MarketPriceClientFallback.kt도 유사하게 작성)

3. Feign Client에 연결

이제 Feign Client에 서킷 브레이커 정책과 Fallback을 연결해 줍니다.

ExternalApiClient.kt

@FeignClient(
    name = "land-registry",
    url = "...",
    fallback = LandRegistryClientFallback::class // Fallback 클래스 지정
)
interface LandRegistryClient {
    @GetMapping("/land-registry/{address}")
    @CircuitBreaker(name = "land-registry") // yml에 정의한 정책 이름
    fun getLandRegistry(@PathVariable address: String): LandRegistry
}
// MarketPriceClient도 동일하게 적용

이제 모든 안정성 장치가 적용되었습니다. 과연 우리 서버는 혹독한 환경에서 살아남을 수 있을까요? 로그와 응답 시간을 통해 직접 확인해보겠습니다.

1. 서킷이 열리는 과정

land-registry API가 계속해서 타임아웃을 일으키는 상황을 시뮬레이션해 보겠습니다. Postman이나 터미널에서 우리 API를 5번 연속으로 호출합니다.

property-report-service의 서버 로그에는 다음과 같은 기록이 남게 됩니다.

// --- 1~4번째 호출 ---
// 타임아웃으로 인한 에러 발생 -> Fallback 실행
2025-08-22T12:05:07.341+09:00 ERROR ... c.l.p.c.CustomRegistryEventConsumer : LandRegistryClient... ERROR!!
2025-08-22T12:05:07.342+09:00  WARN ... c.l.p.f.LandRegistryClientFallback  : LandRegistryClient Fallback 실행. 주소: 강남구
... (4번 반복) ...

// --- 5번째 호출 ---
// 또다시 에러 발생
2025-08-22T12:05:14.285+09:00 ERROR ... c.l.p.c.CustomRegistryEventConsumer : LandRegistryClient... ERROR!!

// 서킷 브레이커가 임계치 도달을 감지!
2025-08-22T12:05:14.285+09:00  WARN ... c.l.p.c.CustomRegistryEventConsumer : LandRegistryClient... failure rate 100.0%
2025-08-22T12:05:14.290+09:00  INFO ... c.l.p.c.CustomRegistryEventConsumer : LandRegistryClient... state CLOSED -> OPEN

// Fallback 실행
2025-08-22T12:05:14.291+09:00  WARN ... c.l.p.f.LandRegistryClientFallback  : LandRegistryClient Fallback 실행. 주소: 강남구
  1. 초반 4번의 호출은 설정한 minimumNumberOfCalls: 5(최소 호출 수)에 도달하지 않아, 실패가 기록되고 Fallback이 실행되었지만 서킷의 상태는 CLOSED로 유지됩니다.
  2. 5번째 호출에서 드디어 최소 호출 수를 만족했고, 실패율이 failureRateThreshold: 50%를 넘자마자 서킷 브레이커는 상태를 CLOSED에서 OPEN으로 변경했습니다.

또한 우리 서비스가 더 이상 500 에러를 뱉지 않고 대신, 다음과 같은 Fallback 데이터를 반환하고 있는 것도 확인할 수 있습니다.

{
  "propertyInfo": { ... },
  "landRegistry": { ... },
  "marketPrice": {
    "marketPrice": 0,
    "trend": "정보 조회 불가" // Fallback 데이터
  },
  "amenities": { ... },
  "news": { ... }
}

그럼, 서킷이 OPEN된 직후, 6번째 요청을 보내면 어떤 일이 벌어질까요?

# 서킷이 OPEN된 상태에서 다시 한번 API 호출 시간 측정
curl -w "TOTAL: %{time_total}s\n" -o /dev/null -s "http://localhost:8080/api/v1/property-report?address=%EA%B0%95%EB%82%A8%EA%B5%AC"

결과는 이전과 비교할 수 없을 정도로 빠릅니다.

TOTAL: 0.018682s

응답 시간이 약 0.018초로 확실하게 빨라진 것을 확인할 수 있습니다. 이유는 서킷 브레이커가 OPEN 상태에 있기 때문에 실제 land-registry API를 호출하는 시도조차 하지 않고, 즉시 Fallback 로직(LandRegistryClientFallback)을 실행했기 때문입니다.

// 외부 API 시뮬레이터의 콘솔 로그
2025-08-22T12:15:27.301+09:00  INFO ... c.l.e.ExternalApiSimulatorController : [land-registry] Request #35 finished.
2025-08-22T12:15:27.305+09:00  INFO ... c.l.e.ExternalApiSimulatorController : [property-info] Request #40: address=강남구
2025-08-22T12:15:27.310+09:00  INFO ... c.l.e.ExternalApiSimulatorController : [market-price] Request #30: address=강남구. Current state: HEALTHY
2025-08-22T12:15:27.313+09:00  INFO ... c.l.e.ExternalApiSimulatorController : [amenities-map] Request #24: address=강남구
2025-08-22T12:15:27.315+09:00  INFO ... c.l.e.ExternalApiSimulatorController : [news-search] Request #24: query=강남구
// --- 잠시 후 다시 우리 API를 호출 ---
2025-08-22T12:15:28.875+09:00  INFO ... c.l.e.ExternalApiSimulatorController : [property-info] Request #41: address=강남구
2025-08-22T12:15:28.881+09:00  INFO ... c.l.e.ExternalApiSimulatorController : [market-price] Request #31: address=강남구. Current state: HEALTHY
2025-08-22T12:15:28.886+09:00  INFO ... c.l.e.ExternalApiSimulatorController : [amenities-map] Request #25: address=강남구
2025-08-22T12:15:28.889+09:00  INFO ... c.l.e.ExternalApiSimulatorController : [news-search] Request #25: query=강남구

외부 API 시뮬레이터의 로그를 확인해 보면 더 명확합니다. 처음에는 실패 요청이 계속 들어오다가, 서킷이 OPEN된 이후로는 더 이상 land-registry 요청 로그가 찍히지 않는 것을 볼 수 있습니다.

이처럼 서킷 브레이커는 실제로 상대 서버에게 회복할 시간을 준다는 측면에서도 의미가 있습니다. 느려진 서버에게 계속해서 API 호출을 시도하는 것은 일종의 DDOS 공격이 될 수 있습니다. 상대 서버가 회복할 시간을 줌으로써, 일정 시간 후에는 정상적인 응답이 오는 것을 기대할 수 있습니다.

마무리하며

오늘 우리는 하나의 외부 서비스 장애가 시스템 전체를 위협하는 상황에서 출발했습니다. 그리고 타임아웃이라는 1차 방어선을 구축했고, 최종적으로는 서킷 브레이커와 Fallback으로 최종 방어선과 함께 사용자 경험도 함께 고려해봤습니다. 이 과정을 통해 우리는 장애 격리 (Failure Isolation)를 위한 여러 방안들을 배웠습니다.

Resilience4j와 같은 라이브러리는 장애 격리를 위한 방법들을 코드에 쉽게 녹여낼 수 있도록 도와주는 훌륭한 도구입니다. 마지막 부록으로 Resilience4j 서킷 브레이커 주요 설정값에 대해 정리하고 글을 마치겠습니다.

부록: Resilience4j 서킷 브레이커 주요 설정값 정리

주요 설정값들이 어떤 의미를 가지는지 정리했습니다. 이 값들을 어떻게 조합하느냐에 따라 서킷 브레이커의 성격이 완전히 달라집니다.

파라미터설명고려사항 및 설정 기준
failure-rate-threshold실패율 임계값 (%)
CLOSED 상태일 때, 슬라이딩 윈도우 내의 실패율이 이 값을 초과하면 서킷을 OPEN 합니다.
서비스의 중요도에 따라 설정합니다. 중요도가 높다면 민감도를 높이는 선택을 할 수 있습니다. 너무 낮으면 과도하게 서킷이 열리게 될 수 있습니다. (디폴트 50%)
slow-call-rate-threshold느린 호출 비율 임계값 (%)
CLOSED 상태일 때, slowCallDurationThreshold 보다 느린 호출의 비율이 이 값을 초과하면 서킷을 OPEN 합니다.
서비스가 응답 지연 특성을 보이는지 분석 후 설정합니다. 서버의 평균 응답시간보다 낮으면 과도하게 서킷이 열릴 수 있습니다.
slow-call-duration-threshold느린 호출 시간 기준 (ms, s)
호출이 이 시간을 초과하면 '느린 호출'로 간주되어 slow-call-rate-threshold 계산에 포함됩니다.
서비스의 응답 시간 SLA 또는 P95/P99 Latency 값을 기준으로, "이 시간보다 오래 걸리면 비정상"이라고 판단되는 현실적인 값을 설정해야 합니다.
wait-duration-in-open-stateOPEN 상태 유지 시간 (ms, s, m)
서킷이 OPEN된 후, HALF_OPEN으로 전환되기까지 대기하는 시간입니다. 외부 서비스에 회복할 시간을 줍니다.
외부 서비스의 예상 복구 시간을 기준으로 설정할 수 있습니다.
sliding-window-type슬라이딩 윈도우 유형
COUNT_BASED(호출 수 기반) 또는 TIME_BASED(시간 기반) 중 어떤 기준으로 실패율을 집계할지 결정합니다.
- COUNT_BASED: 트래픽이 꾸준한 서비스에 적합하고 직관적입니다.
- TIME_BASED: 트래픽이 불규칙하거나 특정 시간에 몰리는(bursty) 서비스에 더 적합합니다.
sliding-window-size슬라이딩 윈도우 크기
COUNT_BASED이면 최근 N개의 호출, TIME_BASED이면 최근 N초를 의미합니다. 실패율을 계산하는 샘플의 크기입니다.
서비스의 트래픽 양을 기준으로 설정합니다.
minimum-number-of-calls최소 호출 수
슬라이딩 윈도우 내에 이 값만큼의 호출이 쌓이기 전까지는 실패율을 계산하지 않습니다. 어설픈 샘플로 서킷이 오작동하는 것을 방지합니다.
통계적 신뢰도를 위한 안전장치입니다. slidingWindowSize보다 작은 값으로 설정해야 합니다.
permitted-number-of-calls-in-half-open-stateHALF-OPEN 상태에서 허용되는 호출 수
HALF_OPEN 상태일 때, 외부 서비스의 복구 여부를 확인하기 위해 보내는 테스트 요청의 수입니다.
복구 중인 서비스에 부담을 주지 않도록 비교적 낮은 값(5 미만)으로 설정하는 것이 일반적입니다.
profile
Code For Christ

4개의 댓글

comment-user-thumbnail
2025년 8월 26일

글 잘보고갑니다. 감사합니다!

1개의 답글
comment-user-thumbnail
2025년 9월 4일

글 너무 잘 읽었습니다! 💪💪

1개의 답글