- Published on
Nginx HTTPS 리다이렉트 루프(301·307) 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영에서 HTTPS 강제 리다이렉트를 켰더니 브라우저가 계속 같은 URL로 튕기거나, curl 에서 301/307이 끝없이 반복되는 경우가 있습니다. 이 문제는 대개 Nginx가 “현재 요청이 HTTP인지 HTTPS인지”를 잘못 판단하면서 발생합니다. 특히 로드밸런서(ALB/ELB), CDN(CloudFront/Cloudflare), 리버스 프록시(ingress, Nginx 앞단 프록시) 뒤에 있을 때 빈도가 높습니다.
이 글에서는 Nginx HTTPS 리다이렉트 루프의 전형적인 패턴(301·307), 원인별 체크리스트, 그리고 가장 안전한 설정 예시를 정리합니다.
301과 307 루프가 의미하는 것
- 301 Moved Permanently: 브라우저/중간 캐시에 강하게 저장될 수 있습니다. 한번 잘못 걸리면 설정을 고쳐도 사용자가 계속 루프를 겪는 것처럼 보일 수 있습니다.
- 307 Temporary Redirect: 메서드(POST 등)를 유지합니다. 프레임워크나 프록시가 “일시적 리다이렉트”를 걸 때 자주 보입니다.
루프의 본질은 단순합니다.
- 클라이언트가
https://example.com으로 요청 - 어딘가(Nginx 또는 앱)가 “HTTP로 들어왔다”고 오판
- 다시
https://example.com으로 리다이렉트 - 1로 반복
가장 흔한 원인 1: TLS 종료 지점 혼동(프록시/로드밸런서 뒤)
증상
- 외부에서는 HTTPS로 접속하지만, Nginx는 upstream 또는 자신이 받는 연결이 HTTP라서
$scheme이http로 평가됨 if ($scheme = http) { return 301 https://$host$request_uri; }같은 룰이 계속 발동
왜 발생하나
ALB/Ingress/CDN에서 TLS를 종료하고 Nginx로는 HTTP로 전달하는 구조에서, Nginx 입장에서는 실제 접속이 HTTP입니다. 하지만 사용자는 이미 HTTPS로 접속했으니, Nginx가 다시 HTTPS로 돌리면 외부에서는 변화가 없고 같은 요청이 반복됩니다.
해결 핵심
- “외부에서 HTTPS였는지”를
X-Forwarded-Proto같은 헤더로 전달받고, 그 값을 기준으로 리다이렉트 판단 - 또는 TLS 종료를 Nginx에서 직접 하고, 외부-내부 모두 HTTPS로 일관되게 구성
가장 흔한 원인 2: X-Forwarded-Proto 미설정/오염
프록시 체인에서 다음 중 하나가 있으면 루프가 잘 납니다.
- 로드밸런서가
X-Forwarded-Proto를 안 넣음 - 중간 프록시가 값을 덮어씀(예: 항상
http로 고정) - Nginx가 그 헤더를 신뢰/사용하지 않음
특히 OAuth 콜백/리다이렉트 URI, 절대 URL 생성에서 스킴이 틀어지면 리다이렉트가 꼬이기 쉽습니다. 프록시 뒤에서 리다이렉트 URI 불일치가 함께 나타난다면 아래 글도 같이 보세요.
가장 흔한 원인 3: HSTS/브라우저 캐시로 “고친 뒤에도” 계속 루프
한 번이라도 잘못된 301이 나가면 브라우저가 영구 캐싱할 수 있습니다. 여기에 HSTS까지 걸려 있으면, 사용자는 HTTP로 접근해도 자동으로 HTTPS로 바뀌고(브라우저 레벨), 서버 리다이렉트와 합쳐져 증상이 더 복잡해집니다.
체크
- 크롬:
chrome://net-internals/#hsts에서 도메인 삭제(환경에 따라 메뉴가 바뀔 수 있음) - 캐시 무시하고
curl -I로 확인 - 프라이빗 모드/다른 브라우저로 재현
진단: 리다이렉트 체인을 눈으로 확인하기
1) curl 로 Location 추적
curl -I http://example.com
curl -I https://example.com
curl -IL http://example.com
-I는 헤더만-L은 리다이렉트 따라가기
여기서 Location 이 계속 같은 곳을 가리키거나, http 와 https 를 번갈아 가리키면 루프입니다.
2) Nginx 접근 로그에 스킴/헤더를 찍어 확인
log_format dbg '$remote_addr $host "$request" '
'status=$status scheme=$scheme '
'xfp=$http_x_forwarded_proto '
'location="$sent_http_location"';
access_log /var/log/nginx/access_dbg.log dbg;
$scheme이http로만 찍히는데 외부는 HTTPS라면, 프록시 환경에서의 오판 가능성이 큽니다.xfp값이 비어있거나http로 고정이면 로드밸런서/프록시 설정을 먼저 봐야 합니다.
해결 패턴 A: Nginx가 직접 80에서만 HTTPS로 리다이렉트(가장 안전)
Nginx가 TLS를 직접 종료하는 구조라면, 리다이렉트는 80 서버 블록에서만 처리하는 게 안전합니다. 443에서 또 스킴 검사로 리다이렉트를 걸면 꼬일 여지가 커집니다.
# HTTP(80)에서는 무조건 HTTPS로
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
# HTTPS(443)에서는 리다이렉트 로직을 두지 말고 서비스
server {
listen 443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://app_upstream;
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 https;
}
}
핵심은 간단합니다.
- 80에서만
return 301 https://... - 443에서는 “난 이미 HTTPS”라는 전제 하에 앱으로 프록시
해결 패턴 B: 프록시/로드밸런서 뒤에서 X-Forwarded-Proto 로 판단
TLS 종료가 로드밸런서에서 일어나고 Nginx는 HTTP로 받는 구조라면, $scheme 는 신뢰할 수 없습니다. 대신 X-Forwarded-Proto 를 기준으로 리다이렉트 여부를 결정해야 합니다.
1) map 으로 안전하게 플래그 만들기
map $http_x_forwarded_proto $redirect_to_https {
default 1;
https 0;
}
X-Forwarded-Proto: https면 리다이렉트하지 않음- 그 외(없음 포함)는 리다이렉트
2) 리다이렉트 적용(주의: 무조건 80에서만 하려면 구조를 더 단순화 가능)
server {
listen 80;
server_name example.com;
if ($redirect_to_https) {
return 301 https://$host$request_uri;
}
location / {
proxy_pass http://app_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
}
}
다만 이 패턴은 “80으로 들어왔는데도 이미 외부는 HTTPS”인 특수 케이스(예: LB가 443에서 TLS 종료 후 80으로 전달)를 다루려는 목적입니다.
현실적으로는 더 깔끔한 해법이 많습니다.
- 로드밸런서에서 HTTP(80) 리스너를 443으로 리다이렉트 처리
- Nginx는 내부 통신만 담당하고 리다이렉트는 LB에서만 수행
해결 패턴 C: 앱(업스트림)에서 또 리다이렉트하는 이중 리다이렉트 제거
Nginx에서 HTTPS 강제를 했는데도 307이 반복된다면, 앱(Spring, Next.js, Express 등)이 다음 중 하나를 하고 있을 수 있습니다.
X-Forwarded-Proto를 신뢰하지 못해 “HTTP로 들어왔다”고 판단- 프레임워크의
force_ssl,redirect_http_to_https류 옵션이 켜져 있음 - 절대 URL 생성 시 스킴이 뒤틀려 리다이렉트가 계속 발생
Nginx에서 업스트림으로 전달 헤더를 표준화
location / {
proxy_pass http://app_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
여기서도 프록시 뒤라면 $scheme 가 내부 기준일 수 있으니, 필요하면 $http_x_forwarded_proto 를 우선하는 형태로 정리하세요.
301 vs 308 vs 307: 무엇을 써야 하나
- 301: 대부분의 “HTTP에서 HTTPS로 영구 이동”에 사용. 캐싱이 강하므로 초기 롤아웃에서 실수하면 영향이 큼.
- 308: 301과 유사하되 메서드 보존. API에서 POST를 HTTPS로 강제할 때 고려.
- 307: 임시. 디버깅/점진 적용에는 편하지만, 영구 정책에는 보통 301/308이 맞습니다.
운영에서 안전하게 가려면,
- 처음에는 307로 배포해 루프/부작용이 없는지 확인
- 문제가 없으면 301(또는 308)로 전환
이 순서가 사고를 줄입니다.
체크리스트: 루프를 끊는 실전 점검 순서
curl -IL로 리다이렉트 체인을 캡처하고, 어떤 홉에서 301/307이 나오는지 확인- Nginx 로그에
$scheme,X-Forwarded-Proto,Location을 찍어서 “오판” 지점을 찾기 - TLS 종료 지점 확정
- Nginx가 TLS를 직접 종료하는가
- 로드밸런서/CDN이 종료하는가
- 리다이렉트 책임을 한 곳으로 모으기
- 가능하면 LB에서만 처리하거나, Nginx의 80 서버에서만 처리
- 브라우저 캐시/HSTS 제거 후 재검증
EKS/ALB 같은 환경에서는 “문제는 Pod가 아닌데 엣지에서 증상이 난다”가 자주 발생합니다. 비슷한 결의 트러블슈팅 흐름은 아래 글도 참고가 됩니다.
결론: 리다이렉트 루프의 80%는 스킴 인식 문제
Nginx HTTPS 리다이렉트 루프(301·307)는 대부분 다음 두 가지 중 하나로 정리됩니다.
- TLS 종료 지점과 리다이렉트 지점이 분리되어 Nginx가 스킴을 오판
X-Forwarded-Proto체인이 깨져 앱/Nginx가 서로 다른 현실을 보고 리다이렉트
가장 안전한 운영 원칙은 간단합니다.
- 리다이렉트는 한 곳에서만(가능하면 80에서만)
- 프록시 뒤에서는
$scheme대신X-Forwarded-Proto를 기준으로 판단 - 301은 캐시가 강하니, 초기에는 307로 검증 후 전환
이 3가지만 지켜도 “고쳤는데도 계속 루프” 같은 장기 장애를 크게 줄일 수 있습니다.