Spring redirect와 리버스 프록시의 함정 - Mixed Content 에러 트러블슈팅
Tomcat 내부 코드까지 파헤친 삽질 기록
여는 글
지난번 레거시 인증/인가 시스템 Spring Security 4.2.5로 마이그레이션 하기 이후 발생했던 트러블 슈팅에 대한 이야기이다.
Mixed Content 에러
문제 발생
Spring Security 마이그레이션 후 테스트를 마치고 운영 서버에 배포한 직후, 동작을 재검증 중이었다. 대시보드 메뉴와 회사 로고에는 onclick="window.location.href='/'" 가 설정되어 있는데, 클릭을 하면 다음과 같은 에러가 발생했다.
Mixed Content: The page at 'https://우리회사URL/' was loaded over HTTPS,
but requested an insecure frame 'http://우리회사URL/main'.
This request has been blocked; the content must be served over HTTPS.
큰일 난 것이다. 운영 환경에서의 먹통이라니. ‘로컬에서는 문제 없었는데….!’
원인 분석 과정
Q. Spring Security 설정이 문제??
가장 첫 번째 의심은 당연히 Spring Security 설정 문제였다. 물론, Spring Security 설정을 하나씩 다 체크했다고 생각했지만 내가 놓친 부분이 있을 거라 생각했고, 무엇보다 마이그레이션 후 발생하지 않던 문제가 발생했기 때문이리라.
‘Spring Security 설정에는 분명 URL 프로토콜 관련 설정을 안 했는데.. 우린 Nginx를 쓰는걸.. Nginx..?!’
Q. Nginx 설정이 문제??
Nginx도 의심 대상 중 하나였다. 물론, 입사한 후부터 변경되지 않은 Nginx 설정이고 잘 돌아가고 있었지만, 모든 게 수상하다. 하지만, Nginx 설정을 의심할 시간에 F12를 눌러 Network 탭을 보는 게 더 정확하리라.
A. 범인 발견
브라우저 개발자 도구 Network 탭에서 /init 요청의 Response Headers를 확인했다.
HTTP/1.1 302 Found
Location: http://우리회사URL/main ← 범인 발견!
서버가 redirect URL을 http://로 생성하고 있었다. 인증/인가를 마이그레이션하면서 Controller에 redirect: 패턴을 사용하게끔 수정했는데, 여기서 문제가 발생한 것이다.
Spring Redirect 내부 동작 분석
단순히 “request.getScheme()이 http를 반환한다”는 것만으로는 부족했다. 왜 그런지, Spring과 Tomcat이 내부적으로 어떻게 redirect URL을 생성하는지 파악해야 했다.
Spring MVC의 redirect 처리 흐름
return "redirect:/main"이 실행되면 Spring MVC는 다음과 같은 흐름으로 처리한다.
Controller → ViewResolver → RedirectView → HttpServletResponse.sendRedirect()
핵심은 최종적으로 Tomcat의 Response.sendRedirect()가 호출된다는 것이다.
Tomcat Response.sendRedirect() 분석
org.apache.catalina.connector.Response 클래스의 sendRedirect() 메서드를 살펴보면,
// org.apache.catalina.connector.Response.java
public void sendRedirect(String location) throws IOException {
this.sendRedirect(location, 302);
}
public void sendRedirect(String location, int status) throws IOException {
if (this.isCommitted()) {
throw new IllegalStateException(sm.getString("coyoteResponse.sendRedirect.ise"));
} else if (!this.included) {
this.resetBuffer(true);
try {
String locationUri;
// 상대 경로 리다이렉트 지원 여부 확인
if (this.getRequest().getCoyoteRequest().getSupportsRelativeRedirects()
&& this.getContext().getUseRelativeRedirects()) {
locationUri = location;
} else {
// 상대 경로를 절대 URL로 변환 ← 여기가 핵심!
locationUri = this.toAbsolute(location);
}
this.setStatus(status);
this.setHeader("Location", locationUri);
// ...
} catch (IllegalArgumentException e) {
// ...
}
}
}
기본 설정에서는 toAbsolute() 메서드가 호출되어 상대 경로(/main)를 절대 URL로 변환한다.
Tomcat Response.toAbsolute() 분석
toAbsolute() 메서드가 실제로 URL을 어떻게 생성하는지 확인했다.
// org.apache.catalina.connector.Response.java
protected String toAbsolute(String location) {
if (location == null) {
return location;
} else {
boolean leadingSlash = location.startsWith("/");
// 이미 scheme이 있는 절대 URL이면 그대로 반환
if (!leadingSlash && UriUtil.hasScheme(location)) {
return location;
} else {
this.redirectURLCC.recycle();
// ★ 핵심: request에서 scheme, serverName, serverPort를 가져옴
String scheme = this.request.getScheme();
String name = this.request.getServerName();
int port = this.request.getServerPort();
try {
// URL 조립: scheme://serverName:port
this.redirectURLCC.append(scheme, 0, scheme.length());
this.redirectURLCC.append("://", 0, 3);
this.redirectURLCC.append(name, 0, name.length());
// 기본 포트가 아닌 경우에만 포트 추가
if (scheme.equals("http") && port != 80
|| scheme.equals("https") && port != 443) {
this.redirectURLCC.append(':');
String portS = port + "";
this.redirectURLCC.append(portS, 0, portS.length());
}
// ... 이하 path 처리
}
}
}
}
여기서 this.request.getScheme()이 호출되는데, 이 값이 http를 반환하면 최종 URL도 http://로 시작하게 된다.
Tomcat Request.getScheme() 분석
그렇다면 request.getScheme()은 어디서 값을 가져오는가? org.apache.catalina.connector.Request 클래스를 확인했다.
// org.apache.catalina.connector.Request.java
public String getScheme() {
return this.coyoteRequest.scheme().toString();
}
coyoteRequest는 Tomcat의 저수준 HTTP 요청 객체다. 이 scheme 값은 실제 네트워크 연결의 프로토콜을 반영한다.
근본 원인 확인
[우리 시스템 구조]
[클라이언트] --HTTPS(443)--> [Nginx] --HTTP(포트는 비밀)--> [WAS/Tomcat]
Nginx와 WAS 사이의 연결이 HTTP이므로, coyoteRequest.scheme()은 항상 http를 반환한다. 결국 toAbsolute("/main")의 결과는
http://우리회사URL/main
이것이 Mixed Content 에러의 근본 원인이었다.
왜 기존에는 문제가 없었나?
기존 레거시 코드는 redirect.jsp를 사용했다.
<%-- 개념적 예시 --%>
<%-- redirect.jsp --%>
<script>
location.href = '${url}';
</script>
이건 클라이언트 측 리다이렉트다. 브라우저가 이미 HTTPS 페이지에 있으므로, JavaScript의 상대 경로 이동 시 현재 페이지의 프로토콜(HTTPS)이 유지된다. 서버는 URL 생성에 전혀 관여하지 않는다.
반면 Spring의 redirect:는 서버 측 리다이렉트다. 서버가 Location 헤더에 절대 URL을 생성해야 하고, 이 과정에서 request.getScheme()을 참조하게 된다.
| 방식 | URL 생성 주체 | 프로토콜 결정 |
|---|---|---|
| redirect.jsp (JS) | 브라우저 | 현재 페이지 기준 → HTTPS 유지 ✓ |
| Spring redirect | 서버 (Tomcat) | request.getScheme() → HTTP ✗ |
해결 방법 탐색
문제의 근본 원인을 파악했다. 이제 해결책을 찾아야 했다.
시도 1: Nginx에서 X-Forwarded-Proto 헤더 추가
가장 먼저 떠오른 건 표준적인 방법이었다. 리버스 프록시 환경에서는 X-Forwarded-Proto 헤더로 원본 프로토콜을 전달하는 것이 일반적이다.
proxy_set_header X-Forwarded-Proto $scheme;
하지만 이것만으로는 부족하다. WAS가 이 헤더를 읽어서 request.getScheme()에 반영해야 한다.
시도 2: Spring ForwardedHeaderFilter 사용
Spring 4.3부터는 ForwardedHeaderFilter가 제공된다.
@Bean
public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilter() {
FilterRegistrationBean<ForwardedHeaderFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new ForwardedHeaderFilter());
return bean;
}
문제는 우리 시스템이 Spring 4.2.x를 사용한다는 것. ForwardedHeaderFilter는 Spring 4.3부터 추가된 클래스라 사용할 수 없었다.
최종 해결: 커스텀 필터 구현
결국 직접 구현하기로 했다. 필요한 건 두 가지다.
- HttpServletRequestWrapper:
getScheme(),getServerPort(),isSecure(),getRequestURL()을 오버라이드하여 HTTPS 환경처럼 보이게 함 - HttpServletResponseWrapper:
sendRedirect()를 가로채서http://로 시작하는 URL을https://로 변환
왜 Response Wrapper까지 필요했냐면, Request만 래핑해도 Spring Security 등 다른 필터들이 Response를 다시 래핑하면서 우리 Request Wrapper가 무시될 수 있기 때문이다. sendRedirect 시점에서 최종 방어선을 두는 것이다.
최종 해결 방법
Step 1: Nginx 설정 수정
모든 서버 블록의 location /에 X-Forwarded-Proto 헤더 추가.
location / {
proxy_pass http://시크릿;
proxy_set_header Host $server_name;
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; # 추가
...
}
Step 2: Spring 커스텀 필터 구현
@Slf4j
public class XForwardedProtoFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String proto = httpRequest.getHeader("X-Forwarded-Proto");
if ("https".equalsIgnoreCase(proto)) {
HttpsRequestWrapper requestWrapper = new HttpsRequestWrapper(httpRequest);
chain.doFilter(requestWrapper, new HttpsResponseWrapper(httpResponse, requestWrapper));
} else {
// 헤더가 없으니 (로컬 개발 환경 등) 원본 그대로 통과
chain.doFilter(request, response);
}
}
/**
* Request Wrapper
* Tomcat의 Request.getScheme(), getServerPort() 등을 오버라이드하여
* HTTPS 환경처럼 동작하게 만든다.
*/
private static class HttpsRequestWrapper extends HttpServletRequestWrapper {
//...
@Override
public StringBuffer getRequestURL() {
// Tomcat의 Request.getRequestURL()과 동일한 로직이지만
// scheme을 https로 고정
StringBuffer url = new StringBuffer();
url.append("https://").append(getServerName());
String contextPath = getContextPath();
String servletPath = getServletPath();
String pathInfo = getPathInfo();
if (contextPath != null) url.append(contextPath);
if (servletPath != null) url.append(servletPath);
if (pathInfo != null) url.append(pathInfo);
return url;
}
}
/**
* Response Wrapper
* Tomcat의 Response.sendRedirect() → toAbsolute()에서
* http://로 URL이 생성되더라도 최종적으로 https://로 변환한다.
*/
private static class HttpsResponseWrapper extends HttpServletResponseWrapper {
private final HttpServletRequest request;
public HttpsResponseWrapper(HttpServletResponse response, HttpServletRequest request) {
super(response);
this.request = request;
}
@Override
public void sendRedirect(String location) throws IOException {
if (location != null) {
// 상대 경로인 경우 직접 절대 URL 생성
if (location.startsWith("/")) {
location = "https://" + request.getServerName() + location;
}
// http://로 시작하면 https://로 변환
else if (location.startsWith("http://")) {
location = location.replace("http://", "https://");
}
}
super.sendRedirect(location);
}
}
}
Step 3: 필터 등록 (web.xml)
Spring Security 필터보다 앞에 위치해야 한다. 요청이 들어오자마자 HTTPS 컨텍스트로 래핑되어야 이후 모든 필터와 컨트롤러에서 올바른 프로토콜을 인식한다.
<!-- X-Forwarded-Proto 처리 (Spring Security 앞에 위치) -->
<filter>
<filter-name>xForwardedProtoFilter</filter-name>
<filter-class>com.--.app.filter.XForwardedProtoFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>xForwardedProtoFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- Spring Security Filter -->
<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
Step 4: iframe 내부 링크 수정
Mixed Content 문제를 해결한 후, 추가로 발견된 문제가 있었다. 대시보드 클릭이 여전히 동작하지 않았는데, 이건 iframe 구조 때문이었다.
우리 시스템은 메인 레이아웃이 iframe 기반이다.
부모 페이지: https://우리회사URL/
└── iframe: /init → /main (실제 콘텐츠)
헤더가 iframe 내부에 있었기 때문에, window.location.href='/'는 iframe 내부만 이동시켰다. 부모 페이지 전체를 이동시키려면,
<!-- Before -->
<div onclick="window.location.href='/'">대시보드</div>
<!-- After -->
<div onclick="window.top.location.href='/'">대시보드</div>
보안 고려사항
X-Forwarded-* 헤더는 클라이언트가 조작할 수 있어 보안 우려가 있다. 악의적인 사용자가 다음과 같이 요청할 수 있다.
curl -H "X-Forwarded-Proto: https" http://우리회사URL/
하지만 현재 구조에서는 안전하다.
proxy_set_header X-Forwarded-Proto $scheme;
Nginx가 클라이언트가 보낸 헤더 값을 무시하고 실제 연결 프로토콜($scheme)로 덮어쓴다. 단, 이 보안이 유지되려면 WAS 포트가 외부에 직접 노출되지 않아야 한다. 모든 트래픽이 반드시 Nginx를 거쳐야 한다.
재발 방지
| 항목 | 조치 |
|---|---|
| Nginx 설정 표준화 | 모든 서버 블록에 X-Forwarded-Proto 헤더 추가 |
| 필터 기본 적용 | XForwardedProtoFilter를 공통 필터로 등록 |
| 로컬 환경 영향 없음 | 헤더가 없으면 필터가 동작하지 않아 로컬 개발에 영향 없음 |
| 문서화 | 리버스 프록시 환경에서의 필수 설정으로 기록 |
교훈
이번 트러블슈팅에서 얻은 교훈
-
로컬과 운영 환경의 차이를 항상 인식하자. 특히 리버스 프록시, SSL 종료 지점 등 인프라 구성이 다르면 동일한 코드도 다르게 동작할 수 있다.
-
레거시 코드가 우연히 문제를 피해가는 경우가 있다.
redirect.jsp가 클라이언트 측 리다이렉트를 사용해서 이 문제를 피해갔던 것처럼, 기존 코드가 “왜 그렇게 작성되었는지” 이해 없이 변경하면 예상치 못한 문제가 터질 수 있다. -
서버 측 리다이렉트는 프록시 환경에서 주의가 필요하다.
redirect:를 사용할 때 서버가 URL을 어떻게 생성하는지, 그 과정에서 어떤 정보를 참조하는지 알아야 한다. -
프레임워크 내부 동작을 이해하자. “안 된다”에서 멈추지 않고 Tomcat의
Response.sendRedirect()→toAbsolute()→request.getScheme()호출 흐름까지 추적해야 정확한 해결책을 찾을 수 있다.