테마 전환

Nginx 성능 튜닝 실전: gzip, 캐시, 연결 풀 설정

Easton editorial illustration: before-and-after response pipe, gzip compression module, cache chamber, connection pool manifold

지난주 알림을 하나 받았습니다. 이커머스 사이트의 홈 화면 로딩 시간이 4초까지 치솟았다는 내용이었습니다. Chrome DevTools를 열어 보니 HTML 파일이 120KB, CSS와 JS를 합치면 또 350KB였습니다. 모두 압축되지 않은 원본 파일이었습니다. 더 심각한 문제는 모든 요청이 백엔드까지 들어가고 있었고, 캐시 적중률은 겨우 12%였다는 점입니다. 그날 밤 야근을 하며 2시간 동안 Nginx 설정을 조정했습니다. gzip 압축을 켜고, 캐시 전략을 보완하고, 연결 풀 파라미터를 조정했습니다. 다음 날 아침 다시 확인해 보니 홈 화면 로딩은 1.6초까지 줄었고, 백엔드 QPS는 거의 절반 가까이 떨어졌습니다.

이런 문제는 정말 흔합니다. 많은 사람이 Nginx를 설치한 뒤 바로 실행만 합니다. gzip은 기본적으로 켜지지 않고, 캐시는 대충 두 줄만 적고, 연결 수 제한은 기본값을 그대로 씁니다. 그러다 트래픽 피크가 오면 서버가 바로 버거워집니다.

이 글에서는 제가 운영 환경에서 검증한 Nginx 성능 튜닝 설정을 정리합니다. gzip 압축은 전송 크기를 60-80% 줄일 수 있고, Brotli로 바꾸면 15-25%를 더 절약할 수 있습니다. 캐시 적중률이 95%에 이르면 백엔드 부하는 90% 줄어듭니다. 연결 풀을 합리적으로 설정하면 동시 처리 능력은 3-4배까지 올라갑니다. 여기에 Thread Pools와 reuseport 같은 고급 기법까지 더하면 단일 서버 RPS를 50K-80K까지 끌어올릴 수 있습니다. 각 모듈의 설정 세부 사항, 실제로 겪은 함정, 테스트 데이터를 차근차근 풀어 보겠습니다.

1장: gzip/Brotli 압축 설정 - 전송 크기 줄이기

먼저 gzip이 왜 이렇게 중요한지 이야기해 보겠습니다. 원본 HTML 파일 크기가 100KB라고 가정해 봅시다. gzip으로 압축하면 20-25KB 정도까지 줄어들 수 있습니다. 절약되는 75-80KB 대역폭은 사용자에게는 더 빠른 로딩 속도이고, 운영자에게는 더 낮은 트래픽 비용입니다.

제가 이 문제를 처음 강하게 체감한 것은 한 고객 프로젝트에서였습니다. 그들의 모바일 사용자 비중은 60%가 넘었고, 많은 사용자가 4G 네트워크 환경에서 접속했습니다. 홈 화면 로딩은 3-4초가 걸렸고, 이탈률은 70%까지 치솟았습니다. gzip을 적용한 뒤 전송 크기는 바로 70% 줄었고, 첫 화면 로딩 시간은 약 1.5초로 내려갔습니다.

1.1 gzip 기본 설정: 먼저 작동하게 만들기

Nginx의 gzip 설정은 사실 복잡하지 않습니다. 핵심은 몇 줄입니다.

http {
    gzip on;
    gzip_vary on;
    gzip_min_length 1000;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
}

gzip on은 스위치입니다. 별도로 설명할 것이 없습니다. gzip_vary on은 매우 중요합니다. 응답 헤더에 Vary: Accept-Encoding을 추가해 CDN과 브라우저에 이 응답 내용이 클라이언트의 압축 지원 여부에 따라 달라질 수 있음을 알려 줍니다. 이를 통해 캐시 혼선을 피할 수 있습니다.

gzip_min_length를 1000바이트로 설정한다는 것은 1KB보다 작은 파일은 압축하지 않겠다는 뜻입니다. 너무 작은 파일은 압축 이득이 크지 않고 오히려 CPU만 낭비할 수 있습니다. gzip_types는 압축할 MIME 타입을 지정합니다. 기본값은 text/html만 압축하므로 CSS, JS, JSON, XML 등을 직접 추가해야 합니다.

1.2 gzip 고급 설정: 압축 레벨과 MIME 타입 목록

압축 레벨은 균형이 필요한 선택입니다. Nginx의 gzip_comp_level은 1-9까지 설정할 수 있습니다. 숫자가 클수록 압축률은 높아지지만 CPU 소모도 커집니다.

제가 테스트한 결과는 다음과 같습니다.

압축 레벨HTML 압축률CPU 시간(ms)추천 상황
165%2CPU 여유가 부족한 경우
472%3균형형(추천)
675%5대역폭이 부족한 경우(추천)
978%12극단적인 상황

솔직히 말하면 레벨 4와 6이 대부분의 상황에서 가장 좋은 선택입니다. 레벨 9는 CPU 소모가 두 배 가까이 늘지만 압축률은 몇 퍼센트포인트만 더 좋아집니다. 비용 대비 효율이 좋지 않습니다.

