728x90
반응형
SMALL

참고자료:

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습

www.inflearn.com

이제 Spring MVC는 어떤걸 편리하게 해주고 어떤 효율성이 있는지 하나씩 파악해보자. 우선 그 전에 Welcome 페이지가 하나 있으면 편리할 거 같아서 Welcome 페이지를 만들자. 근데! Welcome 페이지를 만들기 전에 프로젝트가 있어야 한다. 스프링 프로젝트를 만들자.

 

https://start.spring.io

여기로 가서 스프링 프로젝트를 만들면 된다. DependenciesLombok, Spring Web, Thymeleaf 세 가지를 선택하자.

참고로, src/main/resources/static/index.html 경로에 있는 index.html 파일이 스프링 웹의 기본 Welcome 페이지가 된다.

 

src/main/resources/static/index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<ul>
    <li>로그 출력
        <ul>
            <li><a href="/log-test">로그 테스트</a></li>
        </ul>
    </li>
    <!-- -->
    <li>요청 매핑
        <ul>
            <li><a href="/hello-basic">hello-basic</a></li>
            <li><a href="/mapping-get-v1">HTTP 메서드 매핑</a></li>
            <li><a href="/mapping-get-v2">HTTP 메서드 매핑 축약</a></li>
            <li><a href="/mapping/userA">경로 변수</a></li>
            <li><a href="/mapping/users/userA/orders/100">경로 변수 다중</a></li>
            <li><a href="/mapping-param?mode=debug">특정 파라미터 조건 매핑</a></li>
            <li><a href="/mapping-header">특정 헤더 조건 매핑(POST MAN 필요)</a></li>
            <li><a href="/mapping-consume">미디어 타입 조건 매핑 Content-Type(POST MAN 필요)</a></li>
            <li><a href="/mapping-produce">미디어 타입 조건 매핑 Accept(POST MAN 필요)</a></li>
        </ul>
    </li>
    <li>요청 매핑 - API 예시
        <ul>
            <li>POST MAN 필요</li>
        </ul>
    </li>
    <li>HTTP 요청 기본
        <ul>
            <li><a href="/headers">기본, 헤더 조회</a></li>
        </ul>
    </li>
    <li>HTTP 요청 파라미터
        <ul>
            <li><a href="/request-param-v1?username=hello&age=20">요청 파라미터 v1</a></li>
            <li><a href="/request-param-v2?username=hello&age=20">요청 파라미터 v2</a></li>
            <li><a href="/request-param-v3?username=hello&age=20">요청 파라미터 v3</a></li>
            <li><a href="/request-param-v4?username=hello&age=20">요청 파라미터 v4</a></li>
            <li><a href="/request-param-required?username=hello&age=20">요청 파라미터 필수</a></li>
            <li><a href="/request-param-default?username=hello&age=20">요청 파라미터 기본 값</a></li>
            <li><a href="/request-param-map?username=hello&age=20">요청 파라미터 MAP</a></li>
            <li><a href="/model-attribute-v1?username=hello&age=20">요청 파라미터 @ModelAttribute v1</a></li>
            <li><a href="/model-attribute-v2?username=hello&age=20">요청 파라미터 @ModelAttribute v2</a></li>
        </ul>
    </li>
    <li>HTTP 요청 메시지
        <ul>
            <li>POST MAN</li>
        </ul>
    </li>
    <li>HTTP 응답 - 정적 리소스, 뷰 템플릿
        <ul>
            <li><a href="/basic/hello-form.html">정적 리소스</a></li>
            <li><a href="/response-view-v1">뷰 템플릿 v1</a></li>
            <li><a href="/response-view-v2">뷰 템플릿 v2</a></li>
        </ul>
    </li>
    <li>HTTP 응답 - HTTP API, 메시지 바디에 직접 입력
        <ul>
            <li><a href="/response-body-string-v1">HTTP API String v1</a></li>
            <li><a href="/response-body-string-v2">HTTP API String v2</a></li>
            <li><a href="/response-body-string-v3">HTTP API String v3</a></li>
            <li><a href="/response-body-json-v1">HTTP API Json v1</a></li>
            <li><a href="/response-body-json-v2">HTTP API Json v2</a></li>
        </ul>
    </li>
</ul>
</body>
</html>

 

이렇게 파일 하나를 만들어 두고 서버를 재실행한 후 `localhost:8080`으로 접속하면 이 페이지가 보일것이다.

이제 요청 매핑부터 하나하나 살펴보자.

 

요청 매핑

우선 아주 간단한 요청 매핑 하나를 만들어보자.

MappingController

package net.cwchoiit.springmvc.requestmapping;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class MappingController {

    @RequestMapping("/hello-basic")
    public String hello() {
        log.debug("hello!");
        return "Hello Basic";
    }
}

 

여기서 @RestController@Controller랑 무엇이 다르냐? @Controller는 메서드의 반환값이 String이면 해당 값이 뷰의 이름이 된다. 그래서 해당 이름으로 된 뷰를 찾아 뷰가 렌더링된다. 근데 @RestController를 사용하면 반환값으로 뷰를 찾는게 아니라 HTTP 메시지 바디에 바로 입력한다. 따라서 실행 결과로 "Hello Basic"이라는 메시지를 응답으로 받을 수가 있다. 이게 @ResponseBody와 관련이 있는데 뒤에 더 자세히 설명한다.

 

@RequestMapping("/hello-basic")은 URL이 `/hello-basic`으로 요청이 들어오면 이 메서드가 실행되도록 한다. 

참고로, `/hello-basic``/hello-basic/`은 다른 URL이다. 그러므로 당연히 스프링도 서로 다르게 인식한다.

 

그리고 이렇게 @RequestMapping으로만 해놓으면 GET, POST, PUT, DELETE 다 가능하다. 그래서 이를 딱 지정하기 위해 다음과 같은 애노테이션을 사용할 수 있다.

@GetMapping("/mapping-get-v2")
public String mappingGetV2() {
    log.debug("mappingGetV2!");
    return "ok";
}

 

@GetMapping 애노테이션을 사용하면, GET Method만 허용한다.

이렇게 사용하면 되는데 그리고 99%는 이렇게만 사용할텐데 아래같이 사용해도 가능하긴 하다.

@RequestMapping(value = "/mapping-get", method = RequestMethod.GET)
public String mappingGet() {
    log.debug("mapping GET");
    return "Mapping GET";
}

 

실제로 GET만 받아들이는지 Postman으로 테스트 해보는 것을 추천!

 

PathVariable

그리고 이제 중요한 것, PathVariable이다. 다음 코드를 보자.

@GetMapping("/mapping/{userId}")
public String mappingGet(@PathVariable("userId") String id) {
    log.debug("path variable userId: {}", id);
    return "ok";
}

