트래픽이 갑자기 10배로 폭증하는 상황을 상상해보세요. 평소에는 문제없이 돌아가던 서비스가 순식간에 500 에러와 응답 지연으로 무너집니다. 원인의 상당수는 애플리케이션 코드가 아니라 그 앞에 놓인 Nginx 리버스 프록시의 기본값 설정에 있습니다. Nginx는 기본 설정만으로도 훌륭하지만, 실무 트래픽에 맞게 튜닝하지 않으면 커넥션 큐 포화, 과도한 컨텍스트 스위칭, 불필요한 CPU 낭비로 병목이 생깁니다. 이 글에서는 리버스 프록시로 쓰이는 Nginx를 실제 수치를 근거로 단계적으로 튜닝하는 방법을 설명합니다.

worker_processes와 worker_connections: 동시 처리 한계 설정

Nginx는 멀티프로세스 + 이벤트 기반(비동기 I/O) 아키텍처입니다. 각 워커 프로세스는 단일 스레드로 수천 개의 커넥션을 이벤트 루프로 처리합니다. 따라서 첫 번째 튜닝 지점은 워커 수와 커넥션 수입니다.

# /etc/nginx/nginx.conf — 워커 및 이벤트 블록

# CPU 코어 수와 동일하게 설정하거나 auto 사용
# auto는 Nginx 1.2.5+에서 nproc 값을 자동 감지
worker_processes auto;

# 워커 프로세스가 열 수 있는 파일 디스크립터 수
# ulimit -n 값과 일치시킬 것 (기본 1024는 너무 낮음)
worker_rlimit_nofile 65536;

events {
    # 워커 하나가 동시에 처리할 수 있는 커넥션 수
    # 최대 동시 접속 수 = worker_processes × worker_connections
    # 4코어 서버에서 4 × 4096 = 최대 16,384 커넥션
    worker_connections 4096;

    # Linux epoll이 기본값이지만 명시적으로 선언하면 안전
    use epoll;

    # 하나의 accept 호출로 가능한 많은 커넥션을 수락 (thundering herd 방지)
    multi_accept on;
}

OS 레벨에서도 파일 디스크립터 한계를 올려야 합니다.

# /etc/security/limits.conf 또는 systemd 서비스 파일에 추가
nginx   soft    nofile  65536
nginx   hard    nofile  65536

# systemd 환경이라면 /etc/systemd/system/nginx.service.d/override.conf
[Service]
LimitNOFILE=65536

# 변경 후
systemctl daemon-reload && systemctl restart nginx

4코어 서버에서 worker_processes autoworker_connections 4096 조합이면 이론상 16,384개의 동시 커넥션을 처리할 수 있습니다. 단, 메모리도 고려해야 합니다. 커넥션 하나당 약 10~50KB의 메모리를 소비하므로 16K 커넥션이면 최소 160MB~800MB가 커넥션 처리에만 사용됩니다.

keepalive 튜닝: 커넥션 재활용으로 레이턴시 줄이기

HTTP keepalive는 동일 클라이언트의 여러 요청을 하나의 TCP 커넥션으로 처리하는 메커니즘입니다. 리버스 프록시 환경에서는 클라이언트→NginxNginx→업스트림(백엔드) 두 구간 모두 최적화해야 합니다.

