SSE vs HTTP Streaming vs WebSocket - 실시간 통신 3총사 완전 정복

왜 실시간 통신이 필요한가?

일반적인 HTTP 요청은 “질문-답변” 구조다.

1
2
클라이언트: "오늘 날씨 알려줘"
서버: "맑음, 25도" → 연결 끊김

하지만 이런 상황이라면?

  • 주식 가격이 실시간으로 계속 바뀌는 화면
  • ChatGPT처럼 AI가 답변을 글자 단위로 타이핑하듯 보여주는 화면

매번 “새 데이터 있어?” 하고 물어보는 건 비효율적이다. 서버가 알아서 보내주면 좋겠다.

이걸 해결하는 세 가지 방법이 SSE, HTTP Streaming, WebSocket이다.


1. SSE (Server-Sent Events)

한 줄 요약

서버 → 클라이언트 방향의 단방향 실시간 채널. 브라우저가 기본 지원하는 표준 기술.

연결부터 종료까지 전체 흐름

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[1단계: 연결]
클라이언트 ──── GET /events ────→ 서버
              (일반 HTTP 요청)

[2단계: 서버 응답 시작]
서버 응답 헤더:
  Content-Type: text/event-stream   ← ★ 이게 핵심
  Cache-Control: no-cache
  Connection: keep-alive

[3단계: 데이터 전송 (연결 유지한 채)]
서버 ──→ "data: 주가 50,000원\n\n"
서버 ──→ "data: 주가 50,100원\n\n"
서버 ──→ "data: 주가 49,900원\n\n"
  ...계속...

[4단계: 종료]
서버가 연결을 닫거나, 클라이언트가 close() 호출

데이터 형식

SSE는 텍스트 기반의 정해진 형식이 있다:

1
2
3
4
5
event: price-update        ← 이벤트 이름 (선택)
id: 42                     ← 이벤트 ID (선택, 재연결 시 사용)
retry: 3000                ← 재연결 대기시간 ms (선택)
data: {"stock": "삼성", "price": 50000}   ← 실제 데이터 (필수)
                           ← 빈 줄로 하나의 메시지 끝을 표시

클라이언트 코드

브라우저가 EventSource라는 API를 기본 제공한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 연결 시작
const eventSource = new EventSource('/api/stock-prices');

// 메시지 수신
eventSource.onmessage = (event) => {
  console.log('받은 데이터:', event.data);
};

// 특정 이벤트만 수신
eventSource.addEventListener('price-update', (event) => {
  console.log('주가 업데이트:', event.data);
});

// 에러 처리 (+ 자동 재연결!)
eventSource.onerror = (error) => {
  console.log('연결 끊김, 브라우저가 자동으로 재연결 시도');
};

// 종료
eventSource.close();

SSE의 핵심 특징

특징설명
단방향서버 → 클라이언트만 가능. 클라이언트가 보내려면 별도 HTTP 요청 필요
자동 재연결연결 끊기면 브라우저가 알아서 다시 연결 시도
이벤트 ID재연결 시 Last-Event-ID 헤더로 “여기까지 받았어” 전달
텍스트만바이너리 데이터 전송 불가 (JSON 문자열은 OK)
HTTP/1.1 기반브라우저당 도메인별 최대 6개 연결 제한

2. HTTP Streaming

한 줄 요약

일반 HTTP 응답을 끊지 않고 데이터를 조금씩 흘려보내는 방식. 별도 표준이 아니라 HTTP의 동작 방식을 활용하는 기법.

연결부터 종료까지 전체 흐름

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
[1단계: 연결]
클라이언트 ──── POST /api/chat ────→ 서버
              (아무 HTTP 메서드 가능)
              Body: {"message": "인공지능이 뭐야?"}

[2단계: 서버 응답 시작]
서버 응답 헤더:
  Content-Type: application/json   ← 자유로움
  Transfer-Encoding: chunked       ← ★ 이게 핵심

[3단계: 데이터 전송 (Chunk 단위로)]
서버 ──→ "인공"
서버 ──→ "지능은 "
서버 ──→ "인간의 "
서버 ──→ "학습 능력을..."
  ...계속...

[4단계: 종료]
서버가 마지막 chunk(크기 0)를 보내면 응답 완료 → 연결 자동 종료

핵심 메커니즘: Chunked Transfer Encoding

일반 HTTP는 응답의 전체 크기를 미리 알려준다:

1
Content-Length: 1024    ← "1024바이트짜리 응답이야"

HTTP Streaming은 전체 크기를 모른다:

1
Transfer-Encoding: chunked    ← "크기 모르겠고, 조각조각 보낼게"

실제 전송되는 데이터:

1
2
3
4
5
6
7\r\n          ← 이 chunk는 7바이트
인공\r\n
9\r\n          ← 이 chunk는 9바이트
지능은 \r\n
0\r\n          ← 0바이트 = 끝!
\r\n

클라이언트 코드

EventSource와 달리 fetch API로 직접 스트림을 읽어야 한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 연결 시작
const response = await fetch('/api/chat', {
  method: 'POST',
  body: JSON.stringify({ message: '인공지능이 뭐야?' }),
  headers: { 'Content-Type': 'application/json' },
});

// 스트림 읽기 - 직접 구현해야 함
const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;  // 스트림 끝

  const text = decoder.decode(value);
  console.log('받은 조각:', text);
}

HTTP Streaming의 핵심 특징

특징설명
유연함GET, POST 등 아무 메서드 사용 가능. 요청 Body도 보낼 수 있음
자동 재연결 없음끊기면 직접 재연결 로직 구현 필요
형식 자유데이터 형식을 마음대로 정할 수 있음 (JSON, 텍스트, 바이너리 등)
1회성 응답하나의 요청에 대한 하나의 (긴) 응답. 응답 끝나면 연결 종료
바이너리 가능이미지, 파일 등 바이너리 데이터도 스트리밍 가능

3. WebSocket

한 줄 요약

HTTP로 악수한 다음, 완전히 다른 프로토콜로 전환해서 양방향 자유 통신하는 기술.

연결부터 종료까지 전체 흐름

