1. 프록시 캐시
Nginx는 백엔드 응답을 디스크에 저장해 두고 같은 요청에 바로 응답할 수 있습니다. 이 기능 하나로 백엔드 부하를 극적으로 줄일 수 있습니다.
http {
proxy_cache_path /var/cache/nginx/app
levels=1:2
keys_zone=app_cache:10m
max_size=1g
inactive=60m
use_temp_path=off;
server {
location /api/public/ {
proxy_pass http://app_servers;
proxy_cache app_cache;
proxy_cache_key $scheme$request_method$host$request_uri;
proxy_cache_valid 200 301 10m;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status always;
}
}
}| 지시어 | 설명 |
|---|---|
levels=1:2 | 캐시 파일을 2단계 하위 디렉터리로 분산 (한 디렉터리에 파일 수십만 개 방지) |
keys_zone=app_cache:10m | 캐시 키 메타데이터용 공유 메모리. 1MB에 약 8,000개 키 |
max_size | 디스크 상한. 넘으면 cache manager가 LRU로 삭제 |
inactive | 이 시간 동안 접근이 없으면 유효 여부와 상관없이 삭제 |
proxy_cache_use_stale | 백엔드가 죽어도 오래된 캐시로 응답 → 장애 내성 |
proxy_cache_lock | 같은 키에 대한 동시 요청 중 하나만 백엔드로 (캐시 스탬피드 방지) |
proxy_cache_background_update | 만료된 캐시를 응답하면서 백그라운드에서 갱신 (stale-while-revalidate) |
$upstream_cache_status 값으로 동작을 확인합니다: MISS, HIT, EXPIRED, STALE, UPDATING, BYPASS 등.
curl -sI https://example.com/api/public/items | grep X-Cache-Status주의할 점
- Nginx는 기본적으로 백엔드의
Cache-Control,Expires,Set-Cookie헤더를 존중합니다.Set-Cookie가 있는 응답은 캐시하지 않습니다. - 로그인 사용자별 응답은 캐시 키에 사용자 식별자를 넣거나 캐시를 우회해야 합니다.
proxy_cache_bypass $cookie_session $http_authorization;
proxy_no_cache $cookie_session $http_authorization;microcaching
변경이 잦은 페이지라도 1초만 캐시하면 초당 수천 건의 요청이 백엔드에서는 초당 1건이 됩니다. 이른바 microcaching 기법입니다.
proxy_cache_valid 200 1s;
proxy_cache_use_stale updating;
proxy_cache_lock on;2. Rate Limiting
요청 빈도 제한: limit_req
Nginx의 limit_req는 leaky bucket 알고리즘을 사용합니다.
http {
# 클라이언트 IP별, 초당 10개 요청
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
# 로그인은 훨씬 엄격하게: 분당 5회
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
limit_req_status 429;
server {
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://app_servers;
}
location = /api/login {
limit_req zone=login_limit burst=5;
proxy_pass http://app_servers;
}
}
}$binary_remote_addr: IP를 바이너리로 저장해 메모리 절약 (IPv4 4바이트). 10MB 존이면 대략 16만 개 IP 상태를 담습니다.rate=10r/s: 내부적으로는 100ms마다 1개씩 허용하는 방식입니다. 분 단위는r/m.burst=20: 순간적으로 초과한 요청을 최대 20개까지 대기열에 담습니다.nodelay: 대기열의 요청을 지연 없이 즉시 처리하되, 슬롯은 rate에 맞춰 천천히 비워집니다. API에서는 대부분nodelay가 체감상 자연스럽습니다.- 기본 거부 코드는 503입니다.
limit_req_status 429;로 의미에 맞게 바꾸세요.
burst와 nodelay 조합의 직관:
| 설정 | 순간 30개 요청이 들어오면 |
|---|---|
burst 없음 | 1개 처리, 나머지 거부 |
burst=20 | 1개 즉시, 20개는 100ms 간격으로 지연 처리, 9개 거부 |
burst=20 nodelay | 21개 즉시 처리, 9개 거부 |
동시 연결 수 제한: limit_conn
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
server {
location /downloads/ {
limit_conn conn_per_ip 2; # IP당 동시 다운로드 2개
limit_rate 1m; # 연결당 1MB/s
}
}프록시 뒤에 있다면: real_ip
Nginx 앞에 Cloudflare, AWS ALB 같은 또 다른 프록시가 있으면 $remote_addr는 그 프록시의 IP가 됩니다. 모든 사용자가 같은 IP로 보이니 Rate Limit이 전체에 걸려버립니다.
set_real_ip_from 10.0.0.0/8; # 신뢰할 프록시 대역만!
set_real_ip_from 173.245.48.0/20; # (예: Cloudflare 대역)
real_ip_header X-Forwarded-For;
real_ip_recursive on;신뢰하지 않는 대역까지 넣으면 클라이언트가 XFF 헤더를 위조해 IP를 속일 수 있으니 주의하세요.
3. 보안
add_header 상속의 함정
2편에서 예고한 내용입니다.
server {
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
location /static/ {
add_header Cache-Control "public, max-age=31536000";
# ⚠️ 이 location의 응답에는 X-Frame-Options, X-Content-Type-Options가 없다!
}
}add_header는 현재 레벨에 하나도 없을 때만 상위 레벨 것을 상속합니다. 해결책은 보안 헤더를 스니펫으로 빼서 필요한 곳마다 include하는 것입니다.
# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# CSP는 서비스마다 달라서 신중하게
# add_header Content-Security-Policy "default-src 'self'" always;location /static/ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000";
}always 파라미터가 없으면 2xx/3xx 응답에만 헤더가 붙습니다. 에러 페이지에도 붙이려면 always를 쓰세요. (같은 문제가 proxy_set_header에도 있습니다.)
1.29.3(mainline)부터는
add_header_inherit merge;로 상위 레벨 헤더에 현재 레벨 헤더를 덧붙이게 바꿀 수 있습니다. stable 버전이나 이전 버전을 쓴다면 위의 스니펫 방식이 여전히 정답입니다.
기타 하드닝
http {
server_tokens off; # 에러 페이지와 Server 헤더에서 버전 숨김
client_max_body_size 10m; # 업로드 크기 제한 (기본 1m → 초과 시 413)
client_body_timeout 10s; # 느린 요청(Slowloris류) 대응
client_header_timeout 10s;
server {
# 숨김 파일(.git, .env 등) 차단 (Let's Encrypt용 .well-known은 예외)
location ~ /\.(?!well-known) {
deny all;
}
# 관리자 페이지 IP 제한
location /admin/ {
allow 192.168.0.0/24;
deny all;
proxy_pass http://app_servers;
}
# 간단한 Basic 인증 (htpasswd는 apache2-utils 패키지)
location /internal/ {
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
}
}
}"If is Evil"
Nginx의 if는 일반 프로그래밍 언어의 if가 아니라 rewrite 모듈의 일부라서, location 안에서 쓰면 예상치 못한 동작을 일으키는 것으로 악명 높습니다. location 안의 if에서 안전한 것은 return과 rewrite ... last 정도입니다. 조건 분기는 가능하면 map으로 표현하세요.
# ❌ 피하기
location / {
if ($http_user_agent ~* "badbot") {
return 403;
}
...
}
# ✅ map + 단순 return
map $http_user_agent $is_bad_bot {
default 0;
~*badbot 1;
~*scrapy 1;
}
server {
if ($is_bad_bot) { return 403; } # server 레벨 + return은 안전
}4. 성능 튜닝
워커와 연결
worker_processes auto; # CPU 코어 수
worker_rlimit_nofile 65535; # 워커당 열 수 있는 파일 디스크립터
events {
worker_connections 8192; # 워커당 최대 동시 연결
# use epoll; # Linux에서는 자동 선택되므로 보통 생략
}이론상 최대 동시 연결 = worker_processes × worker_connections. 다만 리버스 프록시에서는 클라이언트 1개 연결 + 업스트림 1개 연결을 쓰므로 실제 수용량은 절반 정도로 봐야 합니다. worker_rlimit_nofile은 worker_connections보다 넉넉하게(보통 2배 이상) 잡습니다.
전송 최적화
http {
sendfile on; # 커널에서 파일 → 소켓 직접 복사 (zero-copy)
tcp_nopush on; # sendfile과 함께: 헤더와 파일 시작을 한 패킷에
tcp_nodelay on; # keepalive 연결에서 작은 패킷 지연 없이 전송
keepalive_timeout 65s;
keepalive_requests 1000;
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_errors on;
}압축
gzip on;
gzip_comp_level 5; # 1~9. 5 근처가 CPU 대비 효율이 좋다
gzip_min_length 1024; # 너무 작은 응답은 압축 이득이 없음
gzip_proxied any; # 프록시 응답도 압축
gzip_vary on; # Vary: Accept-Encoding
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml image/svg+xml;
# 빌드 시 미리 만든 .gz 파일이 있으면 그걸 사용
gzip_static on;Brotli는 Nginx 기본 모듈이 아니므로 ngx_brotli 서드파티 모듈을 추가해야 합니다(일부 배포판은 libnginx-mod-http-brotli 같은 패키지로 제공). 이것 역시 "필요한 기능을 모듈로 붙여야 한다"는 Nginx의 특징을 보여줍니다.
OS 레벨
# /etc/sysctl.d/99-nginx.conf
net.core.somaxconn = 4096 # listen 백로그 상한
net.ipv4.ip_local_port_range = 1024 65535 # 업스트림 연결용 임시 포트 범위
net.ipv4.tcp_tw_reuse = 1listen 443 ssl backlog=4096; # somaxconn과 맞춰야 효과systemd로 실행한다면 LimitNOFILE도 함께 확인하세요(systemctl edit nginx).
튜닝은 측정 → 변경 → 재측정입니다.
wrk,k6,hey같은 도구로 기준선을 먼저 잡고, 한 번에 하나씩 바꾸세요. 대부분의 서비스에서 병목은 Nginx가 아니라 뒤의 애플리케이션이나 DB입니다.
5. 관측성: 로그와 상태
JSON 구조화 로그
로그 수집기(Loki, Elasticsearch, CloudWatch 등)로 보낸다면 JSON이 편합니다.
log_format json_combined escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"request":"$request",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"upstream_addr":"$upstream_addr",'
'"upstream_response_time":"$upstream_response_time",'
'"upstream_cache_status":"$upstream_cache_status",'
'"http_user_agent":"$http_user_agent"'
'}';
access_log /var/log/nginx/access.json json_combined buffer=32k flush=5s;$request_time(전체 처리 시간)과 $upstream_response_time(백엔드 응답 시간)을 같이 남기면 느린 원인이 Nginx 쪽(클라이언트 네트워크)인지 백엔드인지 구분할 수 있습니다.
조건부 로깅으로 헬스 체크 같은 노이즈를 뺄 수도 있습니다.
map $request_uri $loggable {
~^/health 0;
default 1;
}
access_log /var/log/nginx/access.log main if=$loggable;stub_status
server {
listen 127.0.0.1:8081;
location /nginx_status {
stub_status;
allow 127.0.0.1;
deny all;
}
}Active connections: 291
server accepts handled requests
16630948 16630948 31070465
Reading: 6 Writing: 179 Waiting: 106Prometheus를 쓴다면 nginx-prometheus-exporter가 이 엔드포인트를 읽어 메트릭으로 변환해 줍니다.
6. L4 프록시: stream 모듈
HTTP가 아닌 TCP/UDP도 프록시할 수 있습니다. 예를 들어 폐쇄망에서 DB 앞에 Nginx를 두고 포트를 중계하거나, 여러 DB 레플리카로 분산하는 경우입니다.
# nginx.conf 최상위 (http 블록 바깥!)
stream {
upstream postgres_replicas {
least_conn;
server 10.0.1.21:5432 max_fails=2 fail_timeout=10s;
server 10.0.1.22:5432 max_fails=2 fail_timeout=10s;
}
server {
listen 15432;
proxy_pass postgres_replicas;
proxy_connect_timeout 3s;
proxy_timeout 10m;
}
# UDP 예시 (DNS)
server {
listen 53 udp;
proxy_pass 10.0.1.53:53;
}
}배포판에 따라 stream은 동적 모듈(libnginx-mod-stream)로 따로 설치해야 할 수 있습니다.
ssl_preread를 쓰면 TLS를 복호화하지 않고 SNI만 읽어서 도메인별로 다른 백엔드에 넘기는 것도 가능합니다.
stream {
map $ssl_preread_server_name $backend {
app.example.com 10.0.0.11:443;
mail.example.com 10.0.0.12:443;
default 10.0.0.10:443;
}
server {
listen 443;
ssl_preread on;
proxy_pass $backend;
}
}7. 트러블슈팅 체크리스트
| 증상 | 확인할 것 |
|---|---|
502 Bad Gateway | 백엔드가 떠 있는가? 포트/주소가 맞는가? (curl로 백엔드 직접 호출) SELinux의 httpd_can_network_connect? |
504 Gateway Timeout | 백엔드가 느림 → proxy_read_timeout, 그리고 백엔드 자체 성능 |
413 Request Entity Too Large | client_max_body_size |
400 Request Header Or Cookie Too Large | large_client_header_buffers 4 16k; |
리다이렉트가 http://로 감 | X-Forwarded-Proto 전달, 백엔드의 프록시 신뢰 설정 |
| 설정을 바꿨는데 반영이 안 됨 | nginx -T로 실제 로드된 설정 확인, 다른 server 블록이 먼저 매칭되는지 |
| 특정 location이 안 먹음 | 3편의 location 매칭 규칙 다시 보기, 정규식이 가로채고 있지 않은지 |
upstream sent too big header | proxy_buffer_size 16k; proxy_buffers 8 16k; |
디버깅할 때는 응답에 임시 헤더를 박아 어떤 location이 처리했는지 보는 방법이 매우 유용합니다.
location /api/ {
add_header X-Debug-Location "api" always;
...
}8. Nginx 생태계 한 줄 정리
- NGINX Plus: F5의 상용 버전. 액티브 헬스 체크, 동적 업스트림 API, 대시보드, JWT 인증 등을 제공합니다.
- OpenResty: Nginx + LuaJIT. Lua로 요청 처리 로직을 짤 수 있어 API 게이트웨이(Kong, APISIX의 기반)로 많이 쓰입니다.
- njs: Nginx 공식 JavaScript 스크립팅 모듈.
- 포크: 2024년 핵심 개발자였던 Maxim Dounin이 F5를 떠나며 만든 freenginx, 러시아 개발진의 Angie 등이 있습니다.
- 쿠버네티스: 커뮤니티가 관리하던
ingress-nginx컨트롤러는 2026년 3월로 유지보수가 종료(retire)되었습니다. 이는 Nginx 자체가 아니라 특정 인그레스 컨트롤러 프로젝트의 이야기이며, F5가 관리하는 NGINX Ingress Controller나 Gateway API 구현체로의 이전이 권장됩니다.
정리
proxy_cache+use_stale+cache_lock은 성능과 장애 내성을 동시에 올려준다.limit_req의burst/nodelay의미를 정확히 이해하고, 앞단 프록시가 있으면real_ip를 설정한다.add_header/proxy_set_header의 상속 함정을 스니펫 include로 피한다.- 조건 분기는
if보다map이다. - 튜닝은 측정 기반으로, 로그에는
request_time과upstream_response_time을 함께 남긴다. stream모듈로 TCP/UDP까지 프록시할 수 있다.
Nginx 파트는 여기까지입니다. 다음 편부터는 Caddy로 넘어가, 같은 일을 얼마나 다르게 접근하는지 살펴봅니다.