http {
    # 클라이언트와의 keepalive 타임아웃
    # 너무 길면 유휴 커넥션이 워커 자원을 점유, 너무 짧으면 TCP 핸드셰이크 반복
    keepalive_timeout 65s;

    # 하나의 keepalive 커넥션에서 처리할 최대 요청 수
    keepalive_requests 1000;

    upstream backend {
        server 10.0.1.10:8080;
        server 10.0.1.11:8080;
        server 10.0.1.12:8080;

        # Nginx → 백엔드 keepalive 커넥션 풀 크기
        # 각 워커가 백엔드와 유지할 유휴 커넥션 수
        # 너무 높으면 백엔드의 파일 디스크립터를 소진할 수 있음
        keepalive 64;

        # keepalive 요청당 타임아웃 (백엔드 연결 유지 시간)
        keepalive_timeout 60s;
    }

    server {
        listen 80;

        location /api/ {
            proxy_pass http://backend;

            # 업스트림 keepalive를 사용하려면 반드시 HTTP/1.1 지정
            proxy_http_version 1.1;
            # Connection 헤더를 빈 문자열로 지워야 keepalive가 유지됨
            proxy_set_header Connection "";

            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_http_version 1.1proxy_set_header Connection "" 두 줄은 세트입니다. 둘 중 하나라도 빠지면 Nginx와 백엔드 사이의 keepalive가 동작하지 않아 요청마다 새 TCP 커넥션을 맺는 오버헤드가 발생합니다. 실제로 이 두 줄만 추가해도 백엔드 레이턴시가 10~30% 개선되는 사례가 흔합니다.

gzip과 프록시 버퍼: CPU와 네트워크 비용의 균형

gzip 압축은 전송 데이터를 줄여 네트워크 비용과 클라이언트 로딩 시간을 개선합니다. 단, CPU 비용이 따릅니다. 압축 레벨 1~9 중 1~4가 CPU 대비 압축률 균형이 가장 좋습니다. 레벨 6(기본값)은 레벨 4 대비 압축률이 5~10% 높지만 CPU는 30~50% 더 소비합니다.

http {
    # gzip 압축 설정
    gzip on;
    gzip_comp_level 4;        # 1~9 중 4가 CPU/압축률 균형점
    gzip_min_length 1024;     # 1KB 이하 응답은 압축하지 않음 (오버헤드가 이득보다 큼)
    gzip_vary on;             # Vary: Accept-Encoding 헤더 추가 (CDN 캐시 분리)
    gzip_proxied any;         # 프록시된 요청에도 압축 적용
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml;
    # 이미지(JPEG, PNG, WebP)는 이미 압축되어 있어 gzip 효과 없음 → 제외

    # 프록시 버퍼 설정
    # 백엔드 응답을 버퍼에 담아 클라이언트에 전송 → 백엔드 커넥션을 빨리 해제
    proxy_buffering on;
    proxy_buffer_size 16k;       # 응답 헤더 버퍼 (헤더가 클 경우 늘릴 것)
    proxy_buffers 8 32k;         # 응답 바디 버퍼 (8개 × 32KB = 256KB)
    proxy_busy_buffers_size 64k; # 클라이언트에 전송 중인 버퍼 최대 크기

    # 타임아웃 설정
    proxy_connect_timeout 10s;  # 백엔드 연결 시도 제한 (기본 60s는 너무 길다)
    proxy_read_timeout 60s;     # 백엔드에서 데이터 읽기 제한
    proxy_send_timeout 60s;     # 백엔드로 데이터 전송 제한
}

proxy_buffering on이 기본값이지만 스트리밍 API(SSE, chunked transfer)에서는 버퍼링을 끄거나 X-Accel-Buffering: no 헤더를 사용해야 합니다. 버퍼 크기는 일반 API라면 256KB 수준이 적절하고, 큰 파일 다운로드 서버라면 proxy_max_temp_file_size를 함께 조정해야 합니다.

업스트림 로드밸런싱과 헬스 체크

리버스 프록시의 핵심 역할 중 하나는 트래픽을 여러 백엔드에 분산하는 것입니다. Nginx 오픈소스는 round-robin, ip_hash, least_conn을 지원합니다.

알고리즘 동작 방식 적합한 케이스 단점
round-robin (기본) 순서대로 돌아가며 분배 백엔드 성능이 균일할 때 느린 서버에도 동일 요청 전달
least_conn 활성 커넥션이 가장 적은 서버 우선 처리 시간이 불균일한 API 상태 정보 공유 오버헤드
ip_hash 클라이언트 IP 기준 고정 서버 세션 스티키가 필요할 때 부하 불균형 가능성
random (Plus/OSS 1.15.1+) 무작위 선택 (two choices 변형) 대규모 업스트림 풀 세션 유지 불가
upstream backend {
    least_conn;  # 활성 커넥션 최소 서버 우선

    server 10.0.1.10:8080 weight=3;  # 트래픽의 3/5 처리
    server 10.0.1.11:8080 weight=2;  # 트래픽의 2/5 처리
    server 10.0.1.12:8080 backup;    # 다른 서버 모두 다운 시에만 사용

    # 수동 헬스체크 (오픈소스): 실패 시 일정 시간 제외
    # max_fails=3: 3번 실패하면 fail_timeout 동안 제외
    # fail_timeout=30s: 제외 후 30초 후 재시도
    server 10.0.1.10:8080 weight=3 max_fails=3 fail_timeout=30s;

    keepalive 64;
}

능동적 헬스 체크(active health check)는 Nginx Plus 유료 기능입니다. 오픈소스에서는 passive 방식(실제 요청 실패 기준)만 가능합니다. 보다 정교한 헬스 체크가 필요하다면 Nginx Plus 또는 nginx_upstream_check_module 서드파티 모듈을 검토하세요.

실전 팁과 벤치마크 관점

튜닝 전후를 측정하지 않으면 효과를 알 수 없습니다. wrkab로 간단한 부하 테스트를 수행하고 Nginx 상태 모듈(stub_status)로 모니터링하세요.

# stub_status 활성화 (내부 IP에서만 접근 허용)
server {
    listen 127.0.0.1:8080;
    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: 106

# wrk 부하 테스트: 12 스레드, 400 커넥션, 30초간
wrk -t12 -c400 -d30s http://localhost/api/health

체크리스트 형태의 추가 실전 팁입니다.

  • sendfile on / tcp_nopush on / tcp_nodelay on 세트: 정적 파일 전송 효율을 높입니다. sendfile은 커널 공간에서 직접 파일을 전송해 user-space 복사를 생략합니다.
  • open_file_cache: 자주 열리는 파일의 디스크립터, 크기, 수정 시간을 캐시해 stat() 시스템 콜을 줄입니다. open_file_cache max=10000 inactive=30s;가 일반적인 시작값입니다.
  • access_log 최소화: 고트래픽 환경에서 모든 요청을 로그로 기록하면 I/O 병목이 됩니다. access_log off;로 끄거나 buffer=16k flush=5s 버퍼 옵션을 추가하세요.
  • SSL/TLS 최적화: ssl_session_cache shared:SSL:10m;로 TLS 세션을 캐시하면 full handshake를 줄여 HTTPS 응답 시간이 개선됩니다.
  • rate limiting: limit_req_zone으로 IP당 요청 속도를 제한해 DDoS와 어뷰저 트래픽을 차단하세요. 정상 트래픽에는 burst 값으로 순간 급증을 허용합니다.

마무리

Nginx 튜닝은 단일 마법 설정이 없습니다. 서비스의 트래픽 패턴(짧은 요청이 많은지, 큰 파일을 다운로드하는지), 백엔드의 응답 시간 분포, 서버 하드웨어 스펙에 따라 최적값이 달라집니다. 이 글에서 다룬 설정값은 범용 출발점이므로 반드시 실제 트래픽과 유사한 조건에서 부하 테스트를 수행하고 조정하세요. 변경 하나하나를 분리해서 측정하는 것이 효과를 정확히 파악하는 유일한 방법입니다.

자주 묻는 질문

Q. worker_processes를 CPU 코어 수보다 많이 설정하면 안 되나요?
A. 대부분의 경우 코어 수를 넘기면 컨텍스트 스위칭 오버헤드만 늘어납니다. Nginx는 이벤트 기반 비동기 I/O이므로 워커 하나가 여러 커넥션을 처리할 수 있습니다. 예외는 CPU 집약적 작업(SSL 처리, 이미지 처리 모듈 등)이 많을 때인데, 이 경우에도 코어 수의 1~2배를 넘지 않는 것이 일반적입니다.

Q. gzip_comp_level을 높일수록 무조건 좋은 것 아닌가요?
A. 그렇지 않습니다. 레벨 6 이상에서는 압축률 향상이 미미하고 CPU 비용이 급격히 늘어납니다. 실제 벤치마크에서 레벨 1과 레벨 9의 압축 파일 크기 차이는 보통 5~15% 이내인 반면, CPU 사용량은 5~10배 차이가 납니다. 전송 비용이 CPU보다 훨씬 비싼 환경이 아니라면 레벨 4 이하를 권장합니다.

Q. 업스트림 keepalive 값은 어떻게 정하나요?
A. worker_processes × keepalive가 총 유휴 커넥션 수입니다. 백엔드 서버가 허용하는 최대 커넥션 수를 초과하지 않아야 합니다. 일반적으로 백엔드 서버당 keepalive / 업스트림 서버 수개의 커넥션이 유지된다고 볼 수 있습니다. 32~128 범위에서 시작해 백엔드 CLOSE_WAIT 상태 커넥션 수를 모니터링하면서 조정하세요.