서킷 브레이커 적용기: 장애 전파, 너는 이제 그만!

이동휘·2025년 5월 21일
0

매일매일 블로그

목록 보기
16/49

서비스가 확장되고 여러 기능이 모듈화되면서, 우리 애플리케이션은 점점 더 많은 외부 서비스 또는 내부 공통 플랫폼과 통신하게 됩니다. 이때 한 가지 중요한 고민이 생깁니다. "만약 내가 호출하는 저 서비스가 갑자기 느려지거나 장애가 나면, 내 서비스는 괜찮을까?"

안타깝게도 동기 방식으로 연동된 시스템에서 하나의 장애는 쉽게 다른 곳으로 전파(Cascading Failure)됩니다. 이 글에서는 이러한 장애 전파를 막기 위한 강력한 패턴인 서킷 브레이커(Circuit Breaker)를, 마치 개발자가 실제로 고민하고 적용하는 듯한 '의식의 흐름'을 따라 쉽고 재미있게 알아보겠습니다.


1. 문제 인식: "공통 플랫폼님, 아프지 마세요... 제발!"

서비스 규모가 커지면 중복 기능을 통합하여 공통 플랫폼을 만드는 것은 비용 효율화 측면에서 자연스러운 흐름입니다. 검색, 인증, 알림 등 많은 기능이 플랫폼으로 이전되죠. 하지만 이 편리함 뒤에는 숨겨진 위험이 있습니다.

만약 그 공통 플랫폼에 버그가 생기거나 장애가 발생한다면?

우리 서비스는 해당 플랫폼의 기능을 사용하기 위해 동기적으로 호출하는 경우가 많습니다. 예를 들어, 상품 상세 페이지에서 실시간 리뷰 정보를 가져오거나, 특정 카테고리의 상품 목록을 조회하는 것처럼요. 이런 동기 호출은 즉각적인 정보를 얻을 수 있다는 장점이 있지만, 플랫폼 장애 시 우리 서비스까지 함께 수렁에 빠뜨리는 강력한 의존성장애 전파라는 치명적인 단점을 안고 있습니다.

물론 비동기 연동이 가능하다면 직접적인 장애 전파는 막을 수 있겠지만, 실시간 정보가 필수적이거나, 비동기 처리의 복잡도, 혹은 플랫폼 개발 우선순위 등의 이유로 항상 비동기 전환이 가능한 것은 아닙니다.

🤔 꼬리 질문: 여러분의 서비스는 외부 시스템과 주로 어떤 방식으로 연동하고 있나요? 동기 방식의 장단점, 비동기 방식의 장단점에 대해 실제 경험을 바탕으로 이야기해 볼 수 있을까요?

자, 그렇다면 이 동기 호출 상황에서 장애 전파를 막을 방법은 없을까요? 바로 이때 등장하는 해결사가 서킷 브레이커(Circuit Breaker)입니다!


2. 서킷 브레이커란 무엇일까? (feat. 두꺼비집)

"서킷 브레이커"라는 이름, 어디서 많이 들어보지 않았나요? 맞습니다! 우리 집에 있는 두꺼비집(분전반)의 누전차단기 또는 회로차단기가 바로 그것입니다. 전기 회로에 과부하가 걸리거나 누전이 발생하면 자동으로 전기를 차단하여 더 큰 사고를 막아주죠.

소프트웨어 세계에서의 서킷 브레이커도 비슷한 역할을 합니다. 서로 다른 시스템 간 연동 시, 호출 대상 시스템에서 문제가 감지되면 일시적으로 호출을 차단하고, 이후 대상 시스템이 정상으로 돌아오면 자동으로 호출을 재개하여 장애 전파를 막는 기술입니다.

◼️ 서킷 브레이커의 상태 변화: 똑똑한 자동 제어 시스템

서킷 브레이커는 주로 다음과 같은 3가지 상태를 가지며, 이 상태에 따라 다르게 동작합니다.

  1. CLOSED (닫힘): 정상 상태입니다. 모든 요청이 정상적으로 외부 시스템으로 전달됩니다. 이때, 실패하는 호출이 있는지 계속 모니터링합니다.
  2. OPEN (열림): 장애 상태입니다. 정해진 기준(예: 최근 N번 호출 중 실패율 X% 이상)을 초과하여 오류가 발생하면 서킷이 열립니다. 이 상태에서는 외부 시스템으로 실제 요청을 보내지 않고, 즉시 에러를 반환하거나 미리 정의된 대체 응답(Fallback)을 제공합니다. 이를 통해 장애가 발생한 시스템에 불필요한 부하를 주지 않고, 호출하는 시스템의 자원도 보호합니다.
  3. HALF_OPEN (반열림): 복구 시도 상태입니다. OPEN 상태에서 일정 시간이 지나면, 서킷은 HALF_OPEN 상태로 전환되어 소수의 테스트 요청을 외부 시스템으로 보냅니다.
    • 이 테스트 요청이 성공하면, "아, 이제 괜찮아졌나 보다!" 하고 CLOSED 상태로 전환합니다.
    • 만약 테스트 요청마저 실패하면, "아직 멀었군..." 하며 다시 OPEN 상태로 돌아가 대기합니다.

