프록시와 VPN
클라이언트를 대신하는 포워드 프록시와 서버를 대신하는 리버스 프록시의 차이와 쓰임을 정리하고, nginx 설정에서 요청 경로가 뒤쪽 서버로 어떻게 넘어가는지와 X-Forwarded-For를 안전하게 읽는 방법을 코드로 확인합니다. 공용 인터넷 위에 사설망을 잇는 VPN의 터널과 보호 범위, MTU를 줄여야 하는 이유도 함께 봅니다.
프록시(proxy)는 클라이언트와 서버 사이에서 요청을 대신 전하는 서버입니다. 같은 일을 하더라도 누구를 대신하느냐에 따라 쓰임이 완전히 다릅니다. 회사의 직원들이 인터넷에 나갈 때 거치는 프록시와, 사용자의 요청을 받아 뒤쪽의 여러 서버로 나누어 주는 프록시는 방향이 반대입니다. 이 글에서는 두 프록시와 함께, 공용 인터넷 위에 사설망을 만드는 VPN을 다룹니다.
누구를 대신하는가
| 구분 | 누가 고르나 | 대신하는 쪽 | 하는 일 |
|---|---|---|---|
| 포워드 프록시(forward proxy) | 클라이언트가 설정한다 | 클라이언트 | 바깥으로 나가는 요청을 대신 보내고, 회사의 접속 정책을 적용한다 |
| 리버스 프록시(reverse proxy) | 서비스가 앞에 둔다 | 서버 | 서비스로 들어오는 요청을 받아 뒤쪽 서버로 전하고, TLS와 캐시, 라우팅을 맡는다 |
포워드 프록시를 쓰는 클라이언트는 자기가 프록시를 거친다는 것을 압니다. 리버스 프록시 뒤의 서비스에 접속하는 클라이언트는 대개 그것을 모르고, 리버스 프록시를 서비스 그 자체로 여깁니다.
포워드 프록시
포워드 프록시는 클라이언트 쪽에 있습니다. 목적지 서버가 보는 것은 프록시의 주소이므로, 회사나 학교는 이를 이용해 정해진 사이트로의 접속을 막거나 접속 기록을 남깁니다. HTTPS 사이트로 가는 요청은 클라이언트가 프록시에 CONNECT example.com:443 요청을 보내 터널을 열게 하고(RFC 9110 §9.3.6), 그 안에서 서버와 직접 TLS를 맺습니다. 그래서 프록시는 어느 호스트와 포트로 가는지는 알지만 내용은 볼 수 없습니다. 내용까지 검사하려는 회사는 10장 4편에서 본 것처럼 직원의 기기에 회사의 루트 인증서를 설치하고 프록시가 TLS를 풀었다가 다시 암호화하게 합니다.
개발자는 회사 망에서 패키지를 설치하거나 저장소를 받을 때 포워드 프록시를 자주 만납니다. npm install이나 git clone이 회사 안에서만 실패한다면 DNS와 인증서와 함께 프록시 설정을 확인합니다.
export HTTP_PROXY=http://proxy.example.com:8080
export HTTPS_PROXY=http://proxy.example.com:8080
export NO_PROXY=localhost,127.0.0.1,.example.com # 프록시를 거치지 않을 곳
npm config set proxy http://proxy.example.com:8080 # npm 설정 파일에 저장된다
git config --global http.proxy http://proxy.example.com:8080환경 변수의 이름과 해석은 도구마다 조금씩 다릅니다. 예를 들어 curl은 대부분의 프록시 변수를 대문자와 소문자 모두 읽지만, http_proxy만은 소문자로만 읽습니다. NO_PROXY의 앞에 점을 붙인 도메인을 하위 도메인까지로 읽는지도 도구마다 다르므로, 프록시를 거치지 말아야 할 내부 주소가 프록시로 가고 있다면 그 도구의 문서를 확인합니다. npm config와 git config --global은 설정 파일에 값을 저장하므로, 회사 밖에서 같은 컴퓨터를 쓸 때 문제가 될 수 있다는 점도 기억해 둡니다.
리버스 프록시
리버스 프록시는 서버 쪽에서 서비스의 입구를 맡습니다. 앞 글의 L7 로드 밸런서도 리버스 프록시의 한 형태이며, 그 밖에도 여러 일을 합니다.
| 기능 | 하는 일 | 조건 |
|---|---|---|
| TLS 종료 | 인증서를 입구 한곳에서 관리한다 | 뒤쪽 서버까지의 TLS는 따로 정한다 |
| 캐시 | 저장해도 되는 응답을 대신 돌려준다 | 9장 4편의 캐시 규칙을 따른다 |
| 압축 | gzip이나 Brotli로 응답을 줄인다 | CPU를 그만큼 쓴다 |
| 경로 라우팅 | 경로에 따라 다른 서버로 보낸다 | 경로를 바꿔 넘길 때의 규칙을 알아야 한다 |
| 요청 제한 | 정해 둔 기준으로 요청 수를 제한한다 | 아주 큰 공격은 앞단의 방어가 필요하다 |
리버스 프록시를 둔다고 뒤쪽 서버가 숨겨지는 것은 아닙니다. 응답 헤더나 오류 페이지에 뒤쪽 서버의 정보가 드러날 수 있고, 뒤쪽 서버의 주소가 알려지면 프록시를 우회해 직접 접속할 수도 있으므로, 뒤쪽 서버는 프록시에서 온 연결만 받도록 방화벽으로 막아 둡니다.
nginx 설정으로 보는 리버스 프록시
nginx는 가장 널리 쓰는 리버스 프록시입니다. 다음은 HTTP를 HTTPS로 넘기고, 정적 파일은 직접 돌려주며, API와 WebSocket은 뒤쪽 서버로 넘기는 설정의 예입니다. http 블록 안에 두는 설정이고, http2 on은 nginx 1.25.1 이후의 문법입니다.
upstream backend {
least_conn; # 연결이 적은 서버를 고른다
server 127.0.0.1:3001 weight=3;
server 127.0.0.1:3002 weight=1;
server 127.0.0.1:3003 backup; # 다른 서버를 모두 쓸 수 없을 때만
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri; # HTTP는 HTTPS로 보낸다
}
server {
listen 443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
location /static/ { # 정적 파일은 뒤쪽 서버 없이 바로 준다
root /var/www;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 30s; # 응답 전체가 아니라 두 번의 읽기 사이의 한도
}
location /ws/ { # WebSocket의 업그레이드를 넘긴다
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
}location /static/의 root /var/www는 /static/app.js 요청을 /var/www/static/app.js 파일로 찾습니다. immutable은 13장 1편처럼 내용이 바뀌면 이름도 바뀌는 파일에만 붙입니다. WebSocket은 12장 3편에서 본 것처럼 Upgrade와 Connection 헤더를 뒤쪽으로 전해야 하고, 연결이 오래 조용할 수 있으므로 읽기 시간 제한을 길게 두거나 Ping으로 연결을 살려 둡니다.
proxy_pass에 URI를 붙이느냐에 따라 뒤쪽 서버가 받는 경로가 달라집니다. nginx 문서에 따르면 URI가 없으면 받은 요청의 URI를 그대로 넘기고, URI가 있으면 location에 맞은 부분을 그 URI로 바꾸어 넘깁니다. 다음 코드는 이 규칙을 흉내 냅니다.
def upstream_uri(location, proxy_pass_uri, request_uri):
if proxy_pass_uri is None:
return request_uri # URI가 없으면 받은 그대로 넘긴다
return proxy_pass_uri + request_uri[len(location):] # location에 맞은 부분을 바꾼다
req = "/api/v1/users?limit=2"
print(upstream_uri("/api/", None, req)) # proxy_pass http://backend;
print(upstream_uri("/api/", "/", req)) # proxy_pass http://backend/;
print(upstream_uri("/api/", "/v2/", req)) # proxy_pass http://backend/v2/;/api/v1/users?limit=2
/v1/users?limit=2
/v2/v1/users?limit=2http://backend와 http://backend/는 끝의 / 하나 차이지만, 뒤쪽 서버가 받는 경로는 /api/v1/users와 /v1/users로 다릅니다. 프록시를 거치면 404가 나는데 직접 부르면 되는 문제의 상당수가 이 규칙에서 생깁니다. 실제 nginx는 URI를 바꿀 때 퍼센트 인코딩을 푼 정규화된 URI를 쓰므로, 인코딩된 문자가 든 경로는 문서를 함께 확인합니다.
더 깊이: nginx와 뒤쪽 서버 사이의 연결은 재사용될까
nginx 문서에 따르면 1.29.7보다 앞선 nginx는 뒤쪽 서버에 HTTP/1.0으로 요청하고, 뒤쪽 서버와의 연결을 남겨 두는 캐시도 기본으로 꺼져 있었습니다. 그래서 위 설정의 /api/처럼 따로 지정하지 않으면 요청마다 뒤쪽 서버와 TCP 연결을 새로 맺고 닫게 되어, 핸드셰이크의 비용과 6장 2편의 TIME-WAIT가 요청마다 생깁니다. 연결을 재사용하려면 upstream 블록에 keepalive로 작업 프로세스(worker process)마다 남겨 둘 유휴 연결의 수를 정하고, location에서 proxy_http_version 1.1과 proxy_set_header Connection ""을 함께 지정해야 했습니다.
1.29.7부터는 기본값이 바뀌어, 뒤쪽 서버에 HTTP/1.1을 쓰고 작업 프로세스마다 유휴 연결을 32개까지 남겨 두는 keepalive 32 local이 기본으로 켜집니다. local은 같은 뒤쪽 서버라도 다른 location이 남긴 연결은 나누어 쓰지 않는다는 뜻입니다. 어느 버전이든 keepalive의 수는 남겨 둘 유휴 연결의 상한일 뿐 뒤쪽 서버로 여는 연결 전체를 제한하지 않으므로, 쓰고 있는 nginx의 버전부터 확인하고 뒤쪽 서버가 감당할 연결 수와 함께 정합니다.
X-Forwarded-For를 안전하게 읽기
$proxy_add_x_forwarded_for는 받은 X-Forwarded-For 헤더의 뒤에 nginx가 직접 본 상대의 주소를 덧붙인 값입니다. 앞 글에서 본 것처럼 클라이언트는 이 헤더에 아무 값이나 적어 보낼 수 있으므로, 헤더의 맨 왼쪽은 믿을 수 없습니다. 다음 코드는 클라이언트가 헤더를 위조해 CDN과 nginx를 거쳐 온 경우를 따라갑니다.
def append_xff(incoming, peer):
return f"{incoming}, {peer}" if incoming else peer # $proxy_add_x_forwarded_for
forged = "203.0.113.9" # 클라이언트가 스스로 적어 보낸 값
at_cdn = append_xff(forged, "192.0.2.50") # CDN이 본 진짜 클라이언트의 주소
at_nginx = append_xff(at_cdn, "198.51.100.4") # nginx가 본 CDN의 주소
hops = [h.strip() for h in at_nginx.split(",")]
print("뒤쪽 서버가 받은 헤더:", at_nginx)
print("맨 왼쪽을 믿으면:", hops[0])
print("믿는 프록시 두 단계만큼 오른쪽에서 세면:", hops[-2])뒤쪽 서버가 받은 헤더: 203.0.113.9, 192.0.2.50, 198.51.100.4
맨 왼쪽을 믿으면: 203.0.113.9
믿는 프록시 두 단계만큼 오른쪽에서 세면: 192.0.2.50맨 오른쪽 값은 nginx가 직접 본 CDN의 주소이고, 그 왼쪽은 CDN이 직접 본 클라이언트의 주소입니다. 둘 다 내가 운영하거나 믿는 프록시가 적었으므로 믿을 수 있고, 그보다 왼쪽은 클라이언트가 적은 값입니다. 그래서 믿는 프록시의 수만큼 오른쪽에서 세어야 진짜 클라이언트의 주소가 나옵니다. nginx의 realip 모듈은 set_real_ip_from으로 믿는 프록시의 주소 범위를 정하고, real_ip_header X-Forwarded-For로 이 헤더를 읽게 한 뒤(기본값은 X-Real-IP입니다), real_ip_recursive on을 켜면 이 일을 해 줍니다. 맨 왼쪽 값을 클라이언트의 주소로 기록하거나 접근 제어에 쓰면, 공격자가 원하는 주소로 행세할 수 있습니다.
VPN은 공용망 위에 사설망을 잇는다
VPN(virtual private network)은 공용 인터넷 위에 가상의 사설망을 만듭니다. 재택 근무자가 회사의 개발 서버나 데이터베이스에 접속하거나, 두 사무실의 사설망을 하나로 잇는 데 씁니다. 핵심은 터널링(tunneling)입니다. 사설망으로 갈 원래의 패킷을 통째로 암호화한 뒤, VPN 게이트웨이로 가는 바깥 패킷에 담아 보냅니다.
VPN · 보호 경계
VPN 터널의 보호는 게이트웨이에서 끝납니다
암호화 원격 접속 VPN의 예입니다. 단말→게이트웨이는 터널로 보호하지만, 그 뒤 서버까지의 기밀성에는 HTTPS 등 별도 보호가 필요합니다. 내부 접근은 라우트·권한 정책도 따라야 합니다.
청록색은 VPN 터널 구간입니다. 회색 구간이 평문이라는 단정은 아니며 별도 HTTPS 등을 설정할 수 있습니다. 터널 밖 주소·트래픽 양·타이밍은 보일 수 있고, split tunnel에서는 일부 트래픽만 VPN을 사용합니다.
경로 위에서는 내가 VPN 게이트웨이와 통신한다는 사실과 트래픽의 양, 시각은 보이지만, 터널 안의 사설 주소와 데이터는 보이지 않습니다. 하지만 보호는 게이트웨이에서 끝납니다. 게이트웨이에서 꺼낸 패킷은 회사 안의 망을 그대로 지나가므로, 그 구간의 내용까지 지키려면 HTTPS 같은 보호를 따로 써야 합니다. 또 모든 트래픽을 터널로 보내는 full tunnel과, 회사의 대역으로 가는 트래픽만 터널로 보내는 split tunnel은 보호하는 범위가 다릅니다. split tunnel이라면 일반 인터넷 접속은 터널 밖으로 나가므로, VPN에 접속했다고 모든 트래픽이 보호되는 것은 아닙니다.
| 방식 | 구조 |
|---|---|
| IPsec | IP 계층에서 패킷을 보호한다. 보통 IKEv2로 키를 협상하고 ESP로 암호화한다 |
| OpenVPN | UDP나 TCP 위에 TLS로 키를 협상하는 터널을 만든다. IP 패킷(TUN)이나 Ethernet 프레임(TAP)을 나른다 |
| WireGuard | UDP 위에서 IP 패킷을 나른다. 구성이 단순하고 ChaCha20-Poly1305 같은 정해진 암호만 쓴다 |
| L2TP/IPsec | L2TP로 감싸고 IPsec으로 보호한다. L2TP 자체는 암호화하지 않는다 |
터널은 패킷마다 바깥 헤더를 더 붙이므로, 터널 안에서 쓸 수 있는 패킷의 크기는 바깥 경로의 MTU보다 작아집니다. WireGuard라면 바깥의 UDP와 IP 헤더, WireGuard 자기의 헤더를 더해 바깥이 IPv6일 때 80바이트가 붙으므로, MTU 1500인 경로에서는 터널 안의 MTU를 1420으로 두는 것이 기본입니다. 이를 맞추지 않으면 5장 3편에서 본 것처럼 작은 요청은 되는데 큰 전송만 멈추는 장애가 생깁니다.
VPN에 접속했다고 회사의 모든 자원에 닿는 것도 아닙니다. 접속한 기기에 회사 대역으로 가는 경로와 내부 DNS가 설정되어야 하고, 방화벽과 권한 정책이 그 자원을 허락해야 합니다. 최근에는 망 안에 들어왔다는 이유로 믿지 않고, 요청마다 사용자와 기기를 확인하는 제로 트러스트(zero trust) 방식으로 VPN을 대신하는 회사도 늘고 있습니다.
정리
- 포워드 프록시는 클라이언트가 설정해 바깥으로 나가는 요청을 대신 보내고, 리버스 프록시는 서비스 앞에서 들어오는 요청을 받아 뒤쪽 서버로 전합니다.
- HTTPS는 포워드 프록시에
CONNECT로 터널을 열어 지나가므로 프록시는 목적지만 알고, 내용을 보려면 루트 인증서를 설치한 TLS 검사가 필요합니다. - 프록시 환경 변수의 이름과
NO_PROXY의 해석은 도구마다 다르므로, 회사 망에서만 실패하는 설치는 그 도구의 문서로 확인합니다. - nginx의
proxy_pass는 URI가 없으면 경로를 그대로, URI가 있으면location에 맞은 부분을 바꾸어 넘기므로 끝의/하나로 뒤쪽의 경로가 달라집니다. X-Forwarded-For의 맨 왼쪽은 클라이언트가 적은 값이므로, 믿는 프록시의 수만큼 오른쪽에서 세어 클라이언트의 주소를 얻습니다.- VPN은 원래의 패킷을 암호화해 게이트웨이까지 나르며, 보호는 게이트웨이에서 끝나고, 터널의 헤더만큼 안쪽의 MTU를 줄여야 합니다.
연습 문제
1. 넘어가는 경로
location /api/ { proxy_pass http://backend/v1/; }로 설정한 nginx에 /api/users?page=3 요청이 들어오면, 뒤쪽 서버는 어떤 경로를 받나요? proxy_pass http://backend;였다면 어떻게 되나요?
해설 보기
proxy_pass에 URI /v1/이 있으므로, 요청의 URI에서 location에 맞은 /api/를 /v1/로 바꿔 /v1/users?page=3을 넘깁니다. 쿼리는 그대로 따라갑니다. URI가 없는 proxy_pass http://backend;라면 받은 URI를 그대로 넘기므로 뒤쪽 서버는 /api/users?page=3을 받습니다. 뒤쪽 서버의 라우터가 /v1/users를 기다리는데 /api/users가 온다면 404가 나므로, 프록시를 거칠 때만 404가 난다면 이 규칙부터 확인합니다.
2. 프록시가 하나일 때
클라이언트가 X-Forwarded-For: 10.0.0.1을 적어 nginx에 직접 접속했고, nginx가 본 클라이언트의 주소는 192.0.2.77입니다. nginx가 뒤쪽 서버로 보내는 헤더는 무엇이 되나요? 뒤쪽 서버가 "사내 주소인 10.0.0.0/8에서 온 요청만 관리 페이지를 허락한다"는 규칙을 이 헤더의 맨 왼쪽 값으로 판단한다면 어떤 일이 생기나요?
해설 보기
$proxy_add_x_forwarded_for는 받은 값 뒤에 nginx가 본 주소를 덧붙이므로 헤더는 10.0.0.1, 192.0.2.77이 됩니다. 뒤쪽 서버가 맨 왼쪽 값을 믿으면, 바깥의 공격자가 헤더 한 줄로 사내 주소인 척해 관리 페이지에 들어옵니다. 믿는 프록시가 nginx 하나라면 오른쪽에서 첫 번째 값인 192.0.2.77이 진짜 클라이언트의 주소이고, 이 주소는 사내 대역이 아니므로 거부해야 합니다. 관리 페이지라면 주소만으로 판단하지 말고 인증을 함께 요구하는 것이 안전합니다.
3. 터널 안의 패킷 크기
바깥 경로의 MTU가 1280바이트이고, 이 연습에서는 터널이 패킷마다 60바이트를 더 붙인다고 하겠습니다. 안쪽 패킷 1200바이트와 1240바이트 가운데 어느 것이 바깥 MTU에 들어가나요? 들어갈 수 있는 안쪽 패킷의 최대 크기는 얼마인가요? split tunnel이 회사의 10.20.0.0/16만 터널로 보내도록 설정되어 있다면, 일반 웹 사이트로 가는 트래픽도 터널을 지나나요?
해설 보기
1200 + 60 = 1260바이트는 들어가고, 1240 + 60 = 1300바이트는 20바이트를 넘습니다. 들어갈 수 있는 안쪽 패킷의 최대 크기는 1280 − 60 = 1220바이트이므로 터널 인터페이스의 MTU를 1220 이하로 두어야 합니다. 실제로 붙는 바이트 수는 VPN의 방식과 바깥이 IPv4인지 IPv6인지에 따라 다르므로 60바이트를 고정된 값으로 쓰면 안 됩니다. split tunnel에서는 10.20.0.0/16으로 가는 트래픽만 터널을 지나고, 일반 웹 사이트로 가는 트래픽은 다른 경로를 바꾸지 않았다면 평소의 인터넷 경로로 나갑니다.
다음 글 클라우드와 컨테이너 네트워크에서는 클라우드의 VPC와 서브넷, 보안 그룹, Docker와 Kubernetes가 컨테이너에 주소를 주고 서비스로 묶는 방법을 살펴봅니다.