
이전 포스트 Nginx에 Rate Limit 설정하기에 이어 Nginx Rate Limit을 조금 더 구체적으로 설정할 수 있는 방법에 대해 소개해보려 한다. 현재 진행하는 프로젝트에 대한 상황과 Rate Limit 설정값에 대한 근거는 이전 포스트를 참고하길 바란다.
이번 포스트에서는 총 두 가지 포인트에 대해 다뤄보려고 한다.
x-forwarded-for, set_real_ip_frommap, $request_method시작!
이전 포스트에서 다뤘던 내용에 대해 두 가지 리뷰를 받았다. 현재 프로젝트에 설정된 Rate Limit 정책은 다음과 같다.
limit_req_zone $binary_remote_addr zone=cmc_api_limit:1m rate=3r/s;
limit_req zone=cmc_api_limit burst=5 delay=3;
limit_req_status 429;
cmc_api_limit이라는 Rate Limit 정책을 정의하였다.rate을 초과해서 최대 5개의 Burst한 요청을 수용한다.이 사전 지식을 갖고 다음 리뷰 내용을 살펴보자.
현재 프로젝트는 정적 파일 서빙을 Global Edge를 통해서, 애플리케이션 서버는 Nginx 서버를 통해 서로 다른 도메인을 이용하여 관리하고 있다.
그렇다면 사용자가 요청을 보낼 때 클라이언트와 Nginx 서버 사이 Global Edge가 존재한다면 Nginx는 실제 요청을 보낸 클라이언트의 IP가 아닌 Edge의 IP를 Key값으로 받게된다는 리뷰다.

다양한 IP에서 요청을 보내더라도 Nginx는4.4.4.4에서 3개의 요청이 왔다고 생각한다. 세 개의 서로 다른 IP가 하나의 요청을 보내더라도 Nginx는 동일한 IP에서 3개의 요청을 보냈다고 판단하고 곧 Rate Limit 정책에 의해 초과된 요청은 거절되는 문제가 발생한다.
하지만 다행히(?) 우리 프로젝트는 위 그림과 다른 아키텍처를 갖고 있다.

Global Edge와 Nginx 서버는 서로 다른 도메인으로 운영되고 있기 때문에 사실상 두 개의 서버는 서로 통신할 이유가 없다.
사용자가 a.com으로 접속하면 Global Edge를 통해 index.html을 받아오고 페이지에서 발생하는 API는 b.com으로 요청을 보내 데이터를 받아온다.
실제로 요청을 보내보면서 테스트를 진행해보았다. 서로 다른 네트워크로 접근을 시도하더라도 중간에 Global Edge를 거쳐간다면 Nginx 서버에서는 동일한 remote_addr을 받게될 것이다.
// wifi로 접근
nginx-1 | remote_addr="175.197.1.161"xff="-" request="GET / HTTP/1.1"
// 데이터로 접근
nginx-1 | remote_addr="211.234.199.246"xff="-" request="GET / HTTP/1.1"
하지만 와이파이와 데이터로 접속하였을 때, Nginx 서버는 서로 다른 remote_addr을 받아오는 것을 확인하였다. 즉, API 요청은 요청을 보내는 실제 Client가 Nginx 서버에 직접 통신하여 보내고 있다는 것을 확인하였다.
그럼에도 아직 문제가 해결되지 않았다.
다행히 중간에 프록시 서버가 없기 때문에 현재 정상적으로 Client IP를 받아올 수 있지만, 서버가 커질수록 LB, Gateway 등의 프록시 서버가 추가된다면 우리 서버는 정상적으로 요청자의 Key를 식별할 수 없게 된다.
아래에서 이 문제에 대해 해결책을 다뤄보도록 하겠다.
바로 위 테스트에서 와이파이와 데이터를 통해 보내는 요청의 remote_addr이 다르다는 것을 확인하였다. 그렇다면 동일한 와이파이에서는 어떻게 요청을 수신하는지 확인해보자.
// 맥북에서 wifi로 접근
nginx-1 | remote_addr="175.197.1.161" xff="-" request="GET / HTTP/1.1"
// 모바일에서 wifi로 접근
nginx-1 | remote_addr="175.197.1.161" xff="-" request="GET / HTTP/1.1"
서로 다른 장치(맥북, 모바일)로 동일한 와이파이를 사용하여 요청을 보냈을 때, 두 요청의 remote_addr 모두 동일한 IP를 보여주고 있다.
그 말은 즉, 동일한 와이파이 / NAT를 통해 요청을 보내는 사용자들은 동일한 IP를 통해 요청을 보내게 된다.
이에 대해서도 어떻게 개선해볼 수 있는지 아래에서 다뤄볼 예정이다.
현재 프로젝트는 $binary_remote_addr를 Key값으로 요청의 수를 카운팅하고 있다. $binary_remote_addr는 이진수로 이루어진 remote_addr을 의미한다.
remote_addr는 nginx가 클라이언트와 TCP 연결을 맺은 과정에서 얻은 클라이언트의 실제 주소다. HTTP 헤더를 통해 읽어오는 값이 아닌 연결된 소켓 정보를 통해 클라이언트 IP를 직접 가져오기 때문에 신뢰성이 갖는다.
여기서 중요한 점은 첫 요청을 보낸 Client IP가 아닌 TCP 연결을 맺은 Client의 IP를 가져온다는 점이다. 전자와 후자는 동일할 수도 있지만 다를 수도 있다. 바로 Client와 Nginx 서버 사이 또 다른 프록시 서버가 존재하는 경우다.

