마이그레이션 작업 중 proxy_ssl_server_name on; 누락 → 502, X-Forwarded-Proto $scheme → redirect 무한 루프 두 함정 앞에서 멈칫했다. 둘 다 평소에 "당연히 알고 있다" 고 생각하던 개념인데, 막상 내 손으로 한 줄로 정의해보라면 막힌다는 걸 발견했다. 그래서 ChatGPT 와 문답하면서 다시 정리한 결과를 학습 노트로 남겨둔다. 다음에 같은 함정을 만났을 때 이 노트 하나만 다시 보면 되도록.
마이그레이션에 대한 기록은 아래 글을 참고하자.
2026.05.21 - [DevOps, SRE] - Community ingress-nginx 에서 F5 NIC 로 - 사전에 정의한 가설의 절반은 첫 배포에서 깨졌다
Community ingress-nginx 에서 F5 NIC 로 - 사전에 박은 가설의 절반은 첫 배포에서 깨졌다
사내 솔루션 BE 차트들이 올라가는 환경 중 하나가 새로 신설되고 있다. 기존 운영 중인 alpha 환경의 공통 클러스터 는 커뮤니티 ingress-nginx(이하 Community) 위에 있고, 이건 클러스터 운영 정책상 F5
ramos-log.tistory.com
Intro
두 함정은 결국 같은 질문의 다른 얼굴이었다. "Nginx 가 reverse proxy 로 동작할 때 backend 한테 무엇을 알려주느냐".
- SNI 는 TLS handshake 단계에서 upstream 서버에게 *"내가 어느 도메인으로 접속하려는지"* 를 알려준다.
- X-Forwarded-Proto 는 그 뒤 HTTP 헤더로 backend 에게 *"원래 클라이언트는 어떤 프로토콜로 들어왔는지"* 를 알려준다.
둘 다 "디폴트로 알아서 되겠지" 라는 직감이 깨지는 지점에서 사고가 났고, 그 직감을 정리해두는 게 이 노트의 목적이다.
SNI (Server Name Indication)?
한 줄 정의
TLS handshake 첫 단계(
ClientHello)에서 클라이언트가 서버에게 "나 어느 도메인에 접속하려고 함" 을 평문으로 미리 알려주는 TLS 확장 (RFC 6066 Section 3).
왜 필요했나 - 한 IP, 여러 HTTPS 도메인
원래 HTTPS 는 암호화부터 시작하기 때문에, 서버 입장에서는 "어느 인증서를 보내야 할지" 를 알 수가 없다. 특히 같은 IP 에서 여러 도메인을 서비스하는 환경에서 문제가 됐다.
1.2.3.4 IP 하나
├── api.example.com
├── admin.example.com
└── auth.example.com
SNI 가 없으면 서버는 "어느 cert 를 줘야 하지?" 를 알 수 없어서 default cert 를 던질 수밖에 없다.
그래서 ClientHello 안에 평문으로 다음을 넣어 보낸다.
Extension: server_name
Server Name: auth.example.com
이게 SNI. 평문이라는 점이 포인트. 암호화가 시작되기 전에 서버가 읽을 수 있어야 cert 를 고를 수 있기 때문이다.
Nginx 가 이걸 어떻게 쓰는가
server { server_name ...; } 블록 선택에 그대로 쓴다.
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /cert/api.crt;
}
server {
listen 443 ssl;
server_name auth.example.com;
ssl_certificate /cert/auth.crt;
}
클라이언트가 SNI 로 auth.example.com 을 보내면 → Nginx 가 auth 용 인증서를 골라서 응답한다.
K8s Ingress 에서 가장 자주 만나는 형태
Ingress Controller (community ingress-nginx, F5 NIC 둘 다) 는 거의 100% SNI 기반으로 동작한다고 봐도 된다.

