스프링

[스프링] spring 프록시 환경에서 HttpContentCache 적용

ImNM 2023. 3. 6. 01:54

 

두둥 백엔드에서는 에러로그를 cloudWatch 로도 전송하면서,

실시간으로 500번대 알림을 받아보기 위해서 슬랙으로 비동기적으로 에러를 전송하고 있다.

 

하지만 스프링은 한번 깐 body의 값을 볼 수 없어서 

 

ContentCachingRequestWrapper (Spring Framework 6.0.6 API)

handleContentOverflow protected void handleContentOverflow(int contentCacheLimit) Template method for handling a content overflow: specifically, a request body being read that exceeds the specified content cache limit. The default implementation is empt

docs.spring.io

위 처럼 ContentCachingRequest/ResponseWrapper을 사용해서 

복사를 해둔뒤에 꺼내서 쓴다.

 

적용하는 방법은 간단하다.

필터에서 캐싱을 적용하면된다.

@Component
public class HttpContentCacheFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(
            HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        ContentCachingRequestWrapper wrappingRequest = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper wrappingResponse =
                new ContentCachingResponseWrapper(response);

        chain.doFilter(wrappingRequest, wrappingResponse);

        wrappingResponse.copyBodyToResponse();
    }
}

다만 프록시 환경에서는 필터의 순서 때문에 필요한곳에서 ContentCachingRequestWrapper 로 캐싱을 해줄때에,

에러가 나는 경우가있다. 

그 해결방법을 공유하려고 한다.

 

fix : x-forward 필터로 인한 ContentCachingRequestWrapper 캐스팅 오류 해결 by ImNM · Pull Request #267 · Gosrock/Du

개요 close #266 작업사항 500 번대 오류시 슬랙 알림이 서버에서 안오더라고요? 보니깐 proxy 로 nginx 달려있으니 x-Forward 헤더 가 넘어오는데 이때 ForwardedHeaderFilter가 동작해 버리더라고요 위 필터가

github.com

이 글 내용은 위 pr을 기반으로 한다.


목차

1. 문제점

2. 개선하기

  2.1. 스프링 시큐리티 처럼 필터 조정하면되는거아니야?

  2.2. 오더로 조정하기


1. 문제점

final ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper) request;

에러를 슬랙으로 바디 정보를 함께 전송할려면 GlobalExceptionHandler 에서 

@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e, HttpServletRequest request)
        throws IOException {
    final ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper) request;

비즈니스적으로 처리하지못한 500번대인 Exception 을 잡아서 처리해야할때

HttpServletRequest 를 다시 ContentCachingRequestWrapper로 캐스팅 해야한다.

 

로컬에서 개발할때는 전혀 문제가 없었다.

하지만 두둥서버는 nginx 안에서 프록시 되어있는 환경이였는데, 어느날

java.lang.ClassCastException: 
class org.springframework.web.filter.ForwardedHeaderFilter$ForwardedHeaderExtractingRequest 
cannot be cast to class org.springframework.web.util.ContentCachingRequestWrapper

위와 같은 오류가 발생 했다.

ForwardedHeaderExtractionRequestrequest가 넘어와서 캐스팅 할 수 없었던 문제였다.

ForwardedHeaderFilter를 찾아가서 발동 조건을 살펴보았다.

// FORWARDED_HEADER_NAMES
FORWARDED_HEADER_NAMES.add("Forwarded");
FORWARDED_HEADER_NAMES.add("X-Forwarded-Host");
FORWARDED_HEADER_NAMES.add("X-Forwarded-Port");
FORWARDED_HEADER_NAMES.add("X-Forwarded-Proto");
FORWARDED_HEADER_NAMES.add("X-Forwarded-Prefix");
FORWARDED_HEADER_NAMES.add("X-Forwarded-Ssl");
FORWARDED_HEADER_NAMES.add("X-Forwarded-For");

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
   for (String headerName : FORWARDED_HEADER_NAMES) {
      if (request.getHeader(headerName) != null) {
         return false;
      }
   }
   return true;
}

즉 위 헤더가 포함 되어있을 때 필터가 작동하는 것이다.

 

그럼 nginx는? 요청을 upstream server로 넘겨줄 때 어떤 헤더를 넘겨주길래 저렇게 작동할까. 봤다.

(https://www.nginx.com/resources/wiki/start/topics/examples/forwarded/)

X- 종류말고도 , remote_addr 등의 정보를 헤더에 담아 upstream으로 넘겨주는것을  알 수 있다.

 

위와같은 조건들로 인해서. ForwardedHeaderFilter 가 작동하게 된것이다.

로컬에서는 작동하지 않았던 ForwardedHeaderFilter 가 작동하게되면서 캐스팅오류가 난것을 알 수 있었다.

 

그럼 , 캐스팅 오류는 왜 일어난게 된걸까?

정답은 필터의 순서 때문이다.

 

서블릿 필터체인

내가 만들었던 HttpContentCacheFilterForwardedHeaderFilter 앞에있다.

해결방법을 알았다.

ForwardedHeaderFilter 필터가 HttpContentCacheFilter 보다 앞에오게하면,

최종적으로 에러 핸들어에서 넘어오는 HttpRequest는 ContentCachingRequestWrapper 타입일것이 명확하다.

 


2. 개선하기


2.1. 스프링 시큐리티 처럼 필터 조정하면되는거아니야?

자연스럽게... 생각할수 있을것같다. 나도 그랬다.

@RequiredArgsConstructor
@Component
public class FilterConfig
        extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private final JwtTokenFilter jwtTokenFilter;
    private final JwtExceptionFilter jwtExceptionFilter;
    private final HttpContentCacheFilter httpContentCacheFilter;

    @Override
    public void configure(HttpSecurity builder) {
        builder.addFilterBefore(jwtTokenFilter, BasicAuthenticationFilter.class);
        builder.addFilterBefore(jwtExceptionFilter, JwtTokenFilter.class);
        // ForwardedHeaderFilter뒤에 놓으면되네!
        builder.addFilterBefore(httpContentCacheFilter, ForwardedHeaderFilter.class);
    }
}

