Nginx 유의사항 — 버퍼링·타임아웃·설정 우선순위 함정

proxy_pass 한 줄로 잘 돌던 Nginx가, 스트리밍을 붙이거나 트래픽이 늘면 갑자기 이상해진다. SSE가 안 오고, 502가 뜨고, 분명히 맞게 짠 location이 엉뚱한 곳으로 간다.

대부분 새 버그가 아니라 기본값과 매칭 규칙을 몰라서 밟는 함정이다. ‘왜 생기나 → 증상 → 어떻게 피하나’ 순서로 모았다.

→ Worker 모델·SSL·upstream 튜닝 등 깊은 운영은 Nginx 실전 운영에서 다룬다.


SSE/스트리밍이 실시간으로 안 오고 뭉텅이로 온다. 왜?

왜 생기나. Nginx 기본값 proxy_buffering on은 upstream 응답을 버퍼에 쌓아뒀다가 한 번에 클라이언트로 보낸다. 일반 응답에는 효율적이지만, 이벤트가 생길 때마다 즉시 흘려보내야 하는 SSE·청크 스트리밍에는 치명적이다.

증상. 클라이언트에서 이벤트가 아예 안 오거나, 한참 뒤에 여러 개가 뭉텅이로 도착한다.

어떻게 피하나. 해당 location에서 버퍼링을 끈다. 둘 중 하나로 제어한다.

location /api/sse {
    proxy_pass http://spring_boot;

    proxy_buffering off;                       # 버퍼링 끄기
    proxy_set_header X-Accel-Buffering no;      # 또는 upstream이 응답 헤더로 지시

    proxy_http_version 1.1;                     # 청크 전송에 필요
    proxy_set_header Connection "";
}

X-Accel-Buffering: no는 애플리케이션이 응답 헤더로 Nginx에게 “이 응답만 버퍼링하지 말라”고 알리는 방법이다. location마다 설정을 두기 어려울 때 유용하다.


SSE 연결이 딱 60초마다 끊긴다

왜 생기나. proxy_read_timeout 기본값이 60초다. upstream에서 60초 동안 아무 데이터도 안 오면 Nginx가 연결을 끊는다. SSE처럼 이벤트 간격이 길 수 있는 롱커넥션은 여기에 걸린다.

증상. 트래픽이 한가할 때 60초 주기로 연결이 떨어진다.

어떻게 피하나. 타임아웃을 길게 잡고, 애플리케이션에서 주기적으로 하트비트를 흘려보낸다.

location /api/sse {
    proxy_pass http://spring_boot;
    proxy_read_timeout 86400s;   # 60초 → 길게
    proxy_buffering off;
}

하트비트(예: 30초마다 SSE 코멘트)를 보내면 데이터가 흐르므로 타임아웃이 계속 리셋된다. Nginx 뒤에 ALB가 있으면 ALB Idle Timeout(기본 60초)도 같이 늘려야 한다.

→ 하트비트 구현과 ALB 설정은 Nginx 실전 운영에 있다.


location을 맞게 짠 것 같은데 엉뚱한 블록이 매칭된다

왜 생기나. location은 작성 순서대로 매칭되지 않는다. Nginx에는 정해진 우선순위가 있다.

=  (정확히 일치)            ← 가장 높음
^~ (접두 일치, 정규식 검사 생략)
~  / ~* (정규식, 위에서부터 첫 매칭)
(접두 일치, prefix)         ← 가장 낮음 (가장 긴 prefix가 이김)

= > ^~ > 정규식(~, ~*) > 일반 prefix 순이다. 정규식이 일반 prefix보다 먼저 잡힌다는 점에서 헷갈린다.

증상. 정적 파일용 prefix location을 만들었는데, 위에 있는 정규식 location(~ \.php$ 등)이 먼저 잡혀 의도와 다르게 동작한다.

어떻게 피하나.

