[친구하자] OkHttp vs Spring WebFlux WebSocket — 우리가 starter-webflux를 선택한 이유
Gemini Live API를 WebSocket으로 연동하면서 OkHttp와 Spring WebFlux WebSocket 클라이언트를 비교하고, 우리가 spring-boot-starter-webflux를 선택한 이유를 정리했습니다.
Gemini Live API를 WebSocket으로 연동하면서 클라이언트 라이브러리 선택에 대한 고민을 정리합니다.
- 들어가며
- 1. 배경: Gemini와 WebSocket 클라이언트
- 2. OkHttp란?
- 3. Spring WebFlux WebSocket Client란?
- 4. starter-web vs starter-webflux — 뭐가 다른가?
- 5. 왜 OkHttp 대신 starter-webflux를 선택했는가?
- 6. wss://와 TLS — Reactor Netty가 해주는 일
- 정리
들어가며
Gemini Live API를 붙이려고 보니, 우리 서버가 Gemini 쪽에 WebSocket 클라이언트로 접속해야 하는 구조였다. 브라우저나 앱이 우리 서버에 WebSocket으로 붙는 것과는 반대 방향이다. 그래서 “서버에서 외부 WebSocket에 붙을 때 뭘 쓰지?”를 검토하게 됐고, 후보는 OkHttp와 Spring WebFlux의 WebSocket 클라이언트 두 가지였다.
결론부터 말하면, 이미 프로젝트에 spring-boot-starter-webflux가 있어서 OkHttp를 새로 넣지 않고 WebFlux의 ReactorNettyWebSocketClient를 쓰기로 했다. 이 글에서는 그때 비교했던 내용과, 왜 이 선택이 맞다고 봤는지 정리한다.
1. 배경: Gemini와 WebSocket 클라이언트
Gemini Live API는 wss://(WebSocket Secure) 로 통신한다. 음성·영상 같은 스트리밍 데이터를 실시간으로 주고받기 위해서다.
우리 아키텍처에서는:
- 사용자(브라우저/앱) ↔ 우리 서버: 기존처럼 REST, WebSocket(우리 서버가 서버 역할)
- 우리 서버 ↔ Gemini: 우리 서버가 클라이언트가 되어 Gemini에 WebSocket으로 접속
즉, “우리 백엔드가 Gemini에 WebSocket 클라이언트로 붙어서 오디오 스트림을 주고받는다”는 요구가 생긴 것이다. 이걸 구현하려면 JVM에서 동작하는 WebSocket 클라이언트 라이브러리가 필요했다.
검토한 선택지는 두 가지였다.
- OkHttp — Square(현 Block)의 HTTP/WebSocket 클라이언트
- Spring WebFlux의 WebSocketClient — Spring이 제공하는 리액티브 WebSocket 클라이언트 (
ReactorNettyWebSocketClient등)
2. OkHttp란?
OkHttp는 Square(현 Block)에서 만든 HTTP/WebSocket 클라이언트 라이브러리다. Android에서 HTTP 통신할 때 많이 쓰이고, 백엔드(JVM)에서도 외부 API 호출용으로 자주 쓴다.
WebSocket을 쓰려면 별도 의존성을 추가해야 한다.
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
동작 방식: 콜백 기반
OkHttp의 WebSocket API는 콜백이다. “연결됐을 때”, “메시지 왔을 때”, “에러 났을 때” 같은 이벤트를 리스너로 넘겨주는 방식이다.
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder()
.url("wss://generativelanguage.googleapis.com/...")
.build();
client.newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(WebSocket webSocket, Response response) { /* 연결 성공 */ }
@Override
public void onMessage(WebSocket webSocket, String text) { /* 메시지 수신 */ }
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) { /* 오류 */ }
});
직관적이고 사용하기 쉽다. 다만 Spring WebFlux처럼 Mono/Flux로 짜인 코드와는 성격이 다르다. WebFlux에서는 “스트림”을 Flux로 표현하고, 에러 처리나 백프레셔(흐름 제어)를 연산자로 조합하는데, OkHttp는 “이벤트가 올 때마다 콜백 호출”이라서 콜백을 Reactor 타입(Mono/Flux)으로 감싸는 변환 레이어를 따로 만들지 않으면 리액티브 파이프라인과 자연스럽게 이어지지 않는다.
요약
| 항목 | 내용 |
|---|---|
| 프로그래밍 모델 | 동기(Sync) / 콜백(Callback) 기반 |
| WebSocket 지원 | OkHttpClient.newWebSocket() |
| TLS (wss://) | OkHttp 내부에서 처리 |
| Spring 통합 | 없음 (필요하면 직접 연동) |
| 의존성 크기 | 경량 (약 700KB) |
3. Spring WebFlux WebSocket Client란?
Spring WebFlux는 Spring 5부터 나온 리액티브(Reactive) 웹 스택이다. 내부적으로 Project Reactor(Mono, Flux)를 쓰고, 기본 네트워크 엔진으로 Reactor Netty를 사용한다. WebSocket 클라이언트도 이 스택 위에 있다. 대표적으로 ReactorNettyWebSocketClient를 쓰면 된다.
코드는 스트림을 선언적으로 다루는 형태다.
WebSocketClient client = new ReactorNettyWebSocketClient();
client.execute(
URI.create("wss://generativelanguage.googleapis.com/..."),
session -> session
.send(Flux.just(session.textMessage("hello")))
.then(session.receive()
.map(WebSocketMessage::getPayloadAsText)
.doOnNext(System.out::println)
.then())
).subscribe();
session.receive()가 곧바로 Flux(메시지 스트림)를 주기 때문에, 다른 리액티브 스트림(예: 오디오 청크를 보내는 Flux)과 merge, zip, flatMap 등으로 조합하기 쉽다. 에러 처리도 onErrorResume, retry 같은 Reactor 연산자로 일관되게 할 수 있다.
“리액티브”가 여기서 의미하는 것
- 논블로킹: 한 요청이 I/O를 기다리는 동안 스레드를 잡아두지 않고, 이벤트가 오면 그때 처리한다.
- 스트림 모델: 데이터가 “한 번에 한 덩어리”가 아니라 “시간에 걸쳐 흘러오는 스트림”으로 표현된다. 그래서
Flux로 “들어오는 메시지 스트림”, “나가는 메시지 스트림”을 다루기 좋다. - 백프레셔: 상대가 보내는 속도가 우리가 처리하는 속도보다 빠를 때, “잠깐만 보내지 마” 같은 신호를 줄 수 있어서, 무한 버퍼 없이 흐름을 제어할 수 있다.
Gemini Live처럼 오디오 스트리밍을 다룰 때는 “연속적인 데이터 흐름”을 하나의 파이프라인으로 표현하는 게 유리해서, 리액티브 모델이 잘 맞는다.
요약
| 항목 | 내용 |
|---|---|
| 프로그래밍 모델 | 리액티브 (Mono/Flux) |
| WebSocket 지원 | ReactorNettyWebSocketClient |
| TLS (wss://) | Reactor Netty가 처리 |
| Spring 통합 | Spring 생태계 일부 (빈, 설정, 보안 등과 자연스럽게 연동) |
| 의존성 | spring-boot-starter-webflux에 이미 포함 |
4. starter-web vs starter-webflux — 뭐가 다른가?
“WebFlux를 쓰면 기존 Spring MVC랑 충돌하지 않나?”라고 할 수 있다. 실제로 starter-web과 starter-webflux는 역할이 다르고, 한 프로젝트에 둘 다 들어갈 수 있다.
spring-boot-starter-web (우리가 아는 그 환경)
spring-boot-starter-web
└── Spring MVC (서블릿 기반)
└── Tomcat (기본 내장 서버)
└── Jackson (JSON 직렬화)
- 동기/블로킹 모델. 요청 하나당 스레드 하나가 맡아서 처리한다.
@RestController,@GetMapping같은 걸 쓰는 그 환경이다.- 우리 서버가 REST API를 제공하고, WebSocket 서버로 브라우저/앱과 연결하는 쪽은 이 스택 위에 있다.
spring-boot-starter-webflux
spring-boot-starter-webflux
└── Spring WebFlux (리액티브)
└── Reactor Netty (서버/클라이언트 엔진)
└── Project Reactor (Mono, Flux)
└── Jackson (JSON 직렬화)
- 비동기/논블로킹 모델. 이벤트 루프 기반에 가깝다.
- WebClient(리액티브 HTTP 클라이언트), ReactorNettyWebSocketClient(WebSocket 클라이언트) 등이 이 스택에 포함된다.
- 우리는 서버는 MVC로 두고, Gemini에 나가는 WebSocket 클라이언트만 WebFlux 쪽을 쓰는 식으로 가져갔다.
우리 프로젝트의 build.gradle
implementation 'org.springframework.boot:spring-boot-starter-web' // MVC (우리 서버)
implementation 'org.springframework.boot:spring-boot-starter-webflux' // WebFlux (Gemini 클라이언트용)
implementation 'org.springframework.boot:spring-boot-starter-websocket' // WebSocket 서버 (브라우저/앱 연결용)
- starter-web → 사용자 요청을 받는 서버(REST, 일반 HTTP).
- starter-websocket → 사용자와의 WebSocket 서버 (매칭 알림 등).
- starter-webflux → Gemini에 WebSocket 클라이언트로 접속할 때 사용.
역할이 나뉘어 있어서, “MVC만 쓰다가 WebFlux 일부만 가져와서 클라이언트로만 쓴다”는 구성이 가능하다.
5. 왜 OkHttp 대신 starter-webflux를 선택했는가?
이유 1: 이미 있는 의존성으로 충분하다
가장 실질적인 이유다. spring-boot-starter-webflux가 이미 들어 있어서, 같은 목적(외부 WebSocket 클라이언트) 을 위해 OkHttp를 추가할 필요가 없었다. OkHttp를 넣으면 “WebSocket 클라이언트 라이브러리”가 두 개가 되고, 빌드 크기와 관리할 포인트만 늘어난다.
이유 2: Spring 생태계와 한 줄로 맞출 수 있다
ReactorNettyWebSocketClient는 Spring이 제공·관리하는 빈으로 쓰기 쉽고, Spring Security, 설정 프로퍼티(@ConfigurationProperties), 빈 라이프사이클과 자연스럽게 맞는다. OkHttp를 쓰면 “연결 풀 설정”, “타임아웃”, “TLS 설정” 등을 Spring 쪽과 어떻게 맞출지 직접 설계해야 한다.
이유 3: 리액티브 파이프라인과 같은 언어로 짤 수 있다
Gemini Live는 오디오 스트리밍처럼 연속적인 데이터를 주고받는다. 이걸 Flux로 표현하면:
- 스트림 조합(예: 마이크 입력 Flux → 인코딩 → WebSocket send)
- 백프레셔, 에러 핸들링, 재시도
를 Reactor 연산자로 선언적으로 처리할 수 있다. OkHttp는 “메시지 올 때마다 콜백”이라서, 이 흐름을 Reactor와 이어 주는 래퍼를 만들지 않으면 스트리밍 로직이 나뉘고, 예외 전파·취소 처리도 직접 신경 써야 한다.
정리하면, 이미 WebFlux가 있고, 스트리밍이 핵심이라서 OkHttp를 추가하지 않고 WebFlux WebSocket 클라이언트를 쓰는 쪽이 더 자연스러웠다.
6. wss://와 TLS — Reactor Netty가 해주는 일
wss://와 TLS가 뭔지
wss://는 WebSocket Secure다. ws://(암호화 없음)에 TLS(Transport Layer Security) 를 붙인 것이다. https://와 http:// 관계와 같다.
클라이언트가 wss:// 서버에 접속하는 대략적인 단계는 다음과 같다.
- TCP 연결 수립
- TLS 핸드셰이크 — 서버 인증서 검증, 암호화 키 교환
- 그 위에서 WebSocket 업그레이드 (HTTP → WebSocket)
- 이후 WebSocket 프레임이 모두 암호화된 채널 위에서 오간다
TLS 핸드셰이크를 직접 다루려면 SSLContext, SSLEngine 같은 걸 건드려야 해서 꽤 번거롭다.
Reactor Netty는 wss://를 그대로 받아서 처리한다
ReactorNettyWebSocketClient에 wss:// URI만 넘기면, 추가 설정 없이 TLS가 적용된다.
client.execute(
URI.create("wss://generativelanguage.googleapis.com/ws/..."),
headers -> headers.add("x-goog-api-key", apiKey),
session -> /* ... */
);
내부적으로 Reactor Netty는:
- URI 스킴이
wss://인지 보고 - JDK 기본 TrustStore(신뢰할 CA 목록)로 서버 인증서를 검증하고
- TLS 핸드셰이크를 수행한 뒤
- WebSocket 프로토콜을 진행한다
Google 서버는 공개 CA가 서명한 인증서를 쓰기 때문에, 기본 TrustStore만으로 검증이 통과한다. 그래서 SSL 관련 코드를 따로 작성할 필요가 없다.
OkHttp도 TLS는 비슷하게 자동으로 처리해 준다. 차이는, Reactor Netty 쪽은 비동기 I/O(NIO) 로 동작해서 TLS 핸드셰이크 중에도 스레드를 블로킹하지 않고 이벤트 루프 위에서 처리한다는 점이다. “TLS가 편한가”보다는 “같은 스택에서 리액티브하게 끝까지 가져갈 수 있는가”가 선택 이유에 가깝다.
정리
| 구분 | OkHttp | ReactorNettyWebSocketClient |
|---|---|---|
| 별도 의존성 | 필요 | 불필요 (starter-webflux에 포함) |
| 프로그래밍 모델 | 콜백 | 리액티브 (Mono/Flux) |
| Spring 통합 | 수동 연동 필요 | 자연스러운 통합 |
| TLS (wss://) | 자동 처리 | 자동 처리 |
| 스트리밍/파이프라인 | Reactor와 연결하려면 래퍼 필요 | Flux로 선언적 처리 가능 |
| 잘 맞는 상황 | 단순 HTTP 호출, Android 앱 등 | Spring WebFlux 기반, 스트리밍 연동 |
OkHttp 자체가 나쁜 선택은 아니다. 다만 이미 starter-webflux가 있고, Gemini처럼 스트리밍이 중요한 WebSocket 클라이언트를 만든다면, 새 의존성 없이 Spring 생태계와 리액티브 파이프라인으로 맞추는 쪽이 우리 상황에서는 더 자연스러웠고, 그래서 ReactorNettyWebSocketClient를 선택했다.