|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +collection: project_diary |
| 4 | +title: 친구하자 프로젝트 일기 - OkHttp vs Spring WebFlux WebSocket, starter-webflux를 선택한 이유 |
| 5 | +description: > |
| 6 | + Gemini Live API를 WebSocket으로 연동하면서 OkHttp와 Spring WebFlux WebSocket 클라이언트를 비교하고, 우리가 spring-boot-starter-webflux를 선택한 이유를 정리했습니다. |
| 7 | +sitemap: false |
| 8 | +--- |
| 9 | + |
| 10 | +# [친구하자] OkHttp vs Spring WebFlux WebSocket — 우리가 starter-webflux를 선택한 이유 |
| 11 | + |
| 12 | +> Gemini Live API를 WebSocket으로 연동하면서 **클라이언트 라이브러리 선택**에 대한 고민을 정리합니다. |
| 13 | +
|
| 14 | +- [들어가며](#들어가며) |
| 15 | +- [1. 배경: Gemini와 WebSocket 클라이언트](#1-배경-gemini와-websocket-클라이언트) |
| 16 | +- [2. OkHttp란?](#2-okhttp란) |
| 17 | +- [3. Spring WebFlux WebSocket Client란?](#3-spring-webflux-websocket-client란) |
| 18 | +- [4. starter-web vs starter-webflux — 뭐가 다른가?](#4-starter-web-vs-starter-webflux--뭐가-다른가) |
| 19 | +- [5. 왜 OkHttp 대신 starter-webflux를 선택했는가?](#5-왜-okhttp-대신-starter-webflux를-선택했는가) |
| 20 | +- [6. wss://와 TLS — Reactor Netty가 해주는 일](#6-wss와-tls--reactor-netty가-해주는-일) |
| 21 | +- [정리](#정리) |
| 22 | + |
| 23 | +--- |
| 24 | + |
| 25 | +## 들어가며 |
| 26 | + |
| 27 | +Gemini Live API를 붙이려고 보니, 우리 서버가 **Gemini 쪽에 WebSocket 클라이언트**로 접속해야 하는 구조였다. 브라우저나 앱이 우리 서버에 WebSocket으로 붙는 것과는 반대 방향이다. 그래서 “서버에서 외부 WebSocket에 붙을 때 뭘 쓰지?”를 검토하게 됐고, 후보는 **OkHttp**와 **Spring WebFlux의 WebSocket 클라이언트** 두 가지였다. |
| 28 | + |
| 29 | +결론부터 말하면, 이미 프로젝트에 `spring-boot-starter-webflux`가 있어서 **OkHttp를 새로 넣지 않고** WebFlux의 `ReactorNettyWebSocketClient`를 쓰기로 했다. 이 글에서는 그때 비교했던 내용과, 왜 이 선택이 맞다고 봤는지 정리한다. |
| 30 | + |
| 31 | +--- |
| 32 | + |
| 33 | +## 1. 배경: Gemini와 WebSocket 클라이언트 |
| 34 | + |
| 35 | +Gemini Live API는 **`wss://`(WebSocket Secure)** 로 통신한다. 음성·영상 같은 스트리밍 데이터를 실시간으로 주고받기 위해서다. |
| 36 | + |
| 37 | +우리 아키텍처에서는: |
| 38 | + |
| 39 | +- **사용자(브라우저/앱)** ↔ **우리 서버**: 기존처럼 REST, WebSocket(우리 서버가 **서버** 역할) |
| 40 | +- **우리 서버** ↔ **Gemini**: 우리 서버가 **클라이언트**가 되어 Gemini에 WebSocket으로 접속 |
| 41 | + |
| 42 | +즉, “우리 백엔드가 Gemini에 **WebSocket 클라이언트**로 붙어서 오디오 스트림을 주고받는다”는 요구가 생긴 것이다. 이걸 구현하려면 **JVM에서 동작하는 WebSocket 클라이언트 라이브러리**가 필요했다. |
| 43 | + |
| 44 | +검토한 선택지는 두 가지였다. |
| 45 | + |
| 46 | +- **OkHttp** — Square(현 Block)의 HTTP/WebSocket 클라이언트 |
| 47 | +- **Spring WebFlux의 WebSocketClient** — Spring이 제공하는 리액티브 WebSocket 클라이언트 (`ReactorNettyWebSocketClient` 등) |
| 48 | + |
| 49 | +--- |
| 50 | + |
| 51 | +## 2. OkHttp란? |
| 52 | + |
| 53 | +OkHttp는 Square(현 Block)에서 만든 **HTTP/WebSocket 클라이언트 라이브러리**다. Android에서 HTTP 통신할 때 많이 쓰이고, 백엔드(JVM)에서도 외부 API 호출용으로 자주 쓴다. |
| 54 | + |
| 55 | +WebSocket을 쓰려면 별도 의존성을 추가해야 한다. |
| 56 | + |
| 57 | +```groovy |
| 58 | +implementation 'com.squareup.okhttp3:okhttp:4.12.0' |
| 59 | +``` |
| 60 | + |
| 61 | +### 동작 방식: 콜백 기반 |
| 62 | + |
| 63 | +OkHttp의 WebSocket API는 **콜백**이다. “연결됐을 때”, “메시지 왔을 때”, “에러 났을 때” 같은 이벤트를 **리스너**로 넘겨주는 방식이다. |
| 64 | + |
| 65 | +```java |
| 66 | +OkHttpClient client = new OkHttpClient(); |
| 67 | + |
| 68 | +Request request = new Request.Builder() |
| 69 | + .url("wss://generativelanguage.googleapis.com/...") |
| 70 | + .build(); |
| 71 | + |
| 72 | +client.newWebSocket(request, new WebSocketListener() { |
| 73 | + @Override |
| 74 | + public void onOpen(WebSocket webSocket, Response response) { /* 연결 성공 */ } |
| 75 | + |
| 76 | + @Override |
| 77 | + public void onMessage(WebSocket webSocket, String text) { /* 메시지 수신 */ } |
| 78 | + |
| 79 | + @Override |
| 80 | + public void onFailure(WebSocket webSocket, Throwable t, Response response) { /* 오류 */ } |
| 81 | +}); |
| 82 | +``` |
| 83 | + |
| 84 | +직관적이고 사용하기 쉽다. 다만 **Spring WebFlux**처럼 `Mono`/`Flux`로 짜인 코드와는 성격이 다르다. WebFlux에서는 “스트림”을 `Flux`로 표현하고, 에러 처리나 백프레셔(흐름 제어)를 연산자로 조합하는데, OkHttp는 “이벤트가 올 때마다 콜백 호출”이라서 **콜백을 Reactor 타입(Mono/Flux)으로 감싸는 변환 레이어**를 따로 만들지 않으면 리액티브 파이프라인과 자연스럽게 이어지지 않는다. |
| 85 | + |
| 86 | +### 요약 |
| 87 | + |
| 88 | +| 항목 | 내용 | |
| 89 | +| --------------- | ------------------------------------ | |
| 90 | +| 프로그래밍 모델 | 동기(Sync) / **콜백(Callback)** 기반 | |
| 91 | +| WebSocket 지원 | `OkHttpClient.newWebSocket()` | |
| 92 | +| TLS (wss://) | OkHttp 내부에서 처리 | |
| 93 | +| Spring 통합 | 없음 (필요하면 직접 연동) | |
| 94 | +| 의존성 크기 | 경량 (약 700KB) | |
| 95 | + |
| 96 | +--- |
| 97 | + |
| 98 | +## 3. Spring WebFlux WebSocket Client란? |
| 99 | + |
| 100 | +Spring WebFlux는 Spring 5부터 나온 **리액티브(Reactive) 웹 스택**이다. 내부적으로 **Project Reactor**(`Mono`, `Flux`)를 쓰고, 기본 네트워크 엔진으로 **Reactor Netty**를 사용한다. WebSocket **클라이언트**도 이 스택 위에 있다. 대표적으로 `ReactorNettyWebSocketClient`를 쓰면 된다. |
| 101 | + |
| 102 | +코드는 **스트림을 선언적으로** 다루는 형태다. |
| 103 | + |
| 104 | +```java |
| 105 | +WebSocketClient client = new ReactorNettyWebSocketClient(); |
| 106 | + |
| 107 | +client.execute( |
| 108 | + URI.create("wss://generativelanguage.googleapis.com/..."), |
| 109 | + session -> session |
| 110 | + .send(Flux.just(session.textMessage("hello"))) |
| 111 | + .then(session.receive() |
| 112 | + .map(WebSocketMessage::getPayloadAsText) |
| 113 | + .doOnNext(System.out::println) |
| 114 | + .then()) |
| 115 | +).subscribe(); |
| 116 | +``` |
| 117 | + |
| 118 | +`session.receive()`가 곧바로 **Flux**(메시지 스트림)를 주기 때문에, 다른 리액티브 스트림(예: 오디오 청크를 보내는 Flux)과 `merge`, `zip`, `flatMap` 등으로 조합하기 쉽다. 에러 처리도 `onErrorResume`, `retry` 같은 Reactor 연산자로 일관되게 할 수 있다. |
| 119 | + |
| 120 | +### “리액티브”가 여기서 의미하는 것 |
| 121 | + |
| 122 | +- **논블로킹**: 한 요청이 I/O를 기다리는 동안 스레드를 잡아두지 않고, 이벤트가 오면 그때 처리한다. |
| 123 | +- **스트림 모델**: 데이터가 “한 번에 한 덩어리”가 아니라 “시간에 걸쳐 흘러오는 스트림”으로 표현된다. 그래서 `Flux`로 “들어오는 메시지 스트림”, “나가는 메시지 스트림”을 다루기 좋다. |
| 124 | +- **백프레셔**: 상대가 보내는 속도가 우리가 처리하는 속도보다 빠를 때, “잠깐만 보내지 마” 같은 신호를 줄 수 있어서, 무한 버퍼 없이 흐름을 제어할 수 있다. |
| 125 | + |
| 126 | +Gemini Live처럼 **오디오 스트리밍**을 다룰 때는 “연속적인 데이터 흐름”을 하나의 파이프라인으로 표현하는 게 유리해서, 리액티브 모델이 잘 맞는다. |
| 127 | + |
| 128 | +### 요약 |
| 129 | + |
| 130 | +| 항목 | 내용 | |
| 131 | +| --------------- | -------------------------------------------------------- | |
| 132 | +| 프로그래밍 모델 | **리액티브 (Mono/Flux)** | |
| 133 | +| WebSocket 지원 | `ReactorNettyWebSocketClient` | |
| 134 | +| TLS (wss://) | Reactor Netty가 처리 | |
| 135 | +| Spring 통합 | Spring 생태계 일부 (빈, 설정, 보안 등과 자연스럽게 연동) | |
| 136 | +| 의존성 | `spring-boot-starter-webflux`에 이미 포함 | |
| 137 | + |
| 138 | +--- |
| 139 | + |
| 140 | +## 4. starter-web vs starter-webflux — 뭐가 다른가? |
| 141 | + |
| 142 | +“WebFlux를 쓰면 기존 Spring MVC랑 충돌하지 않나?”라고 할 수 있다. 실제로 **starter-web**과 **starter-webflux**는 역할이 다르고, 한 프로젝트에 둘 다 들어갈 수 있다. |
| 143 | + |
| 144 | +### `spring-boot-starter-web` (우리가 아는 그 환경) |
| 145 | + |
| 146 | +``` |
| 147 | +spring-boot-starter-web |
| 148 | + └── Spring MVC (서블릿 기반) |
| 149 | + └── Tomcat (기본 내장 서버) |
| 150 | + └── Jackson (JSON 직렬화) |
| 151 | +``` |
| 152 | + |
| 153 | +- **동기/블로킹** 모델. 요청 하나당 스레드 하나가 맡아서 처리한다. |
| 154 | +- `@RestController`, `@GetMapping` 같은 걸 쓰는 그 환경이다. |
| 155 | +- 우리 서버가 **REST API를 제공**하고, **WebSocket 서버**로 브라우저/앱과 연결하는 쪽은 이 스택 위에 있다. |
| 156 | + |
| 157 | +### `spring-boot-starter-webflux` |
| 158 | + |
| 159 | +``` |
| 160 | +spring-boot-starter-webflux |
| 161 | + └── Spring WebFlux (리액티브) |
| 162 | + └── Reactor Netty (서버/클라이언트 엔진) |
| 163 | + └── Project Reactor (Mono, Flux) |
| 164 | + └── Jackson (JSON 직렬화) |
| 165 | +``` |
| 166 | + |
| 167 | +- **비동기/논블로킹** 모델. 이벤트 루프 기반에 가깝다. |
| 168 | +- **WebClient**(리액티브 HTTP 클라이언트), **ReactorNettyWebSocketClient**(WebSocket 클라이언트) 등이 이 스택에 포함된다. |
| 169 | +- 우리는 **서버**는 MVC로 두고, **Gemini에 나가는 WebSocket 클라이언트**만 WebFlux 쪽을 쓰는 식으로 가져갔다. |
| 170 | + |
| 171 | +### 우리 프로젝트의 build.gradle |
| 172 | + |
| 173 | +```groovy |
| 174 | +implementation 'org.springframework.boot:spring-boot-starter-web' // MVC (우리 서버) |
| 175 | +implementation 'org.springframework.boot:spring-boot-starter-webflux' // WebFlux (Gemini 클라이언트용) |
| 176 | +implementation 'org.springframework.boot:spring-boot-starter-websocket' // WebSocket 서버 (브라우저/앱 연결용) |
| 177 | +``` |
| 178 | + |
| 179 | +- **starter-web** → 사용자 요청을 받는 **서버**(REST, 일반 HTTP). |
| 180 | +- **starter-websocket** → 사용자와의 **WebSocket 서버** (매칭 알림 등). |
| 181 | +- **starter-webflux** → **Gemini에 WebSocket 클라이언트**로 접속할 때 사용. |
| 182 | + |
| 183 | +역할이 나뉘어 있어서, “MVC만 쓰다가 WebFlux 일부만 가져와서 클라이언트로만 쓴다”는 구성이 가능하다. |
| 184 | + |
| 185 | +--- |
| 186 | + |
| 187 | +## 5. 왜 OkHttp 대신 starter-webflux를 선택했는가? |
| 188 | + |
| 189 | +### 이유 1: 이미 있는 의존성으로 충분하다 |
| 190 | + |
| 191 | +가장 실질적인 이유다. `spring-boot-starter-webflux`가 이미 들어 있어서, **같은 목적(외부 WebSocket 클라이언트)** 을 위해 OkHttp를 추가할 필요가 없었다. OkHttp를 넣으면 “WebSocket 클라이언트 라이브러리”가 두 개가 되고, 빌드 크기와 관리할 포인트만 늘어난다. |
| 192 | + |
| 193 | +### 이유 2: Spring 생태계와 한 줄로 맞출 수 있다 |
| 194 | + |
| 195 | +`ReactorNettyWebSocketClient`는 Spring이 제공·관리하는 빈으로 쓰기 쉽고, Spring Security, 설정 프로퍼티(`@ConfigurationProperties`), 빈 라이프사이클과 자연스럽게 맞는다. OkHttp를 쓰면 “연결 풀 설정”, “타임아웃”, “TLS 설정” 등을 Spring 쪽과 어떻게 맞출지 직접 설계해야 한다. |
| 196 | + |
| 197 | +### 이유 3: 리액티브 파이프라인과 같은 언어로 짤 수 있다 |
| 198 | + |
| 199 | +Gemini Live는 **오디오 스트리밍**처럼 연속적인 데이터를 주고받는다. 이걸 `Flux`로 표현하면: |
| 200 | + |
| 201 | +- 스트림 조합(예: 마이크 입력 Flux → 인코딩 → WebSocket send) |
| 202 | +- 백프레셔, 에러 핸들링, 재시도 |
| 203 | + |
| 204 | +를 Reactor 연산자로 선언적으로 처리할 수 있다. OkHttp는 “메시지 올 때마다 콜백”이라서, 이 흐름을 Reactor와 이어 주는 래퍼를 만들지 않으면 스트리밍 로직이 나뉘고, 예외 전파·취소 처리도 직접 신경 써야 한다. |
| 205 | + |
| 206 | +정리하면, **이미 WebFlux가 있고, 스트리밍이 핵심**이라서 OkHttp를 추가하지 않고 WebFlux WebSocket 클라이언트를 쓰는 쪽이 더 자연스러웠다. |
| 207 | + |
| 208 | +--- |
| 209 | + |
| 210 | +## 6. wss://와 TLS — Reactor Netty가 해주는 일 |
| 211 | + |
| 212 | +### wss://와 TLS가 뭔지 |
| 213 | + |
| 214 | +`wss://`는 WebSocket **Secure**다. `ws://`(암호화 없음)에 **TLS(Transport Layer Security)** 를 붙인 것이다. `https://`와 `http://` 관계와 같다. |
| 215 | + |
| 216 | +클라이언트가 `wss://` 서버에 접속하는 대략적인 단계는 다음과 같다. |
| 217 | + |
| 218 | +1. **TCP 연결** 수립 |
| 219 | +2. **TLS 핸드셰이크** — 서버 인증서 검증, 암호화 키 교환 |
| 220 | +3. 그 위에서 **WebSocket 업그레이드** (HTTP → WebSocket) |
| 221 | +4. 이후 WebSocket 프레임이 모두 암호화된 채널 위에서 오간다 |
| 222 | + |
| 223 | +TLS 핸드셰이크를 직접 다루려면 `SSLContext`, `SSLEngine` 같은 걸 건드려야 해서 꽤 번거롭다. |
| 224 | + |
| 225 | +### Reactor Netty는 wss://를 그대로 받아서 처리한다 |
| 226 | + |
| 227 | +`ReactorNettyWebSocketClient`에 `wss://` URI만 넘기면, **추가 설정 없이** TLS가 적용된다. |
| 228 | + |
| 229 | +```java |
| 230 | +client.execute( |
| 231 | + URI.create("wss://generativelanguage.googleapis.com/ws/..."), |
| 232 | + headers -> headers.add("x-goog-api-key", apiKey), |
| 233 | + session -> /* ... */ |
| 234 | +); |
| 235 | +``` |
| 236 | + |
| 237 | +내부적으로 Reactor Netty는: |
| 238 | + |
| 239 | +- URI 스킴이 `wss://`인지 보고 |
| 240 | +- JDK 기본 TrustStore(신뢰할 CA 목록)로 서버 인증서를 검증하고 |
| 241 | +- TLS 핸드셰이크를 수행한 뒤 |
| 242 | +- WebSocket 프로토콜을 진행한다 |
| 243 | + |
| 244 | +Google 서버는 공개 CA가 서명한 인증서를 쓰기 때문에, 기본 TrustStore만으로 검증이 통과한다. 그래서 **SSL 관련 코드를 따로 작성할 필요가 없다**. |
| 245 | + |
| 246 | +OkHttp도 TLS는 비슷하게 자동으로 처리해 준다. 차이는, Reactor Netty 쪽은 **비동기 I/O(NIO)** 로 동작해서 TLS 핸드셰이크 중에도 스레드를 블로킹하지 않고 이벤트 루프 위에서 처리한다는 점이다. “TLS가 편한가”보다는 “같은 스택에서 리액티브하게 끝까지 가져갈 수 있는가”가 선택 이유에 가깝다. |
| 247 | + |
| 248 | +--- |
| 249 | + |
| 250 | +## 정리 |
| 251 | + |
| 252 | +| 구분 | OkHttp | ReactorNettyWebSocketClient | |
| 253 | +| ------------------- | ------------------------------ | ---------------------------------- | |
| 254 | +| 별도 의존성 | 필요 | 불필요 (starter-webflux에 포함) | |
| 255 | +| 프로그래밍 모델 | 콜백 | 리액티브 (Mono/Flux) | |
| 256 | +| Spring 통합 | 수동 연동 필요 | 자연스러운 통합 | |
| 257 | +| TLS (wss://) | 자동 처리 | 자동 처리 | |
| 258 | +| 스트리밍/파이프라인 | Reactor와 연결하려면 래퍼 필요 | Flux로 선언적 처리 가능 | |
| 259 | +| 잘 맞는 상황 | 단순 HTTP 호출, Android 앱 등 | Spring WebFlux 기반, 스트리밍 연동 | |
| 260 | + |
| 261 | +OkHttp 자체가 나쁜 선택은 아니다. 다만 **이미 starter-webflux가 있고**, **Gemini처럼 스트리밍이 중요한 WebSocket 클라이언트**를 만든다면, 새 의존성 없이 Spring 생태계와 리액티브 파이프라인으로 맞추는 쪽이 우리 상황에서는 더 자연스러웠고, 그래서 **ReactorNettyWebSocketClient**를 선택했다. |
0 commit comments