conPig.md

[Spring] MSA - 서비스 디스커버리, 로드 밸런싱, 서킷 브레이커 본문

Spring/MSA

[Spring] MSA - 서비스 디스커버리, 로드 밸런싱, 서킷 브레이커

piggyback 2026. 7. 21. 21:29

MSA에서는 하나의 기능을 여러 서비스가 나누어 담당하기 때문에 서비스 간 호출이 자연스럽게 발생한다. 이 과정에서 다음과 같은 문제가 생기기도 한다.

  • 호출하려는 서비스는 어디에 있는가?
  • 같은 서비스를 실행하는 여러 인스턴스 중 요청은 어디로 보내야 하는가?
  • 호출 대상 서비스에 장애가 발생하면 어떻게 대응해야 하는가?

Spring Cloud는 이러한 문제를 해결하기 위해 서비스 디스커버리(Eureka), 로드 밸런싱(FeignClient + Ribbon), 서킷 브레이커(Resilience4j) 등의 기능을 제공한다.

 

1. 서비스 디스커버리

 MSA에서는 서비스 인스턴스가 동적으로 늘어나거나 줄어들고, 컨테이너나 클라우드 환경에서는 서비스가 재배포될 때 IP 주소와 포트가 변경되기도 한다.

이러한 환경에서 호출하는 서비스가 상대 서비스의 주소를 코드에 직접 작성하면 주소가 바뀔 때마다 코드를 수정해야 한다.

서비스 디스커버리는 각 서비스의 위치를 동적으로 등록하고 조회하게 해 준다.

  • 서비스 등록: 서비스가 실행될 때 자신의 위치를 등록 서버에 알린다
  • 서비스 조회: 다른 서비스가 등록 서버에서 호출 대상의 위치를 조회한다
  • 상태 관리: 일정 시간 동안 응답이 없는 인스턴스를 사용 가능한 목록에서 제외한다

 

(1) Eureka

Eureka는 넷플릭스에서 개발한 서비스 디스커버리 도구다.

 

각 서비스 인스턴스는 자신의 서비스 이름, IP 주소, 포트 등의 정보를 Eureka 서버에 등록한다. 다른 서비스는 Eureka 서버에서 호출 대상의 인스턴스 목록을 조회해 통신할 수 있다.

 

따라서 호출하는 쪽에서 상대 서비스의 IP 주소나 포트를 직접 관리할 필요가 없다.

Eureka 서버를 여러 대 구성하고 서로 피어(Peer)로 연결하면 한 서버에 장애가 발생해도 다른 서버를 통해 서비스 등록 정보와 조회 기능을 유지할 수 있다.

 

(2) Eureka 서버 설정

Eureka 서버는 서비스 인스턴스의 정보를 저장하는 중앙 레지스트리 역할을 한다.

서버 자신은 다른 Eureka 서버에 등록하거나 레지스트리 정보를 가져올 필요가 없으므로 두 옵션을 false로 설정한다.

server:
  port: 8761
eureka:
  client:
    register-with-eureka: false   # 자신을 등록하지 않음
    fetch-registry: false         # 레지스트리를 가져오지 않음
  server:
    enable-self-preservation: false  # 자기 보호 모드 비활성화

 

  • register-with-eureka: 현재 애플리케이션을 Eureka에 서비스로 등록할지를 결정한다.
  • fetch-registry: Eureka에 등록된 다른 서비스 목록을 가져올지를 결정한다.
  • enable-self-preservation: Eureka의 자기 보호 모드 사용 여부를 결정한다.
실습에서는 서비스가 종료되었을 때 등록 목록에서 빠르게 제외되는 것을 확인하기 위해 자기 보호 모드를 비활성화했다.
운영 환경에서는 네트워크 지연이나 일시적인 통신 장애로 정상 인스턴스가 대량 제거되는 것을 방지하기 위해 자기 보호 모드를 신중하게 설정해야 한다.

 

애플리케이션 클래스에는 @EnableEurekaServer를 선언한다.

