← Documents

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)
Spring Cloud (MSA) 구성 Client API Gateway라우팅·필터·LB user-service order-service catalog-service EurekaService Registry Config Server중앙 설정 Resilience4J장애 격리 서비스·게이트웨이는 모두 Eureka 에 등록된다 (점선)
클라이언트 → API Gateway → 마이크로서비스. Eureka(등록·검색)·Config(설정)·Resilience4J(장애)가 이를 받친다

2 · 서비스 검색 — Eureka

EurekaService 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 서버 위치
Eureka 등록 & 검색 Eureka Server:8761 (registry) user-service order-service API Gateway register / heartbeat Gateway → 검색 lookup by name
모든 서비스가 Eureka 에 등록(하트비트)하고, 게이트웨이는 이름으로 인스턴스를 찾아 라우팅한다

3 · API Gateway — Spring Cloud Gateway

모든 외부 요청의 단일 진입점. 요청이 들어오면 Handler Mapping(Predicate) 이 어떤 라우트인지 판단하고, Pre Filter(인증·헤더 조작 등)를 거쳐 실제 서비스로 보낸 뒤, 응답에 Post Filter 를 적용해 돌려준다.

Spring Cloud Gateway 파이프라인 Request HandlerMapping(Predicate) Pre Filter인증·헤더 Service(proxied) Post Filter응답 가공 Response predicate(경로 매칭) → pre-filter → 서비스 → post-filter
게이트웨이는 predicate 로 라우트를 고르고, pre/post 필터로 요청·응답을 가로채 가공한다

라우트·필터는 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 MVCTomcat 위에서 요청 1개당 스레드 1개(thread-per-request)로 동작한다 — I/O(DB·외부 API) 동안 그 스레드는 블로킹되어 논다. 트래픽이 몰리면 스레드가 고갈된다. Spring WebFluxNetty 위에서 적은 수의 이벤트 루프 스레드논블로킹 처리한다 — 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) 이 우선순위를 가진다
MVC — Thread per request (blocking) Thread 1 ⏳ Thread 2 ⏳ Thread 3 ⏳ … 고갈 DB / 외부 I/O스레드는 대기(블로킹) WebFlux — Event Loop (non-blocking) Event Loop ×N 소수의 스레드 I/O 이벤트완료 시 콜백 기다리지 않고 다음 요청 처리 → 적은 스레드로 많은 트래픽
MVC 는 요청마다 스레드를 잡고 I/O 동안 블로킹, WebFlux 는 적은 이벤트 루프 스레드로 논블로킹 처리한다

5 · 리액티브 핵심 — Publisher/Subscriber와 Mono·Flux

리액티브의 본질은 논블로킹이고, 처리 모델은 Publisher–Subscriber(스트림)다. 데이터를 "당겨오는" 게 아니라, 준비되면 흘려보내고(onNext) → 끝나면(onComplete) / 에러나면(onError) 구독자에게 통지한다. Reactor 는 두 가지 Publisher 를 제공한다.

  • Mono<T> — 0 또는 1개의 결과 (단건 조회·저장 등)
  • Flux<T> — 0 ~ N개의 스트림 (목록·이벤트 스트림 등)
Mono (0..1) vs Flux (0..N) Mono 1 onComplete Flux e1e2e3e4 onComplete onNext 로 값을 흘려보내고, 끝나면 onComplete (에러 시 onError)
Mono 는 최대 1개, Flux 는 여러 개의 값을 비동기 스트림으로 흘려보낸다

6 · Netty 구조와 WebFlux 처리 흐름

WebFlux 는 Netty 위에서 동작한다. Netty 는 저수준 네트워크 I/O(소켓·바이트 스트림)를 담당하고, WebFlux 는 그 위에서 라우팅·필터·비즈니스 로직을 담당한다.

  • NettyServerBootstrapChannel 을 만들고, 요청은 ChannelPipeline 의 여러 ChannelHandler(예: HttpRequestDecoder)를 거쳐 디코딩된다.
  • WebFlux — 디코딩된 요청을 WebHandler 가 받아 RouterFunction/HandlerFunction 으로 라우팅하고, WebFilter/GlobalFilter 로 전·후처리한 뒤 Mono/Flux 로 응답을 만든다.
요청 → 응답 흐름 (Netty + WebFlux) Client Netty (I/O) Channel/Pipeline HttpDecoder Encoder(응답) Spring WebFlux WebFilter WebHandler/Router 비즈니스 → Mono/Flux Client → Netty(소켓·디코딩) → WebFlux(필터·라우팅·로직 → Mono/Flux) → Netty(인코딩) → Client
Netty 가 네트워크 I/O 를, WebFlux 가 그 위에서 요청 처리·비즈니스 로직을 담당한다

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); } }
블로킹 스택 vs 리액티브 스택 Web MVC JPA / JDBC DB (blocking) WebFlux R2DBC (Mono/Flux) DB (non-blocking) DB 까지 논블로킹이어야 진짜 리액티브
WebFlux + R2DBC 로 DB 접근까지 논블로킹이어야 이벤트 루프가 막히지 않는다

8 · Mono.timeout 정책 (직접 실험)

필터 체인에서 Mono.timeout() 이 어떻게 동작하는지 직접 실험했다. 핵심은 — timeout 은 별도의 timer 스레드를 사용하고, subscribeOn() 시점과 timeout() 거는 시점이 맞아야 의도대로 동작한다.

public final Mono timeout(Duration timeout, Mono fallback); public final Mono timeout(Duration timeout, Mono 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 의 스레드들이 가져가 처리한다.

ExecutorService 동작 Tasks WorkingQueue Workers Pool core → max 까지 증설 RejectedExecution(큐+max 초과) corePoolSize 우선 → 큐가 차면 maxPoolSize 까지 증설 → 그래도 넘치면 거부
작업은 큐에 쌓이고 워커 스레드가 처리한다. core→max 까지 늘리고, 큐+max 를 넘으면 RejectedExecutionHandler 로 거부
🔥

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 의 대체).

Circuit Breaker 상태 CLOSED정상 통과 OPEN즉시 fallback HALFOPEN 실패율 > 임계치 대기시간 후 시험 호출 성공 → 복구 실패 시 다시 OPEN
CLOSED(정상) → 실패율 초과 시 OPEN(차단) → 일정 시간 후 HALF-OPEN(시험) → 성공하면 CLOSED 복구

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 에서 보기 ↗