본 글은 인프런 김영한님의 스프링 완전 정복 로드맵을 기반으로 정리했습니다.

1. @RequestMapping


@RequestMapping애노테이션이 붙은 핸들러는 RequestMappingHandlerMappingRequestMappingHandlerAdapter를 통해 실행된다.

스프링은 요청을 처리하는 다양한 핸들러를 지원하지만 실무에서는 대부분 이 방식으로 핸들러를 정의한다. RequestMappingHandlerMappingRequestMappingHandlerAdapter 도 각각 핸들러 매핑과 핸들러 어댑터 중에서 가장 우선순위가 높다.

@Controller
public class SpringMemberListController {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @RequestMapping("/members")
    public ModelAndView members() {
        List<Member> members = memberRepository.findAll();
        ModelAndView mv = new ModelAndView("members");
        mv.addObject("members", members);
        return mv;
    }
}

RequestMappingHandlerMapping은 스프링 빈 중에서 @RequestMapping 또는 @Controller가 클래스 레벨에 붙어 있는 경우에 매핑 정보로 인식한다. 물론 @Controller를 쓰면 컴포넌트 스캔도 되므로 @RequestMapping을 클래스에 붙이는 경우는 거의 없다.

핸들러는 ModelAndView를 반환할 수 있다. 생성자로 뷰의 논리이름을 전달하고 ModelAndView addObject(String attributeName, @Nullable Object attributeValue) 메서드를 통해 모델에 데이터를 추가할 수 있다. 그러나 매번 ModelAndView 객체를 생성해서 반환하는 것은 번거롭다.

@Controller
public class SpringMemberListController {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @GetMapping("/members")
    public String members(Model model) {
        List<Member> members = memberRepository.findAll();
        model.addAttribute("members", members);
        return "members";
    }
}

실제로 많이 사용하는 핸들러 메서드의 모양은 위와 같다.

@RequestMapping@GetMapping으로 변경했다. 이러면 해당 핸들러는 HTTP 메서드가 GET일때만 실행된다. @RequestMapping에 옵션을 줘서 HTTP 메서드를 제한할 수도 있다.

핸들러는 뷰의 논리 이름만 반환할 수 있다. 그러면 어댑터가 ModelAndView객체로 바꿔서 DispatcherServlet에게 반환한다.

org.springframework.ui.Model 타입의 파라미터를 받았다. 모델에 데이터를 key/value 쌍으로 추가하면 뷰에서 해당 모델을 참조하여 화면을 렌더링한다.

이처럼 스프링MVC는 이처럼 핸들러의 파라미터와 반환값을 매우 다양하게 설정할 수 있는 유연함을 제공한다. 이는 ArgumentResolver 덕분인데 이에 대해선 MessageConverter와 함께 밑에서 설명하도록 하겠다.

2. @RequestMapping 옵션


