본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 06. 25. 21:27

Spring Boot에서 Claude의 스트리밍 응답을 SSE로 브라우저에 전달하기

요약

Spring Boot 환경에서 Anthropic Java SDK를 사용하여 Claude의 스트리밍 응답을 SSE(Server-Sent Events)로 브라우저에 전달하는 방법을 설명합니다. SDK의 스트림 이벤트를 처리하고 SseEmitter를 통해 비동기적으로 데이터를 전송하는 구현 과정을 다룹니다.

핵심 포인트

  • Claude SDK의 createStreaming 메서드를 사용하여 텍스트 차분(delta) 수신
  • SseEmitter를 활용하여 서버에서 브라우저로 실시간 데이터 스트리밍 구현
  • 별도 스레드 활용 및 try-with-resources를 통한 스트림 자원 관리
  • Spring Boot 3.5 및 Java 21 기반의 최신 구현 가이드

이 기사에 대하여

대상 독자: 지난 회차에서 Claude API의 동기 호출까지 구현해 본 사람 / 응답을 브라우저에 순차적으로 표시하고 싶은 사람 -
얻을 수 있는 것: 공식 Java SDK의 createStreaming을 통해 차분(delta)을 수신하고, 이를 SSE (Server-Sent Events)로 브라우저까지 전달하는 최소 구현. 비동기·완료·에러·클라이언트 접속 종료 처리까지 -
전제 조건 및 환경: Java 21 / Spring Boot 3.5 / com.anthropic:anthropic-java 2.34.0. 지난번에 만든 AnthropicClientAnthropicProperties, Spring MVC (spring-boot-starter-web)를 사용합니다.

이 기사는 "Spring Boot에서 공식 Java SDK로 Claude API를 호출하는 최소 구현"의 후속편입니다. 의존성·설정·Bean 등록은 지난 회차를 참조해 주세요.

버전(SDK·Spring Boot) 및 모델명은 집필 시점(2026년 6월) 기준입니다. 최신 정보는 공식 문서를 통해 확인해 주세요.

결론 (먼저 전체상 파악)

SDK에서 스트리밍으로 전환하기 위한 변경 사항은 거의 한 곳뿐입니다.

client.messages().create(...)client.messages().createStreaming(...)

  • 얻어지는 **이벤트 열(event stream)**에서 텍스트의 차분(text_delta)만 추출합니다.

본론은 그 다음인 **"차분을 브라우저까지 어떻게 전달할 것인가"**입니다. 여기서는 SSE를 사용합니다. 요청 스레드에서 그대로 실행하면 응답이 끝날 때까지 스레드를 점유하게 됩니다. 따라서 별도의 스레드에서 전송하고, 마지막에는 반드시 SseEmitter로 다리를 놓습니다. 단, SDK의 스트림 소비는 블로킹(blocking)되는 complete()를 호출하여 닫습니다. 이 점이 본 기사의 핵심입니다.

[Claude API] --(차분)--> StreamingAiAssistant.askStream(onDelta)
│ onDelta (별도 스레드에서 실행)
▼
...

스트리밍과 SSE는 모두 추가 의존성 없이 이용할 수 있습니다 (SDK와 Spring MVC에 포함되어 있습니다).

askStream을 준비하기

단계 1: 차분을 전달하기. 우선 SDK 측의 구현입니다. createStreamingStreamResponse<RawMessageStreamEvent>를 반환합니다. 이는 AutoCloseable이므로, 반드시 try-with-resources로 닫아야 합니다 (연결을 열어둔 채로 두지 않기 위해).

스트림에는 message_startcontent_block_start, 마지막 message_delta (usage 포함) 등 여러 종류의 이벤트가 흐릅니다. 필요한 것은 본문의 차분뿐이므로, content_block_deltatext_delta를 이중 flatMap으로 걸러냅니다. 수신한 차분은 그때마다 호출 측으로 전달하면서, 전체 문장도 구성하여 반환해 두면 다양한 상황에서 재사용할 수 있습니다.

@Service
@RequiredArgsConstructor
@Slf4j
...

onDelta에 "콘솔에 출력하는 처리"를 전달하면 그 자리에서 즉시 순차적으로 표시할 수 있습니다. 다음은 이 onDelta의 내용을 브라우저로 흘려보내는 SSE로 교체합니다. 전체 문장이 필요한 처리(로그·저장 등)는 반환값으로 받을 수 있습니다.

단계 2: SSE 엔드포인트를 준비하기

SseEmitter를 반환하는 컨트롤러를 준비하고, onDelta에서 emitter.send(...)를 호출합니다. 포인트는 3가지입니다.

@RestController
@RequiredArgsConstructor
@Slf4j
...
  • ① 별도 스레드에서 전송하기: createStreaming의 소비는 블로킹(blocking)입니다. @GetMapping 메서드 내에서 직접 실행하면 응답이 완료될 때까지 요청 스레드를 계속 점유합니다. 동시 접속이 늘어나면 스레드 고갈로 이어질 수 있습니다. 따라서 SseEmitter

