fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • claude-code 페이지로 이동
    • harness 페이지로 이동
    • llm 페이지로 이동
    • ops 페이지로 이동
    • practice 페이지로 이동
  • algorithm 페이지로 이동
    • Alias Method: 가중치 랜덤 선택을 전처리 O(n), 추출 O(1)로 바꾸기
    • Welford's Online Algorithm: 값을 저장하지 않고 평균과 분산 계산하기
  • architecture 페이지로 이동
    • distributed-systems 페이지로 이동
    • domain 페이지로 이동
    • evolution 페이지로 이동
    • patterns 페이지로 이동
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • postgresql 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • gaps and islands: SQL 창 함수로 이어진 구간 찾기
    • MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 한국어 형태소 분석기 Nori vs Lindera — OpenSearch에서 Milvus로 갈 때 어휘 검색은 유지되나
    • 정규화와 역정규화: 무결성과 조회 비용 사이의 선택
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 5종을 실제로 벤치마크했다 — 같은 recall에서 QPS는 얼마나 갈리나
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • Envoy Proxy
    • Graceful Shutdown
    • 종료 신호는 어디서 멈추는가
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS와 TLS: 핸드셰이크, 인증서, 종료 지점
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • Alpine 이미지의 Java 에서 링크 바꿔치기(TOCTOU)를 막지 못하는 이유
    • Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • 로그에 traceId 남기기 — MDC 부터 OpenTelemetry 까지
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • `setImmediate()`
  • kafka 페이지로 이동
    • Kafka 클러스터 아키텍처: Broker, KRaft Controller와 복제
    • Kafka 기본 개념: Topic, Partition, Offset과 Segment
    • Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • Kafka 파티션·리밸런스·컨슈머 지연 운영
    • Kafka를 로그로 이해하기: 복제, 재처리, 상태와 데이터 통합
    • Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • SSH forced command 로 배포 키가 실행할 수 있는 명령 제한하기
  • mlops 페이지로 이동
    • llm-serving 페이지로 이동
    • model-router 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • 한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPS
  • network 페이지로 이동
    • Connection reset by peer는 누가 보낸 걸까 — 리버스 프록시 홉마다 TCP 연결은 따로 논다
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • observability 페이지로 이동
    • Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • K8s 위 Spring Boot 앱의 메트릭 수집
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ThreadLocal 에서 contextvars 로 — Python 의 요청 컨텍스트 전파
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS가 줄지 않는 이유: CPython, gc.collect(), malloc_trim
  • rabbitmq 페이지로 이동
    • RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • resume 페이지로 이동
    • 경험 및 경력기술서
    • 김병태 경력기술서
    • 김병태 포트폴리오
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • personal-projects 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • thinking 페이지로 이동
    • 자기주도 학습: 질문에서 시작해 글로 검증하기
    • 좋은 일을 넘어 중요한 일을 하는 법
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • Glossary↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/java/쿠버네티스에 올렸더니 X-Forwarded-…
java

쿠버네티스에 올렸더니 X-Forwarded-For 가 사라졌다 — forward-headers-strategy 와 RemoteIpValve

프록시 뒤에서 클라이언트 IP 를 확인하는 인터셉터를 만들었다. X-Forwarded-For 와 X-Real-IP 를 읽어 내부 요청인지 판정하는 코드였다. 로컬에서는 잘 돌았는데 쿠버네티스에 올리니 정상 내부 호출이 전부 차단됐다. 진단 로그를 찍어보니 이랬다. x-forwarded-host, x-forwarded-port, x-forwarded-prot...

2026.08.04·7 min read·91 views

프록시 뒤에서 클라이언트 IP 를 확인하는 인터셉터를 만들었다. X-Forwarded-For 와 X-Real-IP 를 읽어 내부 요청인지 판정하는 코드였다. 로컬에서는 잘 돌았는데 쿠버네티스에 올리니 정상 내부 호출이 전부 차단됐다.

진단 로그를 찍어보니 이랬다.

