로컬에서는 정상인데 운영에서만 깨진 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(
<div className="fullscreen-panel">...</div>,
document.body,
);포털은 시각적 위치뿐 아니라 z-index 경쟁도 단순하게 만듭니다. 모달, 전체 화면 뷰어, 도킹 플레이어처럼 뷰포트를 기준으로 해야 하는 UI에 적합합니다.
문제 3: 스크롤 잠금이 즉시 풀렸습니다
전체 화면 패널을 열 때 body 스크롤을 잠갔지만, 포털이 DOM에 추가되는 순간 잠금이 해제됐습니다.
스크롤 잠금 훅에는 고아 잠금을 정리하는 방어 로직이 있었습니다. DOM 변화를 감시하다가 열린 모달이 없는데 body가 잠겨 있으면 잘못 남은 상태로 판단해 잠금을 풀었습니다.
문제는 패널에 aria-modal="true"가 없었다는 점입니다.
- 패널을 열며 body를 잠급니다.
- 포털이 body에 새 노드를 추가합니다.
- MutationObserver가 변화를 감지합니다.
[aria-modal="true"]를 찾지만 존재하지 않습니다.- 고아 잠금으로 판단해 방금 설정한 잠금을 해제합니다.
패널에 대화상자 역할과 모달 속성을 정확히 부여해 문제를 해결했습니다.
<section role="dialog" aria-modal="true" aria-label="문서 AI 전체 화면">
...
</section>접근성 속성은 스크린 리더를 위한 설명에 그치지 않습니다. 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 구축기 시리즈
함께 읽기
- robots.txt는 무엇이고 왜 필요한가웹사이트를 운영하다 보면 루트 주소에서 robots.txt라는 작은 텍스트 파일을 만나게 됩니다. 내용은 몇 줄뿐인데 검색엔진 최적화 점검표에는 거의 빠지지 않고 등장합니다.
- 운영에서만 AI 답변이 한 번에 나온다면, nginx가 스트림을 삼키고 있습니다챗봇 답변을 한 글자씩 흘려보내는 것은 기술적 과시가 아닙니다. 사람이 기다릴 수 있게 만드는 장치입니다. 같은 5초라도 빈 화면을 보는 5초와 글자가 차오르는 5초는 완전히 다른 시간입니다.
- 웹사이트 구축 후 운영 설계: 측정·콘텐츠·보안·백업웹사이트는 공개 버튼을 누르는 순간 완성되는 것이 아니라 운영이 시작된다. 담당자, 지표, 콘텐츠 갱신, 보안 업데이트, 백업과 복구 절차가 없으면 잘 만든 사이트도 빠르게 낡는다. 이 글에서는 소규모 팀도 지속할 수 있는 웹사이트 운영 체계를 만드는 방법을 다룬다.
- 네이버 서치어드바이저 등록부터 사이트맵 제출까지웹사이트를 만들었다고 네이버 검색 결과에 바로 나타나는 것은 아닙니다. 검색로봇이 사이트를 발견하고, 문서를 수집하고, 색인할 수 있어야 합니다. 네이버 서치어드바이저는 이 과정을 직접 보장하는 등록 창구라기보다 사이트 소유권을 확인하고 수집·색인 상태를 점검하는 운영 도구에 가깝습니다.
- 블로그를 서브도메인에서 하위 경로로 옮긴 이유, 그리고 CSP가 애드센스를 막고 있었습니다회사 사이트와 기술 블로그를 따로 운영하는 구성은 흔합니다. 회사는 duolabs.co.kr, 블로그는 blog.duolabs.co.kr.