1단계: 핸드셰이크 (HTTP → WebSocket 업그레이드)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
클라이언트 ──── HTTP GET ────→ 서버
                │  특별한 헤더:
                │  Upgrade: websocket          ← "프로토콜 바꾸자"
                │  Connection: Upgrade         ← "연결 유지한 채로"
                │  Sec-WebSocket-Key: abc123   ← "인증 키"
                │  Sec-WebSocket-Version: 13   ← "버전"

서버 ──── HTTP 101 Switching Protocols ────→ 클라이언트
                │  Upgrade: websocket
                │  Sec-WebSocket-Accept: xyz789  ← "키 확인했어, OK"

         ★ 이 순간부터 HTTP가 아니다 ★
         ★ WebSocket 프로토콜로 전환  ★

HTTP는 딱 한 번, 악수(핸드셰이크)할 때만 사용된다. 그 이후로는 완전히 다른 프로토콜이다.

2단계: 양방향 통신 (Full-Duplex)

1
2
3
4
5
6
7
8
9
┌────────────┐                    ┌────────────┐
│            │ ──"안녕"──────────→ │            │
│            │ ←─"반가워"──────── │            │
│ 클라이언트  │ ──"지금 몇 시?"──→ │   서버      │
│            │ ←─"3시야"───────── │            │
│            │ ←─"참고로 비 온대"─ │            │  ← 서버가 먼저!
│            │ ──"고마워"────────→ │            │
│            │ ←─"새 메시지 도착"─ │            │  ← 서버가 또 먼저!
└────────────┘                    └────────────┘

Full-Duplex란 전화 통화처럼 동시에 말하고 들을 수 있다는 뜻이다. HTTP는 워키토키(한쪽이 말하면 다른 쪽은 기다려야 함)에 가깝다.

3단계: 데이터 전송 (Frame 단위)

WebSocket은 **Frame(프레임)**이라는 단위로 데이터를 보낸다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
┌─────────────────────────────────────┐
│            WebSocket Frame          │
├──────┬──────┬────────┬──────────────┤
│ FIN  │ 종류 │ 길이   │   데이터     │
│ 1bit │ 4bit │ 7bit+  │   N bytes    │
├──────┼──────┼────────┼──────────────┤
│  1   │ 0x1  │  5     │  "안녕"      │  ← 텍스트 메시지
│  1   │ 0x2  │  1024  │  [바이너리]  │  ← 바이너리 (이미지 등)
│  1   │ 0x9  │  0     │              │  ← Ping (살아있니?)
│  1   │ 0xA  │  0     │              │  ← Pong (살아있어!)
│  1   │ 0x8  │  2     │  [코드]      │  ← 종료 요청
└──────┴──────┴────────┴──────────────┘

HTTP처럼 헤더가 매번 붙지 않는다. 프레임 헤더가 2~14바이트밖에 안 되어서 오버헤드가 매우 적다.

4단계: 연결 유지 (Ping/Pong)

1
2
3
4
서버: "살아있니?" (Ping) ──→ 클라이언트
클라이언트: "살아있어!" (Pong) ──→ 서버

30초마다 반복... 응답 없으면 연결 끊긴 걸로 판단

5단계: 종료 (Close Handshake)

1
2
종료하고 싶은 쪽 ── Close Frame (코드: 1000, 이유: "정상 종료") ──→ 상대방
상대방           ── Close Frame (코드: 1000) ──→ 종료 요청한 쪽

양쪽 모두 Close Frame을 교환해야 깔끔한 종료다.

주요 종료 코드:

코드의미
1000정상 종료
1001떠남 (페이지 이동 등)
1006비정상 종료 (연결 끊김)
1011서버 에러

클라이언트 코드

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 1. 연결 (핸드셰이크 자동 처리)
const ws = new WebSocket('ws://example.com/chat');
//                        ▲
//                    ws:// (HTTP) 또는 wss:// (HTTPS)

// 2. 연결 성공
ws.onopen = () => {
  console.log('연결됨!');
  ws.send('안녕하세요');
  ws.send(JSON.stringify({ type: 'join', room: '1' }));
};

// 3. 메시지 수신
ws.onmessage = (event) => {
  console.log('받음:', event.data);
};

// 4. 연결 끊김
ws.onclose = (event) => {
  console.log(`종료: 코드=${event.code}, 이유=${event.reason}`);
  // ⚠️ 자동 재연결 없음! 직접 구현해야 함
};

// 5. 에러
ws.onerror = (error) => {
  console.log('에러 발생:', error);
};

// 6. 종료 (원할 때)
ws.close(1000, '사용자가 나감');

WebSocket의 핵심 특징

특징설명
양방향클라이언트 ↔ 서버 자유롭게 통신 (Full-Duplex)
별도 프로토콜HTTP로 핸드셰이크 후 WS 프로토콜로 전환
낮은 오버헤드프레임 헤더 2~14바이트 (HTTP 헤더보다 훨씬 작음)
자동 재연결 없음직접 구현 필요
텍스트 + 바이너리모든 형식의 데이터 전송 가능
방화벽 주의일부 기업 방화벽/프록시에서 차단될 수 있음

4. 세 가지 한눈에 비교

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
┌──────────────────────────────────────────────────────┐
│                      SSE                              │
│  클라이언트 ◄──────────── 서버                        │
│              단방향, HTTP 위에서                       │
│  📻 라디오: 듣기만 가능                               │
└──────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────┐
│                  HTTP Streaming                       │
│  클라이언트 ───요청──► 서버                           │
│  클라이언트 ◄──조금씩── 서버                          │
│              1회성 응답, HTTP 위에서                   │
│  🚰 수도꼭지: 틀면 나오고, 끝나면 끝                  │
└──────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────┐
│                    WebSocket                          │
│  클라이언트 ◄──────────► 서버                         │
│              양방향, 별도 프로토콜                     │
│  📞 전화통화: 동시에 말하고 들을 수 있음               │
└──────────────────────────────────────────────────────┘

종합 비교표

구분SSEHTTP StreamingWebSocket
프로토콜HTTPHTTPWS (별도 프로토콜)
방향서버→클라 단방향응답만 스트리밍양방향 (Full-Duplex)
연결계속 열림응답 끝나면 닫힘계속 열림
재연결자동직접 구현직접 구현
데이터 형식텍스트만자유텍스트 + 바이너리
오버헤드HTTP 헤더 (큼)HTTP 헤더 (큼)프레임 헤더 2~14바이트 (작음)
HTTP 메서드GET만모두 가능해당 없음 (별도 프로토콜)
방화벽/프록시잘 통과잘 통과가끔 차단됨
브라우저 APIEventSourcefetch + ReadableStreamWebSocket
서버 부담낮음낮음높음 (연결 유지 비용)