plaintext
remoteAddr=10.x.x.0
xffCount=0  xff=[]
realIpCount=1  realIp=[<10.x.x.0>]
allHeaders=[host, x-request-id, x-real-ip, x-forwarded-host,
            x-forwarded-port, x-forwarded-proto, x-forwarded-scheme, ...]

x-forwarded-host, x-forwarded-port, x-forwarded-proto 는 다 있는데 x-forwarded-for 만 없다. nginx 설정에는 분명 보내게 돼 있었고, 같은 변수를 쓰는 X-Real-IP 는 멀쩡히 도착했다.

범인은 프록시가 아니라 Spring Boot 자신이었다. 가져갈 결론부터 적으면 이렇다.

  • 쿠버네티스에 배포하면 server.forward-headers-strategy 가 아무 설정 없이 NATIVE 로 켜진다. 로컬과 동작이 달라지는 지점이다.
  • NATIVE 는 Tomcat 의 RemoteIpValve 를 쓰는데, 이 밸브는 X-Forwarded-For 를 읽고 나서 지운다. 애플리케이션이 그 헤더를 직접 읽으려 하면 이미 없다.

왜 켜졌나 — 클라우드 플랫폼 자동 감지

Spring Boot 문서에 명시돼 있다.

If your application runs in a supported Cloud Platform, the server.forward-headers-strategy property defaults to NATIVE. In all other instances, it defaults to NONE.

application.yml 에 아무것도 안 적었으니 기본값 NONE 이겠거니 했는데, 기본값이 환경에 따라 달라진다. 지원 플랫폼은 CloudPlatform enum 에 정의돼 있다.

값플랫폼
NONE감지 안 됨
CLOUD_FOUNDRYCloud Foundry
HEROKUHeroku
SAPSAP Cloud
NOMADNomad (3.1.0 부터)
KUBERNETES쿠버네티스
AZURE_APP_SERVICEAzure App Service
AWS_ECSAWS ECS (4.0.0 부터)

감지는 플랫폼별 환경변수로 한다. 쿠버네티스는 파드에 자동으로 주입되는 값을 본다.

bash
$ kubectl exec <pod> -- env | grep KUBERNETES_SERVICE
KUBERNETES_SERVICE_HOST=10.x.x.1
KUBERNETES_SERVICE_PORT=443

이 변수는 쿠버네티스가 모든 파드에 자동으로 넣어준다. 내가 뭘 설정해서 켜진 게 아니라, 파드에 올린 순간 켜진 것이다.

RemoteIpValve 가 하는 일

NATIVE 는 Tomcat 의 RemoteIpValve 를 활성화한다. 이 밸브는 요청이 컨트롤러에 닿기 전에 요청 객체를 다시 쓴다.

항목동작
request.remoteAddrX-Forwarded-For 에서 추출한 실제 클라이언트 IP 로 덮어쓴다
request.schemeX-Forwarded-Proto 가 https 면 https 로
request.secure위에 따라 true / false
request.serverPort프로토콜에 맞춰 443 또는 80
X-Forwarded-For 헤더처리한 프록시 IP 를 제거한다

마지막 줄이 핵심이다. 밸브는 X-Forwarded-For 를 오른쪽부터 왼쪽으로 훑으면서 신뢰할 수 있는 프록시 IP 를 제거한다. 공식 문서 표현은 이렇다.

if it matches the internal proxies list, the ip/host is swallowed

그리고 internalProxies 의 기본값에는 사설 대역이 전부 들어 있다.

대역포함
10.0.0.0/8포함
172.16.0.0/12포함
192.168.0.0/16포함
127.0.0.0/8포함
169.254.0.0/16포함
100.64.0.0/10포함
IPv6 loopback포함

그래서 헤더가 통째로 사라진다

