운영에서만 AI 답변이 한 번에 나온다면, nginx가 스트림을 삼키고 있습니다
왜 이 주제가 중요한가
챗봇 답변을 한 글자씩 흘려보내는 것은 기술적 과시가 아닙니다. 사람이 기다릴 수 있게 만드는 장치입니다. 같은 5초라도 빈 화면을 보는 5초와 글자가 차오르는 5초는 완전히 다른 시간입니다.
그런데 개발 환경에서 잘 흐르던 답변이 운영에 올리는 순간 통째로 한 번에 나타나는 일이 있습니다. 코드는 그대로인데 말이죠.
이 문제가 까다로운 이유는 아무것도 고장 나지 않기 때문입니다. 오류도 없고, 로그도 깨끗하고, 답변 내용도 정확합니다. 그저 느낌이 나쁠 뿐입니다. 그래서 코드 리뷰나 자동 테스트로는 거의 잡히지 않고, 실제 서비스를 사람이 써보고 "어라, 여기선 다른데" 하고 알아채야 발견됩니다.
원인은 대개 애플리케이션이 아니라 그 앞에 서 있는 웹 서버(리버스 프록시) 입니다.
핵심 개념
개발과 운영의 구조 차이
개발 환경에서는 보통 앱을 직접 띄웁니다. 브라우저가 앱과 바로 이야기합니다. 운영 환경에는 앱 앞에 nginx 같은 웹 서버가 한 겹 더 있습니다.
개발: 브라우저 ─────────────→ 앱
운영: 브라우저 → nginx → 앱이 한 겹이 스트리밍을 삼킵니다.
nginx가 응답을 모아두는 이유
nginx에는 proxy_buffering이라는 설정이 있고 기본값이 켜짐입니다. 앱이 보낸 응답을 곧바로 전달하지 않고 자기 버퍼에 모았다가 한꺼번에 내보냅니다.
원래는 좋은 동작입니다. 이유가 두 가지 있습니다.
네트워크 효율. 작은 조각을 수백 번 보내는 것보다 모아서 몇 번에 보내는 쪽이 훨씬 쌉니다.
앱 보호. 네트워크가 느린 사용자에게 응답을 다 보낼 때까지 앱의 연결을 붙잡아두면 앱은 그동안 자원을 낭비합니다. nginx가 응답을 통째로 받아두고 앱을 놓아주면, 느린 전송은 nginx가 감당합니다.
정적 파일이나 일반 API 응답에는 분명한 이득입니다.
스트리밍에는 정확히 해로운 동작
문제는 스트리밍 응답의 목적이 조각이 만들어지는 즉시 도착하는 것이라는 데 있습니다.
AI 답변은 몇 초에 걸쳐 조금씩 생성됩니다. 이걸 모아뒀다가 끝나고 한 번에 내보내면, 사용자는 그 시간 내내 빈 화면을 보다가 완성된 답변을 마주합니다. 스트리밍을 구현한 의미가 사라집니다.
여기서 앞의 두 가지 이득도 성립하지 않습니다. 네트워크 효율은 데이터가 애초에 천천히 생성되므로 모을 것이 별로 없습니다. 앱 보호는 더 분명합니다. 응답이 십수 초에 걸쳐 만들어지는데 nginx가 무슨 수로 빨리 받아서 앱을 놓아주겠습니까. 어차피 스트림이 끝날 때까지 연결은 열려 있어야 합니다.
즉 스트리밍에서 버퍼링은 비용만 남고 이득은 없는 상태가 됩니다.
실제 적용 포인트
응답 헤더 한 줄
웹 서버 설정 파일을 고칠 수도 있지만, 더 간단하고 안전한 방법이 있습니다. 응답 헤더에 이 한 줄을 넣으면 nginx가 그 응답만 버퍼링에서 제외합니다.
X-Accel-Buffering: no이 방식을 권하는 이유가 셋입니다.
웹 서버를 건드리지 않습니다. 설정 변경은 재기동을 부르고, 재기동은 다른 것들을 함께 흔듭니다. 헤더는 애플리케이션 코드 안에 있으니 평소 배포에 그대로 실려 나갑니다.
범위가 정확합니다. 스트리밍 응답에만 붙으므로 나머지 트래픽(HTML, 자바스크립트, 이미지, 일반 API)은 버퍼링의 이득을 그대로 누립니다. 설정 파일에서 경로 단위로 끄면 그 경로의 다른 응답까지 함께 영향을 받습니다.
의도가 코드에 남습니다. 설정 파일에 적힌 한 줄은 시간이 지나면 왜 있는지 아무도 모릅니다. 스트리밍 응답 바로 옆에 주석과 함께 있으면 다음 사람이 이해합니다.
함께 넣으면 좋은 헤더
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transformno-transform은 중간 단계(CDN, 보안 프록시)에 "이 응답을 변형하지 말라"는 뜻을 전달합니다.
점검 순서
같은 증상을 만났을 때 확인해 볼 순서입니다.
- 개발 환경에서는 되는지 확인합니다. 여기서도 안 되면 프록시가 아니라 애플리케이션 문제입니다.
- 응답 헤더를 봅니다.
Content-Type이 스트리밍용으로 나가는지,Content-Length가 붙어 있지는 않은지 봅니다. 길이가 붙어 있다면 이미 어딘가에서 응답이 완성된 뒤 전달되고 있다는 신호입니다. - 웹 서버를 거치지 않고 앱에 직접 요청해 봅니다. 여기서 잘 흐른다면 범인은 그 사이의 무언가입니다.
- 중간 단계를 하나씩 지나며 어디서 끊기는지 좁힙니다.
주의할 점
성능이 나빠지지 않습니다
"버퍼링을 끄면 느려지지 않나요"라는 걱정을 많이 합니다. 정리하면 이렇습니다.
| 버퍼링 켬 | 버퍼링 끔 | |
|---|---|---|
| 첫 글자까지 | 답변이 다 만들어질 때까지 대기 | 즉시 |
| 전체 소요 시간 | 같음 | 같음 |
| 체감 | 멈춘 것 같음 | 흐르는 느낌 |
전체 시간은 변하지 않습니다. 바뀌는 것은 그 시간을 사용자가 어떻게 보내느냐입니다. 패킷이 조금 더 나가는 비용은 있지만, 스트리밍 응답 자체가 대개 전체 트래픽에서 큰 비중이 아닙니다.
같은 함정이 숨어 있는 다른 자리
nginx만의 문제가 아닙니다. 요청이 지나는 길목마다 비슷한 것이 있습니다.
압축. gzip 같은 압축은 데이터를 블록 단위로 모아 처리하므로 그 자체가 버퍼 역할을 합니다. 스트리밍 응답에는 압축을 적용하지 않는 편이 낫습니다.
CDN·보안 프록시. 중간에서 응답을 변형하거나 모아둘 수 있습니다.
실행 환경의 제약. 일부 서버리스 환경은 스트리밍을 지원하지 않거나 응답 시간 상한이 걸립니다. 그때는 버퍼링 문제가 아니라 플랫폼의 한계입니다.
한 곳만 고치면 놓칩니다
스트리밍 응답이 여러 곳에 있다면 전부 확인해야 합니다. 챗봇 하나만 고치고 나중에 만든 다른 기능에서 같은 증상을 다시 만나는 일이 흔합니다. 스트리밍 응답을 만드는 자리를 한 번에 찾아 같이 손보는 편이 낫습니다.
듀오랩스가 보는 관점
개발 환경과 운영 환경의 차이는 대개 이런 모습으로 드러납니다. 크게 부서지지 않고, 조용히 나빠집니다.
서버가 죽으면 누구나 압니다. 알림이 오고 로그가 쌓이고 사용자가 항의합니다. 반면 "느낌이 조금 나쁜" 문제는 아무 신호도 남기지 않습니다. 사용자는 불편을 말로 옮기지 못한 채 그냥 떠납니다.
그래서 저희는 기능이 동작하는지만 보지 않고, 운영 환경에서 실제로 써보는 과정을 개발 과정에 포함시킵니다. 로컬에서 잘 되는 것과 사용자가 쓰는 곳에서 잘 되는 것은 다른 일이기 때문입니다.
이런 차이는 대부분 큰 재작업이 필요하지 않습니다. 헤더 한 줄, 설정 한 줄이면 끝나는 경우가 많습니다. 다만 누군가 알아채야 고칠 수 있습니다.
함께 읽기
- 블로그를 서브도메인에서 하위 경로로 옮긴 이유, 그리고 CSP가 애드센스를 막고 있었습니다회사 사이트와 기술 블로그를 따로 운영하는 구성은 흔합니다. 회사는 duolabs.co.kr, 블로그는 blog.duolabs.co.kr.
- 로컬에서는 정상인데 운영에서만 깨진 AI 스트리밍문서 AI 기능을 로컬에서 완성하고 운영에 배포했을 때, 답변 자체는 정상인데 화면의 느낌이 완전히 달라졌습니다. 스트리밍 답변은 한꺼번에 나타났고, 전체 화면 패널은 작은 섹션 안에 갇혔으며, 모달이 열리자마자 배경 스크롤 잠금이 풀렸습니다.
- Upstash 환경변수 7줄, 각각 무엇을 막는가Vercel 같은 서버리스 환경에 백그라운드 작업 큐와 요청 제한을 붙이려고 문서를 펼치면
- Next.js + Postgres 프로젝트 호스팅 비용 비교: 관리형 플랫폼과 VPSNext.js와 PostgreSQL로 만든 웹 애플리케이션을 운영할 때 관리형 플랫폼과 VPS 중 어느 쪽이 더 저렴한지는 월 기본요금만으로 판단하기 어렵습니다. 관리형 플랫폼은 배포와 확장을 대신 처리하고, VPS는 낮은 인프라 비용 대신 서버 운영을 직접 맡아야 합니다.
- robots.txt는 무엇이고 왜 필요한가웹사이트를 운영하다 보면 루트 주소에서 robots.txt라는 작은 텍스트 파일을 만나게 됩니다. 내용은 몇 줄뿐인데 검색엔진 최적화 점검표에는 거의 빠지지 않고 등장합니다.