5. 언제 뭘 써야 할까?

상황추천 기술이유
실시간 알림, 뉴스피드SSE서버→클라 단방향이면 충분, 자동 재연결
ChatGPT 스트리밍 응답HTTP Streaming (SSE 형식)요청에 Body 필요, 1회성 응답
채팅 앱WebSocket양방향 실시간 필수
온라인 게임WebSocket초저지연 양방향 필수
주식 시세SSE 또는 WebSocket단순 시세면 SSE, 주문까지 하면 WebSocket
파일 업로드 진행률HTTP Streaming1회성, 진행 상황만 보여주면 됨
실시간 협업 편집 (Google Docs)WebSocket양방향 + 저지연 + 빈번한 데이터 교환

6. 실전: ChatGPT는 뭘 쓸까?

ChatGPT는 SSE 형식을 사용하는 HTTP Streaming이다.

1
2
3
4
5
6
7
8
POST /v1/chat/completions
Body: { "stream": true, "messages": [...] }

응답:
data: {"choices":[{"delta":{"content":"인"}}]}
data: {"choices":[{"delta":{"content":"공"}}]}
data: {"choices":[{"delta":{"content":"지능"}}]}
data: [DONE]

“POST인데 SSE?” → OpenAI는 SSE의 데이터 형식(text/event-stream)을 빌려 쓰되, EventSource 대신 fetch로 구현한다. 엄밀히는 SSE 형식을 사용하는 HTTP Streaming이라고 볼 수 있다.


7. 관련 기술과의 관계

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
실시간 통신 기술 스펙트럼:

단순 ◄──────────────────────────────────► 복잡

Polling     SSE      HTTP         WebSocket
(주기적     (서버→    Streaming    (양방향,
 질문)      클라)    (응답 쪼개기)  풀 듀플렉스)

"5초마다    "서버가   "응답을      "서버↔클라
 새 거      알아서    조금씩       자유롭게
 있어?"     보내줌"   흘려보냄"    주고받음"

8. Deep Dive: HTTP/1.1 vs HTTP/2에서의 SSE

SSE는 HTTP 위에서 동작하기 때문에, HTTP 버전에 따라 동작 방식이 크게 달라진다.

HTTP/1.1에서의 SSE

핵심 한계: 도메인당 연결 수 제한

HTTP/1.1은 하나의 TCP 연결 = 하나의 요청/응답이다. SSE는 연결을 계속 열어두니까, TCP 연결 하나를 점유하게 된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
브라우저의 HTTP/1.1 연결 제한: 도메인당 최대 6개

┌─ 브라우저 ─────────────────────────────────────┐
│                                                 │
│  연결 1: SSE /api/notifications  ← 점유 중      │
│  연결 2: SSE /api/stock-prices   ← 점유 중      │
│  연결 3: GET /api/users          ← 일반 요청     │
│  연결 4: GET /api/products       ← 일반 요청     │
│  연결 5: POST /api/order         ← 일반 요청     │
│  연결 6: GET /static/image.png   ← 일반 요청     │
│                                                 │
│  ⚠️ 연결 꽉 참! 더 이상 요청 못 보냄             │
│  GET /api/settings → 대기열...                   │
└─────────────────────────────────────────────────┘

더 심각한 문제는 탭 간 연결 공유다:

1
2
3
4
5
6
7
┌─ 탭 1 ──────┐  ┌─ 탭 2 ──────┐  ┌─ 탭 3 ──────┐
│ SSE 연결 1   │  │ SSE 연결 3   │  │ SSE 연결 5   │
│ SSE 연결 2   │  │ SSE 연결 4   │  │ SSE 연결 6   │
└──────────────┘  └──────────────┘  └──────────────┘
                              6개 전부 소진!
                         일반 API 요청 전부 블로킹

같은 도메인이면 탭 간에 6개를 공유하기 때문에, 탭 3개에서 각각 SSE 2개씩 열면 일반 API 호출이 전부 막힌다.

HTTP/1.1에서의 연결 구조

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
TCP 연결 1개 = SSE 스트림 1개

클라이언트 ═══════════════════════ 서버
  │         TCP 연결 #1              │
  │  ◄── data: msg1\n\n             │
  │  ◄── data: msg2\n\n             │
  │         (이 연결은 계속 점유)     │

클라이언트 ═══════════════════════ 서버
  │         TCP 연결 #2              │
  │  ── GET /api/users ──►          │
  │  ◄── 200 OK (응답 후 반환)       │
  │         (사용 후 풀로 반환)       │

헷갈리기 쉬운 포인트: HTTP 응답 종료 ≠ TCP 연결 종료

SSE 응답이 끝나면 “연결이 끊긴다"고 생각하기 쉽지만, 실제로는 HTTP 응답TCP 연결은 별개다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[SSE 응답 완료 시 일어나는 일]

1. 서버가 res.end() 호출
   → HTTP 응답(Response)은 닫힘
   → TCP 연결은 살아있음 (Keep-Alive 기본값)

2. TCP 연결은 유휴(idle) 상태로 대기

3. 클라이언트가 다시 요청
   → 같은 TCP 연결 재사용 (3-way 핸드셰이크 생략)
상황HTTP 응답TCP 연결
res.end() 호출 시닫힘유지 (Keep-Alive)
Keep-Alive 타임아웃 만료 시이미 없음닫힘
Connection: close 헤더 시닫힘같이 닫힘

HTTP/1.1에서 Connection: keep-alive가 기본값이기 때문에, 명시적으로 Connection: close를 보내지 않는 한 TCP 연결은 응답 후에도 살아있다. 즉 SSE 스트림이 끝나고 클라이언트가 바로 재연결하면, 새 TCP 핸드셰이크 없이 기존 연결을 재사용할 수 있다.

HTTP/2에서의 SSE

핵심 개선: 멀티플렉싱 (Multiplexing)