URL 형식에 `{userId}` 이렇게 중괄호가 있으면 이게 PathVariable이다. 즉, URL로부터 특정 값을 userId라는 키로 받아온다는 의미가 된다. 그래서 만약 `/mapping/userA` 이렇게 요청했다고 하면 userIduserA가 된다. 그리고 파라미터 이름이 PathVariable과 같다면 다음과 같이 더 축약할 수 있다.

@GetMapping("/mapping/{userId}")
public String mappingGet(@PathVariable String userId) {
    log.debug("path variable userId: {}", userId);
    return "ok";
}

 

특정 파라미터, 헤더 조건 매핑

잘 사용되지는 않는데 이런게 가능하다.

@GetMapping(value = "/mapping-param", params = "mode=debug")
public String mappingParam() {
    log.debug("mapping param");
    return "ok";
}

이건 뭐냐면, queryString으로 mode=debug 라는 Key/Value가 반드시 있어야 동작하는 요청이 된다.

그래서 이렇게 `/mapping-param?mode=debug` 요청해야만 한다. 솔직히 잘 사용되지는 않는다.

 

만약 `/mapping-param`으로만 요청하면 400 Bad Request로 응답받는다. 

이걸 헤더로도 조건을 걸 수 있다. 이 또한 자주 사용되는 건 아니다.

@GetMapping(value = "/mapping-header", headers = "mode=debug")
public String mappingHeader() {
    log.debug("mapping header");
    return "ok";
}

이건 헤더에 Key/Value로 mode/debug가 들어있어야 함을 의미한다.

 

미디어 타입 조건 매핑

이건 조금 중요한데, Consume, Produce라는 게 있다. 

  • Consume - 요청 시 Content-Type을 지정한다.
  • Produce - 요청 시 Accept를 지정한다.

다음 코드를 보자.

@GetMapping(value = "/mapping-consume", consumes = MediaType.APPLICATION_JSON_VALUE)
public String mappingConsume() {
    log.debug("mapping consume");
    return "ok";
}

이런 요청 매핑이 있으면, 요청 시 반드시 Content-Type"application/json" 이어야 한다. 그니까 요청할 때 보내는 데이터의 타입을 JSON으로 지정하는 건데 그렇지 않으면 이 요청을 서버는 받아들이지 않겠다는 의미가 된다.

 

이번엔 Produce 관련 코드를 보자.

@GetMapping(value = "/mapping-produce", produces = MediaType.APPLICATION_JSON_VALUE)
public String mappingProduce() {
    log.debug("mapping produce");
    return "ok";
}

이런 요청 매핑이 있으면, 요청 시 반드시 Accept"application/json" 이어야 한다. 그니까 요청할 때 받는 응답 데이터의 타입을 JSON으로 지정하는 것이다. 그렇지 않으면 이 요청을 서버는 받아들이지 않겠다는 의미가 된다.

 

그래서 실제 API를 호출하고 만드는 어떤 관례(?)는 이런식으로 만들어진다.

  • 회원 목록 조회: GET `/users`
  • 회원 등록: POST `/users`
  • 회원 조회: GET `/users/{userId}`
  • 회원 수정: PATCH 또는 PUT `/users/{userId}`
  • 회원 삭제 DELETE `/users/{userId}`

MappingClassController

package net.cwchoiit.springmvc.requestmapping;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/mapping/users")
public class MappingClassController {

    @GetMapping
    public String user() {
        return "get users";
    }

    @PostMapping
    public String addUser() {
        return "post user";
    }

    @GetMapping("{userId}")
    public String findUser(@PathVariable String userId) {
        return "get user " + userId;
    }

    @PatchMapping("{userId}")
    public String updateUser(@PathVariable String userId) {
        return "patch user " + userId;
    }

    @DeleteMapping("{userId}")
    public String deleteUser(@PathVariable String userId) {
        return "delete user " + userId;
    }
}

 

깔끔한 요청 URL 방식이라고 볼 수 있고 대부분의 API는 이런 요청 URL를 따른다.

 

HTTP 요청 헤더 조회

이제 헤더 정보를 조회하는 방법을 알아보자. 굉장히 간단하고 편리하게 가져올 수 있다. 

 

RequestHeaderController

package net.cwchoiit.springmvc.request;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Locale;

@Slf4j
@RestController
public class RequestHeaderController {

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

        log.info("request = {}", request);
        log.info("response = {}", response);
        log.info("httpMethod = {}", httpMethod);
        log.info("locale = {}", locale);
        log.info("headers = {}", headers);
        log.info("host = {}", host);
        log.info("cookie = {}", cookie);
        return "Ok";
    }
}

 

위 코드를 보면 파라미터에서 굉장히 이것 저것 많이 받을 수 있게 되어 있다. HttpServletRequest, HttpServletResponse, HttpMethod, Locale, @RequestHeader, @RequestHeader("host"), @CookieValue까지.

 

@RequestHeaderMultiValueMap으로 가져오는 경우는 헤더 정보 전체를 가져오는 것이다. 근데 왜 MultiValueMap이냐? 그나저나 MultiValueMap은 뭘까? 이건 원래 Map은 키가 유일무이 해야 한다. 근데 헤더는 같은키로 여러 데이터가 들어올 수 있다. 그래서 같은 키라고 할지라도 그 값들 모두 다 가져올 수 있는 방법인 MultiValueMap을 사용한다. 

 

@RequestHeader("host") 이건 딱 host 정보 하나를 가져오는 것이다.

@CookieValue 이건 말 그대로 쿠키값을 가져온다.

 

실제로 실행해서 요청해보면 다음과 같은 결과를 받는다.

2024-07-12T14:13:50.285+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : request = org.apache.catalina.connector.RequestFacade@301c5b77
2024-07-12T14:13:50.286+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : response = org.springframework.web.context.request.async.StandardServletAsyncWebRequest$LifecycleHttpServletResponse@f7b8612b
2024-07-12T14:13:50.286+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : httpMethod = DELETE
2024-07-12T14:13:50.286+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : locale = en_US
2024-07-12T14:13:50.286+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : headers = {accept=[application/json], content-type=[application/json], user-agent=[PostmanRuntime/7.40.0], postman-token=[a466c2f2-6b13-49b0-8099-52a3aa66ab4b], host=[localhost:8080], accept-encoding=[gzip, deflate, br], connection=[keep-alive], content-length=[21]}
2024-07-12T14:13:50.286+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : host = localhost:8080
2024-07-12T14:13:50.286+09:00  INFO 3865 --- [springmvc] [nio-8080-exec-2] n.c.s.request.RequestHeaderController    : cookie = null

 

참고로, @Controller의 사용 가능한 파라미터 목록은 아래 공식 매뉴얼에서 모두 확인 가능하다.  
 

Annotated Controllers :: Spring Framework