위와 같이하면 안된다.

서블릿 필터체인

 위 사진의 형광색 놓인 부분이 보이는가? DelegatingFilterProxy를 지나게되면서

스프링 시큐리티의 가상의 필터가 시작된다.

 

FilterChainProxy$VirtualFilterChain

3번의 서블릿 필터를 거치면서 ->

바로 위사진의 스프링 시큐리티의 로직이 수행되고 ->

4번의 필터로 돌아와서 수행된다.

 

위코드처럼 설정을 한다는것은, 그냥 스프링 시큐리티 내부에서 위치를 바꾸는것이다.

 

ForwardedHeaderFilter은 서블릿의 필터다.

그럼 우린.. 다른 방법을 알아봐야한다.


2.2 오더로 적용하기

 

서블릿 필터의 순서를 적용하는방법은 @Order 라는 어노테이션을 활용하는 방법이다.

@Order(Integer.MAX_VALUE)
@Component
public class HttpContentCacheFilter extends OncePerRequestFilter {

위 방식으로 Order을 지정하면 끝난다. 가아니라 이것도 안된다..

 

나는 맨 마지막 순서로 가길 원하는데...하나도 안움직인다.

 

Filter order in spring-boot

How can I specify order of my Filter in spring-boot? I need to insert my MDC filter after Spring Security filter. I tried almost everything but my filter was always first. This didn't work: @Bean ...

stackoverflow.com

그 이유는 위에서 찾아볼 수 있다.
스프링 시큐리티도, ForwardedHeaderFilter도 아무 순서를 지정하지 않아서, 맨 뒤로 배정이 된다.

아무 지정을 안하게되면 컴파일 순서로 배정되는것 같은데, 지멋대로다..

그래서 직접 순서를 재조정해야하는 작업이 필요하다.

@Configuration
@RequiredArgsConstructor
@Profile({"prod", "staging", "dev"})
public class ServletFilterConfig implements WebMvcConfigurer {

    private final HttpContentCacheFilter httpContentCacheFilter;
    private final ForwardedHeaderFilter forwardedHeaderFilter;

    @Bean
    public FilterRegistrationBean securityFilterChain(
            @Qualifier(AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME)
                    Filter securityFilter) {
        FilterRegistrationBean registration = new FilterRegistrationBean(securityFilter);
        registration.setOrder(Integer.MAX_VALUE - 3);
        registration.setName(AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME);
        return registration;
    }

    @Bean
    public FilterRegistrationBean setResourceUrlEncodingFilter() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        registrationBean.setFilter(new ResourceUrlEncodingFilter());
        registrationBean.setOrder(Integer.MAX_VALUE - 2);
        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean setForwardedHeaderFilterOrder() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        registrationBean.setFilter(forwardedHeaderFilter);
        registrationBean.setOrder(Integer.MAX_VALUE - 1);
        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean setHttpContentCacheFilterOrder() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        registrationBean.setFilter(httpContentCacheFilter);
        registrationBean.setOrder(Integer.MAX_VALUE);
        return registrationBean;
    }
}

위방식을 활용해서 필터의 위치를 재조정 시킬 수 있다

사진처럼 우리가 원하는 위치에 필터들이 재조정되는 것을 볼 수 있다.

 

이로써, 프록시 환경에서 ContentCache 필터를 적용하는 방법을 찾아내서 적용시켰다.


이문제를 해결할려고 디버거를 거의 하루 왼종일 돌렸었던것 같다.

덕분에 스프링 시큐리티의 필터와 , 서블릿 필터간의 작동 방식을 알 수 있었다.

 

위 사진에 7,8,9번도 사실 내가 커스텀해서 적용한 필터들이다.

@Component 어노테이션으로 빈으로 등록해서 사용중인 컴포넌트들인데,

해당 필터들은 스프링 시큐리티 필터에서도 등록되어 사용되어지고있다.

그럼 두번 동작하는게 아닌가 의문점이 들수 있지만 

OnecPerRequestFilter를 상속받아 사용하고 있으니 문제가 없을것같다.

 

이걸 보니 시큐리티 FilterConfig 에서 di를 받을게 아니라 생성자로 생성해야하나? 의문점이 들긴하다.

 

위 소스들 은 아래 레포지토리에서 참고 가능하다.

 

GitHub - Gosrock/DuDoong-Backend: 모두를 위한 새로운 공연 라이프, 두둥!

모두를 위한 새로운 공연 라이프, 두둥! Contribute to Gosrock/DuDoong-Backend development by creating an account on GitHub.

github.com