웹 요청 앞에 서는 Nginx의 역할

Nginx 기초: 프록시와 리버스 프록시 이해하기의 핵심 흐름도
그림. 포워드 프록시는 클라이언트 쪽, 리버스 프록시는 서버 쪽을 대신한다.

Nginx란

Nginx는 HTTP 웹 서버, 리버스 프록시, 로드 밸런서로 사용할 수 있는 서버 프로그램이다. 이벤트 기반 구조로 많은 연결을 적은 수의 워커 프로세스에서 처리한다. 애플리케이션이 직접 인터넷에 노출되지 않게 앞에 두고, TLS 종료, 도메인 분기, 정적 파일 전달, 압축, 캐시, 요청 제한을 맡기는 구성이 흔하다.

브라우저가 `https://example.com/posts`를 요청하면 DNS가 서버 주소를 찾고 TCP 연결과 TLS 연결이 만들어진다. Nginx가 443 포트에서 요청을 받은 뒤 정적 파일을 직접 돌려주거나, 내부 애플리케이션으로 전달한다.

브라우저 → 인터넷 → Nginx :443 → 애플리케이션 127.0.0.1:3000
                           └→ 정적 파일 /var/www/site

프록시란

프록시는 두 통신 주체 사이에서 요청과 응답을 중계하는 서버다. 누가 누구를 대신하는지에 따라 포워드 프록시와 리버스 프록시로 나뉜다.

구분포워드 프록시리버스 프록시
대신하는 쪽클라이언트서버
설정 위치브라우저, 운영체제, 사내망서비스 도메인의 서버 앞
목적외부 접속 통제, 클라이언트 주소 은닉, 캐시서버 은닉, TLS 종료, 분산, 캐시, 보안
외부에서 보이는 대상프록시 뒤의 클라이언트가 감춰진다.프록시 뒤의 애플리케이션이 감춰진다.

포워드 프록시

포워드 프록시는 사용자를 대신해 외부 서버에 접속한다. 회사 내부 PC가 인터넷에 직접 나가지 않고 사내 프록시를 거치는 구성이 예다. 외부 사이트는 실제 PC 대신 프록시의 주소를 본다.

클라이언트 A ─┐
클라이언트 B ─┼→ 포워드 프록시 → 인터넷의 여러 서버
클라이언트 C ─┘

포워드 프록시는 접속 정책 적용, 유해 사이트 차단, 감사 로그, 반복 다운로드 캐시에 쓸 수 있다. VPN과 같지는 않다. VPN은 네트워크 계층에서 트래픽 경로를 만들고, HTTP 프록시는 주로 애플리케이션 계층의 요청을 이해하고 중계한다.

Nginx의 주 용도는 포워드 프록시가 아니다. 일반적인 HTTPS 포워드 프록시는 CONNECT 터널과 클라이언트별 정책이 필요하므로 Squid 같은 전용 도구가 더 적합하다. Nginx의 `proxy_pass`는 보통 다음 절의 리버스 프록시를 구성한다.

리버스 프록시

리버스 프록시는 서비스 서버를 대신해 외부 요청을 받는다. 사용자는 뒤에 Next.js, Python, Java 서버가 몇 개 있는지 알 필요가 없다. 도메인 하나와 Nginx만 본다.

                        ┌→ Next.js 127.0.0.1:3000
클라이언트 → Nginx ────┼→ Python  127.0.0.1:8000
                        └→ 정적 파일 /var/www/assets

리버스 프록시를 두면 다음 책임을 한곳에 모을 수 있다.

  • HTTPS 인증서와 TLS 연결을 Nginx에서 처리한다.
  • 도메인이나 경로에 따라 서로 다른 애플리케이션으로 보낸다.
  • 외부에서는 내부 포트와 서버 구성이 보이지 않는다.
  • 여러 서버에 요청을 나누고 고장 난 서버를 제외할 수 있다.
  • 압축, 캐시, 접근 로그, 요청 크기와 속도 제한을 적용한다.

리버스 프록시 자체가 애플리케이션의 취약점을 없애는 것은 아니다. 인증, 입력 검증, 권한 검사는 여전히 애플리케이션 책임이다.

가장 작은 리버스 프록시 설정

다음 설정은 80 포트의 요청을 로컬 3000 포트 애플리케이션으로 보낸다.

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
    }
}

`listen`은 받을 포트, `server_name`은 담당할 호스트 이름이다. `location /`은 모든 경로에 맞고, `proxy_pass`가 업스트림 주소를 정한다.

`Host`는 사용자가 요청한 도메인을 애플리케이션에 전달한다. `X-Real-IP`는 Nginx가 직접 본 클라이언트 주소다. `X-Forwarded-For`는 기존 전달 경로에 현재 주소를 추가한다. `X-Forwarded-Proto`는 원래 요청이 HTTP인지 HTTPS인지 알려 준다.

애플리케이션은 아무 곳에서 온 `X-Forwarded-For`를 무조건 믿으면 안 된다. 신뢰할 수 있는 Nginx에서 들어온 요청에만 전달 헤더를 신뢰하도록 프레임워크의 trusted proxy 설정을 맞춘다.

proxy_pass의 경로 규칙

`proxy_pass` 뒤 URI에 슬래시가 있는지에 따라 업스트림으로 보내는 경로가 달라진다.

location /api/ {
    proxy_pass http://127.0.0.1:8000/;
}

위 설정에서 `/api/users`는 업스트림의 `/users`가 된다. 반면 다음처럼 업스트림 주소 뒤에 URI를 쓰지 않으면 원래 URI가 유지되어 `/api/users`로 전달된다.

location /api/ {
    proxy_pass http://127.0.0.1:8000;
}

