본문 바로가기
Java, Kotlin, Spring/Spring Web MVC

Spring Web MVC - @ResponseBody, ResponseEntity

by Wordbe 2021. 2. 21.
728x90

Spring Web MVC - 핸들러 메소드 4

 

 

@RequestBody

@RequestBody 는 핸들러의 아규먼트로 받아 올 수 있다. 요청 본문(body)에 들어있는 데이터를 HttpMessageConverter를 통해 변환한 객체로 받아올 수 있다.

HttpMessageConverter : 스프링 MVC 설정 (WebMvcConfigurer) 에서 설정할 수 있다. 이 때 configureMessageConverters 를 오버라이딩하면 기본 메시지 컨버터를 대체하게 되어, 기본설정이 바뀔 수 있으므로 조심한다. 대신 extendMessageConverters 를 이용하면 원하는 메시지 컨버터를 추가할 수 있다. (객체를 XML으로 바꾼다든지)

기본 컨버터는 WebMvcConfigurationSupport.addDefaultHttpMessageConverters 이다.

 

@RestController
@RequestMapping("/api/refnews")
public class RefNewsApi {

    @PostMapping
    public News createNews(@RequestBody News news) {
        // save news 로직이 들어갈 자리
        return news;
    }
}

News 객체를 받아서 다시 그대로 화면에 News 를 반환하는 컨트롤러이다. 단순한 컨트롤러.

테스트코드를 만들어보자.

@SpringBootTest
@AutoConfigureMockMvc
class RefNewsApiTest {

    @Autowired
    ObjectMapper objectMapper;

    @Autowired
    MockMvc mockMvc;

    @Test
    public void createNews() throws Exception {
          // Given
        News news = new News();
        news.setTitle("Oil");
        news.setLimit(30);

        String json = objectMapper.writeValueAsString(news);

        // When
        mockMvc.perform(post("/api/refnews")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(json))
        // Then
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("title").value("Oil"))
                .andExpect(jsonPath("limit").value(30))
                ;
    }
}
  • ObjectMapper 를 이용하면 객체를 json 으로 쉽게 바꿀 수 있다. (스프링부트가 자동설정해준다. shift 두번 누르고 JacksonAutoConfiguration 을 보면 jacksonObjectMapper 를 볼 수 있다.)
  • 테스트코드에서 contentType(MediaType.APPLICATION_JSON) 를 적어주어서 콘텐츠타입이 json 임을 명시해주었다.

 

HttpEntity

@RequestBody 와 비슷하지만 추가로 본문정보와 더불어 헤더 정보를 사용할 수 있다.

@RestController
@RequestMapping("/api/refnews")
public class RefNewsApi {

    @PostMapping
    public News createNews(HttpEntity<News> request) {
        // save news 로직이 들어갈 자리

          // 헤더 정보 이용가능
        MediaType contentType = request.getHeaders().getContentType();
        System.out.println(contentType);

        return request.getBody();
    }
}

@RequestBody, HttpEntity 둘 다 @Valid또는 @Validated 를 사용해서 값을 검증할 수 있다. 또한 BindingResult 아규먼트를 사용해서 바인딩 에러를 없애(400 응답 에러 대신 200 정상 응답 코드가 나온다.)는 대신, 코드 단에서 바인딩 또는 검증 에러 로직을 처리할 수 있다.

 


@ResponseBody

@ResponseBody 는 컨트롤러가 데이터를 반환할 때 HttpMessageConverter 를 사용해서 응답 본문 메시지를 만들어 반환할 수 있게 도와준다.

@RestController 를 사용하면 자동으로 모든 핸들러 메소드에 @ResponseBody 가 적용된다.

ResponseEntity

  • 응답 헤더의 상태 코드 본문을 직접 다루고 싶다면 사용하면 된다. ResponseEntity.ok(), ResponseEntity.badRequest().build(), ResponseEntity.created() 등을 사용할 수 있다.
@RestController
@RequestMapping("/api/refnews")
public class RefNewsApi {

    @PostMapping
    public ResponseEntity<News> createNews(@Valid @RequestBody News news,
                                           BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return ResponseEntity.badRequest().build();
        }

        return ResponseEntity.ok(news);
    }
}