@EnableEurekaServer
@SpringBootApplication
public class ServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ServerApplication.class, args);
    }
}

 

(3) Eureka 클라이언트 설정

Eureka 서버에 등록되는 각각의 서비스는 Eureka 클라이언트가 된다.

클라이언트 프로젝트에 spring-cloud-starter-netflix-eureka-client 의존성을 추가하고 애플리케이션 이름과 Eureka 서버 주소를 설정한다.

spring:
  application:
    name: my-service
eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/
    register-with-eureka: true
    fetch-registry: true

 

애플리케이션이 실행되면 my-service라는 이름으로 Eureka 서버에 자동 등록된다.

다른 서비스는 IP 주소 대신 이 서비스 이름을 기준으로 인스턴스 목록을 조회할 수 있다.

 

(4) 등록된 서비스 호출하기

Eureka에 등록된 서비스는 IP 주소나 포트 대신 서비스 이름으로 호출할 수 있다.

예를 들어 my-service라는 서비스가 등록되어 있다면 다음과 같은 형식으로 요청한다.

http://my-service/api/data

 

다만 URL에 서비스 이름을 작성하는 것만으로 호출이 이루어지는 것은 아니다.

Eureka에서 서비스 정보를 조회하고 실제 인스턴스를 선택할 수 있는 HTTP 클라이언트가 필요하다.

 

RestTemplate

RestTemplate@LoadBalanced를 적용하면 서비스 이름을 사용해 요청할 수 있다.

@Bean
@LoadBalanced
public RestTemplate restTemplate() {
    return new RestTemplate();
}
// 사용: restTemplate.getForObject("http://my-service/api/data", String.class);

 

FeignClient

FeignClient를 사용하면 인터페이스와 어노테이션만으로 서비스 호출 코드를 작성할 수 있다.

@FeignClient(name = "my-service")
public interface MyServiceClient {
    @GetMapping("/api/data")
    String getData();
}

 

직접 URL을 조합하거나 응답 변환 코드를 반복해서 작성할 필요가 없어 서비스 간 통신 코드를 간결하게 유지할 수 있다.

 

(5) 고가용성

Eureka 서버가 한 대뿐이라면 해당 서버에 장애가 발생했을 때 새로운 서비스 등록과 레지스트리 조회에 문제가 생길 수 있다.

이를 방지하기 위해 여러 Eureka 서버를 실행하고 서로를 피어로 등록하면 상호 백업이 된다.

eureka:
  client:
    service-url:
      defaultZone: http://eureka-peer1:8761/eureka/,http://eureka-peer2:8761/eureka/

 

각 Eureka 서버가 레지스트리 정보를 공유하면 하나의 서버에 장애가 발생해도 다른 서버를 통해 서비스 정보를 계속 조회할 수 있다.

 

 

2. 로드 밸런싱

로드 밸런싱은 들어오는 요청을 여러 서버 인스턴스에 분산하는 기술이다.

특정 서버에 요청이 집중되는 것을 막고, 시스템의 성능과 가용성을 높이는 데 목적이 있다.

로드 밸런싱은 크게 두 가지 방식으로 나눌 수 있다.

  • 서버 사이드 로드 밸런싱: 별도의 로드 밸런서가 요청을 받아 서버 중 하나로 전달
  • 클라이언트 사이드 로드 밸런싱: 요청을 보내는 클라이언트가 서버 목록을 알고 직접 인스턴스 선택

 

(1) FeignClient와 Ribbon

 

  • FeignClient: 인터페이스와 어노테이션으로 REST 호출을 선언할 수 있는 HTTP 클라이언트
  • Ribbon: Eureka에서 서비스 인스턴스 목록을 가져와 요청을 분산하는 클라이언트 사이드 로드 밸런서

FeignClient는 서비스 이름을 기준으로 호출 대상을 찾고, Ribbon은 조회된 여러 인스턴스 중 하나를 선택해 요청을 전달한다.