◼️ 상태 전이의 기준: 슬라이딩 윈도우 (Sliding Window)

서킷 브레이커가 상태를 변경할지 여부는 슬라이딩 윈도우라는 메커니즘을 통해 결정됩니다.

  • 슬라이딩 윈도우 방식:
    • COUNT_BASED: 최근 N번의 호출 결과를 기준으로 판단합니다.
    • TIME_BASED: 최근 N초 동안의 호출 결과를 기준으로 판단합니다.
  • 동작 원리: 이 윈도우 내에서 발생한 호출들의 성공/실패율을 계산하여, 미리 설정된 임계치(예: 실패율 50% 이상)를 넘어서면 상태를 CLOSED에서 OPEN으로 전환합니다.

🤔 꼬리 질문: COUNT_BASED와 TIME_BASED 슬라이딩 윈도우는 각각 어떤 상황에 더 유리할까요? 예를 들어, 호출 빈도가 매우 불규칙한 서비스의 경우 어떤 방식을 선택하는 것이 좋을까요?

◼️ Resilience4j: 믿음직한 서킷 브레이커 라이브러리

Java 진영에서는 Resilience4j라는 훌륭한 라이브러리를 사용하여 서킷 브레이커를 손쉽게 구현할 수 있습니다. Resilience4j는 Netflix의 Hystrix(현재 유지보수 모드)로부터 영감을 받아 함수형 프로그래밍 스타일로 설계된 경량의 내결함성(Fault Tolerance) 라이브러리입니다. 서킷 브레이커 외에도 Rate Limiter, Retry, Bulkhead, TimeLimiter 등 다양한 장애 대응 패턴을 지원합니다.


3. 개발자 의식의 흐름 따라 서킷 브레이커 적용해보기 (with Resilience4j)

자, 이제 이론은 충분히 알았으니 실제 코드에 서킷 브레이커를 적용해 봅시다! 마치 개발자가 하나하나 고민하며 기능을 추가하는 것처럼 그 과정을 따라가 보겠습니다.

1️⃣ 의존성 추가: "일단 라이브러리부터 깔고 보자!"

가장 먼저 할 일은 프로젝트에 Resilience4j 의존성을 추가하는 것입니다. Spring Boot를 사용한다면 resilience4j-spring-boot3(또는 사용 중인 버전에 맞는) 아티팩트를 추가하면 됩니다.

// build.gradle.kts 예시
implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0") // 버전 확인 필수
implementation("org.springframework.boot:spring-boot-starter-aop") // AOP 기반 동작 시 필요

(Maven 사용 시에는 해당 XML 의존성을 추가하면 됩니다.)

2️⃣ 서킷 브레이커 적용: "@CircuitBreaker 어노테이션이면 되겠지?"

Resilience4j는 @CircuitBreaker 어노테이션을 사용하여 특정 메서드에 서킷 브레이커를 매우 간단하게 적용할 수 있도록 지원합니다.

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;
// ... (기타 import)

@Slf4j // Lombok 사용 시
@Service
public class ExternalPlatformClient {

    private static final String CIRCUIT_BREAKER_NAME = "externalPlatform";

    // 외부 플랫폼을 호출하는 메서드
    @CircuitBreaker(name = CIRCUIT_BREAKER_NAME, fallbackMethod = "callExternalPlatformFallback")
    public PlatformResponseDto callExternalPlatform(RequestDto request) {
        log.info("Calling external platform with: {}", request);
        // 실제 외부 플랫폼 호출 로직 (예: RestTemplate, FeignClient 등)
        // if (호출_실패_조건) { throw new RuntimeException("External platform call failed!"); }
        return new PlatformResponseDto("Success from platform!");
    }

    // callExternalPlatform 메서드 실패 시 호출될 Fallback 메서드
    private PlatformResponseDto callExternalPlatformFallback(RequestDto request, Throwable t) {
        log.warn("Fallback for callExternalPlatform. request: {}, error: {}", request, t.getMessage());
        // 미리 정의된 기본 응답 반환 또는 캐시된 데이터 사용 등의 로직
        return new PlatformResponseDto("Fallback response due to: " + t.getMessage());
    }

