서비스 장애 시 Graceful Degradation과 Circuit Breaker 설계

· 약 13분 읽기 · 조회 2 #장애 대응#Circuit Breaker#Resilience4j#Spring Boot#아키텍처

외부 API 하나가 느려지면 그 API를 기다리는 스레드와 커넥션이 쌓이고, 결국 그 API와 상관없는 화면까지 멈춘다. Graceful Degradation은 "무엇을 포기하고 무엇을 지킬지"를 기능마다 미리 정하는 일이고, Circuit Breaker는 그 결정을 장애 순간에 자동으로 실행하는 장치다. 이 글은 강등 단계를 정하는 방법, Resilience4j Circuit Breaker의 상태와 설정, 타임아웃·Retry·Bulkhead와 함께 쓸 때의 순서, 그리고 fallback이 장애를 숨기지 않게 하는 방법을 정리한다.

지하 전기실에서 전기 기사가 큰 분전반의 차단기 하나를 내리고, 길 건너 아파트는 대부분 창에 불이 켜져 있고 한 층만 어두운 장면

한눈에 보기

장애 대응 장치는 각자 막는 것이 다르다. 하나만 두면 나머지 구멍으로 장애가 번진다.

장치 막는 것 없으면 생기는 일
타임아웃 끝나지 않는 호출 스레드와 커넥션이 응답을 무한정 기다리며 쌓인다
Circuit Breaker 이미 망가진 대상으로 계속 보내는 호출 실패할 호출이 매번 타임아웃까지 자원을 잡는다
Bulkhead 한 의존 대상이 자원을 독차지하는 것 주소 검색 장애가 신청 저장 스레드까지 먹는다
Retry 일시적인 한 번의 실패 순간 끊김에도 사용자가 오류를 본다
Fallback 호출이 막혔을 때 빈 화면 차단은 됐지만 사용자는 500 오류를 본다

이 글의 예시는 민원 신청 서비스다. 신청서 저장은 자체 DB, 주소 검색과 알림 발송은 외부 연계, 공지 위젯과 맞춤 안내는 부가 기능이다.

기능마다 강등 단계를 먼저 정한다

Circuit Breaker를 붙이기 전에 "이 호출이 막히면 화면에 무엇을 보여 줄지"부터 정한다. 그 답이 없으면 breaker가 열려도 결과는 오류 화면이다.

기능을 핵심과 부가로 나눈다

  • 핵심 기능: 막히면 서비스의 존재 이유가 사라지는 것. 신청서 제출, 내 신청 조회.
  • 부가 기능: 없어도 핵심 업무가 끝나는 것. 공지 위젯, 맞춤 안내, 조회수.

강등 방식은 다섯 가지 중에서 고른다

기능 등급 의존 대상 장애 시 강등 방식
주소 검색 핵심 보조 고정 대체: 검색 대신 직접 입력 칸을 연다
공지 위젯 부가 지난 데이터: 캐시에 남은 마지막 값을 "갱신 지연" 표시와 함께 보여 준다
맞춤 안내 부가 기능 끔: 영역을 숨긴다
내 신청 조회 핵심 읽기 전용: DB 쓰기 장애 시 조회만 열고 수정 버튼을 막는다
접수 알림 발송 핵심 보조 나중에 처리: 신청은 저장하고 알림은 재처리 대상으로 남긴다

업무 담당자와 함께 정한다

강등은 업무 결과를 바꾼다. 주소를 직접 입력받으면 잘못된 주소가 들어올 수 있고, 알림을 미루면 민원인이 접수 여부를 늦게 안다. 이 결과를 감수할지는 개발자가 아니라 업무 담당자가 정하고, 결정은 문서로 남긴다.

반대로 강등하면 안 되는 기능도 있다. 본인인증, 권한 확인, 결제 승인은 의존 대상이 죽으면 요청을 거절한다(fail closed). "인증 서버가 안 되니 일단 통과"는 장애를 보안 사고로 바꾼다.

