LLM 스트리밍이 60초에 끊기는 이유와 nginx·ALB·gunicorn 타임아웃 잡는 법

LLM 스트리밍이 60초에

이 글을 읽으시면 LLM 스트리밍이 60초에 끊기는 원인이 nginx·ALB·gunicorn 세 군데에 동시에 걸려 있다는 것과, 각 지점을 어떻게 고쳐야 하는지 바로 적용할 수 있는 설정값까지 확인하실 수 있어요. 결론을 먼저 말씀드리면, 토큰이 느리게 나오는 LLM 응답은 중간에 “데이터가 안 들어온다”고 판단되는 순간 끊기는데, 공교롭게도 nginx의 proxy_read_timeout과 ALB의 Idle timeout 기본값이 똑같이 60초라서 거의 동시에 끊기는 것처럼 보이는 거예요.

LLM 스트리밍이 60초에 끊기는 이유, 세 지점이 동시에 겹칩니다

LLM 스트리밍 응답은 토큰이 한 번에 쏟아지지 않고 조금씩 끊어서 내려오는 구조예요. 문제는 모델이 긴 추론을 하거나 프롬프트가 복잡할 때 토큰 사이 간격이 길어지는 순간이 생긴다는 점이에요.

이때 프록시나 로드밸런서는 “연결이 죽었다”고 오해하고 소켓을 끊어버려요. nginx는 업스트림(여기서는 gunicorn)으로부터 일정 시간 동안 아무 응답도 못 받으면 연결을 닫도록 설계돼 있고, ALB도 똑같은 방식으로 동작해요.

공교롭게도 nginx의 기본 유휴 타임아웃과 ALB의 기본 Idle timeout이 둘 다 60초로 맞춰져 있어서, 운영자 입장에서는 “딱 60초마다 끊긴다”는 증상으로 보이는 거예요. 여기에 gunicorn의 워커 타임아웃(기본 30초)까지 끼어들면 체감 끊김 시점이 더 짧아지기도 해요.

nginx·ALB·gunicorn 타임아웃 기본값 비교

세 구성요소는 “타임아웃”이라는 이름을 똑같이 쓰지만 재는 대상이 달라요. nginx와 ALB는 ‘연결이 멈춘 시간’을 재고, gunicorn(sync 워커)은 ‘워커가 살아있는지’ 신호를 재요.

구성요소 기본 타임아웃 설정 이름 변경 위치
nginx 60초 proxy_read_timeout, proxy_send_timeout nginx.conf의 location 블록
ALB 60초 Idle timeout 로드밸런서 속성 (콘솔/CLI)
gunicorn (sync 워커) 30초 --timeout 실행 커맨드 또는 설정 파일

데이터센터 서버랙과 네트워크 케이블 연결

sync 워커 기준으로 gunicorn의 기본 timeout 값은 30초인데, 이 시간 안에 워커가 마스터 프로세스에 신호를 보내지 못하면 강제로 종료돼요. 스트리밍 중에는 애플리케이션 코드가 토큰을 기다리며 멈춰 있는 구간이 생기기 쉬워서, 이 타이머가 제일 먼저 걸리는 경우도 많아요.

nginx 설정부터 손봐야 하는 까닭

nginx는 기본적으로 업스트림 응답을 버퍼에 모았다가 한 번에 내려주려는 경향이 있어요. 스트리밍 응답인데 버퍼링이 걸려 있으면 클라이언트 화면에는 토큰이 뚝뚝 끊겨서 나타나고, 그 와중에 타임아웃 조건도 같이 맞물려요.

아래처럼 location 블록에서 읽기 타임아웃을 늘리고 버퍼링을 꺼주는 게 기본 대응이에요.

location /chat/stream {
    proxy_pass http://gunicorn_upstream;
    proxy_http_version 1.1;

    # 응답 사이 간격이 길어도 끊지 않도록 여유를 둠
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;

    # 버퍼링을 끄면 토큰이 들어오는 즉시 클라이언트로 전달됨
    proxy_buffering off;
    chunked_transfer_encoding on;

    # SSE/스트리밍 응답에서 중간 프록시 버퍼링 방지용 헤더
    add_header X-Accel-Buffering no;
}