    // 서킷이 OPEN 상태일 때 발생하는 CallNotPermittedException에 대한 특화된 Fallback (선택 사항)
    private PlatformResponseDto callExternalPlatformFallback(RequestDto request, 
                                                             io.github.resilience4j.circuitbreaker.CallNotPermittedException e) {
        log.warn("[CircuitBreaker OPEN] Fallback for callExternalPlatform. request: {}, error: {}", 
                 request, e.getMessage());
        return new PlatformResponseDto("Circuit is OPEN! Fallback response.");
    }
}
  • name: 서킷 브레이커의 고유한 이름을 지정합니다. 이 이름을 기준으로 설정을 적용합니다.
  • fallbackMethod: 서킷 브레이커가 적용된 메서드에서 예외가 발생했을 때 호출될 대체 메서드를 지정합니다. Fallback 메서드는 원본 메서드와 동일한 반환 타입을 가져야 하며, 파라미터로는 원본 메서드의 파라미터와 발생한 예외(Throwable)를 추가로 받을 수 있습니다.
  • 주의! Fallback 메서드 동작: Fallback 메서드는 서킷 브레이커의 상태(CLOSED, OPEN)와 관계없이, @CircuitBreaker가 적용된 메서드 자체에서 예외가 발생하면 항상 호출됩니다. 따라서 Fallback 메서드가 실행되었다고 해서 반드시 서킷이 OPEN 상태인 것은 아닙니다.
  • Exception 전파: Fallback 메서드가 실행되면, 원본 메서드에서 발생한 예외는 상위 호출자로 전파되지 않습니다. 기존에 예외 처리를 하던 코드가 있다면 이 점을 고려하여 수정해야 합니다.

🤔 꼬리 질문: Fallback 메서드에서는 어떤 종류의 응답을 돌려주는 것이 사용자 경험에 가장 좋을까요? 항상 동일한 기본값을 반환해야 할까요, 아니면 상황에 따라 다른 캐시된 데이터를 보여주는 것이 좋을까요?

3️⃣ 상태 변화 로깅: "지금 서킷 상태가 어떻게 되는 거지? 로그 좀 보자!"

서킷 브레이커가 잘 동작하는지, 현재 상태가 어떤지 알려면 로그가 필수입니다. Resilience4j는 이벤트 컨슈머(Event Consumer)를 통해 서킷 브레이커의 다양한 이벤트(상태 전이, 오류 발생 등) 발생 시 특정 동작을 수행하도록 설정할 수 있습니다.

import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.core.registry.EntryAddedEvent;
import io.github.resilience4j.core.registry.RegistryEventConsumer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Slf4j
@Configuration
public class CircuitBreakerLoggingConfiguration {

    @Bean
    public RegistryEventConsumer<CircuitBreaker> circuitBreakerEventConsumer() {
        return new RegistryEventConsumer<CircuitBreaker>() {
            @Override
            public void onEntryAddedEvent(EntryAddedEvent<CircuitBreaker> entryAddedEvent) {
                CircuitBreaker circuitBreaker = entryAddedEvent.getAddedEntry();
                log.info("CircuitBreaker '{}' added.", circuitBreaker.getName());

                circuitBreaker.getEventPublisher()
                    .onStateTransition(event ->
                        log.info("CircuitBreaker '{}' state changed from {} to {}",
                                event.getCircuitBreakerName(),
                                event.getStateTransition().getFromState(),
                                event.getStateTransition().getToState())
                    )
                    .onFailureRateExceeded(event ->
                        log.warn("CircuitBreaker '{}' failure rate {}% exceeded threshold.",
                                event.getCircuitBreakerName(),
                                event.getFailureRate())
                    )
                    .onError(event ->
                        log.error("CircuitBreaker '{}' recorded an error.",
                                event.getCircuitBreakerName(), event.getThrowable())
                    )
                    .onCallNotPermitted(event ->
                        log.warn("CircuitBreaker '{}' call not permitted in state {}.",
                                event.getCircuitBreakerName(), circuitBreaker.getState()) // 현재 상태 확인
                    );
            }
            // onEntryRemovedEvent, onEntryReplacedEvent는 필요시 구현
        };
    }
}

이제 서킷 브레이커의 상태가 변경되거나 주요 이벤트가 발생할 때마다 로그가 기록되어 동작을 명확히 확인할 수 있습니다.

4️⃣ 설정값 튜닝: "우리 서비스에 맞는 최적의 값은 뭘까?"

