Spring WebFlux + SSE로 실시간 스트리밍 구현하기
ChatGPT가 글자를 한 자씩 쓰는 비밀 — SSE(Server-Sent Events)
Spring WebFlux + SSE로 실시간 스트리밍 구현하기
ChatGPT가 글자를 한 자씩 쓰는 비밀 — SSE(Server-Sent Events)

1. SSE가 뭔가요?
SSE = Server-Sent Events
SSE는 서버에서 클라이언트로 실시간 데이터를 “계속 보내는” 기술입니다.
일반 HTTP 요청은 “요청 → 응답 → 연결 종료”로 끝나지만, SSE는 연결을 유지하면서 서버가 원할 때마다 데이터를 푸시할 수 있습니다.
일반 HTTP vs SSE
일반 HTTP 요청
클라이언트: "데이터 주세요!"
서버: "네, 여기 있습니다!"
───────────────────────────────────────
[연결 종료]
더 받으려면 다시 요청해야 함
SSE 방식
클라이언트: "데이터 주세요!" (연결 시작)
서버: "첫 번째 데이터입니다"
서버: "두 번째 데이터입니다"
서버: "세 번째 데이터입니다"
서버: "네 번째 데이터입니다"
───────────────────────────────────────
[연결 유지]
서버가 계속 보낼 수 있음
SSE의 실생활 예시
SSE는 서버에서 클라이언트로 데이터를 계속 보내는 경우에 사용됩니다:
📌 ChatGPT / Claude
- 사용 예시: 답변이 타이핑되듯이 나오는 효과
- 왜 SSE?: AI가 텍스트 생성 → 클라이언트는 받기만 함
📌 CI/CD 도구
- 사용 예시: 빌드/배포 로그 실시간 출력
- 왜 SSE?: 서버 로그 → 클라이언트는 보기만 함
📌 서버 모니터링
- 사용 예시: CPU/메모리 사용량 실시간 대시보드
- 왜 SSE?: 서버 메트릭 → 클라이언트는 표시만 함
📌 진행률 표시
- 사용 예시: 파일 업로드/데이터 처리 진행률
- 왜 SSE?: 서버에서 진행 상태 푸시
📌 로그 뷰어
- 사용 예시: 애플리케이션 로그 실시간 스트리밍
- 왜 SSE?: tail -f 같은 동작
2. SSE vs WebSocket — 언제 뭘 써야 할까?
주요 차이점
🔹 통신 방향
- SSE: 서버 → 클라이언트 (단방향)
- WebSocket: 양방향
🔹 프로토콜
- SSE: HTTP/HTTPS
- WebSocket: WS:// / WSS://
🔹 재연결
- SSE: 브라우저가 자동 재연결
- WebSocket: 수동으로 처리 필요
🔹 구현 복잡도
- SSE: 간단
- WebSocket: 복잡
🔹 방화벽/프록시
- SSE: 문제 없음 (HTTP)
- WebSocket: 차단될 수 있음
🔹 브라우저 지원
- SSE: 대부분 지원 (IE 제외)
- WebSocket: 모든 최신 브라우저
🔹 메시지 형식
- SSE: 텍스트만
- WebSocket: 텍스트 + 바이너리
언제 SSE를 쓸까?
✅ SSE가 적합한 경우:
- 서버가 클라이언트에게 계속 데이터를 보내야 할 때
- 클라이언트는 받기만 하면 될 때
- 실시간 알림, 뉴스 피드, 주가 정보
- AI 챗봇의 스트리밍 응답 ← ChatGPT, Claude
언제 WebSocket을 쓸까?
✅ WebSocket이 적합한 경우:
- 양방향 통신이 필요할 때
- 클라이언트도 서버에 계속 데이터를 보내야 할 때
- 실시간 채팅, 멀티플레이어 게임
- 협업 도구 (Google Docs 같은)
ChatGPT/Claude는 왜 SSE를 쓸까?
답변 생성은 단방향이기 때문입니다.
사용자 입력 → API 요청 (일반 HTTP POST)
↓
[AI 처리]
↓
SSE 스트림 ← 생성된 텍스트를 조금씩 전송
- 사용자는 질문을 보내고 응답만 받으면 됨
- 응답을 생성하는 동안 계속 데이터를 보내야 함
- WebSocket처럼 양방향이 필요 없음
- HTTP 기반이라 프록시, CDN 문제가 적음
3. ChatGPT 타이핑 효과의 비밀
ChatGPT나 Claude 웹에서 답변이 타이핑되듯이 한 글자씩 나오는 걸 보셨나요?
그게 바로 SSE입니다!
사용자: "안녕하세요를 한 글자씩 보내줘"
[SSE 스트림 시작]
서버 → 클라이언트:
event: content_block_delta
data: {"text":"안"}
서버 → 클라이언트:
event: content_block_delta
data: {"text":"녕"}
서버 → 클라이언트:
event: content_block_delta
data: {"text":"하"}
서버 → 클라이언트:
event: content_block_delta
data: {"text":"세"}
서버 → 클라이언트:
event: content_block_delta
data: {"text":"요"}
[SSE 스트림 종료]
화면에는 이렇게 보입니다
[0.1초] 안
[0.2초] 안녕
[0.3초] 안녕하
[0.4초] 안녕하세
[0.5초] 안녕하세요
각 SSE 이벤트가 도착할 때마다 화면에 텍스트가 추가됩니다.
왜 이렇게 할까?
- 사용자 경험: 즉시 응답이 시작되는 느낌
- 스트리밍 처리: AI 모델이 생성하는 대로 바로 전송
- 긴 응답 처리: 전체 응답을 기다리지 않아도 됨
일반 HTTP였다면:
[3초 대기...]
안녕하세요 ← 한 번에 표시
SSE 스트리밍:
안 ← 즉시
안녕 ← 0.1초
안녕하 ← 0.2초
안녕하세 ← 0.3초
안녕하세요 ← 0.4초
4. Anthropic API의 SSE 이벤트 형식
SSE 포맷 기본 구조
SSE는 단순한 텍스트 형식입니다:
event: 이벤트이름
data: 데이터
event: 다음이벤트
data: 다른데이터
규칙:
event:라인 - 이벤트 타입data:라인 - 실제 데이터 (보통 JSON)- 빈 줄 (
\n\n) - 이벤트 구분자
Anthropic API의 6단계 이벤트 순서
Anthropic API는 다음 순서로 SSE 이벤트를 보냅니다:
1. message_start ← 메시지 시작 (ID, 모델 정보)
2. content_block_start ← 컨텐츠 블록 시작
3. content_block_delta ← 실제 텍스트 (여러 번 반복)
4. content_block_stop ← 컨텐츠 블록 종료
5. message_delta ← 메타데이터 (stop_reason, 토큰 사용량)
6. message_stop ← 메시지 종료
실제 SSE 응답 예시
event: message_start
data: {"type":"message_start","message":{"id":"msg_01ABC",...}}
event: content_block_start
data: {"type":"content_block_start","index":0,...}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"text":"안녕"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"text":"하세요"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"text":"!"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},...}
event: message_stop
data: {"type":"message_stop"}
화면에 표시되는 과정
[message_start] → (화면 변화 없음, 내부적으로 메시지 초기화)
[content_block_start] → (화면 변화 없음, 텍스트 블록 준비)
[content_block_delta] → 안녕
[content_block_delta] → 안녕하세요
[content_block_delta] → 안녕하세요!
[content_block_stop] → (화면 변화 없음, 블록 완료)
[message_delta] → (토큰 사용량 등 메타데이터 수신)
[message_stop] → (응답 완료)
왜 이렇게 복잡한 구조일까?
- message_start: 메시지 ID, 모델 정보 등 초기 설정
- content_block_start/stop: 여러 컨텐츠 블록을 지원 (텍스트, 이미지, 도구 사용 등)
- content_block_delta: 실제 텍스트 스트리밍
- message_delta: 최종 메타데이터 (토큰 사용량, 종료 이유)
- message_stop: 스트림 종료 신호
5. glm-proxy로 보는 SSE + WebFlux 실전
glm-proxy란?
glm-proxy는 Claude Code와 Anthropic API 사이에서 동작하는 프록시 서버입니다.
Claude Code → glm-proxy (localhost:8080) → Anthropic API
│
└─ 요청/응답 로깅
└─ SSE 스트리밍 중계
└─ PII 마스킹 (선택)
주요 역할:
- Anthropic API의 SSE 응답을 받아서 Claude Code로 그대로 전달
- 중간에서 요청/응답 로깅
- 선택적으로 개인정보(PII) 마스킹 처리
왜 만들었나?
- Anthropic API가 실제로 어떤 SSE 이벤트를 보내는지 확인
- SSE 스트리밍 구현을 직접 해보기 위해
- Spring WebFlux의 Reactive 스트림 학습
SSE + WebFlux 동작 흐름
glm-proxy의 핵심은 SSE 스트림을 받아서 그대로 전달하는 것입니다.
1. Claude Code → POST /v1/messages
↓
2. glm-proxy → WebClient로 Anthropic API 호출
↓
3. Anthropic API → SSE 스트림 응답
↓
4. WebFlux → bodyToFlux()로 Reactive Stream 변환
↓
5. glm-proxy → Flux를 그대로 Claude Code에 전달
핵심 코드 (ProxyService.kt:501–527):
webClient
.method(method)
.uri(targetUrl)
.bodyValue(bodyString)
.retrieve()
.bodyToFlux(DataBuffer::class.java) // ← SSE 스트림 → Flux 변환
.doOnNext { buffer ->
// 각 SSE 청크를 받을 때마다 실행
logger.debug("Forwarding buffer ({} bytes)", buffer.readableByteCount())
}
.doOnComplete {
logger.info("✅ Streaming completed")
}
포인트:
bodyToFlux(DataBuffer::class.java): HTTP 응답 스트림을 Reactive Flux로 변환doOnNext: 각 SSE 청크(이벤트)가 도착할 때마다 실행- Flux가 자동으로 백프레셔 처리 — 클라이언트 속도에 맞춰 전송
Claude Code SSE 이벤트 직접 만들기
테스트용으로 Anthropic API 없이 SSE 이벤트를 직접 생성할 수도 있습니다.
핵심: ClaudeCode SSE 포맷 만들기
fun createSSEEvent(event: String, data: String): DataBuffer {
val sseFormat = "event: $event\ndata: $data\n\n"
// 이벤트 이름 JSON 데이터 빈 줄 2개 (필수!)
return bufferFactory.wrap(sseFormat.toByteArray(StandardCharsets.UTF_8))
}
3가지만 기억하면 됩니다:
event: 이벤트이름\ndata: JSON데이터\n\n(빈 줄 하나 더)
Flux로 순차 전송:
Flux.just(createMessageStartEvent()) // 1. message_start
.concatWith(Flux.just(createContentBlockStartEvent(0))) // 2. content_block_start
.concatWith(Flux.just(createContentBlockDeltaEvent(0, "안녕"))) // 3. delta
.concatWith(Flux.just(createContentBlockDeltaEvent(0, "하세요"))) // 4. delta
.concatWith(Flux.just(createContentBlockStopEvent(0))) // 5. content_block_stop
.concatWith(Flux.just(createMessageStopEvent())) // 6. message_stop
response.writeWith(eventFlux) // HTTP 응답으로 전송
포인트:
concatWith: 순서대로 연결 (순서 보장!)response.writeWith(): Flux를 HTTP 응답 스트림으로 변환- 각 이벤트가 순서대로 클라이언트에 전달됨
실제 동작 확인 — 로그로 보기
테스트 엔드포인트 호출 시:
15:31:50.871 INFO - 🧪 Test endpoint called
15:31:50.872 DEBUG - 📤 message_start sent
15:31:50.873 DEBUG - 📤 content_block_start sent
15:31:50.874 DEBUG - 📤 content_block_delta sent
15:31:50.875 DEBUG - 📤 content_block_delta sent
15:31:50.876 DEBUG - 📤 content_block_delta sent
15:31:50.879 DEBUG - 📤 content_block_stop sent
15:31:50.880 DEBUG - 📤 message_delta sent
15:31:50.881 DEBUG - 📤 message_stop sent
15:31:50.881 INFO - ✅ All events sent successfully
약 0.01초 간격으로 이벤트가 순차 전송되고, Claude Code 화면에는 타이핑되듯이 텍스트가 나타납니다.
실제 API 프록시 동작:
15:52:54.576 INFO - REQUEST INCOMING
15:52:54.576 INFO - Path: /v1/messages
15:52:54.576 INFO - Body: {"model":"claude-haiku-4-5","messages":[...]}
15:52:54.576 INFO - Forwarding to: https://api.z.ai/api/anthropic/v1/messages
15:52:54.576 INFO - Streaming response from API...
15:52:57.284 DEBUG - Forwarding buffer (383 bytes) ← 첫 SSE 이벤트 도착
15:52:57.287 INFO - ✅ Streaming completed (Duration: 1413ms)
흐름:
- 요청 수신 (15:52:54.576)
- API로 즉시 전달
- 2.7초 후 첫 응답 수신
- 총 1.4초 동안 스트리밍 완료
핵심 정리
glm-proxy의 SSE 처리:
단계기술역할1. SSE 수신WebClientAnthropic API의 SSE 스트림 수신2. 변환bodyToFlux()HTTP 스트림 → Reactive Flux3. 전달response.writeWith()Flux → HTTP 응답 스트림
이게 전부입니다! WebFlux는 복잡한 SSE 처리를 3단계로 간단하게 만들어줍니다.
6. Spring WebFlux로 SSE 스트리밍 처리하기
Anthropic API에서 SSE 수신
핵심 코드:
webClient
.method(method)
.uri(targetUrl)
.bodyValue(bodyString)
.retrieve()
.bodyToFlux(DataBuffer::class.java) // ← SSE 스트림을 Flux로 변환
.doOnSubscribe {
logger.info("Streaming response from API...")
}
.doOnNext { buffer ->
totalResponseBytes += buffer.readableByteCount()
logger.debug("Forwarding API response buffer ({} bytes)",
buffer.readableByteCount())
}
.doOnComplete {
val duration = System.currentTimeMillis() - startTime
logger.info("✅ Response streaming completed (Duration: {}ms)", duration)
}
.doOnError { error ->
logger.error("❌ Error: {}", error.message)
}
동작 원리:
Anthropic API ─(SSE Stream)─> WebClient
│
▼ bodyToFlux()
Flux<DataBuffer>
│
▼ doOnNext()
각 청크마다 처리
│
▼
클라이언트로 전달
Flux와 Mono 이해하기
Mono: 0개 또는 1개의 데이터
Mono.just("하나의 값")
Mono.empty() // 값 없음
Flux: 0개 이상의 데이터 (스트림)
Flux.just("첫번째", "두번째", "세번째")
Flux.fromArray(array)
SSE는 여러 개의 이벤트를 보내므로 Flux를 사용합니다.
concatWith로 순차 처리
Anthropic API는 특정 이벤트 순서를 요구합니다. Claude Code는 message_start → content_block_start → ... 순서로 오는 걸 기대하므로, 이 순서를 지켜야 합니다.
SSE 프로토콜 자체는 순서를 강제하지 않습니다. 다른 용도(예: 주식 시세, 알림)에서는 순서가 중요하지 않을 수 있습니다. 여기서 순서가 중요한 건 Anthropic의 메시지 프로토콜 때문입니다.
Flux.just(event1)
.concatWith(Flux.just(event2)) // event1 완료 후 event2
.concatWith(Flux.just(event3)) // event2 완료 후 event3
.concatWith(Flux.just(event4)) // event3 완료 후 event4
concatWith vs merge:
concatWith: 순차 처리 (순서 보장) ← 순서가 중요한 이벤트 스트림에 적합merge: 병렬 처리 (순서 보장 안 됨)
마무리
배운 내용 정리
1. SSE란?
- 서버 → 클라이언트 단방향 실시간 데이터 전송
- ChatGPT 타이핑 효과의 핵심 기술
2. SSE vs WebSocket
- SSE: 단방향, 간단, HTTP 기반
- WebSocket: 양방향, 복잡, 별도 프로토콜
3. Anthropic API SSE 이벤트
- 6단계 순서: message_start → contentblock* → message_stop
- Anthropic 프로토콜이 순서를 요구 (SSE 자체는 순서 강제 안 함)
4. Spring WebFlux 구현
bodyToFlux(): HTTP 스트림 → Reactive StreamconcatWith(): 순차 처리로 순서 보장- WebFlux가 SSE 처리를 3단계로 간단하게 만들어줌
다음 단계
이 글에서는 SSE의 기본 개념과 Spring WebFlux 구현을 다뤘습니다.
다음 글에서는:
- OpenTelemetry로 분산 추적 구현
- Jaeger UI로 성능 병목 찾기
- 프로덕션 환경 고려사항
을 다룰 예정입니다.
참고 자료
- GitHub: https://github.com/devload/glm-proxy
- Anthropic API Streaming 공식 문서: https://docs.anthropic.com/en/api/messages-streaming
- MDN SSE 가이드: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
- Spring WebFlux 공식 문서: https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html
- Claude Code는 어떤 데이터를 호출할까 — Claude Proxy Server 만들기 : https://medium.com/p/1c5084444295
메타데이터
- post_id
- 7d5ca5fb70ca
- slug
- spring-webflux-sse로-실시간-스트리밍-구현하기-7d5ca5fb70ca
- url
- https://medium.com/@sunghyunroh/spring-webflux-sse%EB%A1%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-7d5ca5fb70ca
- canonical_url
- https://medium.com/@sunghyunroh/spring-webflux-sse%EB%A1%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-7d5ca5fb70ca
- author_url
- https://medium.com/@sunghyunroh
- status
- ok
- fetched_at
- 2026-08-17 16:17:32