WebFlux 다음 단계 — 가상 스레드·RSocket·GraphQL 로드맵

2026-05-03AWS SAA-C03 스터디

Spring WebFlux 핵심 정리 시리즈 완결편 13편. WebFlux 다음 단계 — 가상 스레드(Java 21+ Project Loom)와 WebFlux 비교, RSocket 4가지 상호작용 모델, GraphQL + WebFlux, Kafka 이벤트 기반, Resilience4j, Kubernetes까지 학습 로드맵을 완결로 정리합니다.

📚 Spring WebFlux 핵심 정리 · 13편 / 14편 — 가상 스레드·RSocket·GraphQL 로드맵

이 글은 Spring WebFlux 핵심 정리 시리즈의 마지막 편(13편) 입니다.

1편에서 Spring MVC와 WebFlux의 스레드 모델 차이를 라디오 비유로 잡았고, R2DBC·CRUD·WebFilter·WebClient·SSE·성능 최적화·리액티브 마이크로서비스까지 12편을 함께 달렸습니다. 이번 편은 시리즈 마무리로 — "이제 큰 도구함을 갖췄으니, 다음은 어디로 갈까?"를 정리합니다.

비유로 표현하면 이렇습니다. 시리즈 완결 = 공구함에 공구가 가득 찼으니, 이제 어떤 작업에 어떤 공구를 쓸지 판단하는 단계입니다. 좋은 기술자는 도구를 많이 가진 것보다, 어떤 도구를 언제 쓰는지 아는 것이 핵심이에요.

📚 학습 노트

이 시리즈는 Spring 공식 문서, Project Reactor 공식 문서, Reactive Streams 명세, 여러 비동기 백엔드 학습 자료 등 공개 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.

13편은 각 기술을 깊게 다루지 않고 "왜 이 기술이 필요한가"에 집중합니다. 상세한 구현은 각 기술별 별도 시리즈에서 이어질 예정이에요.

기술 선택의 기준 — 문제에서 출발하기

기술 목록을 나열하기 전에, 어떤 문제를 해결하려는지부터 짚어야 합니다.

해결하려는 문제선택지
높은 동시성 요청 + 기존 MVC 코드 유지가상 스레드 (Java 21+)
서비스 간 REST보다 효율적인 통신gRPC (동기), RSocket (스트리밍)
비동기 이벤트 기반 서비스 간 통신Kafka
클라이언트가 필요한 데이터만 선택해서 요청GraphQL
반복 DB 조회로 응답이 느림Redis 캐싱
특정 서비스 장애가 전체로 퍼짐Resilience4j
수십 개 마이크로서비스 배포·운영 복잡Docker + Kubernetes

가상 스레드 (Virtual Threads, Java 21+) — WebFlux의 대안인가?

가장 자주 나오는 질문이에요. "Java 21 가상 스레드가 나왔으니 WebFlux 필요 없는 거 아닌가?"

먼저 가상 스레드가 등장한 배경을 이해해야 합니다. WebFlux는 높은 동시성을 달성하지만, 코드가 Mono/Flux 중심 함수형 스타일로 바뀌어 러닝 커브가 높아요. 기존 동기 방식(JPA, try-catch, for 루프)을 그대로 쓰면서 논블로킹 수준의 성능을 낼 수 있다면 이상적이지 않을까요? Java 21의 Project Loom(가상 스레드)이 그 목표로 만들어졌습니다.

가상 스레드는 JVM이 관리하는 경량 스레드예요. 생성 비용은 KB 수준(OS 스레드의 1/1000 이하), 수백만 개를 동시에 만들 수 있고, I/O 대기 중 자동으로 다른 가상 스레드에 CPU를 양보합니다. 코드 스타일은 기존 동기 방식 그대로예요.

// Spring Boot 3.2+ — application.properties 한 줄로 활성화
// spring.threads.virtual.enabled=true

// 또는 코드로 설정
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadsProtocolHandlerCustomizer() {
    return protocolHandler ->
        protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}

// 이렇게 설정하면 기존 Spring MVC + JPA 코드가 가상 스레드에서 실행됨
@RestController
public class UserController {
    @GetMapping("/users/{id}")
    public User getUser(@PathVariable Long id) {
        // 이 메서드가 가상 스레드에서 실행 — JPA 호출도 OK
        return jpaUserRepository.findById(id).orElseThrow();
    }
}

