|
| 1 | +--- |
| 2 | +title: "SSE를 Async Generator로 : 진짜 스트림처럼 다루기" |
| 3 | +createdAt: 2025-11-23 |
| 4 | +category: JavaScript |
| 5 | +description: Server Sent Events (SSE) 를 외부에서도 순차적으로 어떻게 다룰수 있을까? 종합설계프로젝트를 진행하면서 LLM으로부터 스트리밍 응답을 깔끔하게 처리하기 위해 Generator 를 활용한 방법을 공유합니다. |
| 6 | +comment: true |
| 7 | +head: |
| 8 | + - - meta |
| 9 | + - name: keywords |
| 10 | + content: SSE, Server Sent Events, Async Generator, JavaScript, EventSource |
| 11 | +--- |
| 12 | + |
| 13 | +GPT, Gemini, Claude 등 다양한 LLM 모델들이 등장하면서, 이 모델들과 상호작용하는 방법도 다양해지고 있습니다. 특히, 서버에서 클라이언트로 실시간으로 데이터를 푸시하는 `Server Sent Events (SSE)` 는 LLM 의 스트리밍 응답을 처리하는데 유용한 기술입니다. |
| 14 | + |
| 15 | +<div style="display:flex"> |
| 16 | +<img src="./img/server-sent-event-generator/chatgpt.png" width="50%"/> |
| 17 | +<img src="./img/server-sent-event-generator/chatgpt-event-stream.png" width="50%"/> |
| 18 | +</div> |
| 19 | + |
| 20 | +그렇다면 먼저 `Server Sent Events` 는 뭔지 알아보겠습니다 |
| 21 | + |
| 22 | +## ⚡️ Server Sent Events (SSE) 란? |
| 23 | + |
| 24 | +> Server Sent Events(SSE) 는 서버가 클라이언트(브라우저)에 단방향으로 지속적으로 데이터를 push 할 수 있게 해주는 기술 |
| 25 | +
|
| 26 | +<center> |
| 27 | +<img src="./img/server-sent-event-generator/sse.png" width="500px"/> |
| 28 | +</center> |
| 29 | + |
| 30 | +SSE 는 HTTP 프로토콜을 기반으로 하고, 클라이언트가 서버에 연결을 열면 서버는 이벤트 스트림을 통해 데이터를 지속적으로 전송할 수 있습니다. 클라이언트는 `EventSource` API 나 `fetch` API 를 사용하여 SSE 스트림에 연결할 수 있습니다. |
| 31 | + |
| 32 | +:::warning 실시간 데이터 전송에 SSE 가 최선일까? 단점은 없을까? |
| 33 | + |
| 34 | +Chrome 브라우저 기준으로 동일한 도메인에 대해 최대 `6개` 의 HTTP 연결을 허용합니다. <br/> |
| 35 | +SSE 는 `keep-alive` 를 통해 연결을 지속적으로 유지하기 때문에, SSE 연결이 많아지면 다른 요청에 영향을 줄 수 있습니다. <br/> |
| 36 | +(단, HTTP/2 에서는 멀티플렉싱을 지원해주기 때문에 그나마 덜한 편입니다) |
| 37 | + |
| 38 | +::: |
| 39 | + |
| 40 | +| 헤더 | 역할 | |
| 41 | +| ---------------------------------------- | ---------------------------------------- | |
| 42 | +| `Content-Type: text/event-stream` | 이 응답이 **SSE 스트림**임을 명시 | |
| 43 | +| `Cache-Control: no-cache` | 중간 프록시/브라우저에서 **캐싱 방지** | |
| 44 | +| `Connection: keep-alive` (주로 HTTP/1.1) | 연결을 **끊지 않도록 유지** | |
| 45 | +| **(HTTP/2일 경우 프레이밍 자동 처리)** | chunked 없이도 **스트림 단위 전송 지원** | |
| 46 | + |
| 47 | +### 😙 SSE 예시 |
| 48 | + |
| 49 | +Express 서버에서 간단한 SSE 는 다음과 같이 구현할 수 있습니다. |
| 50 | + |
| 51 | +```js |
| 52 | +app.get("/stream", (req, res) => { |
| 53 | + res.setHeader("Content-Type", "text/event-stream"); |
| 54 | + res.setHeader("Cache-Control", "no-cache"); |
| 55 | + res.setHeader("Connection", "keep-alive"); |
| 56 | + |
| 57 | + let index = 0; |
| 58 | + |
| 59 | + const intervalId = setInterval(() => { |
| 60 | + res.write(`data: 메시지 ${index++}\n\n`); |
| 61 | + |
| 62 | + if (index >= 5) { |
| 63 | + clearInterval(intervalId); |
| 64 | + res.end(); |
| 65 | + } |
| 66 | + }, 1000); |
| 67 | +}); |
| 68 | +``` |
| 69 | + |
| 70 | +클라이언트에서는 [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) API 를 사용해 SSE 스트림에 연결할 수 있습니다. |
| 71 | + |
| 72 | +```js |
| 73 | +const eventSource = new EventSource("/stream"); |
| 74 | + |
| 75 | +eventSource.onmessage = (event) => { |
| 76 | + console.log("서버로부터 메시지 수신:", event.data); |
| 77 | +}; |
| 78 | +``` |
| 79 | + |
| 80 | +또는 `fetch` API 를 사용하여 SSE 스트림을 처리할 수도 있습니다. |
| 81 | + |
| 82 | +```js |
| 83 | +async function fetchSSE(url) { |
| 84 | + const response = await fetch(url); |
| 85 | + const reader = response.body.getReader(); |
| 86 | + const decoder = new TextDecoder("utf-8"); |
| 87 | + |
| 88 | + while (true) { |
| 89 | + const { done, value } = await reader.read(); |
| 90 | + if (done) break; |
| 91 | + |
| 92 | + const chunk = decoder.decode(value, { stream: true }); |
| 93 | + console.log("서버로부터 메시지 수신:", chunk); |
| 94 | + } |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +:::details 🤔 두 방식의 차이점이 뭔데 ? |
| 99 | + |
| 100 | +| 기능 | `fetch` + `ReadableStream` | `EventSource` | |
| 101 | +| -------------------- | -------------------------------- | -------------------------------------------------- | |
| 102 | +| 연속적인 데이터 읽기 | `reader.read()` 루프 필요 | `onmessage` 로 자동으로 연결유지 및 데이터 전달 | |
| 103 | +| SSE 데이터 파싱 | `data:`, `event:` 직접 파싱 필요 | 브라우저가 자동으로 파싱하여 `message` 이벤트 발행 | |
| 104 | +| 자동 재연결 | 직접 구현 필요 | 기본적으로 자동 재연결 | |
| 105 | +| HTTP 헤더 설정 | 가능 | 불가능 (인증토큰 붙이는 등에 제약) | |
| 106 | + |
| 107 | +::: |
| 108 | + |
| 109 | +## 🧐 프론트엔드에서 이걸 어떻게 함수로 추상화할까 ? |
| 110 | + |
| 111 | +보통 프론트엔드를 개발하면서 API를 `Promise` 를 리턴하는 함수로 추상화하여 사용하는 경우가 많습니다. <br/> |
| 112 | +이 함수는 **한 번만 응답**을 받고 끝내기 때문에 `Promise` 하나만 리턴하는 함수로 충분합니다. |
| 113 | + |
| 114 | +```js |
| 115 | +export async function getUserById(userId) { |
| 116 | + const response = await fetch(`/api/users/${userId}`); |
| 117 | + if (!response.ok) throw new Error("사용자 정보를 불러오는데 실패했습니다."); |
| 118 | + return response.json(); |
| 119 | +} |
| 120 | +``` |
| 121 | + |
| 122 | +### ❌ Promise 로는 SSE 스트림을 다룰 수 없다! |
| 123 | + |
| 124 | +그렇다면 `Promise` 로 SSE 스트림을 다룰 수 있을까요? |
| 125 | + |
| 126 | +SSE 는 **응답이 끝나지 않고**, 여러번 데이터(이벤트)를 지속적으로 받습니다. <br/> |
| 127 | +그래서 아래처럼 SSE 를 `Promise` 로 감싸면 문제가 생깁니다 |
| 128 | + |
| 129 | +```js |
| 130 | +async function subscribeServerSentEvents() { |
| 131 | + const response = await fetch("/api/stream", { |
| 132 | + headers: { Accept: "text/event-stream" }, |
| 133 | + }); |
| 134 | + |
| 135 | + // ❌ response.json() 같은걸 할 수 없음 |
| 136 | + // Stream 이 끝나지 않아 Promise 가 끝까지 Resolve 되지 않거나 |
| 137 | + // 한번만 읽고 끝나버림 |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +결국 SSE Stream 은 값 하나를 돌려주는 함수가 아니라, <br/> |
| 142 | +값들이 여러번 도착하는 특정을 갖는다는 점에서 `Promise` 로는 다룰 수 없습니다. |
| 143 | + |
| 144 | +## ✅ Async Generator 로 SSE 스트림 다루기 |
| 145 | + |
| 146 | +그렇다면 SSE 스트림을 어떻게 함수로 추상화할 수 있을까요? <br/> |
| 147 | +결론부터 말하자면 `Async Generator` 를 사용하면 됩니다! |
| 148 | + |
| 149 | +### 🤨 Generator 가 뭔데 ? |
| 150 | + |
| 151 | +`Generator` 는 함수의 실행을 중간에 멈췄다가 다시 재개할 수 있는 특별한 함수입니다. <br/> |
| 152 | +`function*` 키워드로 정의하고, `yield` 키워드를 사용하여 값을 하나씩 반환합니다. |
| 153 | + |
| 154 | +```js |
| 155 | +function* generator() { |
| 156 | + yield 1; |
| 157 | + yield 2; |
| 158 | + yield 3; |
| 159 | +} |
| 160 | + |
| 161 | +const gen = generator(); |
| 162 | +console.log(gen.next()); // { value: 1, done: false } |
| 163 | +console.log(gen.next()); // { value: 2, done: false } |
| 164 | +console.log(gen.next()); // { value: 3, done: false } |
| 165 | +console.log(gen.next()); // { value: undefined, done: true } |
| 166 | +``` |
| 167 | + |
| 168 | +이렇게 `Generator` 는 값을 하나씩 순차적으로 반환할 수 있고, <br/> |
| 169 | +함수의 실행 흐름을 외부에서 제어할 수 있다는 장점이 있습니다. |
| 170 | + |
| 171 | +:::info TODO |
| 172 | +여기에 Generator 관련 포스트 링크 걸기 |
| 173 | +대충안에 generator, iterator, iterable 내용 정리하기 |
| 174 | +symbol.iterator 내용 추가하기 (symbol.asynciterator 도 ??) |
| 175 | +::: |
| 176 | + |
| 177 | +### 😎 Generator 의 비동기 버전 - Async Generator |
| 178 | + |
| 179 | +Async Generator 는 Generator 의 비동기 버전입니다. |
| 180 | + |
| 181 | +```js |
| 182 | +async function* asyncGenerator() { |
| 183 | + yield await Promise.resolve(1); |
| 184 | + yield await Promise.resolve(2); |
| 185 | + yield await Promise.resolve(3); |
| 186 | +} |
| 187 | + |
| 188 | +for await (const value of asyncGenerator()) { |
| 189 | + console.log(value); |
| 190 | +} |
| 191 | +// 출력: |
| 192 | +// 1 |
| 193 | +// 2 |
| 194 | +// 3 |
| 195 | +``` |
| 196 | + |
| 197 | +`yield` 하는 값이 `Promise` 여도 알아서 기다려주고, <br/> |
| 198 | +사용하는 곳에서는 `for await ... of` 문으로 비동기 Iterable 을 순회할 수 있습니다. |
| 199 | + |
| 200 | +| 기능 | Promise | Generator | Async Generator | |
| 201 | +| ---------------------------- | ------- | --------- | --------------- | |
| 202 | +| 값이 여러 번 도착 | ❌ | ⭕️ | ⭕️ | |
| 203 | +| 값이 비동기로 도착 | ⭕️ | ❌ | ⭕️ | |
| 204 | +| `for await ... of` 반복 처리 | ❌ | ❌ | ⭕️ | |
| 205 | + |
| 206 | +이러한 특성 덕분에, SSE 스트림 같은 `여러 번 + 비동기` 상황에서 Async Generator 가 딱 맞습니다! |
| 207 | + |
| 208 | +<br/> |
| 209 | + |
| 210 | +## 🚀 SSE 를 Async Generator 로 구현하기 |
| 211 | + |
| 212 | +저 역시 종합설계프로젝트2에서 이미지 생성 과정을 실시간으로 사용자에게 보여주는 기능을 구현했습니다. <br/> |
| 213 | +이때 SSE 스트림을 Async Generator 로 추상화하여 깔끔하게 처리할 수 있었습니다. |
| 214 | + |
| 215 | +실제로 구현했던 이미지 생성 스트림 API 를 예시로, 어떻게 Async Generator 패턴을 적용할 수 있는지 살펴보겠습니다. |
| 216 | + |
| 217 | +:::details 📚 [API 명세] 이미지 생성 — taskId 발급 |
| 218 | + |
| 219 | +이미지 생성 요청을 비동기로 등록하고, 스트림으로 상태를 조회합니다. |
| 220 | + |
| 221 | +```http |
| 222 | +POST /api/ai/images/generate |
| 223 | +``` |
| 224 | + |
| 225 | +| Query Param | Type | Required | Default | Description | |
| 226 | +| ----------------- | ------ | -------- | ------- | --------------- | |
| 227 | +| prompt | string | o | - | 프롬프트 | |
| 228 | +| height | int | x | 1536 | 이미지 세로 | |
| 229 | +| width | int | x | 1024 | 이미지 가로 | |
| 230 | +| guidanceScale | double | x | 3.5 | 가이던스 스케일 | |
| 231 | +| numInferenceSteps | int | x | 20 | 추론 스텝 | |
| 232 | +| seed | int | x | 0 | 랜덤 Seed | |
| 233 | + |
| 234 | +**Response (예시)** |
| 235 | + |
| 236 | +```json |
| 237 | +{ |
| 238 | + "taskId": "1b3a4647-0e03-4961-8344-5bc150c84b99", |
| 239 | + "message": "이미지 생성 작업이 등록되었습니다." |
| 240 | +} |
| 241 | +``` |
| 242 | + |
| 243 | +::: |
| 244 | + |
| 245 | +:::details 📚 [API 명세] 이미지 생성 상태 스트림 조회 |
| 246 | + |
| 247 | +```http |
| 248 | +GET /api/ai-images/stream/{taskId} |
| 249 | +Accept: text/event-stream |
| 250 | +Authorization: Bearer <ACCESS_TOKEN> |
| 251 | +``` |
| 252 | + |
| 253 | +| Path Param | Type | Description | |
| 254 | +| ---------- | ------ | ------------------- | |
| 255 | +| taskId | string | 이미지 생성 작업 ID | |
| 256 | + |
| 257 | +**SSE Message Format(예시)** |
| 258 | + |
| 259 | +```text |
| 260 | +id:40d1de7b |
| 261 | +event:image-generation |
| 262 | +data:{"taskId":"...","status":"UPLOADING","message":"S3 업로드 중","progress":90,"timestamp":"2025-11-23T19:25:00.986Z"} |
| 263 | +
|
| 264 | +id:6d999191 |
| 265 | +event:image-generation |
| 266 | +data:{"taskId":"...","status":"COMPLETED","message":"완료","progress":100,"imageUrl":"https://...png","timestamp":"2025-11-23T19:25:01.071Z"} |
| 267 | +``` |
| 268 | + |
| 269 | +::: |
| 270 | + |
| 271 | +### 1️⃣ 이미지 생성 요청 함수 구현하기 |
| 272 | + |
| 273 | +```ts |
| 274 | +export async function generateImage(request: GenerateImageRequest) { |
| 275 | + const response = await api.post<GenerateImageResponse>( |
| 276 | + "/api/ai/images/generate", |
| 277 | + {}, |
| 278 | + { params: request }, |
| 279 | + ); |
| 280 | + return response.data; |
| 281 | +} |
| 282 | +``` |
| 283 | + |
| 284 | +이미지 생성 작업을 서버에 등록하고, `taskId` 를 발급받는 함수입니다. <br/> |
| 285 | +`taskId` 로 이후에 SSE 스트림을 구독할 수 있습니다. |
| 286 | + |
| 287 | +### 2️⃣ 이미지 생성 상태 스트림 함수 구현하기 (Async Generator) |
| 288 | + |
| 289 | +```ts |
| 290 | +export async function* generateImageStream( |
| 291 | + taskId: string, |
| 292 | +): AsyncGenerator<GenerateImageStreamResponse> { |
| 293 | + const { accessToken } = useAuthStore.getState(); |
| 294 | + |
| 295 | + const response = await fetch(API_BASE_URL + `/api/ai-images/stream/${taskId}`, { |
| 296 | + method: "GET", |
| 297 | + headers: { |
| 298 | + Accept: "text/event-stream", |
| 299 | + Authorization: `Bearer ${accessToken}`, |
| 300 | + }, |
| 301 | + }); |
| 302 | + |
| 303 | + if (!response.ok || !response.body) throw new Error("SSE 스트림 연결에 실패했습니다"); |
| 304 | + |
| 305 | + const reader = response.body.getReader(); |
| 306 | + const decoder = new TextDecoder("utf-8"); |
| 307 | + |
| 308 | + let buffer = ""; |
| 309 | + |
| 310 | + while (true) { |
| 311 | + const { value, done } = await reader.read(); |
| 312 | + if (done) break; |
| 313 | + |
| 314 | + buffer += decoder.decode(value, { stream: true }); |
| 315 | + |
| 316 | + // 이중 개행 기준으로 이벤트 블록 분리 |
| 317 | + const events = buffer.split("\n\n"); |
| 318 | + buffer = events.pop() ?? ""; |
| 319 | + |
| 320 | + for (const eventText of events) { |
| 321 | + const lines = eventText.split("\n"); |
| 322 | + |
| 323 | + for (const line of lines) { |
| 324 | + if (line.startsWith("data:")) { |
| 325 | + const json = line.replace("data:", "").trim(); |
| 326 | + |
| 327 | + const parsed: GenerateImageStreamResponse = JSON.parse(json); |
| 328 | + yield parsed; |
| 329 | + // yield 로 이벤트를 외부에 전달 |
| 330 | + |
| 331 | + if (parsed.status === "COMPLETED") return; |
| 332 | + // 완료 이벤트 수신 시 종료 |
| 333 | + } |
| 334 | + } |
| 335 | + } |
| 336 | + } |
| 337 | +} |
| 338 | +``` |
| 339 | + |
| 340 | +SSE 스트림을 구독해서 서버에서 보내주는 진행 상태 이벤트를 `yield` 를 통해 한 번에 하나씩 전달하는 `Async Generator` 함수입니다. <br/> |
| 341 | +`for await ... of` 를 사용하여 순차적으로 이벤트를 사용할 수 있습니다 |
| 342 | + |
| 343 | +### 3️⃣ React Hook 으로 통합하기 |
| 344 | + |
| 345 | +React 18부터 도입된 Automatic Batching 때문에, 비동기 작업 내에서 발생한 여러 개의 `setState` 호출은 성능 최적화를 위해 하나의 렌더링으로 묶여(Batch) 처리됩니다. |
| 346 | + |
| 347 | +따라서, 스트림에서 이벤트를 수신할 때마다 상태를 업데이트하려면 [`flushSync`](https://ko.react.dev/reference/react-dom/flushSync) 를 사용하여 각 상태 업데이트가 즉시 반영되도록 해야 합니다. |
| 348 | + |
| 349 | +```ts |
| 350 | +export const useGenerateImage = () => { |
| 351 | + const [isPending, setIsPending] = useState<boolean>(false); |
| 352 | + const [event, setEvent] = useState<GenerateImageStreamResponse | null>(null); |
| 353 | + const [error, setError] = useState<Error | unknown>(null); |
| 354 | + |
| 355 | + const generate = async (request: GenerateImageRequest) => { |
| 356 | + setIsPending(true); |
| 357 | + |
| 358 | + try { |
| 359 | + const { taskId } = await generateImage(request); |
| 360 | + for await (const streamEvent of generateImageStream(taskId)) { |
| 361 | + // ⚠️ 이벤트 수신 시마다 flushSync 로 상태 업데이트 강제 실행 |
| 362 | + flushSync(() => { |
| 363 | + setEvent(streamEvent); |
| 364 | + }); |
| 365 | + } |
| 366 | + } catch (e) { |
| 367 | + setError(e); |
| 368 | + } finally { |
| 369 | + setIsPending(false); |
| 370 | + } |
| 371 | + }; |
| 372 | + |
| 373 | + return { isPending, event, error, generate }; |
| 374 | +}; |
| 375 | +``` |
| 376 | + |
| 377 | +### 4️⃣ 컴포넌트에서 사용하기 |
| 378 | + |
| 379 | +이제 컴포넌트에서 `useGenerateImage` 훅을 사용하여 이미지 생성 스트림을 처리할 수 있습니다. |
| 380 | + |
| 381 | +```tsx |
| 382 | +const ImageGeneratorComponent = () => { |
| 383 | + const { isPending, event, error, generate } = useGenerateImage(); |
| 384 | + |
| 385 | + const handleGenerateClick = () => { |
| 386 | + generate({ prompt: "귀여운 아기고양이 사진" }); |
| 387 | + }; |
| 388 | + |
| 389 | + return ( |
| 390 | + <div> |
| 391 | + <button onClick={handleGenerateClick} disabled={isPending}> |
| 392 | + 이미지 생성 |
| 393 | + </button> |
| 394 | + |
| 395 | + <img src={event?.imageUrl} alt="생성된 이미지" /> |
| 396 | + </div> |
| 397 | + ); |
| 398 | +}; |
| 399 | +``` |
0 commit comments