HTTP/2는 하나의 TCP 연결 안에서 여러 스트림을 동시에 처리할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
HTTP/2: 하나의 TCP 연결 안에 여러 스트림

클라이언트 ══════════════════════════════ 서버
  │            단일 TCP 연결                │
  │                                        │
  │  ┌─ 스트림 1: SSE /notifications ──┐   │
  │  │  ◄── data: 알림1\n\n            │   │
  │  │  ◄── data: 알림2\n\n            │   │
  │  └─────────────────────────────────┘   │
  │                                        │
  │  ┌─ 스트림 2: SSE /stock-prices ───┐   │
  │  │  ◄── data: 주가1\n\n            │   │
  │  │  ◄── data: 주가2\n\n            │   │
  │  └─────────────────────────────────┘   │
  │                                        │
  │  ┌─ 스트림 3: GET /api/users ──────┐   │
  │  │  ──► 요청                       │   │
  │  │  ◄── 200 OK                     │   │
  │  └─────────────────────────────────┘   │
  │                                        │
  │    ... 스트림 100개도 가능 ...          │

SSE가 몇 개든 일반 API 요청을 블로킹하지 않는다.

비교표

구분HTTP/1.1HTTP/2
연결 구조요청당 TCP 연결 1개1개 TCP 연결에 다수 스트림
SSE 연결 제한도메인당 최대 6개 (브라우저 제한)사실상 무제한 (스트림 단위)
탭 간 영향같은 도메인이면 6개 공유 → 서로 블로킹영향 없음
일반 요청 블로킹SSE가 연결 점유 → 일반 요청 대기SSE와 일반 요청 공존
헤더 오버헤드매 메시지마다 전체 HTTP 헤더HPACK 압축으로 헤더 최소화

데이터 전송 방식의 차이

HTTP/1.1: 텍스트 기반

1
2
3
4
5
6
7
HTTP/1.1 200 OK\r\n
Content-Type: text/event-stream\r\n
Cache-Control: no-cache\r\n
Connection: keep-alive\r\n
\r\n
data: {"message": "hello"}\n\n
data: {"message": "world"}\n\n

모든 것이 평문 텍스트로 전송된다.

HTTP/2: 바이너리 프레임

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
┌─────────────────────────────┐
│  HTTP/2 HEADERS Frame       │  ← 바이너리, HPACK 압축
│  :status = 200              │
│  content-type = text/event- │
│    stream                   │
└─────────────────────────────┘
┌─────────────────────────────┐
│  HTTP/2 DATA Frame          │  ← 바이너리 프레임
│  data: {"message":"hello"}  │
└─────────────────────────────┘
┌─────────────────────────────┐
│  HTTP/2 DATA Frame          │
│  data: {"message":"world"}  │
└─────────────────────────────┘

SSE의 메시지 형식(data:, event:, id:) 자체는 동일하지만, 전송 계층이 텍스트에서 바이너리 프레임으로 바뀌어 더 효율적이다.

HTTP/1.1 시절의 우회 방법들

연결 6개 제한 때문에 다양한 우회가 필요했다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
방법 1: 서브도메인 분리
  api.example.com     → 일반 API (6개)
  stream.example.com  → SSE 전용 (6개)
  → 총 12개 연결 확보

방법 2: SSE 연결 하나로 통합
  여러 이벤트 타입을 하나의 SSE 연결에 몰아넣기

  event: notification
  data: {"type": "message", "content": "안녕"}

  event: stock
  data: {"symbol": "삼성", "price": 50000}

방법 3: Long Polling으로 대체
  SSE 대신 주기적으로 HTTP 요청

HTTP/2에서는 이런 우회가 전부 불필요하다. 하나의 TCP 연결 안에서 SSE 스트림과 일반 API 요청이 자유롭게 공존할 수 있기 때문이다.

정리

1
2
3
4
5
6
7
8
9
HTTP/1.1 + SSE:
  "쓸 수는 있지만, 연결 제한 때문에 조심해야 한다"
  → 서브도메인 분리, 연결 통합 같은 우회 필요

HTTP/2 + SSE:
  "SSE의 약점이 거의 사라진다"
  → 멀티플렉싱 덕분에 연결 제한 문제 해결
  → 헤더 압축으로 오버헤드도 감소
  → 사실상 SSE의 최적 환경

현재 대부분의 프로덕션 환경은 HTTP/2를 사용하고 있어서, SSE의 연결 제한 문제는 실무에서 거의 문제가 되지 않는다.


9. Deep Dive: 스트리밍에서 데이터가 쌓이는 문제와 TCP 백프레셔

SSE든 HTTP Streaming이든, 서버가 데이터를 보내고 클라이언트가 소비하는 구조다. 그런데 서버가 보내는 속도 > 클라이언트가 소비하는 속도라면 어떻게 될까?

문제: 버퍼에 데이터가 쌓인다

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
서버:
  res.write(데이터1)
  res.write(데이터2)
  res.write(데이터3)    ← 계속 보냄
  ...

TCP 송신 버퍼:
  [데이터1][데이터2][데이터3][데이터4]...  ← 쌓임

클라이언트:
  데이터1 처리 중...           ← 아직 나머지를 읽지 못함

서버가 데이터를 아무리 빨리 보내도, 클라이언트가 느리면 중간 버퍼에 데이터가 쌓이게 된다. 이게 무한히 쌓이면 메모리 문제가 발생할 수 있다.

해결: TCP 백프레셔 (Backpressure)

TCP 프로토콜 자체에 이 문제를 해결하는 메커니즘이 내장되어 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[정상 상태]
서버 ──데이터──► TCP 송신 버퍼 ──네트워크──► TCP 수신 버퍼 ──► 클라이언트
                  여유 있음                    여유 있음         빠르게 소비

[클라이언트가 느려지면]
서버 ──데이터──► TCP 송신 버퍼 ──네트워크──► TCP 수신 버퍼 ──► 클라이언트
                                              가득 참!         느리게 소비
                                    "TCP 윈도우 사이즈 = 0"
                                    (더 이상 보내지 마!)
                  TCP 송신 버퍼도 가득 참!
              res.write()가 블로킹됨
              서버도 자동으로 느려짐

