실패록/오늘의 실패록

[실패록] Spring Controller 415 Error

무토(MUTO) 2021. 2. 1. 01:45

Controller에 HTTP문서로 요청할 때, body에 넣을 값이 없다면, 해당 content-type의 기본값을 넣어주자. 해당 body가 null이면 안된다.

@RestController
@RefreshScope
@RequestMapping(path = "/sns/post", consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
@RequiredArgsConstructor
@Slf4j
public class PostController {
    .
    .
    .

    /*
        즐겨찾기 게시판 불러오기
     */
    @GetMapping(path = "/favorite")
    public ResponseEntity<ResponseResource> getFavoritePosts(
            HttpServletRequest request,
            @RequestParam("lastId") int lastId,
            @RequestParam("limit") int limit
    ) {
        Long memberId = util.getTokenMemberId(request);

        Response response = Response.builder()
                .code(10)
                .message("SUCCESS")
                .body(postService.getFavoritePosts((long) lastId, limit, memberId))
                .build();

        ResponseResource resource = new ResponseResource(response, PostController.class);
        resource.add(linkTo(PostController.class).slash("favorite").withRel("get-favorite-posts"));

        return ResponseEntity.ok().body(resource);
    }
}

@RequestMapping(path = "/sns/post", consumes = MediaType.APPLICATION_JSON_VALUE)

  • 프론트에서 해당 API를 요청할 때 body에 빈값을 넣었는데 415 unsupported media type 상태코드가 반환된다고 확인 부탁해달라는 요청이 들어왔다.
  • 처음에는 나도 왜 문제가 생겼는지 전혀 알 수가 없었다. 테스트코드는 무사히 통과가 되었기 때문이다.
@Test @Order(11)
    @DisplayName("즐겨찾기 게시판 목록 읽기 성공")
    void getFavoritePosts() throws Exception {
        //given
        Long lastId = 0L;
        int limit = 10;

        //when
        MockHttpServletRequestBuilder builder = get("/sns/post/favorite",lastId)
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", "Bearer " + token)
                .accept(MediaTypes.HAL_JSON_VALUE)
                .param("lastId", String.valueOf(lastId))
                .param("limit", String.valueOf(limit));

        //then
        mockMvc.perform(builder)
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE + ";charset=UTF-8"))
                .andExpect(jsonPath("code").exists())
                .andExpect(jsonPath("message").exists())
                .andExpect(jsonPath("body",hasSize(7)))
                .andExpect(jsonPath("_links.self").exists())
                .andExpect(jsonPath("_links.get-favorite-posts").exists())
                .andDo(document("get-favorite-posts",
                        links(
                                linkWithRel("self").description("link to self"),
                                linkWithRel("get-favorite-posts").description("link to favorite posts")
                        ),
                        requestHeaders(
                                headerWithName(HttpHeaders.ACCEPT).description("accept header"),
                                headerWithName(HttpHeaders.CONTENT_TYPE).description("contentType header")
                        ),
                        responseHeaders(
                                headerWithName(HttpHeaders.CONTENT_TYPE).description("contentType header")
                        ),
                        responseFields(
                                fieldWithPath("code").description("label code number"),
                                fieldWithPath("message").description("message"),
                                fieldWithPath("body").description("body of the response"),
                                fieldWithPath("body[].id").description("id"),
                                fieldWithPath("body[].title").description("title"),
                                fieldWithPath("body[].content").description("content"),
                                fieldWithPath("body[].createdAt").description("createdAt"),
                                fieldWithPath("body[].updatedAt").description("updatedAt"),
                                fieldWithPath("body[].user").description("user"),
                                fieldWithPath("body[].user.id").description("user id"),
                                fieldWithPath("body[].user.nickname").description("user nickname"),
                                fieldWithPath("body[].user.image").description("user image"),
                                fieldWithPath("body[].images").description("images").optional(),
                                fieldWithPath("body[].images[].src").description("image path").optional(),
                                fieldWithPath("body[].likers").description("likers").optional(),
                                fieldWithPath("body[].likers[].id").description("id of likers").optional(),
                                fieldWithPath("body[].likers[].nickname").description("nickname of likers").optional(),
                                fieldWithPath("body[].likers[].image").description("profile image of likers").optional(),
                                fieldWithPath("body[].likers[].image.src").type(JsonFieldType.STRING).description("profile image path of likers").optional(),
                                fieldWithPath("body[].commentNum").description("commentNum"),
                                fieldWithPath("body[].retweet").type(JsonFieldType.OBJECT).description("post that sharing other post").optional(),
                                fieldWithPath("body[].retweet.id").description("id of retweet"),
                                fieldWithPath("body[].retweet.title").description("title of retweet"),
                                fieldWithPath("body[].retweet.content").description("content of retweet"),
                                fieldWithPath("body[].retweet.createdAt").description("created time of retweet"),
                                fieldWithPath("body[].retweet.updatedAt").description("updated time of retweet"),
                                fieldWithPath("body[].retweet.user").description("user who retweeted"),
                                fieldWithPath("body[].retweet.user.id").description("user id who retweeted"),
                                fieldWithPath("body[].retweet.user.nickname").description("user nickname who retweeted"),
                                fieldWithPath("body[].retweet.user.image").description("user profile image who retweeted"),
                                fieldWithPath("body[].retweet.images").description("images of retweet"),
                                fieldWithPath("body[].retweet.images[].src").description("images path of retweet"),
                                fieldWithPath("body[].retweet.likers").description("user who liked retweet"),
                                fieldWithPath("body[].retweet.commentNum").description("commentNum of retweet"),
                                fieldWithPath("body[].retweet.category").description("category of retweeted post"),
                                fieldWithPath("body[].retweet.hits").description("views of retweet"),
                                fieldWithPath("body[].retweet.blocked").description("blocking flag of retweet"),
                                fieldWithPath("body[].retweet.deleted").description("whether to delete a retweet"),
                                fieldWithPath("body[].category").description("category"),
                                fieldWithPath("body[].hits").description("views"),
                                fieldWithPath("body[].blocked").description("blocking flag of the post"),
                                fieldWithPath("_links.self.href").description("link to self"),
                                fieldWithPath("_links.get-favorite-posts.href").description("link to favorite posts")
                        )
                ));


    }
  • 무엇이 문제였을까?

  • 컨트롤러의 consumes = MediaType.APPLICATION_JSON_VALUE 부분을 살펴보자

  • 위의 방식대로 정의하면 body는 json타입으로만 읽기가 가능하다.

  • 그리고json의 기본값은 {} 이다.

  • 그래서 body에 {}를 집어넣고 테스트를 돌려보았다. 바로 성공했다.

  • 왜 나는 이 문제를 테스트코드로부터 발견할 수 없었을까?

  • MockMvc는 content-type을 json으로 설정하면 기본 값을 {}로 테스트하고 있었기 때문이다.

  • 그런데 혹시나 궁금해서 빈 문자열을 body 에 넣어보았는데 테스트가 성공했다.

