본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2026. 1. 31. · 7 Views
스트리밍 응답 처리 완벽 가이드
대용량 데이터를 청크 단위로 실시간 전송하는 스트리밍 응답 처리 기술을 배웁니다. Spring WebFlux와 SSE를 활용하여 AI 챗봇, 실시간 로그, 대용량 파일 다운로드 등을 효율적으로 구현하는 방법을 실무 예제와 함께 학습합니다.
목차
- 스트리밍_응답의_필요성
- Flux를_활용한_스트리밍
- Server-Sent_Events_SSE_구현
- WebFlux를_통한_비동기_처리
- 스트리밍_응답_에러_핸들링
- 실시간_챗봇_UI_구현
1. 스트리밍 응답의 필요성
어느 날 김개발 씨는 AI 챗봇 서비스를 개발하고 있었습니다. 사용자가 질문을 입력하면 ChatGPT처럼 답변이 한 글자씩 타이핑되듯이 나타나야 했습니다.
하지만 일반적인 REST API로는 응답이 모두 완성될 때까지 기다려야 했습니다.
스트리밍 응답은 서버에서 생성되는 데이터를 작은 청크 단위로 실시간으로 클라이언트에게 전송하는 기술입니다. 마치 영화를 다운로드하면서 동시에 재생하는 것처럼, 모든 데이터가 준비될 때까지 기다리지 않고 준비된 부분부터 즉시 전달할 수 있습니다.
AI 챗봇, 실시간 로그 모니터링, 대용량 파일 다운로드 등에서 사용자 경험을 획기적으로 개선할 수 있습니다.
다음 코드를 살펴봅시다.
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String question) {
// AI 응답을 단어 단위로 스트리밍
return Flux.interval(Duration.ofMillis(100))
.take(20)
.map(i -> "응답 단어 " + i + " ")
.doOnComplete(() -> System.out.println("스트리밍 완료"));
}
// 클라이언트 측 JavaScript
const eventSource = new EventSource('/chat/stream?question=hello');
eventSource.onmessage = (event) => {
document.getElementById('response').innerText += event.data;
};
김개발 씨는 입사 6개월 차 백엔드 개발자입니다. 최근 회사에서 AI 챗봇 서비스를 개발하게 되었는데, 프로젝트 매니저로부터 중요한 요구사항을 전달받았습니다.
"ChatGPT처럼 답변이 한 글자씩 나타나야 해요. 사용자들이 기다리는 느낌을 덜 받거든요." 김개발 씨는 고민에 빠졌습니다.
지금까지 배운 REST API는 서버에서 응답을 완전히 만든 후 한 번에 전송하는 방식이었습니다. 어떻게 해야 답변을 조금씩 나눠서 보낼 수 있을까요?
선배 개발자 박시니어 씨가 모니터를 보며 조언합니다. "아, 스트리밍 응답을 사용해야 하는 상황이네요.
일반 REST API와는 다른 방식이 필요해요." 그렇다면 스트리밍 응답이란 정확히 무엇일까요? 쉽게 비유하자면, 스트리밍 응답은 마치 택배 물건을 한꺼번에 받는 것이 아니라 준비되는 대로 하나씩 받는 것과 같습니다.
큰 가구를 주문했을 때 모든 부품이 다 준비될 때까지 기다리는 대신, 조립 가능한 부품부터 먼저 받아서 조립을 시작할 수 있습니다. 이처럼 스트리밍 응답도 서버에서 데이터가 생성되는 즉시 클라이언트로 전송하는 역할을 담당합니다.
스트리밍 응답이 없던 시절에는 어땠을까요? 개발자들은 AI 챗봇처럼 긴 응답을 생성할 때 모든 텍스트가 완성될 때까지 사용자를 기다리게 해야 했습니다.
30초가 걸리는 응답이라면 사용자는 30초 동안 빈 화면을 바라보며 답답함을 느껴야 했습니다. 더 큰 문제는 대용량 파일 다운로드나 로그 모니터링 같은 경우였습니다.
프로젝트가 커질수록 이런 문제는 눈덩이처럼 불어났습니다. 바로 이런 문제를 해결하기 위해 스트리밍 응답이 등장했습니다.
스트리밍 응답을 사용하면 실시간 피드백이 가능해집니다. 사용자는 서버가 작업하는 과정을 즉시 확인할 수 있습니다.
또한 메모리 효율성도 얻을 수 있습니다. 무엇보다 사용자 경험이 획기적으로 개선된다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 1번째 줄을 보면 produces = MediaType.TEXT_EVENT_STREAM_VALUE로 스트리밍 응답임을 선언하고 있습니다.
이 부분이 핵심입니다. 다음으로 3번째 줄에서는 Flux.interval을 사용하여 100ms마다 데이터를 생성합니다.
4번째 줄의 **take(20)**은 총 20개의 데이터만 전송하도록 제한합니다. 마지막으로 각 데이터가 클라이언트에게 순차적으로 전송됩니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 고객 상담 AI 챗봇 서비스를 개발한다고 가정해봅시다.
사용자가 복잡한 질문을 하면 AI가 긴 답변을 생성하는데 10초 이상 걸릴 수 있습니다. 스트리밍 응답을 활용하면 AI가 답변을 생성하는 즉시 한 단어씩 사용자 화면에 표시되므로 기다리는 시간이 훨씬 짧게 느껴집니다.
많은 AI 서비스 기업에서 이런 패턴을 적극적으로 사용하고 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 일반 REST API 방식으로 스트리밍을 구현하려는 것입니다. 이렇게 하면 브라우저가 응답을 버퍼링해서 결국 모든 데이터를 받은 후에야 화면에 표시하는 문제가 발생할 수 있습니다.
따라서 TEXT_EVENT_STREAM 또는 APPLICATION_STREAM_JSON 같은 적절한 미디어 타입으로 사용해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 ChatGPT가 답변을 조금씩 보여주는 거였군요!" 스트리밍 응답을 제대로 이해하면 사용자 경험을 획기적으로 개선하는 서비스를 만들 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - TEXT_EVENT_STREAM_VALUE 미디어 타입은 SSE 표준을 따르며 자동 재연결 기능을 제공합니다
- Flux 대신 일반 List를 반환하면 스트리밍이 작동하지 않으니 반드시 리액티브 타입을 사용하세요
2. Flux를 활용한 스트리밍
김개발 씨는 스트리밍의 개념은 이해했지만, 실제 구현에서 막막함을 느꼈습니다. "도대체 Flux가 뭔가요?" 박시니어 씨가 웃으며 답합니다.
"Spring WebFlux의 핵심 개념이죠. 리액티브 프로그래밍의 꽃이에요."
Flux는 Spring WebFlux에서 제공하는 리액티브 스트림 타입으로, 0개 이상의 데이터를 비동기적으로 순차 전송할 수 있습니다. 마치 컨베이어 벨트처럼 데이터를 하나씩 흘려보내며, 백프레셔를 통해 생산자와 소비자의 속도를 조절합니다.
AI 응답 생성, 실시간 알림, 데이터베이스 배치 처리 등 다양한 상황에서 활용됩니다.
다음 코드를 살펴봅시다.
@RestController
@RequestMapping("/api")
public class StreamController {
@GetMapping(value = "/numbers", produces = MediaType.APPLICATION_NDJSON_VALUE)
public Flux<Integer> streamNumbers() {
// 1부터 100까지 숫자를 500ms 간격으로 스트리밍
return Flux.range(1, 100)
.delayElements(Duration.ofMillis(500))
.doOnNext(num -> System.out.println("전송: " + num))
.doOnError(err -> System.err.println("에러 발생: " + err))
.doOnComplete(() -> System.out.println("전송 완료"));
}
}
김개발 씨는 코드 에디터를 열고 예제를 따라 타이핑해봤습니다. 하지만 Flux라는 낯선 타입이 계속 등장했습니다.
List나 Array처럼 익숙한 자료구조와는 뭔가 달라 보였습니다. "선배님, Flux는 List랑 뭐가 다른가요?" 김개발 씨가 궁금증을 참지 못하고 물었습니다.
박시니어 씨는 커피를 한 모금 마시고 설명을 시작합니다. Flux가 정확히 무엇인지 이해하려면 먼저 리액티브 프로그래밍의 개념을 알아야 합니다.
쉽게 비유하자면, Flux는 마치 공장의 컨베이어 벨트와 같습니다. 일반 List는 완성된 제품들이 담긴 상자라면, Flux는 제품을 하나씩 만들어서 벨트에 올려놓는 시스템입니다.
상자는 모든 제품이 다 들어가야 배송할 수 있지만, 컨베이어 벨트는 제품이 만들어지는 즉시 다음 공정으로 보낼 수 있습니다. 이처럼 Flux도 데이터를 생성하는 즉시 구독자에게 전달하는 역할을 담당합니다.
Flux가 없던 시절 개발자들은 어떻게 했을까요? 개발자들은 모든 데이터를 메모리에 올려놓고 한꺼번에 처리해야 했습니다.
1만 개의 데이터를 처리한다면 1만 개 모두를 List에 담아서 반환했습니다. 메모리 사용량이 폭증했고, 사용자는 모든 데이터가 준비될 때까지 기다려야 했습니다.
더 큰 문제는 데이터가 100만 개, 1000만 개로 늘어나면 OutOfMemoryError가 발생한다는 점이었습니다. 바로 이런 문제를 해결하기 위해 Flux가 등장했습니다.
Flux를 사용하면 메모리 효율성이 극대화됩니다. 한 번에 하나씩 데이터를 처리하므로 메모리에 모든 데이터를 올릴 필요가 없습니다.
또한 백프레셔 기능으로 소비자가 처리할 수 있는 만큼만 데이터를 생산할 수 있습니다. 무엇보다 비동기 처리로 서버 리소스를 효율적으로 활용할 수 있다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 6번째 줄을 보면 **Flux.range(1, 100)**으로 1부터 100까지의 숫자 스트림을 생성하고 있습니다.
이 부분이 데이터 소스입니다. 다음으로 7번째 줄에서는 delayElements를 사용하여 각 숫자 사이에 500ms 지연을 추가합니다.
이렇게 하면 클라이언트가 0.5초마다 하나씩 숫자를 받게 됩니다. 8번째 줄의 doOnNext는 각 데이터가 전송될 때마다 로그를 출력합니다.
마지막으로 스트림이 완료되거나 에러가 발생하면 적절한 핸들러가 실행됩니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 대용량 주문 데이터를 엑셀로 다운로드하는 기능을 개발한다고 가정해봅시다. 100만 건의 주문 데이터를 한꺼번에 메모리에 올리면 서버가 죽을 수 있습니다.
하지만 Flux를 활용하면 데이터베이스에서 1000건씩 읽어서 클라이언트에게 스트리밍으로 전송할 수 있습니다. 사용자는 다운로드 진행 상황을 실시간으로 확인하며 데이터를 받을 수 있습니다.
많은 전자상거래 플랫폼에서 이런 패턴을 적극적으로 사용하고 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 Flux 안에서 블로킹 작업을 수행하는 것입니다. 예를 들어 **Thread.sleep()**이나 동기 방식의 데이터베이스 쿼리를 사용하면 리액티브의 장점이 사라집니다.
이렇게 하면 전체 스트림이 멈춰버리는 문제가 발생할 수 있습니다. 따라서 delayElements나 리액티브 데이터베이스 드라이버처럼 논블로킹 방식으로 사용해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 눈이 반짝였습니다.
"아, 그래서 대용량 데이터 처리에 Flux를 쓰는 거군요!" Flux를 제대로 이해하면 메모리 효율적이고 확장 가능한 시스템을 구축할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - Flux는 Cold Publisher로 구독자가 생길 때마다 새로운 데이터 스트림을 생성합니다
- 블로킹 코드를 사용해야 한다면 **subscribeOn(Schedulers.boundedElastic())**으로 별도 스레드에서 실행하세요
3. Server-Sent Events SSE 구현
며칠 후 김개발 씨는 새로운 요구사항을 받았습니다. "실시간 알림 기능을 만들어주세요.
주문이 들어오면 관리자 화면에 즉시 표시되어야 해요." 김개발 씨는 고민했습니다. WebSocket을 써야 하나?
아니면 다른 방법이 있을까?
Server-Sent Events는 서버에서 클라이언트로 단방향 실시간 데이터 전송을 제공하는 HTML5 표준 기술입니다. 마치 라디오 방송처럼 서버가 메시지를 보내면 연결된 모든 클라이언트가 자동으로 수신합니다.
WebSocket보다 간단하며 자동 재연결, 이벤트 ID 관리 등의 기능을 기본 제공하여 실시간 알림, 주가 업데이트, 채팅 등에 활용됩니다.
다음 코드를 살펴봅시다.
@GetMapping(value = "/notifications/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamNotifications() {
return Flux.interval(Duration.ofSeconds(5))
.map(sequence -> ServerSentEvent.<String>builder()
.id(String.valueOf(sequence))
.event("notification")
.data("새로운 주문이 도착했습니다: 주문번호 " + (1000 + sequence))
.comment("실시간 알림 시스템")
.build())
.doOnCancel(() -> System.out.println("클라이언트 연결 종료"));
}
// 프론트엔드 JavaScript
const eventSource = new EventSource('/notifications/stream');
eventSource.addEventListener('notification', (event) => {
console.log('알림:', event.data);
});
김개발 씨는 실시간 통신 기술을 검색해봤습니다. WebSocket이 가장 먼저 눈에 들어왔지만, 양방향 통신이 필요한 복잡한 기술처럼 보였습니다.
지금 필요한 건 서버에서 클라이언트로 알림을 보내는 단방향 통신인데 말이죠. 박시니어 씨가 모니터를 보더니 조언합니다.
"SSE를 사용해보는 게 어때요? 단방향 통신이라면 WebSocket보다 훨씬 간단해요." 그렇다면 SSE란 정확히 무엇일까요?
쉽게 비유하자면, SSE는 마치 라디오 방송국과 같습니다. 방송국은 프로그램을 송출하고, 라디오를 켠 사람들은 누구나 그 방송을 들을 수 있습니다.
청취자가 방송국에 메시지를 보낼 수는 없지만, 방송을 듣는 데는 전혀 문제가 없습니다. 만약 라디오가 꺼졌다가 다시 켜지면 방송을 다시 수신할 수 있습니다.
이처럼 SSE도 서버에서 클라이언트로 메시지를 일방적으로 전송하는 역할을 담당합니다. SSE가 없던 시절에는 어떻게 실시간 알림을 구현했을까요?
개발자들은 폴링 방식을 사용했습니다. 클라이언트가 매 5초마다 서버에 "새로운 알림 있나요?"라고 물어보는 식이었습니다.
이 방식은 비효율적이었습니다. 대부분의 요청은 "없음"이라는 답변을 받았고, 서버는 불필요한 요청을 계속 처리해야 했습니다.
더 큰 문제는 실시간성이 떨어진다는 점이었습니다. 알림이 발생해도 최대 5초는 기다려야 사용자가 확인할 수 있었습니다.
바로 이런 문제를 해결하기 위해 SSE가 등장했습니다. SSE를 사용하면 진정한 실시간 통신이 가능해집니다.
서버에서 이벤트가 발생하는 즉시 모든 연결된 클라이언트에게 전달됩니다. 또한 자동 재연결 기능이 내장되어 있어 네트워크가 끊겼다가 복구되면 자동으로 다시 연결됩니다.
무엇보다 WebSocket보다 구현이 훨씬 간단하다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 1번째 줄을 보면 TEXT_EVENT_STREAM_VALUE로 SSE 표준을 따르는 응답임을 선언하고 있습니다. 이 부분이 핵심입니다.
다음으로 3번째 줄에서는 Flux.interval을 사용하여 5초마다 이벤트를 생성합니다. 4번째 줄부터는 ServerSentEvent 빌더를 사용하여 SSE 표준 형식의 이벤트를 만듭니다.
id는 이벤트 식별자, event는 이벤트 타입, data는 실제 전송할 데이터입니다. 클라이언트는 이벤트 타입별로 다른 핸들러를 등록할 수 있습니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 배달 음식 주문 플랫폼을 개발한다고 가정해봅시다.
고객이 주문을 하면 음식점 관리자 화면에 즉시 알림이 표시되어야 합니다. SSE를 활용하면 주문이 접수되는 즉시 서버에서 알림 이벤트를 발생시켜 모든 연결된 관리자 화면에 실시간으로 전달할 수 있습니다.
또한 배달 상태가 변경될 때마다 고객 앱에도 실시간으로 업데이트를 전송할 수 있습니다. 많은 O2O 서비스 기업에서 이런 패턴을 적극적으로 사용하고 있습니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 SSE를 양방향 통신이 필요한 곳에 사용하려는 것입니다.
예를 들어 채팅 애플리케이션에서 사용자가 메시지를 보내야 한다면 SSE만으로는 부족합니다. 이렇게 하면 별도의 HTTP POST 요청을 추가로 구현해야 하는 문제가 발생할 수 있습니다.
따라서 양방향 통신이 필요하면 WebSocket을 사용하고, 단방향 알림이면 SSE를 사용하는 것이 올바른 방법입니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 단방향 알림이라면 SSE가 딱이네요!" SSE를 제대로 이해하면 간단하면서도 효과적인 실시간 알림 시스템을 구축할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - SSE 연결은 브라우저당 도메인당 최대 6개로 제한되므로 여러 탭에서 동시에 사용할 때 주의하세요
- Last-Event-ID 헤더를 활용하면 재연결 시 놓친 이벤트부터 다시 받을 수 있습니다
4. WebFlux를 통한 비동기 처리
김개발 씨는 SSE로 실시간 알림 기능을 성공적으로 구현했습니다. 하지만 성능 테스트에서 문제가 발견되었습니다.
동시 접속자가 1000명을 넘어가자 서버 응답이 느려지기 시작했습니다. "왜 이런 현상이 발생하는 걸까요?"
Spring WebFlux는 리액티브 프로그래밍 모델을 기반으로 논블로킹 비동기 처리를 제공하는 프레임워크입니다. 마치 식당에서 주문을 받은 웨이터가 요리가 완성될 때까지 기다리지 않고 다른 테이블의 주문을 계속 받는 것처럼, 하나의 요청이 처리되는 동안 다른 요청들을 동시에 처리할 수 있습니다.
적은 스레드로 많은 동시 연결을 처리할 수 있어 고성능 API 서버에 적합합니다.
다음 코드를 살펴봅시다.
@RestController
@RequestMapping("/api")
public class AsyncController {
@Autowired
private WebClient webClient;
@GetMapping("/fetch-data")
public Mono<String> fetchDataAsync() {
// 외부 API 호출을 논블로킹 방식으로 처리
return webClient.get()
.uri("https://api.example.com/data")
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(5))
.doOnSuccess(data -> System.out.println("데이터 수신 완료"))
.doOnError(err -> System.err.println("에러: " + err.getMessage()));
}
}
김개발 씨는 서버 모니터링 대시보드를 열어봤습니다. CPU 사용률은 낮은데 응답 시간이 느려지는 이상한 현상이 나타났습니다.
스레드 풀을 확인해보니 200개의 스레드가 모두 사용 중이었고, 대부분 외부 API 응답을 기다리며 WAITING 상태에 머물러 있었습니다. 박시니어 씨가 다가와 모니터를 보더니 말합니다.
"전형적인 블로킹 I/O 문제네요. Spring MVC 대신 WebFlux를 사용하면 해결할 수 있어요." 그렇다면 WebFlux가 정확히 무엇이고 왜 필요한 걸까요?
쉽게 비유하자면, WebFlux는 마치 효율적인 식당 운영과 같습니다. 전통적인 Spring MVC는 각 테이블마다 전담 웨이터를 배치하는 방식입니다.
테이블이 100개면 웨이터도 100명이 필요하고, 웨이터는 주문한 요리가 나올 때까지 그 자리에서 기다립니다. 반면 WebFlux는 소수의 웨이터가 모든 테이블을 담당합니다.
주문을 받으면 주방에 전달하고, 요리가 완성되면 알림을 받아서 서빙합니다. 기다리는 시간에 다른 테이블을 계속 돌봅니다.
이처럼 WebFlux도 적은 스레드로 많은 요청을 효율적으로 처리하는 역할을 담당합니다. WebFlux가 없던 시절 개발자들은 어떻게 대량의 동시 접속을 처리했을까요?
개발자들은 스레드 풀 크기를 계속 늘려야 했습니다. 동시 접속자가 1000명이면 스레드도 1000개가 필요했습니다.
하지만 스레드는 메모리를 소비하므로 무한정 늘릴 수 없었습니다. 각 스레드는 약 1MB의 스택 메모리를 사용하므로 1000개면 1GB가 필요했습니다.
더 큰 문제는 컨텍스트 스위칭 비용이었습니다. 스레드가 많아질수록 CPU는 스레드 전환에 더 많은 시간을 써야 했고, 실제 작업 처리 시간은 줄어들었습니다.
바로 이런 문제를 해결하기 위해 WebFlux가 등장했습니다. WebFlux를 사용하면 적은 리소스로 높은 동시성을 달성할 수 있습니다.
보통 CPU 코어 수만큼의 스레드로 수천 개의 동시 연결을 처리할 수 있습니다. 또한 논블로킹 I/O 덕분에 네트워크나 데이터베이스 응답을 기다리는 동안 다른 작업을 처리할 수 있습니다.
무엇보다 백프레셔를 통해 시스템 과부하를 방지할 수 있다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 9번째 줄을 보면 **Mono<String>**을 반환하고 있습니다. Mono는 0개 또는 1개의 결과를 비동기로 반환하는 리액티브 타입입니다.
다음으로 11번째 줄에서는 WebClient를 사용하여 외부 API를 호출합니다. WebClient는 논블로킹 HTTP 클라이언트로, 응답을 기다리는 동안 스레드를 차단하지 않습니다.
15번째 줄의 timeout은 5초 안에 응답이 오지 않으면 타임아웃 에러를 발생시킵니다. 마지막으로 doOnSuccess와 doOnError로 성공과 실패 케이스를 처리합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 여행 예약 플랫폼을 개발한다고 가정해봅시다.
사용자가 항공권을 검색하면 서버는 10개 항공사 API를 동시에 호출해야 합니다. 전통적인 블로킹 방식이라면 각 API 호출마다 스레드가 필요하고, 응답이 올 때까지 스레드가 대기합니다.
하지만 WebFlux를 활용하면 단 하나의 스레드로 10개 API를 동시에 호출하고, 각 응답이 도착하는 대로 처리할 수 있습니다. 응답 시간은 가장 느린 API 하나의 시간과 거의 같아집니다.
많은 글로벌 여행 플랫폼에서 이런 패턴을 적극적으로 사용하고 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 WebFlux 안에서 블로킹 코드를 사용하는 것입니다. 예를 들어 RestTemplate, JDBC, Thread.sleep() 같은 블로킹 API를 사용하면 WebFlux의 장점이 완전히 사라집니다.
이렇게 하면 전체 이벤트 루프가 멈춰버리는 심각한 문제가 발생할 수 있습니다. 따라서 WebClient, R2DBC, delayElements 같은 논블로킹 대안을 사용해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 감탄했습니다.
"아, 그래서 적은 스레드로도 많은 동시 접속을 처리할 수 있는 거군요!" 김개발 씨는 코드를 WebFlux로 전환했습니다. 성능 테스트 결과, 동일한 서버에서 동시 접속자를 5000명까지 처리할 수 있게 되었습니다.
스레드는 CPU 코어 수인 8개만 사용하면서 말이죠. WebFlux를 제대로 이해하면 효율적이고 확장 가능한 고성능 서버를 구축할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - WebFlux는 I/O 집약적인 작업에 최적화되어 있으며, CPU 집약적 작업에는 적합하지 않습니다
- 블로킹 코드를 꼭 사용해야 한다면 **Schedulers.boundedElastic()**으로 격리된 스레드 풀에서 실행하세요
5. 스트리밍 응답 에러 핸들링
김개발 씨의 AI 챗봇 서비스가 드디어 출시되었습니다. 하지만 이튿날 아침, 사용자들로부터 불만이 쏟아졌습니다.
"답변이 절반만 나오고 끊겨요!" 로그를 확인해보니 스트리밍 중간에 에러가 발생하면 클라이언트가 아무 메시지도 받지 못하고 있었습니다.
스트리밍 응답에서의 에러 핸들링은 일반 REST API와 달리 스트림 중간에 발생하는 에러를 처리하는 특별한 기법입니다. 마치 라이브 방송 중 문제가 생기면 화면에 안내 메시지를 띄우는 것처럼, 데이터 전송 중 에러가 발생해도 클라이언트에게 적절히 알려야 합니다.
onErrorResume, onErrorReturn 등의 연산자로 우아하게 에러를 처리하고 대체 값을 제공할 수 있습니다.
다음 코드를 살펴봅시다.
@GetMapping(value = "/ai/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatWithAI(@RequestParam String question) {
return callAIService(question)
.onErrorResume(error -> {
// 에러 발생 시 사용자 친화적 메시지 반환
System.err.println("AI 서비스 에러: " + error.getMessage());
return Flux.just(
"[시스템] 일시적인 오류가 발생했습니다.",
"[시스템] 잠시 후 다시 시도해주세요."
);
})
.timeout(Duration.ofSeconds(30))
.onErrorReturn("[시스템] 응답 시간이 초과되었습니다.");
}
private Flux<String> callAIService(String question) {
// AI API 호출 시뮬레이션
return Flux.range(1, 10)
.map(i -> "AI 응답 " + i);
}
김개발 씨는 당황했습니다. 일반 REST API에서는 try-catch로 에러를 잡아서 에러 응답을 반환하면 됐는데, 스트리밍에서는 어떻게 해야 할까요?
이미 응답을 보내기 시작한 상태에서 에러가 발생하면 HTTP 상태 코드를 바꿀 수도 없었습니다. 박시니어 씨가 급히 달려와 코드를 살펴봅니다.
"스트리밍에서는 에러 핸들링 방식이 달라야 해요. 리액티브 연산자를 사용해야 합니다." 그렇다면 스트리밍에서의 에러 핸들링은 어떻게 다를까요?
쉽게 비유하자면, 스트리밍 에러 핸들링은 마치 라이브 TV 방송과 같습니다. 일반 REST API는 녹화 방송입니다.
녹화 방송은 문제가 생기면 편집해서 다시 송출할 수 있지만, 라이브 방송은 이미 전파를 타고 있습니다. 문제가 생기면 즉시 안내 멘트를 하거나 대체 화면을 보여줘야 합니다.
방송을 처음부터 다시 시작할 수는 없습니다. 이처럼 스트리밍도 데이터 전송 중 에러가 발생하면 스트림 내에서 처리해야 합니다.
스트리밍 에러 핸들링이 없던 시절에는 어떤 문제가 있었을까요? 개발자들은 에러가 발생하면 스트림이 그냥 끊어졌습니다.
사용자는 답변을 절반만 보고 갑자기 연결이 종료되는 나쁜 경험을 했습니다. 무엇이 잘못되었는지, 다시 시도해야 하는지 알 수 없었습니다.
더 큰 문제는 디버깅이 어렵다는 점이었습니다. 프론트엔드 개발자는 백엔드에서 무슨 에러가 발생했는지 전혀 알 수 없었습니다.
바로 이런 문제를 해결하기 위해 리액티브 스트림의 에러 핸들링 연산자들이 등장했습니다. onErrorResume을 사용하면 에러가 발생해도 다른 스트림으로 대체할 수 있습니다.
사용자는 시스템 메시지를 통해 무엇이 잘못되었는지 알 수 있습니다. 또한 onErrorReturn으로 간단한 기본값을 제공할 수도 있습니다.
무엇보다 사용자 경험이 끊기지 않는다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 4번째 줄을 보면 onErrorResume을 사용하여 에러를 처리하고 있습니다. 이 연산자는 에러가 발생하면 지정된 함수를 실행하여 대체 스트림을 생성합니다.
다음으로 7번째 줄에서는 Flux.just로 사용자 친화적인 에러 메시지를 담은 새로운 스트림을 만듭니다. 이 메시지들이 클라이언트로 전송됩니다.
12번째 줄의 timeout은 30초 안에 응답이 완료되지 않으면 타임아웃 에러를 발생시킵니다. 마지막으로 13번째 줄의 onErrorReturn은 타임아웃 등 다른 에러에 대한 최종 안전망 역할을 합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 주가 실시간 스트리밍 서비스를 개발한다고 가정해봅시다.
증권 거래소 API에서 데이터를 받아서 클라이언트에게 스트리밍합니다. 만약 거래소 API가 일시적으로 장애를 일으키면 어떻게 될까요?
에러 핸들링이 없다면 사용자 화면이 그냥 멈춥니다. 하지만 onErrorResume을 활용하면 "거래소 연결이 일시적으로 끊어졌습니다.
재연결 중입니다"라는 메시지를 보내고, 백그라운드에서 자동으로 재연결을 시도할 수 있습니다. 많은 금융 플랫폼에서 이런 패턴을 적극적으로 사용하고 있습니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 모든 에러를 똑같이 처리하는 것입니다.
예를 들어 네트워크 일시 장애와 인증 실패는 다르게 처리해야 합니다. 네트워크 장애는 재시도하면 되지만, 인증 실패는 재시도해도 소용이 없습니다.
이렇게 하면 무한 재시도로 서버에 부하를 주는 문제가 발생할 수 있습니다. 따라서 에러 타입별로 적절한 처리 로직을 구현해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 즉시 코드를 수정했습니다.
"아, 에러도 스트림의 일부로 처리해야 하는 거군요!" 에러 핸들링을 추가한 후 사용자 불만이 크게 줄었습니다. 에러가 발생해도 사용자는 명확한 메시지를 받았고, 무엇을 해야 할지 알 수 있었습니다.
스트리밍 에러 핸들링을 제대로 이해하면 안정적이고 사용자 친화적인 실시간 서비스를 구축할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - retry() 연산자로 일시적 에러에 대한 자동 재시도를 구현할 수 있습니다
- 에러 메시지는 사용자가 이해할 수 있는 언어로 작성하고, 기술적인 스택 트레이스는 로그에만 남기세요
6. 실시간 챗봇 UI 구현
김개발 씨는 백엔드 스트리밍 API를 완성했습니다. 이제 마지막 단계, 프론트엔드에서 이 스트림을 받아서 화면에 표시하는 UI를 만들어야 했습니다.
"ChatGPT처럼 타이핑되는 효과를 어떻게 구현하지?"
실시간 챗봇 UI는 서버에서 전송되는 스트리밍 응답을 받아서 사용자 화면에 동적으로 표시하는 프론트엔드 구현입니다. 마치 누군가가 실시간으로 타이핑하는 것처럼 텍스트가 한 글자씩 나타나며, EventSource API나 fetch의 ReadableStream으로 데이터를 수신합니다.
사용자는 AI가 답변을 생성하는 과정을 실시간으로 확인할 수 있어 대기 시간이 짧게 느껴집니다.
다음 코드를 살펴봅시다.
// React 컴포넌트 예제
function ChatBot() {
const [messages, setMessages] = useState([]);
const [streaming, setStreaming] = useState(false);
const sendMessage = async (question) => {
setStreaming(true);
let currentResponse = '';
// Fetch API의 ReadableStream 활용
const response = await fetch(`/api/chat?question=${question}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const {done, value} = await reader.read();
if (done) break;
// 받은 청크를 문자열로 디코딩하여 화면에 추가
const chunk = decoder.decode(value, {stream: true});
currentResponse += chunk;
setMessages(prev => [...prev.slice(0, -1),
{role: 'ai', text: currentResponse}]);
}
setStreaming(false);
};
return (
<div className="chat-container">
{messages.map((msg, i) => (
<div key={i} className={msg.role}>
{msg.text}
{streaming && i === messages.length - 1 && <span className="cursor">|</span>}
</div>
))}
</div>
);
}
김개발 씨는 프론트엔드 코드 작성이 처음이라 막막했습니다. 백엔드는 자신 있었지만, React는 아직 낯설었습니다.
어떻게 해야 서버에서 보내는 데이터를 한 글자씩 화면에 표시할 수 있을까요? 프론트엔드 개발자인 이지수 씨가 도와주러 왔습니다.
"스트리밍 데이터를 받으려면 ReadableStream이나 EventSource를 사용해야 해요." 그렇다면 실시간 챗봇 UI는 어떻게 구현할까요? 쉽게 비유하자면, 실시간 챗봇 UI는 마치 속기사가 연설을 받아 적는 것과 같습니다.
연설자가 말을 하면 속기사는 그 즉시 기록합니다. 연설이 끝날 때까지 기다리지 않습니다.
관객들은 스크린을 보며 실시간으로 연설 내용을 읽을 수 있습니다. 말이 길어도 지루하지 않습니다.
계속 새로운 내용이 나타나니까요. 이처럼 실시간 챗봇 UI도 서버에서 보내는 데이터를 즉시 화면에 표시하는 역할을 담당합니다.
실시간 UI가 없던 시절에는 어떻게 했을까요? 사용자는 질문을 입력하고 로딩 스피너만 빙글빙글 돌아가는 화면을 바라봐야 했습니다.
30초가 걸리는 답변이라면 30초 동안 아무것도 할 수 없었습니다. 답변이 길면 길수록 사용자는 답답함을 느꼈습니다.
더 큰 문제는 사용자가 "서버가 멈춘 건가?" 하고 의심하며 새로고침을 누를 수 있다는 점이었습니다. 그러면 지금까지의 작업이 모두 무효가 되었습니다.
바로 이런 문제를 해결하기 위해 스트리밍 UI 패턴이 등장했습니다. 스트리밍 UI를 사용하면 사용자는 즉각적인 피드백을 받을 수 있습니다.
AI가 답변을 생성하는 과정을 실시간으로 확인하므로 기다리는 시간이 짧게 느껴집니다. 또한 타이핑 효과로 자연스러운 대화 느낌을 줄 수 있습니다.
무엇보다 사용자 참여도가 높아진다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 11번째 줄을 보면 fetch API로 서버에 요청을 보내고 있습니다. 다음으로 12번째 줄에서는 **response.body.getReader()**로 ReadableStream의 리더를 얻습니다.
이것이 스트리밍 데이터를 읽는 핵심입니다. 15번째 줄부터는 while 루프로 데이터를 계속 읽습니다.
**reader.read()**는 Promise를 반환하며, 새로운 청크가 도착할 때마다 resolve됩니다. 19번째 줄에서는 받은 바이트를 문자열로 디코딩합니다.
21번째 줄에서 누적된 텍스트로 메시지를 업데이트하면 화면에 즉시 반영됩니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 법률 상담 AI 서비스를 개발한다고 가정해봅시다. 사용자가 복잡한 법률 질문을 하면 AI는 관련 법률과 판례를 분석하여 긴 답변을 생성합니다.
이 과정이 1분 이상 걸릴 수 있습니다. 만약 일반 API 방식이라면 사용자는 1분 동안 빈 화면을 봐야 합니다.
하지만 스트리밍 UI를 활용하면 AI가 분석하는 즉시 "귀하의 사례는 민법 제000조에 해당합니다..."라는 내용이 한 문장씩 나타납니다. 사용자는 답변을 읽기 시작하면서 동시에 나머지 답변을 기다릴 수 있습니다.
많은 AI 서비스 스타트업에서 이런 패턴을 적극적으로 사용하고 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 매 청크마다 새로운 메시지를 추가하는 것입니다. 그러면 화면에 수백 개의 중복 메시지가 나타나며 스크롤이 폭주합니다.
이렇게 하면 사용자 경험이 망가지는 문제가 발생할 수 있습니다. 따라서 마지막 메시지를 업데이트하는 방식으로 구현해야 합니다.
위 코드의 22번째 줄처럼 **prev.slice(0, -1)**로 마지막 메시지를 제거하고 새로운 내용으로 교체합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
이지수 씨의 도움으로 UI를 완성한 김개발 씨는 감탄했습니다. "와, 정말 ChatGPT처럼 타이핑되는 것처럼 보이네요!" 서비스를 출시한 후 사용자 만족도 조사에서 "답변이 빠르다"는 피드백이 많이 나왔습니다.
실제로는 응답 시간이 같았지만, 스트리밍 UI 덕분에 빠르게 느껴진 것입니다. 실시간 챗봇 UI를 제대로 이해하면 사용자가 만족하는 대화형 서비스를 구축할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - TextDecoder의 stream: true 옵션은 청크 경계에서 잘린 UTF-8 문자를 올바르게 처리합니다
- 타이핑 커서 효과를 추가하면 더 자연스러운 대화 느낌을 줄 수 있습니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
Dual-Track 스트리밍 생성 완벽 가이드
실시간 음성 대화 시스템의 핵심인 Dual-Track 스트리밍 아키텍처를 다룹니다. 97ms의 초저지연을 달성하는 방법과 스트리밍 TTS 구현 기법을 초급 개발자도 이해할 수 있도록 쉽게 설명합니다.
MCP 어노테이션 기반 개발 완벽 가이드
Spring AI와 MCP(Model Context Protocol)를 활용한 어노테이션 기반 도구 개발 방법을 알아봅니다. 선언적 프로그래밍으로 AI 에이전트용 도구를 쉽게 만드는 방법을 초급자도 이해할 수 있게 설명합니다.
MCP 클라이언트 구현 완벽 가이드
Model Context Protocol 클라이언트를 Java/Spring 환경에서 구현하는 방법을 다룹니다. 서버 디스커버리부터 멀티 서버 관리까지 실무에서 바로 사용할 수 있는 패턴을 학습합니다.
MCP 서버 구현 WebFlux 완벽 가이드
Spring WebFlux를 활용한 MCP(Model Context Protocol) 서버 구현 방법을 다룹니다. Reactive Programming의 기초부터 비동기 스트림 처리, Backpressure 관리까지 실무에서 바로 활용할 수 있는 내용을 담았습니다.
MCP 서버 구현 WebMVC 완벽 가이드
Spring WebMVC를 활용하여 Model Context Protocol 서버를 구현하는 방법을 단계별로 알아봅니다. AI 에이전트와 통신하는 MCP 서버의 엔드포인트 구성부터 도구 등록, 에러 핸들링까지 실무에 필요한 핵심 내용을 다룹니다.