위에선 요청 경로와 HTTP메서드 만으로 핸들러를 매핑했지만, 스프링은 더 다양한 옵션을 통해 매핑할 수 있도록 지원한다. 하나씩 살펴보자.

  • 요청 파라미터 매핑

    /**
     * 파라미터로 추가 매핑 (자주 쓸 일 X)
     * params="mode",
     * params="!mode"
     * params="mode=debug"
     * params="mode!=debug"
     * params = {"mode=debug","data=good"}
     */
    @GetMapping(value = "/mapping-param", params = "mode=debug")
    public String mappingParam() {
        log.info("mappingParam");
        return "ok";
    }

    요청 파라미터를 통해 매핑될 핸들러를 더 세세하게 지정할 수 있다. 주석을 보면 파라미터의 존재 여부, 일치 여부 등 꽤 세세하게 옵션을 지정할 수 있는 것을 알 수 있다. 자주 사용하지는 않는다.

  • 헤더 매핑

    /**
     * 특정 헤더로 추가 매핑 (자주 쓸 일 X)
     * headers="mode",
     * headers="!mode"
     * headers="mode=debug"
     * headers="mode!=debug"
     */
    @GetMapping(value = "/mapping-header", headers = "mode=debug")
    public String mappingHeader() {
        log.info("mappingHeader");
        return "ok";
    }

    HTTP 요청 헤더의 존재 여부, 일치 여부로 매핑할 수도 있다. 역시 자주 사용하지 않는다.

  • Content-Type 헤더 매핑

    /**
     * Content-Type 헤더 기반 추가 매핑 Media Type
     * consumes="application/json"
     * consumes="!application/json"
     * consumes="application/*"
     * consumes="*\/*"
     * MediaType.APPLICATION_JSON_VALUE
     */
    @PostMapping(value = "/mapping-consume", consumes = MediaType.APPLICATION_JSON_VALUE)
    public String mappingConsumes() {
        log.info("mappingConsumes");
        return "ok";
    }

    HTTP 요청 헤더의 Content-Type을 기반으로 핸들러를 매핑할 수 있다. 애노테이션의 consumes 옵션의 이름을 통해 유추할 수 있듯이 이 핸들러는 실행되기 위해 사용자가 보낸 JSON 데이터를 필요로 한다. 만약 Content-Type 헤더와 서버가 필요로 하는 데이터 타입이 불일치 하면 HTTP 상태 코드 415(Unsupported Media Type)을 반환한다.

  • Accept 헤더 매핑

    /**
     * Accept 헤더 기반 Media Type
     * produces = "text/html"
     * produces = "!text/html"
     * produces = "text/*"
     * produces = "*\/*"
     */
    @PostMapping(value = "/mapping-produce", produces = MediaType.TEXT_HTML_VALUE)
    public String mappingProduces() {
        log.info("mappingProduces");
        return "ok";
    }

    사용자는 요청시 Accept류의 헤더를 통해 원하는 언어나 응답 데이터 타입 등을 지정할 수 있다. 이를 컨텐츠 네고시에이션이라고 한다. 만약 Accept 헤더가 서버가 응답하는 데이터 타입과 불일치 하면 HTTP 상태 코드 406(Not Acceptable)을 반환한다.

3. @PathVariable


@Slf4j
@Controller
public class OrderController {

  @ResponseBody
  @GetMapping("/users/{userId}/orders/{orderId}")
  public String mappingPath(@PathVariable String userId, @PathVariable Long orderId) {
      log.info("mappingPath userId={}, orderId={}", userId, orderId);
      return "ok";
  }
}

최근 HTTP API는 리소스 경로에 식별자를 넣어서 리소스 경로를 계층적으로 설계하는 방법을 많이 사용한다. 이를 위해 스프링은 @PathVariable애노테이션을 제공한다. 위에서 알 수 있듯이 String, Long 타입으로 알 맞게 타입을 변환해서 매개변수로 받을 수 있다. Long 타입인 orderId에 문자가 들어오는 것처럼 타입을 변환할 수 없을 때는 익셉션이 발생한다.

매개변수의 이름을 경로이름과 다르게 하고 싶다면 @PathVarialbe("userId") String memberId와 같이 바꿀 수 있다. 그러나 경로변수와 매개변수의 이름을 맞추는 것이 가독성이 좋은 경우가 많다.

4. 요청 - 헤더


@Slf4j
@RestController
public class RequestHeaderController {

    @RequestMapping("/headers")
    public String headers(HttpServletRequest req,
                          HttpServletResponse res,
                          HttpMethod httpMethod,
                          Locale locale,
                          @RequestHeader MultiValueMap<String, String> headerMap,
                          @RequestHeader("host") String host,
                          @CookieValue(value = "myCookie", required = false) String cookie) {

        log.info("request={}", req);
        log.info("response={}", res);
        log.info("httpMethod={}", httpMethod);
        log.info("locale={}", locale);
        log.info("headerMap={}", headerMap);
        log.info("header host={}", host);
        log.info("myCookie={}", cookie);
        return "ok";
    }
}

위에서 언급했다시피 핸들러는 ArgumentResolver 덕분에 매우 다양한 타입의 매개변수를 받을 수 있다. 심지어 서블릿처럼 HttpServletRequestHttpServletResponse를 받을 수도 있다. 매개변수 타입의 이름이 직관적이기 때문에 일부만 설명한다.

@RequestHeader 애노테이션을 통해 헤더 정보를 key/value 쌍으로 받을 수 있다.

MultiValueMap은 <키, Value 타입의 리스트> 로 저장한다. 옵션을 통해 특정 헤더만 가져올 수 있다.

헤더 중 자주 사용되는 Cookie는 @CookieValue 애노테이션을 따로 제공한다. 옵션으로 쿠키의 이름을 지정해야하고 필수여부를 지정할 수 있다. 기본값은 필수다.