단계별로 보면:

  1. 클라이언트의 소비가 느려짐
  2. 클라이언트 측 TCP 수신 버퍼가 가득 참
  3. 클라이언트가 서버에게 TCP 윈도우 사이즈 = 0 통보 (“더 보내지 마”)
  4. 서버 측 TCP 송신 버퍼도 가득 참
  5. 서버의 res.write() 호출이 블로킹됨
  6. 서버가 자연스럽게 속도를 줄임

메모리가 무한히 쌓이지 않는다. TCP가 알아서 흐름을 조절해준다.

그래도 문제가 될 수 있는 시나리오

TCP 백프레셔가 대부분의 경우를 해결해주지만, 다음 상황에서는 주의가 필요하다:

시나리오어떤 일이 벌어지나영향
클라이언트 네트워크가 극도로 느린 경우 (2G 등)TCP 윈도우가 가득 차서 서버 write가 블로킹서버 스레드/이벤트 루프 점유 시간 증가
모바일 앱이 백그라운드로 전환수신 콜백이 멈추고, 내부 버퍼에 데이터 쌓임포그라운드 복귀 시 밀린 데이터 한꺼번에 처리
서버 측 애플리케이션 버퍼TCP 버퍼와 별개로 애플리케이션 레벨 버퍼가 쌓일 수 있음프레임워크에 따라 메모리 사용량 증가

실무에서는 대부분 괜찮은 이유

LLM 스트리밍 응답 같은 일반적인 케이스를 생각해보면:

1
2
3
4
5
6
7
8
9
┌────────────────────┬──────────────────────────┬─────────────┐
│        구간        │          속도            │ 쌓일 가능성 │
├────────────────────┼──────────────────────────┼─────────────┤
│ LLM → 서버         │ 토큰 생성 속도 (느림)    │ 거의 없음   │
├────────────────────┼──────────────────────────┼─────────────┤
│ 서버 → 클라이언트  │ 네트워크 전송 (빠름)     │ 거의 없음   │
├────────────────────┼──────────────────────────┼─────────────┤
│ 클라이언트 파싱    │ JSON.parse (매우 빠름)   │ 거의 없음   │
└────────────────────┴──────────────────────────┴─────────────┘

데이터를 생성하는 쪽(LLM)이 병목이다. 토큰 생성 속도가 초당 수십~수백 개 수준이라, 네트워크나 클라이언트 파싱이 따라가지 못하는 상황은 현실적으로 거의 발생하지 않는다. 만약 클라이언트가 느려지더라도 TCP 백프레셔가 자동으로 속도를 조절해준다.


10. Deep Dive: 동시 연결 수가 너무 많아지는 문제

9장에서 다룬 건 하나의 연결 안에서 데이터가 쌓이는 문제였다. 이번에는 완전히 다른 문제다: 연결 자체의 수가 너무 많아지는 경우.

1
2
3
4
5
6
7
8
9장 (데이터 쌓임):                    10장 (연결 쌓임):
┌─ 연결 1개 ─────────────────┐       ┌─ 연결 1: 유저A ─┐
│  [데이터1][데이터2][데이터3] │       ├─ 연결 2: 유저B ─┤
│  버퍼에 데이터가 쌓임       │       ├─ 연결 3: 유저C ─┤
└────────────────────────────┘       ├─ ...            ┤
                                     ├─ 연결 10000     ─┤
                                     └──────────────────┘
                                     연결 수 자체가 문제

왜 문제가 되는가?

SSE와 WebSocket은 연결을 계속 유지한다. 일반 HTTP 요청은 응답 후 바로 연결이 반환되지만, 스트리밍 연결은 클라이언트가 연결을 끊거나 서버가 명시적으로 종료할 때까지 살아있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
일반 HTTP:
  유저A 요청 → 응답 → 연결 반환 (수 ms)
  유저B 요청 → 응답 → 연결 반환 (수 ms)
  → 동시에 점유되는 연결 수가 적음

SSE / WebSocket:
  유저A 연결 → 유지... 유지... 유지... (수 분~수 시간)
  유저B 연결 → 유지... 유지... 유지...
  유저C 연결 → 유지... 유지... 유지...
  → 동시 접속자 수 = 동시 연결 수

연결이 쌓이면 서버에서 무슨 일이 벌어지나

서버가 연결 하나당 소비하는 리소스

리소스연결당 비용10,000 연결 시
파일 디스크립터 (FD)1개10,000개
메모리 (TCP 버퍼)~10-20KB~100-200MB
메모리 (애플리케이션)프레임워크에 따라 다름수백 MB 이상 가능
CPU (이벤트 감시)미미누적되면 부담

단계별 증상

1
2
3
4
5
6
동시 연결 수 증가에 따른 서버 상태:

~1,000개:  대부분의 서버에서 문제 없음
~10,000개: 파일 디스크립터 제한에 걸릴 수 있음 (OS 기본값: 1024)
~50,000개: 메모리 사용량 증가, GC 압박
~100,000개: OS 레벨 튜닝 없이는 커널 리소스 부족

기술별 차이

기술연결 쌓임 문제이유
HTTP Streaming상대적으로 적음응답 끝나면 연결 종료. 1회성이라 오래 점유하지 않음
SSE발생할 수 있음연결을 계속 유지. 다만 단방향이라 서버 부담은 상대적으로 적음
WebSocket가장 주의 필요양방향 연결 유지 + 프레임 파싱 + 상태 관리까지 필요

해결 방법

1. 연결 타임아웃 설정

일정 시간 동안 데이터가 없으면 서버에서 연결을 끊는다.

1
2
3
4
5
6
[연결 유지]
클라이언트 ◄── data: 메시지1 ── 서버
클라이언트 ◄── data: 메시지2 ── 서버
클라이언트    (30초간 데이터 없음...)
서버: "타임아웃! 연결 종료"
클라이언트: 자동 재연결 (SSE) 또는 직접 재연결 (WebSocket)

유휴 연결이 서버 리소스를 낭비하는 걸 방지한다.

2. Heartbeat (하트비트)

타임아웃과 함께 사용한다. 실제 데이터가 없어도 주기적으로 빈 메시지를 보내서 진짜 살아있는 연결죽은 연결을 구분한다.

1
2
3
서버 ──► ": heartbeat\n\n" (SSE 주석)     ← 15초마다
서버 ──► ": heartbeat\n\n"
클라이언트 응답 없음 → 죽은 연결로 판단 → 정리

