ALB나 Nginx를 앞단에 두고 Tomcat을 뒤에 붙이는 구성은 흔하다. 그런데 이렇게 올리고 나면 액세스 로그에 찍히는 접속 IP가 전부 프록시 IP 한두 개로 도배되는 걸 발견하게 된다. 로그인 이력이나 접근 제한, 어뷰징 차단 로직이 죄다 프록시 IP를 보고 판단하니 제대로 동작할 리가 없다. 여기에 request.isSecure()가 항상 false로 나오면서 애플리케이션이 만든 리다이렉트 URL이 https인데 http로 튀는 문제까지 겹친다. Tomcat 매뉴얼을 뒤지다 보면 결국 RemoteIpValve로 귀결되는데, 이 밸브 하나로 두 문제를 같이 잡을 수 있다.
결론부터 말하면, Tomcat의 RemoteIpValve를 Engine이나 Host에 등록하면 프록시가 넘겨준 X-Forwarded-For에서 실제 클라이언트 IP를 복원하고, X-Forwarded-Proto를 읽어 request.getScheme()과 isSecure(), 서버 포트까지 교정한다. 앞단(ALB, Nginx)이 이 헤더들을 제대로 채워 보내고, Tomcat 쪽에서 그 앞단 IP를 신뢰 대상으로 잡아주면 끝난다. 아래에서 증상 진단, 밸브 동작 원리, server.xml 설정, 액세스 로그 연동, 자주 걸리는 함정을 차례로 살펴본다.
1. 대상 버전과 기준
이 글은 Tomcat 9.0과 10.1 기준이다. RemoteIpValve는 두 버전 모두 org.apache.catalina.valves 패키지에 들어 있고 속성과 기본값이 사실상 같다. 공식 문서는 Tomcat Configuration Reference의 Valve Components 문서 Remote IP Valve 절을 따른다. 필터로 쓰고 싶다면 같은 동작을 하는 RemoteIpFilter가 있고, 앞단이 Apache HTTP Server라면 mod_remoteip가 동일 역할을 한다. RemoteIpValve 자체가 mod_remoteip를 Tomcat으로 옮겨온 것이다.
2. 증상과 진단
2-1. 액세스 로그에 프록시 IP만 찍힌다
기본 액세스 로그 패턴 %h는 request.getRemoteAddr() 값을 쓴다. ALB나 Nginx가 TCP를 새로 열어 Tomcat에 붙기 때문에, 이 값은 클라이언트가 아니라 프록시의 IP가 된다. 로그를 열어보면 이렇게 나온다.
$ tail -n 3 logs/localhost_access_log.2026-08-09.txt 10.0.2.15 - - [09/Aug/2026:10:12:03 +0900] "GET /board/list HTTP/1.1" 200 8123 10.0.2.15 - - [09/Aug/2026:10:12:05 +0900] "GET /article/1941 HTTP/1.1" 200 15044 10.0.2.15 - - [09/Aug/2026:10:12:07 +0900] "POST /login HTTP/1.1" 302 -
서로 다른 사용자가 접속했는데 앞 칸이 전부 10.0.2.15 하나로 고정돼 있다. 이게 프록시(여기서는 Nginx나 ALB 노드) IP다. 진짜 클라이언트 IP는 프록시가 X-Forwarded-For 헤더에 담아 넘긴다. 헤더가 실제로 들어오는지는 애플리케이션에서 찍어 확인하는 게 빠르다.
// 진단용 스니펫
String xff = request.getHeader("X-Forwarded-For");
String proto = request.getHeader("X-Forwarded-Proto");
log.info("remoteAddr={}, XFF={}, XFProto={}, scheme={}, secure={}",
request.getRemoteAddr(), xff, proto,
request.getScheme(), request.isSecure());
remoteAddr=10.0.2.15, XFF=203.0.113.47, XFProto=https, scheme=http, secure=false
X-Forwarded-For에는 실제 IP 203.0.113.47이 잘 들어와 있는데 getRemoteAddr()는 여전히 프록시 IP다. 밸브가 이 헤더를 읽어 값을 바꿔줘야 한다.
2-2. https인데 secure가 false로 나온다
위 출력에서 XFProto=https인데 scheme=http, secure=false다. TLS 종료를 ALB나 Nginx가 하고 Tomcat까지는 평문 http로 붙기 때문이다. 그래서 Tomcat 입장에서는 자기가 받은 커넥션이 http라고 판단한다. 이 상태에서 response.sendRedirect("/home")처럼 상대 경로를 절대 URL로 만들면 http://로 나가고, 브라우저가 다시 https로 돌아오면서 리다이렉트가 한 번 더 돌거나 혼합 콘텐츠 경고가 뜬다. request.getServerPort()도 https 표준인 443이 아니라 커넥터 포트(8080 등)로 잡힌다.
3. RemoteIpValve가 하는 일
Valve는 Tomcat의 요청 처리 파이프라인에 끼워 넣는 인터셉터다. 요청이 서블릿에 닿기 전에 가로채 무언가를 하고 통과시킨다. RemoteIpValve는 그 자리에서 프록시가 붙여 보낸 헤더를 읽어 요청 객체의 값을 다음과 같이 교정한다.
remoteIpHeader(기본x-forwarded-for)를 오른쪽에서 왼쪽으로 훑어 신뢰 프록시가 아닌 첫 IP를 실제 클라이언트로 보고request.getRemoteAddr()에 넣는다.protocolHeader(기본X-Forwarded-Proto) 값이protocolHeaderHttpsValue(기본https)와 같으면scheme을 https로,isSecure()를 true로, 서버 포트를httpsServerPort(기본 443)로 바꾼다. 아니면 http, false,httpServerPort(기본 80)로 맞춘다.- 중간을 거친 프록시 IP들은
internalProxies정규식에 걸리면 삼켜 버리고,trustedProxies에 걸리면proxiesHeader(기본x-forwarded-by)에 모아 준다.
핵심은 신뢰 조건이다. 밸브는 요청을 보낸 상대(즉 프록시)의 IP가 internalProxies나 trustedProxies 정규식에 맞을 때만 헤더를 신뢰해 값을 바꾼다. 맞지 않으면 헤더가 있어도 무시한다. 아무나 X-Forwarded-For를 위조해 넣는 걸 막기 위한 장치다.
4. server.xml에 밸브 등록
밸브는 Engine, Host, Context 중 어디에나 넣을 수 있다. 전체에 적용하려면 conf/server.xml의 <Host> 안, 액세스 로그 밸브보다 앞에 둔다. 기본 internalProxies는 RFC 1918 사설 대역과 루프백을 이미 포함하므로, 프록시가 사설 IP로 붙는 사내 구성이면 사실 protocolHeader만 명시해도 동작한다. 그래도 신뢰 대역을 명시적으로 좁혀 두는 편이 안전하다.
<Host name="localhost" appBase="webapps"
unpackWARs="true" autoDeploy="true">
<Valve className="org.apache.catalina.valves.RemoteIpValve"
remoteIpHeader="X-Forwarded-For"
protocolHeader="X-Forwarded-Proto"
protocolHeaderHttpsValue="https"
internalProxies="10\.0\.\d{1,3}\.\d{1,3}"
httpServerPort="80"
httpsServerPort="443"
requestAttributesEnabled="true" />
<Valve className="org.apache.catalina.valves.AccessLogValve"
directory="logs" prefix="localhost_access_log" suffix=".txt"
pattern="%h %l %u %t "%r" %s %b"
requestAttributesEnabled="true" />
</Host>
위 internalProxies는 10.0.x.x 대역만 신뢰하도록 좁힌 예다. ALB나 Nginx가 이 대역에서 Tomcat에 붙는다면 이 정규식에 걸려 헤더가 신뢰된다. 여기서 한 번 걸리기 쉬운 지점이 있는데, 이 값은 정규식이라 점을 \.로 이스케이프해야 한다. 그냥 10.0.0.0/8 같은 CIDR 표기를 넣으면 매칭이 안 되고 조용히 무시된다.
설정을 바꿨으면 Tomcat을 재시작한다.
$ ./bin/shutdown.sh && ./bin/startup.sh # 또는 systemd 관리 환경이면 $ sudo systemctl restart tomcat
재시작 뒤 2-1의 진단 스니펫을 다시 찍어 보면 이렇게 바뀐다.
remoteAddr=203.0.113.47, XFF=203.0.113.47, XFProto=https, scheme=https, secure=true
getRemoteAddr()가 실제 클라이언트 IP로 바뀌었고 scheme과 secure도 https 기준으로 교정됐다.
5. 주요 속성 정리
5-1. remoteIpHeader
클라이언트 IP 목록이 담긴 헤더 이름이다. 기본값은 x-forwarded-for. ALB와 Nginx 모두 관례상 이 헤더를 쓰므로 대개 기본값 그대로 둔다.
5-2. protocolHeader와 protocolHeaderHttpsValue
클라이언트가 프록시에 붙을 때 쓴 프로토콜을 담은 헤더다. 기본값은 X-Forwarded-Proto이고, 이 헤더 값이 protocolHeaderHttpsValue(기본 https)와 대소문자 무시하고 같으면 https로 판단한다. 앞단이 X-Forwarded-Ssl: on이나 Front-End-Https: on처럼 다른 헤더를 쓴다면 protocolHeader와 protocolHeaderHttpsValue를 그에 맞춰 바꿔야 한다. 이 헤더가 요청에 아예 없으면 밸브는 scheme을 건드리지 않고 원래 값을 유지한다.
5-3. internalProxies와 trustedProxies
둘 다 프록시 IP를 매칭하는 정규식이다. internalProxies에 걸린 프록시는 X-Forwarded-For에서 삼켜져 흔적이 남지 않는다. trustedProxies에 걸린 프록시는 신뢰하되 x-forwarded-by 헤더에 기록으로 남긴다. trustedProxies는 기본이 비어 있어 아무 외부 프록시도 신뢰하지 않는다. 기본 internalProxies는 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, 100.64/10 사설 및 링크로컬 대역과 IPv6 루프백을 포함한다.
5-4. httpServerPort와 httpsServerPort
프로토콜 판정 결과에 따라 request.getServerPort()가 돌려줄 값이다. 기본은 각각 80과 443이다. TLS 종료를 앞단이 하고 표준 포트를 쓴다면 기본값으로 충분하다.
5-5. requestAttributesEnabled
기본 true다. 밸브가 교정한 값을 요청 속성에 실어 액세스 로그 밸브가 쓸 수 있게 한다. 이 속성이 왜 필요한지는 다음 절에서 설명한다.
6. 액세스 로그와 연동
여기서 처음 밸브를 붙인 사람들이 자주 당황한다. 애플리케이션에서 getRemoteAddr()는 실제 IP로 잘 나오는데, 정작 액세스 로그의 %h는 여전히 프록시 IP로 찍히는 경우가 있다. 공식 문서가 밝히는 이유는 이렇다. 밸브가 바꾼 값은 요청 처리가 밸브를 빠져나갈 때 원래 값으로 복원되는데, 액세스 로그 기록은 그보다 나중에 일어난다. 그래서 로그 시점에는 이미 프록시 IP로 되돌아가 있다.
해결은 요청 속성을 경유하는 것이다. RemoteIpValve가 requestAttributesEnabled="true"로 교정값을 요청 속성에 넣어 두고, AccessLogValve도 같은 속성을 true로 켜서 그 속성을 읽게 한다. 4절 예제처럼 두 밸브 모두에 이 속성을 켜야 한다. 그러면 로그가 이렇게 바뀐다.
203.0.113.47 - - [09/Aug/2026:10:20:11 +0900] "GET /board/list HTTP/1.1" 200 8123 198.51.100.9 - - [09/Aug/2026:10:20:12 +0900] "GET /article/1941 HTTP/1.1" 200 15044
이제 %h가 클라이언트별 실제 IP로 나뉜다.
7. 앞단 헤더 설정 확인
밸브는 헤더가 들어와야 일한다. 앞단이 헤더를 안 붙이면 아무 일도 일어나지 않는다.
7-1. ALB
Application Load Balancer는 X-Forwarded-For와 X-Forwarded-Proto를 기본으로 붙여 보낸다. 별도 설정 없이도 Tomcat 쪽 밸브만 맞추면 동작한다. 다만 ALB가 대상 그룹에 http로 붙는 구성이라면 X-Forwarded-Proto가 클라이언트 프로토콜(https)을 담아 오므로 그 값을 신뢰하면 된다.
7-2. Nginx
Nginx는 프록시 헤더를 명시적으로 넣어줘야 한다. nginx.conf의 location 블록에 다음을 넣는다.
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
$proxy_add_x_forwarded_for는 기존 X-Forwarded-For가 있으면 뒤에 클라이언트 IP를 이어 붙인다. $scheme은 Nginx가 클라이언트에게서 받은 프로토콜(https 종료 시 https)을 넣는다.
8. 자주 걸리는 함정
- 밸브가 아무 일도 안 한다. 프록시 IP가
internalProxies정규식에 안 걸리는 경우가 가장 흔하다. 앞단 IP가 사설 대역이 아니거나(예: ALB가 다른 VPC 대역), 정규식을 CIDR로 잘못 적었을 때다. 앞단 실제 접속 IP(getRemoteAddr()의 원래 값)를 확인해 정규식에 포함시킨다. - 점을 이스케이프하지 않았다.
internalProxies는 정규식이다.10.0.0.1이 아니라10\.0\.0\.1로 적어야 한다. - 액세스 로그만 안 바뀐다. 6절대로 AccessLogValve에도
requestAttributesEnabled="true"를 켠다. 밸브 한쪽만 켜면 로그는 그대로다. - X-Forwarded-For를 이중으로 쌓는다. 앞단이 두 단(ALB 뒤 Nginx)이면 헤더에 IP가 여러 개 쌓인다. 밸브는 오른쪽부터 신뢰 프록시를 걷어내며 왼쪽으로 오므로, 중간 단(Nginx) IP도
internalProxies나trustedProxies에 넣어 신뢰 대상으로 잡아야 실제 클라이언트까지 도달한다. - secure는 맞는데 리다이렉트가 여전히 http다.
protocolHeader가 앞단이 보내는 헤더 이름과 다른 경우다. Nginx가X-Forwarded-Proto대신 다른 헤더를 쓰도록 설정돼 있는지 확인한다.
9. 마무리
ALB나 Nginx 뒤 Tomcat에서 클라이언트 IP와 프로토콜이 어긋나는 문제는 RemoteIpValve 하나로 정리된다. 앞단이 X-Forwarded-For와 X-Forwarded-Proto를 채워 보내게 하고, Tomcat에서 그 앞단 IP를 internalProxies로 신뢰하도록 잡은 뒤, 액세스 로그까지 값을 넘기려면 두 밸브 모두 requestAttributesEnabled를 켜면 된다. 밸브 방식이 부담스럽거나 특정 애플리케이션에만 적용하고 싶다면 같은 동작을 하는 RemoteIpFilter를 web.xml에 걸어도 된다.