@RestController는 View를 반환하는 대신 응답 메시지 바디에 값을 직접 전달하는 컨트롤러다. 응답과 관련된 부분에서 자세히 알아보도록 하자.

5. 요청 - 쿼리 파라미터 - @RequestParam, @ModelAttribute


요청 데이터를 정리하기 전에 헷갈리지 않기 위해서 HTTP요청시 클라이언트가 서버에게 데이터를 전달하는 세 가지 방법을 알아보자. 이 세 가지 방법을 명확히 구분하지 못하면 스프링 MVC의 기능 또한 헷갈려진다.

  1. GET - 쿼리 파라미터

    /url?username=hello&age=20

    GET 방식은 메시지 바디가 없다. 최근 HTTP에서는 사용할 수 있는 경우도 있지만 실무에서는 거의 사용되지 않는다.

    메시지 바디를 사용할 수 없기 때문에 URL의 쿼리 마라미터에 (key=value1&key=value2) 와 같이 키,값 쌍을 & 묶어서 전달한다.

    GET 방식은 그 이름처럼 조회에 주로 사용되므로 검색, 필터, 페이징 등에 필요한 값들이 넘어온다.

  2. POST - HTML Form

    Content-Type: application/x-www-form-urlencoded

    POST 방식은 데이터를 HTTP 메시지 바디를 통해서 전달할 수 있다. 그 중 HTML 태그인 form을 사용해서 요청을 보내면 Content-Type: application/x-www-form-urlencoded이 된다.

    전달 데이터는 GET의 쿼리 파라미터처럼 <키,값> 쌍을 &로 묶어서 생성된다. 회원 가입, 상품 주문과 같이 form을 사용하는 곳에서 사용된다.

    ex) username=hello&age=20

  3. HTTP Message Body

    Content-Type: application/json

    HTTP 메시지 바디를 사용한다. 메시지 바디는 form 데이터 뿐 아니라 사용자가 원하는 데이터 타입을 다양하게 담을 수 있다(JSON, XML, TEXT ...). 최근에는 REST형식의 api와 함께 JSON 형식의 데이터를 많이 사용한다.

세 가지 방법중 1, 2번은 데이터의 형식이 똑같다. key1=value1&key2=value2 형식이다. 그렇기 때문에 스프링은 두 방법을 똑같은 방법으로 처리한다. 이 때 사용되는 것이 @RequestParam, @ModelAttribute 두 가지 애노테이션이다.

@RequestBody는 메시지 바디에 담긴 form 방식 이외의 데이터를 처리하기 위해 사용되는 애노테이션이다.