Spring MVC provides an annotation-based programming model where @Controller and @RestController components use annotations to express request mappings, request input, exception handling, and more. Annotated controllers have flexible method signatures and d

docs.spring.io

 

HTTP 요청 파라미터 - 쿼리 파라미터, HTML Form

이번엔 HTTP 요청 시 전송하는 쿼리 파라미터, HTML Form으로 전송하는 데이터를 어떻게 받는지 알아볼 차례다.

우선 가장 기본적인 서블릿에서 어떻게 받는지 다시 상기시키자.

 

RequestParamController

package net.cwchoiit.springmvc.request;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;

@Slf4j
@RestController
public class RequestParamController {

    @RequestMapping("/request-param-v1")
    public void requestParamV1(HttpServletRequest request, HttpServletResponse response) throws IOException {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        log.info("username = {}, age = {}", username, age);

        response.getWriter().write("ok");
    }
}

서블릿은 이렇게 파라미터로 HttpServletRequest, HttpServletResponse 객체를 받을 수 있고, 그 객체를 통해 데이터를 받아올 수 있다. 테스트 해보면 아주 잘 받아온다.

HTTP 요청 - 쿼리 파라미터
받은 데이터 로그로 찍은 결과

 

근데 이렇게 전송할 때 전달하는 데이터의 생김새가 HTML Form이나 쿼리 파라미터나 동일하다.

`username=cwchoi&age=10` 둘 다 이렇게 들어오기 때문에 동일한 방식으로 데이터를 받을 수가 있다.

 

실제로 그런지 HTML Form을 만들어보자. 간단하게 `src/main/resources/static` 이 경로에 만들자. 이 경로는 스프링 부트는 기본이 다 외부로 내보내게 되어 있는 파일들만 모아놓은 곳이기 때문에 그냥 여기에 파일을 만들면 기본적으로 외부에서 접근이 가능하다.

 

`src/main/resources/static/basic/hello-form.html`

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/request-param-v1" method="post">
    username: <input type="text" name="username" />
    age:      <input type="text" name="age" />
    <button type="submit">전송</button>
</form>
</body>
</html>

 

이렇게 간단히 만들고 해당 경로에 가서 데이터를 전송해보자.

그럼 동일하게 데이터를 받아오는 것을 볼 수 있다.

 

그래서, HTML Form 방식이나 쿼리 파라미터나 같은 방법으로 받아올 수가 있다는 것을 다시 한번 상기했고, 서블릿은 이런식으로 데이터를 받을 수 있다는것도 상기했다. 이 방식을 스프링은 어떻게 더 편리하게 받을 수 있게 해줄까?

 

HTTP 요청 파라미터 - @RequestParam

스프링이 제공하는 이 애노테이션으로 아주 편리하게 데이터를 받아올 수 있다.

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

원하는 쿼리 파라미터 또는 HTML Form 데이터의 키를 @RequestParam("") 안에 적는다. 그럼 그 값을 찾아서 변수에 넣어준다.

 

근데, 만약 변수명과 쿼리 파라미터의 키가 같은 이름으로 된다면 생략 가능하다. 다음 코드처럼.

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

 

둘 다 아주 잘 받아온다. 그나저나, 저 @ResponseBody 애노테이션은 뭐냐면 만약 클래스 레벨에 @RestController가 아니라 @Controller라면 메서드가 String 타입으로 반환하는 메서드일때 기본적으로 뷰의 이름으로 판단하고 뷰를 찾는다.

 

그래서 스프링한테 알려주는 것이다. "이건 뷰의 이름이 아니라 너가 응답 메시지로 반환할 값이야!"라고. 

근데 귀찮게 애노테이션을 하나 더 붙이기가 싫다면 그냥 @RestController를 사용하면 된다.

 

다시 돌아와서, 사람의 욕심은 끝이 없어서 @RequestParam 마저도 생략할 수 있다. 다음 코드처럼.

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

물론 이 경우도 당연히 파라미터 이름과 쿼리 파라미터의 키가 동일한 이름이어야 한다.

근데, 나는 @RequestParam 애노테이션을 붙이는 걸 선호한다. 그래야 한 눈에 바로 파악이 쉽기 때문에.

 

그런데 이 쿼리 파라미터는 기본적으로 무조건 있어야 동작한다. 요청하는 쪽에서 보내지 않으면 에러 화면이 보일것이다. 근데 이 필수라는 옵션을 변경할 수도 있다.

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

이 코드처럼 "required = false"를 주게 되면 이 쿼리 파라미터를 요청 시 던지지 않아도 동작한다. 근데 주의할 점이 있다. `age`와 같이 Primitive Type인 경우 `null`이라는 값을 받을 수 없기 때문에 쿼리 파라미터를 주지 않으면 에러가 발생한다. 그래서 `age`가 필수값이 아니고 싶다면 Integer로 받아야 한다. 아래처럼.

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

 

또 한가지 주의할 점은 null""는 다르다.

"엥? 당연한거 아닌가요?" 당연한건데 URL에 `/request-param-required?username=` 이렇게 입력하면 null로 받는게 아니라 ""로 받아 온다. 그래서 이런 점도 염두하고 있어야 한다. 

 

그리고, 기본값을 설정할 수 있다. 다음 코드를 보자.

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

defaultValue로 기본값을 설정하면 null 처리에 대한 걱정없이 그냥 Primitive Type으로 파라미터를 선언할 수 있고, 사실상 `required = false` 를 입력할 필요도 없다. 없으면 그냥 기본값이 적용될테니까.

 

또한, 파라미터를 Map으로 받을수도 있다.

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

뭐 파라미터 하나하나 다 나열하기 귀찮다하면 이렇게 Map으로 받아오면 된다. 근데 난 개인적으로 좋아하지 않는다.

 

그리고 더 나아가서 MultiValueMap도 사용할 수 있다. 만약, 쿼리 파라미터의 키가 같은데 값이 여러개가 들어갈 수 있는 경우 MultiValueMap을 사용해야 한다. 이런 URL : `/request-param-map?userId=1&userId=2` 근데 이런 경우는 거의 없다.

@ResponseBody
@RequestMapping("/request-param-mulitivaluemap")
public String requestParamMultiValueMap(@RequestParam MultiValueMap<String, Object> paramMap) {
    List<Object> username = paramMap.get("username");
    log.info("username = {}", username.toString());
    return "ok";
}

 

이런식으로 요청 시 쿼리 파라미터와 HTML Form 데이터를 간단하게 받아올 수 있다. (아직 바디에 JSON으로 데이터 보내는 경우는 어떤식으로 처리하는지 작성 안했다) 그럼 이렇게 받아온 데이터를 실제 업무에서는 당연히 객체로 변환하고 사용할 것이다 일반적으로. 그런 경우에 어떻게 하면 될까? 스프링이 이것도 간단하게 도와준다. 

 

