RSocket — 리액티브 메시징 프로토콜
HTTP는 "클라이언트가 요청하면 서버가 응답하는" 구조다. 그런데 서버가 지속적으로 데이터를 밀어줘야 하고, 클라이언트도 동시에 데이터를 보내야 하고, 받는 쪽이 감당할 수 있는 속도로만 보내야 한다면? HTTP로 이 모든 걸 해결할 수 있을까?
RSocket이란
RSocket은 애플리케이션 레벨의 바이너리 메시징 프로토콜 입니다. TCP, WebSocket, Aeron 같은 전송 계층 위에서 동작하며, 양방향 통신과 멀티플렉싱을 기본으로 지원합니다.
핵심 특징을 한 줄씩 정리하면 이렇습니다.
- **바이너리 프로토콜 **: HTTP/1.1처럼 텍스트가 아니라 바이너리 프레임으로 통신합니다. 파싱이 빠르고 오버헤드가 적습니다
- ** 양방향(Bidirectional)**: 클라이언트와 서버 구분 없이 양쪽 모두 요청을 보낼 수 있습니다
- ** 멀티플렉싱 **: 하나의 연결에서 여러 스트림을 동시에 처리합니다. HTTP/2의 멀티플렉싱과 비슷한 개념입니다
- ** 배압(Backpressure)**: Reactive Streams 사양을 프로토콜 레벨에서 지원합니다. 수신자가 처리할 수 있는 만큼만 데이터를 요청합니다
- ** 연결 재개(Resumption)**: 네트워크가 끊겼다 복구되면, 새 연결을 맺지 않고 이전 세션을 이어갈 수 있습니다
┌──────────────────────────────────────────────┐
│ RSocket 프로토콜 스택 │
├──────────────────────────────────────────────┤
│ 애플리케이션 계층: 4가지 상호작용 모델 │
│ (Request-Response, Fire-and-Forget, │
│ Request-Stream, Channel) │
├──────────────────────────────────────────────┤
│ 프레이밍 계층: 바이너리 프레임 인코딩/디코딩 │
│ (SETUP, REQUEST_RESPONSE, REQUEST_STREAM, │
│ REQUEST_N, CANCEL, PAYLOAD, ERROR ...) │
├──────────────────────────────────────────────┤
│ 전송 계층: TCP / WebSocket / Aeron │
│ (Netty가 이 계층을 담당) │
└──────────────────────────────────────────────┘
RSocket은 Facebook(현 Meta), Netflix, Pivotal(현 VMware Tanzu)이 공동으로 설계했습니다. 특히 Netflix가 마이크로서비스 간 내부 통신에서 HTTP의 한계를 느끼고 주도적으로 개발에 참여했습니다.
4가지 상호작용 모델
RSocket의 가장 큰 특징은 4가지 상호작용 모델 을 프로토콜 레벨에서 제공한다는 것입니다. HTTP의 요청-응답만으로는 표현할 수 없는 패턴들을 깔끔하게 지원합니다.
1. Request-Response
가장 익숙한 패턴입니다. 요청 하나를 보내고 응답 하나를 받습니다.
// 클라이언트 — 요청을 보내고 단일 응답 수신
Mono<Payload> response = rSocket.requestResponse(
DefaultPayload.create("Hello") // 요청 페이로드
);
response.subscribe(payload ->
System.out.println("응답: " + payload.getDataUtf8())
);
HTTP의 GET/POST와 비슷하지만, RSocket은 바이너리 프레임이므로 헤더 오버헤드가 없고 하나의 연결을 멀티플렉싱합니다.
2. Fire-and-Forget
요청을 보내고 **응답을 기다리지 않습니다 **. 반환 타입이 Mono<Void>입니다.
// 메트릭 전송 — 응답이 필요 없는 시나리오
Mono<Void> result = rSocket.fireAndForget(
DefaultPayload.create("{\"cpu\": 72.5, \"memory\": 4096}")
);
result.subscribe(); // 보내기만 하고 끝
메트릭 수집, 로그 전송, 이벤트 알림처럼 "보내기만 하면 되는" 시나리오에 적합합니다. HTTP에서 응답 본문을 무시하더라도 서버는 여전히 응답을 만들어 보내야 하지만, Fire-and-Forget은 프로토콜 수준에서 응답 자체가 없습니다.
3. Request-Stream
요청 하나를 보내고 ** 여러 개의 응답을 스트림으로** 받습니다.
// 주식 시세 구독 — 하나의 요청으로 지속적인 데이터 수신
Flux<Payload> stream = rSocket.requestStream(
DefaultPayload.create("AAPL") // 애플 주식 시세 요청
);
stream
.doOnNext(payload ->
System.out.println("시세: " + payload.getDataUtf8()))
.take(100) // 100개만 받겠다는 의미 — 배압 제어
.subscribe();
SSE(Server-Sent Events)와 비슷하지만 결정적인 차이가 있습니다. ** 배압이 프로토콜에 내장 **되어 있어서 클라이언트가 REQUEST_N 프레임으로 "n개만 더 보내줘"라고 제어할 수 있습니다.
4. Channel (양방향 스트림)
양쪽 모두가 ** 동시에 스트림을 보내는** 패턴입니다. 가장 유연하고 복잡한 모델입니다.
// 양방향 채팅 — 클라이언트와 서버가 동시에 메시지 스트림을 교환
Flux<Payload> clientMessages = Flux.interval(Duration.ofSeconds(1))
.map(i -> DefaultPayload.create("클라이언트 메시지 #" + i));
Flux<Payload> serverResponses = rSocket.requestChannel(clientMessages);
serverResponses
.doOnNext(payload ->
System.out.println("서버로부터: " + payload.getDataUtf8()))
.subscribe();
WebSocket과 비슷해 보이지만, Channel은 ** 배압이 양방향으로 동작 **합니다. 클라이언트가 서버에게, 서버가 클라이언트에게 각각 독립적으로 흐름 제어를 할 수 있습니다.
4가지 모델 요약
| 모델 | 요청 | 응답 | 반환 타입 | 대표 사용 사례 |
|---|---|---|---|---|
| Request-Response | 1 | 1 | Mono<Payload> | API 호출, 단건 조회 |
| Fire-and-Forget | 1 | 0 | Mono<Void> | 메트릭, 로그 전송 |
| Request-Stream | 1 | N | Flux<Payload> | 시세 구독, 이벤트 스트림 |
| Channel | N | N | Flux<Payload> | 채팅, 양방향 동기화 |
왜 HTTP가 아닌 RSocket인가
HTTP/1.1과 HTTP/2도 훌륭한 프로토콜이지만, 리액티브 마이크로서비스 환경에서는 구조적인 한계가 있습니다.
배압 (Backpressure)
HTTP에는 배압 메커니즘이 없습니다. 서버가 응답을 쏟아부으면 클라이언트는 그걸 다 받아야 합니다. TCP 레벨의 흐름 제어(윈도우 사이즈)가 있긴 하지만, 이건 바이트 단위이지 메시지 단위가 아닙니다.
RSocket은 REQUEST_N 프레임으로 "앞으로 n개의 메시지만 보내줘" 라고 수신자가 직접 제어합니다.
수신자 → 송신자: REQUEST_N(n=5) // "5개만 보내줘"
송신자 → 수신자: PAYLOAD (1)
송신자 → 수신자: PAYLOAD (2)
송신자 → 수신자: PAYLOAD (3)
수신자 → 송신자: REQUEST_N(n=3) // "3개 더 보내도 돼"
송신자 → 수신자: PAYLOAD (4)
송신자 → 수신자: PAYLOAD (5)
송신자 → 수신자: PAYLOAD (6)
이건 Reactive Streams 사양(Publisher.subscribe → Subscription.request(n))이 네트워크 프로토콜로 확장된 것입니다.
양방향 스트리밍
HTTP/2도 스트리밍을 지원하지만 여전히 ** 클라이언트가 먼저 요청 **해야 합니다. 서버가 자발적으로 클라이언트에게 데이터를 보내려면 Server Push라는 별도 메커니즘이 필요하고, 브라우저에서는 이마저도 대부분 비활성화했습니다.
RSocket의 Channel 모델은 연결이 맺어진 순간부터 양쪽이 대등합니다. 역할 구분 없이 양방향으로 스트림을 보낼 수 있습니다.
연결 재개 (Resumption)
모바일 환경이나 불안정한 네트워크에서 TCP 연결이 끊기면, HTTP는 새 연결을 맺고 요청을 처음부터 다시 보내야 합니다. RSocket은 ** 세션 레벨의 Resumption**을 지원합니다.
1. 최초 연결: SETUP 프레임에 Resume Token 포함
2. 연결 끊김 감지
3. 새 TCP 연결 수립
4. RESUME 프레임 전송 (Resume Token + 마지막 수신 위치)
5. 서버가 Resume Token 검증 → 이전 세션 복구
6. 끊긴 지점부터 데이터 재전송
덕분에 스트리밍 중에 네트워크가 잠깐 끊겨도 처음부터 다시 시작할 필요가 없습니다.
Netty Transport — RSocket의 전송 계층
RSocket은 프로토콜 사양일 뿐이고, 실제 바이트를 네트워크로 보내는 건 ** 전송 계층 **의 역할입니다. RSocket-Java는 Netty를 기본 전송 계층으로 사용합니다.
왜 Netty인가
이 시리즈를 따라왔다면 이미 답을 알고 있을 것입니다.
- EventLoop 기반 비동기 I/O: RSocket은 리액티브 프로토콜이므로, 전송 계층도 논블로킹이어야 합니다. Netty의 EventLoop가 이걸 자연스럽게 해결합니다
- **ByteBuf와 메모리 풀링 **: RSocket 프레임을 바이트로 인코딩/디코딩할 때 Netty의
PooledByteBufAllocator가 메모리 할당 오버헤드를 줄여줍니다 - ChannelPipeline: RSocket 프레임 코덱, 길이 기반 프레이밍, SSL/TLS를 파이프라인에 조합할 수 있습니다
- **TCP + WebSocket 모두 지원 **: 같은 Netty 위에서 TCP 전송과 WebSocket 전송을 선택할 수 있습니다
내부 구조
RSocket-Java가 Netty 위에서 동작하는 구조를 살펴보면 이렇습니다.
┌─────────────────────────────────────────────┐
│ RSocket API (Mono/Flux 기반 상호작용 모델) │
├─────────────────────────────────────────────┤
│ RSocket-Core (프레임 생성, 스트림 관리) │
├─────────────────────────────────────────────┤
│ RSocket-Transport-Netty │
│ ┌─────────────────────────────────────┐ │
│ │ Netty ChannelPipeline │ │
│ │ ┌───────────┐ ┌────────────────┐ │ │
│ │ │ LengthField│ │ RSocket Frame │ │ │
│ │ │ Codec │ │ Codec │ │ │
│ │ └───────────┘ └────────────────┘ │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Netty EventLoop (NioEventLoopGroup) │
├─────────────────────────────────────────────┤
│ TCP / WebSocket │
└─────────────────────────────────────────────┘
TCP 전송의 경우, RSocket 프레임 앞에 3바이트 길이 필드 가 붙습니다. Netty의 LengthFieldBasedFrameDecoder가 이 길이를 읽고 프레임 경계를 잡아줍니다. TCP 프레이밍 글에서 다뤘던 그 패턴입니다.
WebSocket 전송의 경우, WebSocket 프레임 자체가 메시지 경계를 제공하므로 별도의 길이 필드가 필요 없습니다.
// RSocket 서버를 Netty TCP 전송으로 시작하는 코드
RSocketServer.create(
SocketAcceptor.with(new MyRSocketHandler())) // 요청 처리기
.bind(TcpServerTransport.create("localhost", 7000)) // Netty TCP 전송
.subscribe();
// WebSocket 전송으로 바꾸려면 전송 계층만 교체
RSocketServer.create(
SocketAcceptor.with(new MyRSocketHandler()))
.bind(WebsocketServerTransport.create("localhost", 8080)) // Netty WebSocket 전송
.subscribe();
전송 계층을 바꿔도 RSocket 핸들러 코드는 그대로입니다. 이게 프로토콜과 전송을 분리한 RSocket 설계의 장점입니다.
RSocket + Spring Boot
Spring Boot는 spring-boot-starter-rsocket을 통해 RSocket을 1급 시민(first-class citizen)으로 지원합니다. 내부적으로 Spring Messaging + RSocket-Java + Netty가 조합됩니다.
의존성 추가
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-rsocket</artifactId>
</dependency>
서버 설정
# application.yml
spring:
rsocket:
server:
port: 7000 # RSocket 서버 포트
transport: tcp # tcp 또는 websocket
@MessageMapping으로 요청 처리
Spring의 @MessageMapping을 사용하면, RSocket의 4가지 모델을 메서드 시그니처만으로 구분할 수 있습니다.
@Controller
public class GreetingController {
// Request-Response: Mono 입력 → Mono 출력
@MessageMapping("greeting")
public Mono<String> greeting(String name) {
return Mono.just("안녕하세요, " + name + "님!");
}
// Fire-and-Forget: 입력만 있고 반환은 Mono<Void>
@MessageMapping("log")
public Mono<Void> log(LogEvent event) {
System.out.println("로그 수신: " + event);
return Mono.empty();
}
// Request-Stream: 단일 입력 → Flux 출력
@MessageMapping("stock.{symbol}")
public Flux<StockPrice> stockStream(@DestinationVariable String symbol) {
return stockService.getStream(symbol); // 지속적으로 시세 전송
}
// Channel: Flux 입력 → Flux 출력
@MessageMapping("chat")
public Flux<ChatMessage> chat(Flux<ChatMessage> incoming) {
return incoming
.doOnNext(msg -> broadcast(msg)) // 받은 메시지 브로드캐스트
.switchMap(msg -> messageStream()); // 다른 사용자 메시지 스트림 반환
}
}
@MessageMapping의 값(예: "greeting")은 HTTP URL이 아니라 RSocket의 route 메타데이터 에 매핑됩니다. 클라이언트가 요청할 때 이 route를 지정합니다.
클라이언트 코드
// RSocketRequester — Spring이 제공하는 클라이언트 API
RSocketRequester requester = RSocketRequester.builder()
.tcp("localhost", 7000);
// Request-Response 호출
Mono<String> response = requester
.route("greeting") // route 메타데이터 지정
.data("개발자") // 요청 데이터
.retrieveMono(String.class); // 단일 응답
// Request-Stream 호출
Flux<StockPrice> prices = requester
.route("stock.AAPL")
.retrieveFlux(StockPrice.class); // 스트림 응답
HTTP의 RestTemplate이나 WebClient처럼, RSocketRequester가 연결 관리와 직렬화를 추상화합니다.
RSocket vs gRPC vs WebSocket
세 기술 모두 "HTTP/1.1보다 효율적인 통신"을 목표로 하지만, 설계 철학과 적합한 시나리오가 다릅니다.
| 구분 | RSocket | gRPC | WebSocket |
|---|---|---|---|
| 프로토콜 계층 | 애플리케이션 레벨 (TCP/WebSocket 위) | HTTP/2 위 | TCP 위 (HTTP 업그레이드) |
| ** 메시지 형식** | 바이너리 (형식 무관) | Protocol Buffers (스키마 필수) | 텍스트 또는 바이너리 (형식 무관) |
| ** 상호작용 모델** | 4가지 (R-R, FnF, Stream, Channel) | 4가지 (Unary, Server/Client/Bidi Streaming) | 양방향 메시지 (모델 정의 없음) |
| ** 배압** | 프로토콜 내장 (REQUEST_N) | HTTP/2 흐름 제어 (바이트 단위) | 없음 (애플리케이션이 직접 구현) |
| ** 연결 재개** | Resumption 지원 | 없음 (재연결 필요) | 없음 (재연결 필요) |
| ** 스키마 정의** | 선택사항 | 필수 (.proto 파일) | 없음 |
| ** 브라우저 지원** | WebSocket 전송으로 가능 | grpc-web 프록시 필요 | 네이티브 지원 |
| ** 생태계** | Spring 중심 | 언어 중립적, 폭넓은 생태계 | 모든 언어/프레임워크 |
| ** 대표 사용처** | 리액티브 마이크로서비스 | 이종 시스템 간 RPC | 실시간 웹 (채팅, 게임) |
정리하면 이렇습니다.
- gRPC: 스키마(proto) 기반으로 서비스 계약을 강제하고 싶을 때. 언어가 다양한 마이크로서비스 환경에 적합합니다
- WebSocket: 브라우저와 서버 간 실시간 양방향 통신이 핵심일 때. 프로토콜이 단순해서 진입 장벽이 낮습니다
- RSocket: 리액티브 스트림 + 배압이 중요한 백엔드 간 통신에 적합합니다. Spring/Reactor 생태계와 궁합이 좋습니다
gRPC도 양방향 스트리밍을 지원하지만, 배압은 HTTP/2의 바이트 단위 흐름 제어에 의존합니다. RSocket처럼 "메시지 n개만 보내줘"라는 논리적 배압은 아닙니다.
실제 사용 사례
Spring Cloud Gateway RSocket
Spring Cloud Gateway는 HTTP 게이트웨이로 유명하지만, RSocket 버전 도 존재합니다. 마이크로서비스들이 게이트웨이에 RSocket으로 등록하고, 클라이언트 요청을 라우팅합니다.
클라이언트 ──RSocket──→ Gateway ──RSocket──→ 서비스 A
│
└──RSocket──→ 서비스 B
HTTP 게이트웨이와 달리 양방향 스트리밍과 배압이 게이트웨이를 통과해도 유지됩니다.
마이크로서비스 간 내부 통신
Netflix가 RSocket을 만든 원래 목적입니다. 서비스 간 통신에서 HTTP의 한계를 느낀 지점들이 있습니다.
- **연결 비용 **: HTTP/1.1은 요청마다 연결을 맺거나 커넥션 풀을 관리해야 합니다. RSocket은 하나의 연결을 멀티플렉싱합니다
- ** 불필요한 응답 대기 **: 메트릭이나 로그 전송에 Fire-and-Forget을 쓰면 응답 대기 시간이 사라집니다
- ** 과부하 방지 **: 트래픽이 몰릴 때 배압으로 수신자가 감당 가능한 속도를 제어합니다
IoT / 모바일 환경
연결이 불안정한 환경에서 Resumption이 빛을 발합니다. 모바일 앱이 Wi-Fi에서 셀룰러로 전환될 때, 기존 세션을 유지하면서 끊긴 데이터만 재전송할 수 있습니다.
정리
RSocket은 "리액티브 시대의 통신 프로토콜"이라고 볼 수 있습니다. HTTP가 해결하지 못하는 배압, 양방향 스트리밍, 연결 재개를 프로토콜 레벨에서 지원하고, Netty가 그 밑에서 효율적인 비동기 I/O를 담당합니다.
기억할 포인트를 정리하면 이렇습니다.
- RSocket은 **4가지 상호작용 모델 **(R-R, FnF, Stream, Channel)을 제공하고, 메서드 시그니처로 구분합니다
- ** 배압이 프로토콜에 내장 **되어 있어서
REQUEST_N프레임으로 수신자가 흐름을 제어합니다 - Netty가 전송 계층 을 담당하며, TCP와 WebSocket 전송을 바꿔 끼울 수 있습니다
- Spring Boot에서는
@MessageMapping으로 HTTP만큼 쉽게 RSocket 서버를 만들 수 있습니다 - gRPC는 스키마 기반 계약이 강점이고, RSocket은 리액티브 배압이 강점입니다. 용도에 따라 선택하면 됩니다