[LG CNS AM Inspire Camp 1기] MSA (8) - 서킷 브레이커

정성엽·2025년 3월 15일

LG CNS AM Inspire 1기

목록 보기
62/70
post-thumbnail

INTRO

이전 포스팅에서는 Kafka를 이용해서 마이크로서비스 간 데이터 일관성 문제를 해결하는 방법에 대해 살펴봤다.

이번 포스팅에서는 마이크로서비스 아키텍처에서 또 다른 중요한 패턴인 서킷 브레이커(Circuit Breaker)에 대해 알아보고, 실제로 적용해보자 👀


1. 마이크로서비스의 통신 연쇄 오류 문제

마이크로서비스 아키텍처에서는 위 그림처럼 여러 서비스가 API를 통해 통신하는 구조를 가진다.

이런 구조에서는 한 서비스의 장애가 다른 서비스로 전파되는 문제가 발생할 수 있다.

예를 들어 위 사진에서 Order-Service의 getOrders()라는 부분에서만 오류가 발생했다고 해보자.

하지만, 이 오류는 계속 서비스를 타고 올라가서 결국 사용자에게도 에러가 발생했음을 보여줘버린다.

우리는 내부적으로 에러가 발생했음에도 사용자에게는 보여주지 않아야한다.

먼저, 실제로 정상적인 데이터를 가져오지 못하는 에러가 발생하더라도 Fallback Method를 사용해서 사용자에게는 정상적인 응답을 보여주는 방법을 알아보자 👀

💡 오류가 발생했는데 어떻게 정상 응답을 줄 수 있을까?

위 내용만 들었을 때는 이런 의문이 들 수 있다.

실제로 정상적인 데이터를 가져오지 못한다면 어떻게 사용자에게 응답을 줄 수 있을까?

전략

대체 메시지 제공

  • 오더 서비스를 일시적으로 가져올 수 없다는 안내 메시지를 보여준다. (예: "잠시 후에 다시 시도해주세요")

캐싱된 데이터 사용

  • 유저 서비스에서 이전에 Order Service에서 데이터를 한번이라도 가져왔다면 그 데이터를 캐싱해놓고 사용한다. 최신 데이터는 아니지만, 사용자는 완전히 빈 응답보다는 약간 오래된 데이터라도 볼 수 있다.

기본값 반환

  • 데이터를 가져오지 못할 경우 빈 리스트 같은 기본값을 반환한다.

중요한 것은 실제로 정상적인 데이터가 아니더라도 사용자에게 에러 메시지를 보여주는 것보다 대체 응답을 제공하는 것이 더 나은 사용자 경험을 제공한다는 점이다.

이러한 패턴을 MSA에서는 회복성(Resilience) 혹은 회복력(Fault Tolerance)이라고 부른다!

💡 간단한 Try-Catch로 구현하기

가장 기본적인 방법으로 try-catch 문을 사용해서 오류를 처리할 수 있다.

Sample Code

@Override
public UserDto getUserByUserId(String userId) {
    UserEntity userEntity = userRepository.findByUserId(userId);

    if (userEntity == null)
        throw new UsernameNotFoundException("User not found");

    UserDto userDto = new ModelMapper().map(userEntity, UserDto.class);

    log.info("Before call orders microservice");
    List<ResponseOrder> ordersList = new ArrayList<>();

    try {
        ordersList = orderServiceClient.getOrders(userId);
    } catch (FeignException ex) {
        log.error(ex.getMessage());
    }

    userDto.setOrders(ordersList);

    log.info("After called orders microservice");

    return userDto;
}

위 코드에서는 orderServiceClient.getOrders(userId) 호출이 실패하더라도 try-catch를 통해 예외를 잡아내고, 빈 ordersList를 사용하여 응답을 생성한다.

이렇게 하면 사용자는 주문 내역이 비어있는 응답을 받게 되지만, 서비스 자체는 정상적으로 작동한다.

하지만 이 방식은 매우 기본적인 방법이고, 더 복잡한 장애 상황에서는 당연히 부족할 수 있다.

이럴 때 서킷 브레이커 패턴을 사용할 수 있다.


2. 서킷 브레이커 패턴

서킷 브레이커는 다음 3가지 상태를 가진다

서킷 브레이커 상태

Closed (닫힘)

  • 정상 상태로, 모든 요청이 대상 서비스로 전달된다.

Open (열림)

  • 장애 상태로, 요청이 대상 서비스로 전달되지 않고 즉시 폴백(fallback) 메서드가 실행된다.

Half-Open (반열림)

  • 일정 시간 후 서킷 브레이커가 오픈된 상태에서 일부 요청을 시험적으로 대상 서비스에 전달해보는 상태이다.
  • 이 요청들이 성공하면 서킷 브레이커는 다시 Closed 상태로 돌아가고, 실패하면 다시 Open 상태가 된다.

즉, 에러가 임계치 이상으로 자주 발생하면 중간에 서킷브레이커가 동작하여 요청을 뒷단으로 넘기지 않고, 서킷브레이커 선에서 정리하는 방식으로 동작한다.

서킷 브레이커는 단순히 오류가 많이 발생하는 경우 요청을 모두 차단하는 것이 아니라, 실제로는 복구를 위해서 요청을 시험적으로 전달하며 서비스의 복구 여부를 판단한다.

현재 스프링 클라우드 생태계에서는 Resilience4j 가 많이 사용되고 있다.

기존에 사용되던 Netflix Hystrix 가 유지보수 모드로 전환되면서 Resilience4j가 새로운 대안으로 제시된 추세다.

Resilience4j 는 서킷 브레이커 외에도 Retry, Bulkhead, RateLimiter, TimeLimiter 등 다양한 회복성 패턴을 제공한다.