경로가 두 번 붙거나 사라지는 문제는 이 차이에서 자주 생긴다. 브라우저 주소, 선택된 location, 업스트림이 받은 경로를 함께 확인한다.

HTTPS 종료

운영에서는 80 포트를 HTTPS로 돌리고 443에서 인증서를 사용한다.

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

인증서 개인키는 소스 저장소나 게시물에 넣지 않는다. 키 파일 권한을 제한하고, 자동 갱신 뒤 Nginx가 새 인증서를 읽도록 reload한다.

WebSocket 프록시

WebSocket은 HTTP 요청으로 시작한 뒤 연결을 업그레이드한다. Upgrade와 Connection 헤더가 필요하다.

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    location /ws/ {
        proxy_pass http://127.0.0.1:9320;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_read_timeout 60s;
    }
}

장시간 아무 데이터가 없으면 중간 장비와 timeout이 연결을 닫을 수 있다. 애플리케이션의 ping 주기와 `proxy_read_timeout`을 함께 설계한다.

여러 서버에 분산하기

`upstream` 블록은 여러 백엔드를 이름 하나로 묶는다.

upstream app_cluster {
    least_conn;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
}

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://app_cluster;
    }
}

기본 방식은 순서대로 나누는 round-robin이고, `least_conn`은 활성 연결이 적은 서버를 고른다. 메모리에만 세션을 둔 애플리케이션을 여러 대로 늘리면 다음 요청이 다른 서버로 가서 로그인이 풀릴 수 있다. 세션을 공용 저장소에 두거나 서명된 쿠키를 사용해야 한다.

오픈소스 Nginx의 수동 upstream만으로 완전한 장애 감지를 기대해서는 안 된다. 재시도 조건, 애플리케이션 헬스 체크, 배포 플랫폼의 감시를 함께 둔다.

정적 파일과 캐시

정적 파일은 애플리케이션까지 보내지 않고 Nginx가 직접 읽을 수 있다.

location /assets/ {
    alias /var/www/site/assets/;
    expires 7d;
    add_header Cache-Control "public";
}

`root`는 요청 URI를 경로 뒤에 붙이고, `alias`는 location에 대응하는 부분을 지정한 경로로 바꾼다. 슬래시가 맞지 않으면 의도하지 않은 파일을 가리킬 수 있으므로 실제 경로를 검사한다.

프록시 캐시는 원본 서버 부하를 줄이지만 사용자별 응답을 섞으면 개인정보가 노출된다. Cookie와 Authorization이 있는 응답, 사용자별 페이지는 명확한 정책 없이는 캐시하지 않는다.

timeout과 요청 크기

외부 요청이 무한히 자원을 잡지 않도록 연결, 전송, 읽기 시간을 정한다.

location /api/ {
    client_max_body_size 10m;
    proxy_connect_timeout 3s;
    proxy_send_timeout 30s;
    proxy_read_timeout 30s;
    proxy_pass http://127.0.0.1:8000;
}

파일 업로드 크기는 Nginx와 애플리케이션 양쪽 제한을 맞춘다. timeout을 무조건 크게 하면 느린 장애가 연결을 오래 점유한다. 정상 요청 분포를 측정해 정한다.

멱등하지 않은 POST 요청을 다른 서버로 자동 재시도하면 같은 작업이 두 번 실행될 수 있다. 재시도 정책과 idempotency key를 함께 설계한다.

설정 확인과 무중단 반영

설정을 바꾼 뒤 바로 프로세스를 죽이지 않는다.

sudo nginx -t
sudo systemctl reload nginx

`nginx -t`는 문법과 참조 파일을 검사한다. reload는 새 워커가 새 설정으로 시작하고 기존 워커는 처리 중인 연결을 마친 뒤 종료하게 한다. 테스트가 실패하면 reload하지 않는다.

문제를 찾을 때는 순서를 나눈다.

  1. 애플리케이션이 내부 주소에서 직접 응답하는지 확인한다.
  2. `nginx -t`로 설정을 검사한다.
  3. Nginx access log와 error log를 확인한다.
  4. 요청의 Host, 경로, 상태 코드, 업스트림 응답 시간을 확인한다.
  5. 방화벽, DNS, TLS 인증서의 도메인과 만료를 확인한다.

502 Bad Gateway는 대개 Nginx가 업스트림에 연결하지 못했거나 올바른 응답을 받지 못했다는 뜻이다. 504 Gateway Timeout은 정한 시간 안에 업스트림 응답이 끝나지 않았다는 뜻이다. 두 경우 모두 애플리케이션 로그와 Nginx 오류 로그를 같이 봐야 한다.

보안 점검

  • 외부에 필요 없는 애플리케이션 포트는 127.0.0.1에만 바인딩한다.
  • 관리자 경로는 인증과 네트워크 제한을 함께 둔다.
  • 요청 헤더를 신뢰하기 전에 Nginx가 덮어쓰는지 확인한다.
  • 서버 버전 노출을 줄여도 패치가 대신되지는 않는다. 보안 업데이트를 적용한다.
  • 인증서 키, API 키, DB 비밀번호를 설정 파일 저장소에 커밋하지 않는다.
  • 요청 속도 제한은 공격 완화의 한 층일 뿐, CDN·방화벽·애플리케이션 제한을 대신하지 않는다.

Nginx를 이해하는 가장 중요한 기준은 방향이다. 포워드 프록시는 안쪽의 클라이언트를 대신해 밖으로 나가고, 리버스 프록시는 안쪽의 서버를 대신해 밖의 요청을 받는다. `proxy_pass`로 애플리케이션 앞에 서는 일반적인 Nginx 설정은 리버스 프록시다.