끊임없이 검증하라

나에게 당연할지라도

Spring

Spring_짧1_ControllerTest와 @ModelAttribute, @RequestBody

fadet 2022. 7. 18. 23:32

※ 이 포스트는 스프링 실습 과정에서 작성하기 때문에 정보가 부정확할 수 있는 부분이 있습니다.
따라서 참고만 해주시고 틀린 부분이 있을 경우 알려주시면 감사하겠습니다.

 

이번 포스트는 김영한님의 '스프링 MVC 1편 - 백엔드 웹개발 핵심 기술' 강의를 일부 인용하였습니다.
스프링에 대해 더 자세히 공부하고 싶으신 분은 인프런에서 해당 강의를 수강하시길 추천합니다.

이번 포스트 제 깃허브의 https://github.com/kth1017/project_newPostLink 레포를  바탕으로 작성했습니다.

 

해당 레포의 ControllerTest를 작성하다 특정 메소드에서 NPE가 발생하여 그것을 해결하기 위해 이것저것 살펴봤습니다. 아래 나열한 코드에 대한 자세한 설명은 생략토록 하겠습니다. 필요하시다면 댓글로 물어봐주세요.

 

 

원인 탐색

 

우선 처음으로 ControllerTest에 해당 메소드를 작성하였습니다.

@AutoConfigureMockMvc
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CodeControllerTest {

    @Autowired
    CodeController codeController;
    @Autowired
    CodeRepository codeRepository;

    @LocalServerPort
    int port;

    @Autowired
    MockMvc mvc;
    @Test
    public void input_post정상() throws Exception{

        //given
        InputForm form = new InputForm(TestData.testAllCode, TestData.testTitleHtmlKeyword, TestData.testIndexHtmlKeyword);

        String url = "http://localhost:" + port + "/";

        //when
        mvc.perform(post(url)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new ObjectMapper().writeValueAsString(form)))
                .andExpect(status().is3xxRedirection());

        //then
        Code savedOne = codeRepository.findLastOne();
        assertThat(savedOne.getAllCode()).isEqualTo(TestData.testAllCode);

    }
}

다음으로 해당 ControllerTest를 통과하기 위해 Controller를 작성했습니다.

//CodeController
@PostMapping("/")
public String postCode(@ModelAttribute InputForm form){
    Code code = new Code(form.getAllCode(), form.getTitleHtmlKeyword(), form.getIndexHtmlKeyword());

    Code savedCode = codeService.saveCode(code);

    return "redirect:/valid/"+savedCode.getId();
}

하지만 해당 내용을 작성한 뒤 테스트를 돌려보면 다음과 같은 예외를 만나게 됩니다.

이 예외를 해결하기 위해 스택트레이스(로그)를 조금 내려보면 

이 로그를 읽어보면 대략 도메인인 Code 객체가 생성되는 과정에서 포함된 초기화 메서드에 오류가 발생했음을 알 수 있습니다. 

 

문제 해결

 

이제 원인을 파악했으니 금방 해결이 될줄 알았습니다. 제가 일단 생각한 방법은

1. ControllerTest의 의존성을 잘못 주입하는 등 테스트 환경이 잘못되었다,
2. 해당 테스트 메서드의 코드를 잘못 작성하였다.
3. Controller를 잘못 작성하였다.

이렇게 크게 세가지였습니다. 하지만 1번에 기대어 이것저것 구글링해보아 Mock을 사용하지 않거나 WebMvcTest로 환경을 바꿔봤지만 예외는 항상 똑같았기에 환경 문제가 아님을 알 수 있었습니다.

 

다음으로 2번에 기대어 form을 잘못 작성하였거나 perform 이후 구문을 잘못 작성하였는지 살펴보았지만 특별히 이상은 보이지 않았습니다. 위 두가지가 제일 유력했는데 안되서 꽤나 당황한게 사실입니다.

 

하지만 의외로 매우 간단한 부분에서 잘못된 점을 발견하였습니다. 그것은 3번 항목이었는데 결과적으로 말씀드리면 @ModelAttribute를 @RequestBody로 변경하여 해결했습니다.

 

 

부가 설명

 

사실 저 같은 경우엔 @ModelAttribute와 @RequestBody에 대한 학습을 진행했었기때문에 바로 잘못된게 무엇인지 파악하고 넘어갈 수 있었지만 의외로 이런 상황이 자주 나올 것 같아 포스트로 기록하려 생각했습니다.

 

우선 두 어노테이션을 간단히 설명하려 합니다. 더 자세한 설명은 추후 포스트를 작성할 계획이니 더 알고 싶으신 분은 따로 찾아보셔도 좋을 것 같습니다. 우선 @ModelAttribute와 @RequestBody 이 두 어노테이션은 @RequestParam 과 같이 컨트롤러에서 가장 흔하게 사용되는 파라미터 어노테이션입니다. 

 

짧게 설명해야하니 코드로 보죠. 강의 일부를 인용하겠습니다. @ModelAttribute부터 살펴보자면

@RequestParam String username;
@RequestParam int age;
HelloData data = new HelloData();
data.setUsername(username);
data.setAge(age);

이 과정을 스프링에서 축약하여 자동화해주는 어노테이션이 @ModelAttribute입니다. 파라미터를 view로부터 전달받아 자바 객체로 변환해주는 이 과정은 매우 빈번하게 사용되기에 해당 어노테이션이 존재합니다.

 

