본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 06. 18:12

Laravel AI 스트리밍 UX: 타이핑 인디케이터, 사고 상태(Thought States) 및 스트림 취소

요약

AI 스트리밍 응답 시 발생하는 사용자 경험(UX) 문제를 해결하기 위한 Laravel 기반의 구현 가이드를 제공합니다. 첫 번째 토큰 도착 전의 침묵, 텍스트 렌더링 리듬, 스트림 취소 문제를 SSE와 Alpine.js를 통해 개선하는 방법을 다룹니다.

핵심 포인트

  • 첫 토큰 도착 전 '사고 중(Thinking)' 상태를 구현하여 침묵 시간 해결
  • SSE 이벤트를 활용한 프론트엔드 UI 상태 전환 전략
  • 시각적 저하 없는 점진적 토큰 렌더링 구현
  • 사용자가 생성 중인 스트림을 제어할 수 있는 취소 기능의 중요성

“작동하는 것”과 “좋은 것” 사이의 간극

기술적으로는 작동하는 스트리밍 AI 응답이라도 여전히 망가진 것처럼 느껴질 수 있습니다. 최근 Laravel에서 AI 기능을 출시해 보았다면 그 간극을 알고 계실 것입니다. 백엔드는 토큰을 올바르게 스트리밍하고 테스트도 통과하지만, 기술적 지식이 없는 이해관계자가 인터페이스를 열고는 왜 아무것도 나타나기 전까지 화면이 멈춰 있는 것처럼 보이는지 묻곤 합니다.

**Laravel AI 스트리밍 UX (Laravel AI streaming UX)**는 백엔드 문제가 아닙니다. 전송 방식(Transport)의 선택은 이미 정립된 영역입니다. 저희의 실시간 AI 전달 패턴에 대한 모듈 3 개요에서는 문맥에 따른 사용 가능한 옵션들을 다룹니다. 아키텍처에 맞는 적절한 스트리밍 전송 방식 선택하기가 첫 번째 단계입니다. 이 글은 두 번째 단계, 즉 전송 방식이 작동하기 시작한 후 사용자가 실제로 경험하게 되는 것에 대해 다룹니다.

세 가지 상태가 AI 스트리밍 인터페이스의 전체 사용자 경험(UX)을 주도합니다.

  • 스트림 전(Pre-stream, 사고 중): 사용자 제출과 첫 번째 토큰 사이의 침묵
  • 스트림 중(Mid-stream, 생성 중): 시각적 저하 없는 점진적인 토큰 렌더링
  • 스트림 후(Post-stream, 완료 또는 취소됨): 정확한 최종 상태와 함께 깔끔하게 마무리됨

사용자는 여러분의 전송 계층(Transport layer)을 경험하지 않습니다. 그들은 첫 번째 토큰이 도착하기 전 3초간의 침묵, 시각적 리듬 없이 나타나는 텍스트의 벽, 그리고 폭주하는 생성을 중단할 방법이 전혀 없는 상황을 경험합니다. 이것들은 Alpine.js 및 Prism PHP 스택에서 특정 구현을 통해 해결할 수 있는 문제들입니다. 우리는 각각의 문제를 다룰 것입니다.

이 문제들 중 어느 것도 생소한 것이 아닙니다. 전송 계층 위에 의도적인 UX 작업 없이 출시되는 모든 AI 인터페이스에서 나타납니다. 좋은 소식은 각 문제에 대해 백엔드 아키텍처를 변경할 필요 없이 깔끔하게 구현 가능한 해결책이 있다는 것입니다.

스트림 전: 첫 번째 토큰 이전의 침묵 처리하기

사용자가 요청을 제출한 시점과 첫 번째 토큰이 도착하는 시점 사이의 간극은 대부분의 AI 인터페이스가 사용자를 놓치는 지점입니다. 빠르고 복잡도가 낮은 완성(completions) 작업에서는 인지하기 어렵습니다. 하지만 복잡한 추론(reasoning) 작업, 콜드 API 시작(cold API starts), 또는 부하가 많이 걸린 엔드포인트에서는 이 간극이 2초에서 5초까지 이어집니다. 백엔드가 의도한 대로 정확히 작동하고 있더라도, 이러한 침묵은 시스템이 고장 난 것처럼 느껴집니다.

해결책은 간단합니다. Prism 호출이 시작되기 전, 요청이 도착하는 즉시 이름이 지정된 SSE 이벤트를 방출하는 것입니다. 프론트엔드는 첫 번째 토큰이 아닌, 해당 이벤트를 수신했을 때 "생각 중(thinking)" 상태로 전환됩니다. 이를 통해 스트림 전의 침묵은 버려지는 시간이 아닌, 커버된 시간(covered time)이 됩니다.