Circuit Breaker의 세 상태

Resilience4j Circuit Breaker는 CLOSED, OPEN, HALF_OPEN 세 상태를 오간다.

Circuit Breaker 상태 전이: CLOSED에서 실패율이나 느린 호출 비율이 임계값을 넘으면 OPEN으로 바뀌어 호출을 막고 fallback으로 응답하고, 대기 시간이 지나면 HALF_OPEN에서 시험 호출을 보내 성공하면 CLOSED로, 실패하면 다시 OPEN으로 돌아간다

  • CLOSED: 호출을 그대로 보내고 결과를 슬라이딩 윈도에 기록한다. 실패율이나 느린 호출 비율이 임계값을 넘으면 OPEN으로 바뀐다.
  • OPEN: 호출을 보내지 않고 바로 CallNotPermittedException을 던진다. 이 예외를 fallback이 받아 강등 응답을 만든다. 대상에 부담을 주지 않으므로 대상이 회복할 시간을 번다.
  • HALF_OPEN: 정해진 대기 시간이 지나면 시험 호출 몇 건만 보낸다. 시험 호출의 실패율이 임계값 아래면 CLOSED로, 넘으면 다시 OPEN으로 간다.

슬라이딩 윈도는 두 가지다. COUNT_BASED는 최근 N건, TIME_BASED는 최근 N초 동안의 호출로 비율을 계산한다. 호출량이 시간대별로 크게 다르면 TIME_BASED가, 일정하면 COUNT_BASED가 읽기 쉽다.

Resilience4j 설정 읽는 법

Spring Boot에서는 resilience4j-spring-boot3 의존성을 넣고 application.yml에 인스턴스별로 적는다. 아래 값은 예시다.

resilience4j:
  circuitbreaker:
    instances:
      addressApi:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 50
        minimumNumberOfCalls: 20
        failureRateThreshold: 50
        slowCallDurationThreshold: 2s
        slowCallRateThreshold: 80
        waitDurationInOpenState: 30s
        permittedNumberOfCallsInHalfOpenState: 5
        automaticTransitionFromOpenToHalfOpenEnabled: true
        recordExceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
          - org.springframework.web.client.HttpServerErrorException
        ignoreExceptions:
          - com.example.address.InvalidKeywordException
설정 뜻 잘못 잡으면
slidingWindowSize 비율을 계산할 최근 호출 수(또는 초) 너무 작으면 몇 건의 실패로 열리고, 너무 크면 늦게 열린다
minimumNumberOfCalls 이만큼 쌓이기 전에는 판정하지 않음 1로 두면 새벽에 한 건 실패로 OPEN이 된다
failureRateThreshold 실패율(%) 임계값 기록할 예외를 안 정하면 업무 오류까지 실패로 센다
slowCallDurationThreshold / slowCallRateThreshold 이 시간보다 긴 호출을 느린 호출로 보고, 그 비율(%) 임계값 지연 장애는 오류 없이 오므로 이 값이 없으면 끝까지 안 열린다
waitDurationInOpenState OPEN 유지 시간 너무 짧으면 회복 전의 대상을 다시 두드린다
permittedNumberOfCallsInHalfOpenState 시험 호출 수 너무 많으면 회복 중인 대상에 다시 부하가 몰린다

recordExceptions와 ignoreExceptions는 꼭 적는다. 사용자가 검색어를 잘못 넣어 생기는 400 오류는 대상의 장애가 아니다. 이것까지 실패로 세면 사용자 실수가 몰리는 시간에 breaker가 열린다.

타임아웃·Retry·Bulkhead와 함께 쓰는 순서

타임아웃이 먼저다