다만 Ribbon은 현재 유지보수가 종료된 기술이다. Spring Boot 3.x(최신 Spring Cloud)에는 Ribbon이 포함되어 있지 않으며, 로드 밸런싱은 Spring Cloud LoadBalancer가 대신 수행한다.

 

 

(2) 로드 밸런싱 알고리즘

로드 밸런서는 여러 인스턴스 중 하나를 선택하기 위해 알고리즘을 사용한다. 대표적인 방식은 다음과 같다.

 

  • 라운드 로빈: 인스턴스를 순차적으로 선택(기본)
  • 가중치 기반: 서버 성능에 따라 가중치를 두고 요청을 분배
  • 최소 연결: 현재 연결 수가 가장 적은 서버를 선택
  • 응답 시간 기반: 응답 속도가 빠른 서버를 우선 선택

 

(3) 실습 요약

FeignClient를 사용해 클라이언트 사이드 로드 밸런싱을 구현하여, 라운드 로빈 알고리즘을 확인해 보자.

 

  • 실습 시나리오: Order 서비스(1개)가 Product 서비스(3개)를 호출하면, FeignClient는 Ribbon을 통해 3개 중 하나를 라운드 로빈으로 골라 호출하고 응답을 받아 반환한다.

 

동작 흐름

product-service라는 서비스가 여러 인스턴스로 실행 중이라고 가정하면 FeignClient와 Ribbon은 다음 순서로 동작한다.

  1. @FeignClient(name = "product-service")에 작성된 서비스 이름을 확인한다.
  2. Eureka에서 product-service로 등록된 인스턴스 목록을 조회한다.
  3. Ribbon이 인스턴스 목록 중 하나를 선택한다.
  4. 선택한 인스턴스로 HTTP 요청을 전달한다.
  5. 다음 요청에는 다른 인스턴스를 선택해 요청을 분산한다.

Order 서비스가 Product 서비스의 세 인스턴스를 호출한다면, Order 서비스는 실제 포트 번호를 알 필요 없이 product-service라는 이름만 사용하는 것이다.

 

 

Product

자신의 포트를 응답에 담아 반환한다.

@Value("${server.port}")
private String serverPort;

@GetMapping("/product/{id}")
public String getProduct(@PathVariable String id) {
    return "Product " + id + " info From port : " + serverPort;
}

 

같은 애플리케이션을 IntelliJ의 "구성 편집"에서 복사해 VM 옵션 -Dserver.port=19092/19093/19094로 3개 실행한다.

이름은 편의상 포트 번호인 19092, 19093, 19094로 구분 지어준다.

 

 

Order

FeignClient로 Product를 호출한다.

@FeignClient(name = "product-service")
public interface ProductClient {
    @GetMapping("/product/{id}")
    String getProduct(@PathVariable("id") String id);
}

 

 

동작

 

Eureka → order → product(3개) 순으로 앱을 실행한 뒤 http://localhost:19091/order/1을 반복해서 호출해 보자.

요청을 반복할 때마다 19092, 19093, 19094가 나타난다면 요청이 여러 인스턴스에 분산되고 있다는 의미다.

 

반복 호출해도 포트가 오름차순으로 딱딱 떨어지지 않는 것은 정상이다. 라운드 로빈은 "포트 번호 순서대로"가 아니라 "인스턴스 목록 순서대로 돌아가며" 고르기 때문이다.

 

3. 서킷 브레이커

MSA에서는 하나의 서비스가 다른 서비스를 호출하며 동작한다.

호출 대상 서비스에 장애가 발생했는데도 계속 요청을 보내면 응답 대기 시간이 길어지고, 요청을 보내는 서비스의 스레드와 자원도 계속 점유된다. 이 상태가 반복되면 하나의 서비스 장애가 다른 서비스로 퍼지는 연쇄 장애가 발생할 수 있다.

 

서킷 브레이커는 일정 수준 이상의 실패를 감지하면 대상 서비스 호출을 일시적으로 차단하고 요청을 빠르게 실패 처리하여, 장애가 전체 시스템으로 확산되는 것을 막아주는 역할을 한다. 문제가 있는 서비스를 계속 호출해 자원을 낭비하지 않고, 일정 시간이 지난 뒤 제한된 요청을 통해 복구 여부를 확인한다.

 