즉시 반환하고, 실제 전송은 TaskExecutor (Spring Boot 기본 applicationTaskExecutor)의 별도 스레드에 맡깁니다.

  • : send를 통해 클라이언트의 접속 종료를 감지합니다. emitter.send(...)는 상대방이 연결을 끊었을 경우 IOException을 던집니다. 이를 다시 던지면 askStreamforEach가 중단되고, try-with-resources가 StreamResponse를 닫습니다. 결과적으로 이후의 토큰 생성을 중단할 수 있어 불필요한 비용 발생을 방지할 수 있습니다.
  • ③ 반드시 닫기: 정상 종료 시에는 complete(), 실패 시에는 completeWithError(e)를 호출합니다. 둘 중 하나라도 호출하지 않으면 연결이 열린 상태로 유지됩니다. 만약을 위해 new SseEmitter(60_000L)와 같이 타임아웃도 설정해 둡니다.

단계 3: 브라우저 측 (EventSource)

프론트엔드 측은 표준 EventSource로 받습니다. 차분(delta)이 도착할 때마다 화면에 추가합니다.

<input id="prompt" size="50" value="Spring Boot를 한마디로.">
<button id="send">전송</button>
<pre id="out"></pre>
...

EventSource는 GET 방식만 전송할 수 있으므로 엔드포인트도 GET으로 설정합니다. 참고로 SSE에서는 서버가 complete()로 닫더라도 브라우저 측에서는 onerror가 발생합니다 (정상적인 종료와 비정상적인 종료를 구분할 수 없기 때문입니다). 그대로 두면 자동으로 재연결되어 생성이 다시 실행될 수 있습니다. 이를 방지하기 위해 onerror에서 close()를 호출하여 중지합니다.

주의할 점과 해결 방법

구현 시 실수하기 쉬운 부분은 스레드 처리와 스트림을 닫는 방식입니다. 대표적인 5가지 포인트와 대처법을 정리합니다.

  • 요청 스레드를 점유해 버리는 경우: 블로킹(Blocking) 방식인 createStreaming@GetMapping 내에서 직접 소비하면 응답이 완료될 때까지 스레드를 점유합니다. SseEmitter를 반환하고, 전송은 별도 스레드(TaskExecutor)에서 수행합니다.
  • 연결을 제대로 닫지 않는 경우: emitter를 닫는 것을 잊어 연결이 남지 않도록 complete() / completeWithError()를 반드시 거치는 구조로 만듭니다. try ~ catch 양쪽에서 닫아주고, new SseEmitter(timeout)으로 타임아웃도 설정합니다.
  • 클라이언트의 접속 종료를 인지하지 못하는 경우: emitter.sendIOException이 접속 종료의 신호입니다. 예외를 통해 askStream을 멈추면 StreamResponse도 닫히며, 이후의 토큰 생성(즉, 과금)도 중단할 수 있습니다.
  • 에러가 너무 빨리 발생하여 SSE 형식이 되지 않는 경우: 차분이 하나도 전달되지 않은 단계에서 실패하는 케이스입니다. 이때 반환되는 응답은 text/event-stream이 아니라 일반적인 500 (JSON)이 됩니다. 연결은 닫히므로 행(Hang) 상태에 빠지지는 않습니다. 프론트엔드 측은 EventSourceonerror에서 이를 감지합니다.
  • 토큰 수나 중단 사유를 가져오고 싶은 경우: 출력 토큰 수나 중단 사유는 마지막 message_delta 이벤트에 포함되어 있습니다 (usage()delta().stopReason()). event.messageDelta()에서 추출하여 집계하면 운영 로그에 남길 수 있습니다.

요약

  • SDK 측은 createcreateStreaming으로 바꾸고, 이벤트에서 text_delta를 추출하기만 하면 됩니다. 차분은 onDelta로 순차 처리하면서, 전체 문장은 반환값으로 받습니다.
  • 브라우저까지 흘려보내는 핵심은 SSE입니다. SseEmitter즉시 반환하고, 블로킹되는 전송은 별도 스레드에서 수행합니다.
  • 종료 시에는 반드시 complete() / completeWithError()로 닫습니다. sendIOException으로 접속 종료를 감지하여 스트림 전체를 중단합니다.

여기서는 "브라우저까지 순차적으로 표시하는" 최소 형태에 집중했지만, 사양 정의부터 구현·테스트·배포까지를 Claude Code와 일관되게 진행하는 흐름은 제 저서에 정리해 두었습니다. Spring Security / JPA / Flyway 및 운영 배포(Railway)까지, AI와 대화하며 하나의 Web App(웹 애플리케이션)을 완성하는 구성입니다.

📘 『Claude Code와 함께 만드는 Spring Boot 실전 개발 AI 일기 앱을 사양 정의부터 배포까지

Discussion

AI 자동 생성 콘텐츠

본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0