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_files와 root/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.1과 proxy_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 헤더 원본 그대로다(없을 수 있음). $host는 Host 헤더가 없으면 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_files | alias는 경로 치환, try_files 폴백 주의 |
| upstream keepalive | keepalive + http_version 1.1 + Connection "" |
| 413 | client_max_body_size 경로별 상향 |
| too many open files | worker_connections + worker_rlimit_nofile(ulimit) |
| 헤더 underscore | 하이픈 사용 또는 underscores_in_headers on |
| reload | nginx -t && nginx -s reload |
대부분은 **기본값(버퍼링 on, 60초 타임아웃, 1MB 본문, fd 1024)**을 그대로 둔 채 새 요구사항을 얹어서 생긴다.
→ 운영·튜닝 전반은 Nginx 실전 운영을 참고.