Spring Cloud · Reactive Programming
한 줄 소개 — MSA 를 떠받치는 Spring Cloud(Eureka · Gateway · Config · Resilience4J)와, 적은 스레드로 많은 트래픽을 견디는 리액티브 프로그래밍(WebFlux · Netty · Mono/Flux · R2DBC · Schedulers)을 묶어 정리한 노트다. RxProgramming 을 하며 직접 부딪힌 Thread Pool Hell, Mono.timeout 실험까지 담았다.
1 · Spring Cloud 란? (MSA)
Spring Cloud 는 마이크로서비스 아키텍처(MSA) 를 지원하기 위한 프레임워크다. 거대한 하나의 애플리케이션(모놀리식)을 작은 서비스들로 쪼개면, "서비스를 어떻게 찾고 · 어디로 보내고 · 설정은 어떻게 공유하고 · 장애는 어떻게 막을지" 같은 문제가 생긴다. Spring Cloud 는 이 문제들을 컴포넌트로 해결한다.
| 관심사 | 컴포넌트 |
|---|---|
| 중앙 설정 관리 | Spring Cloud Config Server |
| 서비스 등록·검색 (Location transparency) | Netflix Eureka (Naming Server) |
| 로드밸런싱·라우팅 | Spring Cloud Gateway (권장) · Ribbon |
| 서비스 간 통신 | OpenFeign · RestTemplate |
| 분산 추적·모니터링 | Sleuth + Zipkin · ELK |
| 장애 격리 (Fault Tolerance) | Resilience4J (구 Hystrix) |
2 · 서비스 검색 — Eureka
Eureka 는 Service Discovery(전화번호부) 역할을 한다. 각 마이크로서비스와 게이트웨이가 자신을 Eureka 에 등록하면, 다른 서비스는 IP·포트를 몰라도 이름으로 서로를 찾는다(Location transparency).
// ① Eureka 서버
@SpringBootApplication
@EnableEurekaServer // 이 앱을 Eureka 서버로 등록
public class DiscoveryServiceApplication {
public static void main(String[] args) {
SpringApplication.run(DiscoveryServiceApplication.class, args);
}
}
// ② 마이크로서비스 (Eureka 클라이언트)
@SpringBootApplication
@EnableDiscoveryClient // Eureka 에 자신을 등록
public class UserServiceApplication { /* ... */ }# Eureka 서버 application.yml
server:
port: 8761 # Eureka 표준 포트
spring:
application:
name: discovery-service
eureka:
client:
register-with-eureka: false # 서버는 자기 자신을 등록하지 않음
fetch-registry: false # (라이브러리가 있으면 기본 true → 자기 등록 시도)
# 마이크로서비스 application.yml
server:
port: 9001
spring:
application:
name: user-service
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:8761/eureka # Eureka 서버 위치3 · API Gateway — Spring Cloud Gateway
모든 외부 요청의 단일 진입점. 요청이 들어오면 Handler Mapping(Predicate) 이 어떤 라우트인지 판단하고, Pre Filter(인증·헤더 조작 등)를 거쳐 실제 서비스로 보낸 뒤, 응답에 Post Filter 를 적용해 돌려준다.
라우트·필터는 Java 코드(RouteLocator) 또는 YAML 로 선언한다.
// Java 코드로 라우트·필터 구성
@Configuration
public class FilterConfig {
@Bean
public RouteLocator gatewayRoutes(RouteLocatorBuilder builder) {
return builder.routes()
.route(r -> r.path("/first-service/**") // predicate: 경로 매칭
.filters(f -> f.addRequestHeader("first-request", "first-request-header")
.addResponseHeader("first-response", "first-response-header"))
.uri("http://localhost:8081/")) // 라우팅 대상
.route(r -> r.path("/second-service/**")
.filters(f -> f.addRequestHeader("second-request", "second-request-header")
.addResponseHeader("second-response", "second-response-header"))
.uri("http://localhost:8082/"))
.build();
}
}# YAML 로 같은 라우트 구성
spring:
application:
name: apigateway-service
cloud:
gateway:
routes:
- id: first-service
uri: http://localhost:8081/
predicates:
- Path=/first-service/**
filters:
- AddRequestHeader=first-request, first-request-header
- AddResponseHeader=first-response, first-response-header디버깅 메모 — 경로는 슬래시로 시작해야 한다(first-service/** ❌ → /first-service/** ✅). 안 그러면 localhost:8000first-service/** 처럼 붙어버린다. 오타(fisrt)도 흔한 실수.
4 · 왜 Reactive 인가? — Web MVC vs WebFlux
전통적 Spring MVC 는 Tomcat 위에서 요청 1개당 스레드 1개(thread-per-request)로 동작한다 — I/O(DB·외부 API) 동안 그 스레드는 블로킹되어 논다. 트래픽이 몰리면 스레드가 고갈된다. Spring WebFlux 는 Netty 위에서 적은 수의 이벤트 루프 스레드로 논블로킹 처리한다 — I/O 를 기다리지 않고 다른 요청을 처리하다가, 완료되면 콜백으로 이어간다.
// Spring MVC → Tomcat (8080), 블로킹
implementation 'org.springframework.boot:spring-boot-starter-web'
// Spring WebFlux → Netty (8080), 논블로킹
implementation 'org.springframework.boot:spring-boot-starter-webflux'
// 둘 다 추가하면 spring-web(MVC) 이 우선순위를 가진다5 · 리액티브 핵심 — Publisher/Subscriber와 Mono·Flux
리액티브의 본질은 논블로킹이고, 처리 모델은 Publisher–Subscriber(스트림)다. 데이터를 "당겨오는" 게 아니라, 준비되면 흘려보내고(onNext) → 끝나면(onComplete) / 에러나면(onError) 구독자에게 통지한다. Reactor 는 두 가지 Publisher 를 제공한다.
- Mono<T> — 0 또는 1개의 결과 (단건 조회·저장 등)
- Flux<T> — 0 ~ N개의 스트림 (목록·이벤트 스트림 등)
6 · Netty 구조와 WebFlux 처리 흐름
WebFlux 는 Netty 위에서 동작한다. Netty 는 저수준 네트워크 I/O(소켓·바이트 스트림)를 담당하고, WebFlux 는 그 위에서 라우팅·필터·비즈니스 로직을 담당한다.
- Netty —
ServerBootstrap이Channel을 만들고, 요청은ChannelPipeline의 여러ChannelHandler(예:HttpRequestDecoder)를 거쳐 디코딩된다. - WebFlux — 디코딩된 요청을
WebHandler가 받아RouterFunction/HandlerFunction으로 라우팅하고,WebFilter/GlobalFilter로 전·후처리한 뒤Mono/Flux로 응답을 만든다.
7 · Reactive DB — R2DBC
아무리 WebFlux 로 논블로킹을 만들어도, DB 접근이 블로킹(JDBC) 이면 거기서 막힌다. 그래서 R2DBC(Reactive Relational DB) · reactive MongoDB 같은 논블로킹 드라이버를 쓴다 — JDBC 와 다른 드라이버이고, 반환값이 Mono/Flux 로 감싸져 나온다.
// 논블로킹 DB 의존성
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
runtimeOnly 'org.postgresql:r2dbc-postgresql'
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'// JPA 의 PagingAndSortingRepository 처럼, 리액티브는 ReactiveSortingRepository 를 상속
@Repository
public interface PersonRepository extends ReactiveSortingRepository {
Mono deleteById(String id); // 반환이 Publisher(Mono/Flux) 로 감싸진다
Mono findByEmail(String email);
}
// R2DBC 설정 — JDBC DataSource 가 아니라 ConnectionFactory 를 사용
@Configuration
@EnableR2dbcRepositories(basePackages = "com.manage.reactive.apis.domain.repository")
@EnableR2dbcAuditing // JPA Audit 처럼 @CreatedBy 등 자동 생성
public class R2dbcConfig extends AbstractR2dbcConfiguration {
@Value("${spring.r2dbc.url}") private String url;
@Override public ConnectionFactory connectionFactory() {
return ConnectionFactories.get(url);
}
} 8 · Mono.timeout 정책 (직접 실험)
필터 체인에서 Mono.timeout() 이 어떻게 동작하는지 직접 실험했다. 핵심은 — timeout 은 별도의 timer 스레드를 사용하고, subscribeOn() 시점과 timeout() 거는 시점이 맞아야 의도대로 동작한다.
public final Mono timeout(Duration timeout, Mono extends T> fallback);
public final Mono timeout(Duration timeout, Mono extends T> fallback, Scheduler timer);
// 보통 Schedulers.boundedElastic() 같은 스케줄러에서 timer 스레드를 가져온다 실험 시나리오 — Filter1 → … → Filter7 체인에서 Filter2 에 4초 sleep → Filter4 에 5초 timeout → Filter5 에 3초 sleep.
결론 — 뒤쪽에 건 timeout 이 앞단의 시간에는 영향을 주지 않는다. timeout 의 timer 스레드는 새로 생성되며, 내가 지정한 Scheduler(예: boundedElastic)에서 가져와 쓴다. 단, subscribeOn() 과 timeout() 의 적용 시점이 어긋나면 의도대로 측정되지 않는다.
9 · ThreadPool 과 Reactor Schedulers
스레드를 필요할 때마다 생성·종료하면 오버헤드(스택·ID·레지스터 할당)가 크다. 그래서 미리 만들어 두고 재사용하는 스레드 풀을 쓴다. 자바의 ExecutorService 는 작업을 WorkingQueue 에 넣고, Workers Pool 의 스레드들이 가져가 처리한다.
Thread Pool Hell (직접 겪은 문제) — 소켓처럼 짧은 시간에 트래픽이 폭증하면 풀의 스레드가 모두 고갈된다. API-Link 작업에서 이벤트 루프 기반의 Netty 기본 스레드 풀이 이 현상을 겪었다. 자바 스레드 풀은 ① 일반 Java 풀, ② 스프링 제공 풀, ③ WebFlux(Reactor) 풀로 나뉘고, Reactor 에선 publishOn()/subscribeOn() 으로 스케줄러(Schedulers.boundedElastic() 등)를 지정해 어느 스레드에서 돌릴지 제어한다.
10 · 장애 격리 — Resilience4J Circuit Breaker
MSA 에선 한 서비스의 장애가 호출 체인을 타고 전체로 번질 수 있다. Circuit Breaker(회로 차단기)는 실패율이 임계치를 넘으면 회로를 열어(Open) 즉시 fallback 으로 응답해, 죽어가는 서비스로의 호출을 끊는다. Resilience4J 는 Circuit Breaker 외에 Retry · RateLimiter · Bulkhead · TimeLimiter 를 제공한다(구 Netflix Hystrix 의 대체).
11 · 중앙 설정 — Config Server
여러 마이크로서비스의 설정(포트·토큰·DB 정보 등)을 각자 들고 있으면 유지보수가 어렵다. Spring Cloud Config Server 가 외부 저장소(Git 등)의 설정을 중앙에서 관리하고, 각 서비스(Config Client)는 부팅 시 이를 받아온다. 구축 순서는 Config Server → Config Client → 값 암호화 → 설정 저장소를 private 으로.
// Config Server 의존성 (이걸 추가하면 WebFlux 를 넣어도 Netty 대신 Tomcat 으로 뜬다)
implementation 'org.springframework.cloud:spring-cloud-config-server'
implementation 'org.springframework.boot:spring-boot-starter-actuator'@SpringBootApplication
@EnableConfigServer // 이 앱을 Config Server 로
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}설정값은 비대칭키로 암·복호화하고, 설정 저장소(Git repo)는 private + SSH/PrivateKey 로 연결해 민감 정보를 보호한다. 분산 추적은 Sleuth + Zipkin, 부하 테스트는 Apache Bench(ab -n 1000 -c 100 ...)로 검증했다.
📓 2023년 제가 KT DS 에서 클라우드·미들웨어 업무를 하며 Spring Cloud 와 리액티브 프로그래밍(RxProgramming)을 직접 학습·실험하고 정리한 노트입니다 · 원본 Notion 에서 보기 ↗