3. 연결 수 제한 (Connection Limiting)

서버당, 유저당 최대 연결 수를 제한한다.

1
2
3
4
정책 예시:
  - 서버 인스턴스당 최대 동시 SSE 연결: 5,000개
  - 유저당 최대 SSE 연결: 3개 (탭 3개까지)
  - 초과 시: 가장 오래된 연결 끊기 또는 429 Too Many Requests 반환

4. 수평 확장 (Horizontal Scaling)

서버 한 대로 감당이 안 되면 서버를 늘린다.

1
2
3
4
                    ┌─ 서버 1 (연결 5,000개) ─┐
유저 ── 로드밸런서 ──┼─ 서버 2 (연결 5,000개) ─┤
                    └─ 서버 3 (연결 5,000개) ─┘
                    총 15,000개 동시 연결 처리

단, SSE/WebSocket은 Sticky Session(같은 유저를 같은 서버로 라우팅)이 필요할 수 있다. 연결이 유지되는 동안 서버가 바뀌면 안 되기 때문이다.

5. OS 레벨 튜닝

대규모 동시 연결을 처리하려면 OS 설정도 조정해야 한다.

1
2
3
4
5
6
# 파일 디스크립터 제한 늘리기 (기본값 1024 → 65535)
ulimit -n 65535

# 리눅스 커널 파라미터 조정
sysctl -w net.core.somaxconn=65535        # 최대 대기 연결 수
sysctl -w net.ipv4.tcp_max_syn_backlog=65535

6. 연결 풀링 / 팬아웃 아키텍처

하나의 데이터 소스를 여러 클라이언트에게 전달할 때, 중간에 메시지 브로커를 두는 방식이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[연결 풀링 없이]
데이터 소스 ──► 서버 ──┬──► 유저A (SSE)
                      ├──► 유저B (SSE)
                      ├──► 유저C (SSE)
                      └──► ... 10,000명

[메시지 브로커 사용]
데이터 소스 ──► Redis Pub/Sub ──┬──► 서버1 ──► 유저 A~D
                               ├──► 서버2 ──► 유저 E~H
                               └──► 서버3 ──► 유저 I~L

Redis Pub/Sub, Kafka 같은 메시지 브로커를 사용하면 서버를 쉽게 수평 확장할 수 있다.

정리

방법효과적용 난이도
연결 타임아웃유휴 연결 정리쉬움
Heartbeat죽은 연결 탐지쉬움
연결 수 제한리소스 보호보통
수평 확장처리 용량 증가보통~어려움
OS 튜닝단일 서버 한계 확장보통
메시지 브로커대규모 팬아웃어려움

데이터가 쌓이는 문제(9장)는 TCP 백프레셔가 자동으로 해결해주지만, 연결이 쌓이는 문제는 직접 설계하고 관리해야 한다. 서비스 규모에 맞는 전략을 선택하는 것이 중요하다.


11. Deep Dive: Cloud Run에서 스트리밍 운영 시 주의할 점

10장까지는 일반적인 서버 환경을 기준으로 설명했다. 하지만 Cloud Run 같은 서버리스 컨테이너 환경에서는 전통 서버와 다른 특성 때문에 추가적인 문제가 발생한다.

Cloud Run의 특성부터 이해하자

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
전통 서버 (EC2, VM):
  서버가 항상 떠 있음 → 연결 유지 자유로움
  직접 관리 → 세밀한 OS 튜닝 가능
  고정 비용 → 놀고 있어도 돈 나감

Cloud Run:
  요청 없으면 인스턴스 0개로 축소 (Scale to Zero)
  요청이 오면 자동으로 인스턴스 생성 (Auto Scaling)
  요청 처리 시간 기준으로 과금
  최대 요청 타임아웃: 60분
  인스턴스 간 상태 공유 불가 (Stateless)
  배포 시 기존 인스턴스 교체 (Rolling Update)

이 특성이 스트리밍 기술과 만나면 여러 문제가 생긴다.

이슈 1: 요청 타임아웃 (최대 60분)

Cloud Run은 하나의 요청에 대해 최대 60분의 타임아웃이 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
HTTP Streaming:
  요청 → [스트리밍 응답...] → 60분 되면 강제 종료
  보통 수초~수분이라 문제 없음 ✅

SSE:
  연결 → [이벤트 전송...] → 60분 되면 강제 종료
  장시간 연결이 목적인데 60분 제한 ⚠️

WebSocket:
  핸드셰이크 → [양방향 통신...] → 60분 되면 강제 종료
  채팅 같은 장시간 연결에 치명적 ⚠️

대응 방법:

  • SSE: EventSource의 자동 재연결 덕분에 60분마다 끊겨도 클라이언트가 알아서 복구
  • WebSocket: 클라이언트에 재연결 로직 필수 구현. 60분 되기 전에 서버에서 먼저 끊고 재연결 유도
  • HTTP Streaming: LLM 응답 같은 1회성은 보통 수 분 내 완료되므로 문제 없음

이슈 2: Scale to Zero와 과금

Cloud Run의 핵심 장점인 “안 쓰면 0원"이 스트리밍에서는 작동하지 않는다.

1
2
3
4
5
6
7
8
9
[일반 HTTP - Scale to Zero 가능]
요청 → 처리(100ms) → 응답 → 인스턴스 유휴 → 축소 → 과금 중지

[SSE/WebSocket - Scale to Zero 불가]
유저A SSE 연결 유지 ─────────────────────────────
유저B SSE 연결 유지 ─────────────────────────────
  → 연결이 살아있는 한 인스턴스도 살아있음
  → Scale to Zero 불가
  → 요청이 없어도 과금 지속 💸

과금 영향도 크다:

1
2
3
4
5
6
7
[일반 HTTP]
요청 → 처리(100ms) → 응답 → 과금: 100ms
  → 하루 총 활성 시간: 수 분

[SSE/WebSocket]
연결 → 유지(30분) → 종료 → 과금: 30분
  → 동시 접속 100명 × 평균 30분 = 하루 수천 분 💸

대응 방법:

  • 연결에 적절한 타임아웃을 설정해서 유휴 연결 정리
  • 비용이 민감하고 트래픽이 적으면 SSE 대신 Polling 고려
  • 최소 인스턴스 설정으로 Cold Start와 비용 사이 균형 찾기