HTTP 요청 파라미터 - @ModelAttribute

실제 개발을 하면 요청 파라미터를 받아서 필요한 객체를 만들고 그 객체에 값을 넣어주어야 한다. 우선 DTO를 먼저 만들어보고 말해보자.

HelloData

package net.cwchoiit.springmvc.basic;

import lombok.*;

@Data
@AllArgsConstructor
public class HelloData {
    private String username;
    private int age;
}

 

가장 단순하고 원초적인 방법은 이렇게 사용할 것이다. 

@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@RequestParam String username, @RequestParam int age) {
    HelloData helloData = new HelloData(username, age);

    log.info("helloData = {}", helloData);

    return "ok";
}

@RequestParam을 통해 요청 파라미터를 받아서 DTO 객체를 만들어서 넣는다.

 

근데 스프링은 이 과정 자체를 자동화 해준다.

@ResponseBody
@RequestMapping("/model-attribute-v1-2")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {
    log.info("helloData = {}", helloData);

    return "ok";
}

이렇게 @ModelAttribute 라는 애노테이션으로 파라미터로 받는 데이터를 객체에 알아서 넣어준다.

그리고 더 나아가 @RequestParam처럼 이것도 생략이 가능하다.

@ResponseBody
@RequestMapping("/model-attribute-v2")
public String modelAttributeV2(HelloData helloData) {
    log.info("helloData = {}", helloData);

    return "ok";
}

 

그럼 헷갈릴 수 있다. 어떤게 @RequestParam을 생략한거고 어떤게 @ModelAttribute를 생략한 것인지. 스프링은 Primitive Type, Primitive TypeWrapper(String, Integer, ..) 클래스를 파라미터로 받을땐 @RequestParam을 생략한다고 간주한다. 그리고 직접 만든 객체처럼 HelloData이런 것들을 @ModelAttribute로 간주한다. 

주의할 점은, 요청에서 파라미터를 받았을 때 파라미터가 username, age라면 이 프로퍼티의 Setter를 먼저 찾는다. 만약 Setter가 없다면 final 필드로 된 전체 파라미터를 받는 생성자를 찾는다. 이 두가지가 다 없으면 바인딩 되지 않는다. 

 

또한, 위에서 말한것처럼 HelloData와 같은 직접 만든 객체를 @ModelAttribute로 간주한다 했는데 여기서 Argument Resolver는 제외이다. Argument Resolver라는 건 예를 들어 HttpServletRequest, HttpServletResponse 이런것들을 말한다. 

 

지금까지는 요청 파라미터를 받는 방법에 대해 알아보았다. 이제 파라미터가 아니라 메시지(요청 바디에 넣는 데이터)는 어떻게 처리하는지 알아보자.

 

HTTP 요청 메시지

메시지는 크게 세 가지로 받을 수 있다.

  • 단순 텍스트
  • JSON
  • XML

XML은 요즘은 거의 사용하는 추세가 아니기 때문에 따로 다루지 않는다. 그럼 요청 시 바디에 단순 텍스트 또는 JSON을 던져서 보낼 때 어떻게 받는지 하나씩 알아보자.

 

단순 텍스트

서블릿에서 했던것을 기억해보면 뭐 별게 없다. 다음 코드처럼 받을 수 있다.

@PostMapping("/request-body-string-v1")
public void requestBodyStringV1(HttpServletRequest req, HttpServletResponse res) throws IOException {

    ServletInputStream inputStream = req.getInputStream();
    String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

    log.info("messageBody: {}", messageBody);
    res.getWriter().write("ok");
}

HttpServletRequest, HttpServletResponse 객체를 파라미터로 받아서, InputStream을 얻어온다. 그리고 그 안에 있는 데이터를 String으로 받아오면 끝난다. 근데 스프링이 파라미터로 받을 수 있는 것들 중에 InputStreamWriter가 있다. 굳이 HttpServletRequest, HttpServletResponse 전체를 다 받을 필요 없이 딱 필요한것만 받는 방법이 된다.

@PostMapping("/request-body-string-v2")
public void requestBodyStringV1(InputStream inputStream, Writer writer) throws IOException {

    String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

    log.info("messageBody: {}", messageBody);
    writer.write("ok");
}

 

스프링이 파라미터로 받을 수 있는 것들은 이 공식 문서를 참고해보자.

 

Method Arguments :: Spring Framework

JDK 8’s java.util.Optional is supported as a method argument in combination with annotations that have a required attribute (for example, @RequestParam, @RequestHeader, and others) and is equivalent to required=false.

docs.spring.io

 

근데 당연히 여기서 끝날 스프링이 아니다. 그냥 이 과정 자체를 아예 자동화 해준다. 그래서 무엇을 받을 수 있냐? 다음과 같이 HttpEntity를 받을 수 있다.

@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) {

    String body = httpEntity.getBody();

    log.info("body: {}", body);
    return new HttpEntity<>("ok");
}

내가 HttpEntity<String> 이라고 선언을 하면 자동으로 바디에 있는 값을 String으로 변환해서 넣어준다.

그리고 반환도 마찬가지로 HttpEntity<String>이라고 반환을 하면 응답 메시지에 내가 넣을 데이터를 문자열로 받아 반환해준다.

 

그리고 이 HttpEntity를 상속받는 좀 더 구체적인 객체가 있다. 바로 RequestEntity, ResponseEntity

@PostMapping("/request-body-string-v3-2")
public ResponseEntity<String> requestBodyStringV3_2(RequestEntity<String> requestEntity) {
    String body = requestEntity.getBody();
    log.info("body: {}", body);

    return new ResponseEntity<>("ok", HttpStatus.CREATED);
}

ResponseEntity는 이렇게 상태 코드도 넣어줄 수 있다.

그러나, 애노테이션 기반이 대세로 자리잡은 지금 당연히 이것도 애노테이션이 있다.

@PostMapping("/request-body-string-v4")
public ResponseEntity<String> requestBodyStringV4(@RequestBody String messageBody) {
    log.info("body: {}", messageBody);

    return new ResponseEntity<>("ok", HttpStatus.CREATED);
}

이렇게 @RequestBody라고 해주면 끝난다. 알아서 요청 바디의 데이터를 내가 선언한 타입(String)으로 변환해서 넣어준다.

이 방식이 가장 많이 사용되고 실제로 편리한 방식이다.

참고로 @RequestBody@RequestParam, @ModelAttribute와는 아무런 관련이 없다. @RequestParam, @ModelAttribute는 요청 파라미터를 받아오는 방법들 중 하나이다. 반면, @RequestBody 요청 바디를 받아오는 방법이다. 절대 구분!

 

이렇게 요청 바디의 단순 메시지를 받아오는 방법을 알아봤다. 거의 95%는 JSON으로 데이터를 주고 받는다. 그래서 이제 알아볼 JSON을 받아오는 방법에 집중해보자!

 