같은 LB IP 하나로 여러 도메인을 운영할 수 있는 이유가 전부 SNI 덕분이다.
Reverse Proxy 의 upstream 호출에서도 SNI 필요
여기서부터가 실전 함정의 핵심이다. Nginx 가 backend(upstream) 로 HTTPS 호출을 보낼 때도 자기가 클라이언트가 된다. 즉, upstream 에게 SNI 를 보내야 한다.
그런데 proxy_pass https://... 는 디폴트로 SNI 를 안 보낸다. 명시적으로 켜야 한다.
location /api/ {
proxy_pass https://api.example.com/;
proxy_ssl_server_name on; # ← 이게 없으면 SNI 누락
# proxy_ssl_name 미지정 시 $proxy_host 가 자동으로 SNI 가 됨
}
proxy_ssl_server_name on; 만 켜면 Nginx 가 자동으로 proxy_ssl_name 값(디폴트 $proxy_host) 을 SNI 로 보낸다. 별도 값을 설정하지 않아도 proxy_pass 의 호스트가 그대로 들어간다.
SNI 누락 시 증상 - 상대편 ingress 의 너그러움에 따라 다르다
| 상대편 | 동작 |
|---|---|
| 관대한 ingress (community ingress-nginx) | default cert 로 fallback → handshake 는 되지만 cert mismatch 가능 |
| 엄격한 ingress (F5 NIC v5.x) | TLS alert 112 (unrecognized_name) → handshake 실패 → 502 |
실전에서 만난 502 가 정확히 후자 케이스. community 에선 default cert fallback 으로 멀쩡했던 코드가 F5 NIC 환경에서 깨졌던 진짜 이유.
교훈: "alpha 환경에서 잘 됐으니 beta 환경에서도 되겠지" 가 안 통하는 영역이다. SNI 누락은 너그러운 컨트롤러가 숨겨주던 버그다.
TLS Passthrough 와 SNI
Nginx 가 TLS 를 종단하지 않고 그대로 흘릴 때 (stream { ssl_preread on; }) 는 HTTP Host 헤더를 못 본다. 대신 TLS 평문 영역인 SNI 는 볼 수 있다. 그래서 다음 형태가 가능하다.
stream {
map $ssl_preread_server_name $upstream {
api.example.com api_backend;
auth.example.com auth_backend;
}
server {
listen 443;
ssl_preread on;
proxy_pass $upstream;
}
}
이걸 SNI 기반 TCP routing 이라고 부른다. TLS 종단을 안 하고 백엔드까지 그대로 흘릴 때 거의 유일한 라우팅 수단.
curl 테스트할 때의 함정
DNS 가 없는 환경에서 IP 로 직접 테스트할 때.
# 안 됨 — SNI 가 안 들어감 (또는 IP 가 들어감) → default cert 가 떨어진다
curl https://1.2.3.4
# 됨 — DNS 만 우회하고 SNI/Host 헤더는 도메인으로 정상 주입
curl --resolve api.example.com:443:1.2.3.4 https://api.example.com
--resolve 는 DNS 해상만 우회하고 SNI/Host 헤더는 정상으로 들어간다. 사고 디버깅할 때 이 한 줄을 알면 "cert mismatch 가 SNI 누락 때문인지, cert 자체 문제인지" 를 한 번에 가른다.
한 줄 요약
SNI = HTTPS 에서 Host 기반 라우팅을 가능하게 해주는 핵심 정보. 클라이언트로 동작할 때(브라우저든, Nginx 의 upstream 호출이든) 둘 다 보내야 한다. 그리고 Nginx 의 upstream 쪽은 디폴트로 안 보낸다.
X-Forwarded-Proto
한 줄 정의
Reverse proxy 가 backend 에게 "원래 클라이언트는 어떤 프로토콜(http/https)로 들어왔다" 를 알려주는 HTTP 헤더. RFC 7239 의
Forwarded; proto=가 표준화 버전이지만, 실무는 여전히X-Forwarded-Proto가 압도적.
왜 필요했나 - Reverse Proxy 가 TLS 를 종단하는 구조
거의 모든 실무 환경이 이렇게 생겼다.

Spring Boot 입장에서는 들어온 connection 이 cleartext HTTP 다. 그래서 그냥 두면 *"어, 나한테 HTTP 로 들어왔네"* 라고 판단한다. 실제로 유저는 HTTPS 로 접속했는데도.
그래서 Nginx 가 이걸 설정해서 진실을 알려준다.
proxy_set_header X-Forwarded-Proto $scheme;
X-Forwarded-Proto: https 가 backend 로 전달되고, 앱은 "아 원래는 HTTPS 였구나" 를 안다.
흔히 같이 쓰는 헤더 세트
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 헤더 그대로 전달 |
X-Real-IP |
직전 hop 이 본 클라이언트 IP |
X-Forwarded-For |
proxy chain 누적 IP (표준화 버전은 Forwarded; for=) |
X-Forwarded-Proto |
클라이언트가 들어온 프로토콜 (http/https) |
이 네 줄은 거의 reverse proxy 의 디폴트 세트.
안 보내거나 잘못 보내면 생기는 일
1. Redirect Loop
Spring Security 가 "HTTP 면 HTTPS 로 redirect" 정책일 때, 앱이 자기 scheme 을 http 로 오해하면 무한 리다이렉트가 만들어진다.
Client --HTTPS--> Nginx --HTTP--> App
App: "HTTP네? HTTPS 로 redirect 해야지" → 301 https://...
Client --HTTPS--> Nginx --HTTP--> App
App: "또 HTTP네?" → 301 https://...
(무한 반복)
2. 생성 URL 이 http 로 나감
ServletUriComponentsBuilder.fromCurrentRequest()
같은 코드가 http://api.example.com/... 을 만들어버린다. OAuth callback URL 이 https 여야 하는데 http 로 처리되어 인증 흐름이 깨지는 사례가 가장 흔하다.
3. Secure Cookie 처리 꼬임
Set-Cookie: ...; Secure 는 HTTPS 에서만 의미 있는데, 앱이 자기 scheme 을 http 로 오해하면 쿠키 정책이 어긋난다.
Spring Boot 측 설정
X-Forwarded-* 를 앱이 자동으로 신뢰하게 하려면 설정이 필요하다.
server:
forward-headers-strategy: framework # 또는 native
이걸 켜야 HttpServletRequest.isSecure(), getScheme(), getRemoteAddr() 가 X-Forwarded-* 를 반영한다. (구버전: server.use-forward-headers: true.)
$scheme 을 그대로 설정하면 안 되는 케이스 - hairpin 함정
여기서부터가 실전에서 잡힌 함정의 핵심이다. FE pod 안의 Nginx 가 backend hairpin 으로 호출하는 구조 에서는 $scheme 이 진짜 클라이언트 scheme 이 아니다.