(1) 상태

  • Closed
    • 정상 상태
    • 모든 요청을 실제 대상 메서드나 외부 서비스로 전달한다
    • 일정 횟수 이상의 요청을 관찰한 뒤 실패율이 설정한 임계값을 넘으면 Open 상태로 전환된다
  • Open
    • 호출 차단 상태
    • 대상 메서드나 외부 서비스를 실제로 호출하지 않고 즉시 실패 처리한다
    • 설정한 대기 시간이 지나면 Half-Open 상태로 전환된다
  • Half-Open
    • 서비스가 복구되었는지 확인하는 상태
    • 제한된 수의 요청만 허용하여 회복 여부를 확인한다
    • 요청이 성공하면 Closed 상태로 돌아가고 다시 실패하면 Open 상태로 전환된다

 

(2) Resilience4j

Resilience4j는 서킷 브레이커 패턴을 실제로 구현해 주는 경량 장애 대응(fault tolerance) 라이브러리다. 넷플릭스 Hystrix가 유지보수를 멈춘 뒤 그 자리를 대신하는 표준이 되었다고 한다.

 

서킷 브레이커 외에도 재시도(Retry), 속도 제한(RateLimiter), 동시 호출 제한(Bulkhead), 시간 제한(TimeLimiter) 같은 여러 모듈을 제공한다.

  • CircuitBreaker: 실패율이 일정 기준을 넘으면 호출을 차단한다
  • Retry: 실패한 요청을 일정 조건에 따라 다시 시도한다
  • RateLimiter: 일정 시간 동안 허용할 요청 수를 제한한다
  • Bulkhead: 동시에 처리할 수 있는 요청 수를 제한해 자원 고갈을 방지한다
  • TimeLimiter: 호출이 지정된 시간 안에 끝나지 않으면 타임아웃 처리한다

Resilience4j는 필요한 기능만 선택해 사용할 수 있으며, 함수나 메서드 호출을 감싸 장애 대응 로직을 적용하는 방식으로 동작한다.

이번 실습에서는 이 가운데 CircuitBreaker를 사용해 호출 실패율을 기록하고, 실패가 반복될 때 실제 메서드 호출을 일시적으로 차단하는 과정을 확인한다.

 

실습에서 사용하는 핵심 기능

 

  • @CircuitBreaker 어노테이션: 메서드에 붙이기만 하면 서킷브레이커가 적용된다
  • resilience4j: yaml 설정: 슬라이딩 윈도우, 실패율 임계값, 대기 시간 등을 선언적으로 지정
  • Fallback: 호출 실패·차단 시 실행할 대체 메서드 지정
  • CircuitBreakerRegistry + 이벤트 리스너: 상태 전환·실패율 초과 등을 실시간으로 관찰
  • Actuator/Micrometer 연동: 서킷 상태를 메트릭으로 노출해 모니터링

 

(3) 실습 준비

- 의존성 선택: Spring Cloud 추상화와 Resilience4j 직접 사용

Spring 환경에서 Resilience4j 기반 서킷 브레이커를 적용하는 방식은 크게 두 가지로 구분할 수 있다.

두 방식 모두 내부적으로 Resilience4j를 사용할 수 있지만, 애플리케이션 코드에서 어떤 API를 중심으로 사용하는지가 다르다.

 

- ① Spring Cloud CircuitBreaker 추상화 사용

start.spring.io에서 Spring Cloud의 Resilience4j Circuit Breaker 의존성을 선택하면 다음 스타터가 추가된다.

implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'

 

Spring Cloud CircuitBreaker는 특정 서킷 브레이커 구현체에 애플리케이션 코드가 직접 의존하지 않도록 공통 추상화(공통 인터페이스) API를 제공한다.

 

