Spring WebFlux 핵심 정리 시리즈 11편. WebFlux 성능 최적화 — Gzip 압축 3종 설정, ConnectionProvider 연결 풀링, flatMap concurrency 파라미터, Schedulers.boundedElastic() 블로킹 코드 격리, Mono.cache() 캐싱 패턴, BlockHound 블로킹 감지까지 순서대로 정리합니다.
이 글은 Spring WebFlux 핵심 정리 시리즈의 11번째 편입니다. WebFlux를 쓰는 것 자체가 성능을 보장해 주지는 않아요. 올바른 설정과 패턴을 함께 써야 진짜 효과가 나옵니다. 이번 편에서는 "서버를 더 추가하기 전에 먼저 해야 할 일"을 순서대로 정리합니다.
핵심 비유는 공장 라인 병목입니다. 공장에서 생산량이 낮으면 제일 먼저 해야 할 일은 어느 단계가 막히는지 찾는 것이에요. 기계를 더 사는 게 아니라 병목 제거가 먼저입니다. WebFlux 성능도 마찬가지예요 — 어디가 막히는지 파악하고, 우선순위 높은 것부터 개선하는 게 핵심입니다.
이 시리즈는 Spring 공식 문서, Project Reactor 공식 문서, Reactive Streams 명세, 여러 비동기 백엔드 학습 자료 등 공개 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.
성능 최적화는 측정 없이는 의미가 없어요. JMeter 같은 부하 테스트 도구로 실제 병목을 확인한 뒤 개선하는 순서를 지키면, 엉뚱한 곳을 최적화하는 실수를 피할 수 있습니다.
최적화 순서 — 이 순서를 모르면 반은 틀린 것
WebFlux 성능 최적화에는 명확한 우선순위가 있어요.
1순위: 블로킹 코드 제거 → 이벤트 루프 블로킹은 모든 요청 지연
2순위: 연결 풀 설정 → 외부 서비스 연결 재사용
3순위: Gzip 압축 → 네트워크 대역폭 절약
4순위: flatMap 동시성 → 병렬 처리 최적화
5순위: 캐싱 → 반복 계산/DB 조회 제거
마지막: 서버 증설 → 위 5가지를 먼저 적용한 후 고려
여기서 시험 함정이 하나 있어요. 많은 팀이 성능 문제를 만나면 제일 먼저 서버를 더 추가하려 합니다. 하지만 근본 원인이 블로킹 코드나 잘못된 설정에 있다면, 서버 10대를 추가해도 문제는 해결되지 않습니다. 병목이 코드 안에 있으니까요.
블로킹 코드 격리 — 1순위
WebFlux는 소수의 이벤트 루프 스레드(CPU 코어 수 × 2)로 모든 요청을 처리합니다. 4코어 서버면 Worker 스레드가 8개예요. 이 중 하나가 블로킹되면 그 스레드가 담당하는 모든 요청이 지연됩니다.
// 블로킹 코드의 종류
Thread.sleep(1000); // 명백한 블로킹
jpaUserRepository.findById(1L); // JPA (블로킹 드라이버)
Files.readAllBytes(Path.of("/large-file")); // 파일 I/O
restTemplate.getForObject("/api/...", String.class); // 블로킹 HTTP 클라이언트
synchronized (lock) { ... } // 경쟁 발생 시 대기
이런 코드가 이벤트 루프에서 실행되면 안 됩니다. Schedulers.boundedElastic() 로 별도 스레드 풀에 격리해야 해요.
// BAD — 이벤트 루프에서 JPA 직접 호출
@GetMapping("/users/{id}")
public Mono<UserDto> getUser(@PathVariable Long id) {
User user = jpaUserRepository.findById(id).orElseThrow(); // 블로킹!
return Mono.just(toDto(user));
}
// GOOD — boundedElastic 스케줄러로 격리
@GetMapping("/users/{id}")
public Mono<UserDto> getUser(@PathVariable Long id) {
return Mono.fromCallable(() ->
jpaUserRepository.findById(id).orElseThrow()
)
.subscribeOn(Schedulers.boundedElastic())
.map(this::toDto);
}
// 파일 읽기도 동일 패턴
public Mono<byte[]> readFile(String path) {
return Mono.fromCallable(() -> Files.readAllBytes(Path.of(path)))
.subscribeOn(Schedulers.boundedElastic());
}
여기서 시험 함정이 하나 있어요. boundedElastic()의 기본 최대 스레드 수는 CPU 코어 × 10 (최솟값 10) 이고, 대기 큐는 최대 100,000개입니다. 이름은 "boundedElastic" — "경계가 있는 탄성" — 즉, 필요하면 스레드를 늘리지만 무한정은 아닙니다. 큐가 가득 차면 RejectedExecutionException이 발생해요.
Schedulers 종류와 사용 사례
| Scheduler | 스레드 수 | 사용 사례 |
|---|---|---|
parallel() | CPU 코어 수 | CPU 집약적 계산 (이미지 처리·암호화) |
boundedElastic() | CPU × 10 (최소 10) | 블로킹 I/O (JPA·파일) |
single() | 1개 | 순차 처리 보장 필요 시 |
immediate() | 현재 스레드 | 현재 스레드에서 즉시 실행 |
BlockHound — 블로킹 감지 도구
개발 환경에서 이벤트 루프 스레드의 블로킹 호출을 자동으로 감지해 주는 도구입니다.
// pom.xml에 의존성 추가:
// io.projectreactor.tools:blockhound:1.0.8.RELEASE
// main() 또는 @BeforeAll에서 설치
BlockHound.install();
// 이후 이벤트 루프에서 Thread.sleep 등 호출 시 BlockingOperationError 발생
Gzip 압축 — 3가지 설정이 모두 필요
마이크로서비스 환경에서 서비스 간 통신이 빈번하고, 응답 크기가 크면 Gzip 압축이 처리량을 극적으로 높여줍니다. 200KB JSON 응답이 1초에 1,000번 나간다면 초당 200MB 트래픽인데, Gzip으로 30KB로 압축하면 초당 30MB로 줄어들어요.
서버 설정
# application.properties
server.compression.enabled=true
server.compression.min-response-size=2048
server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript
여기서 시험 함정이 하나 있어요. 세 가지 설정이 모두 있어야 Gzip이 기대대로 동작합니다.
enabled=true없으면 → 압축 기능 자체 비활성화min-response-size없으면 → 5바이트 응답도 압축 시도, 오히려 크기 증가 (5바이트 → 792바이트)mime-types없으면 → 이미지·동영상도 압축 시도 → CPU 낭비, 크기 증가
클라이언트 설정 (WebClient)
Gzip은 서버 설정만으로는 부족해요. 클라이언트가 Accept-Encoding: gzip 헤더를 보내야 서버가 압축 응답을 돌려줍니다.
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
// compress(true) 설정 시 자동으로 Accept-Encoding: gzip, deflate 헤더 추가
// 서버 응답의 Content-Encoding: gzip도 자동 디코딩
HttpClient httpClient = HttpClient.create()
.compress(true);
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
}
또 하나의 함정 — Gzip 효과는 로컬에서 측정하면 안 보입니다. 로컬호스트는 네트워크 지연이 0ms라 압축 이득이 없어요. 스테이징 환경처럼 실제 네트워크 구간이 있는 곳에서 JMeter로 측정해야 효과가 보입니다.
연결 풀링 — 2순위
외부 서비스에 HTTP 요청마다 새 TCP 연결을 맺으면 3-way handshake 비용이 매번 발생해요. 서울-서울 내부망에서도 ~1ms인데, 초당 1,000요청이면 연결 설정에만 초당 1초의 오버헤드가 생깁니다.
@Bean
public WebClient webClient() {
ConnectionProvider provider = ConnectionProvider.builder("custom-pool")
.maxConnections(100) // 최대 연결 수
.maxIdleTime(Duration.ofSeconds(20)) // 유휴 연결 유지 시간
.maxLifeTime(Duration.ofSeconds(60)) // 연결 최대 수명
.pendingAcquireTimeout(Duration.ofSeconds(60)) // 연결 획득 최대 대기 시간
.evictInBackground(Duration.ofSeconds(120)) // 백그라운드 정리 주기
.metrics(true) // Micrometer 메트릭 활성화
.build();
HttpClient httpClient = HttpClient.create(provider)
.compress(true)
.responseTimeout(Duration.ofSeconds(30));
return WebClient.builder()
.baseUrl("http://product-service:8080")
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
여기서 시험 함정이 하나 있어요. pendingAcquireTimeout 없으면 연결 풀이 꽉 찼을 때 무한 대기가 발생합니다. 모든 연결이 사용 중이면 새 요청은 풀에 자리가 날 때까지 기다리는데, 제한이 없으면 큐가 끝없이 쌓여요. 반드시 타임아웃을 설정해야 합니다.
maxConnections 계산 공식:
maxConnections = (예상 초당 최대 요청 수) × (평균 응답 시간 초)
예시: 초당 500 요청, 평균 100ms 응답 → 500 × 0.1 = 50 → 넉넉히 100 설정
flatMap 동시성 제어 — 4순위
flatMap은 기본적으로 모든 내부 Publisher를 즉시 구독합니다. 기본 concurrency는 Queues.SMALL_BUFFER_SIZE (보통 256)이에요. 1,000개 ID에 대해 flatMap을 돌리면 최대 256개 HTTP 요청이 동시에 나갈 수 있습니다. 외부 서비스 과부하·연결 풀 고갈·타임아웃 폭발로 이어지는 거예요.
// BAD — 무제한 동시성 (기본 256개 동시 요청)
Flux.range(1, 1000)
.flatMap(id -> webClient.get()
.uri("/product/{id}", id)
.retrieve()
.bodyToMono(ProductDto.class))
.collectList();
// GOOD — 두 번째 인자로 동시성 제한
Flux.range(1, 1000)
.flatMap(
id -> webClient.get()
.uri("/product/{id}", id)
.retrieve()
.bodyToMono(ProductDto.class),
10 // 동시에 최대 10개만 실행
)
.collectList();
flatMap vs concatMap vs flatMapSequential
// flatMap: 순서 무관, 완료되는 대로 방출 (가장 빠름)
// 출력 순서 불확정: 3, 1, 2 처럼 나올 수 있음
Flux.range(1, 3).flatMap(i -> asyncOp(i), 3);
// concatMap: 순서 유지, 앞 항목 완료 후 다음 항목 시작 (가장 느림, 순차)
// flatMap(mapper, 1)과 동일
Flux.range(1, 3).concatMap(i -> asyncOp(i));
// flatMapSequential: 동시에 시작하지만 방출은 원래 순서대로 (중간 타협)
// 처리는 병렬, 출력은 1, 2, 3 순서 보장
Flux.range(1, 3).flatMapSequential(i -> asyncOp(i), 3);
여기서 시험 함정이 하나 있어요. flatMap의 기본 concurrency 256은 조용히 외부 서비스를 과부하시킵니다. Rate Limit이 걸린 외부 API라면 429 오류가 쏟아지고, DB 연결 풀을 공유하는 상황에서는 연결 고갈로 전체가 느려집니다. "왜 갑자기 성능이 나빠졌지?" 할 때 flatMap 동시성이 원인인 경우가 꽤 많아요.
캐싱 패턴 — 5순위
반복적으로 동일한 데이터를 조회하는 경우, 캐싱이 지연을 극적으로 줄여줍니다.
// Mono.cache() — 한 번 평가하고 결과를 캐싱
@Service
public class ProductCacheService {
private final Map<Long, Mono<Product>> cache = new ConcurrentHashMap<>();
public Mono<Product> getProductCached(Long id) {
return cache.computeIfAbsent(id,
k -> productRepository.findById(k)
.cache(Duration.ofMinutes(10)) // 10분 캐싱
);
}
}
여기서 시험 함정이 하나 있어요. Mono.cache()는 구독이 완료된 결과를 캐싱합니다. 처음 구독 시 실제 DB 조회가 발생하고, 이후 구독은 캐싱된 결과를 즉시 반환해요. cache(Duration) 오버로드로 TTL을 설정할 수 있습니다.
Spring Data Redis Reactive로 분산 캐시도 구성할 수 있어요. 여러 서비스 인스턴스가 있는 환경에서 In-Memory 캐시 대신 Redis를 쓰면 캐시 공유가 됩니다.
@Service
public class ProductServiceWithRedis {
public Mono<ProductDto> getProduct(Long id) {
String cacheKey = "product:" + id;
return redisTemplate.opsForValue().get(cacheKey)
.switchIfEmpty(
productRepository.findById(id)
.map(this::toDto)
.flatMap(dto ->
redisTemplate.opsForValue()
.set(cacheKey, dto, Duration.ofMinutes(10))
.thenReturn(dto)
)
);
}
}
완전한 고성능 WebClient 빈
지금까지 다룬 모든 설정을 한 번에 모아 보면 이렇습니다.
@Bean
public WebClient webClient() {
ConnectionProvider connectionProvider = ConnectionProvider
.builder("optimized-pool")
.maxConnections(200)
.maxIdleTime(Duration.ofSeconds(20))
.maxLifeTime(Duration.ofSeconds(120))
.pendingAcquireTimeout(Duration.ofSeconds(30))
.pendingAcquireMaxCount(500)
.evictInBackground(Duration.ofSeconds(60))
.metrics(true)
.build();
HttpClient httpClient = HttpClient.create(connectionProvider)
.compress(true)
.responseTimeout(Duration.ofSeconds(10))
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.doOnConnected(conn ->
conn.addHandlerLast(new ReadTimeoutHandler(10))
.addHandlerLast(new WriteTimeoutHandler(10)));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(configurer ->
configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024))
.build();
}
자주 만나는 함정 — 시험 직전 압축 노트
11편의 핵심을 정리합니다.
- 최적화 순서: 블로킹 제거 → 연결 풀 → Gzip → flatMap → 캐싱 → 서버 증설
- 블로킹 격리:
Mono.fromCallable(() -> ...).subscribeOn(Schedulers.boundedElastic()) boundedElastic()기본 최대 = CPU × 10 (최소 10), 큐 100k- Gzip 3종 세트: enabled + min-response-size + mime-types — 하나라도 없으면 의도대로 안 됨
- Gzip 클라이언트:
HttpClient.create().compress(true)또는Accept-Encoding헤더 수동 설정 - Gzip 효과는 로컬에서 안 보임 — 스테이징+JMeter로 측정
ConnectionProvider.builder()— maxConnections · maxIdleTime · pendingAcquireTimeout 필수pendingAcquireTimeout없으면 연결 풀 고갈 시 무한 대기 위험flatMap(mapper, concurrency)— 두 번째 인자로 동시성 제한 (기본 256)- concatMap = 순차 / flatMap = 병렬 순서 무관 / flatMapSequential = 병렬 순서 유지
Mono.cache()— 한 번 평가·캐싱,cache(Duration)으로 TTL 설정 가능- 외부 서비스 타임아웃 없으면 연결이 영원히 열려 있음 —
.timeout(Duration.ofSeconds(5))필수 - 성능 테스트는 JMeter, Postman은 단일 요청 → 부하 테스트 불가
- 서버 증설은 위 5가지 적용 후 마지막 수단
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.