JSON

우선, 가장 먼저 태초의 상태에서부터 점진적으로 세련된 코드(?)로 나아가보자.

당연히 가장 원초적인것은 서블릿에서 처리했던 방식이다.

 

RequestBodyJsonController

package net.cwchoiit.springmvc.request;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import net.cwchoiit.springmvc.basic.HelloData;
import org.springframework.http.HttpEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseBody;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Slf4j
@Controller
public class RequestBodyJsonController {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @PostMapping("/request-body-json-v1")
    public void requestBodyJsonV1(HttpServletRequest request, HttpServletResponse response) throws IOException {
        ServletInputStream inputStream = request.getInputStream();
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        log.info("messageBody: {}", messageBody);
        HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
        log.info("helloData: {}", helloData);

        response.getWriter().write("ok");
    }
}

 

우선 단순 텍스트를 어떤 특정 객체로 변환하기 위해 사용되는 라이브러리인 ObjectMapper를 새로 만들자. 당연히 이 ObjectMapper는 실제 개발에서는 여러 설정이 곁들어진 상태로 빈으로 등록해서 여기저기서 주입되는 방식으로 사용될테지만 지금은 그런 경우는 아니니까.

 

그리고 서블릿에서 했던것처럼 HttpServletRequest, HttpServletResponse 객체를 받아서 InputStream을 가져와서 스트링으로 변환한다. 그리고 변환된 문자열을 ObjectMapper를 통해 객체로 변환한다. 

 

그 다음은 @RequestBody 애노테이션을 사용해서 좀 더 편리하게 문자열로 가져오는 방법이다.

@ResponseBody
@PostMapping("/request-body-json-v2")
public String requestBodyJsonV2(@RequestBody String messageBody) throws IOException {

    log.info("messageBody: {}", messageBody);
    HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
    log.info("helloData: {}", helloData);

    return "ok";
}

 

근데 굳이 @RequestBody를 문자열로 받지 않아도 될 것 같다. 바로 그 객체에 담아버릴 수 없을까? @ModelAttribute처럼? 당연히 된다. 다음 코드를 보자.

@ResponseBody
@PostMapping("/request-body-json-v3")
public String requestBodyJsonV3(@RequestBody HelloData helloData) {
    log.info("helloData: {}", helloData);

    return "ok";
}

 

이게 이제 가장 최신 방식이고 편리한 방식이다. 그리고 주의할 은 이 @RequestBody를 통해서 요청 바디의 데이터를 특정 객체로 변환할 때는 @RequestBody를 생략할 수 없다! 왜냐하면 이미 @ModelAttribute에서 생략 가능하기 때문에 @RequestBody를 생략해버리면 @ModelAttribute처럼 동작하게 된다. 즉, 파라미터에서 데이터를 찾게 된다는 말이다. 그래서 안된다!

참고로, 이렇게 @RequestBody 애노테이션으로 JSON을 특정 객체로 변환해 주려면 반드시 요청 헤더에 Content-Type`application/json`이어야 한다. 이래야만 이후에 다룰 HTTP 메시지 컨버터가 "아 이 값이 지금 JSON이고 이런 객체로 변환하길 원하는구나!?"로 이해하고 바꿔주기 때문이다.

 

그리고 HttpEntity로도 받을 수 있다. 단순 텍스트가 가능했듯 이 특정 객체도 HttpEntity를 사용해 받아올 수 있다.

@ResponseBody
@PostMapping("/request-body-json-v2-1")
public String requestBodyJsonV2_1(HttpEntity<HelloData> httpEntity) {
    HelloData body = httpEntity.getBody();
    log.info("helloData: {}", body);

    return "ok";
}

근데 그러려면 이렇게 getBody()를 호출해야 하는 번거로움 때문에 거의 사용하지 않는다.

 

그래서 요청 바디에 JSON을 태울 때 어떻게 받아오는지도 알아보았다. 결국 단순 텍스트JSON이든 @RequestBody를 통해 깔끔하고 쉽게 받아올 수가 있다. 이제 요청 관련 처리를 쭉 알아봤으니 응답 관련 처리를 쭉 알아보자! 

 

HTTP 응답 - 정적 리소스, 뷰 템플릿

스프링에서 응답 데이터를 만드는 방법은 크게 세가지이다. 

  • 정적 리소스 예) 웹 브라우저에 정적인 HTML, CSS, JS를 제공할 때는 정적 리소스를 사용한다.
  • 뷰 템플릿 예) 웹 브라우저에 동적인 HTML을 제공할 때는 뷰 템플릿을 사용한다.
  • HTTP 메시지 예) HTTP API를 제공하는 경우에는 HTML이 아니라 데이터를 전달해야 하므로, HTTP 메시지 바디에 JSON과 같은 형식으로 데이터를 실어 보낸다

정적 리소스

스프링 부트는 클래스패스의 다음 디렉토리에 있는 정적 리소스를 제공한다.

`/static`, `/public`, `/resources`, `/META-INF/resources`

`src/main/resources`는 리소스를 보관하는 곳이고, 또 `classpath`의 시작 경로이다.

 

그러므로 이 디렉토리에 리소스를 넣어두면 스프링 부트가 정적 리소스로 서비스를 제공한다.

정적 리소스 경로 : `src/main/resources/static` 

이 경로에 파일이 다음과 같이 들어있으면 `src/main/resources/static/basic/hello-form.html`

웹 브라우저에서 다음과 같이 실행하면 된다. `http://localhost:8080/basic/hello-form.html`

 

정적 리소스는 해당 파일을 변경 없이 그대로 서비스하는 것이다.

뷰 템플릿

뷰 템플릿을 거쳐서 HTML이 생성되고, 뷰가 응답을 만들어서 전달한다.

일반적으로 HTML을 동적으로 생성하는 용도로 사용하지만, 다른 것들도 가능하다. 뷰 템플릿이 만들 수 있는 것이라면 뭐든지 가능하다.

 

스프링 부트는 기본 뷰 템플릿 경로를 제공하고 그 경로는 다음과 같다.

`src/main/resources/templates`

 

뷰 템플릿을 생성해보자. 여기서 뷰 템플릿은 Thymeleaf를 사용한다. 

`src/main/resources/templates/response/hello.html`

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<p th:text="${data}">empty</p>
</body>
</html>

 

뷰 템플릿을 호출하는 컨트롤러

ResponseViewController

package net.cwchoiit.springmvc.response;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

@Controller
public class ResponseViewController {

    @RequestMapping("/response-view-v1")
    public ModelAndView responseViewV1() {
        ModelAndView mv = new ModelAndView("response/hello");

        mv.addObject("data", "Hello World");
        return mv;
    }

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