@RequestParam 부터 살펴보자.

  • @RequestParam 기본

    @ResponseBody
    @RequestMapping("/request-param")
    public String requestParam(@RequestParam String username,
                               @RequestParam int age) {
    
        log.info("username={}, age={}", username, age);
        return "ok";
    }

    쿼리 파라미터의 이름을 매개변수의 변수 명으로 바로 매핑했다. @RequestMapping 애노테이션을 사용했기 때문에 GET 요청과 form 을 사용하는 POST 요청을 둘 다 처리할 수 있다. 쿼리 파라미터와 매개변수의 이름을 다르게 하고 싶다면 @RequestParam("username") String memberName 처럼 하면 된다. @PathVariable의 경우와 마찬가지로 매개변수와 이름을 똑같이 하는 것이 가독성이 좋다.

    @ResponseBody 애노테이션을 사용하면 view를 찾지 않고 반환값이 바로 HTTP 응답 바디에 실려서 나간다. 응답 부분에서 자세히 알아본다.

  • @RequestParam 생략

    @ResponseBody
    @RequestMapping("/request-param")
    public String requestParam(String username, int age) {
        log.info("username={}, age={}", username, age);
        return "ok";
    }

    String, int, Integer와 같은 단순 타입은 애노테이션을 생략할 수 있다. 그러나 생략하면 가독성이 떨어지기 때문에 명시적으로 쿼리 파라미터를 처리한다는 것을 알 수 있도록 애노테이션을 붙이는것이 좋다.

  • @RequestParam 필수 여부

    @ResponseBody
    @RequestMapping("/request-param")
    public String requestParam(@RequestParam String username,
                               @RequestParam(required = false) Integer age) {
    
        log.info("username={}, age={}", username, age);
        return "ok";
    }

    @RequestParam에 required 옵션으로 필수 여부를 정할 수 있다. 기본값은 true이며 보내지 않으면 HTTP 상태코드 400(Bad Request)가 나간다. 선택적 파라미터의 경우 보내지 않으면 null이 입력된다.

    주의할 점이 두 가지 있다.

    • /request-param?username=처럼 파라미터를 지정해줬지만 값은 없는 경우 null이 아닌 빈 문자열로 통과된다.
    • @RequestParam(required=false) int age 의 경우에 선택적 파라미터인 age를 보내지 않으면 null이 입력되어야 하는데 int타입엔 null을 넣을 수 없기 때문에 500예외가 발생한다. 따라서 래퍼 타입인 Integer로 변경하거나 다음에 나올 defaultValue 옵션으로 기본값을 지정해줘야 한다.
  • @RequestParam 기본값

    @ResponseBody
    @RequestMapping("/request-param-default")
    public String requestParam(@RequestParam(defaultValue = "guest") String username,
                              @RequestParam(defaultValue = "-1") int age) {
    
        log.info("username={}, age={}", username, age);
        return "ok";
    }

    쿼리 파라미터의 기본값을 지정해줄 수 있다. 기본값을 지정해주면 required 옵션은 의미가 없다. 주의할 점은 /request-param?username= 처럼 빈 문자를 보낼 경우에 기본값이 적용된다.

  • @RequestParam 맵으로 조회

    @ResponseBody
    @RequestMapping("/request-param")
    public String requestParam(@RequestParam Map<String, Object> paramMap) {
        log.info("username={}, age={}", paramMap.get("username"), paramMap.get("age"));
        return "ok";
    }

    파라미터 전체를 Map 타입으로 한꺼번에 받을 수 있다. 파라미터의 값이 한 개인 경우 Map으로 조회하면 되지만 /request-param?key=val1&key=val2 처럼 여러개라면 MultiValueMap을 사용하면 된다. 그러나, 파라미터를 여러개 받는 경우는 드물다.

개발을 하면 보통 파라미터롤 받아서 객체에 바인딩 한다. 쿼리 파라미터를 다음과 같은 객체에 바인딩해야 되는 상황이라고 하자. @Data는 롬복의 애노테이션으로 ToString, Getter, Setter, EqualsAndHashCode, RequiredArgsConstructor를 자동으로 생성해준다.

@Data
public class HelloData {

  private String username;
  private int age;
}

@ModelAtttribute는 파라미터값을 객체에 바인딩 하는 수고를 줄이기 위해 사용하기 위해 사용한다. @RequestParam만 사용하면 다음과 비슷하게 처리할 것이다.

@ResponseBody
@RequestMapping("/request-param")
public String requestParam(@RequestParam String username,
                           @RequestParam int age) {

  	HelloData data = new HelloData();
    data.setUsername(username);
 		data.setAge(age);
    return "ok";
}

@ModelAttribute는 요청 파라미터를 객체에 바인딩 해주는 편의기능을 제공한다.

  • @ModelAttribute

    @ResponseBody
    @RequestMapping("/model-attribute")
    public String modelAttribute(@ModelAttribute HelloData helloData) {
        log.info("helloData={}", helloData);
        return "ok";
    }

    스프링은 @ModelAttribute를 보고 HelloData객체를 생성하고 요청 파라미터의 이름으로 객체의 프로퍼티를 찾는다. 그리고 해당 프로퍼티의 세터(setXxx)를 호출해서 파라미터의 값을 바인당 한다. 그러나 정수형 타입에 문자열을 입력하는 등 바인딩을 할 수 없는 경우 BindException이 발생한다. 이를 처리하는 방법은 검증 부분에서 다룬다.

    @RequestParam애노테이션을 생략할 수 있는 것처럼 @ModelAttribute도 생략할 수 있지만 가독성 차원에서 생략하지 않는 것이 좋다. 애노테이션을 생략할 경우 스프링은 String, int, Integer와 같은 단순타입은 @RequestParam으로 처리하고 나머지 타입은 @ModelAttribute으로 처리한다.

    ArgumentResolver로 지정해둔 타입은 둘 다 적용되지 않고 따로 처리되는데 이는 밑에서 알아보겠다.

    @ModelAttribute는 요청 파라미터의 처리 외에도, Model에 값을 자동으로 추가하는 편의기능 또한 제공한다. 위의 코드는 View를 반환하는 핸들러가 아니라서 Model 매개변수가 없지만 만약 View를 생성하는 핸들러라면 자동으로 model.addAttribute("helloData", helloData) 를 수행해주는 것이다. 이때 모델의 키는 클래스 이름의 첫글자만 소문자로 바꿔서 사용한다. @ModelAttribute("myKey") HelloData helloData처럼 모델에 자동 추가할 키의 이름을 지정해줄 수도 있다.

