Skip to content

Commit 0443e4b

Browse files
committed
docs: SSE를 Async Generator로 : 진짜 스트림처럼 다루기
1 parent 46174ef commit 0443e4b

File tree

4 files changed

+399
-0
lines changed

4 files changed

+399
-0
lines changed
172 KB
Loading
148 KB
Loading
305 KB
Loading
Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
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

Comments
 (0)