[V1] Nginx 설정 - 100-hours-a-week/KTB4-15th-CLOUD GitHub Wiki

Nginx 설정

1. default.conf

# /opt/lookddak/nginx/conf.d/default.conf
# nginx.conf의 http { } 안에 include되는 파일

# Docker DNS
resolver 127.0.0.11 valid=10s ipv6=off;

# IP별 요청 속도 제한
limit_req_zone $binary_remote_addr zone=api_ip:20m    rate=20r/s;
limit_req_zone $binary_remote_addr zone=static_ip:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=web_ip:30m    rate=30r/s;

# IP별 동시 요청 제한
limit_conn_zone $binary_remote_addr zone=api_conn_ip:10m;
limit_conn_zone $binary_remote_addr zone=web_conn_ip:10m;

# 모든 IP를 합친 API 전체 동시 요청 제한
limit_conn_zone $server_name zone=api_total:1m;

# 제한 초과 응답 코드
limit_req_status 429;
limit_conn_status 429;

# 응답시간 및 rate limit 결과 기록
log_format lookddak_timing
    '$remote_addr - $remote_user [$time_local] "$request" '
    '$status $body_bytes_sent "$http_referer" "$http_user_agent" '
    'rt=$request_time urt=$upstream_response_time '
    'uaddr="$upstream_addr" ustatus="$upstream_status" '
    'req_limit="$limit_req_status" conn_limit="$limit_conn_status"';

# Next.js 정적 파일 캐시
proxy_cache_path /var/cache/nginx/next_static
    levels=1:2
    keys_zone=next_static:10m
    max_size=1g
    inactive=7d
    use_temp_path=off;

# Spring upstream
upstream spring_backend {
    zone spring_backend 64k;
    server spring:8080 resolve;

    keepalive 32;
    keepalive_timeout 30s;
}

# Next.js upstream
upstream nextjs_frontend {
    zone nextjs_frontend 64k;
    server nextjs:3000 resolve;

    keepalive 16;
    keepalive_timeout 30s;
}

# HTTP
server {
    listen 80;
    server_name lookddak.com www.lookddak.com;

    access_log /var/log/nginx/access.log lookddak_timing;

    location = /healthz {
        access_log off;

        allow 127.0.0.1;
        deny all;

        default_type text/plain;
        return 200 "ok";
    }

    # Let's Encrypt 인증 경로
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/certbot;
        default_type text/plain;
        try_files $uri =404;
    }

    # 워드프레스, 숨김 파일, 백업 파일 스캔 차단
    location ~* (^/(wp-|xmlrpc\.php)|/\.(?!well-known/)|\.(bak|old|sql|swp)$) {
        return 444;
    }

    location / {
        return 301 https://lookddak.com$request_uri;
    }
}

# www HTTPS → 기본 도메인 이동
server {
    listen 443 ssl;
    http2 on;
    server_name www.lookddak.com;

    access_log /var/log/nginx/access.log lookddak_timing;

    ssl_certificate     /etc/nginx/ssl/live/lookddak.com/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/live/lookddak.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    return 301 https://lookddak.com$request_uri;
}

# 실제 서비스
server {
    listen 443 ssl;
    http2 on;
    server_name lookddak.com;

    access_log /var/log/nginx/access.log lookddak_timing;

    ssl_certificate     /etc/nginx/ssl/live/lookddak.com/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/live/lookddak.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # 공통 프록시 설정
    proxy_http_version 1.1;
    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_set_header X-Forwarded-Proto $scheme;

    proxy_connect_timeout 3s;

    # 스캐너 차단
    location ~* (^/(wp-|xmlrpc\.php)|/\.(?!well-known/)|\.(bak|old|sql|swp)$) {
        return 444;
    }

    # Next.js 정적 파일
    location ^~ /_next/static/ {
        # IP당 30r/s, 순간 초과분 100건
        limit_req zone=static_ip burst=100 nodelay;

        # IP당 동시 요청 최대 100개
        limit_conn web_conn_ip 100;

        # Nginx 디스크 캐시
        proxy_cache next_static;
        proxy_cache_methods GET HEAD;
        proxy_cache_valid 200 7d;

        # 같은 파일의 동시 MISS는 Next.js에 한 번만 요청
        proxy_cache_lock on;
        proxy_cache_lock_timeout 5s;

        # HIT, MISS, BYPASS 등 캐시 상태 표시
        add_header X-Cache-Status $upstream_cache_status always;

        proxy_pass http://nextjs_frontend;
    }

    # Spring API
    location /api/ {
        client_max_body_size 10m;

        # IP당 20r/s, 순간 초과분 40건
        limit_req zone=api_ip burst=40 nodelay;

        # IP 하나의 API 동시 요청 최대 50개
        limit_conn api_conn_ip 50;

        # 모든 IP를 합친 API 동시 요청 최대 100개
        limit_conn api_total 100;

        proxy_read_timeout 60s;
        proxy_pass http://spring_backend;
    }

    # Next.js 페이지 및 기타 URL
    location / {
        # IP당 10r/s, 순간 초과분 30건
        limit_req zone=web_ip burst=50 nodelay;

        # IP당 동시 요청 최대 100개
        limit_conn web_conn_ip 100;

        proxy_pass http://nextjs_frontend;
    }
}
적용 영역 요청 경로 IP당 지속 속도 순간 허용량 순간 요청 처리 IP당 동시 요청 전체 동시 요청
전체 공통 모든 경로 없음 없음 영역별 설정 사용 영역별 설정 사용 API에만 존재
Spring API /api/ 20건/초 40건 즉시 전달 50개 100개
Next.js 정적 파일 /_next/static/ 30건/초 100건 즉시 전달 100개 없음
Next.js 일반 요청 나머지 / 30건/초 50건 즉시 전달 100개 없음
스캐너 차단 /.env, /.git, /wp-*, *.sql 등 해당 없음 해당 없음 즉시 연결 종료 해당 없음 해당 없음