location = /health        { return 200; }       # 정확히 이 경로만
location ^~ /static/       { root /var/www; }    # 이 접두면 정규식 검사 건너뜀
location ~* \.(jpg|png)$   { expires 30d; }      # 확장자 정규식
location /                 { proxy_pass http://spring_boot; }  # 나머지

자주 타는 정적 경로는 ^~로 못박아 정규식 평가 자체를 건너뛰게 하면 의도도 명확하고 빠르다.


try_filesroot/alias에서 자꾸 404가 난다

왜 생기나. 두 가지를 자주 헷갈린다.

root는 location 경로를 뒤에 붙이고, alias는 location 경로를 치환한다.

# root: 최종 경로 = root + URI
location /static/ {
    root /var/www;          # /static/a.js → /var/www/static/a.js
}

# alias: location 부분을 alias 값으로 교체
location /static/ {
    alias /var/www/assets/; # /static/a.js → /var/www/assets/a.js
}

alias는 location이 /로 끝나면 alias 값도 /로 끝나야 한다. 안 맞으면 경로가 어긋나 404가 난다.

try_files는 나열한 경로를 왼쪽부터 시도하고 첫 번째로 존재하는 것을 반환한다. 마지막 인자는 폴백이다.

location / {
    try_files $uri $uri/ /index.html;   # 파일 → 디렉터리 → SPA 폴백
}

마지막 인자를 파일이 아닌 =404로 두거나, 폴백 라우트를 잘못 적으면 정상 요청이 404로 빠지는 함정이 흔하다.


upstream 연결이 매 요청마다 새로 맺힌다. 느리다

왜 생기나. upstream 블록에 keepalive를 설정하지 않으면 Nginx는 요청마다 upstream과 새 TCP 연결을 맺고 끊는다. 트래픽이 많아지면 3-way handshake와 종료 오버헤드가 쌓인다.

증상. 초당 요청이 많아질수록 TIME_WAIT 소켓이 늘고 응답이 미세하게 지연된다.

어떻게 피하나. keepalive를 설정하되, 세트로 묶이는 두 줄을 빠뜨리면 안 된다.

upstream spring_boot {
    server 127.0.0.1:8080;
    keepalive 100;            # Worker당 유지할 유휴 연결 수
}

server {
    location / {
        proxy_pass http://spring_boot;
        proxy_http_version 1.1;          # ← 둘이 세트
        proxy_set_header Connection "";   # ← 기본 Connection: close 제거
    }
}

proxy_http_version 1.1proxy_set_header Connection "" 없이 keepalive만 넣으면 동작하지 않는다. Nginx가 upstream에 기본으로 HTTP/1.0 + Connection: close를 보내기 때문이다.

→ keepalive 타임아웃을 Tomcat과 맞추는 법은 Nginx 실전 운영에 있다.


파일 업로드가 413으로 거부된다

왜 생기나. client_max_body_size 기본값은 1MB다. 요청 본문이 이를 초과하면 Nginx가 upstream까지 보내지 않고 바로 413 Request Entity Too Large를 반환한다.

증상. 작은 파일은 되는데 큰 파일·이미지 업로드만 413으로 실패한다. 애플리케이션 로그에는 요청이 도착한 흔적조차 없다.

어떻게 피하나. 업로드를 받는 경로에 한도를 올린다. 애플리케이션(예: Spring max-file-size) 쪽 한도와도 맞춘다.

location /api/upload {
    client_max_body_size 50m;
    proxy_pass http://spring_boot;
}

전역에 무조건 크게 잡기보다 업로드 경로에만 올리는 게 안전하다. 아무 데서나 큰 본문을 받으면 메모리·디스크 소비와 DoS 표면이 늘어난다.


동시 접속이 늘면 too many open files가 뜬다

왜 생기나. 두 한계가 겹친다. worker_connections는 Worker 하나가 동시에 열 수 있는 연결 수이고, 그 연결은 모두 OS의 파일 디스크립터를 쓴다. worker_connections를 올려도 OS의 ulimit -n(fd 한도)이 낮으면 거기서 막힌다.

증상. 부하가 오르면 too many open files, 신규 연결이 거부된다.

어떻게 피하나. 두 값을 함께 올린다.

worker_rlimit_nofile 65536;   # Worker의 fd 한도

events {
    worker_connections 4096;  # worker_rlimit_nofile의 절반 이하 권장
}

worker_connections에는 클라이언트 연결뿐 아니라 upstream 연결도 포함된다는 점을 기억한다. 하나만 올리면 나머지가 병목이 된다.

→ epoll·연결 처리 모델은 Nginx 실전 운영에서 다룬다.


커스텀 헤더에 underscore를 넣었더니 사라진다

왜 생기나. Nginx는 기본적으로 헤더 이름에 언더스코어(_)가 들어간 헤더를 무시한다(underscores_in_headers off가 기본). X_Request_Id 같은 헤더가 upstream에 전달되지 않는다.

증상. 클라이언트는 보냈는데 애플리케이션에서 그 헤더가 null이다.

어떻게 피하나. 가능하면 하이픈(X-Request-Id)을 쓰는 게 표준이다. 언더스코어를 꼭 써야 하면 명시적으로 허용한다.

server {
    underscores_in_headers on;
}

$host$http_host는 뭐가 다른가? reload와 restart는?

$host vs $http_host. $http_host는 클라이언트가 보낸 Host 헤더 원본 그대로다(없을 수 있음). $hostHost 헤더가 없으면 server_name으로 폴백하고 포트를 떼는 등 정규화된 값이다. 보통은 더 안전한 $host를 권장한다.

proxy_set_header Host $host;   # 정규화된 호스트 (일반적으로 권장)

reload vs restart. nginx -s reload(graceful)는 새 Worker를 띄우고 기존 Worker는 진행 중인 요청을 마친 뒤 종료한다 — 무중단이다. restart는 프로세스를 통째로 내렸다 올려 순간적으로 연결이 끊긴다. 설정 변경은 reload로 충분하다. 단, 반드시 검증 먼저 한다.

nginx -t && nginx -s reload   # 문법 검사 통과해야만 reload

nginx -t 없이 reload하면 설정 오류가 있어도 기존 Worker가 그대로 돌아 “적용됐다”고 착각하기 쉽다.


정리

함정핵심 회피책
SSE 버퍼링proxy_buffering off / X-Accel-Buffering: no
60초 끊김proxy_read_timeout 확대 + 하트비트
location 매칭= > ^~ > 정규식 > prefix 우선순위
root/alias, try_filesalias는 경로 치환, try_files 폴백 주의
upstream keepalivekeepalive + http_version 1.1 + Connection ""
413client_max_body_size 경로별 상향
too many open filesworker_connections + worker_rlimit_nofile(ulimit)
헤더 underscore하이픈 사용 또는 underscores_in_headers on
reloadnginx -t && nginx -s reload

대부분은 **기본값(버퍼링 on, 60초 타임아웃, 1MB 본문, fd 1024)**을 그대로 둔 채 새 요구사항을 얹어서 생긴다.

→ 운영·튜닝 전반은 Nginx 실전 운영을 참고.