FE pod 안에서 $scheme=http 인 채로 X-Forwarded-Proto 를 설정하면, 그 다음 hop 의 Ingress 가 *"XFP 가 http 면 https 로 redirect"* 정책을 발동해서 무한 루프가 만들어진다.
이 케이스에서는 $scheme 이 아니라 https 하드코딩 이 정답이다.
proxy_set_header X-Forwarded-Proto https; # FE 는 외부 진입이 항상 HTTPS 라는 사실을 알고 있음
본질 :
$scheme은 "직전 connection 의 scheme" 일 뿐, "최초 클라이언트의 scheme" 이 아니다. TLS 종단이 앞 hop 에서 일어났다면$scheme은 더 이상 진실이 아니다.
X-Forwarded-For 신뢰 결정과 같은 맥락
X-Forwarded-Proto 도 X-Forwarded-For 와 똑같이 클라이언트가 임의로 넣어 보낼 수 있는 헤더 다. 그래서 신뢰 경계를 결정해야 한다.
- 외부에서 직접 받는 첫 hop (Ingress) 은 클라이언트가 보낸 XFP/XFF 를 신뢰하면 안 된다 (덮어쓰기).
- 첫 hop 이후 내부 hop 은 직전 hop 이 설정한 XFP/XFF 를 신뢰한다.
같은 환경에서 X-Forwarded-For 신뢰를 명시 비활성으로 결정한 것과 같은 맥락. XFP 도 같은 결정 축에 같이 올려놓아야 한다.
한 줄 요약
X-Forwarded-Proto = "원래 클라이언트는 어떤 scheme 으로 들어왔다" 를 backend 에 전달하는 헤더.
$scheme으로 설정하는 게 디폴트 답이지만, 자기가 hairpin 의 중간 hop 일 때는 진실이 아니다.
두 개념을 한 그림으로

두 개념의 공통 교훈
| 개념 | 누가 책임 | "디폴트면 알아서 되겠지" 의 진실 |
|---|---|---|
| SNI | TLS handshake 시작하는 쪽 | Nginx 의 proxy_pass https://... 는 디폴트로 SNI 안 보낸다. 명시 필요. |
| X-Forwarded-Proto | reverse proxy 가 backend 에 전달 | $scheme 디폴트는 "내가 받은 scheme" 일 뿐. TLS 종단 뒤 hop 에서는 거짓이 된다. |
둘 다 결론은 같다. "reverse proxy 의 디폴트 동작에 기대지 말고 명시해라."
빠르게 다시 끄집어낼 수 있는 체크리스트
다음에 같은 함정을 만났을 때 다시 체크해볼 사항들.
- upstream 이 HTTPS 인가? →
proxy_ssl_server_name on;켰는지 확인. 안 켜져 있으면 SNI 누락 → 엄격한 ingress 에서 TLS alert 112. - TLS 종단 뒤의 hop 인가? →
X-Forwarded-Proto $scheme가 거짓일 수 있다. 외부 진입이 항상 HTTPS 면https하드코딩 검토. -
$scheme을 무비판적으로 신뢰하지 않는가? → 직전 connection scheme 일 뿐, 최초 클라이언트 scheme 아님. - upstream Ingress 에
redirect-to-https같은 정책이 켜져있나? → XFP=http 만 봐도 redirect 발동하는 구현이 있다 (F5 NIC v5.x). 이걸 모르면 무한 루프. - DNS 없이 curl 로 검증할 때? →
curl --resolve <host>:443:<ip> https://<host>로 SNI/Host 를 정상 주입.curl https://<ip>는 default cert 함정. - Spring Boot 가 XFP 를 자동 신뢰하는가? →
server.forward-headers-strategy: framework켜져 있는지 확인. - 상대편 ingress 의 너그러움에 기대고 있지 않은가? → community 가 default cert fallback 으로 숨겨주던 버그가 F5 NIC 에서는 502 로 폭발한다.
References
- NGINX
proxy_ssl_server_name— https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ssl_server_name - NGINX
ssl_preread_server_name(TLS passthrough 라우팅용) — https://nginx.org/en/docs/stream/ngx_stream_ssl_preread_module.html - RFC 6066 Section 3 (TLS Extensions, SNI 정의) — https://datatracker.ietf.org/doc/html/rfc6066#section-3
- RFC 7239 (
Forwarded헤더, X-Forwarded-* 의 표준화 버전) — https://datatracker.ietf.org/doc/html/rfc7239 - Spring Boot
forward-headers-strategy— https://docs.spring.io/spring-boot/reference/web/servlet.html#web.servlet.embedded-container.behind-proxy-server
'Infrastructure > nginx' 카테고리의 다른 글
| Nginx 101 (부제: Nginx 기본) (0) | 2025.02.02 |
|---|