쿠버네티스 클러스터 안에서는 모든 IP 가 사설 대역이다. 그러면 이런 일이 벌어진다.

  1. Ingress 가 X-Forwarded-For: 10.x.x.0 을 붙여 보낸다
  2. Tomcat 이 받는다. TCP 소스는 Ingress 파드 IP(10.x.x.55)이고, 사설 대역이라 internal 로 판정
  3. 밸브가 XFF 를 오른쪽부터 훑는다. 10.x.x.0 도 사설 대역이라 swallowed
  4. 제거하고 남은 값이 없으니 헤더 자체가 제거된다
  5. request.remoteAddr 는 마지막으로 제거한 10.x.x.0 이 된다

공식 문서의 예시가 정확히 이 상황을 보여준다.

처리 전처리 후
request.remoteAddr192.168.0.10140.211.11.130
x-forwarded-for140.211.11.130, 192.168.0.10null

X-Real-IP 가 살아남은 이유도 명확하다. 밸브가 보는 헤더는 X-Forwarded-For 하나(remoteIpHeader 기본값)이고, X-Real-IP 는 nginx 가 별도로 붙이는 비표준 헤더라 손대지 않는다.

즉 내 인터셉터는 Spring 이 이미 해준 일을 다시 하려다, 그 과정에서 치워진 헤더를 찾아 헤맨 것이다. getRemoteAddr() 만 읽었으면 처음부터 답이 거기 있었다.

internalProxies 와 trustedProxies

둘을 헷갈리기 쉬운데 역할이 다르다.

internalProxiestrustedProxies
의미내 인프라의 프록시신뢰하지만 기록은 남기고 싶은 외부 프록시
처리제거하고 버린다X-Forwarded-By 헤더로 옮긴다
기본값사설 대역 전체비어 있음

신뢰하지 않는 IP 를 만나면 거기서 멈추고 그 값이 remoteAddr 가 된다. 그 왼쪽에 남은 값들은 X-Forwarded-For 에 그대로 남는다.

plaintext
설정: internalProxies="192\.168\.0\.10"  trustedProxies="proxy1"
 
처리 전:  x-forwarded-for: 140.211.11.130, untrusted-proxy, proxy1
          request.remoteAddr: 192.168.0.10
 
처리 후:  request.remoteAddr: untrusted-proxy     ← 신뢰 못 하는 곳에서 멈춤
          x-forwarded-for: 140.211.11.130         ← 남음
          x-forwarded-by: proxy1                  ← 신뢰 프록시는 여기로

이 규칙 덕분에 외부에서 헤더를 위조해도 신뢰 경계 밖의 값은 그대로 노출된다. 위조한 IP 가 remoteAddr 가 되지 않는다.

세 가지 전략

값구현특징
NATIVE웹 서버 자체 기능 (Tomcat 은 RemoteIpValve)서블릿 컨테이너 레벨에서 처리해 빠르다. 헤더가 소비된다
FRAMEWORKForwardedHeaderFilter (서블릿) / ForwardedHeaderTransformer (리액티브)Spring 이 처리한다. 표준 Forwarded 헤더도 다룬다
NONE없음전달 헤더를 무시한다. remoteAddr 는 실제 TCP 소스

언제 무엇을 쓰나

상황선택
프록시 뒤에서 클라이언트 IP·스킴이 필요하다지정하지 않는다. 클라우드면 NATIVE 가 자동 적용
클라우드 밖(온프레미스 등)에서 프록시 뒤에 둔다NATIVE 를 명시
표준 Forwarded 헤더를 쓰거나 세밀한 제어가 필요하다FRAMEWORK
전달 헤더를 애플리케이션이 직접 다뤄야 한다NONE 을 명시

마지막 줄이 내가 겪은 경우다. 헤더를 직접 읽는 코드가 있다면 자동으로 켜지는 동작과 충돌한다. 둘 중 하나를 골라야 한다.

  • 밸브에 맡기고 getRemoteAddr() 를 읽는다 (권장)
  • 직접 다루겠다면 server.forward-headers-strategy: NONE 을 명시해 밸브를 끈다

어디서 깨지나

