Spring/MVC

[Spring MVC] 06. 스프링 MVC - 기본 기능

lumana 2024. 11. 24. 22:37

 

06. 스프링 MVC - 기본 기능

#Spring/MVC


정리


로깅

운영 서버에는 System.out.println()을 사용하지 않고, 로깅 라이브러리를 사용해 로그를 출력해야 한다.


SLF4J

로그 라이브러리는 Logback, Log4J, Log4J2 등등 수많은 라이브러리가 있는데, 그것을 통합해서 인터페이스로 제공한다.


로그 선언

private Logger log = LoggerFactory.getLogger(getClass());

@Slf4j 롬복에서 사용할 수 있다.


로그 사용 예시

@Slf4j
@RestController
public class LogTestController {
 @RequestMapping("/log-test")
 public String logTest() {
     String name = "Spring";

     log.trace("trace log={}", name);
     log.debug("debug log={}", name);
     log.info(" info log={}", name);
     log.warn(" warn log={}", name);
     log.error("error log={}", name);
     // 로그를 사용하지 않아도 a+b 계산 로직이 먼저 실행됨, 이런 방식으로 사용하면 X
     log.debug("String concat log=" + name);
     return "ok";
 }
}

로그 레벨: log.xxx trace? debug? info? warn? error?

  • TRACE > DEBUG > INFO > WARN > ERROR
    • TRACE에 가까울수록 더 많은 로그를 출력해준다.
    • 개발 서버에는 debug로 사용하고, 운영 서버는 info를 사용해야 한다.

운영 서버에 만약 sout이 있다면, 로그 폭탄을 맞는다. 고객 요청에 다 남는다. 중요한 정보만 남기기 위해 log.info를 사용한다.



application.properties

# 전체 로그 레벨 설정(기본 info)
logging.level.root=info
# hello.springmvc 패키지와 그 하위 로그 레벨 설정
logging.level.hello.springmvc=debug

아무것도 안 적으면 디폴트가 info다. 그래서 info 레벨부터 찍힌다. 만약 루트를 debug로 설정하면 엄청난 로그가 쏟아져 나온다. 기본은 info로 두고, 원하는 부분만 조금씩 debug로 바꾸자.


절대 이렇게는 사용하지 말자

// 잘못된 사용법
log.debug("data="+data)

만약 log 레벨을 info로 설정하면, 위 debug 문구는 실제로 출력되지 않는다. 하지만 해당 코드에 있는 "data="+data는 실행된다. 문자열 더하기 연산이 실행되는 것이다. 리소스가 낭비된다.


// 올바른 사용법
log.debug("data={}", data)

의미없는 연산이 발생하지 않도록 하자.


로그 사용 장점

  • 쓰레드 정보, 클래스 이름 같은 부가 정보를 같이 볼 수 있고, 출력 형태를 커스터마이징 할 수 있다.
  • 로그 레벨을 필요에 따라 조절할 수 있다. (운영서버에는 ~~로그만 출력, 개발 서버에는 ~~로그도 출력…)
  • 콘솔에만 출력하는 것이 아니라, 파일이나 네트워크 등, 로그를 별도의 위치에 남길 수 있다.
    • 용량별 파일 분할 등 여러 기능을 제공한다.
  • Sout보다 성능도 더 좋다

요청 매핑 애노테이션
@RestController

  • 기본적으로 @Controller는 반환 값이 String이면 뷰 이름으로 인식하여 뷰를 찾고 뷰가 랜더링된다.
  • 하지만 @RestController는 반환 값으로 뷰를 찾지 않고 바로 HTTP 바디에 입력한다.

@RequestMapping("/hello-basic")

  • /hello-basic URL 호출이 오면 이 메서드가 실행되도록 매핑한다.
  • 대부분의 속성을 배열 [] 로 제공하므로 다중 설정이 가능하다. {"/hello-basic", "/hello-go"}
  • method 속성으로 HTTP 메서드를 지정하지 않으면 HTTP 메서드와 무관하게 호출된다
    • method = RequestMethod.GET 이런식으로 지정할 수 있다.

주의 | 스프링 부트 3.0부터 /hello-basic, /hello-basic/는 서로 다른 URL로 인식한다. (마지막 /를 제거해주지 않는다.)


메서드 매핑 축약

  • @GetMapping
  • @PostMapping
  • @PutMapping
  • @DeleteMapping
  • @PatchMapping

PathVariable(경로 변수)

애노테이션에 등록한 템플릿 경로의 URL을 파라미터에 매핑시킨다.


기본적인 사용법은 아래과 같다.