두 기술을 비교하면:

항목가상 스레드Spring WebFlux
코드 스타일동기 (기존 방식)비동기 (Mono/Flux)
러닝 커브낮음높음
기존 코드 호환JPA·JDBC 그대로R2DBC로 교체 필요
스트리밍·취소 전파제한적강력 (Flux·cancel)
언제 선택?기존 MVC 코드베이스 유지처음부터 반응형 설계

여기서 시험 함정이 하나 있어요. 가상 스레드는 WebFlux의 대체가 아닙니다. 두 기술은 강점 영역이 달라요. 스트리밍 응답, 취소 전파(5편에서 다룬 요청 취소 전파), 백프레셔 같은 Reactor 고유 기능은 가상 스레드로는 구현하기 어렵습니다. "R2DBC를 이미 쓰고 있다면 WebFlux 유지가 맞다, 기존 JPA 프로젝트에 동시성을 추가하려면 가상 스레드가 더 쉽다" — 이 기준이 실용적입니다.

RSocket — 반응형 네트워크 프로토콜

gRPC가 HTTP/2 기반의 동기 통신이라면, RSocket은 TCP/WebSocket 위에서 동작하는 완전히 새로운 반응형 프로토콜입니다.

RSocket의 가장 큰 특징은 4가지 상호작용 모델을 지원한다는 점이에요.

  • Request-Response — REST와 유사, 요청 하나에 응답 하나
  • Request-Stream — 서버 스트리밍 (SSE와 유사하지만 양방향 가능)
  • Fire-and-Forget — 응답 불필요한 이벤트 전송 (로그·알림)
  • Channel — 양방향 스트리밍 (WebSocket과 유사)

그리고 백프레셔가 프로토콜 레벨에서 내장되어 있어요. Reactor Flux의 request(n) 백프레셔 메커니즘이 네트워크 프로토콜 자체에 구현되어 있어서, 서비스 간 통신에서 진짜 백프레셔 제어가 가능합니다.

// RSocket 서버 컨트롤러
@Controller
public class StockRSocketController {

    @MessageMapping("stock.price")
    public Mono<StockDto> getStockPrice(String ticker) {
        return stockService.getPrice(ticker);  // Request-Response
    }

    @MessageMapping("stock.stream")
    public Flux<StockDto> streamStockPrices(String ticker) {
        return stockService.getPriceStream(ticker);  // Request-Stream
    }

    @MessageMapping("stock.alert")
    public Mono<Void> sendAlert(AlertRequest alert) {
        return alertService.send(alert);  // Fire-and-Forget
    }
}

여기서 시험 함정이 하나 있어요. RSocket은 WebSocket 위에서도 동작합니다. TCP만 가능한 게 아니라, WebSocket 위에서도 RSocket 프로토콜을 올릴 수 있어요. 브라우저에서는 TCP 직접 연결이 불가능하니, 브라우저 클라이언트가 필요한 경우 WebSocket 위 RSocket을 씁니다.

GraphQL + WebFlux — 유연한 API 쿼리

REST API의 고질적인 문제 두 가지가 있어요.

Over-fetching — 화면에서 이름만 필요한데 고객 전체 정보(이름·이메일·전화·주소·생성일)가 다 옵니다.

Under-fetching — 포트폴리오 페이지에서 종목명과 현재가가 필요한데, 포트폴리오 API에는 현재가가 없어서 각 종목마다 주가 API를 추가로 호출해야 합니다.

GraphQL은 클라이언트가 정확히 필요한 필드만 요청하게 해줍니다.

# 클라이언트가 필요한 것만 선택
query {
    customer(id: 1) {
        name
        portfolio {
            ticker
            quantity
            currentPrice   # 현재가도 한 번에
        }
    }
}

Spring Boot 3.0+에서는 Spring for GraphQL이 WebFlux와 통합됩니다.

@Controller
public class CustomerGraphQLController {

    @QueryMapping
    public Mono<Customer> customer(@Argument Integer id) {
        return customerService.getCustomer(id);
    }

