운영에서만 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-transform

no-transform은 중간 단계(CDN, 보안 프록시)에 "이 응답을 변형하지 말라"는 뜻을 전달합니다.

점검 순서

같은 증상을 만났을 때 확인해 볼 순서입니다.

개발 환경에서는 되는지 확인합니다. 여기서도 안 되면 프록시가 아니라 애플리케이션 문제입니다.

응답 헤더를 봅니다. Content-Type이 스트리밍용으로 나가는지, Content-Length가 붙어 있지는 않은지 봅니다. 길이가 붙어 있다면 이미 어딘가에서 응답이 완성된 뒤 전달되고 있다는 신호입니다.

웹 서버를 거치지 않고 앱에 직접 요청해 봅니다. 여기서 잘 흐른다면 범인은 그 사이의 무언가입니다.

중간 단계를 하나씩 지나며 어디서 끊기는지 좁힙니다.

주의할 점

성능이 나빠지지 않습니다

"버퍼링을 끄면 느려지지 않나요"라는 걱정을 많이 합니다. 정리하면 이렇습니다.

| 버퍼링 켬 | 버퍼링 끔
첫 글자까지 | 답변이 다 만들어질 때까지 대기 | 즉시
전체 소요 시간 | 같음 | 같음
체감 | 멈춘 것 같음 | 흐르는 느낌

전체 시간은 변하지 않습니다. 바뀌는 것은 그 시간을 사용자가 어떻게 보내느냐입니다. 패킷이 조금 더 나가는 비용은 있지만, 스트리밍 응답 자체가 대개 전체 트래픽에서 큰 비중이 아닙니다.

같은 함정이 숨어 있는 다른 자리

nginx만의 문제가 아닙니다. 요청이 지나는 길목마다 비슷한 것이 있습니다.

압축. gzip 같은 압축은 데이터를 블록 단위로 모아 처리하므로 그 자체가 버퍼 역할을 합니다. 스트리밍 응답에는 압축을 적용하지 않는 편이 낫습니다.

CDN·보안 프록시. 중간에서 응답을 변형하거나 모아둘 수 있습니다.

실행 환경의 제약. 일부 서버리스 환경은 스트리밍을 지원하지 않거나 응답 시간 상한이 걸립니다. 그때는 버퍼링 문제가 아니라 플랫폼의 한계입니다.

한 곳만 고치면 놓칩니다

스트리밍 응답이 여러 곳에 있다면 전부 확인해야 합니다. 챗봇 하나만 고치고 나중에 만든 다른 기능에서 같은 증상을 다시 만나는 일이 흔합니다. 스트리밍 응답을 만드는 자리를 한 번에 찾아 같이 손보는 편이 낫습니다.

듀오랩스가 보는 관점

개발 환경과 운영 환경의 차이는 대개 이런 모습으로 드러납니다. 크게 부서지지 않고, 조용히 나빠집니다.

서버가 죽으면 누구나 압니다. 알림이 오고 로그가 쌓이고 사용자가 항의합니다. 반면 "느낌이 조금 나쁜" 문제는 아무 신호도 남기지 않습니다. 사용자는 불편을 말로 옮기지 못한 채 그냥 떠납니다.

그래서 저희는 기능이 동작하는지만 보지 않고, 운영 환경에서 실제로 써보는 과정을 개발 과정에 포함시킵니다. 로컬에서 잘 되는 것과 사용자가 쓰는 곳에서 잘 되는 것은 다른 일이기 때문입니다.

이런 차이는 대부분 큰 재작업이 필요하지 않습니다. 헤더 한 줄, 설정 한 줄이면 끝나는 경우가 많습니다. 다만 누군가 알아채야 고칠 수 있습니다.