HTTPS 리다이렉트가 무한 루프를 돌면, 원인은 대개 앱이 원래 요청의 프로토콜을 모른다는 것이다. 로드밸런서가 TLS를 끊고 뒤로는 HTTP로 넘기면 앱은 "이건 HTTP다"라고 본다. 그래서 HTTPS로 리다이렉트하고, 브라우저가 HTTPS로 다시 오면 로드밸런서는 또 HTTP로 넘긴다. 끝나지 않는다. 로드밸런서는 원래 프로토콜을 헤더로 알려준다. 앱이 그 헤더를 안 믿도록 설정돼 있는 게 문제다.
루프가 도는 순서
한 바퀴를 따라가면 원인이 보인다.
1. 브라우저 → https://example.com/ (HTTPS)
2. 로드밸런서: TLS 종료, 뒤로는 HTTP 로 전달
3. 앱: "HTTP 요청이네" → 301 https://example.com/
4. 브라우저 → https://example.com/ (2번으로 돌아감)
3번에서 앱이 틀린 판단을 한다. 앱이 보는 것은 로드밸런서와 자기 사이의 프로토콜이고, 브라우저와 로드밸런서 사이의 프로토콜이 아니다.
이 구조에서는 앱 로그도 도움이 안 된다. 앱 입장에서는 정상 요청을 받아 정상 리다이렉트를 냈을 뿐이다.
로드밸런서는 이미 알려주고 있다
ALB는 클라이언트가 어떤 프로토콜로 접속했는지를 X-Forwarded-Proto 요청 헤더에 담아 넘긴다.
X-Forwarded-Proto: https
문서가 이 헤더의 용도를 명시한다 — 애플리케이션이 이 값을 보고 적절한 URL로 리다이렉트하는 응답을 만들라는 것이다. 서버 액세스 로그에는 서버와 로드밸런서 사이의 프로토콜만 남으므로, 클라이언트 쪽 프로토콜을 알려면 이 헤더를 봐야 한다. (문서)
같이 오는 헤더가 둘 더 있다.
| 헤더 | 담긴 값 |
|---|---|
X-Forwarded-Proto |
클라이언트가 쓴 프로토콜 (http 또는 https) |
X-Forwarded-For |
클라이언트 IP |
X-Forwarded-Port |
클라이언트가 접속한 목적지 포트 |
X-Forwarded-For에는 처리 모드가 있다. 기본값은 append(기존 값에 클라이언트 IP를 덧붙임)이고 preserve(손대지 않음)·remove(지움)로 바꿀 수 있다. preserve나 remove로 두면 앱이 기대하는 값이 안 온다. 클라이언트 IP가 안 찍히는 문제를 쫓을 때는 이 속성부터 본다.
앱이 그 헤더를 안 믿고 있다
Spring Boot라면 설정 하나로 갈린다.
server.forward-headers-strategy=native
값이 셋이다.
| 값 | 동작 |
|---|---|
NATIVE |
컨테이너(Tomcat 등)의 자체 지원을 쓴다. X-Forwarded-For·X-Forwarded-Proto를 쓰는 흔한 구성이면 이것으로 충분하다 |
FRAMEWORK |
스프링의 ForwardedHeaderFilter를 서블릿 필터로 등록해 처리한다 |
NONE |
X-Forwarded-*를 무시한다 |
NONE이면 헤더가 와도 앱은 안 본다. 그게 루프의 직접 원인이다.
Tomcat을 쓰면 내부 구현이 RemoteIpValve다. 자동 설정을 끄고(NONE) 직접 밸브 인스턴스를 붙여 세부 제어를 할 수도 있다. (Spring Boot 문서)
어느 값으로 돌고 있는지는 추측하지 말고 확인한다. 버전과 설정 파일에 따라 다르고, 명시하지 않았으면 무엇으로 도는지도 버전마다 다르다.
고치기 전에 재는 순서
루프를 보면 바로 설정을 바꾸고 싶어지는데, 그러면 왜 고쳐졌는지 모르게 된다. 두 가지를 먼저 확인한다.
1. 헤더가 실제로 오는가. 앱에서 받은 요청의 헤더를 한 번 찍어 본다.
log.info("proto={} secure={} url={}",
req.getHeader("X-Forwarded-Proto"), req.isSecure(), req.getRequestURL());
proto=https인데 secure=false면 헤더는 오는데 앱이 안 믿는 것이다. 설정 문제로 확정된다.
헤더 자체가 null이면 로드밸런서 앞에 다른 프록시가 있거나, 요청이 로드밸런서를 안 거치고 들어온 경로다.
2. 리다이렉트를 누가 내는가. 앱만 의심하다 틀리는 경우가 있다. 앞단 웹서버 설정에 HTTPS 리다이렉트가 이미 있고 앱에도 있으면 둘이 서로 넘긴다.
curl -sI -H "X-Forwarded-Proto: https" http://127.0.0.1:8080/
박스 안에서 직접 찔러 보면 앱이 내는 응답만 볼 수 있다. 여기서 301이 나오면 앱이 원인이고, 200이 나오면 앞단이다.
리다이렉트는 앞단에 두는 편이 낫다
앱이 X-Forwarded-Proto를 믿게 만드는 것도 답이지만, 리다이렉트 자체를 로드밸런서 리스너에서 끝내면 앱은 이 문제를 알 필요가 없다.
- HTTP 리스너 → HTTPS로 리다이렉트하는 규칙
- HTTPS 리스너 → 타깃 그룹으로 전달
앱에는 HTTPS로 온 요청만 도달하므로 프로토콜을 판단할 일이 없어진다.
인증을 앱이 아니라 앞단에 둔 것과 같은 발상이다. 앞에서 끊어 낼 수 있는 것은 앞에서 끊는다. 앱이 판단하지 않으면 앱이 틀릴 일도 없다.
헤더를 그대로 믿을 때의 조건
X-Forwarded-*는 누구나 보낼 수 있는 헤더다. 클라이언트가 직접 X-Forwarded-Proto: https를 붙여 보내면 앱은 HTTPS 요청으로 착각한다.
AWS 문서도 X-Forwarded-For에 대해 경고한다 — 네트워크 안에서 적절히 보호된 시스템이 붙인 값일 때만 신뢰할 수 있다.
그래서 전제가 하나 필요하다. 앱에 로드밸런서를 거치지 않고 도달하는 경로가 없어야 한다. 보안 그룹이 로드밸런서에서 오는 트래픽만 받게 되어 있어야 이 헤더를 신뢰할 수 있다. 앱 포트가 열려 있으면 헤더 신뢰가 곧 취약점이다.
FAQ
설정을 NATIVE로 바꿨는데 여전히 루프가 돕니다
앞단 웹서버나 로드밸런서 리스너에도 리다이렉트가 걸려 있는지 본다. 위의 curl로 앱 응답만 분리해서 확인하면 어느 쪽인지 갈린다.
HSTS를 켰는데 루프가 생겼습니다
HSTS는 브라우저가 HTTP 요청을 HTTPS로 바꿔 보내게 만든다. 앱이 여전히 HTTP로 인식하고 리다이렉트를 내면 같은 루프가 된다. 원인은 HSTS가 아니라 프로토콜 인식이므로 위 순서대로 확인한다.
로컬에서는 왜 안 나나요
로컬은 로드밸런서 없이 직접 붙는다. 프로토콜이 하나뿐이라 어긋날 자리가 없다. 이 문제는 앞에 프록시를 둔 환경에서만 난다.
확인한 것과 확인하지 못한 것
X-Forwarded-Proto·X-Forwarded-For·X-Forwarded-Port의 역할, X-Forwarded-For 처리 모드의 기본값이 append인 것, AWS가 이 헤더의 신뢰 조건을 경고한 것은 ALB 공식 문서에서 확인했다. server.forward-headers-strategy의 세 값과 RemoteIpValve·ForwardedHeaderFilter 관계는 Spring Boot 문서에서 확인했다.
확인하지 못한 것은 server.forward-headers-strategy를 명시하지 않았을 때의 기본값이다. 버전에 따라 다를 수 있어 이 글에 특정 값을 적지 않았다. 그래서 "무엇으로 돌고 있는지 확인하라"로 썼다 — 어차피 위 로그 한 줄이면 실제 동작이 나온다.
기본값을 외우는 것보다 지금 값을 찍어 보는 쪽이 빠르다. 이 문제는 설정을 몰라서 생기는 게 아니라, 어느 계층이 무엇을 보고 있는지 모른 채 한쪽만 고쳐서 길어진다.
'웹개발' 카테고리의 다른 글
| 프론트 검증은 UX지 보안이 아니다 — 서버 검증 누락이 남긴 것 (0) | 2026.09.28 |
|---|---|
| 차단했는데 12시간을 더 쓴다 — 세션 인증에는 무효화가 없다 (0) | 2026.09.21 |
| 메서드 이름이 트랜잭션을 결정한다 — Spring AOP 일괄 트랜잭션의 함정 (0) | 2026.09.17 |
| npm ci와 npm install 차이 — 속도가 아니라 락 파일을 대하는 태도다 (0) | 2026.09.14 |
| Spring readOnly 트랜잭션을 리더로 보내면 생기는 read-after-write 문제 (0) | 2026.09.13 |
댓글