위 Global Edge에서도 동일한 흐름을 한 번 이야기하였듯, 전달된 요청이 중간 프록시 서버를 거치게 되면 Nginx Server는 Client의 원래 IP를 잃어버리게 된다.
이를 검증하기 위해 간단히 Nginx 프록시를 추가하여 요청을 테스트하였다.
proxy-nginx-1: 프록시 서버nginx-1: 애플리케이션 서버(이후 본 서버라 칭함)// wifi로 접근
proxy-nginx-1 | 175.197.1.161 - - "GET / HTTP/1.1" 200 3
nginx-1 | remote_addr="172.18.0.3" xff="175.197.1.161"
// 데이터로 접근
proxy-nginx-1 | 211.234.192.134 - - "GET / HTTP/1.1" 200 3
nginx-1 | remote_addr="172.18.0.3" xff="211.234.192.134"
기존 프록시가 존재하지 않았을때, 본 서버에서는 동일한 서로 다른 remote_addr를 가져왔었다. 하지만 프록시 서버가 추가되고 이제는 동일한 remote_addr를 가져오게 된다. 이는 본 서버가 Client가 아닌 프록시 서버와 TCP 연결을 맺어 요청을 수신하였다는 것을 보여준다.
즉, 와이파이와 데이터를 통해 보낸 요청이 172.18.0.3 IP를 가진 프록시 서버를 거쳐 본 서버에 도달한 상황이다. 그리고 본 서버에서는 서로 다른 네트워크의 요청에 대해서도 동일한 IP에서 들어온 요청이라 착각할 수 있는 상황이다.
그렇다면 본 서버에서 실제 Client IP를 파악하려면 어떻게 해야 할까?
다행히 로그를 보면 xff 속성에 Client IP가 담겨져있는 것을 확인할 수 있다. 이는 HTTPX-Forwarded-For 헤더를 간단히 표현하여 로깅한 것이다.
HTTP X-Forwarded-For 헤더는 HTTP 프록시나 로드 밸런서를 통해 웹 서버에 접속하는 클라이언트의 원 IP 주소를 식별하는 표준 헤더다.
위와 같이 서버 중간 트래픽이 프록시를 거치면 본 서버에는 프록시의 IP를 담고있기 때문에 Client의 원 IP 주소를 보기 위해 XFF 헤더를 사용한다.
X-Forwarded-For: <client>, <proxy1>, <proxy2>
하나의 요청이 프록시를 거치면 각 프록시의 IP가 XFF 헤더에 열거되어 표시된다.
문제를 해결하였다.
이제 프록시 서버를 거치더라도 본 서버에서는 X-Forwared-For 헤더에 Client IP가 전달될테니 이를 Key값으로 사용하면 된다.
과연 그럴까? 프록시 서버를 하나 더 추가해보자.
X-Forwarded-For 헤더는 요청이 거쳐간 프록시 서버가 열겨되어 표시된다고 말하였다. 즉, 이 헤더는 하나의 IP를 가리키는 것이 아닌 지나온 서버의 IP를 스택처럼 축적하여 가지고 있다.
아래와 같이 프록시 서버를 하나 더 추가하여 테스트를 진행하였다.