다음으로 @RequestBody는 

public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) {
 String messageBody = httpEntity.getBody();
 log.info("messageBody={}", messageBody);
 return new HttpEntity<>("ok");

마찬가지로 이 과정을 스프링에서 축약하여 자동화해주는 어노테이션이 @RequestBody입니다. 위 코드처럼 @RequestBody는 HTTP 메시지 바디 자체를 컨트롤러에서 쉽게 불러올 수 있게 해주는 기능이란 것입니다.

 

사실 @RequestBody를 설명하려면 HTTP 메시지 컨버터를 알아야하는데 이것까지 자세히 알기엔 글의 흐름에서 지나치게 멀어지므로 간단히 설명하겠습니다. 컨트롤러에서 @RequestBody 어노테이션이 붙은 파라미터는 사용자가 뷰에 입력한 정보를 얻기 위해 호출하는 뷰리졸버가 아닌 HTTP 메시지 컨버터가 호출됩니다. 뒤에서 다시 짧게 언급하겠습니다.

 

HTTP 메시지 컨버터는 @RequestBody 뿐만 아니라 HttpEntity 파라미터 역시 사용할 수 있으며 해당 메시지를 읽을 수 있는지 판단하고 타입을 분류하여 객체로 변환하는 과정을 거칩니다. 


 

위 내용을 종합해보면 엄청나게 중요한 점을 알 수 있습니다. 바로 @ModelAttribute와 @RequestBody는 아에 다른 기능이라는 겁니다. 어노테이션을 붙이는 위치가 똑같다보니 사람들이 매우 많이 헷갈리지만 @ModelAttribute는 사용자가 보낸 파라미터 정보를 객체화 해주는 어노테이션이고 @RequestBody는 HTTP 메시지 바디를 조회하는 어노테이션입니다.

 

쉽게 말해 @ModelAttribute는 https://localhost:8080/username="k"&age=1이라는 URL로 요청이 온다면 username과 age를 객체의 필드로 set해주는 어노테이션이고 @RequestBody는 요청이 온 HTTP 메시지 바디를 직접 읽고 그 자체를 자바 객체화시키는 어노테이션입니다.

 

따라서 우리가 살펴본 레포에 이를 적용하여 이해해보면 CodeControllerTest에서 메소드 요청이 올 때 애초에 파라미터 정보를 넘겨주지 않았으니 @ModelAttribute를 사용할 수 없고 넘겨준 정보는 form 그 자체를 json으로 넘겨준 것이기 때문에 사용해야할 어노테이션은 @RequestBody이며 해당 어노테이션으로 컨트롤러가 넘겨준 form을 읽어와 form안에 담긴 필드를 자바 객체에 담는 것입니다. 

 

넘어가기 전에 앞서 소개한 HTTP 메시지 컨버터는 스프링 MVC의 핸들러 어댑터를 처리하는 과정에서 작동합니다. HTTP 메시지 컨버터의 경우 검색해보면 더 자세한 내용들이 많으니 직접 찾아보시길 추천드립니다.(핸들러 어댑터를 잘 모르신다면 https://fadet-coding.tistory.com/38 를 읽어보셔도 좋습니다.) 

 

기서 강의 내용을 일부 인용하면 스프링은 정말 다양한 어노테이션들을 사용할 수 있으며 우리가 앞서 다루었던  @ModelAttribute, @RequestBody처럼 기능이 다른데 같은 위치에 붙는 다양한 어노테이션들을 유연하게 사용할 수 있는 비밀이 여기 있습니다. 핸들러 어댑터가 30개가 넘는 다양한 ArgumentResolver를 호출하여 어노테이션마다 다른 파라미터들을 분류하기 때문입니다. 이런 편리함때문에 오히려 우리는  @ModelAttribute vs @RequestBody같은 오해를 갖기도 하니 조금 웃픈 상황이긴 합니다.

 

조금 더 얹어보자면 @ModelAttribute는 생략 가능하지만(@RequestParam도 마찬가지) @RequestBody는 생략하면 안됩니다. 이 문장을 언급한 이유는 실수로 만약 컨트롤러에서 

@PostMapping("/")
public String postCode([@RequestBody 실수로 생략] InputForm form){

이런 메서드가 발생한다면 해당 메서드는 스프링에서 

@PostMapping("/")
public String postCode(@ModelAttribute InputForm form){

해당 코드로 받아들여져서 우리가 겪은 오류랑 똑같은 상황을 겪게되니 어노테이션 생략을 너무 남발하다 디버깅이 어려워지는 걸 생각해봐야합니다.


이번 포스트를 한 줄로 요약하면 @ModelAttribute와 @RequestBody는 주로 쓰는 위치만 같다뿐이지 아에 다른 어노테이션이니 단편적으로 어떨때는 뭘 쓰고 이런식으로 외우기보단 써야할 때를 꽤 고민해야한다 정도겠네요. 원래부터 해당 내용은 숙지하고 있어서 포스트 자체는 매우 짧은 시간에 작성했지만 문제는 ControllerTest 환경을 아직 덜 숙지해서 디버깅하는데 시간이 오래 걸렸다는 것 정도겠네요. 다들 Test에 대한 학습을 생활화합시다!