    /**
     * 권장하지 않음
     * @param model
     */
    @RequestMapping("/response/hello")
    public void responseViewV3(Model model) {
        model.addAttribute("data", "Hello V3");
    }
}

 

  • ModelAndView를 반환하는 경우 해당 객체에 뷰 논리 이름이 들어가 있고 데이터도 같이 들어가 있다. 
  • 단순 문자열을 반환하는 경우 반환값이 곧 뷰 논리 이름이 된다. 그리고 파라미터로 Model 객체를 받아 모델에 데이터를 추가하면 뷰 템플릿에서 모델에 넣은 키와 일치하는 부분에 알아서 치환이 된다.
  • 세번째 방법은 권장하지 않는다. 반환값이 없는 경우 만약 @RequestMapping("")에 넣은 경로가 templates 경로와 일치하는 경우 그 뷰를 보여주게 되는데 명시성이 너무 떨어지기 때문에 사용하지 않는걸 권장.
참고로, Thymeleaf 스프링 부트 설정은 Thymeleaf 라이브러리를 추가하면 자동으로 스프링 부트에서 해준다. 스프링 부트가 ThymeleafViewResolver와 필요한 스프링 빈들을 등록해준다. 그리고 application.yml 파일에 자동으로 prefix, suffix를 등록해준다.

 

application.properties

spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html

 

물론 실제로 이 파일을 열었을 때 저 두줄이 보이는게 아니라 자동으로 등록해준다는 의미이다. 그리고 저 값을 변경하고 싶을때만 이 파일을 수정하면 된다. 여기서 `classpath``src/main/resources`를 말한다.

 

아마 Thymeleaf와 스프링 부트를 사용하는 것은 이후에도 해 볼 것이기 때문에 이 정도로만 하고 가장 중요한(?) HTTP API에 대한 응답 처리를 알아보자!

 

HTTP 응답 - HTTP API

HTTP API를 제공하는 경우에는 HTML이 아니라 데이터를 전달해야 하므로, HTTP 메시지 바디에 JSON과 같은 형식으로 데이터를 실어 보낸다. 

참고로, HTML이나 뷰 템플릿을 사용해도 HTTP 응답 메시지 바디에 HTML 데이터가 담겨서 전달된다. 여기서 설명하는 내용은 정적 리소스나 뷰 템플릿을 거치지 않고, 직접 HTTP 응답 메시지를 전달하는 경우를 말한다.

 

말로 장황하게 설명할 것 없이 바로 코드로 들어가자.

ResponseBodyController

package net.cwchoiit.springmvc.response;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import net.cwchoiit.springmvc.basic.HelloData;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import java.io.IOException;

@Slf4j
@Controller
public class ResponseBodyController {

    @GetMapping("/response-body-string-v1")
    public void responseBodyStringV1(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.getWriter().write("ok");
    }

    @GetMapping("/response-body-string-v2")
    public ResponseEntity<String> responseBodyStringV2() {
        return new ResponseEntity<>("ok", HttpStatus.OK);
    }

    @ResponseBody
    @GetMapping("/response-body-string-v3")
    public String responseBodyStringV3() {
        return "ok";
    }

    @GetMapping("/response-body-json-v1")
    public ResponseEntity<HelloData> responseBodyJsonV1() {
        HelloData helloData = new HelloData();
        helloData.setUsername("A");
        helloData.setAge(30);

        return new ResponseEntity<>(helloData, HttpStatus.OK);
    }

    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    @GetMapping("/response-body-json-v2")
    public HelloData responseBodyJsonV2() {
        HelloData helloData = new HelloData();
        helloData.setUsername("B");
        helloData.setAge(30);
        return helloData;
    }
}

 

단순 스트링의 경우

  • 첫번째 메서드의 경우, 간단하게 서블릿 방식을 사용해서 파라미터로 들어온 HttpServletResponse 객체의 Writer를 사용해서 응답 메시지를 보낸다.
  • 두번째 메서드의 경우, ResponseEntity를 반환타입으로 주면 알아서 HTTP 응답 바디에 메시지를 넣어 보내고 상태 코드도 설정할 수 있다.
  • 세번째 메서드의 경우, 그냥 단순 String을 반환하는 경우 뷰 논리 이름이 기본이지만 @ResponseBody 애노테이션이 붙었기 때문에 반환값 자체가 응답 바디에 넣어지는 값이 된다.

JSON의 경우

  • 네번째 메서드의 경우, 반환값으로 ResponseEntity<HelloData>로 지정했다. 이 말은 응답 메시지에 HelloData 타입의 데이터를 실어 내보내겠다는 의미가 되고 상태 코드도 지정할 수 있게 된다.
  • 다섯번째 메서드의 경우, 반환값이 HelloData이다. 이 경우도 이 HelloData 객체가 응답 메시지에 들어간다. 그러나 이 경우 상태 코드를 지정하지 못하기 때문에 @ResponseStatus(HttpStatus.OK) 애노테이션을 사용해서 상태코드를 추가해준다.

 

근데 이렇게 계속 @ResponseBody 애노테이션을 붙이기가 상당히 귀찮다. 그리고 실제로 개발을 해보면 이렇게 사용하지도 않는다.

@ResponseBody + @Controller가 합쳐진 애노테이션이 바로 @RestController이다.

 

그래서 @RestController가 클래스 레벨에 붙고, 단순 String을 반환하는 메서드가 있다면 이 메서드는 무조건 응답 바디에 반환값을 넣는다는 의미다. 뷰를 찾는게 아니라.

ResponseBodyController - @RestController 사용

package net.cwchoiit.springmvc.response;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import net.cwchoiit.springmvc.basic.HelloData;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;

@Slf4j
@RestController
public class ResponseBodyController {

    @GetMapping("/response-body-string-v1")
    public void responseBodyStringV1(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.getWriter().write("ok");
    }

    @GetMapping("/response-body-string-v2")
    public ResponseEntity<String> responseBodyStringV2() {
        return new ResponseEntity<>("ok", HttpStatus.OK);
    }

    @GetMapping("/response-body-string-v3")
    public String responseBodyStringV3() {
        return "ok";
    }

    @GetMapping("/response-body-json-v1")
    public ResponseEntity<HelloData> responseBodyJsonV1() {
        HelloData helloData = new HelloData();
        helloData.setUsername("A");
        helloData.setAge(30);

        return new ResponseEntity<>(helloData, HttpStatus.OK);
    }

    @ResponseStatus(HttpStatus.OK)
    @GetMapping("/response-body-json-v2")
    public HelloData responseBodyJsonV2() {
        HelloData helloData = new HelloData();
        helloData.setUsername("B");
        helloData.setAge(30);
        return helloData;
    }
}

 

 

HTTP 메시지 컨버터