ResponseEntity 는 응답 헤더, 본문 양식을 맞추어 반환하므로 @ResponseBody 가 필요없다. 따라서 @RestController 대신 @Controller 를 등록해도 잘 동작한다.

테스트코드

@SpringBootTest
@AutoConfigureMockMvc
class RefNewsApiTest {

    @Autowired
    ObjectMapper objectMapper;

    @Autowired
    MockMvc mockMvc;

    @Test
    public void createNews() throws Exception {
        News news = new News();
        news.setTitle("Oil");
        news.setLimit(-30);

        String json = objectMapper.writeValueAsString(news);

        mockMvc.perform(post("/api/refnews")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(json))
                .andDo(print())
                .andExpect(status().isBadRequest())
//                .andExpect(status().isOk())
//                .andExpect(jsonPath("title").value("Oil"))
//                .andExpect(jsonPath("limit").value(30))
                ;
    }
}

 


모델 @ModelAttribute

@ModelAttribute 는 핸들러 메소드의 아규먼트로 사용해서 모델 속성을 가져오고 변경하여 사용할 수 있었다.

여기에 또 다른 기능이 있다. @Controller, @ControllerAdvice 를 사용한 클래스에서 모델 정보를 초기화할 때 사용할 수 있다.

@RestController
@RequestMapping("/api/refnews")
public class NewsApi {

  // "categrories" 라는 애트리뷰트를 추가할 수 있다.
  @ModelAttribute
  public void categories(Model model) {
    model.addAttribute("categories", List.of("Bloomberg", "Thomson Reuters", "Yahoo finance"));
  }
}

또한 @RequestMapping 과 메소드에서 같이 사용하면 해당 메소드에서 리턴하는 객체를 모델에 모두 넣어줄 수 있다. 이 때 @ModelAttribute 는 생략이 가능하다.

그러면 원래 리턴자리에 있던 뷰 이름은 어떻게 연관시켜야할지 고민이 될 것이다. 이 때는 RequestToViewNameTranslator@GetMapping("/api/news")/api/news 와 같게 뷰 경로를 설정해준다.

@GetMapping("/api/news")
// @ModelAttribute (생략가능)
public News getNews() {
  return new News();
}

 


데이터 바인더 @InitBinder

바인딩

@InitBinder 는 컨트롤러에서 바인딩 또는 검증 설정을 변경하려고 할 때 사용한다.

@InitBinder
public void initNewsBinder(WebDataBinder webDataBinder) {
  webDataBinder.setDisallowedFields("id");
  // webDataBinder.setAllowedFields("title", "limit");
}

setDisallowedFields 는 데이터와 객체의 바인딩을 원하지 않는 필드를 골라서 설정할 수 있다. (blacklist)

setAllowedFields 는 바인딩을 원하는 필드만 골라서 설정할 수 있다. (whitelist)

 

포매터

webDataBinder.addCustomFormatter() 를 통해서 포매터도 설정할 수 있다. 예를 들어 문자열로 2021-02-21 날짜를 받으면 이를 LocalDataTime 이라는 타입으로 바꾸어주는 것을 말한다.

public class News {
    ...

  @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
  private LocalDate startDate;
}

위에 iso 타입은 사실 @DateTimeFormat(pattern = "yyyy-MM-dd") 이렇게 해준 것과 같다. 그런데 상수로 미리 지정되어있으니 타입-세이프하게 위에 처럼 적어주면 좋을 것 같다.

@DataTimeFormat 은 사실 커스텀 포매터는 아니다. 이 어노테이션을 이해하는 포매터가 이미 스프링에 등록되어 있어서 별도 설정없이 위 코드가 작동한다. 커스텀 타입을 등록하고 싶으면 webDataBinder.addCustomFormatter() 를 사용해주면 된다.

 

Validator 설정

복잡한 로직의 validator 를 설정하고 싶다면, 아래와 같이 Validator 를 구현하여 만들 수 있다. 여기서 만든 구현체를 webDataBinder.addValidators() 안에 등록해주면 밸리데이터를 사용할 수 있게 된다.

public class NewsValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return News.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        News news = (News) target;
        if (news.getTitle().contains("emergency")) {
            errors.rejectValue("title", "wrongValue", "No emergency news");
        }
    }
}
@InitBinder
public void initNewsBinder(WebDataBinder webDataBinder) {
  webDataBinder.addValidators(new NewsValidator());
}