3. Resilience4j 적용하기

이제 실제로 Resilience4j 를 스프링 부트 프로젝트에 적용해보자

우선 위 사진과 같이 Resilience4j 의존성을 프로젝트에 추가하자

💡 서킷 브레이커 설정

우선, Resilience4j 서킷 브레이커를 설정하는 코드를 살펴보자

Sample Code

@Configuration
public class Resilience4JConfig {
    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> globalCustomConfiguration() {
        CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
                .failureRateThreshold(4)
                .waitDurationInOpenState(Duration.ofMillis(1000))
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
                .slidingWindowSize(2)
                .build();

        TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom()
                .timeoutDuration(Duration.ofSeconds(4))
                .build();

        return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
                .timeLimiterConfig(timeLimiterConfig)
                .circuitBreakerConfig(circuitBreakerConfig)
                .build()
        );

    }

    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> specificCustomConfiguration1() {
        CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
                .failureRateThreshold(6).waitDurationInOpenState(Duration.ofMillis(1000))
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
                .slidingWindowSize(3).build();

        TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom()
                .timeoutDuration(Duration.ofSeconds(4)).build();

        return factory -> factory.configure(builder -> builder.circuitBreakerConfig(circuitBreakerConfig)
                .timeLimiterConfig(timeLimiterConfig).build(), "circuitBreaker1");
    }

    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> specificCustomConfiguration2() {
        CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
                .failureRateThreshold(8).waitDurationInOpenState(Duration.ofMillis(1000))
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
                .slidingWindowSize(4).build();

        TimeLimiterConfig timeLimiterConfig = TimeLimiterConfig.custom()
                .timeoutDuration(Duration.ofSeconds(4)).build();

        return factory -> factory.configure(builder -> builder.circuitBreakerConfig(circuitBreakerConfig)
                        .timeLimiterConfig(timeLimiterConfig).build(),
                "circuitBreaker2");
    }
}

뭔가 복잡해보이지만, 실제로 서킷 브레이커 패턴을 2개 등록해서 그렇게 보이는 것 뿐이다.

코드를 살펴보면 다음과 같다.

간단하게 설정 정보를 정의하고, 이를 리턴할 때 적용해주는 것이다.

글로벌로 설정할 때는 리턴 과정에서 configureDefault 를 호출하여 설정하고, 특정 조건으로 사용할 서킷 브레이커는 configure 메서들르 호출하여 정의하고 id값을 추가한다는 것을 기억하자

보면 알겠지만, Global로 정의한 것과 Specific으로 등록한 서킷 브레이커 사이에는 이 차이점 말고는 다를게 없다.

💡 서킷 브레이커 사용하기

이제 서킷 브레이커를 서비스에 적용하는 코드는 다음과 같다.

Sample Code

@Service
@Slf4j
public class UserServiceImpl implements UserService {

    private UserRepository userRepository;
    private OrderServiceClient orderServiceClient;
    private CircuitBreakerFactory circuitBreakerFactory;

    @Autowired
    public UserServiceImpl(UserRepository userRepository, 
                          OrderServiceClient orderServiceClient,
                          CircuitBreakerFactory circuitBreakerFactory) {
        this.userRepository = userRepository;
        this.orderServiceClient = orderServiceClient;
        this.circuitBreakerFactory = circuitBreakerFactory;
    }

    @Override
    public UserDto getUserByUserId(String userId) {
        UserEntity userEntity = userRepository.findByUserId(userId);

        if (userEntity == null)
            throw new UsernameNotFoundException("User not found");

        UserDto userDto = new ModelMapper().map(userEntity, UserDto.class);

        log.info("Before call orders microservice");
        List<ResponseOrder> ordersList = new ArrayList<>();

        // CircuitBreaker 사용
        CircuitBreaker circuitBreaker = circuitBreakerFactory.create("circuitBreaker1");
        ordersList = circuitBreaker.run(() -> orderServiceClient.getOrders(userId),
                throwable -> new ArrayList<>());

        userDto.setOrders(ordersList);

        log.info("After called orders microservice");

        return userDto;
    }

    // 다른 메서드들...
}

한가지 기억할 부분은 circuitBreakerFactory 라는 클래스를 의존성 주입받아 사용하고 있다는 것이다.

여기에는 우리가 이전에 설정한 정보들이 모두 저장된 상태이므로, 코드에서 확인할 수 있다시피 .create("Circuit Breaker ID"); 를 통해서 Circuit Breaker를 적용할 수 있다.

위 코드에서 circuitBreaker.run() 메서드의 첫 번째 인자는 실행할 코드, 두 번째 인자는 장애 발생 시 실행할 폴백 함수이다.

즉, 오더 서비스 호출이 실패하면 빈 리스트를 반환하도록 설정했다.

위 코드를 통해 어떻게 서킷 브레이커를 설정하고, 어떻게 호출해서 사용하는지를 알아두면 괜찮을 것 같다.


OUTRO

이번 포스팅에서는 마이크로서비스 아키텍처에서 회복성을 확보하기 위한 서킷 브레이커 패턴에 대해 알아보았다.

try-catch를 이용한 기본적인 오류 처리부터 Resilience4j를 활용한 서킷 브레이커 구현까지 다양한 방법을 살펴봤다.

서킷 브레이커 패턴은 MSA 환경에서 한 서비스의 장애가 다른 서비스로 전파되는 것을 방지하고, 사용자에게 더 나은 경험을 제공하는 중요한 패턴이다.

마이크로서비스 아키텍처를 설계할 때는 기능 구현뿐만 아니라 이런 회복성 패턴도 함께 고려하는 것이 중요하다는 것을 기억하자 👊

profile
코린이

0개의 댓글