본문 바로가기
개발 팁

Nginx 실전 설정 — 리버스 프록시, HTTPS, 캐싱 완전 정복

by 루까(Luka) 2026. 3. 20.
반응형

왜 이 글을 쓰나

서버 배포를 처음 해보는 사람들이 가장 많이 막히는 지점 중 하나가 Nginx 설정이다.

Node.js나 Python으로 앱을 만들었는데 외부에서 접속이 안 된다. HTTPS를 붙이려고 했더니 인증서 설정이 어디서 뭘 해야 하는지 모르겠다. 정적 파일 서빙이 느리다. 이런 문제들이 대부분 Nginx 설정 몇 줄로 해결된다.

Apache를 쓰다가 Nginx로 넘어온 케이스도 비슷하다. .htaccess 방식에 익숙한 상태에서 Nginx의 server block 방식이 낯설게 느껴진다. 설정 파일 구조 자체가 다르니까.

이 글은 Nginx를 처음 쓰는 사람도, Apache에서 넘어온 사람도 실무에서 바로 쓸 수 있도록 구조 이해부터 실전 설정까지 순서대로 정리한다.


Nginx 기본 구조 이해

설정 파일 위치

Ubuntu 기준으로 Nginx 설정 파일은 다음 위치에 있다.

/etc/nginx/
├── nginx.conf              # 메인 설정 파일
├── sites-available/        # 사용 가능한 가상 호스트 설정
│   ├── default
│   └── myapp.conf
├── sites-enabled/          # 실제 활성화된 설정 (심볼릭 링크)
│   └── myapp.conf -> ../sites-available/myapp.conf
├── conf.d/                 # 추가 설정 파일 디렉토리
└── snippets/               # 재사용 가능한 설정 조각

sites-available에 설정 파일을 만들고, sites-enabled에 심볼릭 링크를 걸어서 활성화한다. 설정을 비활성화하고 싶을 때는 링크만 지우면 된다.

# 활성화
sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/

# 설정 문법 검증
sudo nginx -t

# 설정 재로드 (무중단)
sudo systemctl reload nginx

nginx.conf 기본 구조

# /etc/nginx/nginx.conf

user www-data;
worker_processes auto;          # CPU 코어 수에 맞게 자동 설정
pid /run/nginx.pid;

events {
    worker_connections 1024;    # 워커당 최대 연결 수
    multi_accept on;
}

http {
    sendfile on;
    tcp_nopush on;
    types_hash_max_size 2048;

    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # 로그
    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;

    # Gzip 압축
    gzip on;

    # 가상 호스트 설정 포함
    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}

worker_processes auto는 대부분의 경우 그대로 두면 된다. Nginx가 CPU 코어 수를 감지해서 알아서 설정한다.


정적 파일 서빙 기본 설정

가장 단순한 형태의 서버 블록이다.

# /etc/nginx/sites-available/mysite.conf

server {
    listen 80;
    server_name example.com www.example.com;

    root /var/www/mysite;       # 정적 파일 루트 디렉토리
    index index.html index.htm;

    location / {
        try_files $uri $uri/ =404;
        # $uri: 요청 경로 그대로
        # $uri/: 디렉토리로 시도
        # =404: 없으면 404
    }

    # 특정 경로 별도 처리
    location /images/ {
        root /var/www/mysite;
        expires 30d;            # 이미지는 30일 캐시
    }

    # 에러 페이지
    error_page 404 /404.html;
    error_page 500 502 503 504 /50x.html;
}

React, Vue, Next.js 같은 SPA를 배포할 때는 try_files를 약간 바꿔야 한다.

location / {
    try_files $uri $uri/ /index.html;
    # 파일이 없으면 index.html로 넘겨서 프론트엔드 라우터가 처리하게
}

리버스 프록시 설정