    // 중첩 필드 해석 — PortfolioItem의 currentPrice 계산
    @SchemaMapping(typeName = "PortfolioItem")
    public Mono<Integer> currentPrice(PortfolioItem item) {
        return stockServiceClient.getStockPrice(item.getTicker())
                .map(StockDto::getPrice);
    }

    // GraphQL Subscription — SSE/WebSocket으로 실시간 데이터
    @SubscriptionMapping
    public Flux<StockDto> stockPrices(@Argument String ticker) {
        return stockServiceClient.getStockPriceStream()
                .filter(s -> s.getTicker().equals(ticker));
    }
}

GraphQL의 @SubscriptionMapping은 WebFlux의 Flux와 자연스럽게 연결됩니다. SSE나 WebSocket으로 실시간 데이터를 클라이언트에 보낼 수 있어요.

Kafka — 이벤트 기반 비동기 통신

12편에서 WebClient로 서비스 간 동기 HTTP 통신을 다뤘습니다. 하지만 "결과를 바로 받을 필요 없는 경우"(이메일 발송, 알림 처리, 통계 집계)에는 Kafka 같은 메시지 브로커를 쓰는 게 더 적합해요.

서비스 A → HTTP → 서비스 B (강한 결합: B가 다운되면 A도 실패)

서비스 A → Kafka Topic → 서비스 B (약한 결합: B가 다운돼도 A는 계속 진행)
                        ↘ 서비스 C  (fan-out: 같은 이벤트를 여러 서비스가 수신)

Reactor Kafka로 WebFlux와 통합할 수 있어요.

// Kafka Producer — 주식 거래 이벤트 발행
public Mono<Void> publishTradeEvent(StockTradeEvent event) {
    return kafkaSender.send(Mono.just(
            SenderRecord.create(
                new ProducerRecord<>("stock-trades", event.getTicker(), event),
                event.getTicker()
            )
    )).then();
}

// Kafka Consumer — 거래 이벤트 처리 (WebFlux 파이프라인)
kafkaReceiver.receive()
        .flatMap(record -> {
            return notificationService.notifyCustomer(record.value())
                    .doOnSuccess(v -> record.receiverOffset().acknowledge());
        })
        .retryWhen(Retry.backoff(3, Duration.ofSeconds(5)))
        .subscribe();

Resilience4j — 탄력성 패턴

12편에서 간단한 타임아웃+fallback을 다뤘습니다. 운영 환경에서는 더 정교한 Circuit Breaker가 필요합니다.

Resilience4j의 Circuit Breaker는 세 가지 상태로 전환해요.

  • CLOSED — 정상 상태, 모든 요청 통과
  • OPEN — 실패율이 임계값(예: 50%) 초과 시 활성화. 요청을 즉시 차단하고 fallback 반환
  • HALF-OPEN — 일정 시간(예: 30초) 후 일부 요청을 허용하여 서비스 복구 여부 확인
@CircuitBreaker(name = "customer-service", fallbackMethod = "getCustomerFallback")
@TimeLimiter(name = "customer-service")
@Retry(name = "customer-service")
public Mono<CustomerDto> getCustomer(Integer customerId) {
    return webClient.get().uri("/customer/{id}", customerId)
            .retrieve().bodyToMono(CustomerDto.class);
}

public Mono<CustomerDto> getCustomerFallback(Integer customerId, Exception ex) {
    return Mono.just(CustomerDto.builder().id(customerId).name("Unknown").build());
}

여기서 시험 함정이 하나 있어요. Resilience4j는 리액티브 어댑터가 별도입니다. resilience4j-reactor 모듈을 추가해야 Mono/Flux와 통합이 됩니다. 기본 모듈만으로는 리액티브 파이프라인에서 동작하지 않아요.

또 하나 — Bulkhead 패턴도 함께 알아두세요. 특정 서비스 호출에 사용할 동시 요청 수를 제한해서, 느린 서비스 하나가 전체 연결 풀을 점유하지 못하도록 막는 패턴입니다.

분산 추적 — TraceID 전파

마이크로서비스에서 요청이 여러 서비스를 거쳐 가면, 문제가 생겼을 때 어느 서비스에서 어떤 일이 일어났는지 추적하기 어렵습니다. 분산 추적(Distributed Tracing)이 이 문제를 해결해요.

