왜 이 글을 쓰나
서버 배포를 처음 해보는 사람들이 가장 많이 막히는 지점 중 하나가 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-IP와 X-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 off로 Server: 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가 업스트림(앱 서버)에서 응답을 못 받았을 때 발생한다. 원인이 여러 가지라 처음엔 막막하다.
가장 흔한 원인들:
- 앱 서버가 죽어 있다. 가장 기본적인 케이스.
pm2 status,systemctl status myapp으로 프로세스 상태 확인이 먼저다. - 포트 번호가 다르다.
proxy_pass http://127.0.0.1:3000으로 설정했는데 앱이 3001에서 돌고 있는 경우.ss -tlnp | grep 3000으로 포트 사용 여부 확인. - Unix 소켓 경로 오류. 소켓 파일로 연결할 때 경로가 틀리거나 파일이 없는 경우.소켓 파일 존재 여부와 권한을 확인해야 한다.
proxy_pass http://unix:/var/run/myapp.sock;- SELinux 또는 AppArmor가 막고 있다. CentOS/RHEL 계열에서 SELinux가 Nginx의 네트워크 연결을 차단하는 경우.
setsebool -P httpd_can_network_connect 1- 타임아웃. 앱 서버 처리가 느려서 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_header4종 세트면 대부분 해결된다. - 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
'개발 팁' 카테고리의 다른 글
| cron 표현식 완전 정복 — 스케줄링 실전 치트시트 (1) | 2026.03.20 |
|---|---|
| npm vs yarn vs pnpm vs Bun — 패키지 매니저 제대로 선택하기 (1) | 2026.03.20 |
| 웹 성능 최적화 기초 — Lighthouse 점수 올리는 실전 방법 (0) | 2026.03.19 |
| 개발 브랜치 전략 완전 정복 — Git Flow, GitHub Flow, Trunk-based 비교 (0) | 2026.03.19 |
| REST API 설계 원칙 — 네이밍부터 버전 관리까지 (0) | 2026.03.19 |