처음 Nginx를 쓸 때는 proxy_pass http://localhost:8080; 한 줄 넣고 끝냈다.
동작은 했다. 그런데 트래픽이 늘어나면서 SSE 연결이 60초마다 끊기고, 배포할 때마다 요청이 튀고, 특정 요청이 이상하게 느렸다. Nginx를 제대로 이해하지 못한 대가였다.
처음부터 파고들자.
Apache가 있는데 왜 Nginx를 쓰나?
Apache의 전통적인 방식(mpm_prefork, mpm_worker)은 요청마다 프로세스 또는 스레드를 하나 할당한다.
동시 접속 1,000개면 스레드 1,000개. 스레드당 수 MB 메모리가 필요하고, 컨텍스트 스위칭 비용이 누적된다. 동시 접속이 늘어날수록 메모리와 CPU가 선형으로 증가한다.
이게 C10K Problem이다 — 1만 개 동시 연결을 처리하려면 어떻게 해야 하는가.
Apache MPM Prefork 방식으로 10,000 동시 연결을 처리하면 10,000개 프로세스가 필요하다. 메모리가 수 GB 날아간다.
Nginx의 해법 — 이벤트 기반 비동기 모델:
[Master Process] — 설정 읽기, Worker 프로세스 관리, 시그널 처리
│
├── [Worker Process 1] — epoll 이벤트 루프
│ ├── 연결 1 (클라이언트 → 데이터 읽기 대기)
│ ├── 연결 2 (Spring Boot → 응답 수신 중)
│ ├── 연결 3 (클라이언트 → 쓰기 진행 중)
│ └── 연결 N ... (수천 개)
│
└── [Worker Process 2] — epoll 이벤트 루프
└── ...
Worker 프로세스 하나가 이벤트 루프로 수천 개의 연결을 처리한다. 네트워크 읽기/쓰기가 완료되면 OS 커널이 이벤트를 발생시키고, 그때 해당 연결을 처리한다. 기다리는 동안 다른 연결을 처리한다. 스레드를 추가로 생성하지 않는다.
이 차이가 고동시성 상황에서 극적인 성능 차이를 만든다. 같은 서버에서 Apache 대비 10배 이상 많은 동시 연결을 처리하는 경우도 있다.
Worker 프로세스가 뭔가? 왜 여러 개인가?
worker_processes auto;
auto는 CPU 코어 수를 자동으로 감지해서 그 수만큼 Worker 프로세스를 생성한다. 4코어 CPU면 Worker 4개.
왜 코어 수에 맞추나?
Worker 1개만 있으면 어떤 문제가 생기나? CPU 코어가 4개인데 Worker가 1개라면, 나머지 3개 코어는 놀고 있다. 연결이 몰리면 그 하나의 Worker가 병목이 된다.
반대로 너무 많으면? Worker가 8개인데 코어가 4개라면, 컨텍스트 스위칭이 늘어난다. 커널이 Worker 간에 CPU를 계속 교체하는 오버헤드가 생긴다. 코어 수 = Worker 수가 최적이다.
Nginx Worker는 CPU 바운드 작업이 아니라 I/O 바운드 작업을 한다. 네트워크에서 읽고, upstream에 보내고, 응답을 받아서 다시 보낸다. CPU는 이 과정에서 많이 쓰이지 않는다. 핵심은 I/O 이벤트를 얼마나 효율적으로 처리하느냐다.
worker_processes auto;
worker_cpu_affinity auto; # Worker를 특정 CPU 코어에 고정
worker_cpu_affinity auto는 CPU 캐시 지역성을 최대화한다. NUMA 아키텍처 서버에서 메모리 접근 레이턴시를 줄이는 효과가 있다.
worker_connections와 ulimit -n이 왜 같이 봐야 하나?
events {
worker_connections 1024;
}
Worker 프로세스 하나가 동시에 열 수 있는 최대 연결 수다.
여기서 중요한 점 — 클라이언트 연결뿐 아니라 upstream(Spring Boot) 연결도 포함된다.
Nginx가 클라이언트 요청 하나를 처리할 때:
- 클라이언트 → Nginx: 연결 1개
- Nginx → Spring Boot: 연결 1개
실제 처리 가능한 동시 클라이언트 수 = worker_connections / 2. worker_connections 1024면 Worker 하나가 약 512개 동시 요청을 처리할 수 있다.
이 숫자가 부족하면? 새 연결 요청이 OS Accept 큐에 쌓인다. 큐도 가득 차면 클라이언트가 Connection refused 또는 타임아웃을 받는다.
ulimit -n과 반드시 맞춰야 한다:
# 현재 파일 디스크립터 한도 확인
ulimit -n
# 기본값: 1024 (매우 부족할 수 있음)
Linux는 모든 연결을 파일 디스크립터(fd)로 관리한다. worker_connections를 4096으로 올렸는데 ulimit -n이 1024면 1024개 이상 연결이 불가능하다. too many open files 에러가 발생한다.
# nginx.conf에서 설정
worker_rlimit_nofile 65536; # Worker 프로세스의 파일 디스크립터 한도
events {
worker_connections 4096; # worker_rlimit_nofile의 절반 이하로
}
두 값을 항상 함께 확인해야 한다. 하나만 올리면 나머지가 병목이 된다.
epoll이 왜 빠른가? select/poll과 비교하면 어떻게 다른가?
I/O 멀티플렉싱 메커니즘의 진화다.
select (POSIX 표준):
감시할 fd 목록을 매번 커널에 전달
→ 커널이 모든 fd를 순회
→ 준비된 fd 반환
복잡도: O(n) — fd 1만 개면 1만 번 순회
fd 수 제한: FD_SETSIZE (보통 1024)
연결이 1,000개인데 실제로 데이터가 있는 건 10개뿐이라면, 990개는 확인만 하고 버려진다. 낭비다.
poll (select 개선):
- fd 수 제한 없음
- 여전히 O(n) — 준비된 fd가 하나여도 전체 목록을 순회
epoll (Linux 2.5.44+, Nginx가 사용):
epoll_create: 커널에 epoll 인스턴스 생성 (한 번만)
epoll_ctl: 관심 있는 fd를 등록/수정/삭제
epoll_wait: 이벤트가 발생한 fd만 반환
복잡도: O(1) — fd 수와 무관
핵심은 커널이 이벤트를 추적한다는 것이다. 10,000개 연결이 있어도 실제로 데이터가 도착한 연결만 알려준다. 나머지 9,999개는 건드리지 않는다.
동시 연결이 1,000개일 때와 10,000개일 때 epoll의 처리 시간은 거의 같다. select/poll은 10배 느려진다.
events {
use epoll; # Linux에서 명시적 지정 (auto로 해도 epoll 선택됨)
multi_accept on; # 이벤트 발생 시 가능한 한 많은 연결을 한 번에 Accept
}
multi_accept on은 Accept 큐에 쌓인 연결을 한 번에 모두 받아들인다. off(기본값)이면 이벤트당 연결 하나씩. 갑작스러운 연결 폭발에 더 빠르게 대응한다.
Spring Boot 앞에 Nginx를 두면 keepalive를 어떻게 설정해야 하나?
keepalive를 설정하지 않으면 Nginx는 요청마다 Spring Boot와 새로운 TCP 연결을 맺는다.
요청마다 TCP 3-way handshake + 응답 + FIN. 이 오버헤드가 트래픽이 많아지면 쌓인다. 초당 수백 req/s 트래픽이라면 초당 수백 번의 TCP 연결/종료가 발생한다.
upstream spring_boot {
server 127.0.0.1:8080;
server 127.0.0.1:8081;
keepalive 100; # Worker당 유지할 upstream keepalive 연결 수
keepalive_requests 1000; # 연결 하나가 처리할 최대 요청 수 (이후 재생성)
keepalive_timeout 75s; # 유휴 keepalive 연결 유지 시간
}
server {
location / {
proxy_pass http://spring_boot;
proxy_http_version 1.1; # keepalive는 HTTP/1.1에서만 가능
proxy_set_header Connection ""; # HTTP/1.0의 기본 Connection: close 제거
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
proxy_http_version 1.1과 proxy_set_header Connection ""은 반드시 세트로 설정해야 한다. 이 두 줄 없이 keepalive 100을 설정해도 keepalive가 동작하지 않는다. Nginx가 upstream에 보내는 기본 버전이 HTTP/1.0이고, HTTP/1.0의 기본이 Connection: close다.
Spring Boot Tomcat 설정과 맞춰야 한다:
# application.yml
server:
tomcat:
max-connections: 8192
threads:
max: 200
min-spare: 20
keep-alive-timeout: 75000 # ms — Nginx keepalive_timeout과 맞춤
max-keep-alive-requests: 1000 # Nginx keepalive_requests와 맞춤
Nginx keepalive_timeout 75s와 Tomcat keep-alive-timeout 75000ms를 맞추는 것이 중요하다.
Nginx 타임아웃이 더 짧으면 Nginx가 먼저 연결을 끊으면서 Tomcat이 “클라이언트가 연결을 끊었다”는 오류를 로그에 남긴다. Tomcat 쪽이 더 짧으면 Nginx가 이미 닫힌 연결에 요청을 보내다가 502 에러가 발생한다.
SSL을 Nginx에서 끊으면 Spring Boot는 뭘 신경 안 써도 되나? 그런데 X-Forwarded-For가 왜 중요한가?
Nginx에서 TLS를 종료하면 Spring Boot는 HTTP만 받으면 된다. SSL 설정, 인증서 관리가 Spring Boot에서 사라진다.
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off; # TLS 1.3에서는 off 권장
# Worker 간 TLS 세션 공유 (10MB ≈ 40,000 세션)
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# OCSP Stapling: CA 서버 별도 조회 없이 인증서 유효성 첨부
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
# HSTS: 1년간 HTTPS 강제
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://spring_boot; # Spring Boot로는 HTTP
proxy_set_header X-Forwarded-Proto $scheme; # "https"를 전달
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
그런데 X-Forwarded-For가 왜 중요한가?
Spring Boot는 HTTP로 요청을 받는다. request.getRemoteAddr()를 호출하면 Nginx의 내부 IP가 반환된다. 클라이언트 IP가 아니다.
Rate Limiting을 IP 기반으로 하면 — 모든 요청이 같은 Nginx IP로 보인다. Rate Limiting이 동작하지 않는다.
Spring Security의 HTTPS 리다이렉트가 안 먹힌다. request.getScheme()이 http를 반환하기 때문이다.
해결:
# application.yml
server:
forward-headers-strategy: framework
이 설정을 하면 Spring이 X-Forwarded-Proto: https를 읽어서 request.getScheme()이 https를 반환한다. X-Forwarded-For도 올바르게 처리한다.
SSE 서비스를 Nginx 뒤에 두면 이벤트가 왜 안 오나?
가장 많이 겪는 Nginx SSE 문제다. 원인이 두 가지다.
원인 1 — 응답 버퍼링:
Nginx 기본 설정은 upstream 응답을 버퍼에 쌓은 뒤 클라이언트에 보낸다. 응답이 일정 크기가 되거나 연결이 닫혀야 전달한다.
SSE는 서버가 이벤트를 발생시킬 때마다 즉시 전달해야 한다. 버퍼에 가로막히면 실시간성이 깨진다. 클라이언트 입장에서 이벤트가 아예 안 오거나, 뭉텅이로 한 번에 온다.
원인 2 — 타임아웃:
proxy_read_timeout 기본값은 60초다. SSE 연결은 이벤트가 없어도 수 분~수 시간 연결을 유지한다. 60초 동안 데이터가 없으면 Nginx가 연결을 끊어버린다.
location /api/sse {
proxy_pass http://spring_boot;
# 버퍼링 비활성화: 즉시 클라이언트에 전달
proxy_buffering off;
proxy_cache off;
proxy_set_header X-Accel-Buffering no;
# SSE 연결 타임아웃 (60초 → 24시간)
proxy_read_timeout 86400s;
# HTTP/1.1 필수 (청크 전송 인코딩 지원)
proxy_http_version 1.1;
proxy_set_header Connection "";
add_header Content-Type "text/event-stream; charset=utf-8";
add_header Cache-Control "no-cache";
add_header X-Accel-Buffering "no";
}
Spring Boot 쪽도 맞춰줘야 한다:
@GetMapping(value = "/api/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe() {
// timeout을 Nginx proxy_read_timeout보다 짧게
SseEmitter emitter = new SseEmitter(3_600_000L); // 1시간
// Heartbeat 전송 (30초마다) — 중간 프록시의 타임아웃 방지
ScheduledFuture<?> heartbeat = scheduler.scheduleAtFixedRate(() -> {
try {
emitter.send(SseEmitter.event().comment("heartbeat"));
} catch (IOException e) {
emitter.completeWithError(e);
}
}, 0, 30, TimeUnit.SECONDS);
emitter.onCompletion(() -> heartbeat.cancel(true));
emitter.onTimeout(() -> heartbeat.cancel(true));
return emitter;
}
comment("heartbeat")는 SSE 스펙에서 :heartbeat 형태의 코멘트로 전송된다. 클라이언트는 이벤트로 인식하지 않지만, 데이터가 흐르므로 Nginx 타임아웃이 리셋된다.
ALB도 함께 확인하라:
Nginx 뒤에 ALB가 있다면 ALB의 Idle Timeout(기본 60초)도 늘려야 한다. ALB 콘솔에서 Attributes → Idle timeout을 3600초로 변경한다.
Nginx에서 Rate Limiting을 설정하면 WAF랑 역할이 겹치지 않나?
겹치는 것처럼 보이지만 레이어와 목적이 다르다.
| AWS WAF Rate Limiting | Nginx Rate Limiting | |
|---|---|---|
| 위치 | 네트워크 엣지 (CloudFront/ALB 앞) | Spring Boot 직전 |
| 집계 단위 | IP, Forwarded-IP, Header 등 | IP, URI 조합 등 |
| 정밀도 | 서비스 전체 단위 | URI 경로별 세밀한 제어 |
| 응답 | 즉시 Block (요청이 리전에 오기 전) | 429 응답 (Nginx까지 온 후) |
| 적합한 용도 | DDoS, 대규모 스캐닝 | 경로별 API 제한, 로그인 보호 |
두 가지를 함께 쓰는 게 맞다. WAF가 대규모 공격을 먼저 걸러내고, Nginx가 세밀한 경로별 제한을 한다.
http {
# IP별 요청 속도 제한 존
limit_req_zone $binary_remote_addr zone=api_zone:10m rate=100r/s;
limit_req_zone $binary_remote_addr zone=login_zone:1m rate=5r/m;
# IP별 동시 연결 수 제한 존
limit_conn_zone $binary_remote_addr zone=conn_zone:10m;
}
server {
# 일반 API
location /api/ {
limit_req zone=api_zone burst=200 nodelay;
limit_conn conn_zone 20;
limit_req_status 429;
limit_conn_status 429;
proxy_pass http://spring_boot;
}
# 로그인 — 더 엄격하게
location /api/auth/login {
limit_req zone=login_zone burst=10 nodelay;
limit_req_status 429;
proxy_pass http://spring_boot;
}
}
burst=200 nodelay의 의미: 순간적으로 rate를 초과해도 burst 범위(200개)까지는 즉시 처리한다. burst를 초과하면 즉시 429를 반환한다.
$binary_remote_addr는 IP를 바이너리로 저장한다. IPv4는 4바이트, IPv6는 16바이트. 문자열($remote_addr) 대비 메모리 효율이 좋다.
nginx -s reload가 무중단으로 동작하는 원리가 뭔가?
nginx -s reload
설정이 바뀐다. 진행 중인 요청이 끊어지지 않는다.
처음엔 “설정 파일을 바꾸면 기존 연결이 끊기는 거 아닌가?”라는 의문이 생긴다. 어떻게 가능한가?
동작 원리:
1. nginx -s reload 실행
→ Master Process에 SIGHUP 시그널 전송
2. Master Process:
a. 새 설정 파일 읽기 및 검증 (문법 오류 있으면 여기서 실패)
b. 새 설정으로 새 Worker Process 생성
c. 기존 Worker Process에 SIGQUIT(우아한 종료) 신호 전송
3. 기존 Worker Process (SIGQUIT 받은 후):
→ 새 연결 수락 중단
→ 현재 처리 중인 요청은 완료될 때까지 계속 처리
→ 모든 요청 완료 후 종료
4. 새 Worker Process:
→ 즉시 새 연결 수락 시작
→ 새 설정 적용
새 Worker가 새 연결을 받고, 기존 Worker는 진행 중인 요청을 마무리한다. 두 세대가 잠깐 공존한다.
# 반드시 설정 검사 후 reload
nginx -t && nginx -s reload
nginx -t 없이 바로 reload하면 설정 오류가 있어도 일단 시도하다가 실패한다. 기존 Worker는 그대로 계속 돌아가지만, 새 설정이 적용됐다고 착각하기 쉽다. 배포 스크립트에 항상 nginx -t && nginx -s reload 패턴을 써라.
Nginx 로그에서 응답시간을 어떻게 분석하나?
기본 로그 포맷은 사람이 읽기엔 괜찮지만 자동 파싱이 번거롭다. JSON 포맷으로 바꾸면 CloudWatch Logs Insights, Loki에 바로 넣을 수 있다.
http {
log_format json_main escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"x_forwarded_for":"$http_x_forwarded_for",'
'"method":"$request_method",'
'"uri":"$uri",'
'"query":"$query_string",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"upstream_response_time":"$upstream_response_time",'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"request_id":"$request_id"'
'}';
access_log /var/log/nginx/access.log json_main buffer=32k flush=5s;
}
핵심 지표 — request_time vs upstream_response_time의 차이가 뭘 의미하는지:
request_time:
클라이언트가 첫 바이트 전송 시점
~ Nginx가 마지막 바이트 전송 시점
→ 클라이언트 네트워크 속도까지 포함
→ 느린 클라이언트, 대용량 업로드에서 크게 나올 수 있음
upstream_response_time:
Nginx가 upstream에 요청 전송 완료 시점
~ 응답 마지막 바이트 수신 시점
→ 순수 Spring Boot 처리 시간에 가장 가까움
분석 공식:
request_time 크고, upstream_response_time 작음
→ 클라이언트 네트워크가 느리다
(업로드 속도 문제 또는 모바일 환경)
upstream_response_time 크다
→ Spring Boot 처리가 느리다
(DB 쿼리 지연, 외부 API 호출 등)
request_time 크고, upstream_addr가 "-" (빈값)
→ upstream에 연결도 못 했다
(Spring Boot 다운, 연결 거부)
upstream_status가 502, 503, 504
→ upstream 에러. Spring Boot 상태 확인 필요
요청 추적 — Nginx request_id를 Spring Boot까지:
proxy_set_header X-Request-ID $request_id;
// Spring Boot - MDC에 request_id 등록
@Component
public class RequestIdFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String requestId = ((HttpServletRequest) request).getHeader("X-Request-ID");
if (requestId != null) MDC.put("requestId", requestId);
try {
chain.doFilter(request, response);
} finally {
MDC.remove("requestId");
}
}
}
이제 Nginx 로그의 request_id와 Spring Boot 로그의 [requestId]가 같은 값이다. 하나의 느린 요청을 Nginx → Spring Boot → DB까지 ID 하나로 추적할 수 있다.
최적화
gzip 압축:
http {
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6; # 1(빠름)~9(높은 압축). 6이 균형점
gzip_min_length 1024; # 1KB 미만은 압축 안 함 (오히려 커질 수 있음)
gzip_types
text/plain
text/css
application/json
application/javascript
application/xml
image/svg+xml;
# JPEG, PNG, WebP, 동영상은 이미 압축됨 — gzip 제외
}
JSON API 응답은 gzip으로 70~80% 줄어든다. gzip_vary on을 빠뜨리면 Vary: Accept-Encoding 헤더가 없어서 CDN이 gzip 버전과 비압축 버전을 같은 캐시로 혼동한다.
자주 요청되는 공개 API 캐시:
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g inactive=60m;
location /api/public/ {
proxy_cache api_cache;
proxy_cache_valid 200 5m;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_use_stale error timeout updating;
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://spring_boot;
}
proxy_cache_use_stale error timeout updating은 upstream이 오류를 반환하거나 업데이트 중일 때 오래된 캐시를 반환한다. 배포 중에도 사용자는 캐시된 응답을 받는다.
튜닝
연결 타임아웃 조정:
# 클라이언트 측
client_header_timeout 15s;
client_body_timeout 30s;
send_timeout 30s;
keepalive_timeout 75s;
keepalive_requests 1000;
# upstream 측
proxy_connect_timeout 5s; # Spring Boot 다운 시 빠른 감지 (기본 60s보다 짧게)
proxy_send_timeout 60s;
proxy_read_timeout 60s; # SSE라면 86400s
proxy_connect_timeout을 5초로 짧게 잡는 게 중요하다. Spring Boot가 다운됐을 때 Nginx가 60초(기본값) 동안 기다리면 클라이언트도 60초를 기다린다. 5초로 줄이면 빠른 장애 감지와 upstream failover가 가능하다.
Passive Health Check:
upstream spring_boot {
server 127.0.0.1:8080 max_fails=3 fail_timeout=30s;
server 127.0.0.1:8081 max_fails=3 fail_timeout=30s backup;
}
30초 안에 3번 실패하면 30초간 해당 서버를 제외한다. backup 서버는 primary가 모두 실패할 때만 활성화된다.
운영상 주의사항
nginx -t 없이 reload하지 말 것
배포 스크립트에서 실패를 감지하지 못하면 새 설정이 적용됐다고 착각한다. nginx -t && nginx -s reload 패턴을 항상 쓴다.
로그 로테이션 설정
# /etc/logrotate.d/nginx
/var/log/nginx/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
sharedscripts
postrotate
nginx -s reopen # 새 로그 파일로 전환 (이게 없으면 old fd에 계속 씀)
endscript
}
logrotate가 로그 파일을 rotate하면 Nginx는 여전히 old fd를 열고 거기에 쓴다. nginx -s reopen으로 새 파일을 열도록 알려줘야 한다.
디스크 버퍼 설정
access_log /var/log/nginx/access.log json_main buffer=32k flush=5s;
32KB 버퍼에 쌓이거나 5초가 지나면 플러시한다. 요청마다 즉시 파일에 쓰는 기본 설정 대비 디스크 I/O 오버헤드를 줄인다. 단, 서버가 갑자기 죽으면 버퍼의 로그가 유실될 수 있다.
버전 업그레이드 주의
새 버전에서 기본값이 바뀌거나 지시어가 deprecated되는 경우가 있다. 업그레이드 전에 nginx -v와 변경 로그를 확인하고, 스테이징에서 먼저 검증한다.