로컬에서는 정상인데 운영에서만 깨진 AI 스트리밍

문서 AI 기능을 로컬에서 완성하고 운영에 배포했을 때, 답변 자체는 정상인데 화면의 느낌이 완전히 달라졌습니다. 스트리밍 답변은 한꺼번에 나타났고, 전체 화면 패널은 작은 섹션 안에 갇혔으며, 모달이 열리자마자 배경 스크롤 잠금이 풀렸습니다.

세 문제 모두 테스트 환경에서는 잘 드러나지 않았습니다. 원인은 AI 모델이 아니라 프록시, CSS 합성 계층, 접근성 속성과 상태 감시 로직 사이의 상호작용에 있었습니다.

문제 1: SSE를 보내는데 스트리밍처럼 보이지 않았습니다

서버는 Server-Sent Events로 답변 조각을 계속 전송하고 있었습니다. 개발 환경에서는 글자가 생성되는 대로 화면에 나타났습니다. 운영에서는 몇 초 동안 아무 변화가 없다가 답변이 큰 덩어리로 한꺼번에 표시됐습니다.

원인은 리버스 프록시의 응답 버퍼링이었습니다.

애플리케이션 → 토큰 A → 토큰 B → 토큰 C
프록시 → [A+B+C를 버퍼에 모음]
브라우저 → A+B+C를 한 번에 받음

애플리케이션 입장에서는 스트리밍이 맞지만 사용자 입장에서는 일반 응답과 다르지 않습니다. SSE 응답에 버퍼링을 비활성화하는 헤더를 추가해 해결했습니다.

return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
});

중요한 점은 비슷한 스트리밍 경로를 함께 점검하는 것입니다. 한 라우트에서 발견된 프록시 문제는 같은 방식으로 구현된 다른 라우트에도 존재할 가능성이 높습니다.

문제 2: position: fixed가 전체 화면이 아니었습니다

AI 패널의 전체 화면 모드는 position: fixed; inset: 0으로 구현했습니다. 그런데 운영 화면에서는 뷰포트 전체가 아니라 패널이 있던 섹션 크기 안에서만 열렸습니다.

position: fixed는 항상 브라우저 화면을 기준으로 할 것 같지만, 조상 요소에 transform, filter, perspective 또는 일부 will-change가 적용되면 그 조상이 새로운 기준 영역이 될 수 있습니다.

.animated-section {
transform: translateY(0);
}

.fullscreen-panel {
position: fixed;
inset: 0;
}

이 구조에서 전체 화면 패널은 .animated-section 안에 갇힐 수 있습니다. 해결 방법은 패널을 DOM의 body 아래에 포털로 렌더링하는 것입니다.

return createPortal(
... ,
document.body,
);

포털은 시각적 위치뿐 아니라 z-index 경쟁도 단순하게 만듭니다. 모달, 전체 화면 뷰어, 도킹 플레이어처럼 뷰포트를 기준으로 해야 하는 UI에 적합합니다.

문제 3: 스크롤 잠금이 즉시 풀렸습니다

전체 화면 패널을 열 때 body 스크롤을 잠갔지만, 포털이 DOM에 추가되는 순간 잠금이 해제됐습니다.

스크롤 잠금 훅에는 고아 잠금을 정리하는 방어 로직이 있었습니다. DOM 변화를 감시하다가 열린 모달이 없는데 body가 잠겨 있으면 잘못 남은 상태로 판단해 잠금을 풀었습니다.

문제는 패널에 aria-modal="true"가 없었다는 점입니다.

패널을 열며 body를 잠급니다.

포털이 body에 새 노드를 추가합니다.

MutationObserver가 변화를 감지합니다.

[aria-modal="true"]를 찾지만 존재하지 않습니다.

고아 잠금으로 판단해 방금 설정한 잠금을 해제합니다.

패널에 대화상자 역할과 모달 속성을 정확히 부여해 문제를 해결했습니다.


...


접근성 속성은 스크린 리더를 위한 설명에 그치지 않습니다. UI의 상태를 나타내는 공통 계약으로 사용될 때 자동화와 방어 로직의 기준이 되기도 합니다.

보너스 문제: 번역 최적화가 React key를 없앴습니다

다국어 페이로드를 줄이기 위해 서버가 현재 언어의 값만 특정 키에 담아 전달했습니다. 그런데 카드 컴포넌트는 원래 언어 키를 React key로 사용하고 있었습니다. 운영 데이터에서는 해당 키가 모두 undefined가 되었습니다.

// 데이터 축약 뒤에는 값이 없을 수 있습니다.
items.map(item => <Card key={item.title.ko} item={item} />)

// 언어와 무관한 안정적인 ID를 사용합니다.
items.map(item => <Card key={item.id} item={item} />)

표시 문자열은 번역이나 최적화 과정에서 달라질 수 있습니다. React key에는 데이터의 수명 동안 유지되는 식별자를 사용해야 합니다.

운영에서 확인해야 할 경계

로컬과 운영의 차이는 대개 애플리케이션 바깥 경계에서 생깁니다.

경계 | 확인할 항목
애플리케이션 ↔ 프록시 | 버퍼링, 압축, 캐시, 타임아웃
컴포넌트 ↔ DOM | 포털, stacking context, fixed 기준 영역
UI 상태 ↔ 접근성 | role, aria-modal, 포커스, 스크롤 잠금
서버 데이터 ↔ React | 안정적인 ID, 로케일 축약, hydration

단위 테스트가 통과해도 이 경계는 실제 배포 구조에서 달라질 수 있습니다.

점검 체크리스트

SSE 응답이 프록시를 통과한 뒤에도 조각 단위로 도착하나요?

스트리밍 경로의 캐시와 압축 설정을 확인했나요?

전체 화면 UI가 transform이 있는 조상 아래에 있지 않나요?

모달을 body 포털로 렌더링해야 하는지 검토했나요?

role="dialog"와 aria-modal이 상태 로직과 일치하나요?

React key가 번역 가능한 문자열에 의존하지 않나요?

운영과 같은 프록시 구조에서 통합 테스트했나요?

듀오랩스가 보는 관점

운영 장애를 AI 모델 문제로 단정하면 엉뚱한 곳에서 시간을 쓰게 됩니다. 답변 생성, 네트워크 전달, 브라우저 렌더링은 서로 다른 계층이며 각 계층의 성공 여부를 따로 확인해야 합니다.

서버가 스트림을 만들었다는 사실과 사용자가 스트리밍을 본다는 사실은 다릅니다. position: fixed를 썼다는 사실과 화면 전체를 덮는다는 사실도 다릅니다. 운영 환경에서는 프록시와 브라우저까지 포함한 전체 경로가 제품입니다.

다음 글에서는 내부 문서를 다루는 관리자 AI와 공개 AI를 함께 운영할 때 필요한 공개 범위, 프롬프트 주입 방어와 실패 정책을 정리합니다.

문서 RAG 구축기 시리즈

27B보다 2.4B가 더 나았던 문서 RAG 모델 선택기

문서 RAG를 관리자와 공개 서비스에 함께 붙인 구조

RAG 검색 임계값을 감으로 정하면 안 되는 이유

로컬에서는 정상인데 운영에서만 깨진 AI 스트리밍

내부 문서를 공개 AI에 연결할 때 필요한 안전장치

데스크톱 AI 화면을 모바일에서 과감히 제거한 이유

배포 문서 한 줄이 AI 기능 전체를 막을 뻔한 이유