대표적인 인터페이스가 CircuitBreakerFactory다. Spring Cloud는 이 공통 API 뒤에 실제 구현체로 Resilience4j를 연결한다. 따라서 애플리케이션에서는 다음과 같이 실행할 로직과 Fallback을 팩토리로 감싸는 방식으로 사용할 수 있다.

 

@Service
@RequiredArgsConstructor
public class ProductService {

    private final CircuitBreakerFactory<?, ?> circuitBreakerFactory;

// 추상화 계층 방식: 어노테이션이 아니라 팩토리로 감싼다
    public Product getProduct(String productId) {
        return circuitBreakerFactory
                .create("productService")
                .run(
                        () -> getProductDetails(productId),
                        throwable -> fallback(productId, throwable)
                );
    }

    private Product getProductDetails(String productId) {
        return new Product(productId, "Sample Product");
    }

    private Product fallback(String productId, Throwable throwable) {
        return new Product(productId, "Fallback Product");
    }
}

 

이 방식의 핵심은 애플리케이션 코드가 Resilience4j의 네이티브 API보다 Spring Cloud가 제공하는 공통 API를 중심으로 작성된다는 점이다.

 

다만 Spring Cloud 스타터를 사용한다고 해서 Resilience4j의 설정이나 관련 클래스가 무조건 사용할 수 없다는 뜻은 아니다. Spring Cloud CircuitBreaker도 내부 구현체로 Resilience4j를 사용하며, 공식적으로 설정 파일을 통한 CircuitBreaker와 TimeLimiter 구성을 지원한다.

 

<참고 문서>

https://docs.spring.io/spring-cloud-circuitbreaker/reference/spring-cloud-circuitbreaker-resilience4j.html?utm_source=chatgpt.com

 

Configuring Resilience4J Circuit Breakers :: Spring Cloud Circuitbreaker

Stable

docs.spring.io

 

- ② Resilience4j Spring Boot 스타터 직접 사용

이번 실습에서는 Spring Cloud의 추상화 API를 거치지 않고, Resilience4j가 직접 제공하는 Spring Boot 스타터를 사용한다.

dependencies {
    implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
}

 

Spring Boot 3 환경이므로 resilience4j-spring-boot3 의존성을 선택해야 한다.

이 스타터는 Resilience4j의 어노테이션과 AOP 기능을 자동 설정해준다. 따라서 다음과 같이 메서드에 @CircuitBreaker를 적용할 수 있다.

@CircuitBreaker(
        name = "productService",
        fallbackMethod = "fallbackGetProductDetails"
)
public Product getProductDetails(String productId) {
    if ("111".equals(productId)) {
        throw new RuntimeException("Empty response body");
    }

    return new Product(productId, "Sample Product");
}

 

설정 파일에서는 resilience4j.circuitbreaker 프로퍼티를 사용하고, CircuitBreakerRegistry를 주입받아 특정 CircuitBreaker 인스턴스나 상태 이벤트를 직접 다룰 수 있다.

 

<참고 문서>

https://resilience4j.readme.io/docs/getting-started-3

 

Getting Started

Getting started with resilience4j-spring-boot2 or resilience4j-spring-boot3

resilience4j.readme.io

 

- 설정

yaml 파일

resilience4j:
  circuitbreaker:
    configs:
      default:
        registerHealthIndicator: true
        slidingWindowType: COUNT_BASED   # 최근 N번 호출 기준
        slidingWindowSize: 5             # 윈도우 크기 5
        minimumNumberOfCalls: 5          # 최소 호출 수
        failureRateThreshold: 50         # 실패율 50% 초과 시 Open
        permittedNumberOfCallsInHalfOpenState: 3
        waitDurationInOpenState: 20s     # Open → Half-Open 대기 20초

 

 

  • slidingWindowType: COUNT_BASED: 최근 호출 횟수를 기준으로 실패율을 계산한다.
  • slidingWindowSize: 5: 최근 5번의 호출을 관찰한다.
  • minimumNumberOfCalls: 5: 최소 5번의 호출이 쌓인 뒤 실패율을 계산한다.
  • failureRateThreshold: 50: 실패율이 50% 이상이면 Open 상태로 전환한다.
  • permittedNumberOfCallsInHalfOpenState: 3: Half-Open 상태에서 최대 3개의 시험 요청을 허용한다.
  • waitDurationInOpenState: 20s: Open 상태를 20초 동안 유지한 뒤 Half-Open으로 전환한다.

 