6. 요청 - HTTP 메시디 바디 - @RequestBody


사용자는 GET 방식의 쿼리 파라미터, POST 방식의 form 쿼리 파라미터 외에도 HTTP 메시지 바디에 데이터를 직접 담아서 전달할 수 있다. form 데이터도 메시지 바디를 통해 넘어오긴 하지만 데이터의 형식이 GET 방식의 쿼리 파라미터와 똑같기 때문에 @RequestParam 혹은 @ModelAttribute를 사용해서 동일한 방식으로 처리할 수 있었다.

form 방식 이외의 메시지 바디 데이터는 다른 방법으로 처리해야 한다. 메시지 바디를 통해 텍스트(text/plain)가 넘어올 수도 있고 xml(application/xml)이 넘어올 수도 있다. 그러나 최근엔 대부분 JSON(application/json)을 통해 통신하기 때문에 JSON형식의 데이터를 어떻게 처리하는지만 살펴보겠다.

  • 서블릿

    @Slf4j
    @Controller
    public class RequestBodyJsonController {
    
        private ObjectMapper objectMapper = new ObjectMapper();
    
        @PostMapping("/request-body-json")
        public void requestBodyJson(HttpServletRequest req, HttpServletResponse res) throws IOException {
            ServletInputStream inputStream = req.getInputStream();
            String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
    
            log.info("messageBody={}", messageBody);
            HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
            log.info("helloData={}", helloData);
    
            res.getWriter().write("ok");
        }
    }

    여러번 말했지만, ArgumentResolver 덕분에 핸들러는 다양한 타입의 매개변수를 받는다. 이 경우엔 서블릿 객체를 통해 JSON 데이터를 처리했다. 먼저, 서블릿 객체로부터 InputStream을 받는다. 그 다음, Stream에 담긴 바이트 데이터를 유틸 라이브러리를 통해 String타입의 JSON문자열로 변환한다. 바이트와 문자열간 변환할 때는 항상 인코딩 정보를 명시해줘야한다. 대부분 UTF-8 방식을 사용한다. 그 뒤 JSON문자열을 실제 자바 객체로 변환하기 위해 ObjectMapper를 사용하였다. JSON문자열과 변환할 객체의 타입을 넘겨주면 JSON문자열을 자바 객체로 변환해준다.

    응답은 서블릿 객체로부터 Writer를 얻어와서 직접 반환해줬다. 응답하는 방법에 관해선 밑에서 더 자세히 알아보겠다. 이 방법은 서블릿을 통해 데이터를 처리하는 것과 큰 차이가 없다. 코드 중복이 심해지고 JSON을 통해 객체를 얻기 불편하다.

    HTTP 메시지 바디를 더욱 편하게 처리하도록 편의 기능을 제공하는 것이 바로 @RequestBody 애노테이션이다. @RequestBody 애노테이션을 붙인 매개변수의 타입은 다양하게 설정할 수 있는데 이는 MessageConverter와 관련이 있다. @ResponseBody는 핸들러에서 View를 반환하지 않고 메시지 바디에 직접 데이터를 넣을 수 있게 해준다. 역시 MessageConverter와 관련이 있다. 밑에서 더 자세히 설명한다.

  • @RequestBody + String

    @ResponseBody
    @PostMapping("/request-body-json")
    public String requestBodyJson(@RequestBody String messageBody) throws IOException {
    
        log.info("messageBody={}", messageBody);
        HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
        log.info("helloData={}", helloData);
    
        return "ok";
    }

    @RequestMapping 를 사용해서 문자열을 직접 받을 수 있다. 이를 통해서 문자열을 ObjectMapper를 통해 바로 객체로 변환했다. 그러나 여전히 문자열을 객체로 변환하는 중복이 발생한다. 쿼리 파라미터를 일일이 객체에 바인딩 해주는 불편함이랑 비슷하다.

  • @RequestBody + 객체

    @ResponseBody
    @PostMapping("/request-body-json")
    public String requestBodyJson(@RequestBody HelloData helloData) throws IOException {
        log.info("helloData={}", helloData);
        return "ok";
    }

    쿼리 파라미터를 @ModelAttribute를 통해 바로 객체로 변환 가능한 것처럼 @RequestBody 도 객체를 직접 받을 수 있다. MessageConverter가 메시지 바디의 내용을 문자나 객체로 다양하게 변환해준다.

    주의할 점이 있는데, @ModelAttribute는 생략할 수 있지만, @RequestBody는 생략하면 안된다. 생략하면 @ModelAttribute가 적용되어 쿼리 파라미터 형식으로 데이터를 처리한다. @ModelAttribute도 가능하면 생략하지 않고 명시적으로 적어주는 것이 좋다.

  • HttpEntity

    @ResponseBody
    @PostMapping("/request-body-json")
    public String requestBodyJson(HttpEntity<HelloData> httpEntity) throws IOException {
        HelloData helloData = httpEntity.getBody();
        log.info("helloData={}", helloData);
        return "ok";
    }

    HttpEntity 타입을 통해 메시지 바디를 처리할 수도 있다. 제네릭 클래스이므로 타입을 지정해주면 getBody()를 통해 형변환 없이 바로 데이터를 얻을 수 있다. HttpEntity의 하위 클래스로 RequestEntity, ResponseEntity 가 있는데 두 클래스는 각각 요청/응답을 처리하기 위한 추가적인 기능을 제공한다. 특히, ResponseEntity는 핸들러의 반환 타입으로 흔히 사용하는데 이는 응답과 관련된 부분에서 살펴본다.

