nginx.conf를 중심으로 Nginx를 실무 수준으로 다룹니다. 컨텍스트와 요청 처리 순서, server_name·location 매칭 규칙, root/alias·proxy_pass 슬래시 함정, add_header 상속 문제, map 변수, 리버스 프록시 keepalive·WebSocket, TLS, Rate Limit·보안 헤더, 프록시 캐시, 디버깅과 운영용 전체 템플릿까지 정리했습니다. 이 가이드는 개념을 나열하기보다, 실제 프로젝트에서 판단해야 하는 순서대로 내용을 따라갈 수 있게 구성했습니다.
핵심 관점
인프라 / 운영
설치 명령을 외우기보다 트래픽, 런타임, 관측, 장애 대응이 어떤 순서로 이어지는지 파악합니다.
nginx.conf 설계리버스 프록시 & 로드밸런싱정적 파일 서빙HTTPS(TLS) 종료Rate Limit & 보안 하드닝프록시 캐시
구조 다이어그램
글로 읽은 내용을 머릿속에 오래 남기려면 먼저 흐름을 그림으로 잡는 편이 좋습니다. 아래 두 그림은 Nginx를 학습할 때 계속 되돌아볼 수 있는 기준 지도입니다.
학습 흐름
다이어그램 렌더링 중…
아키텍처 관점
다이어그램 렌더링 중…
설치
Nginx를 처음 펼칠 때는 세부 명령보다 큰 그림이 먼저입니다. 이 섹션에서는 앞으로 배울 개념들이 어떤 문제를 풀기 위해 등장했는지부터 잡아봅니다.
운영 서버는 패키지 매니저로 직접 설치해 systemd로 관리하고, 로컬 테스트나 컨테이너 환경에서는 공식 Docker 이미지로 빠르게 띄우는 경우가 많습니다. 설정을 바꾼 뒤에는 nginx -t로 먼저 문법 오류가 없는지 확인하고 나서 reload해야, 오타로 인해 서비스 전체가 중단되는 사고를 막을 수 있습니다.
여기서는 nginx.conf 컨텍스트 구조을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
nginx.conf는 계층적인 컨텍스트(context)로 구성됩니다. 상위 컨텍스트의 지시어는 하위로 상속되고, 하위에서 같은 지시어를 다시 쓰면 그 범위에서만 덮어씁니다. 이 상속 규칙을 모르면 "분명 설정했는데 특정 location에서만 안 먹는" 문제를 겪기 쉽습니다.
/etc/nginx/nginx.confNGINX
user nginx;worker_processes auto;error_log /var/log/nginx/error.log warn;pid /run/nginx.pid;events { worker_connections 1024;}http { include /etc/nginx/mime.types; # 확장자 → Content-Type 매핑표 default_type application/octet-stream; # 매핑에 없는 확장자의 기본값 log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" "$http_user_agent"'; access_log /var/log/nginx/access.log main; sendfile on; keepalive_timeout 65; gzip on; # 도메인별 설정은 별도 파일로 분리해 관리한다 include /etc/nginx/conf.d/*.conf; include /etc/nginx/sites-enabled/*;}
컨텍스트
역할
대표 지시어
main (최상위)
워커 프로세스, 로그 등 전역 설정
user, worker_processes, error_log, pid
events
연결 처리 방식
worker_connections, use
http
웹 서버 전역 설정 — 모든 server 블록의 부모
include mime.types, default_type, sendfile, gzip, log_format
server
도메인/포트 하나를 담당하는 가상 호스트
listen, server_name, root, index, ssl_certificate
location
URL 경로별 세부 처리 규칙
try_files, proxy_pass, alias, return, rewrite
요청 한 건이 nginx.conf를 통과하는 순서
여기서는 요청 한 건이 nginx.conf를 통과하는 순서을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
nginx.conf를 읽을 때 가장 중요한 감각은 "요청 하나가 어떤 순서로 어떤 블록을 고르는가"입니다. Nginx는 ① 요청이 들어온 IP:포트로 후보 server 블록을 추리고, ② Host 헤더를 server_name과 비교해 server 하나를 고른 뒤, ③ URI를 location 규칙과 비교해 location 하나를 고르고, ④ 그 location 안의 지시어(rewrite → 접근 제어 → try_files/proxy_pass)를 정해진 단계(phase) 순서대로 실행합니다. 설정 파일에 적힌 순서대로 위에서 아래로 실행되는 스크립트가 아니라는 점이 핵심입니다.
다이어그램 렌더링 중…
단계(phase)
대표 지시어
실무에서 헷갈리는 점
server 선택
listen, server_name
Host가 어디에도 안 맞으면 default_server(없으면 첫 번째 server)가 응답합니다.
location 선택
location
파일에 적힌 순서가 아니라 매칭 규칙의 우선순위로 결정됩니다(정규식끼리만 순서가 의미 있음).
rewrite
rewrite, return, set, if
return은 즉시 응답을 끝내므로 뒤의 지시어가 모두 무시됩니다.
access
allow/deny, limit_req, auth_basic
allow/deny는 위에서부터 첫 매칭으로 판단합니다.
content
try_files, proxy_pass, root/alias
location 하나에는 콘텐츠 핸들러가 하나만 동작합니다.
filter
gzip, add_header
add_header는 기본적으로 2xx/3xx 응답에만 붙습니다(always로 확장).
설정 파일 레이아웃 & include 전략
여기서는 설정 파일 레이아웃 & include 전략을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
설정 파일을 어떻게 나누느냐는 배포판과 설치 방식에 따라 다릅니다. Debian/Ubuntu 패키지는 sites-available / sites-enabled 구조를, 공식 nginx.org 패키지와 공식 Docker 이미지는 conf.d/*.conf 구조를 씁니다. 두 방식을 섞으면 같은 server_name이 두 번 로드되어 "conflicting server name" 경고와 함께 엉뚱한 블록이 응답하는 일이 생기므로, 팀에서 한 가지로 통일하세요. 여러 server 블록에서 반복되는 설정은 snippets/ 에 모아 include로 재사용합니다.
/etc/nginx 권장 디렉터리 구조TEXT
/etc/nginx/
├── nginx.conf # main / events / http 전역 설정만 둔다
├── mime.types # 확장자 → Content-Type 매핑 (수정하지 않음)
├── conf.d/
│ ├── 00-upstreams.conf # upstream 블록 모음 (파일명 순서대로 로드)
│ ├── 10-maps.conf # map 변수 정의
│ ├── api.example.com.conf
│ └── www.example.com.conf
└── snippets/
├── proxy-headers.conf # proxy_set_header 묶음
├── security-headers.conf
└── ssl-params.conf # TLS 공통 설정
server { listen 443 ssl; http2 on; server_name api.example.com; include snippets/ssl-params.conf; include snippets/security-headers.conf; location / { include snippets/proxy-headers.conf; proxy_pass http://api_backend; }}
설정 확인 명령BASH
sudo nginx -t # 문법 + 파일 경로 검사sudo nginx -T # include까지 모두 펼친 "실제로 로드되는 전체 설정" 출력sudo nginx -T | grep -n "server_name" # 중복 server_name 찾기nginx -V # 컴파일 옵션·포함된 모듈 확인 (http_v2, stream 등)
main & events 컨텍스트
여기서는 main & events 컨텍스트을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
최상위(main)와 events 컨텍스트는 워커 프로세스 수와 동시 연결 한도를 결정합니다. 이론상 최대 동시 연결 수는 worker_processes × worker_connections이며, 리버스 프록시는 클라이언트 연결 1개당 업스트림 연결 1개를 더 쓰므로 실제 수용 가능한 클라이언트는 그 절반 정도입니다. 연결 수를 늘릴 때는 OS의 파일 디스크립터 한도(worker_rlimit_nofile)도 함께 올려야 "Too many open files" 에러를 피할 수 있습니다.
nginx.conf (main / events)NGINX
user nginx;worker_processes auto; # CPU 코어 수만큼 워커 생성worker_rlimit_nofile 65535; # 워커당 열 수 있는 파일(소켓 포함) 한도pid /run/nginx.pid;error_log /var/log/nginx/error.log warn;# 동적 모듈은 main 컨텍스트에서 로드# load_module modules/ngx_http_geoip2_module.so;events { worker_connections 8192; # 워커 하나당 최대 동시 연결 multi_accept on; # 한 번에 대기 중인 연결을 모두 accept # use epoll; # Linux는 자동 선택되므로 보통 생략}
지시어
기본값
권장 출발점
비고
worker_processes
1
auto
CPU 바운드 작업(TLS, gzip)이 많을수록 코어 수에 맞추는 효과가 큼
worker_connections
512
4096 ~ 16384
worker_rlimit_nofile보다 작아야 함
worker_rlimit_nofile
OS 한도
worker_connections × 2 이상
systemd의 LimitNOFILE과도 맞춰야 함
error_log 레벨
error
warn (장애 분석 시 info/debug)
debug는 --with-debug 빌드에서만 동작
http 컨텍스트 — 전역 기본값 설계
여기서는 http 컨텍스트 — 전역 기본값 설계을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
http 블록은 모든 server 블록의 부모이므로, 여기에 "모든 사이트에 공통으로 적용할 안전한 기본값"을 둡니다. 특히 client_max_body_size(기본 1m)와 각종 timeout은 기본값이 실무 요구와 맞지 않는 경우가 많아, 파일 업로드 413 에러나 느린 API의 504 에러의 원인이 됩니다. 로그는 JSON 형식으로 남기면 Loki·Elasticsearch 같은 수집기에서 필드 단위로 검색·집계할 수 있습니다.
nginx.conf (http)NGINX
http { include /etc/nginx/mime.types; default_type application/octet-stream; charset utf-8; server_tokens off; # 에러 페이지·Server 헤더에서 버전 숨김 # ── 전송 최적화 ───────────────────────────── sendfile on; # 커널에서 파일을 바로 소켓으로 복사 tcp_nopush on; # sendfile과 함께: 헤더+데이터를 한 패킷에 tcp_nodelay on; # keepalive 연결에서 작은 패킷 지연 제거 # ── 타임아웃 & 요청 크기 ───────────────────── keepalive_timeout 65s; client_header_timeout 15s; client_body_timeout 30s; send_timeout 30s; client_max_body_size 20m; # 기본 1m → 초과 시 413 Request Entity Too Large # ── JSON 액세스 로그 ──────────────────────── log_format json escape=json '{' '"time":"$time_iso8601",' '"request_id":"$request_id",' '"remote_addr":"$remote_addr",' '"host":"$host",' '"method":"$request_method",' '"uri":"$request_uri",' '"status":$status,' '"bytes":$body_bytes_sent,' '"request_time":$request_time,' '"upstream_addr":"$upstream_addr",' '"upstream_status":"$upstream_status",' '"upstream_response_time":"$upstream_response_time",' '"user_agent":"$http_user_agent"' '}'; access_log /var/log/nginx/access.log json buffer=32k flush=5s; # ── 압축 ──────────────────────────────────── gzip on; gzip_comp_level 5; gzip_min_length 1024; gzip_proxied any; # 프록시 응답도 압축 gzip_vary on; # Vary: Accept-Encoding (CDN 캐시 분리) gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml; include /etc/nginx/conf.d/*.conf;}
server 블록 선택 규칙 (listen & server_name)
여기서는 server 블록 선택 규칙 (listen & server_name)을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
Nginx는 먼저 listen 지시어의 IP:포트로 후보를 좁힌 다음, Host 헤더와 server_name을 비교합니다. 매칭 우선순위는 ① 정확한 이름 → ② *로 시작하는 가장 긴 와일드카드 → ③ *로 끝나는 가장 긴 와일드카드 → ④ 설정 순서상 처음 매칭되는 정규식입니다. 어떤 이름에도 맞지 않으면 그 포트의 default_server가 응답하는데, 이를 명시하지 않으면 "첫 번째로 로드된 server 블록"이 기본값이 되어 IP로 직접 들어온 스캐너 요청에 실제 서비스가 노출됩니다.
conf.d/00-default.conf — 알 수 없는 Host 차단NGINX
# 등록되지 않은 Host(IP 직접 접속, 스캐너)는 응답 없이 연결을 끊는다server { listen 80 default_server; listen 443 ssl default_server; server_name _; ssl_reject_handshake on; # 1.19.4+: 인증서 없이 TLS 핸드셰이크 자체를 거부 return 444; # nginx 전용 코드: 응답 없이 연결 종료}
conf.d/www.example.com.conf — 정규화 리다이렉트NGINX
# http → https, www → apex 로 한 번에 정규화server { listen 80; server_name example.com www.example.com; return 301 https://example.com$request_uri;}server { listen 443 ssl; http2 on; server_name www.example.com; include snippets/ssl-params.conf; return 301 https://example.com$request_uri;}server { listen 443 ssl; http2 on; server_name example.com; include snippets/ssl-params.conf; root /var/www/example;}
server_name 매칭 예시NGINX
server_name example.com; # ① 정확한 이름server_name *.example.com; # ② 앞쪽 와일드카드 (a.example.com, a.b.example.com)server_name mail.*; # ③ 뒤쪽 와일드카드server_name ~^(?<tenant>[a-z0-9-]+)\.saas\.com$; # ④ 정규식 + 이름 있는 캡처# 정규식 캡처는 이후 지시어에서 변수로 사용 가능# root /var/www/tenants/$tenant;
location 매칭 규칙 완전정복
여기서는 location 매칭 규칙 완전정복을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
location은 파일에 적힌 순서대로 검사되지 않습니다. Nginx는 ① = 정확 일치가 있으면 즉시 확정하고, ② 접두 문자열 location 중 가장 긴 것을 기억해 둔 뒤, 그것이 ^~ 이면 정규식 검사를 건너뛰고 확정합니다. ③ 아니면 정규식 location(~ 대소문자 구분, ~* 무시)을 파일에 적힌 순서대로 검사해 처음 맞는 것을 쓰고, ④ 정규식이 하나도 맞지 않을 때만 기억해 둔 최장 접두 location을 사용합니다. 즉 "더 구체적인 접두 location을 적었는데 정규식 location이 가로채는" 현상은 규칙대로 동작한 결과입니다.
# SPA: 파일 → 디렉터리 → index.html 순으로 탐색location / { try_files $uri $uri/ /index.html;}# 정적 파일이 없으면 백엔드로 넘기기 (Rails/Django/Next.js 등)location / { try_files $uri @app;}location @app { include snippets/proxy-headers.conf; proxy_pass http://app_backend;}
문법
의미
예: /images/logo.png 요청
location = /images/logo.png
정확 일치 — 가장 먼저, 매칭 즉시 종료
이 location이 선택됨
location ^~ /images/
접두 일치 + 정규식 검사 생략
최장 접두가 이것이면 정규식을 보지 않고 선택
location ~* \.(png|jpg)$
정규식(대소문자 무시), 파일 순서대로
^~ 가 없다면 이것이 /images/ 접두보다 우선
location /images/
일반 접두 일치 (최장 일치)
정규식이 하나도 맞지 않을 때만 선택
location /
모든 요청의 최종 fallback
다른 어떤 것도 맞지 않을 때
location @fallback
이름 있는 location — 외부 요청은 매칭 불가
try_files, error_page의 내부 이동 대상
root vs alias, proxy_pass 슬래시 함정
여기서는 root vs alias, proxy_pass 슬래시 함정을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
nginx.conf에서 가장 많은 404와 보안 사고를 만드는 두 가지 함정입니다. root는 "root 경로 + 전체 URI"로 파일을 찾고, alias는 "location에 매칭된 부분을 alias 경로로 치환"합니다. proxy_pass도 비슷하게, 주소 뒤에 URI(슬래시 하나라도)가 붙어 있으면 location에 매칭된 부분을 그 URI로 치환하고, 없으면 원래 URI를 그대로 전달합니다.
위험한 alias (off-by-slash 취약점)NGINX
# ❌ location에는 슬래시가 없고 alias에는 있음location /static { alias /var/www/static/;}# GET /static../app/.env → /var/www/static/../app/.env (상위 디렉터리 노출!)# ✅ location과 alias의 끝 슬래시를 반드시 맞춘다location /static/ { alias /var/www/static/;}
정규식 location + alias / proxy_passNGINX
# 정규식 location에서 alias를 쓰려면 캡처로 경로를 직접 조립해야 한다location ~ ^/download/(.+\.pdf)$ { alias /data/files/$1;}# 정규식 location 안의 proxy_pass에는 URI를 붙일 수 없다 (nginx -t 에러)# 경로를 바꾸려면 rewrite ... break 로 URI를 먼저 수정한다location ~ ^/api/v1/(.*)$ { rewrite ^/api/v1/(.*)$ /$1 break; proxy_pass http://api_backend;}
설정
요청
실제로 찾는 파일 / 전달 URI
location /static/ { root /var/www; }
/static/app.js
/var/www/static/app.js
location /static/ { alias /var/www/assets/; }
/static/app.js
/var/www/assets/app.js
location /api/ { proxy_pass http://be; }
/api/users
http://be/api/users (그대로)
location /api/ { proxy_pass http://be/; }
/api/users
http://be/users (/api/ → / 치환)
location /api/ { proxy_pass http://be/v2/; }
/api/users
http://be/v2/users
지시어 상속 함정 (add_header, proxy_set_header)
여기서는 지시어 상속 함정 (add_header, proxy_set_header)을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
상위 컨텍스트의 지시어는 하위로 상속되지만, add_header·proxy_set_header처럼 여러 번 쓸 수 있는 "배열형" 지시어는 하위 블록에 같은 지시어가 하나라도 있으면 상위 값 전체가 사라집니다(합쳐지지 않음). server에 보안 헤더를 걸어 두고 특정 location에 Cache-Control 하나만 추가했다가, 그 location에서 보안 헤더가 전부 빠지는 사고가 대표적입니다.
# snippets/security-headers.confadd_header X-Frame-Options "DENY" always;add_header X-Content-Type-Options "nosniff" always;add_header Referrer-Policy "strict-origin-when-cross-origin" always;# conf.d/example.com.confserver { include snippets/security-headers.conf; location /assets/ { include snippets/security-headers.conf; # 다시 포함 add_header Cache-Control "public, max-age=31536000, immutable"; }}
지시어
상속 방식
주의
add_header
하위에 하나라도 있으면 상위 전체 무시
always가 없으면 4xx/5xx 응답에는 붙지 않음
proxy_set_header
하위에 하나라도 있으면 상위 전체 무시
기본값(Host $proxy_host, Connection close)으로 되돌아감
root, index, client_max_body_size
일반 상속 — 하위에서 덮어쓰기
location별로 값을 바꿀 수 있음
rewrite, return
상속되지 않음 (해당 블록에서만 동작)
server 레벨 rewrite는 location 선택 전에 실행
변수, map, 그리고 if를 피하는 법
여기서는 변수, map, 그리고 if를 피하는 법을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
Nginx 변수는 요청마다 평가되며($host, $uri, $request_uri, $remote_addr, $http_헤더명, $cookie_이름, $arg_파라미터 등), map 블록으로 한 변수에서 다른 변수를 파생할 수 있습니다. location 안의 if는 내부적으로 별도 location을 만드는 방식이라 return·rewrite 외의 지시어와 섞으면 예상과 다르게 동작하는 것으로 유명합니다("If is Evil"). 조건 분기는 가능하면 map으로 바꾸고, if는 return 용도로만 쓰세요.
server { access_log /var/log/nginx/access.log json if=$loggable; location / { # 점검 중에만 아래 한 줄의 주석을 풀고 reload — 사내 IP는 통과 # if ($maintenance_block) { return 503; } add_header Cache-Control $cache_control always; try_files $uri $uri/ /index.html; }}
리버스 프록시 & 로드밸런싱
여기서는 리버스 프록시 & 로드밸런싱을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
Nginx 뒤에 여러 백엔드 인스턴스를 두면 `upstream` 블록으로 요청을 분산할 수 있습니다. 기본은 라운드로빈이며, 세션 고정이 필요하면 `ip_hash`를 씁니다.
다이어그램 렌더링 중…
reverse-proxy.confNGINX
upstream backend { server 127.0.0.1:3000; server 127.0.0.1:3001; server 127.0.0.1:3002; # ip_hash; # 같은 클라이언트를 같은 서버로 고정하고 싶을 때}server { listen 80; server_name api.example.com; location / { proxy_pass http://backend; 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; }}
리버스 프록시 심화 — keepalive, 타임아웃, WebSocket
여기서는 리버스 프록시 심화 — keepalive, 타임아웃, WebSocket을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
기본 proxy_pass만으로는 운영 트래픽에서 성능과 안정성이 부족합니다. Nginx는 기본적으로 업스트림과 HTTP/1.0으로 매 요청마다 새 TCP 연결을 맺기 때문에, upstream keepalive를 켜서 연결을 재사용해야 합니다(이때 proxy_http_version 1.1과 빈 Connection 헤더가 필수). 또한 백엔드 장애 시 다른 서버로 넘길 조건(proxy_next_upstream)과 타임아웃을 API 성격에 맞게 정해야 502/504가 폭증하는 상황을 통제할 수 있습니다.
conf.d/00-upstreams.confNGINX
upstream api_backend { least_conn; # 활성 연결이 가장 적은 서버로 server 10.0.1.11:8080 max_fails=3 fail_timeout=30s; server 10.0.1.12:8080 max_fails=3 fail_timeout=30s; server 10.0.1.13:8080 backup; # 나머지가 모두 죽었을 때만 사용 keepalive 64; # 워커당 유지할 유휴 연결 수 keepalive_timeout 60s;}
여기서는 파일이 화면에 안 보이고 다운로드되는 문제 해결을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
브라우저가 응답을 화면에 렌더링할지 다운로드할지는 오직 `Content-Type` 응답 헤더로 결정됩니다. Nginx는 서빙하는 파일의 확장자를 `mime.types`에서 찾아 Content-Type을 정하고, 목록에 없으면 `default_type`(기본값 `application/octet-stream` — "알 수 없는 바이너리 파일")으로 폴백합니다. `index.test`처럼 `.html`이 아닌 커스텀 확장자로 요청이 들어오고 내부적으로 실제 html을 이어붙이는 구조라면, 이 폴백 때문에 다운로드 창이 뜨는 경우가 대부분입니다.
diagnose.shBASH
# 실제로 어떤 Content-Type이 내려오는지부터 직접 확인 (http/https 동일하게 확인)curl -I http://example.com/index.testcurl -I https://example.com/index.test# → Content-Type: application/octet-stream 이면 아래 해결책 적용
가장 흔한 해결책 — 정적 파일을 내부적으로 .html로 연결NGINX
location = /index.test { default_type text/html; # 이 location의 응답은 항상 text/html로 고정 try_files /index.html =404; # 실제 html 파일을 내부 서빙 (URL은 /index.test 그대로 유지, 클라이언트에게 리다이렉트 노출 안 됨)}
여러 개의 .test 파일을 각각의 .html로 매핑NGINX
location ~ \.test$ { rewrite ^(.*)\.test$ $1.html last; # last는 URI를 .html로 바꿔 location 매칭을 처음부터 다시 태운다 — # 이후 일반 location(/)에서 .html로 정상 서빙되므로 mime.types가 text/html을 올바르게 잡아준다.}
try_files/rewrite로 실제 .html로 내부 전달하거나 default_type을 location에서 강제
Content-Disposition: attachment 헤더 존재
상위(server/http) 컨텍스트에 다운로드 강제 헤더가 걸려있음
해당 add_header를 제거하거나 필요한 location에만 한정
Content-Type이 아예 없거나 비어있음
proxy_pass로 넘긴 백엔드가 헤더를 안 보냄
proxy_hide_header + add_header로 nginx에서 덮어쓰기
HTTPS(TLS) 설정
여기서는 HTTPS(TLS) 설정을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
Let's Encrypt의 certbot을 쓰면 무료 인증서 발급과 자동 갱신을 Nginx 설정에 바로 연동할 수 있습니다. certbot이 Nginx 설정 파일을 직접 읽고 SSL 관련 지시어를 자동으로 추가해주기 때문에, 인증서 경로나 프로토콜 버전을 손으로 하나씩 맞출 필요 없이 명령어 한 번으로 HTTPS 전환이 끝납니다. 사이트가 여러 개라면 TLS 공통 설정은 snippets/ssl-params.conf로 분리해 모든 server 블록이 같은 보안 수준을 갖게 하세요. Nginx 1.25.1부터는 listen 443 ssl http2 대신 별도 지시어 http2 on; 을 사용합니다.
BASH
sudo apt install -y certbot python3-certbot-nginxsudo certbot --nginx -d example.com -d www.example.com# 이후 80 → 443 리다이렉트와 인증서 설정이 자동으로 추가됨sudo certbot renew --dry-run # 자동 갱신 테스트
snippets/ssl-params.confNGINX
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;ssl_protocols TLSv1.2 TLSv1.3; # TLS 1.0/1.1 비활성ssl_prefer_server_ciphers off; # TLS 1.3 시대에는 클라이언트 선택 존중ssl_session_cache shared:SSL:10m; # 약 4만 세션 — 재접속 시 핸드셰이크 생략ssl_session_timeout 1d;ssl_session_tickets off;
snippets/hsts.confNGINX
# HSTS: 브라우저가 이후 1년간 https로만 접속 (적용 전 모든 서브도메인 https 확인!)# add_header라서 location에 add_header를 추가할 때마다 이 파일도 함께 include한다add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
http + https를 함께 서비스하는 전체 예시NGINX
# 80번 포트 — 필요하면 여기서도 동일한 location을 두고 그대로 서비스 가능server { listen 80; server_name example.com www.example.com; root /var/www/myapp; location = /index.test { default_type text/html; try_files /index.html =404; } location / { try_files $uri $uri/ =404; }}# 443번 포트 — TLS가 붙어도 위와 동일한 location을 그대로 사용server { listen 443 ssl; http2 on; server_name example.com www.example.com; root /var/www/myapp; include snippets/ssl-params.conf; include snippets/hsts.conf; location = /index.test { default_type text/html; try_files /index.html =404; } location / { try_files $uri $uri/ =404; }}
보안 하드닝 — 헤더, 접근 제어, Rate Limit
여기서는 보안 하드닝 — 헤더, 접근 제어, Rate Limit을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
인터넷에 노출된 Nginx는 매일 .env, .git, wp-admin 같은 경로를 찾는 스캐너 트래픽을 받습니다. 숨김 파일 차단, 관리 경로 IP 제한, 요청 속도 제한(limit_req)과 동시 연결 제한(limit_conn), 보안 응답 헤더를 기본 템플릿에 넣어 두면 애플리케이션 코드를 건드리지 않고 공격 표면을 크게 줄일 수 있습니다.
http 컨텍스트 — 제한 zone 정의NGINX
# 키: 클라이언트 IP(바이너리 형태가 메모리 효율적), 10MB ≈ 16만 IPlimit_req_zone $binary_remote_addr zone=api_rl:10m rate=10r/s;limit_req_zone $binary_remote_addr zone=login_rl:10m rate=5r/m;limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;limit_req_status 429; # 기본 503 대신 의미가 맞는 429limit_conn_status 429;
server 컨텍스트 — 적용NGINX
server { include snippets/security-headers.conf; # 숨김 파일(.env, .git ...) 차단 — 단, ACME 인증용 .well-known은 허용 location ~ /\.(?!well-known/) { deny all; access_log off; log_not_found off; } location /api/ { limit_req zone=api_rl burst=20 nodelay; # 순간 20건까지 허용, 초과분은 429 limit_conn conn_per_ip 20; include snippets/proxy-headers.conf; proxy_pass http://api_backend; } location = /api/auth/login { limit_req zone=login_rl burst=5; # 무차별 대입 방어 include snippets/proxy-headers.conf; proxy_pass http://api_backend; } location /admin/ { allow 10.0.0.0/8; # 위에서부터 첫 매칭 규칙 적용 allow 203.0.113.10; deny all; include snippets/proxy-headers.conf; proxy_pass http://admin_backend; }}
여기서는 프록시 캐시 (proxy_cache)을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
자주 조회되지만 자주 바뀌지 않는 API 응답(상품 목록, 공지, 설정값)은 Nginx에서 캐시하면 백엔드 부하와 응답 시간을 동시에 줄일 수 있습니다. proxy_cache_use_stale을 켜면 백엔드가 장애일 때도 마지막으로 성공한 응답을 내려줘 서비스가 버티는 "완충 장치" 역할도 합니다. 반면 사용자별 응답(Authorization·Cookie가 있는 요청)이 캐시되면 다른 사용자에게 개인정보가 노출되므로 캐시 대상에서 반드시 제외해야 합니다.
http 컨텍스트NGINX
proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api_cache:50m # 키 메타데이터 (1MB ≈ 8천 키) max_size=2g inactive=30m # 30분간 요청 없으면 삭제 use_temp_path=off;
location 적용NGINX
location /api/catalog/ { proxy_cache api_cache; proxy_cache_key "$scheme$request_method$host$request_uri"; proxy_cache_valid 200 301 5m; proxy_cache_valid 404 1m; # 백엔드 장애·갱신 중에는 오래된 캐시라도 응답 proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_cache_background_update on; proxy_cache_lock on; # 같은 키에 대한 동시 miss를 1건만 백엔드로 # 개인화된 요청은 캐시 우회 + 저장 금지 proxy_cache_bypass $http_authorization $cookie_session; proxy_no_cache $http_authorization $cookie_session; add_header X-Cache-Status $upstream_cache_status always; # HIT / MISS / STALE ... include snippets/proxy-headers.conf; proxy_pass http://api_backend;}
성능 튜닝
여기서는 성능 튜닝을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
트래픽이 늘어나면 기본값만으로는 부족해집니다. 튜닝은 "측정 → 한 항목 변경 → 재측정" 순서로 진행하고, 아래 항목은 대부분의 서비스에서 효과가 확인된 출발점입니다. 정적 파일이 많다면 open_file_cache로 파일 메타데이터 조회를 줄이고, 프록시가 주 역할이라면 upstream keepalive와 버퍼 크기가 가장 큰 차이를 만듭니다.
server { listen 127.0.0.1:8081; # 외부 노출 금지 location = /nginx_status { stub_status; allow 127.0.0.1; deny all; }}# curl 127.0.0.1:8081/nginx_status# Active connections: 291# server accepts handled requests# 16630948 16630948 31070465# Reading: 6 Writing: 179 Waiting: 106# → nginx-prometheus-exporter로 수집해 Grafana에서 시각화
설정
설명
worker_processes auto;
CPU 코어 수만큼 워커 프로세스를 자동으로 띄움
worker_connections 8192;
워커 하나가 처리할 수 있는 최대 동시 연결 수 (worker_rlimit_nofile과 함께)
keepalive_timeout 65;
클라이언트와의 연결을 재사용해 TLS 핸드셰이크 비용을 줄임
upstream keepalive 64;
백엔드 연결 재사용 — 프록시 지연과 TIME_WAIT 소켓 감소
open_file_cache
정적 파일의 fd·메타데이터를 캐시해 stat() 호출 감소
proxy_buffers / proxy_buffer_size
큰 응답 헤더·본문을 디스크 대신 메모리에서 처리
limit_req_zone / limit_req
IP별 요청 속도 제한(Rate Limiting)으로 과도한 트래픽 방어
설정 디버깅 & 자주 만나는 에러
여기서는 설정 디버깅 & 자주 만나는 에러을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
설정 문제는 대부분 "어떤 server·location이 선택됐는가"와 "실제로 로드된 설정이 무엇인가"만 확인하면 풀립니다. 변경은 항상 nginx -t로 검사한 뒤 reload(무중단)로 반영하고, 디버깅 중에는 응답 헤더에 선택된 location을 표시하는 임시 헤더를 넣으면 추측 대신 사실로 판단할 수 있습니다.
디버깅 루틴BASH
sudo nginx -t && sudo systemctl reload nginx # 검사 통과 시에만 reloadsudo nginx -T | less # 실제 로드된 전체 설정# Host 헤더를 바꿔가며 어느 server 블록이 응답하는지 확인curl -sI -H "Host: api.example.com" http://127.0.0.1/health# TLS SNI까지 포함해 특정 서버 IP로 테스트 (DNS 변경 전 검증)curl -sI --resolve example.com:443:203.0.113.5 https://example.com/sudo tail -f /var/log/nginx/error.log # 대부분의 원인이 여기에 찍힌다
임시 디버그 헤더NGINX
location /api/ { add_header X-Debug-Location "api" always; add_header X-Debug-Upstream $upstream_addr always; # ...}# 확인 후 반드시 제거 (내부 주소 노출 방지)
에러 / 로그 메시지
원인
해결
nginx: [emerg] unknown directive
오타, 또는 해당 모듈이 빌드에 없음
nginx -V로 모듈 확인, 세미콜론 누락 여부 확인
conflicting server name ... ignored
같은 server_name이 두 곳에 정의됨
nginx -T | grep server_name 으로 중복 제거
403 Forbidden
디렉터리에 index 파일 없음 또는 파일 권한 부족
index 지시어, 상위 디렉터리까지 nginx 사용자 x 권한 확인
404 (파일은 존재)
root/alias 경로 조합 오류
error.log의 "open() ... failed" 경로 확인
413 Request Entity Too Large
업로드 크기가 client_max_body_size 초과
해당 server/location에서 값 상향
502 + connect() failed (111: Connection refused)
백엔드가 떠 있지 않거나 포트 불일치
백엔드 상태, upstream 주소 확인
502 + (13: Permission denied) while connecting
SELinux가 Nginx의 네트워크 연결 차단
setsebool -P httpd_can_network_connect 1
504 upstream timed out
백엔드 응답이 proxy_read_timeout 초과
느린 엔드포인트만 location 분리 후 타임아웃 조정
rewrite or internal redirection cycle
try_files/rewrite가 자기 자신으로 무한 이동
try_files 마지막 인자에 =404 사용, rewrite의 last/break 확인
운영용 nginx.conf 전체 템플릿
여기서는 운영용 nginx.conf 전체 템플릿을 실제 코드와 함께 확인합니다. 예제를 그대로 따라 하기보다, 입력과 출력, 그리고 바뀌기 쉬운 부분이 어디인지 보면서 읽어보세요.
지금까지 다룬 내용을 한 파일 구조로 합친 템플릿입니다. nginx.conf에는 전역 설정만, 사이트별 설정은 conf.d/에, 반복 설정은 snippets/에 두는 구조를 따릅니다. 그대로 복사하기보다 도메인·업스트림 주소·제한 값을 서비스에 맞게 조정한 뒤 nginx -t로 검증하세요.