[Spring Boot] - Spring Interceptor 에서 Response 조작이 가능한가?

참고

📝 후처리에서 메뉴 데이터를 Response 객체에 데이터를 넣어주세요

개발 요구사항 중 페이지 접근시 전처리에서 접근 권한을 확인하고 후처리로 Response 객체에 사용자가 접근할 수 있는 메뉴를 같이 내려달라는 요구를 받았습니다. 해당 요구사항을 Spring 에서 제공하는 Interceptor 로 가능한지 검토해 달라는 요청을 받았습니다.

🤔 Interceptor 에서 데이터 변경이 가능할까?

스프링에서 제공하는 Interceptor 는 아래 코드와 같습니다. 요청을 처리하는 Controller 전에 preHandler 를 거치기 때문에 사용자 접근을 제어해달라는 요구사항은 어렵지 않았습니다.

마찬가지로 요청이 끝난 후에는 postHandler 를 거쳐 나가는데 그때 응답 객체를 수정할 수 있지 않을까? 마침, HttpServletRequest 객체를 매게변수로 받아서 조작이 가능할 것 같은 그런 기분이 들지만, 결론은 안됩니다. (두둥탁 🥁)

HandlerInterceptor
public interface HandlerInterceptor {

default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
return true;
}

default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable ModelAndView modelAndView) throws Exception {
}

default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable Exception ex) throws Exception {
}
}

🧐 그럼 왜 인터셉터 에서 응답 객체 조작이 불가능한 것인가?

응답 객체가 Commit 된 후에는 응답 객체 수정이 불가능합니다.

응답 객체를 수정할 수 없는 이유를 이해하기 위해서는 먼저, 응답 객체에 값이 어떻게 쓰여지는지를 이해할 필요가 있습니다.

@ResponseBody가 붙은 컨트롤러는 HttpMessageConverter 를 사용하는데, 이 컨버터는 응답 객체의 출력 스트림 을 이용해 데이터를 모두 쓴 후, flush 메서드를 호출합니다. 이때 응답 객체는 commit 상태가 되고 클라이언트로 전송합니다.

응답 객체가 commit 되면 응답 객체에서 제공하는 출력스트림을 close 시켜 해당 스트림을 사용할 수 없게 합니다. 그래서 응답 객체가 commit 된 이후에는 응답 내용을 수정하는 것은 불가능합니다. 결과적으로 commit 된 응답객체를 받는 인터셉터는 데이터 수정이 불가능합니다.

🔎 Commit 시점 확인하기 - AbstractGenericHttpMessageConverter

MappingJackson2HttpMessageConverter 가 상속 하는 AbstractGenericHttpMessageConverter 클래스를 확인해보면 해당 클래스 내 write 메소드에서 응답 객체에 데이터를 쓴 후 flush 메소드를 호출합니다.

AbstractGenericHttpMessageConverter
@Override
public final void write(final T t, @Nullable final Type type, @Nullable MediaType contentType,
HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {

final HttpHeaders headers = outputMessage.getHeaders();
addDefaultHeaders(headers, t, contentType);

if (outputMessage instanceof StreamingHttpOutputMessage streamingOutputMessage) {
streamingOutputMessage.setBody(new StreamingHttpOutputMessage.Body() {
@Override
public void writeTo(OutputStream outputStream) throws IOException {
writeInternal(t, type, new HttpOutputMessage() {
@Override
public OutputStream getBody() {
return outputStream;
}

@Override
public HttpHeaders getHeaders() {
return headers;
}
});
}

@Override
public boolean repeatable() {
return supportsRepeatableWrites(t);
}
});
}
else {
writeInternal(t, type, outputMessage);
outputMessage.getBody().flush();
}
}

🔎 인터셉터에서 응답 객체 상태 확인하기

Controller 에 응답값을 return 할 경우 응답 객체는 Commit 됩니다.

응답객체에 값을 넣지 않는 Controller 와 값을 넣는 Controller 를 만들어 응답값 유무에 따른 응답 객체 상태가 어떻게 변화 되는지 확인해봅니다.

@GetMapping("/empty")
public ResponseEntity empty(){
return new ResponseEntity(null, HttpStatus.OK);
}

@GetMapping("/hello")
public ResponseEntity hello(){
return new ResponseEntity("Hello World", HttpStatus.OK);
}

응답 객체의 Commit 상태에 따른 응답 객체의 값 변경 확인을 위한 인터셉터를 만들어줍니다.

public class ModifyResponseBodyInterceptor implements HandlerInterceptor {
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
System.out.println("Request URI = " + request.getRequestURI());
System.out.println("Response is committed = " + response.isCommitted());

response.getOutputStream().write("값이 바뀔까요?".getBytes(StandardCharsets.UTF_8));
}
}