Node.js, Python(Django/FastAPI), Java 앱 앞에 Nginx를 두는 구성이다. 클라이언트는 443(HTTPS)으로 Nginx에 연결하고, Nginx는 내부적으로 앱 서버(예: 3000번 포트)로 요청을 전달한다.

Client → Nginx (80/443) → Node.js App (3000)
                        → Python App (8000)

기본 리버스 프록시

server {
    listen 80;
    server_name api.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;

        # 클라이언트 정보를 앱 서버로 전달
        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_connect_timeout  60s;
        proxy_send_timeout     60s;
        proxy_read_timeout     60s;
    }
}

X-Real-IPX-Forwarded-For를 설정해야 Node.js 앱에서 req.ip로 클라이언트 실제 IP를 가져올 수 있다. 이걸 안 하면 앱 서버 로그에 모든 요청 IP가 127.0.0.1로 찍힌다.

Express에서는 app.set('trust proxy', 1)도 같이 설정해야 req.ip가 제대로 동작한다.

WebSocket 지원

location /socket.io/ {
    proxy_pass http://127.0.0.1:3000;

    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host       $host;

    proxy_read_timeout 3600s;   # WebSocket은 연결이 길게 유지됨
}

HTTPS 설정 (Let's Encrypt + Certbot)

무료 SSL 인증서를 발급받고 자동 갱신까지 설정하는 방법이다.

Certbot 설치 및 인증서 발급

# Certbot 설치
sudo apt update
sudo apt install certbot python3-certbot-nginx

# 인증서 발급 (Nginx 플러그인 사용)
sudo certbot --nginx -d example.com -d www.example.com

# 발급 후 자동 갱신 테스트
sudo certbot renew --dry-run

Certbot이 Nginx 설정 파일을 자동으로 수정해서 HTTPS 설정을 추가해준다. 자동으로 수정된 결과를 확인하고 이해해두는 것이 좋다.

수동 SSL 설정

자동 설정에 의존하지 않고 직접 제어하고 싶다면 아래처럼 작성한다.

# HTTP → HTTPS 리다이렉트
server {
    listen 80;
    server_name example.com www.example.com;

    # Let's Encrypt 갱신용 경로는 HTTP로 허용
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# HTTPS 메인 서버
server {
    listen 443 ssl;
    server_name example.com www.example.com;

    # SSL 인증서
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # SSL 프로토콜 및 암호화 설정
    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;

    # SSL 세션 캐시
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;

    # HSTS (이미 HTTPS를 쓰고 있다면 추가)
    add_header Strict-Transport-Security "max-age=63072000" always;

    # OCSP Stapling
    ssl_stapling        on;
    ssl_stapling_verify on;
    resolver 8.8.8.8 8.8.4.4 valid=300s;

    root /var/www/mysite;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

인증서 자동 갱신

Certbot은 설치 시 자동으로 systemd 타이머를 등록한다. 별도로 설정할 필요는 없지만 확인은 해두는 게 좋다.

# 타이머 상태 확인
sudo systemctl status certbot.timer

# 타이머 목록
sudo systemctl list-timers | grep certbot

cron을 선호한다면 직접 등록할 수도 있다.

# crontab -e
0 0 * * * certbot renew --quiet && systemctl reload nginx

Gzip 압축 설정

텍스트 기반 응답을 압축해서 전송 용량을 줄인다. 체감 속도 향상 효과가 크다.

# nginx.conf 또는 server block 내

gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;              # 1(빠름, 압축률 낮음) ~ 9(느림, 압축률 높음). 6이 적당
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;            # 256바이트 미만은 압축하지 않음

gzip_types
    text/plain
    text/css
    text/xml
    text/javascript
    application/javascript
    application/x-javascript
    application/json
    application/xml
    application/rss+xml
    application/atom+xml
    image/svg+xml
    font/ttf
    font/opentype
    application/vnd.ms-fontobject;

gzip_vary on은 프록시 서버가 압축 버전과 비압축 버전을 별도로 캐시하도록 Vary: Accept-Encoding 헤더를 추가한다. CDN을 쓴다면 반드시 켜야 한다.

이미지(jpeg, png, webp)는 이미 압축된 포맷이라 gzip_types에 넣어도 의미가 없다.


캐싱 설정

정적 파일 브라우저 캐싱

server {
    # ...

    # 이미지, 폰트 — 장기 캐시
    location ~* \.(jpg|jpeg|png|gif|ico|webp|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;         # 정적 파일 접근 로그 끄기
    }

    # CSS, JS — 중기 캐시 (해시가 파일명에 포함된 경우 장기 가능)
    location ~* \.(css|js)$ {
        expires 7d;
        add_header Cache-Control "public";
    }

    # HTML — 캐시하지 않음 (항상 최신 버전 확인)
    location ~* \.html$ {
        expires -1;
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }
}

빌드 도구(Webpack, Vite)가 CSS/JS 파일명에 해시를 붙여준다면(main.a3b2c1.js) 파일 변경 시 이름이 달라지므로 캐시 기간을 1년으로 설정해도 된다.

프록시 캐시

리버스 프록시로 앱 서버를 두고 있을 때, Nginx가 응답을 캐시해서 앱 서버 부하를 줄일 수 있다.

# nginx.conf — http 블록에 캐시 존 설정
proxy_cache_path /var/cache/nginx
    levels=1:2
    keys_zone=app_cache:10m     # 캐시 키 저장 공간 10MB
    max_size=1g                 # 최대 캐시 용량 1GB
    inactive=60m                # 60분간 접근 없으면 삭제
    use_temp_path=off;

# server block
server {
    location / {
        proxy_pass http://127.0.0.1:3000;

        proxy_cache        app_cache;
        proxy_cache_valid  200 302  10m;    # 200, 302 응답은 10분 캐시
        proxy_cache_valid  404      1m;     # 404는 1분 캐시
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;

        # 캐시 상태를 응답 헤더로 확인 가능 (HIT/MISS/BYPASS)
        add_header X-Cache-Status $upstream_cache_status;

        # 특정 조건에서 캐시 우회
        proxy_cache_bypass  $http_cache_control;
        proxy_no_cache      $http_pragma $http_authorization;
    }
}

X-Cache-Status 헤더를 추가해두면 브라우저 개발자 도구나 curl에서 캐시 히트 여부를 바로 확인할 수 있다.


로드밸런싱 기초

동일한 앱 서버를 여러 개 띄워놓고 트래픽을 분산하는 설정이다.

# nginx.conf — http 블록
upstream app_servers {
    # 기본: round-robin (순서대로 분산)
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;

    # 특정 서버에 더 많은 트래픽 배분
    # server 127.0.0.1:3000 weight=3;
    # server 127.0.0.1:3001 weight=1;

    # 서버 다운 시 잠시 제외 후 재시도
    # server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;

    # 같은 IP는 같은 서버로 (세션 유지가 필요한 경우)
    # ip_hash;

    # 연결 수가 가장 적은 서버로
    # least_conn;

    # 헬스 체크 (Nginx Plus 기능, 오픈소스는 passive만)
    keepalive 32;
}

server {
    listen 443 ssl;
    server_name example.com;

    location / {
        proxy_pass http://app_servers;

        proxy_http_version 1.1;
        proxy_set_header Connection "";         # keepalive를 위해 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_set_header X-Forwarded-Proto $scheme;
    }
}

오픈소스 Nginx는 passive 헬스 체크만 지원한다. 요청이 실패할 때 해당 서버를 일시 제외하는 방식이다. Active 헬스 체크(주기적으로 ping 보내는 방식)는 Nginx Plus(유료) 또는 ngx_http_upstream_check_module 같은 서드파티 모듈이 필요하다.


보안 설정

서버 정보 숨기기

# nginx.conf — http 블록
server_tokens off;      # Nginx 버전 정보를 응답 헤더에서 제거

기본 상태에서는 Server: nginx/1.18.0 형태로 버전이 노출된다. server_tokens offServer: nginx만 남긴다.

불필요한 HTTP 메서드 차단

server {
    # GET, POST, HEAD만 허용
    if ($request_method !~ ^(GET|POST|HEAD)$ ) {
        return 405;
    }

    # 또는 location 단위로 세밀하게
    location /api/ {
        limit_except GET POST {
            deny all;
        }
    }
}

요청 크기 제한

# nginx.conf — http 블록 또는 server 블록
client_max_body_size 10m;       # 요청 바디 최대 10MB (기본 1m)
client_body_timeout 30s;        # 요청 바디 수신 타임아웃
client_header_timeout 30s;      # 요청 헤더 수신 타임아웃
send_timeout 30s;               # 응답 전송 타임아웃

파일 업로드 기능이 있다면 client_max_body_size를 적절히 올려야 한다. 기본값 1m으로는 대부분의 이미지 업로드도 막힌다.

Rate Limiting

# nginx.conf — http 블록
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
# 10m: 약 16만 IP를 저장할 수 있는 메모리
# rate=10r/s: IP당 초당 10개 요청 허용

server {
    location /api/ {
        limit_req zone=api_limit burst=20 nodelay;
        # burst=20: 순간 20개까지 버스트 허용
        # nodelay: 버스트 내 요청은 지연 없이 처리

        proxy_pass http://127.0.0.1:3000;
    }
}

비교 테이블: Nginx vs Apache

항목 Nginx Apache
아키텍처 이벤트 기반, 비동기 (단일 스레드) 프로세스/스레드 기반 (연결당 스레드)
정적 파일 성능 매우 빠름 보통
동시 연결 처리 수만 연결에서도 메모리 안정적 연결 수 증가 시 메모리 급증
동적 컨텐츠 직접 처리 불가, 외부 프로세스로 전달 mod_php 등으로 직접 처리 가능
설정 파일 server block, 디렉티브 기반 VirtualHost, .htaccess 지원
.htaccess 지원 안 함 지원 (디렉토리별 설정 가능)
리버스 프록시 매우 강력, 기본 기능 mod_proxy로 가능하지만 설정 복잡
모듈 컴파일 타임 포함 런타임 동적 로딩 가능
메모리 사용 적음 많음
학습 곡선 중간 .htaccess 익숙하면 낮음
공유 호스팅 거의 없음 매우 흔함
주요 사용처 고성능 웹 서버, 리버스 프록시 공유 호스팅, 레거시 시스템

웹 서버를 처음 선택한다면 Nginx가 맞다. 공유 호스팅이나 .htaccess 기반의 레거시 코드베이스가 아닌 이상 Apache를 쓸 이유가 거의 없다.


삽질 기록

502 Bad Gateway 원인들

502는 Nginx가 업스트림(앱 서버)에서 응답을 못 받았을 때 발생한다. 원인이 여러 가지라 처음엔 막막하다.

가장 흔한 원인들:

  1. 앱 서버가 죽어 있다. 가장 기본적인 케이스. pm2 status, systemctl status myapp으로 프로세스 상태 확인이 먼저다.
  2. 포트 번호가 다르다. proxy_pass http://127.0.0.1:3000으로 설정했는데 앱이 3001에서 돌고 있는 경우. ss -tlnp | grep 3000으로 포트 사용 여부 확인.
  3. Unix 소켓 경로 오류. 소켓 파일로 연결할 때 경로가 틀리거나 파일이 없는 경우.소켓 파일 존재 여부와 권한을 확인해야 한다.
  4. proxy_pass http://unix:/var/run/myapp.sock;
  5. SELinux 또는 AppArmor가 막고 있다. CentOS/RHEL 계열에서 SELinux가 Nginx의 네트워크 연결을 차단하는 경우.
  6. setsebool -P httpd_can_network_connect 1
  7. 타임아웃. 앱 서버 처리가 느려서 Nginx의 타임아웃(기본 60초)이 먼저 터지는 경우. proxy_read_timeout을 늘려서 확인.

디버깅 순서:

# 1. Nginx 에러 로그 확인
sudo tail -f /var/log/nginx/error.log

# 2. 앱 서버 프로세스 확인
pm2 status
ss -tlnp

# 3. 직접 앱 서버에 요청 (Nginx 우회)
curl http://127.0.0.1:3000/api/health

SSL 인증서 갱신 자동화 — 무중단으로 안 되는 케이스

Certbot이 인증서를 갱신할 때 Nginx를 잠깐 내렸다 올리는 방식(--standalone)을 쓰면 서비스가 중단된다.

Nginx 플러그인(--nginx 또는 --webroot)을 쓰면 Nginx를 내리지 않고 갱신할 수 있다.

# webroot 방식 — Nginx가 계속 떠 있는 상태에서 갱신
certbot renew --webroot -w /var/www/certbot

# 갱신 후 Nginx 재로드만 해주면 됨
certbot renew --post-hook "systemctl reload nginx"

/etc/letsencrypt/renewal/example.com.conf 파일에 갱신 설정이 저장된다. renew_hook이나 post_hook을 여기에 넣어두면 certbot renew 실행 시 자동으로 반영된다.

# /etc/letsencrypt/renewal/example.com.conf
[renewalparams]
authenticator = webroot
webroot_path = /var/www/certbot,

[post_hook]
post_hook = systemctl reload nginx

permission 문제 — 403 Forbidden이 나는 이유

Nginx 정적 파일 서빙에서 403이 발생하면 대부분 파일 권한 문제다.

# Nginx가 실행되는 사용자 확인
ps aux | grep nginx
# www-data 또는 nginx 사용자로 실행됨

# 파일 권한 확인
ls -la /var/www/mysite

# 디렉토리 실행 권한이 필요 (755)
chmod 755 /var/www/mysite

# 파일 읽기 권한 (644)
chmod 644 /var/www/mysite/index.html

# www-data가 읽을 수 있도록 소유자 변경
chown -R www-data:www-data /var/www/mysite

부모 디렉토리에 실행 권한이 없어도 403이 난다. /home/ubuntu/mysite에 파일을 넣었는데 /home/ubuntu에 실행 권한이 없는 케이스가 많다. /var/www/ 아래에 두는 게 안전하다.

nginx -t 통과했는데 reload 후 설정이 안 먹히는 케이스

nginx -t는 문법만 검사한다. 파일 경로나 권한 문제는 런타임에 발생한다.

# 로그에서 실제 에러 확인
sudo journalctl -u nginx -n 50
sudo tail -f /var/log/nginx/error.log

# 설정 전체 덤프 (include된 파일까지 모두 확인)
sudo nginx -T

nginx -T는 모든 include 파일을 펼쳐서 최종 설정 전체를 출력한다. 어떤 설정이 실제로 적용되고 있는지 확인할 때 유용하다.


마무리

Nginx 설정에서 핵심만 추리면 이렇다.

  • 리버스 프록시: proxy_pass + proxy_set_header 4종 세트면 대부분 해결된다.
  • HTTPS: Certbot으로 인증서 받고, HTTP → HTTPS 리다이렉트, 갱신 자동화.
  • 캐싱: 정적 파일은 expires로, 프록시 응답은 proxy_cache로.
  • 보안: server_tokens off, client_max_body_size, limit_req_zone.

nginx -t로 문법 검사, systemctl reload nginx로 무중단 적용, /var/log/nginx/error.log로 디버깅. 이 세 가지를 반복하면 대부분의 문제가 해결된다.


기술 스택: Nginx, Let's Encrypt, Certbot, Ubuntu, SSL/TLS, Gzip, HTTP Cache

반응형