Projects/[Final] Shopping Mall Project

Request Body 재사용 불가로 Internal API 테스트 400 오류

montmer27 2026. 5. 5. 17:19

[Github Repo]

https://github.com/all-in-market/alarm-server/pull/1/changes/22900932660660f6991daa696985223e00c1efc2

 

Feat/restock notification by ginsengcandy · Pull Request #1 · all-in-market/alarm-server

개요 재입고 알림 구현 작업 내용 고객 서버에서 재입고 이벤트 발생 시 알림 서버 REST 호출(엔드포인트: '{알림서버 주소}/internal/notifications/restock') 로컬에서는 localhost:8083 알림 서버는 호출 시

github.com

 

[빠른 요약]

InternalAuthFilter에서 요청 body 검증을 위해 ContentCachingRequestWrapper로 body를 먼저 읽은 뒤 컨트롤러에 전달했지만, 스트림이 이미 소진되어 @RequestBody 파싱이 실패했다. body를 byte[]로 직접 캐싱하고 재읽기 가능한 wrapper로 교체해 해결했다.

[상황]내부 API 요청에 대해 HMAC 서명 검증을 수행하기 위해 InternalAuthFilter를 구현했다.
요청 헤더(X-Client-Id, X-Timestamp, X-Request-Id, X-Signature)를 검증하고, request body를 포함한 payload를 기반으로 signature를 계산하는 구조였다.

signature 검증 payload는 다음과 같이 구성했다.

timestamp + requestId + requestBody

따라서 필터 단계에서 request body를 반드시 읽어야 했다.

처음 구현에서는 Spring이 제공하는 ContentCachingRequestWrapper를 사용해 body를 읽고, 이후 같은 wrapper를 컨트롤러로 전달했다.

ContentCachingRequestWrapper wrappedRequest =
        new ContentCachingRequestWrapper(request, 4096);

String body = StreamUtils.copyToString(
        wrappedRequest.getInputStream(),
        StandardCharsets.UTF_8
);

filterChain.doFilter(wrappedRequest, response);

겉보기에는 body를 캐싱하므로 이후 컨트롤러도 정상적으로 body를 읽을 수 있을 것이라 생각했다.

하지만 통합 테스트에서 일부 internal API 요청이 예상과 다르게 실패했다.

[문제]

다음과 같은 internal API 테스트에서 401이 아니라 400이 반환되었다.

  • 잘못된 서명 요청 테스트
  • 만료된 timestamp 요청 테스트
  • 정상 body를 포함한 내부 API 호출 테스트 일부 실패

실제 원인은 테스트 코드가 아니라 InternalAuthFilter 프로덕션 코드에 있었다.

ContentCachingRequestWrapper.getInputStream()은 매번 새로운 stream을 반환하지 않고, 동일한 ContentCachingInputStream 인스턴스를 반환한다.

즉, 필터에서 다음 코드가 실행되면:

String body = StreamUtils.copyToString(
        wrappedRequest.getInputStream(),
        StandardCharsets.UTF_8
);

request body stream이 끝까지 읽혀 EOF 상태가 된다.

이후 컨트롤러에서 @RequestBody가 다시 body를 읽으려고 하면 이미 소진된 stream에 접근하게 되고, 결과적으로 빈 body만 읽게 된다.

Jackson은 빈 body를 역직렬화할 수 없으므로 다음 예외가 발생한다.

HttpMessageNotReadableException

최종적으로 응답은:

400 Bad Request

가 된다.

핵심은 ContentCachingRequestWrapper의 용도를 잘못 이해한 것이었다.

이 wrapper는:

  • 컨트롤러가 body를 읽은 이후
  • getContentAsByteArray()로 body를 조회하는 사후 검사 용도

에 가깝다.

즉,

필터가 먼저 body를 읽고
→ 컨트롤러가 다시 읽는 패턴

에는 적합하지 않다.

해결 방법은 ContentCachingRequestWrapper를 제거하고, body를 직접 byte[]로 읽어 캐싱한 뒤, getInputStream() 호출 시마다 새로운 stream을 반환하는 wrapper를 만드는 것이었다.

수정 후:

byte[] bodyBytes = StreamUtils.copyToByteArray(request.getInputStream());

로 body를 먼저 저장하고,

HttpServletRequestWrapper reReadableRequest = new HttpServletRequestWrapper(request) {
    @Override
    public ServletInputStream getInputStream() {
        return new ServletInputStream() {
            private final ByteArrayInputStream bais =
                    new ByteArrayInputStream(bodyBytes);

            @Override
            public int read() {
                return bais.read();
            }
        };
    }
};

처럼 요청을 재구성했다.

마지막으로:

filterChain.doFilter(reReadableRequest, response);

로 전달해 컨트롤러가 body를 정상적으로 다시 읽을 수 있게 했다.

결과적으로:

  • 필터는 body 기반 signature 검증 가능
  • 컨트롤러는 @RequestBody 정상 파싱 가능
  • internal API 테스트 정상 통과

정리하면 이번 이슈의 핵심은:

Servlet request body는 기본적으로 one-shot stream이며, 필터에서 먼저 읽으면 재사용 불가하다. body 재사용이 필요하면 직접 캐싱 + 재생성 wrapper가 필요하다.