Spring MVC 기반의 웹 애플리케이션을 개발하다보면 Interceptor나 AOP Advice 등에서 request body를 사용해야하는 경우가 종종 있을 수 있습니다.

reqeust 전송 시, header에 추가되 전송되는 JWT의 claims에 포함된 memberId와 request body에 포함된 memberId가 동일한지를 검증하는 예를 들어보겠습니다.

@Slf4j
@Component
@Aspect
public class MemberVerifyAdvice extends VerifyAdvice {
    private final ObjectMapper objectMapper;
    public MemberVerifyAdvice(JwtTokenizer jwtTokenizer, ObjectMapper objectMapper) {
        super(jwtTokenizer);
        this.objectMapper = objectMapper;
    }

    @Pointcut(
            "execution(* com.itvillage.member.controller.MemberController.postQnaQuestionOfMember(..)) || " +
                    "execution(* com.itvillage.qna.controller.QnaQuestionController.postAnswerOfQuestion(..))"
    )
    public void verifyMySelfWithPointcut(){}

    @Before(value = "verifyMySelfWithPointcut()")
    public void verifyMySelf(JoinPoint joinPoint) throws IOException {
        String jws = getHeader("Authorization").substring(7);
        long memberIdFromJws = getMemberIdFromJws(jws);

        long memberIdFromRequest = extractMemberId();

        checkSameMember(memberIdFromJws, memberIdFromRequest);

    }

    private long extractMemberId() throws IOException {
        // (1)
        HttpServletRequest request = 
                  ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();
        
        
        String body = IOUtils.toString(request.getReader());  // (2-1)
        or
        String body = 
        	StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); // (2-2)

        ...
        ...
        
    }
}

코드 1-1 MemberVerifyAdvice

먼저 코드 1-1에서 처럼 request body를 파싱하기 위해 AOP Advice에서 (1)과 같이 RequestContextHolder를 이용해 HttpServletRequest를 얻습니다.

그리고 (2-1)과 같이 apache commons io 라이브러리를 이용하거나 (2-2)와 같이 Spring에서 제공하는 StreamUtils를 이용해 request body를 얻기 위한 시도를 할 수 있습니다.

그런데 (2-1) 또는 (2-2)와 같은 코드가 실행되면 request body가 비어있거나 getInputStream() has already been called for this request 같은 에러가 발생해 정상적으로 request body를 얻을 수 없습니다.

⭐ 이유는 Spring Boot에 내장된 Tomcat의 경우 HttpServletRequest의 InputStream을 한번 read 하게되면 다시 읽을 수 없도록 구현되어 있기 때문입니다.

즉, 이 말은 AOP Advice에서 얻은 HttpServletRequest는 Advice에 도달하기 전에 이미 어딘가에서 InputStream을 read 했다는 의미와도 같습니다.

Advice에서 request body를 정상적으로 사용하려면 어떻게 해야할까요?

이런 경우에는 InputStream을 애플리케이션 어딘가에서 read 하기 전에 ContentCachingRequestWrapper를 이용해 원본 HttpServletRequest를 캐싱해 두면 됩니다.

즉, 캐싱해서 저장해 두었다가 나중에 필요할 때 꺼내서 사용할 수 있도록 하는 것입니다.

ContentCachingRequestWrapper로 원본 HttpServletRequest를 캐싱하기 좋은 지점은 바로 필터입니다.

필터를 하나 추가해서 해당 필터에서 ContentCachingRequestWrapper로 원본 HttpServletRequest를 캐싱하면 됩니다.

 

ContentCachingRequestWrapper로 원본 HttpServletRequest를 캐싱하기

Filter 생성

import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class HttpServletWrappingFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // ContentCachingRequestWrapper로 HttpServletRequest 캐싱
        ContentCachingRequestWrapper wrappingRequest = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper wrappingResponse = new ContentCachingResponseWrapper(response);
        filterChain.doFilter(wrappingRequest, wrappingResponse);
        wrappingResponse.copyBodyToResponse();
    }
}

코드 1-2 HttpServletWrappingFilter

코드 1-2와 같이 ContentCachingRequestWrapper를 이용해 HttpServletRequest와 HttpServletResponse를 캐싱하는 필터를 생성합니다.

이렇게하면 나중에 원본 HttpServletRequest를 사용하고자 하는 곳에서 ContentCachingResponseWrapper를 이용해 request body를 사용하면 됩니다.

 

Filter 추가

@Configuration
public class WebConfiguration {
    @Bean
    public FilterRegistrationBean<HttpServletWrappingFilter> firstFilterRegister()  {
    	// HttpServletWrappingFilter 추가
        FilterRegistrationBean<HttpServletWrappingFilter> registrationBean =
                new FilterRegistrationBean<>(new HttpServletWrappingFilter());
        registrationBean.setOrder(Integer.MIN_VALUE);

        return registrationBean;
    }
}

코드 1-3 FilterRegistrationBean

코드 1-3과 같이 FilterRegistrationBean을 이용해 앞에서 생성한 HttpServletWrappingFilterFilterChain에 추가합니다.

 

ContentCachingRequestWrapper로 원본 HttpServletRequest 사용

@Slf4j
@Component
@Aspect
public class MemberVerifyAdvice extends VerifyAdvice {
    private final ObjectMapper objectMapper;
    public MemberVerifyAdvice(JwtTokenizer jwtTokenizer, ObjectMapper objectMapper) {
        super(jwtTokenizer);
        this.objectMapper = objectMapper;
    }

    @Pointcut(
            "execution(* com.itvillage.member.controller.MemberController.postQnaQuestionOfMember(..)) || " +
                    "execution(* com.itvillage.qna.controller.QnaQuestionController.postAnswerOfQuestion(..))"
    )
    public void verifyMySelfWithPointcut(){}

    @Before(value = "verifyMySelfWithPointcut()")
    public void verifyMySelf(JoinPoint joinPoint) throws IOException {
        String jws = getHeader("Authorization").substring(7);
        long memberIdFromJws = getMemberIdFromJws(jws);

        long memberIdFromRequest = extractMemberId();

        checkSameMember(memberIdFromJws, memberIdFromRequest);

    }

    private long extractMemberId() throws IOException {
        HttpServletRequest request = 
                  ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();
        
		// (1)
		ContentCachingRequestWrapper requestWrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
        byte[] body = requestWrapper.getContentAsByteArray();  // (2)
        
        ...
        ...
        
    }
}

코드  1-3 개선된 MemberVerifyAdvice

코드 1-3과 같이 (1)에서 WebUtils를 이용해 ContentCachingRequestWrapper 얻은 후에  (2)에서 ContentCachingRequestWrapper의 getContentAsByteArray()로 request body를 얻고 있습니다.

 

이 글에서 처럼 request body를 read하려는데 정상적으로 read하지 못하는 경우가 있다면 이 글이 도움이 되길 바래봅니다.

+ Recent posts

출처: http://large.tistory.com/23 [Large]