UI 상태 ↔ SSE 스트림 라이프사이클 (Lifecycle)

UI State - SSE Stream Lifecycle

패턴 A: Alpine.js를 이용한 애니메이션 타이핑 인디케이터 (Typing Indicator)

SSE 라우트는 Prism 호출이 시작되기 전, 즉시 event: thinking을 방출합니다. 두 단계 모두 동일한 response()->stream() 콜백 내부에서 실행되므로 별도의 두 번째 요청이 필요하지 않습니다:

use Illuminate\
Http\\Request;
use Prism\\Prism\\Facades\\Prism;

...

Alpine.js 컴포넌트는 상태를 불리언(boolean) 플래그가 아닌 명시적인 문자열 열거형(string enum)으로 관리합니다. 이는 취소(cancellation) 기능을 추가할 때 매우 중요합니다. EventSource가 닫힐 때, 닫힘이 의도적이었는지 여부와 상관없이 onerror가 실행됩니다. 문자열 상태를 사용하면 onerror에서 종료가 사용자의 취소였는지 아니면 네트워크 오류였는지를 확인하여 다르게 대응할 수 있습니다. 불리언 형태의 isLoading은 이러한 구분을 할 수 없습니다. 모든 반응형 속성(reactive properties)은 data 객체에 선언되어 있어 Alpine이 초기화 단계부터 이를 추적합니다:

function chatStream() {
    return {
        output:   '',
...

상태 기반 템플릿은 컴포넌트에 직접 연결됩니다:

<div x-data="chatStream()"> <div x-show="state === 'idle' || state === 'complete' || state === 'cancelled'"> <input x-model="message" type="text" placeholder="질문하기…"> ... ```

[Architect’s Note] 이 예제들은 퍼스트 파티(first-party) laravel/ai SDK 대신 Prism PHP를 사용합니다. Prism의 스트리밍 API는 작성 시점을 기준으로 에이전트 워크플로우(agent workflows)에 더 성숙하며, 더 넓은 제공자(provider) 범위를 지원합니다. laravel/ai SDK는 Laravel 팀이 지향하는 장기적인 프로덕션(production) 방향입니다. 새로운 프로젝트를 시작할 때 Prism을 기본값으로 설정하기 전에 이를 검토하십시오. 특히 사용 중인 제공자가 OpenAI 또는 Anthropic으로 제한적인 경우 더욱 그렇습니다.

패턴 B: Livewire를 이용한 스켈레톤 로더 (Skeleton Loader)

AI 응답이 카드, 데이터 테이블 또는 생성된 폼 미리보기와 같이 구조화된 레이아웃으로 렌더링될 때는 타이핑 인디케이터(typing indicator)보다 스켈레톤 플레이스홀더(skeleton placeholder)가 더 적합합니다. 타이핑 인디케이터는 응답이 텍스트 기반의 대화형임을 암시합니다. 반면 스켈레톤은 구조화된 데이터가 들어오고 있음을 암시합니다. Livewire의 wire:loading 디렉티브를 사용하면 추가적인 JavaScript 없이도 이를 처리할 수 있습니다:

<div wire:loading wire:target="generate">
    <div class="skeleton-card">
        <div class="skeleton-line w-3/4"></div>
...

Livewire 및 Claude API 실시간 채팅 가이드에서는 지속적인 SSE 연결을 지원하지 않는 환경을 위한 wire:poll 폴백(fallback)을 포함하여, 스트리밍 응답을 위한 전체 Livewire 컴포넌트 라이프사이클(lifecycle)을 다룹니다.

스트림 중간 단계: 브라우저 지연 없는 토큰 렌더링

단순한 토큰 추가 방식(모든 SSE 메시지에 대해 출력값에 직접 추가하는 방식)은 긴 응답이 생성될 때 레이아웃 스래싱(layout thrashing)을 유발합니다. 브라우저는 모든 변이(mutation)가 일어날 때마다 DOM을 리플로우(reflow)합니다. 복잡한 답변에서 토큰이 500개에 도달하면 중간 사양의 하드웨어에서는 인터페이스가 눈에 띄게 느려지며, 응답이 길어질수록 성능 저하는 가속화됩니다.

requestAnimationFrame을 사용하는 배치형 DOM 업데이트 (Batched DOM update) 방식은 위에서 설명한 chatStream() 컴포넌트에 이미 통합되어 있습니다. flush 함수는 들어오는 토큰들을 this.buffer에 축적한 다음, 각 SSE 이벤트가 발생할 때마다가 아니라 매 애니메이션 프레임마다 해당 버퍼를 this.output으로 비웁니다(drain). 이를 통해 SSE 메시지가 얼마나 빨리 도착하든 상관없이 DOM 변형(mutation)을 60fps로 제한할 수 있습니다.

[운영 시 주의사항 (Production Pitfall)] 모바일 하드웨어에서 높은 토큰 수의 응답(2,000개 이상의 토큰)을 실행하는 팀은 데스크톱 사용자보다 먼저 성능 저하를 경험합니다. 성능이 좋은 모델과 빠른 네트워크 환경에서 원시 토큰을 단순히 추가(raw token appending)하는 방식은 초당 수백 번의 DOM 변형을 시도할 수 있습니다. 프레임 레이트 제한은 균일하게 적용됩니다. 응답이 초당 30개의 토큰으로 도착하든 300개로 도착하든, 출력 영역은 브라우저의 자연스러운 페인트 사이클(paint cycle)에 맞춰 업데이트됩니다.

의존성 비용을 감수할 만한 가치가 있는 두 번째 최적화는 다음과 같습니다. 만약 AI 출력값이 마크다운(markdown)이라면, 스트림 도중 점진적 렌더링(incremental rendering)을 수행하는 것이 가공되지 않은 마크다운 구문이 쌓이는 것을 지켜보는 것보다 훨씬 더 나은 읽기 경험을 제공합니다. marked.js와 같은 가벼운 클라이언트 측 파서(client-side parser)를 사용하세요. 여기에는 실제적인 트레이드오프(tradeoff)가 존재합니다. 부분적인 마크다운은 스트림 중간에 잘못된 형식의 HTML을 생성하므로, 렌더링은 모든 토큰마다 수행하는 대신 디바운스(debounced)된 간격으로 이루어져야 합니다. 150ms의 디바운스를 적용하면 출력물이 시각적으로 점진적으로 나타나게 유지하면서도, 절반만 렌더링된 코드 블록이 사용자에게 노출되는 것을 방지할 수 있습니다. 각 디바운스 틱(tick)마다 개별 토큰이 아닌, 전체적으로 축적된 문자열을 대상으로 파싱을 수행하십시오.

에이전틱 사고 상태 (Agentic Thought States)

다단계 에이전틱 워크플로우(Multi-step agentic workflows)는 단일 턴 스트리밍(single-turn streaming)에는 없는 특유의 UX 문제를 안고 있습니다. 모델은 가시적인 응답을 생성하기 전에 두세 개의 도구(tools)를 호출할 수 있습니다. 사용자 관점에서 이는 스트림이 시작되기 전의 유난히 긴 침묵과 구별되지 않습니다. 처리 과정이 보이지 않는 것입니다. 사용자는 무언가 일어나고 있다는 신호도, 시스템이 무엇을 하고 있는지에 대한 표시도, 기다릴지 아니면 취소할지를 결정할 근거도 갖지 못하게 됩니다.

올바른 접근 방식은 각 도구 호출 (tool invocation)이 발생하는 즉시 이를 표면화하는 것입니다. 각 Prism 도구 콜백 (tool callback)은 실행 시작 시 이름이 지정된 SSE 이벤트를 방출합니다. 프론트엔드는 tool_call 이벤트를 수신하여 이를 메인 응답 영역 상단에 위치한 일시적인 사고 상태 (thought state) 로그로 렌더링하며, 시각적으로는 보조적인 수준을 유지합니다:

use Prism\Prism\Tool;

$tools = [
...

tool_call 이벤트 리스너와 thoughts 배열은 이미 chatStream() 컴포넌트의 일부로 포함되어 있습니다. 위의 템플릿 섹션이 해당 로그를 렌더링합니다. 사고 상태 항목들은 글꼴 크기를 작게 하고, 색상은 차분하게(muted) 설정하며, 구분선(divider)보다는 시각적 무게감을 통해 기본 출력 영역과 분리하십시오. 이것들은 프로세스 표시기 (process indicators)이지 콘텐츠가 아닙니다. 사용자는 사고 로그가 주의를 분산시키지 않은 상태에서 메인 응답을 읽을 수 있어야 합니다.

[운영 시 주의사항 (Production Pitfall)] SSE를 통한 사고 상태 방출은 도구 콜백이 스트리밍 응답과 동일한 PHP 출력 버퍼 (output buffer) 내에서 실행될 때만 작동합니다. 이는 단일 HTTP 요청에서 실행되는 동기식 SSE 스트림의 경우에 해당합니다. Laravel Horizon의 큐 작업 (queued jobs)으로 전달되는 에이전트의 경우, 도구 콜백은 HTTP 출력 버퍼가 없는 워커 프로세스 (worker process)에서 실행됩니다. 그러한 아키텍처에서는 Reverb를 통해 WebSockets로 사고 상태를 브로드캐스트(broadcast)하십시오. Reverb 토큰 단위 전달 가이드에서 장시간 실행되는 큐 프로세스를 위한 브로드캐스트 채널 접근 방식을 다룹니다.

이러한 이벤트들을 구동하는 전체 Prism 도구 등록 및 콜백 패턴에 대해서는, Prism PHP를 사용한 에이전트 기반 Laravel 앱 구축 (Building Agentic Laravel Apps with Prism PHP)이 참조 구현체 역할을 합니다.

스트림 취소: 아무도 구현하지 않는 백엔드 측면

취소에는 두 가지 측면이 있습니다. 프론트엔드 측면은 단 다섯 줄의 JavaScript로 해결되며 대부분의 구현체에 포함되어 있습니다. 백엔드 측면은 구현이 무너지는 지점이며, 규모가 커졌을 때 발생하는 결과는 이론적인 수준에 그치지 않습니다.

프론트엔드 취소

EventSource를 닫고 상태(state)를 업데이트합니다. cancel() 메서드는 이미 chatStream() 컴포넌트에 정의되어 있습니다. 핵심적인 세부 사항은 상태 머신(state machine)입니다. onerror는 의도적인 종료 여부와 관계없이 EventSource가 닫힐 때마다 발생합니다. source.close()를 호출하기 전에 상태를 cancelled로 설정함으로써, onerror 핸들러가 cancelled 상태를 확인하고 이를 idle로 덮어쓰지 않도록 합니다. 이 처리가 없다면, 요청을 취소한 후 즉시 새로운 요청을 시작한 사용자의 UI가 예상치 못하게 idle 상태로 튕겨 나가는 현상이 발생할 수 있습니다.

직접 스트림을 위한 백엔드 취소 (Backend Cancellation for Direct Streams)

SSE 연결이 종료될 때, PHP는 실행 중인 프로세스를 자동으로 종료하지 않습니다. 클라이언트가 출력을 받지 못하더라도 서버에서는 Prism 호출이 계속 실행되어 토큰과 제공자(provider) 할당량(quota)을 소모합니다. 짧은 완성(completions)의 경우 이는 낭비에 불과하지만, 여러 번의 도구 호출(tool calls)이 포함된 에이전트 루프(agentic loops)의 경우 실제로 큰 비용이 발생할 수 있습니다.

[운영 시의 함정 (Production Pitfall)] 적당한 부하가 걸리는 멀티 테넌트(multi-tenant) 애플리케이션에서, 보호되지 않은 스트림은 계속 누적됩니다. 각각의 고립된(orphaned) 프로세스는 LLM 제공자와의 HTTP 연결을 열어둔 상태로 유지합니다. 제공자의 속도 제한(rate limits)은 분당 요청 수뿐만 아니라 활성 연결(active connections) 수도 계산합니다. 첫 번째 가시적인 증상은 부하가 걸릴 때 간헐적으로 발생하는 429 에러이며, 트래픽이 줄어들면 사라지기 때문에 실제 원인을 파악하기 어렵게 만듭니다.

위의 '스트림 전(Pre-Stream)' 섹션에서 보여준 foreach 루프 내의 connection_aborted() 가드는 올바른 패턴입니다. 매 반복(iteration)마다 이를 확인하고 클라이언트가 연결을 끊었을 때 break 하십시오. 정상적인 완료 시에는 [DONE] 센티넬(sentinel) 방출을 생략하지 마십시오. 프론트엔드가 연결 종료 시 onerror 이벤트가 발생하기를 기다리는 대신 complete 상태로 전환하기 위해서는 이 신호가 필요합니다. 재연결 및 타임아웃을 다루는 SSE 운영 가이드에서는 keepalive 간격 및 멀티 테넌트 스트림 격리를 포함하여, 장기 유지되는 SSE 연결에 대한 추가적인 라이프사이클(lifecycle) 관련 사항을 다룹니다.

[Edge Case Alert] connection_aborted()는 클라이언트가 TCP 연결을 종료했음을 PHP가 감지하는 것에 의존합니다. 일부 Nginx 설정에서는 브라우저가 연결을 끊은 후에도 PHP-FPM으로의 업스트림(upstream) 연결이 계속 유지되어, connection_aborted()가 결코 true를 반환하지 않을 수 있습니다. PHP가 연결 끊김을 올바르게 감지하려면 Nginx의 fastcgi_ignore_client_abort 지시어가 off(기본값)로 설정되어 있어야 합니다. connection_aborted()를 취소 메커니즘으로 신뢰하기 전에, 사용 중인 스택에서 이 사항을 반드시 확인하십시오. 간단한 테스트 방법: SSE 스트림을 열고, 브라우저 탭을 닫은 뒤, PHP 프로세스가 몇 초 이내에 종료되는지 확인하십시오.

큐에 쌓인 에이전트 작업(Agentic Jobs)을 위한 백엔드 취소

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0