2. Nginx 리버스 프록시의 동작 원리 (현재 문서)
API Gateway가 "무엇을" 하는지는 알았다. 이제 "어떻게" 하는지를 알아야 한다. Nginx가 요청을 받아서 백엔드 서비스로 전달하는 메커니즘, 즉 리버스 프록시의 동작 원리를 파헤친다.
웹서버와 리버스 프록시
Nginx는 원래 정적 파일을 서빙하는 웹서버로 태어났다. HTML, CSS, JS 파일을 클라이언트에게 전달하는 것이 본래 역할이다. 하지만 Nginx가 진짜 강력한 이유는 리버스 프록시 기능에 있다.
프록시라는 단어는 "대리인"이라는 뜻이다. 네트워크에서 프록시는 두 가지 방향으로 작동한다.
- 포워드 프록시 : 클라이언트 앞에 서서 클라이언트를 대신해 서버에 요청한다. 회사에서 인터넷 접속할 때 거치는 프록시 서버가 이것이다.
- 리버스 프록시 : 서버 앞에 서서 서버를 대신해 클라이언트의 요청을 받는다. 클라이언트는 뒤에 어떤 서버가 있는지 모른다.
API Gateway로서의 Nginx는 리버스 프록시다. 클라이언트가 Nginx에 요청을 보내면, Nginx가 그 요청을 적절한 백엔드 서비스로 전달하고, 응답을 받아서 다시 클라이언트에게 돌려준다.
요청이 어떤 경로를 거치는지 보면 차이가 명확해진다.
(리버스 프록시) participant B as 백엔드 서비스 rect rgb(232, 248, 232) Note over C, N: 클라이언트는 Nginx만 본다 C->>N: GET /api/users end rect rgb(240, 248, 255) Note over N, B: Nginx가 백엔드에 대신 요청 N->>B: GET /api/users B-->>N: 200 OK + 데이터 end rect rgb(232, 248, 232) Note over C, N: Nginx가 응답을 전달 N-->>C: 200 OK + 데이터 end
클라이언트 입장에서는 Nginx가 곧 서버다. 초록 영역에서 보이듯 클라이언트는 Nginx에 요청을 보내고 Nginx로부터 응답을 받는다. 파란 영역에서 일어나는 백엔드와의 통신은 클라이언트에게 보이지 않는다.
Nginx 설정 파일의 구조
Nginx는 코드가 아니라 설정 파일로 동작한다. 설정 파일은 계층적인 블록 구조로 되어 있고, 각 블록이 서로 다른 역할을 담당한다.
API Gateway 설정에서 핵심이 되는 블록은 세 가지다.
nginx.conf (최상위)
├── http { ← HTTP 전역 설정
│ ├── upstream { } ← 백엔드 서비스 그룹 정의
│ └── server { ← 가상 호스트 (하나의 도메인/포트)
│ └── location { }← URL 패턴별 처리 규칙
│ }
}
이 시리즈의 실습 프로젝트에서는 api-gateway.conf 파일 하나에 upstream, server, location을 모두 정의한다. Nginx의 기본 설정 파일이 conf.d/ 디렉토리에서 이 파일을 자동으로 포함하기 때문이다.
(location)"} MATCH -->|"/api/users"| U["upstream
user-service"] MATCH -->|"/api/orders"| O["upstream
order-service"] MATCH -->|"/api/payments"| P["upstream
payment-service"] MATCH -->|"/"| S["정적 파일
서빙"] end style REQ fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style MATCH fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style U fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style O fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style P fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style S fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style SERVER fill:#fff8f0,stroke:#FF9800
요청이 들어오면 server 블록이 받고, location에서 URL 패턴을 매칭한 뒤, 해당하는 upstream으로 전달한다. 이 흐름을 각 블록별로 자세히 살펴본다.
upstream — 백엔드 서비스 그룹 정의
upstream은 Nginx가 요청을 전달할 백엔드 서비스의 주소를 등록하는 블록이다. 이름을 붙여서 나중에 proxy_pass에서 참조한다.
upstream user-service {
server user-service:8081;
}
upstream order-service {
server order-service:8082;
}
upstream payment-service {
server payment-service:8083;
}
server user-service:8081에서 앞의 user-service는 Docker 네트워크의 DNS 이름이다. Docker Compose로 띄우면 각 컨테이너가 서비스 이름으로 서로를 찾을 수 있기 때문에, IP 주소 대신 이름을 쓸 수 있다.
로드 밸런싱
upstream에 같은 서비스의 서버를 여러 개 등록하면 로드 밸런싱이 된다. 기본 방식은 라운드 로빈이다.
upstream user-service {
server user-service-1:8081;
server user-service-2:8081;
server user-service-3:8081;
}
이렇게 등록하면 요청이 1번 → 2번 → 3번 → 1번 순서로 순환하며 분산된다. 이 실습에서는 각 서비스가 1개 인스턴스이므로 로드 밸런싱을 직접 구현하지는 않지만, upstream 구조 자체가 이미 로드 밸런싱을 위한 틀이라는 점을 알아두면 된다.
upstream의 이름과 Docker Compose의 서비스 이름, 그리고 server 지시어에 쓰는 호스트 이름이 모두 같으면 혼란스러울 수 있다. upstream 이름은 Nginx 내부에서만 쓰는 참조용 식별자이고, server 지시어의 호스트는 실제 네트워크 DNS 이름이다. 같은 이름을 쓰는 것은 관례일 뿐 필수가 아니다.
location — URL 패턴 매칭
location은 요청 URL에 따라 어떤 처리를 할지 결정하는 블록이다. 하나의 server 블록 안에 여러 location을 정의해서, URL 패턴마다 다른 동작을 지정한다.
실습 프로젝트의 location 구성을 보면 네 가지 패턴이 있다.
# 루트 경로 — 정적 파일 서빙
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# 정적 파일 — 확장자 기반 매칭
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg)$ {
root /usr/share/nginx/html;
}
# 헬스체크 — 직접 응답
location /health {
add_header Content-Type application/json;
return 200 '{"status":"UP","service":"api-gateway"}';
}
# API 라우팅 — 리버스 프록시
location /api/users {
proxy_pass http://user-service;
# ...프록시 헤더 설정
}
각 location이 매칭되는 방식과 처리가 다르다는 점에 주목해야 한다.
매칭 우선순위
Nginx는 여러 location 중에서 가장 구체적으로 일치하는 것을 선택한다. 매칭 순서는 다음과 같다.
=정확 일치 :location = /health는/health에만 매칭~*정규식 :location ~* \.(css|js)$는.css,.js로 끝나는 경로에 매칭- 접두사 일치 :
location /api/users는/api/users로 시작하는 모든 경로에 매칭 /기본값 : 다른 어떤 location에도 매칭되지 않으면 여기로 온다
/api/users/1이라는 요청이 오면 location /api/users에 매칭된다. 접두사 방식이기 때문에 /api/users로 시작하는 모든 경로를 잡아낸다. /api/users/1/orders처럼 하위 경로도 마찬가지다.
매칭?"} R -->|"아니오"| P{"/api/users
접두사?"} P -->|"일치"| PROXY["proxy_pass
→ user-service"] E -->|"일치"| HEALTH["직접 응답"] R -->|"일치"| STATIC["정적 파일 서빙"] style REQ fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style E fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style R fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style P fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style PROXY fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style HEALTH fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style STATIC fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000
proxy_pass — 요청 전달의 핵심
proxy_pass는 리버스 프록시의 핵심 지시어다. 요청을 지정한 upstream 또는 URL로 전달하라는 명령이다.
location /api/users {
proxy_pass http://user-service;
}
이 한 줄로 /api/users로 들어온 요청이 upstream에 정의된 user-service:8081로 전달된다. 이때 요청 URL 경로는 그대로 유지된다. 클라이언트가 /api/users/1을 요청하면, 백엔드 서비스도 /api/users/1로 받는다.
proxy_pass의 URL 끝에 슬래시를 붙이면 동작이 달라진다.
proxy_pass http://user-service;→ 경로 그대로 전달./api/users/1→/api/users/1proxy_pass http://user-service/;→ location 부분이 잘려나간다./api/users/1→/1
이 차이를 모르면 백엔드에서 404가 나오는 원인을 찾지 못한다. 실습에서는 백엔드 Controller가 @RequestMapping("/api/users")로 매핑되어 있으므로, 경로를 그대로 전달하는 슬래시 없는 형태를 사용한다.
실제 요청이 어떤 경로로 전달되는지 전체 흐름을 보면 이해가 쉽다.
→ user-service:8081 end rect rgb(232, 248, 232) N->>U: GET /api/users/1 Note right of U: @GetMapping("/{id}")
PathVariable id = 1 U-->>N: 200 OK + UserDto end N-->>C: 200 OK + UserDto
주황 영역에서 Nginx가 location 매칭과 upstream 조회를 수행하고, 초록 영역에서 실제 백엔드 통신이 이루어진다. 요청 경로 /api/users/1이 그대로 전달되기 때문에, Spring의 @GetMapping("/{id}")가 정상적으로 동작한다.
프록시 헤더 — 클라이언트 정보 전달
리버스 프록시를 거치면 한 가지 문제가 생긴다. 백엔드 서비스 입장에서는 요청을 보낸 상대가 Nginx다. 원래 클라이언트의 IP 주소나 프로토콜 정보가 사라진다. 이 정보를 보존하기 위해 프록시 헤더를 설정한다.
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;
각 헤더가 어떤 정보를 전달하는지 알아야 한다.
- Host : 클라이언트가 요청한 원래 호스트 이름.
$host변수는 요청의 Host 헤더 값을 담고 있다. 이 설정이 없으면 백엔드가 upstream 이름을 Host로 받게 된다. - X-Real-IP : 실제 클라이언트의 IP 주소.
$remote_addr은 Nginx에 직접 연결된 클라이언트의 IP다. - X-Forwarded-For : 요청이 거쳐온 프록시 체인의 IP 목록. 프록시가 여러 단계일 때 모든 경유지가 기록된다.
- X-Forwarded-Proto : 원래 요청의 프로토콜. HTTPS로 들어온 요청을 HTTP로 백엔드에 전달할 때, 백엔드가 원래 프로토콜을 알 수 있게 해준다.
프록시 헤더가 없을 때와 있을 때의 차이를 비교하면 필요성이 분명해진다.
192.168.1.100 participant N as Nginx
10.0.0.1 participant B as 백엔드 rect rgb(255, 230, 230) Note over C, B: 프록시 헤더 없음 C->>N: GET /api/users N->>B: GET /api/users Note right of B: remote_addr: 10.0.0.1
Host: user-service
클라이언트 IP 유실 end
프록시 헤더를 설정하면 백엔드가 원래 클라이언트 정보를 정확히 알 수 있다.
192.168.1.100 participant N as Nginx
10.0.0.1 participant B as 백엔드 rect rgb(232, 248, 232) Note over C, B: 프록시 헤더 설정 C->>N: GET /api/users N->>B: GET /api/users Note right of B: Host: localhost
X-Real-IP: 192.168.1.100
X-Forwarded-For: 192.168.1.100
X-Forwarded-Proto: http end
분홍 영역에서는 백엔드가 Nginx의 IP만 보이고 클라이언트 정보를 알 수 없다. 초록 영역에서는 프록시 헤더를 통해 원래 클라이언트의 IP와 프로토콜 정보가 온전히 전달된다.
X-Forwarded-For는 클라이언트가 직접 조작할 수 있는 헤더다. 악의적인 클라이언트가 가짜 IP를 넣을 수 있다. 따라서 이 헤더를 신뢰할 수 있는 범위는 자신이 관리하는 프록시가 추가한 부분뿐이다. IP 기반 접근 제어를 할 때는 $remote_addr을 우선으로 사용해야 한다.
커스텀 응답 헤더
실습 프로젝트에서는 라우팅이 제대로 작동하는지 확인하기 위해 커스텀 응답 헤더를 추가한다.
add_header X-Gateway-Service "user-service" always;
add_header X-Rate-Limit "20/min" always;
add_header는 Nginx가 클라이언트에게 보내는 응답에 헤더를 추가하는 지시어다. always 키워드는 에러 응답에도 헤더를 포함시킨다. 이 키워드가 없으면 200 OK 같은 성공 응답에만 헤더가 붙는다.
테스트 대시보드에서 이 헤더를 읽어서 어떤 서비스로 라우팅되었는지, Rate Limit이 얼마인지를 화면에 표시한다. 디버깅과 모니터링에 유용한 패턴이다.
정적 파일 서빙과 SPA 라우팅
Nginx의 본래 역할인 정적 파일 서빙도 빠뜨릴 수 없다. 실습에서는 테스트 대시보드의 HTML, CSS, JS 파일을 Nginx가 직접 서빙한다.
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
try_files는 Nginx가 파일을 찾는 순서를 정의한다.
$uri: 요청 경로에 해당하는 파일이 있는지 확인./style.css→/usr/share/nginx/html/style.css$uri/: 디렉토리인지 확인/index.html: 둘 다 아니면index.html로 넘긴다
세 번째 폴백이 있기 때문에 SPA에서 새로고침을 해도 404가 나지 않는다. React나 Vue 같은 SPA를 Nginx로 서빙할 때 반드시 필요한 설정이다.
자주 하는 실수
proxy_pass http://service;와 proxy_pass http://service/;는 완전히 다르게 동작한다. 슬래시 하나의 차이로 경로가 잘리면서 백엔드에서 404가 발생한다. Spring Controller의 @RequestMapping 경로와 Nginx의 location + proxy_pass 조합이 어떤 최종 경로를 만드는지 반드시 확인해야 한다.
[!DANGER] 프록시 헤더를 설정하지 않기
프록시 헤더 없이 리버스 프록시를 구성하면, 백엔드의 접근 로그에 모든 요청이 Nginx의 IP로 기록된다. IP 기반 Rate Limiting이나 감사 로깅이 무의미해진다. proxy_set_header는 리버스 프록시의 필수 설정이다.
[!DANGER] location 매칭 우선순위를 모르고 설정하기
/api와 /api/users 두 location이 있을 때, /api/users/1 요청은 더 구체적인 /api/users에 매칭된다. 이 우선순위를 모르면 의도와 다른 location으로 요청이 빠지는 원인을 찾기 어렵다. 특히 정규식 location은 접두사 location보다 우선순위가 높다는 점을 기억해야 한다.