Spring Interceptor 를 통해 setHeader 가 안되는 경우

조원준·2022년 8월 2일
2

마주한 문제

특정 API를 통해 나가는 응답에 대해 공통 커스텀 헤더를 달아줘야 하는 일이 발생하여 인터셉터를 통해 해결하고자 하였다.

스프링과 필터, 인터셉터, ... What? (https://victorydntmd.tistory.com/176)

@Slf4j
public class CustomHandlerInterceptor implements HandlerInterceptor {

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    	response.setHeader("X-Custom-Response-Header", UUID.randomUUID());
    }
}

그런데 setHeader 를 호출함에도 헤더가 안들어가는 것이였다.

Response 가 어떻게 이루어지는지 상세히 분석해볼 좋은 기회라고 생각되어 분석하게 되었다.
응답되는 Response 에 대하여 응답 헤더를 추가적으로 넣고 싶을 경우에 고려해야 할 사항에 대해서 정리해 보겠다.

사전조사

스프링 어플리케이션이 요청받아 응답을 사용자에게 보내주기까지의 코드레벨을 도식화하면 아래와 같다.

무언가 복잡해 보이지만 결과적으로는 Nio Thread 의 경우 (Tomcat) NioEndpoint.java 안에서 알아서 모두 해주는 것인데 우리가 주목해야 할 부분은 Wrapper Buffer Flush 부다.

실질적인 문제

스프링 컨트롤러에서 ResponseEntity 객체를 전달하면 Content-Type 에 따라서 해당 타입에 대한 Message Converter 가 실행되고 이후 Flush 과정을 거친다.

Flush 과정을 거친 다음 Response 객체를 변경하지 못하도록 'AppCommittedFlag'란 것을 설정하게 되는데 이때 헤더를 추가 설정할 수 없도록 잠기게 된다.

	public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodProcessor {
      @Override
      public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
              ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
            .... 중략
            // Ensure headers are flushed even if no body was written.
            outputMessage.flush();
		}
	}
    /**
     * Set the application commit flag.
     *
     * @param appCommitted The new application committed flag value
     */
    public void setAppCommitted(boolean appCommitted) {
        this.appCommitted = appCommitted;
    }
    @Override
    public void setHeader(String name, String value) {

        if (isCommitted()) { // 해당 부분에서 return 으로 빠짐 (잠겨있기 때문에)
            return;
        }

        response.setHeader(name, value);

    }

Flush 는 어떤 객체가 시킬까? 전달되는 ResponseWrapper 가 flush 를 지원한다면 잠기게 되는데 기본 스프링 응답 래핑 객체(ServletResponseWrapper)는 flush 시키도록 되어 있다.

public class ServletResponseWrapper implements ServletResponse {
	/**
     * The default behavior of this method is to call flushBuffer() on the
     * wrapped response object.
     */
    @Override
    public void flushBuffer() throws IOException {
        this.response.flushBuffer(); // 기본전략
    }
}

인터셉터는 해당 Flush 작업 이후에 실행된다. 그래서 잠겨있기 때문에 헤더가 설정되지 않았다.

해결방안

필자가 찾은 해결방안은 단순하다. 요청 진입시 ResponseWrapper 를 한번 더 씌워서 응답에 대한 Flush 처리를 안하도록 하면 된다.

스프링에는 해당 부분을 도와주는 ContentCachingResponseWrapper 라는 객체가 있다. 요청 진입시 필터를 씌워 ContentCachingResponseWrapper 로 래핑을 시켜주면 Flush 처리를 안되도록 할 수 있다.

필터를 씌워보자.

@Component
public class CustomHeaderContentValve implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        ContentCachingRequestWrapper contentCachingRequestWrapper = new ContentCachingRequestWrapper((HttpServletRequest) req);
        ContentCachingResponseWrapper contentCachingResponseWrapper = new ContentCachingResponseWrapper((HttpServletResponse) res);

        chain.doFilter(contentCachingRequestWrapper, contentCachingResponseWrapper);
        contentCachingResponseWrapper.copyBodyToResponse();
    }
}

contentCachingResponseWrapper.copyBodyToResponse(); 해당 부분이 없으면 마지막에 Json 으로 쓰는 것 또한 안되기 때문에 캐시 되어 있는 응답에 대하여 flush 처리를 해주어야 한다.

해당 객체의 Flush 코드는 다음과 같다.

public class ContentCachingResponseWrapper extends HttpServletResponseWrapper {
	@Override
	public void flushBuffer() throws IOException {
		// do not flush the underlying response as the content as not been copied to it yet
	}
}
profile
뿌리를 생각하는 나무 개발자

2개의 댓글

comment-user-thumbnail
2022년 8월 2일

이것은 아주 훌륭한 글이군요!

1개의 답글