바로 DNS 나 프록시를 의심하기 어려운 형태로 나타나서 추적이 오래 걸린다.

  • 헤더를 직접 읽는 코드가 빈 값을 본다 — 내 경우다. 로컬(NONE)에서는 헤더가 살아 있어 테스트가 통과하고, 배포 환경(NATIVE)에서만 깨진다. 환경에 따라 기본값이 달라지는 게 함정의 본질이다.
  • remoteAddr 를 TCP 소스로 믿는 코드가 어긋난다 — 접근 제어나 감사 로그가 프록시 IP 대신 클라이언트 IP 를 보게 된다. 의도한 동작일 수도, 아닐 수도 있다.
  • 신뢰 경계 설정 실수가 위조를 허용한다 — internalProxies 를 넓게 잡으면 외부가 보낸 XFF 값을 그대로 신뢰하게 된다. 기본값을 함부로 넓히지 않는 게 좋다.
  • 리다이렉트 URL 이 http 로 나간다 — X-Forwarded-Proto 를 처리하지 못하면 request.scheme 이 http 로 남아, https 로 접속한 사용자에게 http 링크를 돌려준다.

확인 절차

의심될 때 밟는 순서다. 추측보다 이게 빠르다.

bash
# 1. 클라우드 플랫폼이 감지되는 환경인가
kubectl exec <pod> -- env | grep -E "KUBERNETES_SERVICE|VCAP_|DYNO"
 
# 2. 애플리케이션이 실제로 보는 값 — 헤더를 목록째 찍는다
#    값만 찍으면 "빈 문자열 1개"와 "헤더 없음"이 구분되지 않는다
log.warn("remoteAddr={} headers={}",
         request.getRemoteAddr(),
         Collections.list(request.getHeaderNames()));
 
# 3. 프록시가 실제로 보내는 값과 대조
kubectl exec -n ingress-nginx <pod> -- grep "proxy_set_header X-Forwarded" /etc/nginx/nginx.conf

2번에서 내가 시간을 버렸다. 처음엔 헤더 값만 리스트로 찍었는데, 자바에서 빈 문자열 하나가 든 리스트와 빈 리스트가 둘 다 [] 로 출력된다. 두 경우는 판정이 갈리는데 로그로는 구분이 안 됐다. 개수를 따로 찍고 값을 <> 로 감싸고 나서야 헤더가 아예 없다는 걸 확인했다.

지금 보면

이 문제의 뿌리는 설정하지 않은 것도 설정이라는 점이었다. application.yml 에 forward-headers-strategy 가 없으니 기본값 NONE 일 거라 읽었고, 그 가정 위에서 헤더를 직접 파싱하는 코드를 짰다. 실제로는 배포 환경이 그 기본값을 바꿔놓고 있었다.

프레임워크가 알아서 해주는 일을 모르면, 그 위에 같은 일을 한 번 더 얹게 된다. 그리고 두 처리가 겹치는 지점에서 조용히 깨진다. 라이브러리가 자동으로 켜는 동작은 명시적으로 끄거나 명시적으로 맡기거나, 둘 중 하나를 골라야 한다는 걸 이번에 배웠다.

참고

  • Spring Boot — Use Behind a Proxy Server
  • Spring Boot API — CloudPlatform
  • Apache Tomcat — RemoteIpValve
on this page
  • 01왜 켜졌나 — 클라우드 플랫폼 자동 감지
  • 02RemoteIpValve 가 하는 일
  • 03그래서 헤더가 통째로 사라진다
  • 04internalProxies 와 trustedProxies
  • 05세 가지 전략
  • 06언제 무엇을 쓰나
  • 07어디서 깨지나
  • 08확인 절차
  • 09지금 보면
  • 10참고
tags
#심화#study

이런 글도

  • java

    JPA 식별자 전략: Long IDENTITY와 외부용 UUID v7

    2026.10.09
  • java

    Alpine 이미지의 Java 에서 링크 바꿔치기(TOCTOU)를 막지 못하는 이유

    2026.10.09
  • java

    서블릿 async 재디스패치에서 preHandle이 두 번 돌고 응답 객체가 바뀌는 이유

    2026.08.02
  • java

    [학습중] JPA 벌크 변경과 트랜잭션 정합성

    2026.07.21

댓글 (0)