breaker는 호출이 끝나야 결과를 센다. 응답이 영영 오지 않으면 느린 호출로도 실패로도 기록되지 않는다. 그래서 모든 외부 호출에 타임아웃을 먼저 건다.

  • 동기 호출(RestClient, RestTemplate)은 HTTP 클라이언트의 연결 타임아웃과 읽기 타임아웃으로 끊는다.
  • Resilience4j TimeLimiter는 CompletableFuture 같은 비동기 반환에 걸린다. 동기 메서드에 붙여도 기대한 대로 끊기지 않는다.
  • 타임아웃은 slowCallDurationThreshold보다 길게 둔다. 예시 설정이라면 느린 호출 기준 2초, 읽기 타임아웃 3초다. 반대로 두면 느린 호출로 세기 전에 타임아웃 예외로만 잡힌다.

Retry는 breaker 바깥에 있다

Resilience4j Spring Boot 어노테이션의 기본 적용 순서는 바깥부터 Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead → 실제 호출이다. Retry가 가장 바깥이므로 재시도 한 번 한 번이 breaker를 지나며 기록된다.

  • breaker가 열린 뒤의 CallNotPermittedException은 재시도하지 않는다. Retry의 ignoreExceptions에 넣지 않으면 열린 breaker에 대고 같은 실패를 몇 번 더 받는다.
  • 계층마다 재시도하면 곱으로 늘어난다. 화면 서버, 업무 서버, 연계 서버가 각각 3번씩 시도하면 원래 요청 1건이 대상에 최대 27건으로 도착한다. 재시도는 한 계층에서만 하고, 간격에 지수 백오프와 무작위 지연을 준다.
  • 저장·결제처럼 두 번 실행되면 안 되는 호출은 멱등 키 없이 재시도하지 않는다.

Bulkhead로 자원을 나눈다

breaker가 열리기 전, 판정에 필요한 호출이 쌓이는 동안에도 스레드는 묶인다. 의존 대상별로 동시 호출 수(maxConcurrentCalls)를 제한하면 주소 검색이 느려져도 신청 저장에 쓸 스레드가 남는다.

fallback이 장애를 숨기지 않게 한다

fallback은 사용자를 지키는 장치이지 장애를 덮는 장치가 아니다. 대체 응답이 너무 자연스러우면 운영자는 한참 뒤에야 연계가 죽은 것을 안다.

@CircuitBreaker(name = "addressApi", fallbackMethod = "searchFallback")
public AddressResult search(String keyword) {
    return addressClient.search(keyword);
}

// breaker가 열려 호출 자체를 막은 경우
private AddressResult searchFallback(String keyword, CallNotPermittedException e) {
    fallbackCounter("open").increment();
    return AddressResult.manualInput("주소 검색이 잠시 지연되고 있습니다. 주소를 직접 입력해 주세요.");
}

// 호출은 나갔지만 연결·타임아웃·5xx로 실패한 경우 (입력 오류 예외는 그대로 전파)
private AddressResult searchFallback(String keyword, RestClientException e) {
    log.warn("addressApi 호출 실패 keyword길이={}", keyword.length(), e);
    fallbackCounter("error").increment();
    return AddressResult.manualInput("주소 검색을 쓸 수 없습니다. 주소를 직접 입력해 주세요.");
}

세 가지를 지킨다

  • 원인을 나눠 남긴다. Resilience4j는 예외 타입이 가장 가깝게 맞는 fallback을 고른다. 차단과 실패를 다른 메서드로 받아 로그와 지표를 따로 쌓는다.
  • 사용자에게 사실대로 말한다. "지연되고 있습니다, 직접 입력해 주세요"처럼 무엇이 안 되고 무엇을 하면 되는지 쓴다. 빈 목록을 정상 결과처럼 돌려주면 사용자는 "검색 결과 없음"으로 오해한다.
  • 업무 오류는 fallback으로 받지 않는다. 입력 검증 실패는 그대로 400으로 돌려준다. Exception 하나로 다 받으면 버그까지 강등 응답 뒤에 숨는다.