7. 응답 - View


응답 데이터도 요청과 마찬가지로 크게 세 가지 경우로 나눌 수 있다.

  1. 정적 리소스

    정적 리소스인 html, css, js 등을 제공한다. 보통 정적 리소스 제공은 애플리케이션 서버에서 담당하지 않고 다른 서버에서 제공하는것이 일반적이다. 그러나 스프링을 비롯한 대부분의 프레임워크는 정적 리소스 제공 기능을 포함한다.

  2. 뷰 템플릿

    JSP, Thymeleaf 와 같은 템플릿 엔진을 통해 동적인 html을 제공한다. 이 방법을 서버사이드 렌더링이라고 한다.

  3. HTTP 메시지

    HTTP API를 제공하는 경우엔 html이 아닌 데이터를 전달해야 한다. 최근엔 HTTP 메시지 바디를 통해 JSON을 응답하는 방법이 주로 사용된다.

  • 정적 리소스

    src/main/resources 는 클래스패스의 시작경로이자 리소스를 보관하는 곳이다. 따라서 해당 디렉토리의 /static, /public, /resources, /META-INF/resources 에 정적 리소스를 넣어두면 스프링 부트가 정적 리소스를 응답한다.

  • 뷰 템플릿

    뷰 템플릿은 보통 서버사이드 렌더링을 통해 동적인 html을 생성하는데 사용한다. 경로는 src/main/resources/templates와 같다. 예를 들어, src/main/resources/templates/response/hello.html에 타임리프 파일을 넣었다면 컨트롤러에서 다음과 같이 뷰 템플릿을 응답할 수 있다.

    @RequestMapping("/response-view")
    public String responseView(Model model) {
        model.addAttribute("data", "hello");
        return "response/hello";
    }

    Model에 뷰 템플릿이 렌더링하는데 필요한 데이터를 담고 뷰의 논리적인 이름을 반환하면 ViewResolver가 물리적인 뷰를 찾아서 모델을 통해 화면을 렌더링하고 동적인 html을 응답한다.

8. 응답 - HTTP 메시지 바디 - @ResponseEntity, @ResponseBody