아래는 제가 운영 환경에서 사용하는 완전한 gzip 설정입니다.

# gzip 압축 설정
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1000;
gzip_types
    text/plain
    text/css
    text/xml
    text/javascript
    application/json
    application/javascript
    application/xml
    application/xml+rss
    application/xhtml+xml
    application/x-javascript;
gzip_disable "msie6";

gzip_proxied any는 쉽게 놓치는 파라미터입니다. Nginx가 리버스 프록시로 동작할 때 백엔드가 반환한 응답 헤더에 Content-Length가 없으면 기본적으로 압축하지 않을 수 있습니다. any로 설정하면 조건에 맞는 모든 응답을 강제로 압축할 수 있습니다.

gzip_disable "msie6"은 오래된 IE6 호환성을 위한 설정입니다. IE6는 gzip 지원에 문제가 있습니다. 지금은 IE6가 사실상 사라졌으니 이 줄은 지워도 되지만, 저는 혹시 모를 상황에 대비해 습관적으로 남겨 둡니다.

1.3 어떤 파일 타입에서 이득이 가장 큰가?

제가 측정한 데이터 기준으로 파일 타입마다 압축 효과는 꽤 다릅니다.

파일 타입원본 크기압축 후압축률
HTML100KB20-25KB75-80%
CSS80KB24-28KB65-70%
JavaScript120KB36-42KB65-70%
JSON API50KB20-25KB50-60%
이미지/비디오이미 압축됨의미 없음0-5%