Fallback

호출이 실패하거나 서킷 브레이커가 Open 상태라면 대체 메서드인 Fallback이 실행된다.

Fallback 메서드는 원본 메서드와 동일한 매개변수를 가지며, 마지막 매개변수로 예외를 받을 수 있다.

@CircuitBreaker(name = "productService", fallbackMethod = "fallbackGetProductDetails")
public Product getProductDetails(String productId) {
    if ("111".equals(productId)) {
        throw new RuntimeException("Empty response body");
    }
    return new Product(productId, "Sample Product");
}

public Product fallbackGetProductDetails(String productId, Throwable t) {
    return new Product(productId, "Fallback Product");
}

 

상태 이벤트 리스너

CircuitBreakerRegistry를 이용하면 서킷 브레이커의 상태 전환과 오류 이벤트를 로그로 확인할 수 있다.

@PostConstruct
public void registerEventListener() {
    circuitBreakerRegistry.circuitBreaker("productService").getEventPublisher()
        .onStateTransition(e -> log.info("State Transition: {}", e))
        .onFailureRateExceeded(e -> log.info("Failure Rate Exceeded: {}", e))
        .onCallNotPermitted(e -> log.info("Call Not Permitted: {}", e))
        .onError(e -> log.info("Error: {}", e));
}

 

이벤트 리스너를 통해 다음과 같은 정보를 확인할 수 있다.

  • Closed, Open, Half-Open 상태 전환
  • 실패율 임계값 초과
  • Open 상태에서 차단된 호출
  • 실제 호출 중 발생한 오류

 

모니터링

Actuator와 Micrometer를 연동하면 서킷 브레이커 상태를 메트릭으로 수집할 수 있다.

management:
  endpoints:
    web:
      exposure:
        include: prometheus
  prometheus:
    metrics:
      export:
        enabled: true

 

다음 주소에서 Prometheus 형식의 메트릭을 확인할 수 있다.

http://{host}:{port}/actuator/prometheus

 

인스턴스의 각종 상태 값이 텍스트로 쭉 나오는데, 아래로 내려가다 보면 서킷브레이커 관련 메트릭을 볼 수 있다.

예를 들어 resilience4j_circuitbreaker_state(상태), resilience4j_circuitbreaker_calls(성공/실패 호출 수), resilience4j_circuitbreaker_failure_rate(실패율) 같은 항목이다.

111을 여러 번 호출해 서킷을 Open으로 만든 뒤 이 페이지를 새로고침하면 상태 값이 바뀌는 것을 확인할 수 있다.

 

수집된 메트릭을 Prometheus에 저장하고 Grafana와 연동하면 서킷 브레이커의 상태와 호출 결과를 시각화할 수 있다.

 

(4) 실습 요약

이 실습에서는 Eureka를 사용하지 않고 하나의 애플리케이션 안에서 서킷 브레이커의 동작만 확인했다.

상품 ID가 111이면 예외가 발생하도록 설정했다.

실습 흐름은 다음과 같다.

 

1. `/product/1` 또는 `/product/2`를 호출해 정상 응답을 확인한다.

2. `/product/111`을 반복해서 호출해 실패를 발생시킨다.

3. 실패율이 임계값을 넘으면 서킷 브레이커가 Open 상태로 전환된다.

4. Open 상태에서는 실제 메서드를 호출하지 않고 바로 Fallback이 실행된다.

5. `waitDurationInOpenState`(20초)가 지나면 Half-Open 상태로 전환된다.

6. 시험 호출이 성공하면 다시 Closed 상태로 돌아간다.

 

Closed (정상 호출)

 

호출 실패 누적

 

Open 상태 - Fallback 반환