아래 결과를 보면 Commit 상태가 true 일 경우는 값이 변경되지 않고 false 일 경우는 값 변경이 가능하다는 것을 확인할 수 있었습니다.

응답 객체에 값을 쓰지 않을 경우는 HttpMessageConverter 를 거치지 않으므로 Response 객체의 Commit 상태가 변경되지 않습니다.

=============== Server ===============
Request URI = /hello
Response is committed = true

=============== Client ===============
"/hello" 응답: "Hello World"


=============== Server ===============
Request URI = /empty
Response is committed = false

=============== Client ===============
"/empty" 응답: "값이 바뀔까요?" # 원래는 응답 값이 없어야 합니다.

❗️ 응답 객체에 값을 가져오는 것도 안된다

관련 내용을 정리하다가 알게 된 것은 응답 객체에 값을 넣은 후 Body 에 넣은 값을 가져와 인터셉터에서 출력로그로 찍으려고 했는데, HttpServletResponse 는 출력 스트림만 제공하기 때문에 값을 꺼내올 방법이 없습니다.

응답 객체 값을 별도로 저장하는 변수를 만들어서 응답 객체에 값을 쓸때 해당 변수에도 값을 같이 써준 후 값을 읽어와야 합니다.

서블릿에서는 기존 응답 객체를 감싸는 HttpServletResponseWrapper 클래스를 제공합니다. 해당 클래스는 HttpServletResponse 를 구현한 클래스라 기존 응답 객체와 동일한 함수를 사용할 수 있고 기존 응답 객체 기능에 원하는 부가 기능을 추가해 사용할 수 있습니다.

HttpServletResponseWrapper - 응답 래핑 클래스

HttpServletResponseWrapper 클래스를 이용해 래핑 클래스를 만들어 줍니다. 해당 클래스에서는 응답 객체에 값을 쓸때 버퍼에도 값을 써주는 기능을 추가하고, 버퍼에 저장된 값을 읽어오는 새로운 함수를 추가해줍니다.

public class BufferedResponseWrapper extends HttpServletResponseWrapper {
private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
private final ServletOutputStream out = new ServletOutputStream() {
@Override
public void write(int b) {
buffer.write(b);
}
};

private final PrintWriter writer = new PrintWriter(new OutputStreamWriter(buffer));

public BufferedResponseWrapper(HttpServletResponse response) {
super(response);
}

@Override
public ServletOutputStream getOutputStream() {
return out;
}

@Override
public PrintWriter getWriter() {
return writer;
}

public byte[] getResponseData() {
writer.flush(); // writer를 flush 해야 실제 내용이 buffer에 반영됨
return buffer.toByteArray();
}
}

HttpServletResponseWrapper - 응답 래핑 클래스 사용

HttpServletResponseWrapper 객체를 사용하기 위해서 filter 에서 응답 객체를 래핑 객체로 대체해 넘겨줍니다. 이후 요청이 처리되고 응답 객체를 사용할때는 래핑 객체를 사용하게 됩니다.

인터셉터의 preHandler 메소드에 HttpServletResponseWrapper 객체를 넘겨줄 수도 있지만, 제일 앞단에서 요청을 받는 filter 에서 세팅해 넘겨주는 방식이 제일 선호하는 것 같습니다.

@Component
public class ResponseLoggingFilter implements Filter {

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {

HttpServletResponse httpServletResponse = (HttpServletResponse) response;
LoggingHttpServletResponseWrapper responseWrapper = new LoggingHttpServletResponseWrapper(httpServletResponse);

chain.doFilter(request, responseWrapper);

// 복사된 응답 내용 콘솔 출력
String responseBody = new String(responseWrapper.getCopy(), StandardCharsets.UTF_8);
System.out.println("Response Body = " + responseBody);
}
}
Share