이제 HTML을 생성해서 응답하는게 아니라 HTTP API처럼 JSON 데이터를 HTTP 메시지 바디에서 직접 읽거나 쓰는 경우 그래서 그것을 반환할 때 HTTP 메시지 컨버터를 사용하면 편리하다. 원래는 어떻게 했나? 원래는 서블릿 방식으로 HttpServletResponse 객체를 받아와서 response.getWriter().write("OK"); 이런식으로 Writer를 가져왔었다. 이건 쓰기 방식이고 읽을땐? HttpServletRequest 객체를 받아와서 InputStream을 받아와서 데이터를 읽어왔다. 매우매우 불편하다.

 

스프링 MVC는 다음의 경우에 HTTP 메시지 컨버터를 적용한다.

  • HTTP 요청: @RequestBody, HttpEntity(RequestEntity)
  • HTTP 응답: @ResponseBody, HttpEntity(ResponseEntity)

HTTP 메시지 컨버터는 다음과 같이 생겼다.

HttpMessageConverter

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.http.converter;

import java.io.IOException;
import java.util.Collections;
import java.util.List;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;

public interface HttpMessageConverter<T> {
    boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);

    boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);

    List<MediaType> getSupportedMediaTypes();

    default List<MediaType> getSupportedMediaTypes(Class<?> clazz) {
        return !this.canRead(clazz, (MediaType)null) && !this.canWrite(clazz, (MediaType)null) ? Collections.emptyList() : this.getSupportedMediaTypes();
    }

    T read(Class<? extends T> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException;

    void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException;
}

 

  • canRead(), canWrite(): 메시지 컨버터가 해당 클래스, 미디어타입을 지원하는지 체크
  • read(), write(): 메시지 컨버터를 통해서 메시지를 읽고 쓰는 기능

스프링 부트 기본 메시지 컨버터는 다음과 같다.

(일부 생략)

0 = ByteArrayHttpMessageConverter
1 = StringHttpMessageConverter
2 = MappingJackson2HttpMessageConverter

 

스프링 부트는 다양한 메시지 컨버터를 제공하는데, 대상 클래스 타입과 미디어 타입 둘을 체크해서 사용여부를 결정한다. 만약 만족하지 않으면 다음 메시지 컨버터로 우선순위가 넘어간다.

 

몇가지 주요한 메시지 컨버터를 알아보자.

  • ByteArrayHttpMessageConverter: byte[] 데이터를 처리한다.
    • 클래스 타입: byte[], 미디어타입: */*
    • 요청 예) @RequestBody byte[] data
    • 응답 예) @ResponseBody, return byte[]; 쓰기 미디어 타입 application/octet-stream
  • StringHttpMessageConverter: String 문자로 데이터를 처리한다.
    • 클래스 타입: String, 미디어타입: */*
    • 요청 예) @RequestBody String data
    • 응답 예) @ResponseBody, return "ok"; 쓰기 미디어 타입 text/plain
  • MappingJackson2HttpMessageConverter: JSON 관련 데이터 처리
    • 클래스 타입: 객체 또는 HashMap, 미디어 타입: application/json
    • 요청 예) @RequestBody HelloData data
    • 응답 예) @ResponseBody, return data; 쓰기 미디어타입: application/json

 

그러니까, 쉽게 생각해서 이전에 요청 바디의 메시지를 가져올땐 HttpServletRequest 객체를 파라미터로 받아서 객체의 InputStream을 가져왔다. InputStream으로부터 데이터를 꺼내오는 작업을 했었는데 이 작업의 자동화를 해준 애노테이션이 @RequestBody 애노테이션이었다. 그럼 이 @RequestBody 애노테이션이 어떻게 이 작업을 해주는 것인가?에 대한 비밀이 이 HttpMessageConverter가 되는것이다. 

 

HttpMessageConverter의 동작 흐름

그래서 만약 다음 코드가 있다고 했을때,

@ResponseBody
@PostMapping("/request-body-json-v3")
public String requestBodyJsonV3(@RequestBody HelloData helloData) {
    log.info("helloData: {}", helloData);

    return "ok";
}

 

스프링 부트의 HttpMessageConverter는 우선 컨버터 우선순위에 입각해서 하나씩 물어본다. 기본적으로 아래가 우선순위라고 생각하면 된다. 물론 더 많은 컨버터가 있지만 중요한것만 위주로 보자.

0 = ByteArrayHttpMessageConverter
1 = StringHttpMessageConverter
2 = MappingJackson2HttpMessageConverter

첫번째로, ByteArrayHttpMessageConverter에게 읽을 수 있는 데이터인지 물어본다. (canRead())

ByteArrayHttpMessageConverter 이 녀석은 클래스 타입이 byte[]여야 하고, 미디어 타입은 */*여야 한다. 클래스 타입이 HelloData이기 때문에 넘어가게 된다.

두번째로, StringHttpMessageConverter에게 읽을 수 있는 데이터인지 물어본다. (canRead())

StringHttpMessageConverter 이 녀석은 클래스 타입이 String 이어야 하고 미디어 타입은 */*여야 한다. 클래스 타입이 HelloData이기 때문에 넘어가게 된다.

세번째로, MappingJackson2HttpMessageConverter에게 읽을 수 있는 데이터인지 물어본다. (canRead())

MappingJackson2HttpMessageConverter 이 녀석은 클래스 타입이 객체 또는 HashMap 이어야 하고, 미디어 타입이 application/json이어야 한다. 우선, HelloData는 객체라서 조건을 만족하고, 요청 시 Content-Typeapplication/json으로 보냈기 때문에 미디어타입도 만족한다. 그럼 이 MappingJackson2HttpMessageConverter 녀석이 이 데이터를 가지고 HelloData 객체의 각 프로퍼티에 데이터를 바인딩 해주게 된다. 이게 바로 HTTP 메시지 컨버터가 하는 일이다. 

 

요청과 일치하게 응답도 동일하다. 만약 다음 코드가 있다고 했을 때,

@ResponseStatus(HttpStatus.OK)
@GetMapping("/response-body-json-v2")
public HelloData responseBodyJsonV2() {
    HelloData helloData = new HelloData();
    helloData.setUsername("B");
    helloData.setAge(30);
    return helloData;
}

반환값이 HelloData로 되어 있다. 그리고 이 메서드를 가진 클래스는 @RestController 애노테이션을 달았다. 그럼 메시지 컨버터가 동작하고, 클래스 타입이 HelloData라는 객체이고 미디어 타입은 요청 시 Acceptapplication/json으로 던졌다고 가정하면 클래스 타입과 미디어 타입을 모두 만족하는 MappingJackson2HttpMessageConverter 녀석이 동작하여 응답 시 알아서 잘 해주게 되는 것이다. 참고로 여기서 컨버터들에게 너 이거 쓸 수 있어? 라고 물어보는 메서드는 canWrite() 메서드가 되겠지!

 