또는 NewsValidator 를 빈으로 등록해서 바로 사용할 수도 있다. (@Compoent) 그리고 컨트롤러에서 이 클래스를 주입받은 후, newsvalidator.validate(news, bindingResult) 이런식으로 사용할 수 있다.

그리고 NewsValidator 는 더 이상 Validator 를 구현하지 않아도 되며, 아래와 같이 코드를 간단하게 변경하면 된다.

public class NewsValidator {

    @Override
    public void validate(News news, Errors errors) {
        if (news.getTitle().contains("emergency")) {
            errors.rejectValue("title", "wrongValue", "No emergency news");
        }
    }
}

특별이 특정 모델 객체에만 바인딩 또는 Validator 설정을 원한다면 @InitBinder("news") 등으로 응용해서 사용하면 된다.

 

 

 


예외 처리 핸들러 @ExceptionHandler

예외를 구체적으로 처리하고 싶은 경우, 기존 있는 예외처리에서 지원을 하지 않는 예외일 경우 사용한다.

예시를 보자.

public class NewsException extends RuntimeException {
}
@ExceptionHandler
public String newsErrorHandler(NewsException newsException, Model model) {
  model.addAttribute("message", "news error");
  return "/news/error";
}

@GetMapping("/news/error-test")
public String errorTest() {
  throw new NewsException();
}

/resources/news/error.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>Error</title>
</head>
<body>

<div th:if="${message}">
    <h2>Error</h2>
    <h3 th:text="${message}" />
</div>

</body>
</html>

위와 같이 만들면,NewsException 예외가 발생한 경우, newsErrorHandler 메소드가 호출되면서 /news/error 라는 이름을 가진 뷰가 화면에 보여지게 된다.

여러 예외를 한 핸들러에서 동시에 처리하고 싶으면

@ExceptionHandler({AException.class, BException.class, NewsException.class, ...})
public String newsErrorHandler(NewsException newsException, Model model) {
  model.addAttribute("message", "news error");
  return "/news/error";
}

위처럼 사용하면 된다. 단, 나머지 Exception 을 포괄하는 제일 부모 클래스의 Exception 을 아규먼트로 받아와서 사용하면 된다.

 

또한 에러 페이지를 따로 만들지 않고, ResponseEntity 를 사용해서 바로 응답본문을 반환할 수도 있다. 이렇게되면 응답코드 (400 = badRequest 등) 도 같이 보낼 수 있기 때문에 조금 더 친절한 설명을 보내줄 수 있을 것이다.

@ExceptionHandler
public ResponseEntity<String> newsResponseEntityErrorHandler(NewsException newsException) {
  return ResponseEntity.badRequest().body("error message example");
}

 

 

 


전역 컨트롤러 @ControllerAdvice

InitBinder 나 ExceptionHadler 등을 여러 컨트롤러에 걸쳐서 한 번에적용하고 싶다면, 전역 컨트롤러인 @ControllerAdvice 를 이용하면 된다. 아래와 같이 베이스컨트롤러를 만들고, 어노테이션을 붙인 뒤 공통으로 적용하기 원하는 메소드를 입력한다.

특정 클래스에만 적용할 수 도 있고, 특정 패키지 이하의 컨트롤러에만 적용할 수도있다. 또는 특정 어노테이션을 가지고 있는 컨트롤러에만 적용할 수도있다.

@ControllerAdvice(assignableTypes = {NewsController.class, RefNewsApi.class})
public class BaseController {

    @ExceptionHandler
    public String newsErrorHandler(NewsException newsException, Model model) {
        model.addAttribute("message", "news error");
        return "/news/error";
    }

    @InitBinder
    public void initNewsBinder(WebDataBinder webDataBinder) {
        webDataBinder.setDisallowedFields("id");
        webDataBinder.addValidators(new NewsValidator());
    }
}

특별히 모든 클래스에대해 @RestController 를 적용하고 싶다면, @RestControllerAdvice 를 사용하면 된다.

 

 

 

 

- 백기선님 스프링 웹 MVC 참고

728x90

댓글