이슈 3: 배포 시 연결 끊김

Cloud Run은 새 버전 배포 시 기존 인스턴스를 교체한다. 이때 모든 스트리밍 연결이 끊긴다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
[배포 전]
유저A ──SSE──► 인스턴스 v1 (구버전)
유저B ──SSE──► 인스턴스 v1
유저C ──WS───► 인스턴스 v1

[배포 시작]
유저A ──SSE──► 인스턴스 v1 (드레이닝 시작)
유저B ──SSE──► 인스턴스 v1
유저C ──WS───► 인스턴스 v1
               인스턴스 v2 (신버전, 대기 중)

[드레이닝 완료 - 구 인스턴스 종료]
유저A ──끊김!
유저B ──끊김!
유저C ──끊김!

[재연결]
유저A ──SSE──► 인스턴스 v2 (자동 재연결 ✅)
유저B ──SSE──► 인스턴스 v2 (자동 재연결 ✅)
유저C ──????─► 인스턴스 v2 (재연결 로직 없으면 끊김 ❌)

대응 방법:

  • SSE: EventSource 자동 재연결 덕분에 비교적 안전
  • WebSocket: 재연결 + 상태 복구 로직 필수
  • 모든 방식: 클라이언트가 “연결 끊김 → 재연결” 시나리오를 반드시 처리해야 함

이슈 4: 인스턴스 간 상태 공유 불가

Cloud Run 인스턴스는 Stateless다. 인스턴스 간 메모리를 공유할 수 없다.

1
2
3
4
5
6
7
8
[문제 상황: 채팅방 브로드캐스트]

유저A ──WS──► 인스턴스 1 (유저A의 연결 보관)
유저B ──WS──► 인스턴스 2 (유저B의 연결 보관)

유저A가 메시지 전송:
  인스턴스 1: "유저A가 메시지 보냄. 유저B에게도 전달해야 하는데..."
  인스턴스 1: "유저B가 어디 연결되어 있는지 모름!" ❌

대응 방법:

1
2
3
Redis Pub/Sub로 인스턴스 간 메시지 전달:

유저A ──WS──► 인스턴스 1 ──publish──► Redis ──subscribe──► 인스턴스 2 ──WS──► 유저B
  • SSE (단방향): 서버가 일방적으로 보내므로 문제 적음
  • WebSocket (양방향): Redis Pub/Sub, Firestore 같은 외부 상태 저장소 필수
  • HTTP Streaming: 1회성이라 인스턴스 간 통신 불필요

이슈 5: 동시 요청 수 (Concurrency) 설정

Cloud Run은 인스턴스당 동시 처리 가능한 요청 수를 설정할 수 있다 (기본 80, 최대 1000).

1
2
3
4
5
6
7
8
[Concurrency = 80 설정 시]

인스턴스 1:
  SSE 연결 1 (유지 중...)
  SSE 연결 2 (유지 중...)
  ...
  SSE 연결 80 (유지 중...)
  SSE 연결 81 → ❌ 새 인스턴스로 라우팅

일반 HTTP 요청은 수 ms 만에 슬롯을 반환하지만, SSE/WebSocket은 연결 시간 내내 슬롯을 차지한다.

대응 방법:

  • SSE/WebSocket 전용 서비스는 Concurrency를 높게 설정 (예: 500~1000)
  • 일반 API와 SSE 서비스를 별도 Cloud Run 서비스로 분리하여 각각 다른 Concurrency 설정 적용

이슈 6: Cold Start와 재연결

SSE/WebSocket이 타임아웃이나 배포로 끊기면 클라이언트가 재연결한다. 이때 인스턴스가 이미 축소되었다면 Cold Start가 발생한다.

1
2
3
4
5
6
7
연결 끊김 → 인스턴스 축소 (Scale to Zero)
         → 재연결 요청
         → Cold Start (1~3초 대기)
         → 인스턴스 생성
         → 연결 복구

유저 입장: "연결 끊기고 몇 초간 아무것도 안 됨"

대응 방법:

  • 최소 인스턴스를 1로 설정하면 Cold Start 방지 (단, 비용 증가)
  • 클라이언트에서 재연결 시 로딩 UI 표시

종합 비교: Cloud Run 적합도

이슈HTTP StreamingSSEWebSocket
타임아웃 60분거의 문제 없음자동 재연결로 커버재연결 직접 구현 필요
Scale to Zero가능 (1회성)어려움 (연결 유지)어려움 (연결 유지)
배포 시 끊김영향 적음자동 재연결재연결 + 상태 복구 필요
인스턴스 상태상관 없음대체로 괜찮음외부 저장소 필수
Concurrency 점유짧은 점유장시간 점유장시간 점유
과금 효율좋음보통나쁨
Cloud Run 적합도매우 좋음 ✅괜찮음 ⚠️주의 필요 ⚠️⚠️

Cloud Run에서 실시간 통신이 필요하다면, HTTP Streaming이 가장 궁합이 좋고, SSE는 자동 재연결 덕분에 쓸 만하며, WebSocket은 추가 인프라(Redis 등)와 재연결 로직이 반드시 필요하다.


12. Deep Dive: 다중 인스턴스에서 실시간 세션 관리 - Redis Pub/Sub 적용

11장의 이슈 4에서 인스턴스 간 상태 공유 불가 문제를 간단히 다뤘다. 이번에는 실제로 이 문제를 어떻게 해결했는지, 구체적인 아키텍처를 살펴보자.

문제 상황: “왜 메시지가 안 와요?”

Streamable HTTP(HTTP POST + SSE 결합) 방식으로 서버→클라이언트 실시간 메시지를 구현했다. 로컬에서는 완벽하게 동작했지만, 다중 인스턴스로 배포하자마자 메시지가 간헐적으로 전달되지 않는 현상이 발생했다.

왜 다중 인스턴스에서 세션이 깨지는가?

실시간 메시지 전송의 핵심은 서버가 사용자의 세션 정보를 메모리에 유지하고 있어야 한다는 것이다.

1
2
3
4
1. 클라이언트 → 서버: Session ID 발급 요청
2. 서버 → 클라이언트: Session ID 반환
3. 클라이언트 → 서버: Session ID로 세션 등록 (SSE 연결)
4. 서버: 세션을 메모리에 저장, 이후 자유롭게 메시지 푸시 가능