응답 데이터로 가장 많이 사용하는 방법이다. 응답 데이터로 html이 아닌 JSON을 비롯한 다양한 타입의 데이터를 메시지 바디에 직접 실어서 응답한다.

  • Writer

    @GetMapping("/response-body-string")
    public void responseBodyString(HttpServletResponse res) throws IOException {
        res.getWriter().write("ok");
    }

    위에서 살펴본 것처럼, 서블릿 객체로부터 Writer를 얻어서 직접 메시지 바디에 값을 적어 넣을수 있다. 잘 사용하지 않는다.

  • ResponseEntity

    @GetMapping("/response-body-json")
    public ResponseEntity<HelloData> responseBodyJson() {
        HelloData helloData = new HelloData();
        helloData.setUsername("userA");
        helloData.setAge(20);
    
        return new ResponseEntity<>(helloData, HttpStatus.OK);
    }

    ResponseEntity는 HttpEntity의 하위 클래스다. HttpEntity는 HTTP 메시지의 헤더, 바디 정보를 갖고 있다. ResponseEntity는 이를 확장해서 HTTP 상태코드를 설정할 수 있다. HttpEntity와 마찬가지로 제네릭 클래스 이므로 바디에 넣을 데이터 타입을 지정할 수 있다. 생성자의 매개변수로 응답 데이터와 HTTP 상태코드를 전달하였다. 프로그램 로직에 따라 상태코드를 다르게 응답해야할 때 사용한다.

    위 코드에선 객체를 변환하여 JSON 이 반환된다. 문자열을 반환할 수도 있다. 이 때, 데이터 타입에 맞는 MessageConverter가 동작한다.

    HttpStatus는 enum타입으로 여러가지 상태코드를 제공한다. 정수를 직접 넘기기 보다 enum 타입을 적극 활용하는 것이 가독성 측면에서 좋다.

  • @ResponseBody

    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    @GetMapping("/response-body-json")
    public HelloData responseBodyJson() {
        HelloData helloData = new HelloData();
        helloData.setUsername("userA");
        helloData.setAge(20);
    
        return helloData;
    }

    ResponseEntity를 응답할 때와 마찬가지 @ResponseBody를 사용하여 응답 데이터를 HTTP 메시지 바디에 넣을 수 있다. 마찬가지로 데이터 타입을 처리할 수 있는 MessageConverter가 동작한다.

    @ResponseStatus를 통해 HTTP 응답 코드를 설정해 줄수 있다. 그러나, 애노테이션이기 때문에 로직에 따라 동적인 코드를 응답할 수는 없다. 응답코드가 동적이라면 ResponseEntity를 사용하면 된다.

  • @RestController

    @Controller대신 @RestController를 클래스 레벨에 붙이면 해당 클래스의 메서드(핸들러)들에 일괄적으로 @ResponseBody가 적용된다. 즉, View를 반환하지 않고 HTTP 메시지 바디에 응답 데이터를 직접 넣을 수 있다.

9. MessageConverter, ArgumentResolver, ReturnValueHandler


MessageConverter

HTTP 요청 바디에서 데이터를 직접 읽거나, 응답 바디에 직접 데이터를 넣을 때 @RequestBody@ResponseBody 애노테이션을 사용했다. 또는, HttpEntity, 그 하위 타입인 RequestEntity, ResponseEntity를 사용하기도 했다. 이러한 요청과 응답을 처리하기 위해 스프링MVC는 org.springframework.http.converter.HttpMessageConverter 인터페이스를 사용한다. 이 인터페이스는 메시지 컨버터가 해당 클래스와 미디어 타입을 지원하는지 체크하는 canRead(), canWrite() 메서드와 데이터를 실제로 읽고 쓰는 read(), write() 메서드 등이 있다.

바디에 데이터를 읽을 때 문자열로 읽을 수도 있고 객체로 읽을 수도 있었다. 마찬가지로, 바디에 데이터를 넣을 때 문자열을 넣을 수도 있고 객체를 넣을 수 도 있었다. 이는 HttpMessageConverter를 구현한 구체 클래스들 덕분에 가능한 것이다.

메시지 컨버터는 대상 클래스 타입과 미디어 타입 두 가지를 체크해서 데이터를 처리할 수 있는 컨버터가 선택된다. 메시지 컨버터에는 우선순위가 있으며 만약 자신이 처리할 수 없으면 다음 메시지 컨버터로 우선순위가 넘어간다. 메시지 컨버터의 종류는 매우 다양하다. 몇 가지 주요한 메시지 컨버터들은 다음과 같다. 가장 많이 사용되는 것은 JSON과 객체 간 변환을 처리해주는 MappingJackson2HttpMesssageConverter다.

요청의 경우, 메시지 컨버터는 @RequestBody 애노테이션이 있어서 메시지 바디를 읽어야 하거나, HttpEntity 파라미터가 있을 때 사용된다. 메시지 컨버터는 메시지를 읽을 수 있는지 확인하기 위해 canRead()를 호출한다. canRead()는 대상 클래스와 Content-Type 헤더를 확인해서 조건을 만족하면 read()를 호출해서 객체를 생성해서 반환한다.

