← Back to list

Spring WebFlux + SSE로 실시간 스트리밍 구현하기

ChatGPT가 글자를 한 자씩 쓰는 비밀 — SSE(Server-Sent Events)

Sunghyun Roh · 2026-01-17 08:16 · 9 claps · 17.4 min read
#cladue #spring #webflux #sse #llm
Open on Medium ↗
Wiki topics: LLM · Large Language Models MM · Multimodal & Generative Media

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 이벤트가 도착할 때마다 화면에 텍스트가 추가됩니다.

왜 이렇게 할까?

  1. 사용자 경험: 즉시 응답이 시작되는 느낌
  2. 스트리밍 처리: AI 모델이 생성하는 대로 바로 전송
  3. 긴 응답 처리: 전체 응답을 기다리지 않아도 됨

일반 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]           → (응답 완료)

왜 이렇게 복잡한 구조일까?

  1. message_start: 메시지 ID, 모델 정보 등 초기 설정
  2. content_block_start/stop: 여러 컨텐츠 블록을 지원 (텍스트, 이미지, 도구 사용 등)
  3. content_block_delta: 실제 텍스트 스트리밍
  4. message_delta: 최종 메타데이터 (토큰 사용량, 종료 이유)
  5. message_stop: 스트림 종료 신호

5. glm-proxy로 보는 SSE + WebFlux 실전

glm-proxy란?

glm-proxy는 Claude Code와 Anthropic API 사이에서 동작하는 프록시 서버입니다.

[embed]GitHub - devload/glm-proxy Contribute to devload/glm-proxy development by creating an account on GitHub.github.com

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가지만 기억하면 됩니다:

  1. event: 이벤트이름\n
  2. data: JSON데이터\n
  3. \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)

흐름:

  1. 요청 수신 (15:52:54.576)
  2. API로 즉시 전달
  3. 2.7초 후 첫 응답 수신
  4. 총 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_startcontent_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 Stream
  • concatWith(): 순차 처리로 순서 보장
  • WebFlux가 SSE 처리를 3단계로 간단하게 만들어줌

다음 단계

이 글에서는 SSE의 기본 개념과 Spring WebFlux 구현을 다뤘습니다.

다음 글에서는:

  • OpenTelemetry로 분산 추적 구현
  • Jaeger UI로 성능 병목 찾기
  • 프로덕션 환경 고려사항

을 다룰 예정입니다.

참고 자료


메타데이터
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