상태를 보이게 한다

  • Micrometer와 Actuator를 함께 쓰면 resilience4j.circuitbreaker.state, resilience4j.circuitbreaker.calls, resilience4j.circuitbreaker.failure.rate 같은 지표가 나온다. OPEN 전환과 not_permitted 호출 수에 알림을 건다.
  • /actuator/circuitbreakerevents로 최근 상태 전환과 실패 이벤트를 볼 수 있다. 장애 회고 때 "언제 열렸고 언제 닫혔나"를 여기서 확인한다.
  • breaker 상태를 health 지표에 넣으면 설정에 따라 OPEN일 때 인스턴스가 DOWN으로 보인다. 외부 연계 장애로 내 인스턴스가 로드밸런서에서 빠지면 강등 대신 전면 장애가 된다. liveness·readiness 그룹에는 넣지 않는다.

장애를 일부러 일으켜 시험한다

설정 값은 실제로 열리고 닫히는 것을 보기 전까지 가설이다. 개발·검증 환경에서 장애를 주입해 시나리오마다 결과를 확인한다.

시나리오 주입 방법(예) 기대 결과
응답 지연 WireMock 고정 지연 3초, Toxiproxy latency 느린 호출 비율로 OPEN, 화면은 직접 입력 칸
서버 오류 WireMock 500 응답 실패율로 OPEN, 오류 로그와 error 지표 증가
연결 거부 대상 컨테이너 중지 연결 타임아웃 안에 실패, OPEN
회복 주입 해제 대기 시간 뒤 HALF_OPEN, 시험 호출 성공 후 CLOSED
업무 오류 잘못된 검색어 다량 breaker는 CLOSED 유지, 사용자는 400

시험하면서 볼 것은 세 가지다. 다른 기능의 응답 시간이 유지되는지, 화면 문구가 강등 단계표대로 나오는지, 알림이 운영자에게 가는지. 단위 테스트에서는 CircuitBreakerRegistry에서 인스턴스를 꺼내 transitionToOpenState()로 상태를 강제로 바꾸고 fallback 경로를 확인할 수 있다.

자주 하는 실수

  • 타임아웃 없이 breaker만 붙인다. 끝나지 않는 호출은 기록되지 않아 breaker가 열리지 않는다.
  • 모든 예외를 실패로 센다. 사용자 입력 오류가 몰리면 멀쩡한 대상을 차단한다.
  • 열린 breaker에 재시도한다. CallNotPermittedException을 재시도하면 지연만 늘고 결과는 같다.
  • fallback이 빈 값을 정상처럼 돌려준다. 사용자는 오해하고 운영자는 장애를 늦게 안다.
  • 인증·결제까지 강등한다. 의존 대상 장애가 우회 통로가 된다.

점검표

  • 기능마다 핵심·부가 등급과 강등 방식을 표로 정했고 업무 담당자가 확인했다
  • 인증·권한·결제는 장애 시 거절(fail closed)하도록 정했다
  • 모든 외부 호출에 연결·읽기 타임아웃이 있다
  • 읽기 타임아웃이 slowCallDurationThreshold보다 길다
  • recordExceptions와 ignoreExceptions에 업무 오류와 장애를 나눠 적었다
  • 재시도는 한 계층에서만 하고 CallNotPermittedException은 재시도하지 않는다
  • 의존 대상별로 Bulkhead 동시 호출 수를 정했다
  • fallback이 차단과 실패를 나눠 로그·지표를 남기고 사용자에게 사실대로 안내한다
  • OPEN 전환에 알림이 걸려 있고 breaker 상태가 readiness에 들어가지 않는다
  • 지연·오류·연결 거부·회복 시나리오를 장애 주입으로 확인했다

장애를 없앨 수는 없지만 번지는 범위는 설계할 수 있다. 무엇을 포기할지는 기능별 강등 표로 미리 정하고, 그 결정은 타임아웃과 Circuit Breaker가 실행하며, fallback은 사용자에게 사실을 말하고 운영자에게 흔적을 남겨야 한다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기