이미지와 비디오는 JPEG, PNG, MP4처럼 이미 압축된 형식입니다. 여기에 gzip을 다시 적용하면 오히려 크기가 커질 수 있습니다. 따라서 gzip_types에 절대 image/*와 video/*를 넣지 마세요. 넣는 순간 역효과가 납니다.

한 번은 다른 사람의 문제를 도와 점검하다가 gzip_types에 image/jpeg가 들어 있는 것을 발견했습니다. 결과적으로 이미지 크기가 오히려 3-5% 늘었습니다. 이런 초보적인 실수는 저도 예전에 해 본 적이 있습니다. 잘 모를 때는 모든 MIME 타입을 다 넣고 싶어지니까요.

1.4 Brotli: gzip보다 15-25% 더 절약

한 단계 더 나아가고 싶다면 Brotli가 더 좋은 선택입니다. Brotli는 Google이 개발한 압축 알고리즘으로, 같은 압축 레벨에서 gzip보다 압축률이 15-25% 높습니다. 특히 정적 리소스에 잘 맞습니다. 브라우저의 Brotli 지원도 이미 널리 퍼져 있습니다.

다만 한 가지 함정이 있습니다. Brotli는 Nginx 기본 모듈이 아니므로 추가로 컴파일하거나 동적 모듈을 설치해야 합니다. 저는 공식 동적 모듈 방식을 사용합니다. Nginx를 다시 컴파일하지 않아도 됩니다.

# 먼저 모듈을 로드해야 함(동적 모듈을 사용하는 경우)
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;

http {
    brotli on;
    brotli_comp_level 6;
    brotli_types text/plain text/css application/javascript application/json;
    brotli_min_length 256;
}

Brotli의 압축 레벨은 1-11이지만 저는 6을 권장합니다. 레벨이 너무 높으면, 예를 들어 11이면, 압축 시간이 눈에 띄게 늘어 동적으로 생성되는 콘텐츠에는 적합하지 않습니다. 정적 파일은 미리 Brotli 최고 레벨로 압축해 두고 Nginx가 사전 압축된 .br 파일을 바로 반환하게 만들 수 있습니다.

실측 비교 데이터는 다음과 같습니다.

압축 방식100KB HTML 압축 후압축 소요 시간브라우저 지원
gzip (level 6)25KB5ms거의 전부
Brotli (level 6)18KB15ms95%+
Brotli (사전 압축 level 11)15KB095%+

제 권장안은 이렇습니다. 동적 콘텐츠에는 Brotli level 4-6을 사용하고, 정적 리소스에는 사전 압축 방식을 사용하세요. Nginx 컴파일이 너무 번거롭다면 gzip만으로도 충분합니다. 어쨌든 75% 압축률은 이미 상당히 큰 효과입니다.

2장: 캐시 전략 설정 - 정적 콘텐츠 가속

캐시는 성능 튜닝에서 가장 직접적인 효과가 나는 영역입니다. 제대로 설정하면 요청의 95%를 Nginx가 바로 반환할 수 있고, 백엔드까지 갈 필요가 없습니다. 제가 본 많은 시스템은 백엔드 서버가 힘겹게 버티고 있는데도 Nginx 쪽 캐시가 거의 작동하지 않았습니다. 캐시를 안 켠 것이 아니라 잘못 설정한 경우가 많았습니다.

2.1 proxy_cache와 fastcgi_cache는 어떻게 고를까?

Nginx는 두 가지 캐시 메커니즘을 제공합니다.

  • proxy_cache: 업스트림 서버의 응답을 캐시합니다. Node.js, Python, Go 서비스 같은 리버스 프록시 환경에 적합합니다.
  • fastcgi_cache: FastCGI 프로세스의 응답을 캐시합니다. PHP-FPM 환경에 적합합니다.

어느 것을 선택할지는 백엔드 기술 스택에 달려 있습니다. PHP를 쓴다면 fastcgi_cache를, Node.js, Python, Go라면 proxy_cache를 쓰면 됩니다. 두 방식의 설정 논리는 거의 같습니다. 아래에서는 proxy_cache를 예로 설명하겠습니다.

2.2 완전한 proxy_cache 설정

먼저 http 블록에 캐시 경로를 정의합니다.

http {
    proxy_cache_path /var/cache/nginx
                     levels=1:2
                     keys_zone=my_cache:10m
                     max_size=10g
                     inactive=60m
                     use_temp_path=off;
}

한 줄씩 설명하면 다음과 같습니다.

  • levels=1:2: 캐시 디렉터리 계층입니다. 1:2는 두 단계 디렉터리 구조를 의미하며, 단일 디렉터리에 파일이 너무 많아지는 것을 방지합니다.
  • keys_zone=my_cache:10m: 캐시 영역 이름과 메타데이터 메모리 크기입니다. 10m이면 약 8만 개의 캐시 키를 저장할 수 있습니다.
  • max_size=10g: 전체 캐시 크기 상한입니다. 초과하면 LRU 알고리즘에 따라 제거됩니다.
  • inactive=60m: 60분 동안 접근되지 않은 캐시는 정리됩니다.
  • use_temp_path=off: 임시 파일 이동 비용을 피하기 위해 캐시 디렉터리에 직접 씁니다.

그다음 server 또는 location에서 활성화합니다.

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://backend;
        proxy_cache my_cache;

        # 캐시 유효 기간 설정
        proxy_cache_valid 200 302 10m;
        proxy_cache_valid 404 1m;
        proxy_cache_valid any 1m;

        # 캐시 키 설계
        proxy_cache_key $scheme$request_method$host$request_uri;

        # 장애 대응 전략
        proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;

        # 응답 헤더에 캐시 상태 추가(디버깅용)
        add_header X-Cache-Status $upstream_cache_status;
    }
}

proxy_cache_valid가 핵심 설정입니다. 상태 코드별 캐시 시간을 정의합니다.

  • 200 302 10m: 정상 응답을 10분 캐시합니다.
  • 404 1m: 404 오류를 1분 캐시해 악성 요청이 백엔드를 계속 때리는 상황을 피합니다.
  • any 1m: 기타 상태 코드를 1분 캐시합니다.

2.3 캐시 키 설계와 무효화 전략

캐시 키 proxy_cache_key는 어떤 요청을 “같은 요청”으로 볼지 결정합니다. 기본값은 $scheme$proxy_host$request_uri이지만, 저는 명시적으로 선언하는 것을 권장합니다.

proxy_cache_key $scheme$request_method$host$request_uri;

이렇게 하면 캐시 키에 프로토콜, 요청 메서드, 호스트명, 전체 URI가 포함됩니다. 사이트가 GET과 POST를 함께 지원하거나 여러 도메인을 사용한다면 이 설정이 더 정확합니다.

캐시 무효화는 골치 아픈 문제입니다. 흔한 전략은 다음과 같습니다.

  1. 시간 만료: proxy_cache_valid로 시간을 설정하고 만료되면 자동 무효화합니다.
  2. 능동 우회: proxy_cache_bypass로 캐시를 우회합니다.
  3. 캐시 삭제: 상용 버전 Nginx Plus에는 proxy_cache_purge가 있습니다.

저는 보통 두 번째 방식을 사용합니다. 요청 헤더로 제어하는 방식입니다.

# 특정 요청 헤더로 캐시 우회
proxy_cache_bypass $http_x_nocache;

# 또는 특정 파라미터로 우회
proxy_cache_bypass $arg_nocache;

캐시를 새로고침해야 할 때 ?nocache=1을 붙이거나 요청 헤더 X-Nocache: 1을 넣으면 됩니다.

2.4 장애 대응 전략: 백엔드가 내려가도 서비스하기

proxy_cache_use_stale 설정은 매우 실용적입니다. 백엔드 서비스에 오류가 나거나 타임아웃이 발생했을 때, Nginx가 바로 오류를 반환하는 대신 만료된 캐시 콘텐츠를 반환할 수 있습니다.

proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;

작년 광군제 때 백엔드 서비스 확장 과정에서 문제가 생겨 API 서비스가 간헐적으로 502를 반환한 적이 있습니다. 다행히 캐시에 이 장애 대응 전략을 설정해 두었기 때문에 사용자 접근은 거의 영향을 받지 않았습니다. 캐시는 몇 분 정도 만료된 상태였지만 콘텐츠는 정상적으로 반환되었습니다. 백엔드가 복구된 뒤 캐시는 자동으로 갱신되었습니다.

실측 데이터 비교는 다음과 같습니다.

지표캐시 없음캐시 적중장애 대응 모드
응답 시간150-200ms5-10ms5-10ms
백엔드 QPS1000500
사용자 경험정상정상약간 느림

캐시가 적중하면 응답 시간은 200ms에서 5-10ms로 내려갑니다. 거의 20배 빨라집니다. 효과가 아주 직관적입니다.

2.5 마이크로 캐시: 동적 콘텐츠도 가속하기

많은 사람이 동적 콘텐츠는 캐시할 수 없다고 생각하지만, 마이크로 캐싱(microcaching)을 사용할 수 있습니다. 동적 페이지를 1-5초만 캐시하는 방식입니다. 고동시성 순간에도 백엔드 부하를 크게 줄일 수 있습니다.

저는 한 이커머스 홈 화면에서 이 방법을 써 봤습니다. 홈 화면에는 실시간 추천과 재고 표시가 있어 완전히 동적이었습니다. 하지만 대형 프로모션 기간에 트래픽이 급증하면서 백엔드가 버티지 못했습니다. 그래서 5초 마이크로 캐시를 추가했습니다.

proxy_cache_path /var/cache/nginx/micro levels=1:2 keys_zone=micro:10m max_size=1g;

location / {
    proxy_cache micro;
    proxy_cache_valid 200 5s;  # 5초만 캐시
    proxy_cache_lock on;        # 캐시 갱신 중 동시 요청 관통 방지
    proxy_cache_background_update on;  # 백그라운드에서 비동기 캐시 갱신
}

proxy_cache_lock on은 매우 중요합니다. 캐시가 만료되면 첫 번째 요청만 백엔드로 새 데이터를 가져가고, 다른 요청은 기존 캐시를 기다리거나 사용합니다. 이렇게 하면 “캐시 만료 순간에 대량 요청이 동시에 백엔드로 몰리는” 상황을 막을 수 있습니다.

효과는 놀라웠습니다. 홈 화면 TTFB는 800ms에서 약 5ms로 떨어졌고, 백엔드 QPS는 2000에서 400으로 낮아졌습니다. 5초 캐시 지연은 사용자가 거의 느끼지 못하지만 서버 부하는 크게 줄어듭니다.

2.6 조건부 요청: 대역폭을 아끼는 기법

proxy_cache_revalidate는 Nginx가 조건부 요청(If-Modified-Since / If-None-Match)으로 백엔드에 캐시 만료 여부를 확인하게 합니다. 백엔드가 304 Not Modified를 반환하면 전체 콘텐츠를 다시 전송할 필요 없이 캐시 메타데이터만 갱신합니다.

proxy_cache_revalidate on;

이 설정은 대역폭에 민감한 환경에서 특히 유용합니다. 예를 들어 백엔드가 큰 파일을 반환하지만 콘텐츠가 자주 바뀌지 않는다면, 조건부 요청으로 대량의 데이터 전송을 줄일 수 있습니다.

3장: 연결 풀 설정 - 고동시성 환경의 필수 요소

gzip과 캐시는 “어떻게 더 빠르게 전송할 것인가”를 해결합니다. 연결 풀은 “어떻게 더 많은 요청을 감당할 것인가”를 해결합니다. 기본 설정에서 단일 Nginx worker의 최대 동시 연결은 1024개뿐입니다. 트래픽이 올라오면 곧바로 부족해집니다.

3.1 worker_connections: 한도를 정확히 계산하기

최대 동시 연결 수 계산 공식은 다음과 같습니다.

최대 동시성 = worker_processes × worker_connections

서버가 8코어 CPU이고 worker_processes를 8 또는 auto로 설정했으며 worker_connections를 4096으로 설정했다고 가정해 봅시다.

최대 동시성 = 8 × 4096 = 32768

이 숫자는 커 보이지만 기억해야 할 점이 있습니다. 각 요청은 보통 두 개의 연결을 사용합니다. 클라이언트에서 Nginx로 하나, Nginx에서 백엔드로 하나입니다. 따라서 실제로 처리 가능한 동시 요청은 대략 이 숫자의 절반 정도입니다.

설정은 events 블록에 둡니다.

events {
    worker_connections 4096;
    use epoll;
    multi_accept on;
}

use epoll은 Linux에서 기본값이므로 명시적으로 쓰지 않아도 되지만, 적어 두면 더 분명합니다. multi_accept on은 worker가 여러 새 연결을 동시에 받게 해 고동시성 환경에서 연결 대기를 줄여 줍니다.

3.2 클라이언트 keepalive: 연결 재사용으로 오버헤드 줄이기

TCP 연결을 만들려면 3-way handshake가 필요하고, 이 비용은 작지 않습니다. keepalive는 클라이언트와 Nginx 사이의 연결을 재사용하게 해 매 요청마다 새 연결을 만들 필요가 없게 합니다.

http {
    keepalive_timeout 65;
    keepalive_requests 1000;
}

keepalive_timeout 65: 연결을 65초 동안 유지하고, 이후 닫습니다. 이 값이 너무 크면 서버 리소스를 많이 차지하고, 너무 작으면 재사용 효과가 약합니다. 60-75초가 합리적인 범위입니다.

keepalive_requests 1000: 단일 연결에서 최대 1000개의 요청을 처리합니다. 너무 작게 설정하면 연결이 자주 끊기고, 너무 크게 설정하면 리소스 누수가 생길 수 있습니다. 1000은 제가 테스트해 본 값 중 비교적 안정적이었습니다.

keepalive_time이라는 새 파라미터도 있습니다. 단일 연결의 전체 생존 시간을 제어합니다. 처리한 요청 수와 관계없이 적용됩니다. 서버가 오래 실행되면서 연결 누적으로 리소스 문제가 생길 수 있다면 다음처럼 추가할 수 있습니다.

keepalive_time 1h;  # 단일 연결은 최대 1시간 생존

3.3 upstream keepalive: 백엔드 연결 풀

이 설정은 모르는 사람이 많지만 효과는 분명합니다. Nginx와 백엔드 서비스 사이에서도 연결을 재사용할 수 있어 TCP를 자주 새로 만드는 비용을 줄일 수 있습니다.

upstream backend {
    server 127.0.0.1:8080;
    server 127.0.0.1:8081;

    keepalive 64;
    keepalive_timeout 60s;
    keepalive_requests 1000;
}

server {
    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

keepalive 64: 64개의 유휴 연결 풀을 유지합니다. 이 값은 백엔드 서비스 수에 따라 조정합니다. 일반적으로 백엔드 서버 수의 4-8배로 설정합니다.

proxy_http_version 1.1과 proxy_set_header Connection "" 두 줄은 반드시 추가해야 합니다. HTTP/1.1은 기본적으로 keepalive를 지원하고, Connection 헤더를 비워야 연결을 재사용할 수 있습니다. 이 두 줄을 쓰지 않으면 upstream keepalive는 적용되지 않습니다.

실측 효과 비교는 다음과 같습니다.

설정연결 생성 횟수/분CPU 오버헤드추천 상황
upstream keepalive 없음6000높음저트래픽
keepalive 323000중간중간 트래픽
keepalive 641500낮음고트래픽

upstream keepalive를 추가하면 연결 생성 비용이 바로 50% 줄어듭니다. 이 설정은 고동시성 환경에서 특히 중요합니다.

3.4 파일 디스크립터 제한: 시스템 계층을 잊지 말기

Nginx의 연결 수는 시스템의 파일 디스크립터 한도에도 영향을 받습니다. worker_connections를 4096으로 설정했더라도 시스템이 프로세스당 1024개 파일만 열 수 있게 제한되어 있다면 여전히 부족합니다.

현재 제한 확인:

ulimit -n

반환값이 65536보다 작다면 시스템 설정에서 높여야 합니다. /etc/security/limits.conf를 편집합니다.

* soft nofile 65536
* hard nofile 65536

그리고 Nginx 설정에도 선언합니다.

worker_rlimit_nofile 65536;

이 설정은 main 블록, 즉 worker_processes와 같은 레벨에 둡니다. Nginx가 시작할 때 충분한 파일 디스크립터를 요청하게 합니다.

3.5 추천 파라미터 빠른 참고표

다양한 상황에 맞춘 추천 설정을 정리하면 다음과 같습니다.

파라미터저트래픽(<1000 QPS)중간 트래픽(1000-5000 QPS)고트래픽(>5000 QPS)
worker_processesautoautoauto
worker_connections102420484096
keepalive_timeout606575
keepalive_requests1005001000
upstream keepalive163264
worker_rlimit_nofile4096819265536

이것은 출발점일 뿐입니다. 실제 조정은 부하 테스트 데이터와 함께 봐야 합니다. 저는 보통 wrk 또는 ab로 부하 테스트를 수행하고, 연결 수와 응답 시간 변화 곡선을 보면서 최적값을 찾습니다.

4장: 고급 최적화 - Thread Pools와 reuseport

앞의 세 장은 대부분의 상황에 필요한 최적화를 다룹니다. 트래픽이 매우 크거나, 단일 서버 RPS 목표가 50K를 넘거나, 지연 시간에 특히 민감하다면 여기서 소개하는 두 가지 고급 설정을 시도해 볼 수 있습니다.

4.1 Thread Pools: sendfile 병목 넘어서기

Nginx의 기본 모델은 단일 스레드 이벤트 드리븐입니다. 대부분의 상황에서는 충분히 효율적입니다. 하지만 고동시성 정적 파일 서비스에는 숨은 병목이 있습니다. sendfile은 제로 카피이긴 하지만 파일 읽기와 socket 쓰기가 같은 worker 스레드에서 완료됩니다. 디스크 IO가 느리면 전체 worker가 막힐 수 있습니다.

Thread Pools는 이 문제를 해결합니다. 파일 읽기와 전송을 독립 스레드 풀로 분리하고, worker는 스케줄링만 담당하므로 IO에서 막히지 않습니다.

Nginx 공식 블로그에는 단일 서버에서 9배 성능 향상을 보인 테스트 사례가 있습니다. 테스트 환경은 1MB 파일 다운로드였고, 원래는 디스크 IO 제한을 받았지만 Thread Pools를 추가한 뒤 병목을 직접 넘어섰습니다.

설정 방법은 다음과 같습니다.

http {
    thread_pool default threads=32 max_queue=65536;
    aio threads=default;
    sendfile_max_chunk 512k;
}

threads=32는 스레드 풀에 32개 스레드를 둔다는 뜻이고, max_queue=65536은 최대 대기 작업 수입니다. aio threads=default는 비동기 IO를 활성화하고 사용할 스레드 풀을 지정합니다. sendfile_max_chunk 512k는 한 번에 전송할 데이터 블록 크기를 제어해 큰 파일이 너무 오래 점유하지 않게 합니다.

다만 Thread Pools가 만능은 아닙니다. 백엔드가 동적 콘텐츠 중심, 예를 들어 API 서비스라면 디스크 IO가 병목이 아닐 수 있습니다. 이 경우 오히려 컨텍스트 스위칭 비용이 늘어날 수 있습니다. 제 권장안은 정적 파일 서비스나 대용량 파일 다운로드 환경에서만 켜는 것입니다.

4.2 Socket Sharding(reuseport): 연결 지연 낮추기

Nginx 1.9.1에는 reuseport 파라미터가 도입되었습니다. 여러 worker가 같은 포트를 독립적으로 listen할 수 있게 해 worker 간 락 경쟁을 피합니다.

전통적인 방식에서는 모든 worker가 하나의 listen socket을 공유하고, 새 연결이 들어올 때 accept mutex를 두고 경쟁합니다. 고동시성 상황에서는 이 락 경쟁 비용이 지연 시간 흔들림으로 드러납니다.

reuseport를 추가하면 다음과 같습니다.

server {
    listen 80 reuseport;
}

각 worker가 독립적인 listen socket을 갖고, 커널이 새 연결을 서로 다른 worker에 자동으로 분배합니다. 락 경쟁이 완전히 사라집니다.

실측 데이터(Nginx 공식 블로그 기준)는 다음과 같습니다.

지표reuseport 없음reuseport 있음
평균 지연15.65ms12.35ms
지연 표준편차3.5ms1.2ms
연결 분포불균등균등

지연 시간이 21% 낮아지고, 더 중요한 것은 지연 시간 흔들림이 눈에 띄게 줄어든다는 점입니다. 이 설정은 고동시성 환경, 예를 들어 QPS가 20K를 넘는 경우에 효과가 가장 뚜렷합니다. 저트래픽 환경에서는 체감이 크지 않을 수 있습니다.

4.3 open_file_cache: 파일 디스크립터 캐시

정적 파일 서비스 환경에서는 open_file_cache로 파일 디스크립터와 메타데이터를 캐시할 수 있습니다. 매 요청마다 디스크를 조회하지 않아도 됩니다.

http {
    open_file_cache max=10000 inactive=30s;
    open_file_cache_valid 60s;
    open_file_cache_min_uses 2;
    open_file_cache_errors on;
}
  • max=10000: 최대 10000개 파일 정보를 캐시합니다.
  • inactive=30s: 30초 동안 접근이 없으면 정리합니다.
  • open_file_cache_valid 60s: 60초마다 캐시 만료 여부를 확인합니다.
  • open_file_cache_errors on: 파일 없음 같은 오류 상태도 캐시합니다.

이 설정은 정적 사이트에 효과가 큽니다. 하지만 동적 콘텐츠 환경에서는 권장하지 않습니다. 파일이 자주 바뀔 수 있고, 캐시 때문에 콘텐츠가 갱신되지 않는 문제가 생길 수 있습니다.

5장: 통합 설정 템플릿 - 운영 환경에서 바로 쓰기

앞의 네 장에서는 원리를 설명했습니다. 여기서는 통합된 설정 템플릿을 바로 제공합니다. 실제 상황에 맞게 파라미터를 조정하면 되지만, 전체 틀은 범용적으로 사용할 수 있습니다.

# nginx.conf 운영 환경 템플릿(고트래픽 상황)

user nginx;
worker_processes auto;
worker_rlimit_nofile 65536;

events {
    worker_connections 4096;
    use epoll;
    multi_accept on;
}

http {
    # gzip 압축 설정
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 1000;
    gzip_types text/plain text/css text/xml text/javascript
               application/json application/javascript application/xml
               application/xml+rss application/xhtml+xml;
    gzip_disable "msie6";

    # 캐시 경로 설정
    proxy_cache_path /var/cache/nginx
                     levels=1:2
                     keys_zone=my_cache:10m
                     max_size=10g
                     inactive=60m
                     use_temp_path=off;

    # 클라이언트 연결 설정
    keepalive_timeout 65;
    keepalive_requests 1000;
    keepalive_time 1h;

    # 파일 캐시(정적 사이트 선택 사항)
    open_file_cache max=10000 inactive=30s;
    open_file_cache_valid 60s;
    open_file_cache_min_uses 2;

    # 백엔드 서버 그룹
    upstream backend {
        server 127.0.0.1:8080;
        server 127.0.0.1:8081;
        keepalive 64;
        keepalive_timeout 60s;
        keepalive_requests 1000;
    }

    server {
        listen 80 reuseport;
        server_name example.com;

        location / {
            proxy_pass http://backend;
            proxy_http_version 1.1;
            proxy_set_header Connection "";

            # 캐시 설정
            proxy_cache my_cache;
            proxy_cache_valid 200 302 10m;
            proxy_cache_valid 404 1m;
            proxy_cache_key $scheme$request_method$host$request_uri;
            proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
            proxy_cache_revalidate on;

            # 디버깅용 응답 헤더
            add_header X-Cache-Status $upstream_cache_status;
        }
    }
}

세 가지 상황별 차별화 설정

이커머스 사이트: 홈 화면과 상품 상세 페이지가 자주 바뀌므로 캐시 시간을 짧게 둡니다. 10-15분이면 충분합니다. 데이터베이스 조회가 많고 백엔드 부하가 크므로 upstream keepalive는 크게 잡아야 합니다. 대형 프로모션 기간에는 마이크로 캐시를 켜고 1-3초만 캐시해도 좋습니다.

API 서비스: 데이터 실시간성이 중요하므로 proxy_cache_valid가 1-5분 정도에 불과할 수 있습니다. gzip은 JSON에 효과가 좋으므로 반드시 추가하세요. Brotli를 설치할 수 있다면 설치하는 것이 좋습니다. API 응답 크기가 작더라도 요청이 잦기 때문에 압축 이득이 큽니다.

정적 사이트: HTML, CSS, JS가 거의 바뀌지 않으므로 캐시를 1시간 이상으로 설정해도 됩니다. 텍스트가 많은 정적 파일은 gzip 압축 이득이 가장 큽니다. open_file_cache와 Thread Pools를 켜면 파일 디스크립터 캐시와 비동기 IO가 정적 콘텐츠에 확실한 도움을 줍니다.

성능 향상 비교(업데이트 버전)

설정 항목최적화 전최적화 후향상 폭
HTML 전송 크기(gzip)100KB25KB75% ↓
HTML 전송 크기(Brotli)100KB18KB82% ↓
API 응답 시간(캐시 적중)200ms8ms96% ↓
동시 연결 능력100040004x ↑
RPS(단일 서버 최적화 후)10K50K-80K5-8x ↑
TTFB(reuseport)15.65ms12.35ms21% ↓

이 데이터는 제가 직접 측정한 결과와 공식 문서를 종합한 것입니다. 실제 효과는 서버 구성, 네트워크 환경, 비즈니스 특성에 따라 달라집니다. 부하 테스트 검증이 중요합니다.

6장: 자주 묻는 문제와 문제 해결

제가 자주 겪었던 문제 몇 가지를 바로 해결책과 함께 정리합니다.

Q: gzip 압축이 적용되지 않고 응답 헤더에 Content-Encoding: gzip이 없습니다

세 가지를 확인하세요.

  1. gzip on이 올바른 설정 레벨, 즉 http 블록에 있는지
  2. gzip_types에 응답 MIME 타입이 포함되어 있는지
  3. 응답 크기가 gzip_min_length보다 큰지

curl로 테스트합니다. curl -H "Accept-Encoding: gzip" -I http://your-site.com

Q: 캐시 적중률이 낮고 X-Cache-Status가 대부분 MISS입니다

흔한 원인은 다음과 같습니다.

  • 캐시 키 설계가 부적절해 모든 요청이 “서로 다른 요청”으로 처리됨
  • proxy_cache_valid가 너무 짧아 캐시가 사용되기 전에 만료됨
  • 백엔드 응답 헤더에 Cache-Control: no-cache 또는 Set-Cookie가 있음

응답 헤더를 확인해 캐시를 금지하는 지시어가 없는지 확인하세요.

Q: worker_connections가 부족해 502 오류가 발생합니다

Nginx 오류 로그를 보세요. worker_connections are not enough가 있다면 동시성이 한도를 넘었다는 뜻입니다.

해결 방법:

  1. worker_connections 값을 높입니다.
  2. 연결 누수가 있는지 확인합니다. keepalive 설정이 부적절할 수 있습니다.
  3. 서버 수를 늘려 로드 밸런싱하는 방안을 고려합니다.

Q: gzip과 sendfile은 충돌하나요?

충돌하지 않습니다. gzip은 응답 콘텐츠를 압축하고, sendfile은 파일 전송 방식을 처리합니다. 둘은 동시에 활성화할 수 있습니다. 주의할 점은 하나입니다. gzip은 동적 콘텐츠를 메모리에서 압축하므로 sendfile 경로를 타지 않습니다. 사전 압축된 정적 파일은 sendfile로 바로 전송할 수 있습니다.

Q: upstream keepalive가 적용되지 않습니다

흔한 원인은 두 가지입니다.

  1. proxy_http_version 1.1을 빠뜨렸습니다. HTTP/1.0은 기본적으로 keepalive를 지원하지 않습니다.
  2. proxy_set_header Connection ""을 빠뜨렸습니다. 이 헤더를 비우지 않으면 연결이 닫힙니다.

이 두 줄은 upstream 블록이 아니라 location 블록에 반드시 추가해야 합니다.

Q: reuseport에서 “duplicate listen options” 오류가 납니다

reuseport는 listen 줄에 한 번만 선언할 수 있으며 다른 곳에서 반복하면 안 됩니다. 각 server 블록의 listen 줄에 reuseport가 최대 한 번만 들어가는지 확인하세요. 여러 server가 같은 포트를 listen한다면 각자 독립적으로 reuseport를 추가해야 합니다.

Q: 메모리 사용량이 너무 높고 서버가 자주 OOM이 됩니다

가능한 원인은 다음과 같습니다.

  • proxy_cache_path의 keys_zone을 너무 크게 설정함
  • 캐시 파일이 너무 많아 메모리 매핑 사용량이 높음
  • keepalive 연결 풀이 너무 커서 유휴 연결이 리소스를 차지함

이 파라미터들을 적절히 낮추거나 서버 메모리를 늘리세요.


마치며

길게 설명했지만, Nginx 성능 튜닝의 핵심은 사실 세 가지입니다. 압축, 캐시, 연결 풀입니다. 여기에 Brotli, 마이크로 캐시, Thread Pools, reuseport 같은 고급 기법을 몇 가지 더하면 단일 서버 RPS를 10K에서 50K-80K까지 끌어올릴 수 있습니다.

튜닝은 한 번 하고 끝나는 일이 아닙니다. 저는 다음 순서를 권장합니다.

  1. 먼저 gzip 켜기: 변경이 가장 작고 효과가 가장 직접적이며, 10분이면 끝납니다.
  2. 그다음 캐시 설정하기: 비즈니스 상황에 맞춰 캐시 전략을 설계합니다. 하루 안에 완료할 수 있습니다.
  3. 이후 연결 풀 조정하기: 부하 테스트 검증이 필요하며, 트래픽이 안정된 뒤 진행하는 것이 좋습니다.
  4. 마지막으로 고급 최적화 시도하기: Thread Pools와 reuseport는 트래픽이 매우 큰 경우에만 고려합니다.

매번 변경한 뒤에는 반드시 부하 테스트로 효과를 검증하세요. wrk나 ab 모두 사용할 수 있습니다. 응답 시간, QPS, 오류율 변화를 확인하세요. 감으로 조정하지 말고 데이터로 이야기해야 합니다.

마지막 점검 목록은 다음과 같습니다.

  • gzip이 켜져 있고 MIME 타입 설정이 완전함
  • gzip_comp_level이 4-6으로 설정되어 CPU와 압축률의 균형을 맞춤
  • Brotli가 설정됨(사용 가능한 경우)
  • proxy_cache_path가 설정되어 있고 캐시 크기가 합리적임
  • proxy_cache_valid가 비즈니스 상황에 맞게 설정됨
  • 마이크로 캐시가 켜져 있음(고동시성 동적 콘텐츠 상황)
  • proxy_cache_use_stale 장애 대응 전략이 설정됨
  • worker_connections가 4096 이상으로 설정됨
  • keepalive_timeout이 60-75초로 설정됨
  • upstream keepalive가 설정됨(HTTP/1.1과 Connection 헤더 포함)
  • reuseport가 켜져 있음(고트래픽 상황)
  • 파일 디스크립터 제한이 65536으로 상향됨
  • 디버깅을 위해 응답 헤더에 X-Cache-Status가 포함됨

대략 이 정도입니다. 문제가 있으면 댓글로 남겨 주세요. 최대한 답변하겠습니다.

FAQ

gzip 압축이 적용되지 않고 응답 헤더에 Content-Encoding: gzip이 없으면 어떻게 해야 하나요?
세 가지를 확인하세요. 1) gzip on이 http 블록에 있는지, 2) gzip_types에 응답 MIME 타입이 포함되어 있는지, 3) 응답 본문 크기가 gzip_min_length보다 큰지 확인합니다. curl -H "Accept-Encoding: gzip" -I 명령으로 테스트해 검증할 수 있습니다.
캐시 적중률이 낮고 X-Cache-Status가 대부분 MISS라면 어떻게 점검해야 하나요?
흔한 원인은 캐시 키 설계가 부적절하거나, proxy_cache_valid가 너무 짧거나, 백엔드가 Cache-Control: no-cache 또는 Set-Cookie를 반환하는 경우입니다. 응답 헤더를 확인해 캐시 금지 지시어가 없는지 보세요.
worker_connections가 부족해 502 오류가 발생하면 어떻게 해결하나요?
Nginx 오류 로그를 확인하세요. "worker_connections are not enough"가 있다면 동시 접속 한도를 초과한 것입니다. 해결 방법은 worker_connections 값을 높이고, 연결 누수가 있는지 확인하거나, 서버를 늘려 로드 밸런싱하는 것입니다.
upstream keepalive가 적용되지 않는 흔한 원인은 무엇인가요?
두 가지 실수가 많습니다. 1) proxy_http_version 1.1을 빠뜨린 경우입니다. HTTP/1.0은 기본적으로 keepalive를 지원하지 않습니다. 2) proxy_set_header Connection ""를 빠뜨린 경우입니다. 이 두 줄은 반드시 location 블록에 추가해야 합니다.
Brotli와 gzip은 어떻게 선택해야 하나요?
Brotli는 gzip보다 압축률이 15-25% 높지만 추가 모듈 설치가 필요합니다. 동적 콘텐츠에는 Brotli level 4-6을, 정적 리소스에는 사전 압축 level 11을 사용하세요. Nginx 컴파일이 번거롭다면 gzip level 6만으로도 충분하며 약 75% 압축률을 얻을 수 있습니다.
트래픽 규모별 추천 설정 값은 무엇인가요?
저트래픽(<1000 QPS): worker_connections 1024, keepalive 16; 중간 트래픽(1000-5000 QPS): 2048, keepalive 32; 고트래픽(>5000 QPS): 4096, keepalive 64. 실제 값은 부하 테스트 데이터를 바탕으로 조정해야 합니다.

4분 읽기 · 게시일: 2026년 5월 15일 · 수정일: 2026년 10월 21일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog