Livewire와 충돌 없이 Laravel에서 AI 스트리밍 구현하기
요약
Laravel과 Livewire 환경에서 사용자 경험(UX)을 해치지 않고 AI 토큰 스트리밍을 구현하는 아키텍처 가이드를 제공합니다. 상태 관리와 렌더링 최적화를 통해 스크롤 튐, 중복 메시지, 실패 처리 등의 문제를 해결하는 실질적인 방법을 다룹니다.
핵심 포인트
- 스트리밍 구현의 핵심은 상태 관리와 렌더링 최적화에 있음
- 시스템을 영구적 상태, 일시적 상태, 제공자 전송의 3계층으로 분리
- 메시지 생명주기(queued, streaming, completed 등)를 명시적으로 관리
- Livewire의 과도한 재렌더링을 방지하기 위한 구조적 접근 필요
토큰을 스트리밍하는 것은 쉬운 데모입니다. Laravel 제품 내부에서 네이티브처럼 느껴지는 채팅 UI를 출시하는 것이 어려운 부분입니다.
대부분의 팀은 처음 20%를 빠르게 구현합니다: 모델을 호출하고, 텍스트를 스트리밍하며, 박스 안에 출력하는 것이죠. 하지만 그 이후에는 사용자가 즉각적으로 알아챌 수 있는 방식으로 UX가 망가지기 시작합니다. 읽는 동안 스크롤이 튀고, 중단(Stop) 기능이 제대로 작동하지 않습니다. 요청이 실패하면 마치 완료된 것처럼 보이는 절반의 답변만 남습니다. 재시도(Retry) 시 메시지가 중복됩니다. Livewire는 아주 작은 청크(chunk)가 들어올 때마다 전체 스레드를 계속 다시 렌더링(re-rendering)하며, 인터페이스는 버벅거리기 시작합니다.
**Laravel AI 스트리밍 (Laravel AI streaming)**이 프로덕션 수준으로 느껴지길 원한다면, 핵심 규칙은 간단합니다: 스트리밍은 첫 번째로 상태 관리(state-management) 문제이며, 두 번째로 렌더링(rendering) 문제입니다. 부분적인 출력은 임시 UI 상태로 취급하고, 영구적인 메시지 상태는 명시적으로 유지하며, Livewire가 모든 토큰마다 세상을 다시 그리는 대신 구조를 조정하도록 하세요.
이 튜토리얼은 실제로 중요한 부분들, 즉 부분 토큰, 취소(cancellation), 재시도(retries), 스크롤 동작, 낙관적 UI (optimistic UI), 그리고 실패 상태를 처리하는 실질적인 아키텍처를 살펴봅니다. 목표는 화려한 데모 위젯이 아닙니다. 목표는 실제 SaaS 제품에 속해 있는 것처럼 느껴지는 채팅 경험입니다.
Livewire와 충돌하지 않는 기본 아키텍처
첫 번째 실수는 제공자(provider)의 스트리밍이 UI 모델을 직접 제어하게 두는 것입니다. 만약 프론트엔드가 단순히 "지금까지 도착한 모든 토큰"이라면, 취소, 재연결 또는 부분적 영속성(partial persistence)에 대해 깔끔한 해답을 내놓을 수 없습니다.
더 나은 사고 모델은 시스템을 세 가지 계층으로 나누는 것입니다:
- Laravel 및 데이터베이스의 영구적 채팅 상태 (durable chat state)
- 브라우저의 일시적 스트림 상태 (transient stream state)
- 서비스 클래스 뒤에 숨겨진 제공자 전송 (provider transport)
지루하게 들릴 수 있지만, 바로 그 점 때문에 효과가 있습니다.
무엇이 영구적이어야 하는가
최소한, 데이터베이스의 각 메시지는 다음을 저장해야 합니다:
chat_idrolecontentstatussequenceerror_message또는error_code- 타임스탬프 (timestamps)
중요한 필드는 status입니다. 어시스턴트(assistant)의 출력을 단순히 “메시지가 존재함 또는 존재하지 않음”으로 축소하지 마세요. 다음과 같이 명시적인 생명주기 상태(lifecycle states)를 가져야 합니다:
queued(대기 중)streaming(스트리밍 중)completed(완료됨)cancelled(취소됨)failed(실패함)
이렇게 하면 UI에 실제적인 의미론(semantics)을 부여할 수 있습니다. streaming 상태의 메시지는 중지 버튼과 커서를 표시할 수 있습니다. failed 메시지는 재시도(retry)를 표시할 수 있습니다. cancelled 메시지는 답변이 깔끔하게 끝난 것처럼 가장하지 않고도 계속 표시될 수 있습니다.
일시적(Transient)으로 유지되어야 하는 것
브라우저는 활성화된 어시스턴트 메시지에 대한 임시 토큰 버퍼(token buffer)를 소유해야 합니다. 해당 버퍼는 영구적인 진실(durable truth)이 아닙니다. 그것은 프레젠테이션 상태(presentation state)입니다.
이러한 구분은 중요합니다. 왜냐하면 사용자들은 147번 토큰이 DOM에 도달했는지 여부에는 관심이 없기 때문입니다. 그들은 최종 메시지 상태가 예측 가능한지에 관심을 가집니다. 만약 스트림(stream)이 중간에 끊긴다면, UI는 해당 부분적인 텍스트가 복구 가능한지, 취소되었는지, 아니면 실패했는지를 알아야 합니다. 가공되지 않은 스트림(raw stream)만으로는 이를 알려줄 수 없습니다.
Livewire가 실제로 해야 할 일
다음 작업에는 Livewire를 사용하세요:
- 사용자 메시지 제출 (submitting)
- 안정적인 메시지 목록 렌더링 (rendering)
- 영구적인 상태 변경 반영 (reflecting durable status changes)
- 중지 및 재시도와 같은 액션 노출 (exposing actions)
매 청크(chunk)마다 컴포넌트를 다시 렌더링해야 하는 초고빈도 토큰 페인팅(token painting)에는 Livewire를 사용하지 마세요. Livewire는 서버 주도 구조(server-driven structure)에 매우 탁월합니다. 하지만 초당 20번씩 전체 스레드를 비교(diff)하고 다시 그리는(repaint) 데 최적의 장소는 아닙니다.
공식 Livewire 문서와 Laravel 브로드캐스팅(broadcasting) 문서가 기본 요소(primitives)를 제공합니다. 실제 설계상의 선택은 책임의 소재입니다. Livewire는 구조를 소유하고, 작은 클라이언트 측 레이어가 활성 스트림 버퍼를 소유하며, 여러분의 AI 클라이언트가 제공자별 전송(provider-specific transport)을 소유하는 것입니다.
예쁜 UI를 만들기 전에 메시지 생명주기를 구축하세요
이 단계는 백엔드 의식(backend ceremony)처럼 느껴지기 때문에 팀들이 건너뛰는 단계입니다. 하지만 이 단계는 나중에 발생할 수 있는 몇 주간의 지저분한 예외 케이스(edge-case) 정리 작업을 방지해 줍니다.
사용자가 프롬프트(prompt)를 보낼 때, 그 시퀀스(sequence)는 의도적이어야 합니다.
- 사용자 메시지를 저장(persist)합니다.
status = streaming상태를 가진 빈 어시스턴트 메시지 셸(shell)을 생성합니다.- 프로바이더 스트림(provider stream)을 시작합니다.
- 브라우저의 일시적 버퍼(transient buffer)에 청크(chunks)를 추가합니다.
- 정상적으로 완료되면, 최종 콘텐츠를 저장하고
completed로 표시합니다. - 사용자가 중단하면,
cancelled로 표시하고 스트림 루프를 중단합니다. - 실패 시, 지금까지 확보된 내용을 보존하고
failed로 표시하며 재시도(retry) 기능을 노출합니다.
이것이 실제 라이프사이클(lifecycle)입니다. 그 외의 모든 것은 UI를 다듬는 작업일 뿐입니다.
실용적인 서비스 경계 (A Practical Service Boundary)
모델 프로바이더(model provider)를 작은 인터페이스(interface) 뒤로 캡슐화하세요. 현재는 단 하나의 프로바이더만을 대상으로 하더라도, 폴백 모델(fallback models), 커스텀 로깅(custom logging), 또는 비스트리밍 재시도 경로(non-streaming retry path)를 추가하는 순간 이 추상화(abstraction)가 필요해질 것입니다.
<?php
namespace App\AI;
...
청크(chunk) 객체는 매우 작게 유지될 수 있습니다:
<?php
namespace App\AI;
...
단일 어시스턴트 턴(assistant turn)을 오케스트레이션(orchestrate)하는 Laravel 서비스는 프로바이더의 메커니즘 대신 상태 전이(state transitions)에 집중할 수 있습니다.
<?php
namespace App\Actions\Chat;
...
여기에는 다른 무엇보다 중요한 두 가지 세부 사항이 있습니다.
첫째, 데이터베이스는 모든 토큰(token)이 아니라 최종 메시지와 최종 상태를 저장합니다. 둘째, 루프는 청크 사이에서 취소(cancellation) 여부를 확인합니다. 만약 사용 중인 프로바이더가 진정한 중단 신호(abort signal)를 지원한다면 그것을 사용하세요. 지원하지 않더라도 협력적 취소 확인(cooperative cancellation check)을 통해 여전히 합리적인 동작을 보장할 수 있습니다.
Livewire 컴포넌트 전체가 아닌, 브라우저가 토큰을 그리게 하라
이 부분이 보통 UX를 망가뜨리는 지점입니다.
모든 토큰 업데이트가 전체 Livewire 재렌더링(re-render)으로 이어진다면, 앱은 사소한 변경을 위해 비용이 많이 드는 작업을 수행하기 시작합니다. 이는 깜빡임(flicker), 스크롤 불안정성, 그리고 불필요한 네트워크 통신(network chatter)을 유발합니다. 또한 지속적인 상태(durable state)와 일시적인 상태(transient state)가 뒤엉키기 때문에 컴포넌트의 로직을 파악하기 더 어렵게 만듭니다.
해결책은 Livewire를 포기하는 것이 아닙니다. 해결책은 브라우저에 아주 작은 로컬 스트림 저장소(local stream store)를 제공하는 것입니다.
책임 분할 (The Responsibility Split)
Livewire를 사용하여 안정적인 컨테이너를 가진 메시지 목록을 렌더링합니다. 그런 다음 Alpine 또는 작은 Vanilla JS 스토어(store)를 사용하여 스트리밍되는 텍스트를 활성화된 메시지 노드에만 추가합니다.
이를 통해 즉시 세 가지 이점을 얻을 수 있습니다:
- 메시지 트리(message tree)가 구조적으로 안정적으로 유지됩니다.
- 토큰(token) 업데이트가 전체 컴포넌트가 아닌 단 하나의 DOM 노드에만 영향을 미칩니다.
- 최종 영속성(persistence) 처리는 여전히 Laravel을 통해 깔끔하게 흐릅니다.
간단한 Blade 형태는 다음과 같을 수 있습니다:
<div x-data="chatStream(@js($chat->id))" class="flex h-full flex-col">
<div x-ref="scroller" class="flex-1 overflow-y-auto">
@foreach ($messages as $message)
...
그 다음, 아주 작은 클라이언트 측 스토어(client-side store)가 점진적인 업데이트(incremental updates)를 처리하도록 합니다.
<script>
function chatStream(chatId) {
return {
...
이것은 화려하지 않습니다. 그것이 핵심입니다. 나머지 UI를 모든 점진적 업데이트에 끌어들이지 않으면서, 활성화된 토큰 버퍼(token buffer)를 소유할 수 있는 가능한 한 가장 작은 클라이언트 측 레이어(client-side layer)를 원하는 것입니다.
이 패턴이 더 잘 유지되는 이유
이 하이브리드 설정은 실제 문제들을 깔끔하게 해결할 수 있게 해줍니다:
- 페이지 새로고침 시에도 데이터베이스에서 영구적인 메시지를 여전히 복구할 수 있습니다.
- 취소된 스트림(stream)은 히스토리를 손상시키지 않고 시각적으로 중단될 수 있습니다.
- 실패한 스트림은 부분적인 콘텐츠와 함께 정직한 상태(status)를 남길 수 있습니다.
- 재시도(retry) 시 DOM 체조(gymnastics) 없이 새로운 어시스턴트 메시지를 생성할 수 있습니다.
이것이 바로 "AI가 Laravel에서 네이티브(native)처럼 느껴진다"는 말의 실제 의미입니다. 이는 채팅이 Blade 파일에 접착제로 붙여놓은 실험실 실험처럼 작동하는 것이 아니라, 제품의 나머지 부분과 동일하게 작동함을 의미합니다.
중단, 재시도, 중복 제출을 운영 환경에서 발생할 것처럼 처리하기
왜냐นั้น, 실제로 발생할 것이기 때문입니다.
사용자들이 이 기능을 신뢰하기 시작하면, 이것들은 더 이상 예외적인 케이스(edge cases)가 아닙니다. 표준 경로(standard paths)입니다.
취소는 협력적(cooperative)이고 즉각적이어야 합니다
스피너(spinner)만 숨기는 중지 버튼은 가짜 취소입니다. 사용자들은 이를 알아챕니다.
사용자가 중지를 클릭하면:
- 메시지 상태를 즉시
cancelled로 업데이트합니다. - 브라우저에서 스트리밍 커서(streaming cursor) 표시를 중단합니다.
- 서버 스트림 루프(stream loop)가 취소 플래그(cancellation flag)를 인지하도록 합니다.
- 유지하고 싶은 부분적인 콘텐츠(partial content)가 있다면 이를 보존합니다.
많은 제품에서 부분적인 콘텐츠를 보존하는 것이 더 나은 선택입니다. 이는 사용자에게 참조할 수 있는 무언가를 제공하며, 중지 동작이 파괴적인 느낌 대신 정직하게 느껴지도록 만듭니다.
간단한 Livewire 액션만으로도 충분할 수 있습니다:
public function stop(int $messageId): void
{
Message::query()
...
만약 사용 중인 제공자(provider)의 SDK가 요청 중단(request abortion)을 지원한다면, 그 기능도 연결하십시오. 하지만 전송 계층(transport-level)의 중단 기능이 없더라도, 협력적 취소(cooperative cancellation)는 메시지 상태가 즉시 진실되게 변하기 때문에 UX를 극적으로 향상시킵니다.
재시도(Retries)는 새로운 어시스턴트 턴(Assistant Turn)을 생성해야 합니다
실패한 어시스턴트 메시지를 그 자리에서 직접 수정(mutate)하지 마십시오. 이는 대화 기록을 모호하게 만들고 디버깅을 복잡하게 합니다.
재시도는 일반적으로 새로운 어시스턴트 메시지 셸(shell)을 생성하면서 동일한 사용자 메시지 컨텍스트를 재사용해야 합니다. 이렇게 하면 다음과 같이 깔끔한 전후 기록을 남길 수 있습니다:
- 첫 번째 시도 실패
- 두 번째 시도 성공
이러한 기록은 고객 지원, 관측성(observability), 그리고 사용자 신뢰를 위해 유용합니다.
실용적인 재시도 규칙은 다음과 같습니다:
- 실패한 메시지를 계속 표시합니다.
- 하나의 재시도가 활성화되어 있는 동안 중복 재시도를 비활성화합니다.
- 새로운 어시스턴트 플레이스홀더(placeholder)를 생성합니다.
- 새로운 플레이스홀더로 스트리밍합니다.
더 깔끔한 타임라인을 원한다면 UI에서 재시도 항목들을 시각적으로 그룹화할 수 있습니다. 하지만 단순히 스레드를 더 예쁘게 보이게 하려고 데이터베이스 수준에서 기록을 다시 쓰지는 마십시오.
멱등성(Idempotency)은 선택 사항이 아닙니다
사용자는 더블 클릭을 합니다. 모바일 연결은 끊기기도 합니다. 요청이 서버 측에서는 여전히 실행 중인 동안 클라이언트 측에서 타임아웃이 발생할 수 있습니다. 멱등성(idempotency)을 추가하지 않으면, 동일한 프롬프트에 대해 중복된 어시스턴트 턴이 생성되는 결과로 이어질 것입니다.
가장 안전한 방법은 클라이언트에서 제출당 하나의 멱등성 키(idempotency key)를 생성하고, 이를 사용자 메시지 또는 턴 레코드와 함께 보존하는 것입니다.
그런 다음 다음과 같은 규칙을 적용하십시오:
- 동일한 키를 가진 제출(submission)이 이미 존재하고 여전히 활성 상태라면, 새로운 스트림을 시작하는 대신 활성 상태를 반환합니다.
- 완료된 상태라면, 결과를 다시 불러옵니다.
- 실패했다면, UI에서 명시적인 재시도(retry)를 제공하도록 합니다.
이 단 하나의 가드(guard)가 "왜 봇이 두 번 대답했지?"와 같은 수많은 지저분한 버그를 방지합니다.
스크롤 동작과 에러 핸들링(Error Handling)은 UX가 차분하게 느껴질지, 아니면 저렴하게 느껴질지를 결정하는 지점입니다
잘못된 스크롤 동작은 대부분의 모델 실수보다 더 빠르게 신뢰를 무너뜨립니다. 이는 인터페이스를 불안정하게 느껴지게 만듭니다.
사용자가 따라오기를 원할 때만 따라가기
규칙은 간단합니다: 사용자가 이미 하단 근처에 있을 때만 자동 스크롤(auto-scroll)을 수행합니다. 만약 사용자가 무언가를 읽기 위해 위로 스크롤한다면, 스트림을 따라가는 것을 중단하고 대신 작은 "최신 메시지로 이동" 어포던스(affordance)를 보여줍니다.
이는 무조건적인 자동 스크롤보다 나은데, 사용자의 의도를 존중하기 때문입니다. 사용자는 움직임이 아니라 문맥(context)을 원한다고 당신에게 말하고 있는 것입니다.
대부분의 앱에서는 임계값(threshold) 기반의 체크만으로도 충분합니다:
function isNearBottom(container, threshold = 120) {
const distance = container.scrollHeight - container.scrollTop - container.clientHeight;
return distance < threshold;
...
이 기능을 과하게 설계(over-engineer)하지 마세요. 스크롤 물리 엔진(scroll physics engine)이 필요한 것이 아닙니다. 합리적인 규칙과 일관된 동작이 필요할 뿐입니다.
유용한 정직함으로 에러를 렌더링하기
AI 채팅은 몇 가지 뚜렷한 방식으로 실패합니다:
- 제공자(provider)가 요청을 거부함
- 완료되기 전에 스트림이 연결 해제됨
- 저장하거나 브로드캐스팅(broadcasting)하는 동안 애플리케이션이 실패함
- 사용자가 의도적으로 취소함
이 모든 상황이 단순히 "문제가 발생했습니다"라고 표시되어서는 안 됩니다. 그 메시지는 정보가 전혀 없습니다.
대신, 다음 행동을 안내할 수 있을 만큼 UI를 구체적으로 만드세요:
완료 전 응답이 중단되었습니다. 마지막 프롬프트부터 다시 시도하세요.모델 제공자가 요청을 거부했습니다. 다시 시도하거나 모델을 변경하세요.답변을 저장할 수 없습니다. 재시도하기 전에 스레드를 새로고침하세요.사용자에 의해 생성이 중단되었습니다.
사용자는 당신의 예외 추적(exception trace)을 필요로 하지 않습니다. 그들에게 필요한 것은 메시지가 어떤 상태에 있는지, 그리고 다음에 어떤 작업을 사용할 수 있는지에 대한 명확한 설명입니다.
진단 정보는 서버에 유지하세요
UI는 깔끔해야 합니다. 하지만 로그는 그렇지 않아야 합니다.
최소한 다음 항목들을 추적하세요:
- 제공자(provider) 이름 및 모델
- 첫 번째 토큰 생성 시간 (time to first token)
- 총 소요 시간 (total duration)
- 중단 사유 (stop reason)
- 실패 유형 (failure class)
- 사용 가능한 경우 토큰 또는 사용량 메타데이터 (token or usage metadata)
나중에 AI 기능을 개선하고 싶다면, 이러한 지표들은 막연한 직관보다 훨씬 더 중요합니다. 이 지표들은 모델이 UX 예산에 비해 너무 느린지, 특정 제공자가 피크 시간대에 더 자주 실패하는지, 또는 실행 시간이 긴 생성 경로에 폴백(fallback)이 필요한지 등과 같은 실질적인 질문에 답하는 데 도움을 줍니다.
공식 Laravel 로깅 (logging) 및 큐 모니터링 패턴 (queue monitoring patterns)만으로도 시작하기에 충분합니다. 첫날부터 거대한 관측성(observability) 플랫폼을 구축할 필요는 없습니다. 다만, 구조화된 이벤트(structured events)와 깨진 세션을 재구성할 수 있을 만큼 충분한 메타데이터는 반드시 필요합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기