//when
        MockHttpServletRequestBuilder builder = get("/sns/post/favorite",lastId)
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", "Bearer " + token)
                .accept(MediaTypes.HAL_JSON_VALUE)
                .content("")
                .param("lastId", String.valueOf(lastId))
                .param("limit", String.valueOf(limit));
  • 다음과 같은 조건으로 content에 빈 문자열을 넣어 test를 진행해도 테스트는 무사히 통과가 되었다. 아무래도 null이 아니기만 하면 굳이{}가 아니라도 무엇이든 되는것 같다.
MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /sns/post/favorite
       Parameters = {lastId=[0], limit=[10]}
          Headers = [Content-Type:"application/json;charset=UTF-8", Authorization:"Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJtZjAwMDEiLCJyb2xlIjoiUk9MRV9NQU5BR0VSIiwiaWQiOjEsImV4cCI6MTkxMjA2NDU4NiwiaWF0IjoxNTk2NzA0NTg2fQ.VmpRq6R0NhyteAp2ToaPPbjAANcSfZTMKvrXxCd3iFBcm3gVLn9GYd6lJQ07gRIyk_U38x4t7VEpzA2qcbMAgA", Accept:"application/hal+json"]
             Body = 
    Session Attrs = {}
  • 심지어 12345를 테스트에 넣어도 통과했다.

  • 그럼 딱히 json이 문제는 아니었다는 소리가 된다. 그러면 대체 무엇이 문제일까?

  • @RequestMapping 어노테이션을 이러저리 둘러보면서 근원을 찾는 도중 문제가 되는 부분을 발견했다.

  • AbstractMessageConverterMethodArgumentResolver.java를 살펴보자

    .
    .
    .

    /**
     * Create the method argument value of the expected parameter type by reading
     * from the given HttpInputMessage.
     * @param <T> the expected type of the argument value to be created
     * @param inputMessage the HTTP input message representing the current request
     * @param parameter the method parameter descriptor
     * @param targetType the target type, not necessarily the same as the method
     * parameter type, e.g. for {@code HttpEntity<String>}.
     * @return the created method argument value
     * @throws IOException if the reading from the request fails
     * @throws HttpMediaTypeNotSupportedException if no suitable message converter is found
     */
    @SuppressWarnings("unchecked")
    @Nullable
    protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
            Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {

        MediaType contentType;
        boolean noContentType = false;
        try {
            contentType = inputMessage.getHeaders().getContentType();
        }
        catch (InvalidMediaTypeException ex) {
            throw new HttpMediaTypeNotSupportedException(ex.getMessage());
        }
        if (contentType == null) {
            noContentType = true;
            contentType = MediaType.APPLICATION_OCTET_STREAM;
        }

        Class<?> contextClass = parameter.getContainingClass();
        Class<T> targetClass = (targetType instanceof Class ? (Class<T>) targetType : null);
        if (targetClass == null) {
            ResolvableType resolvableType = ResolvableType.forMethodParameter(parameter);
            targetClass = (Class<T>) resolvableType.resolve();
        }

        HttpMethod httpMethod = (inputMessage instanceof HttpRequest ? ((HttpRequest) inputMessage).getMethod() : null);
        Object body = NO_VALUE;

        EmptyBodyCheckingHttpInputMessage message;
        try {
            message = new EmptyBodyCheckingHttpInputMessage(inputMessage);

            for (HttpMessageConverter<?> converter : this.messageConverters) {
                Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
                GenericHttpMessageConverter<?> genericConverter =
                        (converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter<?>) converter : null);
                if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
                        (targetClass != null && converter.canRead(targetClass, contentType))) {
                    if (message.hasBody()) {
                        HttpInputMessage msgToUse =
                                getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
                        body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
                                ((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
                        body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
                    }
                    else {
                        body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType);
                    }
                    break;
                }
            }
        }
        catch (IOException ex) {
            throw new HttpMessageNotReadableException("I/O error while reading input message", ex, inputMessage);
        }

        if (body == NO_VALUE) {
            if (httpMethod == null || !SUPPORTED_METHODS.contains(httpMethod) ||
                    (noContentType && !message.hasBody())) {
                return null;
            }
            throw new HttpMediaTypeNotSupportedException(contentType, this.allSupportedMediaTypes);
        }

        MediaType selectedContentType = contentType;
        Object theBody = body;
        LogFormatUtils.traceDebug(logger, traceOn -> {
            String formatted = LogFormatUtils.formatValue(theBody, !traceOn);
            return "Read \"" + selectedContentType + "\" to [" + formatted + "]";
        });

        return body;
    }
    .
    .
    .
  • 코드를 읽어보면 바디가 NO_VALUE가 되어선 안된다. 그 후에 body가 return되어 컨트롤러의 @RequestBody 에 해당하는 부분에 매핑이 되는데 내 코드에는 body에 매핑 될 객체의 형태가 없었기 때문에 12345,""를 넣더라도 에러가 딱히 발생하지 않은것이고, 그 이전에 빈값을 넣었을 때는 415에러가 발생한 것이다.

  • 그렇다면 이러한 문제를 앞으로 해결하기 위해 어떻게 테스트코드를 변경해야할까?

  • mockMVC 테스트코드에 의도를 드러내는 수밖에 없는 것 같다. requestBuilder의 content에 {}를 사용하여 제이슨의 빈 타입이라는 의도를 드러내는 것 말고는 방도가 없어보인다.

결론

  • default 값으로 설정하지 말고 의도를 드러내어 코딩하자.

        MockHttpServletRequestBuilder builder = get("/sns/post/favorite",lastId)
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", "Bearer " + token)
                .accept(MediaTypes.HAL_JSON_VALUE)
                .content("{}") //비어있는 것을 표시하자
                .param("lastId", String.valueOf(lastId))
                .param("limit", String.valueOf(limit));

        //then
        mockMvc.perform(builder)
        .
        .
        .

PS. 테스트 코드가 조금 지저분한것같다. 중복을 제거하는 방향으로 테스트 코드를 정리할 필요가 있겠다.