@GetMapping("/mapping/{userId}")
public String mappingPath(@PathVariable("userId") String data) {
//...

URL 경로에서 중괄호 {}로 감싸인 경로 변수(예: {userId} 및 {orderId})를 메서드의 매개변수로 연결합니다.


참고 | 작동 원리

  1. 요청이 들어올 때, Spring은 요청 URL을 애노테이션에 등록한 템플릿 경로와 비교하여 변수 부분을 추출한다.
  2. 메서드의 매개변수에 @PathVariable이 선언되어 있는지 확인한다.
  3. ConversionService를 사용해 매개변수 타입(예: Long, Integer, 사용자 정의 클래스 등)으로 변환
  4. 값을 주입

최근 HTTP API는 다음과 같이 리소스 경로에 식별자를 넣는 스타일을 선호한다.

  • /mapping/userA
  • /users/1

@RequestMapping은 URL 경로를 템플릿화 할 수 있는데, @PathVariable을 사용하면 매칭 되는 부분을 편리하게 조회할 수 있다.

  • @PathVariable의 이름과 파라미터 이름이 같으면 생략할 수 있다.

예시: PathVariable 경로 변수 생략

@GetMapping("/mapping/{userId}")
public String mappingPath(@PathVariable String userId) {

주의! @PathVariable 자체를 생략할 수는 없다.


PathVariable : 다중 경로 변수
경로 변수가 URL에 여러 개 있어도 파라미터에 매핑할 수 있다.

@GetMapping("/mapping/users/{userId}/orders/{orderId}")
public String mappingPath(@PathVariable String userId, @PathVariable Long orderId)

특정 파라미터 조건 매핑
특정 파라미터(key)가 있거나(+value 일치) 없는 조건을 추가할 수 있다. 잘 사용하지는 않는다.

@GetMapping(value = "/mapping-param", params = "mode=debug")
public String mappingParam() {

이 컨트롤러는 HTTP 요청 파라미터에 mode=debug가 있어야 호출된다.


  • params="mode",
  • params="!mode"
  • params="mode=debug"
  • params="mode!=debug" (! = )
  • params = {"mode=debug","data=good"}

특정 헤더 조건 매핑

@GetMapping(value = "/mapping-header", headers = "mode=debug")
public String mappingHeader() {

파라미터 매핑과 비슷하지만, HTTP 헤더를 사용한다.

  • headers="mode",
  • headers="!mode"
  • headers="mode=debug"
  • headers="mode!=debug" (! = )

미디어 타입 조건 매핑 - HTTP 요청 Content-Type, consume
서버 입장에서는 클라이언트가 보낸 Content를 사용한다. consume이라고 표현할 수 있다.
서버가 consume할 Media Type을 Content-Type 헤더 기반으로 추가 매핑할 수 있다.


@PostMapping(value = "/mapping-consume", consumes = "application/json")
public String mappingConsumes() {

위 예시에서는 HTTP 요청 메시지의 Content-Type 헤더 값이 application/json이 아니라면 HTTP 415 상태코드(Unsupported Media Type)을 반환한다.


  • consumes="application/json"
  • consumes="!application/json"
  • consumes="application/*"
  • consumes=“*/*”

또한, Consume할 미디어 타입을 여러 개 설정할 수 있다. 문자열 대신 constant도 미리 만들어져 있다.

consumes = "text/plain"
consumes = {"text/plain", "application/*"}
consumes = MediaType.TEXT_PLAIN_VALUE


미디어 타입 조건 매핑 - HTTP 요청 Accept, produce
서버는 consume할 Content를 클라이언트로 받아서 새로운 Content를 HTTP 응답 메시지로 내려준다. 클라이언트는 서버로 요청을 보낼 때 응답 Content로 Accept할 수 있는 Content-Type을 지정해서 요청 메시지를 보낼 수 있다. 즉, 서버가 Produce할 Content-Type과 관련되어 있다. 이를 매핑할 수 있다.


@PostMapping(value = "/mapping-produce", produces = "text/html")
public String mappingProduces() {

HTTP 요청의 Accept 헤더를 기반으로 미디어 타입으로 매핑한다.
만약 위 예시에서 클라이언트가 Accept 헤더에 application/json을 넣으면, text/html을 반환해주는 컨트롤러에서 406 에러를 반환한다. (Accept 헤더는 보통 */*이다)


  • produces = "text/html"
  • produces = "!text/html"
  • produces = "text/*"
  • produces = "/"

produces = "text/plain"
produces = {"text/plain", "application/*"}
produces = MediaType.TEXT_PLAIN_VALUE
produces = "text/plain;charset=UTF-8"

API 요청 매핑 예시


회원 관리 API

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

/users 경로가 중복된다 클래스 레벨로 빼자. 메서드 레벨에서 해당 정보를 조합해서 사용한다.

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

참고로 Api를 내려줄 때는 @RestController를 사용하자. (문자열, Json, Xml 등 Http 응답 바디에 문자열로 실어보내는 것이기 때문)


HTTP 요청 - 헤더 조회

HTTP 요청 메시지에서 헤더 메시지를 조회해보자. HttpServletRequest에서 직접 request.getHeader()로 일일히 조회할 수 있지만, 애노테이션 기반 스프링 컨트롤러는 다양한 파라미터를 지원한다.


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

  • HttpMethod: HTTP 메서드를 조회한다.
    • org.springframework.http.HttpMethod
  • Locale: Locale 정보를 조회한다.
  • @RequestHeader MultiValueMap<String, String> headerMap
    • 모든 HTTP 헤더를 MultiValueMap 형식으로 조회한다.
  • @RequestHeader("host") String host
    • 특정 HTTP 헤더를 조회한다.
    • 속성
      • 필수 값 여부:required
      • 기본 값 속성:defaultValue
        • 요청에서 지정된 값이 제공되지 않았을 때 기본 값이 등록된다.
  • @CookieValue(value = "myCookie", required = false) String cookie
    • 특정 쿠키를 조회한다.
    • 속성
      • 필수 값 여부:required
      • 기본 값: defaultValue

MultiValueMap
HTTP header, HTTP 쿼리 파라미터와 같이 하나의 키에 여러 값을 받을 때 사용한다.

  • keyA=value1&keyA=value2

MultiValueMap<String, String> map = new LinkedMultiValueMap();
map.add("keyA", "value1");
map.add("keyA", "value2");

map.get(키)로 value 리스트를 가져올 수 있다.


이 외에도 @Controller의 사용 가능한 파라미터는 굉장히 많다. 스프링 공식 메뉴얼에 잘 나와있다.


HTTP 요청 데이터 전달 방법

  1. GET - 쿼리 파라미터 방식
  2. POST - HTML Form을 통해 메시지 바디에 쿼리 파라미터 형식으로 전달하는 방식
  3. HTTP message body에 데이터를 직접 담아서 요청하는 방법 (HTTP API에서 주로 사용)

GET - 쿼리 파라미터 방식 + POST - HTML Form 방식
둘 다 형식이 같으므로 구분 없이 조회할 수 있다 요청 파라미터(request parameter) 조회라 한다.


스프링으로 요청 파라미터를 조회하는 방법을 처음에 배웠던 HttpServletRequest부터 애노테이션 기반 방법까지 모두 알아보자.


  1. HttpServletRequest가 제공하는 방식으로 요청 파라미터를 조회(request.getParameter())
@Controller
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"));
	...
}

  1. 스프링이 제공하는 @RequestParam을 사용
@ResponseBody// 반환 값을 뷰 리졸버가 아닌 RequestBody에 바로 실어서 내려보내준다.
@RequestMapping("/request-param-v2") 
public String requestParamV2(@RequestParam("username") String memberName,
    						 @RequestParam("age") int memberAge) {
	// ...
}

  • @RequestParam: 파라미터 이름으로 바인딩
    • 위 예시에서 username, age 파라미터에 해당하는 값이 memberName, memberAge로 바인딩
  • @ResponseBody: View 조회를 무시하고, HTTP message body에 직접 해당 내용 입력

  1. @RequestParam 의 name 생략
@ResponseBody
@RequestMapping("/request-param-v3")
public String requestParamV3( @RequestParam String username,
						      @RequestParam int age ) { 
	//...
}

HTTP 파라미터 이름이 변수 이름과 같으면 @RequestParam(name="xx") 생략 가능


  1. @RequestParam 자체를 생략
@ResponseBody
@RequestMapping("/request-param-v4")
public String requestParamV4(String username, int age) {
	///
}

주의

@RequestParam 애노테이션을 생략하면 스프링 MVC는 내부에서 required=false를 적용한다.


김영한 강사님은 @RequestParam까지 생략하는 것은 과하다는 생각을 갖고 계신다고 합니다.
개인적으로도 요청 파라미터에서 데이터를 읽는다는 것을 명확하게 표시해주는게 혼동을 주지 않는다고 생각합니다!


@RequestParam 파라미터 필수 여부: required

@ResponseBody
@RequestMapping("/request-param-required")
public String requestParamRequired(
    @RequestParam(required = true) String username,
    @RequestParam(required = false) Integer age) {
	// ...
}

@RequestParam.required

  • 파라미터 필수 여부
  • 기본값이 파라미터 필수 (true)이다.

주의! - 파라미터 이름만 사용
/request-param-required?username=
파라미터 이름만 있고 값이 없는 경우 빈문자로 통과. null이 아니다.


주의! - 기본형(primitive)에 null 입력

  • /request-param-required
  • @RequestParam(required = false) int age

null을 int에 입력하는 것은 불가능(500 예외 발생) null을 받을 수 있는 Integer로 변경하거나, 또는 다음에 나오는 defaultValue 사용해야 한다.


@RequestParam 기본 값 적용 - defaultValue

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

파라미터에 값이 없는 경우 defaultValue를 사용하면 기본 값을 적용할 수 있다.

  • 이미 기본 값이 있기 때문에 required는 의미가 없다.

defaultValue빈 문자의 경우에도 설정한 기본 값이 적용된다.

  • /request-param-default?username=

빈 문자를 파라미터 값으로 넘긴 경우 null로 처리되지 않지만, 그렇다고 기본 값 설정을 무시하지는 않는다.


  1. 파라미터를 Map으로 조회하기 - requestParamMap
@ResponseBody
@RequestMapping("/request-param-map")
public String requestParamMap(@RequestParam Map<String, Object> paramMap) {

헤더를 MultiValueMap으로 조회했듯이, 파라미터도 Map, MultiValueMap으로 조회할 수 있다.
파라미터의 값이 1개가 확실하다면 Map을 사용해도 되지만, 그렇지 않다면 MultiValueMap을 사용하자.


  1. @ModelAttribute: 요청 파라미터 객체 바인딩
@RequestParam String username;
@RequestParam int age;
HelloData data = new HelloData();
data.setUsername(username);
data.setAge(age);

이 과정을 직접 작성하지 않고 아래와 같이 편하게 사용할 수 있다.


@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {
	// ...
}

HelloData 객체가 생성되고, 요청 파라미터의 값도 모두 들어가 있다.


스프링 MVC는 @ModelAttribute가 있으면 다음을 실행한다.

  1. HelloData 객체를 생성한다.
  2. 요청 파라미터의 이름으로 HelloData 객체의 프로퍼티를 찾는다. 그리고 해당 프로퍼티의 setter를 호출해서 파라미터의 값을 입력(바인딩)한다.
    • 예) 파라미터 이름이 username이면 setUsername() 메서드를 찾아서 호출하면서 값을 입력한다.
    • 객체에 getUsername(), setUsername() 메서드가 있으면, 이 객체는 username이라는 프로퍼티를 가지고 있다.
    • username 프로퍼티의 값을 변경하면 setUsername() 이 호출되고, 조회하면 getUsername() 이 호출된다.

참고: model.addAttribute(helloData) 코드도 함께 자동 적용됨, 뒤에 model을 설명할 때 자세히 설명


바인딩 오류
age=abc처럼 숫자가 들어가야 할 곳에 문자를 넣으면 BindException이 발생한다.


  1. @ModelAttribute 생략 - modelAttributeV2
@ResponseBody
@RequestMapping("/model-attribute-v2")
public String modelAttributeV2(HelloData helloData) {
	// ...
}

@ModelAttribute는 생략할 수 있다. 그런데 @RequestParam도 생략할 수 있으니 혼란이 발생할 수 있다.
다음과 같은 규칙을 적용한다.


  • String, int, Integer 같은 단순 타입 = @RequestParam
  • 나머지 = @ModelAttribute (argument resolver로 지정해둔 타입은 ModelAttribute가 적용되지 않는다. e.g. HttpServletRequest. 추가로 지정할 수 있음)

HTTP 요청 메시지 - Body

HTTP 메시지 바디를 통해 데이터가 직접 넘어오는 경우는 @RequestParam , @ModelAttribute 를 사용할 수 없다. (물론 HTML Form 형식으로 전달되는 경우는 요청 파라미터로 인정된다.)


스프링 MVC는 다양한 타입의 파라미터를 바디를 조회하는데 지원한다. 알아보자.


  1. HttpServletRequest에서 바디의 데이터를 InputStream을 사용해서 직접 읽는 방법
@Controller
public class RequestBodyStringController {
    @PostMapping("/request-body-string-v1")
    public void requestBodyString(HttpServletRequest request,
    HttpServletResponse response) throws IOException {
        ServletInputStream inputStream = request.getInputStream();
        String messageBody = StreamUtils.copyToString(inputStream,
        StandardCharsets.UTF_8);
		// ...
	}

  1. 파라미터를 InputStream, OutputStream으로 받는 방법
@PostMapping("/request-body-string-v2")
public void requestBodyStringV2(InputStream inputStream, Writer responseWriter) throws IOException {
	// ...
}

  • InputStream(Reader): HTTP 요청 메시지 바디의 내용을 직접 조회
  • OutputStream(Writer): HTTP 응답 메시지의 바디에 직접 결과 출력

  1. 파라미터를 HttpEntity<> 로 받는 방법
@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) {

  • HttpEntity: HTTP header, body 정보를 편리하게 조회
    • 스프링이 HttpBody가 바디가 문자임을 확인 HttpMessageConverter가 동작해서 HttpBody에 있는 걸 문자로 바꿔서 HttpEntity에다가 넣어준다.
      • InputStream을 String으로 변환하는 과정을 스프링이 해준다.
      • HttpEntity는 Http 스펙을 객체화 해둔거라 보면 된다.
    • 메시지 바디 정보를 직접 조회
    • 요청 파라미터를 조회하는 기능과 관계 없음 @RequestParam X, @ModelAttribute X
      • Get에 쿼리 스트링, Post Form 데이터 전송만 위 두 개를 사용한다.
  • HttpEntity는 응답에도 사용 가능
    • 메시지 바디 정보 직접 반환
    • 헤더 정보 포함 가능
    • view 조회X

  1. HttpEntity를 상속받은 객체 사용
@PostMapping("/request-body-string-v3")
    public ResponseEntity<String> requestBodyStringV3(RequestEntity<String> httpEntity) throws IOException {

        String messageBody = httpEntity.getBody();
        log.info("messageBody={}", messageBody);

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

HttpEntity를 상속받은 다음 객체들도 같은 기능을 제공한다.

  • RequestEntity
    • HttpMethod, url 정보가 추가, 요청에서 사용
  • ResponseEntity
    • HTTP 상태 코드 설정 가능, 응답에서 사용
    • return new ResponseEntity<String>("Hello World", responseHeaders,HttpStatus.CREATED)

  1. @RequestBody : 메시지 바디를 문자열로 조회
@ResponseBody
@PostMapping("/request-body-string-v4")
public String requestBodyStringV4(@RequestBody String messageBody) {

@RequestBody
@RequestBody를 사용하면 HTTP 메시지 바디 정보를 편리하게 조회할 수 있다.
참고로 헤더 정보가 필요하다면

  1. HttpEntity를 사용하거나
  2. @RequestHeader를 파라미터 바인딩으로 추가 사용하면 된다.

@ResponseBody
@ResponseBody를 사용하면 응답 결과를 HTTP 메시지 바디에 직접 담아서 전달할 수 있다. 물론 이 경우에도 view를 사용하지 않는다.


한 번 더 정리하자면
요청 파라미터 vs HTTP 메시지 바디

  • 요청 파라미터를 조회하는 기능: @RequestParam, @ModelAttribute
  • HTTP 메시지 바디를 직접 조회하는 기능: @RequestBody

HTTP 요청 메시지 - JSON

예전에 배웠던 것 방식부터, Spring이 지원하는 파라미터까지 모두 알아보자.


  1. HttpServletRequest InputStream {ObjectMapper} 객체
@Controller
public class RequestBodyJsonController {
    private 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);
		HelloData data = objectMapper.readValue(messageBody, HelloData.class);
		// ...
	}
}

  1. @RequestBody 문자 변환
@ResponseBody
@PostMapping("/request-body-json-v2")
public String requestBodyJsonV2(@RequestBody String messageBody) throws
IOException {
    HelloData data = objectMapper.readValue(messageBody, HelloData.class);

JSON도 문자 데이터이기 때문에 바디를 문자열로 받은 다음, objectMapper.readValue()메서드를 통해 문자열 형태의 messageBody를 객체로 변환할 수 있다.


  1. @RequestBody 객체 파라미터
@ResponseBody
@PostMapping("/request-body-json-v3")
public String requestBodyJsonV3(@RequestBody HelloData data) {

@RequestBody에 직접 만든 객체를 지정할 수 있다.
HttpEntity, @RequestBody를 사용하면 HTTP 메시지 컨버터가 HTTP 메시지 바디의 내용을 우리가 원하는 문자나 객체 등으로 변환해준다. (HttpEntity)


HTTP 메시지 컨버터는 문자 뿐만 아니라 JSON도 객체로 변환해주는데(MappingJackson2HttpMessageConverter), 우리가 방금 V2에서 했던 작업을 대신 처리해준다.


주의!!!

@RequestBody는 생략할 수 없다. @RequestBody를 생략하는 순간 @ModelAttribute가 적용된다. 우리는 요청 파라미터 조회가 아니라 Body Message 조회를 하고 있다. 즉, Body를 조회할 땐 @RequestBody를 생략할 일이 없다.


주의

HTTP 요청 시에 content-typeapplication/json인지 꼭! 확인해야 한다. 그래야 JSON을 처리할 수 있는 HTTP 메시지 컨버터가 실행된다.


  1. HttpEntity<>
@ResponseBody
@PostMapping("/request-body-json-v4")
public String requestBodyJsonV4(HttpEntity<HelloData> httpEntity) {
	HelloData data = httpEntity.getBody();
}

파라미터가 HttpEntity인 경우에도 제네릭 타입에 맞춰서 HTTP 메시지 컨버터가 객체로 변환해준다.


Http 응답 - 객체를 직접 반환

@ResponseBody
@PostMapping("/request-body-json-v5")
public HelloData requestBodyJsonV5(@RequestBody HelloData data) {
    log.info("username={}, age={}", data.getUsername(), data.getAge());
    return data;
}

일단 @ResponseBody를 사용하면 뷰를 조회하지 않고 문자열을 그대로 내려준다.
그런데 만약 문자열이 아니라 객체를 반환한다면?


@RequestBody로 요청을 받을 때 JSON 요청 -> HTTP 메시지 컨버터 -> 객체 를 거치는 것 처럼
@ResponseBody로 응답을 내릴 때 객체 -> HTTP 메시지 컨버터 -> JSON 응답 과정을 거치게 된다


  • HttpMessageConverter 사용 MappingJackson2HttpMessageConverter 적용
    • 요청 메시지의 Accept 헤더가 application/json을 포함하고 있어야 가능하다.

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

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


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

정적 리소스
스프링 부트는 클래스패스의 다음 디렉토리에 있는 정적 리소스를 제공한다.
/static, /public, /resources, /META-INF/resources


리소스를 보관하는 곳이고, 또 클래스패스의 시작 경로이다.
따라서 다음 디렉토리에 리소스를 넣어두면 스프링 부트가 정적 리소스로 서비스를 제공한다.


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


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


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


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


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


뷰 템플릿 경로: src/main/resources/templates


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

@Controller
public class ResponseViewController {
    @RequestMapping("/response-view-v1")
    public ModelAndView responseViewV1() {
        ModelAndView mav = new ModelAndView("response/hello")
        .addObject("data", "hello!");
        return mav;
    }

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

@ResponseBody가 없다 뷰 리졸버가 실행되서 뷰를 찾고 렌더링한다.


복습

  • V1: ModelAndView 객체에 논리 이름과 Model 정보를 담아서 프론트 컨트롤러(DispatcherServlet) 한테 넘긴다
  • V2: Model 자체를 미리 만들어둬서 컨트롤러에서는 단순히 모델에 데이터를 저장하고 논리 이름만 반환하면 어댑터가 알아서 ModelAndView를 만들어서 프론트 컨트롤러(DispatcherServlet) 한테 넘긴다

void를 반환?

 @RequestMapping("/response/hello")
    public void responseViewV3(Model model) {
        model.addAttribute("data", "hello!!");
    }

@Controller를 사용하고, HttpServletResponse, OutputStream(Writer) 같은 HTTP 메시지 바디를 처리하는 파라미터가 없으면 요청 URL을 참고해서 논리 뷰 이름으로 사용한다.


  • 요청 URL: /response/hello
  • 실행: templates/response/hello.html

HTTP 메시지

@ResponseBody, HttpEntity를 사용하면, 뷰 템플릿을 사용하는 것이 아니라, HTTP 메시지 바디에 직접 응답 데이터를 출력할 수 있다.


API, 메시지 바디에 직접 입력
HTTP API를 제공하는 경우에는 HTML이 아니라 데이터를 전달해야 하므로, HTTP 메시지 바디에 JSON 같은 형식으로 데이터를 실어 보낸다. 이에 대해서는 이미 충분히 다뤘으므로 단순히 정리만 해보자.


단순 문자열 반환


  1. HttpServletResponse 사용
@GetMapping("/response-body-string-v1")
    public void responseBodyV1(HttpServletResponse response) throws IOException
    {
        response.getWriter().write("ok");
    }

  1. HttpEntity + Http Status ResponseEntity<> 사용
@GetMapping("/response-body-string-v2")
    public ResponseEntity<String> responseBodyV2() {
        return new ResponseEntity<>("ok", HttpStatus.OK);
    }

  1. @ResponseBody 문자열 리턴
@ResponseBody
    @GetMapping("/response-body-string-v3")
    public String responseBodyV3() {
        return "ok";
    }

2번, 3번 방법은 HTTP 메시지 컨버터를 통해 HTTP 메시지를 직접 입력하는 방식이다.


Json 반환


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

  1. 객체 반환
@ResponseStatus(HttpStatus.OK) // 단순히 객체를 반환하면 상태 코드 지정이 안 되니, 애노테이션을 사용한다
    @ResponseBody
    @GetMapping("/response-body-json-v2")
    public HelloData responseBodyJsonV2() {
        HelloData helloData = new HelloData();
        helloData.setUsername("userA");
        helloData.setAge(20);
        return helloData;
    }

1번과 2번 모두 HTTP 메시지 컨버터가 동작하여 JSON 형식으로 변환되어 반환된다.


2번 객체 직접 반환 방법의 경우 상태 코드 지정을 반환값에 넣을 수 없으므로 애노테이션을 사용할 수 있다.
다만, 애노테이션이기 때문에 응답 코드를 동적으로 변경할 수는 없다. 프로그램 조건에 따라서 동적으로 변경하려면 ResponseEntity를 사용하면 된다.


@RestController
@Controller + @ResponseBody 이다. REST API(HTTP API)를 만들 때 사용하는 컨트롤러이다.
클래스 레벨에 두면 전체 메서드에 대해 적용된다. @ResponseBody를 클래스 레벨에 두는 경우도 마찬가지다.


HTTP 메시지 컨버터

위 예시에서 봤듯이, HTTP API처럼 JSON 데이터를 HTTP 메시지 바디에서 직접 읽거나 쓰는 경우 HTTP 메시지 컨버터를 사용하면 편리하다.


우리는 입문편에서 다음과 같은 내용을 공부했다.

  • @ResponseBody를 사용
    • HTTP의 BODY에 문자 내용을 직접 반환
    • viewResolver 대신에 HttpMessageConverter가 동작
    • 기본 문자처리: StringHttpMessageConverter
    • 기본 객체처리: MappingJackson2HttpMessageConverter
    • byte 처리 등등 기타 여러 HttpMessageConverter가 기본으로 등록되어 있음

돌아와서 보면
일단 HTTP 메시지 컨버터이다. 당연히 요청이 Body Message를 통해서(파라미터 방식 X) 전달될 때, 그리고 응답이 Body Message를 통해서 내려갈 때만 사용된다는 것을 알아야 한다.


그리고 HttpServletResponse, HttpServletRequest 객체를 직접 사용하는 경우를 제외하면 사실 아래만 남게 된다.


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

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

어떻게 구현이 되어있길래 메시지 컨버터가 바디에 있는 메시지를 읽고 변환해주는 것일까?


HTTP 메시지 컨버터 인터페이스


org.springframework.http.converter.HttpMessageConverter

package org.springframework.http.converter;

public interface HttpMessageConverter<T> {
    boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);
    boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);
    List<MediaType> 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
    • application/json
    • 클래스 타입: 객체 또는 HashMap, 미디어타입 application/json 관련
    • 요청 예) @RequestBody HelloData data
    • 응답 예) @ResponseBody return helloData
    • 쓰기 미디어타입: application/json 관련

HTTP 요청 데이터 읽기

  1. HTTP 요청이 오고, 컨트롤러에서 @RequestBody, HttpEntity 파라미터를 사용한다.
  2. 메시지 컨버터가 메시지를 읽을 수 있는지 확인하기 위해 canRead()를 호출한다.
    • 대상 클래스 타입을 지원하는가.
      • 예) @RequestBody의 대상 클래스 (byte[], String, HelloData)
    • HTTP 요청의 Content-Type 미디어 타입을 지원하는가.
      • 예) text/plain, application/json, */*
  3. canRead() 조건을 만족하면 read()를 호출해서 객체 생성하고, 반환한다.

HTTP 응답 데이터 생성

  1. 컨트롤러에서 @ResponseBody, HttpEntity로 값이 반환된다.
  2. 메시지 컨버터가 메시지를 쓸 수 있는지 확인하기 위해 canWrite()를 호출한다.
    • 대상 클래스 타입을 지원하는가.
      • 예) return의 대상 클래스 (byte[], String, HelloData)
    • HTTP 요청의 Accept 미디어 타입을 지원하는가. (더 정확히는 @RequestMappingproduces)
      • 예) text/plain, application/json, */*
  3. canWrite() 조건을 만족하면 write()를 호출해서 HTTP 응답 메시지 바디에 데이터를 생성한다.

? (안 되는 케이스)

content-type: text/html
@RequestMapping
void hello(@RequestBody HelloData data) {}

미디어 타입이 application/json 관련 X 라서 실패한다.


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

HTTP 메시지 컨버터는 스프링 MVC 어디쯤에서 사용되는 것일까? 우리가 전에 배웠던 구조에는 드러나 있지 않는다.


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



RequestMappingHandlerAdapter 동작 방식


컨트롤러에 분명히 누군가가 파라미터를 주입해줘야지만 우리가 파라미터를 원하는 편리한 형태로 사용할 수 있을 것이다. 이 역할을 ArgumentResolver가 해준다


애노테이션 기반 컨트롤러를 처리하는 RequestMappingHandlerAdapter는 바로 이 ArgumentResolver를 호출해서 컨트롤러(핸들러)가 필요로 하는 다양한 파라미터의 값(객체)을 생성한다. 그리고 이렇게 파라미터의 값이 모두 준비되면 컨트롤러를 호출하면서 값을 넘겨준다.


(스프링은 30개가 넘는 ArgumentResolver를 기본으로 제공한다. 자세한 건 공식 메뉴얼을 참고하자)


정확히는 HandlerMethodArgumentResolver인데 줄여서 ArgumentResolver라고 부른다.

public interface HandlerMethodArgumentResolver {
    boolean supportsParameter(MethodParameter parameter);
    @Nullable
    Object resolveArgument(MethodParameter parameter, @Nullable
    ModelAndViewContainer mavContainer,
    NativeWebRequest webRequest, @Nullable WebDataBinderFactory
    binderFactory) throws Exception;
}

ArgumentResolversupportsParameter()를 호출해서 해당 파라미터를 지원하는지 체크하고, 지원하면 resolveArgument()를 호출해서 실제 객체를 생성한다. 그리고 이렇게 생성된 객체가 컨트롤러 호출 시에 넘어가는 것이다.


ArgumentResolversupportsParameter()를 호출해서 해당 파라미터를 지원하는지 체크하고, 지원하면 resolveArgument()를 호출해서 실제 객체를 생성한다. 그리고 이렇게 생성된 객체가 컨트롤러 호출 시에 넘어가는 것이다.


그리고 원한다면 여러분이 직접 이 인터페이스를 확장해서 원하는 ArgumentResolver를 만들 수도 있다


ReturnValueHandler
HandlerMethodReturnValueHandler를 줄여서 ReturnValueHandler라고 부른다.
ArgumentResolver와 비슷한데, 이것은 응답 값을 변환하고 처리한다.


컨트롤러에서 String으로 뷰 이름을 반환해도, 동작하는 이유가 바로 ReturnValueHandler 덕분이다.


스프링은 10여 개가 넘는 ReturnValueHandler를 지원한다.
예) ModelAndView, @ResponseBody, HttpEntity, String


HTTP 메시지 컨버터는 어디에?


HTTP 메시지 컨버터는 바디 메시지를 객체로 변환하거나, 반대로 변환하는 역할을 한다.


ArgumentResolver는 컨트롤러 호출 시 파라미터에 객체를 넣어서 넘겨주는데, 이 때 이 객체를 HTTP 메시지 컨버터가 만드는 것이다. 책임과 역할이 명확히 분리되있는 것.


요청의 경우 @RequestBody 를 처리하는 ArgumentResolver 가 있고, HttpEntity 를 처리하는 ArgumentResolver 가 있다. 이 ArgumentResolver 들이 HTTP 메시지 컨버터를 사용해서 필요한 객체를 생성 하는 것이다. (어떤 종류가 있는지 코드로 살짝 확인해보자)


응답의 경우 @ResponseBodyHttpEntity 를 처리하는 ReturnValueHandler 가 있다. 그리고 여기에서 HTTP 메시지 컨버터를 호출해서 응답 결과를 만든다


응답의 경우에도 마찬가지이다.


스프링 MVC는@RequestBody @ResponseBody 가 있으면 RequestResponseBodyMethodProcessor(ArgumentResolver, ReturnValueHandler 둘다 구현)


HttpEntity 가 있으면 HttpEntityMethodProcessor(ArgumentResolver, ReturnValueHandler 둘다 구현) 를 사용한다.


스프링은 다음을 모두 인터페이스로 제공한다. 따라서 필요하면 언제든지 기능을 확장할 수 있다.

  • HandlerMethodArgumentResolver
  • HandlerMethodReturnValueHandler
  • HttpMessageConverter

스프링이 필요한 대부분의 기능을 제공하기 때문에 실제 기능을 확장할 일이 많지는 않다. 기능 확장은 WebMvcConfigurer를 상속 받아서 스프링 빈으로 등록하면 된다. 실제 자주 사용하지는 않으니 실제 기능 확장이 필요할 때 WebMvcConfigurer를 검색해보자.


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