인스턴스가 하나일 때는 문제 없다. 하지만 Cloud Run처럼 다중 인스턴스 + 이벤트 기반 아키텍처에서는:

sequenceDiagram participant Client as 📱 User A participant A as Instance A
(User A Session 보유) participant B as Instance B participant PS as Pub/Sub participant C as Instance C Note over A: User A Session ✅ Client-->>A: Stream 연결 유지 Note over C: 0. 유저에게 전달할
메시지 생성 완료 C->>PS: 1. 관련 Event Publish PS->>C: 2. Event 수신 Note over C: 3. 메시지를 User A에게
실시간 전달해야 하는데... C--xClient: ❌ 세션이 없어서 전달 불가! Note over C: 세션이 없는데
어떻게 전달하지?

User A의 세션은 인스턴스 A의 메모리에 있다. 그런데 이벤트를 수신한 인스턴스 C가 User A에게 메시지를 전달해야 하는 상황이 발생한다. 인스턴스 C는 User A의 세션 정보가 없으니 메시지를 전달할 방법이 없다.

핵심은 세션을 가진 인스턴스 ≠ 이벤트를 처리하는 인스턴스라는 것이다.

해결: Redis Pub/Sub 도입

인스턴스 간에 **“이 사용자에게 메시지를 보내줘”**라고 통신할 수 있는 채널이 필요하다. 여기서 Redis Pub/Sub을 선택했다.

1
2
3
Publisher → Channel("user-messages") → Subscriber A (인스턴스 A)
                                     → Subscriber B (인스턴스 B)
                                     → Subscriber C (인스턴스 C)

발행자가 구독자를 알 필요 없다. 채널에 던지면 구독 중인 모든 인스턴스가 받는다.

Case 1: 다른 인스턴스에 세션이 있는 경우

sequenceDiagram participant Client as 📱 User A participant A as Instance A
(User A Session 보유) participant B as Instance B participant Redis as Redis participant C as Instance C Note over A: User A Session ✅ Client-->>A: Stream 연결 유지 Note over C: 0. 유저에게 전달할
메시지 생성 완료 C->>Redis: 1. Session 확인 Redis-->>C: 활성 세션 있음 C->>Redis: 2. Message Publish
(온라인 사용자) Redis->>A: 3. 메시지 Sub Redis->>B: 3. 메시지 Sub Note over B: User A 세션 없음 → 무시 Note over A: User A 세션 있음! A-->>Client: 4. 실시간 메시지 전달
(Stream)

모든 인스턴스가 Redis 채널을 Subscribe하고 있으므로 메시지를 수신하고, User A의 세션을 실제로 보유한 인스턴스 A만 SSE Stream을 통해 클라이언트에 전달한다.

Case 2: 해당 인스턴스에 세션이 있는 경우

sequenceDiagram participant Client as 📱 User A participant A as Instance A participant B as Instance B participant Redis as Redis participant C as Instance C
(User A Session 보유) Note over C: User A Session ✅ Client-->>C: Stream 연결 유지 Note over C: 0. 유저에게 전달할
메시지 생성 완료 C->>Redis: 1. 최신 Session 확인 Redis-->>C: 활성 세션 있음 Note over C: 2. 자체 메모리에서
활성 세션 확인 → 있다! C-->>Client: 3. 실시간 메시지 전달
(Stream) Note over Redis: Redis Pub/Sub
거치지 않음 ⚡

이벤트를 처리하는 인스턴스가 마침 세션도 가지고 있으면, Redis Pub/Sub을 거치지 않고 바로 전달한다. 이 최적화 경로 덕분에 불필요한 Redis 통신을 줄일 수 있다.

Case 3: 세션 종료도 Pub/Sub으로

sequenceDiagram participant Client as 📱 User A participant A as Instance A
(User A Session 보유) participant B as Instance B participant Redis as Redis Pub/Sub participant C as Instance C Note over A: User A Session ✅ Client-->>A: Stream 연결 유지 Client->>C: 1. 세션 종료 요청 C->>Redis: 2. User A Session 종료
메시지 Publish Redis->>A: 3. 메시지 Sub Redis->>B: 3. 메시지 Sub Note over B: User A 세션 없음 → 무시 Note over A: User A 세션 있음! Note over A: 4. User A Session 삭제 🗑️

세션 종료 요청이 어떤 인스턴스에 도착하든, 실제 세션을 가진 인스턴스가 이를 수신하고 정리할 수 있다.

왜 Sticky Session이나 공유 세션 저장소가 아닌가?

방법장점단점
Sticky Session (로드밸런서)구현 간단Cloud Run에서 지원 제한, 오토스케일링과 충돌
공유 세션 저장소 (Redis에 세션 자체 저장)어느 인스턴스든 세션 접근 가능SSE 연결은 특정 인스턴스에 묶여 있어 근본 해결 안됨
Redis Pub/Sub느슨한 결합, 확장 용이메시지 영속성 없음 (실시간 전달 전용)

핵심은 SSE 연결이 특정 인스턴스의 메모리에 바인딩된다는 것이다. 세션을 공유 저장소에 옮기는 것만으로는 해결이 안 된다. 결국 “세션을 가진 인스턴스에게 알려주는” 메커니즘이 필요하고, Redis Pub/Sub이 이 역할에 적합하다.

또한 실시간 메시지는 전달 시점에만 의미가 있고, 오프라인 사용자에게는 별도로 푸시 알림을 보내므로 Pub/Sub의 “fire-and-forget” 특성이 오히려 적합하다.

남은 과제: 브로드캐스트 최적화

현재 구조에서는 메시지가 모든 인스턴스에 브로드캐스트된다. 인스턴스가 수백 개로 늘어나면 불필요한 네트워크 트래픽이 발생할 수 있다.

개선 방향은 인스턴스 매핑 레지스트리를 만드는 것이다:

1
2
3
4
Redis Hash: session-registry
  user-a → instance-id-1
  user-b → instance-id-3
  user-c → instance-id-1

채널을 user-messages:{instance-id}처럼 인스턴스별로 분리하면 타겟 Pub/Sub이 가능해져 불필요한 메시지 처리를 줄일 수 있다.