여기서 시험 함정이 하나 있어요. Spring WebFlux에서 TraceID는 ThreadLocal이 아니라 Reactor Context로 전파됩니다. 기존 Spring MVC에서는 각 요청이 독립적인 스레드를 갖기 때문에 ThreadLocal에 TraceID를 저장하면 됐어요. 하지만 WebFlux는 이벤트 루프 스레드가 여러 요청을 처리하므로, ThreadLocal을 쓰면 TraceID가 섞여버립니다. Reactor Context에 TraceID를 넣어야 해요.

Micrometer Tracing (Spring Boot 3.x 기준)이 이 작업을 자동으로 처리해줍니다. WebClient를 통한 서비스 간 호출에서 traceparent 헤더를 자동으로 전파해요.

추천 학습 로드맵

이 시리즈를 마쳤다면 다음 순서를 권장합니다.

현재 위치: Spring WebFlux 13편 완료

Phase 1 — 성능·캐싱 (2-4주)
├─ Redis Reactive (캐싱, Pub/Sub)
└─ Java 가상 스레드 (기존 MVC 마이그레이션 검토 시)

Phase 2 — 서비스 간 통신 (4-6주)
├─ gRPC (동기 서비스 간 통신, Protobuf)
├─ Kafka + Reactor Kafka (비동기 이벤트 기반)
└─ RSocket (반응형 스트리밍 통신) ← 선택

Phase 3 — 탄력성 (2-3주)
├─ Resilience4j (Circuit Breaker, Retry, Bulkhead)
└─ Spring Cloud Gateway (API 게이트웨이 리액티브 기반)

Phase 4 — API 설계 (2-3주)
└─ GraphQL + Spring for GraphQL

Phase 5 — 배포·운영 (4-8주)
├─ Docker (컨테이너화)
├─ Kubernetes (오케스트레이션, Service Discovery, HPA)
└─ Helm (K8s 패키지 관리)

Phase 6 — 모니터링 (2-4주)
├─ Micrometer + Prometheus + Grafana
└─ OpenTelemetry (분산 추적)

현업 Spring 개발자 기준 즉시 필요한 것부터 우선순위를 매기면:

★★★★★ Docker
★★★★★ Redis
★★★★☆ Kafka
★★★★☆ Kubernetes 기초
★★★★☆ gRPC
★★★☆☆ GraphQL
★★★☆☆ Resilience4j
★★★☆☆ RSocket

자주 만나는 함정 — 시리즈 전체 완결 압축 노트

13편이자 시리즈의 마지막 압축 노트입니다.

  • 가상 스레드 ≠ WebFlux 대체 — 스트리밍·취소 전파·백프레셔는 여전히 Reactor 고유 영역
  • 가상 스레드 선택 기준: 기존 JPA/JDBC 코드 유지 + 동시성 개선이 목적
  • WebFlux 선택 기준: 처음부터 반응형 설계 + 스트리밍 + R2DBC
  • RSocket 4모드: Request-Response / Request-Stream / Fire-and-Forget / Channel
  • RSocket은 WebSocket 위에서도 동작 (브라우저 클라이언트 지원)
  • GraphQL @SubscriptionMapping — Flux 반환으로 SSE/WebSocket 실시간 스트리밍
  • Kafka 선택 기준: 즉시 응답 불필요·fan-out·이벤트 재처리 필요
  • Resilience4j 리액티브 어댑터resilience4j-reactor 모듈 별도 추가 필요
  • Circuit Breaker 3단계: CLOSED → OPEN → HALF-OPEN
  • 분산 추적에서 TraceID 전파: ThreadLocal X → Reactor Context
  • kotlinx-coroutines-reactor 의존성으로 Kotlin suspend fun ↔ Mono 변환 자동화
  • 추천 학습 순서: Redis/Docker → Kafka/gRPC → K8s → GraphQL/RSocket

시리즈 돌아보기

13편에 걸쳐 함께 달려온 시리즈를 마무리합니다.

이벤트 루프 하나에서 출발해 마이크로서비스 아키텍처까지 왔습니다. 쌓인 개념들이 실제 코드로 구현될 때, 이 노트들이 옆에서 "아, 그거 함정이 있었지" 하고 떠올려지길 바랍니다. 13편 모두 함께해 주셔서 감사합니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!