proxy_read_timeout은 nginx가 업스트림으로부터 연속해서 아무 바이트도 못 받을 때 기다리는 최대 시간이에요. 값을 올리면 느린 응답을 참아주지만, 정말 죽은 연결을 감지하는 시간도 같이 늘어난다는 점은 감안하셔야 해요. 이 디렉티브의 정확한 동작은 proxy_read_timeout 문서에 설명돼 있어요.

ALB 유휴 제한 시간을 늘렸는데도 끊긴다면

ALB의 Idle timeout도 기본 60초인데, 1초에서 4000초 사이로 조정할 수 있어요. 콘솔에서 로드밸런서 속성을 바꾸거나 CLI로 아래처럼 지정하시면 돼요.

aws elbv2 modify-load-balancer-attributes \
  --load-balancer-arn arn:aws:elasticloadbalancing:... \
  --attributes Key=idle_timeout.timeout_seconds,Value=180

여기서 트레이드오프가 하나 있어요. ALB 앞단에 API Gateway를 붙여서 쓰는 구조라면, API Gateway의 통합 타임아웃은 최대 29초로 고정돼 있고 늘릴 수 없어요. 이 경우 ALB와 nginx 타임아웃을 아무리 늘려도 API Gateway 쪽에서 먼저 끊기 때문에, 구조 자체를 바꾸거나(API Gateway를 거치지 않고 ALB에 직접 연결) 응답 방식을 조정해야 해요.

gunicorn 타임아웃과 워커 종류, 어떻게 골라야 할까요

gunicorn의 --timeout은 “요청이 전체 몇 초 안에 끝나야 한다”는 제한이 아니라, sync 워커가 마스터에 생존 신호를 못 보내는 시간이 기준이에요. 스트리밍처럼 I/O를 기다리며 오래 머무는 작업은 sync 워커에서 이 신호가 끊기기 쉬워요.

# sync 워커라면 타임아웃을 넉넉히
gunicorn app:app --workers 4 --timeout 180

# 비동기 워커(gevent)로 바꾸면 I/O 대기 중에도 이벤트 루프가 계속 신호를 보냄
gunicorn app:app --workers 4 --worker-class gevent --timeout 180

# ASGI 앱(FastAPI 등)이라면 uvicorn 워커 사용
gunicorn app:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --timeout 180

타임아웃을 180초로 늘렸는데도 여전히 30초 근처에서 끊긴다면, worker-class를 확인해 보셔야 해요. sync 워커는 설정값을 늘려도 워커 내부 코드가 블로킹 상태로 오래 머물면 신호 전송 자체가 막히는 경우가 있는데, gevent나 uvicorn 워커로 바꾸면 이 문제가 줄어들어요.

스트리밍 응답이 화면에 끊김 없이 흐르게 하려면 스트리밍 방식 자체가 중간에 끊기지 않고 지속적으로 바이트를 흘려보내야 하는데, 애플리케이션 코드에서 몇 초에 한 번씩 빈 주석 라인(SSE라면 : ping\n\n)을 보내는 것도 중간 타이머들을 리셋시키는 데 도움이 돼요.

지금 당장 바꿀 수 있는 설정 두 가지

가장 먼저 손볼 곳은 nginx의 proxy_read_timeout과 proxy_buffering off예요. 이 두 줄만 바꿔도 짧은 지연 구간에서 끊기는 증상은 많이 줄어들어요.

그다음으로 ALB Idle timeout을 120~180초 사이로 올리고, gunicorn은 worker-class를 점검해 보세요. sync 워커를 그대로 쓰고 있다면 --timeout 값만 올리기보다는 gevent나 uvicorn 워커로 바꾸는 쪽이 근본적인 해결에 가까워요. 다만 모든 값을 무조건 크게 늘리면 죽은 연결을 오래 붙잡아 두는 부작용이 있으니, 서비스 특성에 맞는 범위에서 조정하시길 권해요.

LLM 스트리밍 취소, 창을 닫으면 과금은 정말 멈출까

Leave a Comment