ex) MappingJackson2HttpMessageConverter는 @RequestBody HelloData dataContent-Type: application/json 일 때 자신이 메시지를 읽을 수 있으므로 객체를 생성해서 반환한다.

응답의 경우, 메시지 컨버터는 @ResponseBody 애노테이션이 있어서 메시지 바디를 써야 하거나, HttpEntity 를 반환하면 사용된다. 메시지 컨버터는 메시지를 쓸 수 있는지 확인하기 위해 canWrite()를 호출한다. canWrite()는 대상 클래스와 Accept 헤더를 확인해서 조건을 만족하면 write()를 호출해서 응답 메시지 바디에 데이터를 넣는다.

이전 글에서 DispatcherServlet을 중심으로 스프링MVC에서 핸들러가 어떻게 호출되는지 자세히 살펴봤었다.

ArgumentResolver, ReturnValueHandler

MessageConverter는 이 과정에서 ArgumentResolverReturnValueHandler 에 의해서 사용된다.

ArgumentResolver, ReturnValueHandler@RequestMapping 타입의 핸들러를 처리할 수 있는 핸들러 어댑터 즉, @RequestMappingHandlerAdpater를 통해 동작한다. 그림을 통해 살펴보자.

애노테이션 기반의 컨트롤러는 매우 다양한 매개변수를 지원할 수 있었다.

ex) HttpServletRequest, Model, @RequestParam, @ModelAttribute, @RequestBody, HttpEntity....

이것이 가능한 이유가 바로 ArgumentResolver 덕분이다. 실제 이름은 HandlerMethodArgumentResolver 이지만 줄여서 ArgumentResolver 라고 부른다. 이 타입은 인터페이슨데 다양한 매개변수를 처리하기 위해 다양한 구체 클래스가 이미 준비되어 있다.

ArgumentResolversupportsParameter()를 호출해서 해당 파라미터를 지원하는지 체크하고 resolveArgument()를 호출해서 실제 객체를 생성한 뒤 핸들러에게 넘겨준다.

매우 다양한 매개변수를 처리해주는데 자세한 목록은 공식문서를 참고하자.

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-arguments

비슷하게, 애노테이션 기반의 컨트롤러가 다양한 반환값을 사용할 수 있었다.

ex) ModelAndView, @ResponseBody, HttpEntity, String...

이것이 가능한 이유가 바로 ReturnValueHandler 덕분이다. 실제 이름은 HandlerMethodReturnValueHandler이지만 줄여서 ReturnValyeHandler라고 부른다. 이 타입도 인터페이슨데 다양한 반환값을 처리하기 위해 10가지가 넘는 구체 클래스가 이미 준비되어 있다.

요청의 경우 @RequestBodyHttpEntity타입이 있으면 ArgumentResolverHttpMessageConverter에게 메시지 바디를 읽어서 요청 데이터를 생성하는 작업을 위임한다.

응답의 경우 @ResponseBodyHttpEntity타입이 있으면 ReturnValueHandlerHttpMessageConverter에게 메시지 바디에 데이터를 넣는 작업을 위임한다.

  • 확장

    HandlerMethodArgumentResolver, HandlerMethodReturnValueHandler, HttpMessageConverter 모두 인터페이스기 때문에 필요하면 기능을 확장할 수 있다. 예를 들어 원하는 타입의 매개변수를 처리하기 위해 HandlerMethodArgumentResolver를 구현한 클래스를 만들면 된다. 기능 확장은 WebMvcConfigurer를 상속 받아서 스프링 빈으로 등록하면 된다. 그러나, 스프링이 이미 필요한 기능을 대부분 제공하므로 기능을 확장할 일은 거의 없다.

10. 정리


이 글에서 스프링 MVC가 제공하는 기능중 자주 사용되는 것들을 위주로 살펴보았다. 기능이 매우 많기 때문에 헷갈리지 않기 위해서 편의 기능 이면에 스프링 MVC의 원리를 이해하는 것이 매우 중요하다.

이전 글과, 이 글의 9번 섹션을 확실히 이해하고 기능을 사용한다면 헷갈리지 않고 스프링 MVC가 제공하는 편리한 기능을 누리며 개발할 수 있을 것이다.

profile
아임쿨

0개의 댓글