로그를 통해 서킷 브레이커의 동작을 확인했다면, 이제 우리 서비스 환경에 맞게 설정을 튜닝할 차례입니다. Resilience4j의 주요 설정값은 다음과 같습니다.

  • failureRateThreshold: 실패율 임계값 (%). 슬라이딩 윈도우 내에서 이 비율 이상 실패 시 OPEN 상태로 전환. (기본값: 50)
  • slidingWindowSize: 슬라이딩 윈도우의 크기. (기본값: 100)
  • slidingWindowType: COUNT_BASED 또는 TIME_BASED. 윈도우 크기의 단위를 결정. (기본값: COUNT_BASED)
  • minimumNumberOfCalls: 실패율 및 성공률을 계산하기 위한 최소 호출 횟수. 이 횟수만큼 호출이 누적되기 전까지는 서킷이 열리지 않음. (기본값: 100)
  • waitDurationInOpenState: OPEN 상태를 유지하는 시간. 이 시간이 지나면 HALF_OPEN으로 전환. (기본값: 60000ms = 60초)
  • permittedNumberOfCallsInHalfOpenState: HALF_OPEN 상태에서 허용할 테스트 호출 횟수. (기본값: 10)

설정 예시 (application.yml):

resilience4j:
  circuitbreaker:
    configs:
      default: # 모든 서킷 브레이커에 적용될 기본 설정
        failureRateThreshold: 50
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 10
        minimumNumberOfCalls: 5 # 최소 5번 호출 후 판단 시작
        waitDurationInOpenState: 10s # OPEN 상태 10초 유지
        permittedNumberOfCallsInHalfOpenState: 3
        # 예외 무시 또는 기록 설정 등도 가능
        # ignoreExceptions:
        #   - com.example.MyBusinessException
        # recordExceptions:
        #   - java.io.IOException
    instances:
      externalPlatform: # "externalPlatform" 이름을 가진 서킷 브레이커에 대한 개별 설정
        baseConfig: default # default 설정을 상속
        failureRateThreshold: 60 # 실패율 임계값을 60%로 변경
        # ... 기타 필요한 설정 오버라이드

이러한 설정값들은 서비스의 특성, 호출 대상 시스템의 응답 시간, 장애 발생 빈도 등을 고려하여 신중하게 조정해야 합니다.

🤔 꼬리 질문: minimumNumberOfCalls 설정은 왜 중요할까요? 만약 이 값을 너무 작거나 크게 설정하면 어떤 문제가 발생할 수 있을까요?

5️⃣ 모니터링 대시보드 구축: "한눈에 보고 싶다!"

운영 환경에서는 터미널 로그만 계속 들여다볼 수는 없습니다. 서킷 브레이커의 현재 상태, 실패율, 지연 시간 등의 메트릭을 시각적으로 보여주는 대시보드가 필수적입니다.

Resilience4j는 Micrometer와의 통합을 통해 다양한 메트릭을 Prometheus와 같은 모니터링 시스템으로 손쉽게 전송할 수 있습니다. 그리고 이 데이터를 Grafana를 사용하여 멋진 대시보드로 시각화할 수 있습니다. (이 부분은 "로그와 메트릭" 글에서 다룬 Micrometer 내용을 참고하시면 좋습니다.)


4. 장애? 멈춰! 서킷 브레이커는 만능일까? ✋

지금까지 개발자의 의식의 흐름을 따라 서킷 브레이커를 적용해 보았습니다. 생각보다 손쉽게 구현할 수 있죠?

모든 시스템은 장애의 가능성을 안고 살아갑니다. 서킷 브레이커와 같은 장애 전파 방지 조치는 결코 특정 시스템에 대한 불신이나 과도한 걱정(Over-engineering)이 아닙니다. 오히려 안정적인 서비스를 위한 기본적인 방어 수단이라고 생각해야 합니다.

물론 서킷 브레이커가 모든 장애 상황을 해결해 주는 만능 열쇠는 아닙니다. 데이터베이스 이중화, 메시지 큐를 이용한 비동기 처리, 타임아웃 및 재시도(Retry) 패턴 등 다른 장애 대응 전략과 함께 사용될 때 더욱 강력한 힘을 발휘합니다.

단순히 try-catch로 모든 예외를 처리하는 것과 비교했을 때, 서킷 브레이커는 다음과 같은 운영상의 명확한 이점을 제공합니다.

  • 장애 발생 시 빠르게 실패를 인지하고 전파를 차단(Fail-fast)하여 시스템 전체의 안정성을 높입니다.
  • 호출 대상 시스템이 정상화되면 자동으로 호출을 재개하여 수동 개입 없이 서비스가 복구될 수 있도록 돕습니다.

수정해야 할 코드가 조금 더 늘어날 수는 있지만, 그로 인해 얻는 안정성과 운영 효율성은 훨씬 클 것입니다.


이 글이 동기 방식 외부 연동 시 발생할 수 있는 장애 전파 문제에 대해 고민하고, 서킷 브레이커라는 강력한 도구를 여러분의 서비스에 적용해 보는 계기가 되었기를 바랍니다. 안정적인 서비스를 향한 여정에 이 글이 작은 도움이 되었으면 좋겠습니다!

0개의 댓글