사용자의 요청은 proxy1을 거치고 proxy2를 거친 후 본 서버에 도달하게 된다. 그리고 아래와 같은 로그를 남겼다.
proxy1-1 | 211.196.250.219
proxy2-1 | 172.18.0.4
nginx-1 | remote_addr="172.18.0.3" xff="211.196.250.219, 172.18.0.4"
proxy1은 클라이언트의 IP 인 “211.196.250.219”을 직접 확인하고 XFF에 클라이언트의 remote_addr인 “211.196.250.219”를 추가하여 넘긴다.proxy2는 proxy1의 IP인 “172.18.0.4”를 확인하고 proxy1 의 XFF에 proxy1 의 remote_addr인 “172.18.0.4”를 추가하여 넘긴다.nginx 는 proxy2의 IP 인 “172.18.0.3”을 확인하고 XFF에는 < Client IP >, < Proxy IP 1 >이 담겨 있다.동일하게 프록시가 하나인 경우도 로그를 남겨보았다.
proxy2-1 | 211.196.250.219
nginx-1 | remote_addr="172.18.0.3" xff="211.196.250.219"
프록시를 하나만 거치게 된다면 user > proxy2 > server 흐름을 갖게 된다.
proxy2는 클라이언트의 IP인 “211.196.250.219”을 직접 확인하고 XFF에 클라이언트의 remote_addr인 “211.196.250.219”를 추가하여 넘긴다.nginx 는 proxy2의 IP 인 “172.18.0.3”을 확인하고 XFF 에는 < Client IP >가 담겨 있다.마지막으로 프록시가 없는 경우도 로그를 남겨보았다.
nginx-1 | remote_addr="211.196.250.219" xff="-"
프록시를 거치지 않는다면 user > server 흐름을 갖게 된다.
nginx 는 클라이언트의 IP 인 “211.196.250.219”을 확인하고 XFF 는 공란이다.이렇게 프록시 존재 여부에 따라, 개수에 따라 본 서버에 찍히는 로그가 제각각 달라진다는 것을 테스트를 통해 확인해볼 수 있었다. 이제는 프록시가 있더라도 본 서버가 Client IP를 가져올 수 있는 방법에 대해서 살펴보겠다.
Nginx에서는 realip 모듈을 통해 Client IP를 가져올 수 있도록 제공한다.
아래 예제를 통해 세 가지 문법을 살펴보자.
set_real_ip_from 192.168.1.0/24;
set_real_ip_from 192.168.2.1;
set_real_ip_from 2001:0db8::/32;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
set_realip_from
Syntax: set_real_ip_from address | CIDR | unix:;
Default: —
Context: http, server, location
set_real_ip_from은 신뢰할 수 있는 프록시 서버의 IP/CIDR을 지정한다. 신뢰할 수 있는 프록시 서버의 범위를 통해 실제 Client IP를 찾는데 사용된다.
real_ip_header
Syntax: real_ip_header field | X-Real-IP | X-Forwarded-For | proxy_protocol;
Default:
real_ip_header X-Real-IP;
Context: http, server, location
Client의 실제 IP주소를 포함할 HTTP 헤더를 정의한다. 설정된 헤더를 검사하여 그 안에 포함된 값을 토대로 Client IP를 올바르게 식별하도록 보장한다.
real_ip_recursive_on
Syntax: real_ip_recursive on | off;
Default:
real_ip_recursive off;
Context: http, server, location
This directive appeared in versions 1.3.0 and 1.2.1.
real_ip_header에서 설정된 헤더에서 재귀적으로 Client IP를 탐색한다.
on으로 설정할 경우, 신뢰할 수 있는 프록시 주소를 우측부터 순차적으로 탐색하고 신뢰할 수 없는 IP 주소가 등장하였을 때 이를 Client IP라고 판단한다.
off일 경우 이런 탐색을 진행하지 않고 real_ip_header 값의 가장 오른쪽 주소를 클라이언트 IP로 판단한다.
문법만 보면 제대로 이해하기 어려우니, 실제 프로젝트 로그를 통해 한 번 살펴보자! 아래 로그는 '두 개의 프록시를 거칠 때' 확인하였던 로그다.
proxy1-1 | 211.196.250.219
proxy2-1 | 172.18.0.4
nginx-1 | remote_addr="172.18.0.3" xff="211.196.250.219, 172.18.0.4"
요청이 전달된 서버의 IP를 살펴보자면 211.196.250.219 > 172.18.0.4 > 172.18.0.3 순서대로 요청이 전달되었다.
여기서 내가 설정한 프록시 서버는 172.18.*.*의 IP 주소 형태를 갖고 있다. 그렇다면 나는 set_ip_from 헤더를 다음과 같이 설정할 수 있다.
set_real_ip_from 172.18.0.0/16;
Subnet과 CIDR에 대한 설명은 여기로
172.18로 시작하는 IP 주소는 신뢰할 수 있는 프록시 서버의 범위로 설정한 것이다. 그리고 현재 프로젝트는 X-Forwarded-For를 통해 Client IP를 포함한 IP 주소를 받아오고 있으니 다음과 같이 설정할 수 있다.
real_ip_header X-Forwarded-For;
real_ip_recursive on;
그렇다면 이제, X-Forwarded-For 헤더에서 신뢰할 수 있는 프록시 서버를 매칭해가며 Client IP를 재귀적으로 탐색해올 수 있다.
X-Forwarded-For: 211.196.250.219, 172.18.0.4, 172.18.0.3
✅ trusted trusted
real_ip 에 대한 설정을 추가하였으니, 실제 요청을 보내 정상적으로 remote_addr을 받아오는 지 테스트를 해보자.
proxy1-1 | 211.196.250.219
proxy2-1 | 172.18.0.4
nginx-1 | remote_addr="211.196.250.219" xff="211.196.250.219, 172.18.0.4"
기존에는 remote_addr가 proxy2의 IP인 172.18.0.3을 보았다면 지금은 Client IP를 정상적으로 받아오는 것을 확인할 수 있다.
추가적으로 real_ip_recursive가 정말 신뢰할 수 있는 프록시 서버 범위를 기준으로 탐색하고 있는 지가 궁금해져 한 가지 간단한 테스트를 더 진행하였다.
지금은 set_real_ip_from를 범위로 설정하여 프록시 서버를 모두 통과하였는데, 만약 proxy2의 IP만 신뢰할 수 있다고 설정한다면 remote_addr은 proxy1의 IP를 가져와야할 것이다.
set_real_ip_from 172.18.0.3;
X-Forwarded-For: 211.196.250.219, 172.18.0.4, 172.18.0.3
✅ trusted
설정을 변경하고 다시 한 번 요청을 보내보면
proxy1-1 | 211.196.250.219
proxy2-1 | 172.18.0.4
nginx-1 | remote_addr="172.18.0.4" xff="211.196.250.219, 172.18.0.4"
예상했던 대로 본 서버는 proxy1의 IP를 신뢰할 수 없어, 이를 remote_addr로 지정한 로그를 확인할 수 있었다.
이를 통해 우리는 중간에 프록시 서버가 존재하더라도 본 서버에서 Client IP를 가져올 수 있는 방법을 이해할 수 있게 되었다.
위에서 우리는 동일한 와이파이로 접속하였을 때, 서로 다른 기기를 통해 접속하더라도 본 서버는 동일한 Client IP로 인식했다는 것을 확인한 바가 있다.
이 말은 즉, 서로 다른 사용자가 같은 네트워크를 통해 요청을 보낸다면 동일한 Key로 요청의 수가 집계되어 요청이 거절될 수 있다는 문제가 존재한다.
그렇다면 Nginx에서 동일한 remote_addr에 대해 다른 사용자의 접속을 구별할 수 있을까?
Nginx Rate Limit은 사용자별 요청 제어보다 트래픽을 완화하고 악의적 요청을 줄이는 1차 방어 장치에 가깝다.
Nginx에서는 개개인의 사용자를 명확히 식별할 수 없기 때문에, 사용자 별 요청의 횟수를 제한하기 보단 특정 IP로부터 악의적인 요청(DoS) 혹은 과도한 트래픽으로부터 Application Server를 보호하기 위해 Limiting을 거는 것이다. 만약 사용자 별 횟수를 명확히 제한하고 싶다면 Nginx나 Gateway가 아닌 Application 레벨에서 user_id , session_id 등을 key로 설정하여 제약을 걸 수 있다.
그럼에도 단순히 안된다고 말하고 넘어가기엔 찜찜한 부분이 있다. 그래서 양심의 가책을 덜 수 있는 최소한의 개선 포인트를 소개해보려 한다.
burst, delay를 여유롭게 설정한다.
Nginx에서 제공하는 burst나 delay를 더 넉넉하게 설정하여 Burst한 요청에 더 유연하게 대처할 수 있다.
하지만 넉넉함의 정도를 정의할 수 있어야 하며, 넉넉하게 무분별한 요청을 수용할 수도 있다는 문제가 있다.
location으로 API Endpoint를 나누어 서로 다른 Rate Limit 정책을 사용하도록 설정할 수 있다.
location /api/auth/ {
limit_req zone=api_auth burst=5 nodelay;
proxy_pass http://backend:3000/;
}
location /api/ {
limit_req zone=api_read burst=30 delay=10;
proxy_pass http://backend:3000/;
}
하지만 API 별로 정책을 설정하고 관리해야 하는 비용이 발생한다.
API 조회 요청과 쓰기 요청을 분리하여 Rate Limit 정책을 설정한다.
조회 요청은 쓰기 요청에 비해 훨씬 빈번하게 발생되기 때문에 두 요청의 종류를 분리하여 Rate Limit 정책을 설정한다면 관리의 비용도 줄이고 유연하게 정책은 운용할 수 있다.
✏️ 읽기 요청과 쓰기 요청을 분리하기
조금 더 깊이 찾아보고 적용했던 방식은 3번이었다. 읽기, 쓰기 요청을 Nginx에서 어떻게 구별하는지, 그리고 이를 통해 어떻게 정책을 설정하는지 아예 몰랐기에 더 흥미롭게 내용을 살펴볼 수 있었다.
읽기 요청은 쓰기 요청보다 훨씬 빈번하게 이루어지며 시스템 설계에서도 이러한 특성을 고려하여 하나의 Master DB에서만 쓰기 요청을 처리하고, 나머지 여러 개의 Replica DB를 통해 읽기 요청을 분산 처리하기도 한다.
따라서 동일한 IP에서 들어오는 요청이라도 조회 요청과 쓰기 요청을 분리해 제한하면, 정상적인 읽기 트래픽으로 인해 중요한 쓰기 요청이 불필요하게 차단되는 상황을 줄일 수 있다.
Nginx에서는 map을 통해 변수의 값을 지정해줄 수 있다.
Syntax: map string $variable { ... }
Default: —
Context: http
map $http_user_agent $mobile {
default 0;
"~Opera Mini" 1;
}
$mobile이라는 값은 Nginx가 받아오는 $http_user_agent 헤더 값에 따라 0이 될수도(기본값) 1이 될 수도 있다.
마찬가지로 우리는 들어오는 요청의 메서드 값을 통해 새롭게 변수를 매핑할 수 있다.
map $request_method $read_key {
GET $binary_remote_addr;
HEAD $binary_remote_addr;
default "";
}
map $request_method $write_key {
POST $binary_remote_addr;
PUT $binary_remote_addr;
PATCH $binary_remote_addr;
DELETE $binary_remote_addr;
default "";
}
$read_key나 $write_key는 들어오는 메서드를 통해 $binary_remote_addr 혹은 빈 문자열("")을 key로 설정하게 된다.
limit_req_zone $cmc_api_read_key zone=cmc_api_read_limit:8m rate=24r/s;
limit_req_zone $cmc_api_write_key zone=cmc_api_write_limit:1m rate=3r/s;
그리고 설정된 key를 통해 각각의 Rate Limit 정책을 생성할 수 있다.
location / {
limit_req zone=cmc_api_read_limit burst=40 delay=24;
limit_req zone=cmc_api_write_limit burst=5 delay=3;
limit_req_status 429;
proxy_pass http://backend:3000/;
}
그리고 설정된 Rate Limit 정책을 다음과 같이 적용시킬 수 있다.
그렇다면 $read_key나 $write_key가 모두 $binary_remote_addr로 설정할 것이라면 굳이 map으로 나눌 필요가 있을까?
map으로 나눈 이유는 두 가지 서로 다른 Rate Limit을 구분하기 위해서다.
현재 / 경로로 들어오는 모든 요청은 두 가지 Rate Limit 정책을 통과하게 된다. 이는 조건문이 아닌 반드시 두 정책을 통과하게 되는 것이다. 그렇기에 각각의 요청은 자신의 정책이 아니라면 반드시 통과할 수 있어야 한다.
예를 들어, GET 요청이 들어오면 $read_key에서는 $binary_remote_addr가 되겠지만 $write_key에서는 빈 문자열("")이 된다. 그리고 빈 문자열은 Rate Limit에 카운팅이 되지 않기 때문에 읽기 Rate Limit 정책에만 적용된다.
반대로 POST 요청이 들어온다면 $read_key는 빈 문자열이 되어 쓰기 Rate Limit 정책에만 적용되는 방식이다.
이처럼 요청의 종류에 따라 Rate Limit 정책을 구분하여 유연하게 트래픽을 수용하면서 무분별한 공격을 방지할 수 있는 개선 포인트들을 살펴보았다.
이번 포스트를 통해 중간에 프록시가 있더라도 본 서버가 Client IP를 가져올 수 있는 방법과, 동일한 IP를 통해 다수의 사용자가 접속하였을 때를 고려하여 유연하게 Rate Limit을 설정할 수 있는 방법 두 가지를 살펴보았다.
첫 번째 방법은 문제에 대한 명확한 해답을 찾은 기분이지만 두 번째 방법은 깔끔하지 못한 해답인 느낌이 든다.
다시 한 번 더 강조하면, Nginx는 사용자별 요청을 제한하는 곳이 아닌 전체적인 트래픽을 조절하고 무분별한 공격을 방어할 수 있는 곳이다.
사용자들 개개인을 식별할 수 없기에 정책을 더 내 입맛대로 구성하여 요청에 따라 유연하게 반응할 수 있는 Rate Limit을 만들어냈다는 점에서 만족한다.