여는 글

지난번 레거시 인증/인가 시스템 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부터 추가된 클래스라 사용할 수 없었다.

최종 해결: 커스텀 필터 구현

결국 직접 구현하기로 했다. 필요한 건 두 가지다.

  1. HttpServletRequestWrapper: getScheme(), getServerPort(), isSecure(), getRequestURL()을 오버라이드하여 HTTPS 환경처럼 보이게 함
  2. 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를 공통 필터로 등록
로컬 환경 영향 없음 헤더가 없으면 필터가 동작하지 않아 로컬 개발에 영향 없음
문서화 리버스 프록시 환경에서의 필수 설정으로 기록

교훈

이번 트러블슈팅에서 얻은 교훈

  1. 로컬과 운영 환경의 차이를 항상 인식하자. 특히 리버스 프록시, SSL 종료 지점 등 인프라 구성이 다르면 동일한 코드도 다르게 동작할 수 있다.

  2. 레거시 코드가 우연히 문제를 피해가는 경우가 있다. redirect.jsp가 클라이언트 측 리다이렉트를 사용해서 이 문제를 피해갔던 것처럼, 기존 코드가 “왜 그렇게 작성되었는지” 이해 없이 변경하면 예상치 못한 문제가 터질 수 있다.

  3. 서버 측 리다이렉트는 프록시 환경에서 주의가 필요하다. redirect:를 사용할 때 서버가 URL을 어떻게 생성하는지, 그 과정에서 어떤 정보를 참조하는지 알아야 한다.

  4. 프레임워크 내부 동작을 이해하자. “안 된다”에서 멈추지 않고 Tomcat의 Response.sendRedirect()toAbsolute()request.getScheme() 호출 흐름까지 추적해야 정확한 해결책을 찾을 수 있다.