nginx.conf

user  nginx;
worker_processes  auto;

# 워커 하나가 열 수 있는 최대 FD 수 (docker-compose ulimits.nofile 과 같은 값으로)
worker_rlimit_nofile 65535;

error_log  /var/log/nginx/error.log notice;
pid        /run/nginx.pid;

events {
    # 기존 1024 → 4096 (동접 1,000명 기준)
    worker_connections  4096;
}

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

    # nginx 버전 숨기기 (선택)
    server_tokens off;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/*.conf;
}

default.conf - 기존

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

    location = /healthz {
        access_log off;
        allow 127.0.0.1;
        deny all;
        return 200 "ok";
    }

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/certbot;
        default_type text/plain;
        try_files $uri =404;
    }

    location / {
        return 301 https://lookddak.com$request_uri;
    }
}
server {
    listen 443 ssl;
    http2 on;
    server_name lookddak.com;

    ssl_certificate /etc/nginx/ssl/live/lookddak.com/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/live/lookddak.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    resolver 127.0.0.11 valid=10s;

    location /api/ {
        client_max_body_size 10m;
        set $spring_upstream spring:8080;
        proxy_pass http://$spring_upstream;
        proxy_http_version 1.1;
        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;
    }

    location / {
        set $nextjs_upstream nextjs:3000;
        proxy_pass http://$nextjs_upstream;
        proxy_http_version 1.1;
        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;
    }
}

default.conf - 변경

# ── server 443 블록 ──
location /_next/static/ {
    # [추가] 정상 사용자는 burst=100 안에서 모두 통과, 공격은 초당 30건으로 제한.
    limit_req  zone=static_ip burst=100 nodelay;
    limit_conn conn_ip 100;

    # [추가] 한 번 받은 파일은 nginx가 저장해 두고 직접 응답 → Node까지 가지 않음.
    proxy_cache next_static;
    proxy_cache_valid 200 7d;     # 정상 응답(200)만 7일 캐시. 404는 캐시하지 않음.

    # [추가] 캐시에 없는 같은 파일 요청이 동시에 몰려도 Next.js에는 1번만 요청.
    proxy_cache_lock on;

    # [추가] 응답 헤더에 HIT/MISS를 표시 → 캐시가 작동하는지 브라우저 개발자 도구에서 확인용.
    add_header X-Cache-Status $upstream_cache_status;

    proxy_pass http://nextjs_frontend;
}

전체 한도는 "평소 최대 트래픽 < 한도 < 백엔드가 버티는 양" 사이에 있어야 합니다.

값 (어제 로그 기준)
평소 API 최대 초당 12건
평소 프론트 페이지·RSC 최대 초당 약 20건
Spring이 버틴 양 11:0011:01에 초당 약 160190건을 200으로 처리했고, 초당 약 300건에서 다운
# ── http 영역 (기존 zone들 아래에 추가) ──

# [추가] 전체 제한용 zone. 키가 $server_name이라 모든 요청이 카운터 하나를 공유함.
#        카운터가 1개뿐이라 공간은 1MB면 충분.
limit_req_zone  $server_name zone=api_all:1m  rate=150r/s;  # Spring 다운 지점(약 300)의 절반
limit_req_zone  $server_name zone=page_all:1m rate=100r/s;  # Next.js 서버 렌더링 요청
limit_req_zone  $server_name zone=img_all:1m  rate=20r/s;   # 이미지 리사이즈는 CPU를 많이 써서 낮게

# [추가] Spring에 동시에 처리를 맡기는 요청 수의 전체 상한.
limit_conn_zone $server_name zone=conn_all:1m;
  • req, res time 로그에 남도록 설정

    $request_time
    $upstream_response_time
    $limit_req_status
    $limit_conn_status
    
  • DDos(분산된 환경에서의 공격) 대비 전체 IP 허용 제한 거는 방식 추가 — 전체 Spring 동시 요청 제한

    location /api/ {
        limit_conn conn_ip 50;
        limit_conn api_total 100;
    }
    
  • 그 외의 요청(무작위 url)에 대한 요청 제한 추가


Rate limit 설정 이유

1. Rate limit 알고리즘 종류

1. Fixed window

  • 고정된 시간 구간마다 요청 수를 셈

    10:00:00 ~ 10:00:59 → 최대 100회
    10:01:00 ~ 10:01:59 → 다시 최대 100회
    
  • 시간 경계에 허점 존재

    10:00:59에 100회
    10:01:00에 100회
    

    이 알고리즘 상 다른 1분에 각각 100번의 요청이지만, 실제로는 수십 초동안 요청을 몰아보냄

2. sliding window

현재 시각이 10:01:20이면
10:00:20 ~ 10:01:20 사이 요청 수를 계산

현재 시점 기준 바로 이전 일정 시간 계산

3. Token bucket

통의 최대 크기: 토큰 10개
초당 토큰 2개 충전
요청 1회: 토큰 1개 사용

토큰이 있으면 요청 처리, 없으면 거절 — 지속적인 대량 요청 차단

4. Leaky bucket

  • Nginx limit_req가 공식적으로 사용하는 방식

    요청이 양동이에 들어옴
            ↓
    정해진 속도로만 빠져나감
            ↓
    양동이가 꽉 차면 추가 요청 거절
    
  • ex. 초당 2개 가정시

    정상 요청 1~2개
    → 통과
    
    순간 요청 5개
    → burst 범위라면 일부 허용
    
    계속 초당 68개
    → 허용 범위를 초과한 요청 거절
    

    https://nginx.org/en/docs/http/ngx_http_limit_req_module.html

2. Rate limit 설정

찰리가 보낸 공격은,

  • 단일 IP에
  • 특정 API 집중
  • 짧은 시간 반복
  • HTTP/2 병렬 요청 — 동시성 제한 필요

따라서, nginx 기본제공 leaky bucket(limit_req)를 활용 — IP별 초당 요청 횟수 제한 및 나머지 429 거절

이를 통해 Spring이 죽기전에 차단

2-1. burst 활용

Leaky Bucket을 너무 딱딱하게 적용하면 정상적인 페이지 로딩도 막을 수 있음

ex. 페이지를 열 때 브라우저가 동시에 여러 API를 호출

  • burst는 ‘순간 폭주 최대치 설정’

3. 결론

1. 누구를 기준으로 셀지: IP
2. 지속 허용량: rate
3. 순간 허용량: burst
4. 동시 처리량: limit_conn
알고리즘
Nginx Leaky Bucket

지속 속도 rate limit

짧은 순간 요청
→ burst

동시 요청 설정

거절 응답
→ 429

최근 7일간의 API 요청을 확인하려고 했으나, 현재 nginx 로그가 다음 정보만 기록

IP
요청 시각
HTTP 메서드·경로
상태 코드
응답 크기
Referer
User-Agent

필요한 것:
$request_time
$upstream_response_time

구조

사용자
  ↓
Nginx
  ├─ /api/*          → Spring Boot
  ├─ /_next/static/* → Next.js + Nginx 캐시
  └─ 나머지 경로       → Next.js
  1. spring boot

    • IP당 지속적으로 초당 20건
    • 순간 초과 요청 최대 40건
    • IP당 동시 요청 최대 50건
    • 전체 API 동시 요청 최대 100건
    • 업로드 가능한 요청 본문 최대 10MB
    • Spring 응답을 최대 60초까지 기다림

    API는 DB 조회·저장과 비즈니스 로직을 수행하므로 Next.js 정적 파일보다 엄격하게 제한한 구조

    운영 트래픽과 Spring의 Thread Pool, DB Connection Pool 크기를 측정한 후 조정 필요

  2. Next.js 정적 파일

    • IP당 초당 30건
    • 순간 초과 요청 최대 100건
    • IP당 동시 요청 최대 100개
    • 응답을 Nginx 디스크에 7일간 캐싱

    웹페이지 하나를 열 때 브라우저가 여러 JavaScript와 CSS 파일을 동시에 요청하기 때문에 API보다 순간 허용량을 크게 잡음

    첫 번째 요청 → Next.js에서 파일을 가져와 Nginx에 저장
    두 번째 요청 → Next.js까지 가지 않고 Nginx가 바로 응답
    

    *stampede 방지— proxy_cache_lock on
    캐시에 없는 동일 파일을 사용자 100명이 동시에 요청해도 Next.js에는 대표 요청 하나만 보내도록 막아줌

  3. Next.js 일반 페이지

    • IP당 초당 30건
    • 순간 초과 요청 최대 50건
    • IP당 동시 요청 최대 100개
  4. upstream 연결 재사용

    Nginx가 요청마다 Spring과 TCP 연결을 새로 만들지 않고 기존 연결을 재사용

    TCP 연결을 매번 만드는 비용을 줄이는 설정

    • Spring: 최대 32개의 유휴 연결 유지
    • Next.js: 최대 16개의 유휴 연결 유지
    • 사용하지 않는 연결은 30초 후 정리
  5. 스캐너 요청 차단

    • /wp-admin, /wp-login 등 워드프레스 탐색
    • /.env, /.git 등 숨김 파일 탐색
    • .sql, .bak, .old 등 백업 파일 탐색