Monolith와 MSA를 비교하면서 Feign Client, Circuit Breaker 같은 단어들이 나왔는데, 이것들이 전부 Spring Cloud라는 하나의 생태계에 속한 도구들이다.
Spring Cloud는 MSA 환경에서 흔히 마주치는 공통 문제들(서비스 탐색, 설정 관리, 서비스 간 통신, 장애 격리 등)을 Spring 방식으로 쉽게 해결할 수 있도록 모아놓은 프로젝트 모음이다.
Spring Boot가 "하나의 애플리케이션을 빠르게 만드는 도구"라면, Spring Cloud는 "여러 개의 Spring Boot 애플리케이션을 하나의 분산 시스템으로 엮는 도구"라고 보면 된다.
Spring Cloud는 단일 프레임워크가 아니라 여러 하위 프로젝트의 모음이다.
MSA로 서비스를 쪼개기 시작하면 모놀리식에서는 없던 문제들이 새로 생긴다.
application.yml)을 서비스마다 따로 관리하면 너무 번거롭지 않나?RestTemplate으로 직접 짜야 하나?
이 모든 문제를 직접 처음부터 구현하려면 엄청난 시간이 든다.
Spring Cloud는 이런 분산 시스템의 공통 문제들에 대해 이미 검증된 해결책을 라이브러리로 제공한다.
@FeignClient 애노테이션 하나로 서비스 간 통신을 처리하고,
@EnableEurekaClient 하나로 서비스 등록과 탐색을 해결하는 식이다.
Spring Cloud의 핵심 구성 요소들이 함께 동작하는 흐름을 "주문 생성" 요청으로 살펴보자.
[서비스 등록 단계 - 애플리케이션 시작 시]
User Service 시작
↓
Eureka Server에 자신을 등록 (IP, 포트, 서비스명)
"user-service: 192.168.0.10:8081"
Order Service 시작
↓
Eureka Server에 자신을 등록
"order-service: 192.168.0.11:8082"
Product Service 시작
↓
Eureka Server에 자신을 등록
"product-service: 192.168.0.12:8083"
[요청 처리 단계 - 클라이언트 요청 시]
[Client]
|
| POST /api/orders
↓
[Spring Cloud Gateway] (단일 진입점, 포트 8080)
|
| ① 라우팅 규칙 확인: /api/orders → order-service로
| ② Eureka에서 order-service의 현재 위치 조회
↓
[Order Service]
|
| ③ OpenFeign으로 User Service 호출
| UserServiceClient.getUser(userId)
| → 내부적으로 Eureka에서 user-service 위치 조회 후 호출
↓
[User Service] → 응답
[Order Service]
|
| ④ Circuit Breaker로 감싼 Product Service 호출
| 만약 Product Service가 응답 없으면 → fallback 메서드 실행
↓
[Product Service] → 응답 (또는 장애 시 fallback)
[Order Service]
|
| ⑤ 주문 생성 완료, 응답 반환
↓
[Spring Cloud Gateway]
↓
[Client]
Response
핵심은 각 서비스가 서로의 정확한 위치(IP, 포트)를 몰라도, Eureka를 통해 동적으로 찾아서 통신할 수 있다는 것이다. 서비스가 재시작되어 IP가 바뀌어도 Eureka에 다시 등록되면 자동으로 갱신된다.
모든 클라이언트 요청을 받는 단일 진입점(API Gateway)이다. 요청 경로에 따라 적절한 서비스로 라우팅하고, 인증, 로깅, Rate Limiting 같은 공통 기능을 여기서 처리할 수 있다. 클라이언트는 개별 서비스 주소를 몰라도 Gateway 하나만 알면 된다.
각 서비스가 자신의 위치 정보를 등록하고, 다른 서비스의 위치를 조회할 수 있게 하는 레지스트리다.
다른 서비스를 호출할 때, RestTemplate이나 WebClient로 직접 URL을 조립하고 호출하는 대신
인터페이스만 정의하면 구현체를 자동으로 만들어주는 라이브러리다.
Eureka와 연동하면 서비스 이름만으로 호출할 수 있다.
서비스가 많아지면 application.yml 설정도 서비스마다 따로 관리해야 한다.
Config Server는 모든 서비스의 설정을 한 곳(Git 저장소 등)에서 중앙 관리하게 해준다.
설정이 바뀌면 서비스 재배포 없이 갱신할 수도 있다(Spring Cloud Bus와 결합 시).
한 서비스의 장애가 연쇄적으로 다른 서비스까지 퍼지는 것을 막는 역할이다. 일정 횟수 이상 호출이 실패하면 Circuit을 "열어서" 더 이상 호출을 시도하지 않고 즉시 fallback으로 응답한다. 일정 시간 후 다시 정상 여부를 확인한다.
하나의 요청이 여러 서비스를 거칠 때, 각 서비스 호출에 고유 추적 ID를 붙여서 전체 흐름을 하나로 연결해서 추적할 수 있게 해준다. Zipkin 같은 도구와 연동해서 시각화한다.
Spring Cloud는 Spring Boot 프로젝트에 의존성을 추가하는 방식으로 사용한다. 버전 관리는 Spring Cloud BOM(Bill of Materials)으로 Spring Boot 버전과 호환되는 버전을 맞춘다.
// build.gradle
ext {
set('springCloudVersion', "2024.0.0")
}
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
각 마이크로서비스는 독립적인 Spring Boot 애플리케이션이고, 필요한 Spring Cloud 모듈만 골라서 추가한다.
[Eureka Server 프로젝트] — 별도의 독립 애플리케이션
spring-cloud-starter-netflix-eureka-server
[각 마이크로서비스 - User, Order, Product Service]
spring-cloud-starter-netflix-eureka-client (서비스 등록용)
spring-cloud-starter-openfeign (서비스 간 호출용)
[Gateway 프로젝트] — 별도의 독립 애플리케이션
spring-cloud-starter-gateway
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
# application.yml (Eureka Server)
server:
port: 8761
eureka:
client:
register-with-eureka: false # 자기 자신은 등록하지 않음
fetch-registry: false
@SpringBootApplication
@EnableDiscoveryClient
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
# application.yml (Order Service)
spring:
application:
name: order-service # 이 이름으로 Eureka에 등록됨
server:
port: 8082
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka
@SpringBootApplication
@EnableFeignClients // Feign Client 활성화
public class OrderServiceApplication { ... }
// Eureka에 등록된 "user-service"라는 이름으로 자동 탐색해서 호출
@FeignClient(name = "user-service")
public interface UserServiceClient {
@GetMapping("/users/{id}")
UserResponse getUser(@PathVariable("id") Long id);
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final UserServiceClient userServiceClient;
public Order createOrder(OrderRequest request) {
// IP, 포트를 몰라도 서비스 이름만으로 호출 가능
UserResponse user = userServiceClient.getUser(request.userId());
// ...
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductServiceClient productServiceClient;
@CircuitBreaker(name = "productService", fallbackMethod = "getProductFallback")
public ProductResponse getProduct(Long productId) {
return productServiceClient.getProduct(productId);
}
public ProductResponse getProductFallback(Long productId, Throwable t) {
// Product Service 장애 시 기본값 반환 — 전체 흐름이 멈추지 않음
return ProductResponse.unavailable(productId);
}
}
# application.yml — Resilience4j 설정
resilience4j:
circuitbreaker:
instances:
productService:
sliding-window-size: 10 # 최근 10번의 호출을 기준으로 판단
failure-rate-threshold: 50 # 50% 이상 실패하면 Circuit Open
wait-duration-in-open-state: 5s # 5초 후 재시도
# application.yml (Gateway)
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service # lb:// = Eureka 기반 로드밸런싱
predicates:
- Path=/api/orders/**
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
- id: product-service
uri: lb://product-service
predicates:
- Path=/api/products/**
lb:// 접두사는 Eureka에 등록된 서비스 이름을 기반으로 로드밸런싱하며 라우팅한다는 의미다.
서비스 인스턴스가 여러 개라도 Gateway가 자동으로 분산시켜 전달한다.
Spring Cloud를 추가한다고 해서 자동으로 MSA가 되는 게 아니다. 서비스를 어떻게 나눌지, 도메인 경계를 어떻게 설계할지는 여전히 개발자의 몫이다. Spring Cloud는 "이미 나눈 서비스들을 어떻게 연결하고 운영할 것인가"에 대한 도구일 뿐이다.
둘 다 리버스 프록시 역할을 하지만 성격이 다르다.
실무에서는 Nginx(또는 AWS ALB)가 가장 바깥의 진입점 역할을 하고, 그 안쪽에서 Spring Cloud Gateway가 마이크로서비스 라우팅을 담당하는 식으로 함께 쓰는 경우도 많다.
Eureka는 Netflix OSS 프로젝트로 시작했지만, 현재는 유지보수 모드에 가깝다. Kubernetes 환경에서는 Kubernetes 자체의 서비스 디스커버리(Service, DNS)를 사용하는 경우가 많아져서, Eureka 대신 Consul이나 Kubernetes Native 방식을 선택하는 추세도 있다. 새 프로젝트라면 배포 환경(Kubernetes 여부)을 먼저 고려하는 게 좋다.
설정 파일을 Git 저장소에 두고 Config Server가 이를 읽어서 각 서비스에 제공하면, 설정 변경 이력을 Git 커밋으로 추적할 수 있고, 환경별(dev, staging, prod) 설정 분리도 브랜치나 디렉토리로 깔끔하게 관리할 수 있다.
fallback이 단순히 빈 데이터를 반환하면 클라이언트 입장에서 혼란스러울 수 있다. "이 데이터는 일시적으로 사용 불가능하다"는 걸 명확히 알 수 있는 응답 구조를 설계해야 한다. 무조건 200 OK로 빈 값을 주기보다, 상태 코드나 플래그로 구분하는 게 좋다.
서비스가 몇 개 안 될 때는 트레이싱 없이도 디버깅이 가능하지만, 서비스가 늘어날수록 트레이싱 없이는 문제를 찾기가 거의 불가능해진다. Micrometer Tracing + Zipkin(또는 Jaeger) 조합을 MSA 초기 단계부터 구축해두는 게 나중에 도입하는 것보다 훨씬 수월하다.
Spring Boot와 Spring Cloud는 버전 호환 매트릭스가 존재한다. Spring Boot 버전을 올릴 때 Spring Cloud 버전도 호환되는지 반드시 확인해야 한다. 호환되지 않는 조합은 애플리케이션 구동 자체가 실패하는 경우가 많다.
MSA의 개념만 공부했을 때는 "서비스를 나누면 되는 거구나" 정도로 생각했는데, Spring Cloud의 구성 요소들을 하나씩 보면서 "나눈 다음에 해결해야 할 문제가 이렇게나 많구나"를 느꼈다. 서비스 위치 찾기, 설정 관리, 장애 전파 방지, 요청 추적까지 — 전부 모놀리식에서는 고민할 필요가 없던 것들이다.
Spring Cloud는 결국 "분산 시스템을 운영하는 비용"을 라이브러리 형태로 줄여주는 도구라는 걸 느꼈다. 이 도구들이 없었다면 MSA를 직접 구현하는 게 훨씬 더 힘들었을 것 같다. 다만 도구가 문제를 자동으로 없애주는 건 아니고, 여전히 각 개념(Circuit Breaker는 언제, Saga는 어떻게)을 제대로 이해하고 적용해야 한다는 걸 다시 느꼈다.