MappingJackson2HttpMessageConverter 이 녀석 내부적으로 Writer를 가져와서 write()를 하겠지만 우리가 직접 하지 않아도 되니 얼마나 편한가? 

 

그럼 HTTPMessageConverter가 뭐하는건지 이해를 했다. 그럼 스프링 MVC의 어디쯤에 존재하고 사용되는 것일까?

 

요청 매핑 핸들러 어댑터(RequestMappingHandlerAdapter) 구조

그래서 다시 위의 원초적인 질문으로 돌아와서 그렇다면 HTTP 메시지 컨버터는 스프링 MVC 어디쯤에서 사용되는 것일까?

이전에 봤던 이 그림을 다시 보자.

이 그림에서 HTTP 메시지 컨버터는 보이지 않는다. 그럼 어디에?

애노테이션 기반의 컨트롤러, 그러니까 @RequestMapping을 처리하는 핸들러 어댑터인 RequestMappingHandlerAdapter(요청 매핑 핸들러 어댑터)에 있다.

 

한번 이 핸들러 어댑터의 동작 방식을 다시 한번 상기해보자.

  • 1. 외부로부터 요청이 들어온다.
  • 2. 이 요청과 매핑된 핸들러(컨트롤러)를 찾는다.
  • 3. 찾은 핸들러를 처리할 수 있는 핸들러 어댑터를 찾는다
  • 4. 찾은 핸들러 어댑터는 RequestMappingHandlerAdapter가 된다. 이 핸들러 어댑터가 본인이 가지고 있는 handle()을 호출해서 실제로 핸들러가 실행된다.
  • 5. 실행된 핸들러의 결과를 반환 받은 핸들러 어댑터는 뷰를 반환해야 하면 뷰 리졸버를 호출하고, 그대로 응답 메시지에 반환해야 하면 그 작업을 또 하게 된다.

저기서 4번이 중요하다. 생각해보면 컨트롤러에 엄청 많은 Arguments를 받을 수 있었다. 대표적인 예로,

  • HttpServletRequest, HttpServletResponse
  • @ModelAttribute
  • Model
  • @RequestParam
  • @RequestBody
  • 등등

그럼 결국 이 arguments들에 실제 어떤 값이 담겨서 컨트롤러를 호출하는 handle()을 호출해야 한다. 그 값들이 채워지는 시점은 바로 4번이다. 그래서 다음 그림을 보자.

핸들러 어댑터가 필요한 Arguments들을 보고 이 Arguments들을 처리할 수 있는 ArgumentResolver를 호출한다.

호출해서 "너가 이 Argument 처리할 수 있어?"라고 물어보고 처리할 수 있는 녀석이 해당 Argument를 처리해서 실제 값을 담아주게 된다. 값을 그렇게 하나씩 차곡차곡 담아서 핸들러 어댑터가 모든 Arguments들이 완성이 되면 그제서야 핸들러를 호출하게 되는것이다.

 

참고로, ArgumentResolver의 정확한 명칭은 HandlerMethodArgumentResolver이고, 생김새는 이렇게 생겼다.

HandlerMethodArgumentResolver

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.web.method.support;

import org.springframework.core.MethodParameter;
import org.springframework.lang.Nullable;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;

public interface HandlerMethodArgumentResolver {
    boolean supportsParameter(MethodParameter parameter);

    @Nullable
    Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;
}

그래서 딱 봐도 supportsParameter()를 호출해서 이 Argument를 처리할 수 있는 구현체를 찾을 것이다. 참고로 구현체 겁나 많다..

그래서 찾았다면 resolveArgument를 호출해서 실제 값을 객체에 차곡차곡 담아준다.

 

그리고 이렇게 다 생성된 객체를 핸들러 어댑터가 받아서 실제 핸들러를 호출하게 되는것이다.

그리고 스프링은 이렇게 모든게 인터페이스 - 구현체 구조로 되어 있기 때문에 확장에 너무나 용이하다. OCP 원칙을 고수하고 있다는 의미이고 새로운 기술을 도입해도 클라이언트 코드는 변경에 영향을 받지 않는다. 그리고 그 말은! 내가 원하는 나만의 Argument를 받을 수 있게 나만의 ArgumentResolver를 만들수도 있다는 얘기다.

 

"아니 그래서 HTTP 메시지 컨버터는 어디 있는데요!?"

맞다. 아직도 그래서 이 HTTP 메시지 컨버터의 비밀을 밝혀지지 않았다. 이 메시지 컨버터가 사용되는 지점은 바로 ArgumentResolver가 사용한다. 당연히 그렇겠지? 왜냐하면 @RequestBody는 파라미터로 들어오니까 그 파라미터를 처리할 수 있는 ArgumentResolver가 있고 거기서 사용될것이다. 다음 그림을 보자.

저렇게 ArgumentResolver가 HTTP 메시지 컨버터를 사용한다. 그리고 핸들러가 결국 응답을 하게 되는데 그 응답도 또한 인터페이스 - 구현체 구조로 되어 있다. 그 때 인터페이스는 ReturnValueHandler라는 녀석인데 뷰를 다루는 핸들러가 아니라 응답 데이터를 HTTP 메시지에 입력해야 하는 핸들러는 이 인터페이스를 구현한 구현체를 핸들러 어댑터에게 응답한다. 그리고 이 ReturnValueHandler가 또 HTTP 메시지 컨버터를 사용한다. HTTP 메시지 컨버터는 요청과 응답 둘 다 사용된다고 말했던 바 있다.

 

좀 더 깊이 들어가보자.

ArgumentResolver는 요청의 경우, @RequestBody를 처리하는 ArgumentResolver가 있고 HttpEnttiy를 처리하는 ArgumentResolver가 있다. (파라미터로 @RequestBody, HttpEntity를 둘 다 받을 수 있었다. 기억해보자!) 그래서 둘 중에 어떤게 사용됐는지 확인하여 적절한 ArgumentResolver가 선택되면 거기서 HTTP 메시지 컨버터가 실행된다.

 

응답의 경우도 동일하게 @ResponseBodyHttpEntity를 처리하는 ReturnValueHandler가 있다. 그리고 이 ReturnValueHandler 녀석이 HTTP 메시지 컨버터를 사용하는 것이다.

 

그래서 정말 신기하고 새로웠던 @RequestBody로 데이터를 자동으로 바인딩 해주고 @ResponseBody로 반환값을 자동으로 반환해주는 비밀의 열쇠인 HttpMessageConverter에 대해 알아봤다.

 

결론

여기까지 하면 스프링 MVC의 핵심 구조들은 다 이해해 본 것이다. 이젠 이걸 활용하고 사용하는 것만이 남았다.

잘 사용해보자!

 

 

728x90
반응형
LIST

+ Recent posts