이 기사에 대하여
대상 독자: 지난 회차에서 Claude API의 동기 호출까지 구현해 본 사람 / 응답을 브라우저에 순차적으로 표시하고 싶은 사람 -
얻을 수 있는 것: 공식 Java SDK의 createStreaming을 통해 차분(delta)을 수신하고, 이를 SSE (Server-Sent Events)로 브라우저까지 전달하는 최소 구현. 비동기·완료·에러·클라이언트 접속 종료 처리까지 -
전제 조건 및 환경: Java 21 / Spring Boot 3.5 / com.anthropic:anthropic-java 2.34.0. 지난번에 만든 AnthropicClient와 AnthropicProperties, 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 측의 구현입니다. createStreaming은 StreamResponse<RawMessageStreamEvent>를 반환합니다. 이는 AutoCloseable이므로, 반드시 try-with-resources로 닫아야 합니다 (연결을 열어둔 채로 두지 않기 위해).
스트림에는 message_start나 content_block_start, 마지막 message_delta (usage 포함) 등 여러 종류의 이벤트가 흐릅니다. 필요한 것은 본문의 차분뿐이므로, content_block_delta 중 text_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을 던집니다. 이를 다시 던지면 askStream의 forEach가 중단되고, 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.send의 IOException이 접속 종료의 신호입니다. 예외를 통해 askStream을 멈추면 StreamResponse도 닫히며, 이후의 토큰 생성(즉, 과금)도 중단할 수 있습니다.
- 에러가 너무 빨리 발생하여 SSE 형식이 되지 않는 경우: 차분이 하나도 전달되지 않은 단계에서 실패하는 케이스입니다. 이때 반환되는 응답은
text/event-stream이 아니라 일반적인 500 (JSON)이 됩니다. 연결은 닫히므로 행(Hang) 상태에 빠지지는 않습니다. 프론트엔드 측은 EventSource의 onerror에서 이를 감지합니다.
- 토큰 수나 중단 사유를 가져오고 싶은 경우: 출력 토큰 수나 중단 사유는 마지막
message_delta 이벤트에 포함되어 있습니다 (usage()와 delta().stopReason()). event.messageDelta()에서 추출하여 집계하면 운영 로그에 남길 수 있습니다.
요약
- SDK 측은
create를 createStreaming으로 바꾸고, 이벤트에서 text_delta를 추출하기만 하면 됩니다. 차분은 onDelta로 순차 처리하면서, 전체 문장은 반환값으로 받습니다.
- 브라우저까지 흘려보내는 핵심은 SSE입니다.
SseEmitter를 즉시 반환하고, 블로킹되는 전송은 별도 스레드에서 수행합니다.
- 종료 시에는 반드시
complete() / completeWithError()로 닫습니다. send의 IOException으로 접속 종료를 감지하여 스트림 전체를 중단합니다.
여기서는 "브라우저까지 순차적으로 표시하는" 최소 형태에 집중했지만, 사양 정의부터 구현·테스트·배포까지를 Claude Code와 일관되게 진행하는 흐름은 제 저서에 정리해 두었습니다. Spring Security / JPA / Flyway 및 운영 배포(Railway)까지, AI와 대화하며 하나의 Web App(웹 애플리케이션)을 완성하는 구성입니다.
📘 『Claude Code와 함께 만드는 Spring Boot 실전 개발 AI 일기 앱을 사양 정의부터 배포까지』
Discussion