1. 로깅 간단히 알아보기
앞으로 "System.out.println()"은 사용하지 않을 것이다. 실무에서는 이를 쓰면 안된다. 로그라는 것을 통해 콘솔로 출력해야한다. 기본적으로 스프링 부트의 Dependencies를 보면 항상 spring-boot-starter가 들어있다. 여기에 spring-boot-starter-logging이라는 라이브러리를 자동으로 가져오 그 안에 몇 개의 라이브러리가 또 들어있다.
세상에는 Logback, Log4J, Log4J2 등 굉장히 많은 로그 프레임워크들이 있다. 이들을 통합하여 인터페이스로 제공하는 것이 SLF4J이다. 즉, SLF4J가 인터페이스인 것이고, Logback은 구현체 중 하나다. 실무에서는 스프링 부트가 제공하는 Logback을 주로 사용한다고 한다. 이제 로그 선언 및 로그 호출을 살펴보자.
// @Controller: 반환하는 것이 view의 이름으로 판단.
// @RestController: 문자를 반환하면 String이 바로 반환되어 화면에 반환된 String이 그대로 작성됨.
@RestController
public class LogTestController {
// LoggerFactory.getLogger(LogTestController.class); 로 써도 됨.
private final Logger log = LoggerFactory.getLogger(getClass());
@RequestMapping("/log-test")
public String logTest() {
String name = "Spring";
// 이전에 썼던 방식: System.out.println("name = "+name);
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);
return "ok";
}
}
- @RestController: @Controller는 반환값이 String이면 뷰 이름으로 인식한다. 즉, 뷰를 찾고 뷰가 렌더링된다. 반면 @RestController는 반환 값으로 뷰를 찾는 것이 아니라 "HTTP 메시지 바디에 바로 입력"한다. 따라서 해당 URL로 HTTP 요청시 반환 값인 ok가 화면에 출력된다.
위와 같이 log를 선언하면 기본적으로 "http://localhost:8080/log-test" 요청시 아래와 같이 출력된다.
기존에 사용했던 System.out.println()보다 훨씬 많은 정보를 볼 수 있다. 이때 주의깊게 봐야할 점은 log.trace, log.debug 관련 로그는 나오지 않았다는 점이다. 로그는 이와 같이 레벨을 설정하여 설정된 로그보다 낮은 레벨의 로그는 출력하지 않는다. 즉, 스프링부트는 기본적으로 info 레벨에 있기 때문에 trace와 debug 관련 로그는 나오지 않은 것이다. 레벨 순위는 다음과 같다.
- TRACE → DEBUG → INFO → WARN → ERROR
보통 개발 서버는 개발을 위해 trace 혹은 debug를 사용한다. 운영 서버의 경우 개발 서버에서 사용했던 정보들까지 볼 필요가 없으므로 보통 info 레벨을 사용한다. 만약 로그 레벨을 바꾸고 싶다면 /resources/application.properties 파일에 아래 내용을 추가하면 된다. (trace로 설정하면 trace, debug로 설정하면 debug로 로그 레벨이 설정된다.)
logging.level.hello.springmvc=trace
위와 같이 설정하면 아래처럼 trace부터 error까지 모든 로그가 출력된다.
만약 세부적으로 로그 레벨을 설정하려면 아래처럼 할 수도 있다.
# 전체 로그 레벨 설정
logging.level.root=info
# 세부 패키지 설정 (해당 패키지의 하위 패키지들도 적용됨.)
logging.level.hello.springmvc=debug
로그 테스트 코드를 보면 로그를 선언할 때 "private final Logger log = LoggerFactory.getLogger(getClass());"를 사용했다. 매번 이 코드를 적기 귀찮을 텐데 이를 위해 lombok은 @Slf4j 어노테이션을 제공한다. 그러면 해당 코드를 지우고 기존 코드를 그대로 사용할 수 있다.
@Slf4j
@RestController
public class LogTestController {
@RequestMapping("/log-test")
public String logTest() {
String name = "Spring";
// 이전에 썼던 방식: System.out.println("name = "+name);
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);
return "ok";
}
}
- 올바른 로그 사용법
- log.debug("data="+data): 로그 출력 레벨이 info라도 문자 더하기 연산이 발생함.
- log.debug("data={}", data): 로그 출력 레벨이 info면 아무일도 발생하지 않음. 즉, 의미없는 연산이 없어짐.
- 로그 사용시 장점
- 쓰레드 정보, 클래스 이름 등 부가 정보와 함께 볼 수 있음.
- 로그 레벨에 따라 출력을 조절할 수 있음.
- 콘솔에만 출력하는 것이 아니라 파일이나 네트워크 등 별도의 위치에 나길 수 있음.
- 일반 System.out보다 성능이 좋음.
2. 요청 매핑
(1) 기본(URL) 매핑 및 메서드 매핑
요청 매핑은 요청이 왔을 때 어떤 컨트롤러가 호출되어야 하는지 매핑하는 것이다. 단순히 url로 매핑하는 방법 뿐만 아니라 여러 가지 요소들을 가지고 매핑을 한다. 먼저 간단하게 컨트롤러를 하나 만들어보자.
@Slf4j
@RestController
public class MappingController {
@RequestMapping("/hello-basic")
public String helloBasic() {
log.info("helloBasic");
return "ok";
}
}
- @RequestMapping은 {"/hello-basic", "/hello-go"}와 같이 배열을 통해 다중 설정도 가능하다.
- "/hello-basic"과 "/hello-basic/"는 서로 다른 요청이다. 하지만 스프링은 두 요청을 "/hello-basic"으로 동일하게 매핑한다.
위처럼 기본적으로 url을 통해서 요청을 매핑할 수 있다. 이때 "method 속성"을 지정하지 않았는데 이 경우 HTTP 메서드에 상관없이 호출된다. 즉, post, get put, patch, delete, head 어떤 것이 와도 해당 컨트롤러가 실행된다. 만약 HTTP 메서드를 따로 설정하고 싶다면 method를 지정해주면 된다. 혹은 @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping을 사용하면 된다.
@RequestMapping(value = "/mapping-get-v1", method = RequestMethod.GET)
public String mappingGetV1() {
log.info("mapping-get-v1");
return "ok";
}
@GetMapping("/mapping-get-v2")
public String mappingGetV2() {
log.info("mapping-get-v2");
return "ok";
}
(2) PathVariable 사용
PathVariable(경로 변수)를 사용하는 방식도 있다.
@GetMapping("/mapping/{userId}")
public String mappingPath(@PathVariable("userId") String data) {
log.info("mappingPath userId={}", data);
return "ok";
}
- @PathVariable의 이름과 파라미터의 이름이 같다면, 즉, String data가 아니라 String userId로 사용할거라면, "@PathVariable String userId"로 사용할 수 있다.
만약 "http://localhost:8080/mapping/userA" 요청을 보내면 아래와 같이 로그가 발생한다.
최근 HTTP API는 위처럼 리소스 경로에 식별자를 넣는 스타일을 선호한다. 즉, 이전에 배운 쿼리 파라미터 방식인 "?userid=userA"와 같이 사용하는 것이 아니라 위처럼 그냥 url 상으로 넣어주는 것이다. 그리고 아래처럼 다중으로 사용할 수도 있다.
@GetMapping("/mapping/users/{userId}/orders/{orderId}")
public String mappingPath2(@PathVariable String userId, @PathVariable Long orderId) {
log.info("mappingPath userId={}, orderId={}", userId, orderId);
return "ok";
}
"http://localhost:8080/mapping/users/userA/orders/100" 요청이 오면 아래와 같이 올바르게 로그가 출력된다.
(3) 파라미터 조건 매핑
특정 파라미터 조건을 매핑할 수도 있다. 아래와 같은 것들이 있을 수 있다.
- params="mode"
- params="!mode"
- params="mode=debug"
- params="mode!=debug"
- params={"mode=debug", "data=good"}
만약 적혀있는 조건이 없다면, 즉, params="mode=debug"의 경우 url에 해당 정보가 없다면 url 매핑이 이루어지지 않는다.
@GetMapping(value = "/mapping-param", params = "mode=debug")
public String mappingParam() {
log.info("mappingParam");
return "ok";
}
"http://localhost:8080/mapping-param?mode=debug"의 경우 올바르게 작동하지만 "http://localhost:8080/mapping-param" 요청은 아래와 같이 에러 페이지가 발생한다.
(4) 특정 헤더 조건 매핑
특정 파라미터 뿐만 아니라 헤더도 매핑이 가능하다. 매핑 정보는 파라미터와 동일하게 작성하면 된다.
@GetMapping(value = "/mapping-header", headers = "mode=debug")
public String mappingHeader() {
log.info("mappingHeader");
return"ok";
}
이 경우 아래처럼 postman에 header정보를 추가함으로써 잘 동작하는 것을 확인할 수 있다.
(5) 미디어 타입 매핑
원하는 Content-Type도 지정할 수 있다. 지정할 수 있는 방법 예시는 다음과 같다.
- consumes="application/json"
- consumes="!application/json"
- consumes="application/*"
- consumes="*/*"
- MediaType.APPLICATION_JSON_VALUE
@PostMapping(value = "/mapping-consume", consumes = "application/json")
public String mapplingConsumes() {
log.info("mappingConsumes");
return "ok";
}
만약 Content-Type이 맞지 않으면 415 상태코드(Unsupported Media Type)을 반환한다. 또한 "consumes=MediaType.APPLICATION_JSON_VALUE"로 작성할 수 있는데, 문자열을 그대로 적는 것보다 이 방식을 추천한다.
(6) 미디어 타입 매핑 Accept
클라이언트가 받아들일 수 있는 형식이 작성된 Accept와 컨트롤러가 만들어내는 형식이 지정된 produces가 맞지 않는다면, 406 상태코드(Not Acceptable)을 반환한다. 예를 들어, 아래 코드를 보자.
@PostMapping(value="/mapping-produce", produces = "text/html")
public String mappingProduces() {
log.info("mappingProduces");
return "ok";
}
이때 만약 accept 형식이 application/json이라면 아래와 같이 에러가 발생한다.
3. 요청 매핑 - API 예시
회원 관리를 HTTP API로 만들어보자. 단, 실제 데이터가 넘어가는 건 일단 생략하고 URL 매핑만 수행해보자. 일단 회원 관리 API의 기본 구조는 다음과 같다.
- 회원 목록 조회 - GET, "/users"
- 회원 등록 - POST, "/users"
- 회원 조회 - GET, "/users/{userId}"
- 회원 수정 - PATCH, "/users/{userId}"
- 회원 삭제 - DELETE, "/users/{userId}"
아래 코드가 회원 관리 HTTP API를 위한 컨트롤러다.
@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 userId="+userId;
}
@PatchMapping("/{userId}")
public String updateUser(@PathVariable String userId) {
return "update userId="+userId;
}
@DeleteMapping("/{userId}")
public String deleteUser(@PathVariable String userId) {
return "delete userId="+userId;
}
}
4. HTTP 요청 - 기본, 헤더 조회
먼저 HTTP 헤더 정보를 조회하는 방법을 살펴보자.
@Slf4j
@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) {
log.info("request={}", request);
log.info("response={}", response);
log.info("httpMethod={}", httpMethod);
log.info("locale={}", locale);
log.info("headerMap={}", headerMap);
log.info("host={}", host);
log.info("cookie={}", cookie);
return "ok";
}
}
- RequestMapping을 사용하면 기본적으로 HttpServletRequest와 HttpServletResponse를 받아올 수 있다.
- HttpMethod: HTTP 메서드를 조회한다.
- Locale: Locale 정보를 조회한다.
- @RequestHeader MultiValueMap<String, String> headerMap: 모든 HTTP 헤더를 MultiValueMap 형식으로 조회한다. MultiValueMap은 Map과 유사한데, 하나의 키에 여러 값을 받을 수 있다. 만약 요청에 "keyA=value1", "keyA=value2"와 같이 하나의 키에 여러 값을 받을 수 있다. MultiValueMap은 하나의 키에 여러 값을 받고, 이후 get() 메서드를 실행할 때 해당 키에 여러 값이 있다면 List 형식으로 모든 값을 받을 수 있다.
- @RequestHeader("host") String host: 특정 HTTP 헤더를 조회할 수도 있다.
- @CookieValue(value="myCookie", required=false) String cookie: 특정 쿠키를 조회한다.
사용가능한 파라미터 목록은 아래 공식 메뉴얼에서 확인할 수 있다.
5. HTTP 요청 파라미터 - 쿼리 파라미터, HTML Form
기본적으로 쿼리 파라미터, HTML Form을 통해 전달된 데이터들은 HttpServletRequest를 통해 가져올 수 있다. 즉, 이전에 서블릿을 사용할 때 봤던 request.getParameter() 메서드를 사용하는 방법이다.
@Slf4j
@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"));
log.info("username={}, age={}", username, age);
response.getWriter().write("ok");
}
}
6. HTTP 요청 파라미터 - @RequestParam
스프링에서 제공하는 @RequestParam을 사용하면 전달된 파라미터들을 쉽게 가져올 수 있다.
@ResponseBody
@RequestMapping("/request-param-v2")
public String requestParamV2(
@RequestParam("username") String username,
@RequestParam("age") int age
) {
log.info("username={}, age={}", username, age);
return "ok";
}
- @RequestParam: 파라미터 이름을 통해 전달된 데이터를 조회하여 데이터를 가져와줌.
- @ResponseBody: View 조회를 무시하고, HTTP message body에 직접 해당 내용을 입력함. 즉, @Controller를 사용할 때는 String을 반환할 때 view의 이름으로 생각하는데 이 경우 @ResponseBody를 붙였으므로 해당 문자열을 그냥 body에 적어버림.
단, 위처럼 요청 파라미터의 이름과 데이터를 담으려는 변수의 이름이 같다면 "@RequestParam String username"으로 작성할 수 있다.
@ResponseBody
@RequestMapping("/request-param-v3")
public String requestParamV3(
@RequestParam String username,
@RequestParam int age
) {
log.info("username={}, age={}", username, age);
return "ok";
}
더 나아가서 요청 파라미터의 이름과 변수의 이름이 같고 (이전과 같은 경우) String, int, Integer 등의 단순 타입이라면 아래처럼 @RequestParam을 아예 생략할 수도 있다.
@ResponseBody
@RequestMapping("/request-param-v4")
public String requestParamV4(String username, int age) {
log.info("username={}, age={}", username, age);
return "ok";
}
이렇게 어노테이션을 완전히 생략해도 되지만 너무 없는 것도 좀 과하다. @RequestParam이 있으면 명확히 요청 파라미터에서 데이터를 읽는다는 것을 알 수 있다는 것이 직관적으로 다가온다. 이것조차 없으면 좀 직관적이지 않을 수 있다. (넣는 걸 권장한다.)
아래처럼 파라미터의 필수 여부를 줄 수도 있다. "required=true"라면 해당 파라미터는 필수로 받아야 한다. 단, "required=false"인 경우 파라미터로 데이터를 받지 않아도 요청을 처리할 수 있다. 이때 해당 파라미터가 전달되지 않았다면 해당 인자에 null이 들어가는데, 만약 자료형이 int인 경우는 null이 들어갈 수 없으므로 에러가 발생한다.
@ResponseBody
@RequestMapping("/request-param-required")
public String requestParamRequired(
@RequestParam(required = true) String username,
@RequestParam(required = false) Integer age
) {
log.info("username={}, age={}", username, age);
return "ok";
}
위처럼 int 타입에는 null이 들어갈 수 없으므로 참조타입인 Integer를 자료형으로 해야 에러가 발생하지 않는다. 만약 int 타입으로 하고 싶다면, "defaultValue"를 설정하면 된다.
@ResponseBody
@RequestMapping("/request-param-default")
public String requestParamDefault(
@RequestParam(required = true, defaultValue = "guest") String username,
@RequestParam(required = false, defaultValue = "-1") int age
) {
log.info("username={}, age={}", username, age);
return "ok";
}
요청 파라미터를 아예 Map으로 조회할 수도 있다.
@ResponseBody
@RequestMapping("/request-param-map")
public String requestParamMap(@RequestParam Map<String, Object> paramMap) {
log.info("username={}, age={}", paramMap.get("username"), paramMap.get("age"));
return "ok";
}
꼭 Map이 아니라 MultiValueMap으로 조회할 수도 있다.
7. HTTP 요청 파라미터 - @ModelAttribute
실제 개발을 하면 요청 파라미터를 받아서 필요한 객체를 만들고, 그 객체에 값을 넣어준다. 즉, 보통 아래와 같이 코드를 작성한다.
@RequestParam String username;
@RequestParam int age;
User user = new User();
user.setUsername(username);
user.setAge(age);
스프링은 이러한 과정을 자동화해주는 @ModelAttribute 기능을 제공한다. 먼저 요청 파라미터를 바인딩 받을 객체를 만들어야 한다.
@Data
public class User {
private String username;
private int age;
}
- @Data: @Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsConstructor 등을 자동으로 적용한다.
그리고 @ModelAttribute로 User 객체를 받아와보자.
@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute User user) {
log.info("username={}, age={}", user.getUsername(), user.getAge());
return "ok";
}
@ResponseBody
@RequestMapping("/model-attribute-v2")
public String modelAttributeV2(User user) {
log.info("username={}, age={}", user.getUsername(), user.getAge());
return "ok";
}
- @ModelAttribute가 있으면 아래과정을 수행한다.
- User 객체를 생성한다.
- 요청 파라미터의 이름을 바탕으로 User 객체의 프로퍼티를 찾는다. 이때 해당 프로퍼티의 setter를 호출해서 파라미터의 값을 바인딩한다. 즉, 파라미터의 이름이 username이라면 setUsername() 메서드를 찾아서 호출한다.
- age경우 int타입인데 이럴 때 age=abc로 문자가 넘어왔다면 BindException이 발생한다. 이런 바인딩 오류를 처리하는 법인 이후 검증 부분에서 다뤄보자.
- 객체에 getUsername(), setUsername()이 있다면 이 객체는 "username"이라는 프로퍼티를 가진다고 한다. 이때 username 프로퍼티 값을 변경하면 setUsername()이 호출되고, 조회하면 getUsername()이 호출된다.
- @ModelAttribute는 생략이 가능하다. 단, @RequestParam도 생략될 수 있어서 혼란이 될 수 있다. 스프링은 어노테이션 색략시 String, int, Integer와 같은 단순 타입은 @RequestParam, 나머지는 @ModelAttribute로 처리한다. 단, argument resolver로 지정해둔 타입은 @ModelAttribute가 적용되지 않는다. argument resolver에는 HttpServletRequest 같은 것들이 있다.
ModelAttribute는 정확히는 아래 두 기능을 수행한다.
- 요청 파리미터 처리: 원하는 객체를 생성해준다. 이때 해당 객체의 프로퍼티인 setter를 통해 생성한다.
- Model 추가: @ModelAttribute를 사용하면 Model 객체에 이름 그대로 객체를 넣어준다. 위의 경우 model.addAttribute("user", user)와 같은 기능을 수행한다.
8. HTTP 요청 메시지 - 텍스트
HTTP 메시지 바디에 데이터를 직접 담아서 요청하는 경우 주로 HTTP API를 사용하는데 JSON, XML, TEXT 등을 담을 수 있다. 주로 데이터 형식은 JSON을 사용하며 HTTP 메서드는 POST, PUT, PATCH 등을 사용한다. 요청 파라미터와 다르게 HTTP 메시지 바디를 통해 직접 데이터가 넘어오는 경우는 @RequestParam과 @ModelAttribute를 사용할 수 없다.
일단 단순한 텍스트 메시지를 바디에 담아서 전송하고, 읽어보자. 먼저 InputStream을 사용하는 방법이다.
@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);
log.info("messageBody={}", messagebody);
response.getWriter().write("ok");
}
그런데 사실 HttpServletRequest나 HttpServletResponse 뿐만 아니라 스프링은 inputStream과 Writer를 따로 받아올 수 있다. 그러면 아래와 같이 코드가 좀 더 간단해진다.
@PostMapping("/request-body-string-v2")
public void requestBodyStringV2(InputStream inputStream, Writer responseWriter) throws IOException {
String messagebody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
log.info("messageBody={}", messagebody);
responseWriter.write("ok");
}
하지만 위처럼 개발하는 것은 InputStream이나 Writer을 따로 다 받아와야 하므로 불편하다. 따라서 스프링에서 제공하는 HttpEntity를 사용하면 된다. 이때 HttpEntity<String>으로 받아오면 Http Body에 있는 내용을 스트링으로 인지하고 "String messagebody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);"를 자동으로 실행시킨다. 그리고 해당 메세지는 "httpEntity.getBody()"를 통해 가져올 수 있다. 또한 Writer를 통해 메세지 바디를 작성해주는 것이 아니라 HttpEntity<String>을 아래와 같이 반환하면 바디에 원하는 내용을 적어서 response할 수 있다.
@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) throws IOException {
String body = httpEntity.getBody();
log.info("messageBody={}", body);
return new HttpEntity<>("ok");
}
- HttpEntity: HTTP header, body 정보를 편리하게 조회할 수 있다. 메시지 바디 정보를 직접 조회하는 거지 요청 파라미터를 조회하는 기능(@RequestParam, @ModelAttribute)과 관계는 없다. HttpEntity는 응답에도 사용할 수 있다. 이경우 메시지 바디 정보를 직접 반환하며 헤더 정보도 포함할 수 있다. 단, HttpEntity를 사용하는 경우 view를 조회하진 않는다.
- Spring MVC 내부에서 HTTP 메시지 바디를 읽어서 문자나 객체로 변환해서 전달해주는데, 이때 HttpMessageConverter 기능을 사용한다. 이는 조금 뒤에서 알아보자.
하지만 HttpEntity를 작성하는 것도 역시 불편하다. 따라서 @RequestBody라는 어노테이션이 제공된다. 또한 response의 경우 @ResponseBody를 제공한다.
@ResponseBody
@PostMapping("/request-body-string-v4")
public String requestBodyStringV4(@RequestBody String messageBody) throws IOException {
log.info("messageBody={}", messageBody);
return "ok";
}
- @RequestBody: HTTP 메시지 바디 정보를 편리하게 조회할 수 있다. 헤더 정보가 필요하면 전처럼 HttpEntity나 @RequestHeader를 사용하면 된다. 단, 이렇게 메시지 바디를 직접 조회하는 기능은 요청 파라미터를 조회하는 기능들과는 관계가 없다.
- @ResponseBody: 응답 결과를 HTTP 메시지 바디에 직접 담아서 전달한다. 이 경우도 view를 사용하진 않는다.
9. HTTP 요청 메시지 - JSON
이제 JSON 데이터 형식을 조회해보자. 먼저 서블릿에서 사용했던 방식은 다음과 같다.
@Slf4j
@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);
log.info("messageBody={}", messageBody);
User user = objectMapper.readValue(messageBody, User.class);
log.info("username={}, age={}", user.getUsername(), user.getAge());
response.getWriter().write("ok");
}
}
두 번째 버전은 방금 배웠던 @ResponseBody와 @RequestBody를 사용하는 방법이다. 기존에 문자열을 가져오는 부분은 모두 사라지고, objectMapper만 사용하여 객체의 프로퍼티를 잘 채워주면 된다.
@ResponseBody
@PostMapping("/request-body-json-v2")
public String requestBodyJsonV2(@RequestBody String messageBody) throws IOException {
log.info("messageBody={}", messageBody);
User user = objectMapper.readValue(messageBody, User.class);
log.info("username={}, age={}", user.getUsername(), user.getAge());
return "ok";
}
하지만 여전히 objectMapper를 사용하는 방법은 불편하다. 이를 위해 스프링은 @RequestBody에 String이 아니라 바로 User와 같은 객체에 값을 채워줄 수 있다.
@ResponseBody
@PostMapping("/request-body-json-v3")
public String requestBodyJsonV3(@RequestBody User user) throws IOException {
log.info("username={}, age={}", user.getUsername(), user.getAge());
return "ok";
}
- 이와 같이 @RequestBody에 단순한 String 말고 직접 만든 객체를 넘길 수도 있다. HttpEntity<User>와 같이 사용해도 된다. 그러면 HttpMessageConverter가 HTTP 메시지 바디 내용을 우리가 원하는 문자나 객체 등으로 변환해준다. HttpMessageConverter는 문자뿐만 아니라 JSON도 객체로 변환해준다. 해당 내용은 이후 살펴보자.
- @RequestBody는 생략할 수 없다. 왜냐하면 이를 생략하면 요청 파라미터를 받아와주는 @RequestParam, @ModelAttribute로 착각하기 때문이다. 즉, String, int, Integer 등의 단순 타입은 @RequestParam, argument resolver로 지정한 타입 외 나머지는 @ModelAttribute로 판단한다.
이때 반환도 단순한 String 타입이 아니라 객체를 반환하여 JSON 형식으로 넘겨줄 수 있다.
@ResponseBody
@PostMapping("/request-body-json-v4")
public User requestBodyJsonV4(@RequestBody User user) throws IOException {
log.info("username={}, age={}", user.getUsername(), user.getAge());
return user;
}
postman으로 확인해보면 아래와 같이 JSON 데이터가 바디에 작성되고 응답으로 전달된 것을 확인할 수 있다.
- 이 경우도 객체를 HttpMessageConverter가 JSON 응답으로 변형해준 것이다. 이 겨웅 역시 @ResponseBody가 아닌 HttpEntity를 사용해도 된다.
10. 응답 - 정적 리소스, 뷰 템플릿
스프링은 응답 데이터를 크게 3가지 방법으로 만든다.
- 정적 리소스: HTML, css, js 등을 제공할 때, 즉, 파일을 그대로 전달하는 경우 "정적 리소스"를 사용한다.
- 뷰 템플릿 사용: 웹 브라우저에 동적인 HTML을 제공할 때는 뷰 템플릿을 사용한다.
- HTTP 메시지 사용: HTTP API를 제공하는 경우 HTML이 아니라 데이터를 전달해야 한다. 따라서 HTTP 메시지 바디에 JSON과 같은 형식으로 데이터를 보낸다.
(1) 정적 리소스
스프링 부트는 클래스패스의 아래 디렉토리에 있는 정적 리소스를 제공한다.
- "/static", "/public", "/resources", "/META-INF/resources"
"src/main/resources"는 리소스를 보관하는 곳이고, 클래스패스의 시작 경로다. 이 디렉토리에 리소스를 넣어두면 스프링 부트가 정적 리소스로 서비스를 제공한다. 위 4가지 중 어떤 걸 사용해도 상관없다.
(2) 뷰 템플릿
뷰 템플릿은 뷰 템플릿을 거쳐서 HTML이 생성되고, 뷰가 응답을 만들어서 전달한다. 일반적으로 HTML은 동적으로 생성하는 용도로 사용하지만 다른 것들도 가능하다. 스프링 부트는 기본 뷰 템플릿 경로를 제공한다.
- 뷰 템플릿 경로: src/main/resources/templates
만약 해당 경로에서 "response/hello"라는 뷰 템플릿이 있다면 아래와 같이 몇 가지 방법을 통해 불러올 수 있다.
@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 ModelAndView responseViewV2(Model model) {
model.addAttribute("data", "hello!");
return "response/hello";
}
// 경로의 이름과 동일하다면 반환형이 없어도 된다. (권장하진 않음. 너무 불명확함.)
@RequestMapping("/response/hello")
public void responseViewV3(Model model) {
model.addAttribute("data", "hello!");
}
}
- 세 번째 방식을 권장하진 않지만 사용하려면 몇 가지 조건이 있다.
- void 반환형이여야 한다.
- @Controller를 사용하고, HttpServletResponse, OutputStream(Writer)와 같이 HTTP 메시지 바디를 처리하는 파라미터가 없다면, 요청 URL을 참고하여 논리 뷰 이름으로 사용한다.
타임리프의 경우 사용하려면 build.gradle에 다음을 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
이렇게 하면 스프링 부트가 ThymeleafViewResolver와 필요한 스프링 빈들을 등록한다. 또한 아래 설정도 추가로 해준다. 변경이 필요하다면 변경해주면 된다.
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
11. HTTP 응답 - HTTP API, 메시지 바디에 직접 입력
REST API 혹은 HTTP API라고 하는데 이런 API를 제공하는 경우 html이 아니라 데이터를 직접 전달해야 한다. 그래서 HTTP 메시지 바디에 JSON 형식으로 데이터를 실어서 보낸다. HTTP 요청에서 응답은 이전에 다 다루었다. 정리해보면 아래 같은 것들이 있었다.
@Slf4j
@Controller
public class ResponseBodyController {
@GetMapping("/response-body-string-v1")
public void responseBodyV1(HttpServletResponse response) throws IOException {
response.getWriter().write("ok");
}
@GetMapping("/response-body-string-v2")
public ResponseEntity<String> responseBodyV2() {
return new ResponseEntity<>("ok", HttpStatus.OK);
}
@ResponseBody
@GetMapping("/response-body-string-v3")
public String responseBodyV3() {
return "ok";
}
@GetMapping("/response-body-json-v1")
public ResponseEntity<User> responseBodyJsonV1() {
User user = new User();
user.setUsername("nam");
user.setAge(20);
return new ResponseEntity<>(user, HttpStatus.OK);
}
@ResponseStatus(HttpStatus.OK)
@ResponseBody
@GetMapping("/response-body-json-v2")
public User responseBodyJsonV2() {
User user = new User();
user.setUsername("nam");
user.setAge(20);
return user;
}
}
- ResponseEntity는 HttpEntity를 상속받아 return할 때 HTTP 상태도 함께 반환할 수 있다. 단, @ResponseBody의 경우 상태를 원하는데로 설정할 수 없는데 이를 위해 @ResponseStatus를 제공한다.
근데 사실 @ResponseBody를 계속 붙이는 것은 매우 귀찮다. 이런 경우 @ResponseBody를 클래스 레벨에 붙이면 된다. 근데 그럼 클래스 레벨에 @Controller와 @ResponseBody가 들어가게 된다. 이 두 개를 같이 가지고 있는 어노테이션이 있으면 편할텐데 이것이 바로 @RestController다. @RestController 코드를 보면 다음과 같다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
@AliasFor(annotation = Controller.class)
String value() default "";
}
12. HTTP 메시지 컨버터
뷰 템플릿으로 HTML을 생성해서 응답하는 것이 아니라, HTTP API처럼 JSON 데이터 같은 것을 HTTP 메시지 바디에서 직접 읽거나 쓰는 경우 HttpMessageConverter를 사용하는 것이 편하다. @ResponseBody의 사용 원리는 다음과 같다.
- HTTP 요청이 온다.
- Controller가 호출된다.
- @ResponseBody가 있으면 HttpMessageConverter가 동작한다. 이때 JsonConverter, StringConverter 등이 있는데, 응답이 String으로 나가야할지, Json으로 나가야할지 선택하여 올바른 Converter를 실행한다.
@ResponseBody를 사용하면 viewResolver 대신에 HttpMessageConverter가 동작한다. 이때 기본 문자는 StringHttpMessageConverter가 동작하고, 기본 객체는 MappingJackson2HttpMessageConverter가 동작한다. 이외에도 byte처리 등 여러 HttpMessageConverter가 기본으로 동록된다.
- 응답의 경우 HTTP Accept 헤더와 서버의 컨트롤러 반환 타입 정보 둘을 조합하여 HttpMessageConverter가 선택된다.
스프링 MVC는 아래의 경우 HTTP 메시지 컨버터를 적용한다.
- HTTP 요청: @RequestBody, HttpEntity(RequestEntity)
- HTTP 응답: @ResponseBody, HttpEntity(ResponseEntity)
HttpMessageConverter 인터페이스에는 다음과 같은 중요 메서드들이 있다.
- canRead(), canWrite(): 메시지 컨버터가 해당 클래스, 미디어타입을 지원하는지 체크한다.
- read(), write(): 메시지 컨버터를 통해 메시지를 읽고 쓴다.
스프링 부트의 기본 메시지 컨버터에는 다음과 같은 것들이 있다. 스프링 부트는 이전에 말했듯이 대상 클래스 타입과 미디어 타입(요청의 경우 Content-Type, 응답의 경우 Accept) 두 가지를 체크해서 사용할 컨버터를 결정한다. 만약 만족되지 않으면 우선 순위에 따라 다음 컨버터로 넘어간다.
- 0 = ByteArrayHttpMessageConverter, byte[] 데이터를 처리한다.
- 클래스 타입: byte[] / 미디어 타입: */*
- 요청: @RequestBody byte[] data
- 응답: @ResponseBody return byte[] / 미디어 타입: application/octet-stream
- 1 = StringHttpMessageConverter, 문자로 데이터를 처리한다.
- 클래스 타입: String / 미디어 타입: */*
- 요청: @RequestBody String data
- 응답: @ResponseBody return "ok" / 미디어 타입: text/plain
- 2 = MappingJackson2HttpMessageConverter, application/json을 처리한다.
- 클래스 타입: 객체 또는 HashMap / 미디어 타입: application/json 관련
- 요청: @RequestBody User user
- 응답: @ResponseBody return user / 미디어 타입: application/json 관련
HTTP 요청 데이터를 읽는다고 할 때 다음과 같은 과정을 거친다.
- HTTP 요청이 오고, 컨트롤러를 호출한다.
- 컨트롤러에 @RequestBody 혹은 HttpEntity 파라미터를 사용했다.
- 메시지 컨버터가 메시지를 읽을 수 있는지 확인하기 위해 canRead()를 호출한다. 대상 클래스 타입을 지원하고, HTTP 요청의 Content-Type 미디어 타입을 지원하는지 확인한다.
- canRead() 조건에 맞으면 read()를 호출한다.
HTTP 응답 데이터를 생성하는 경우 다음 과정을 거친다.
- 컨트롤러에서 @ResponseBody혹은 HttpEntity로 값이 반환된다.
- 메시지 컨버터가 메시지를 생성할 수 있는지 확인하기 위해 canWrite()를 호출한다. 이때 대상 클래스 타입을 지원하고, HTTP 요청의 Accept 미디어 타입을 지원하는지 확인한다.
- canWrite() 조건을 만족하면 write()를 호출해서 HTTP 응답 메시지 바디에 데이터를 생성한다.
13. 요청 매핑 헨들러 어댑터 구조
HttpMessageConverter는 스프링 MVC의 어느 과정에서 사용될까? spring mvc의 기본 작동 구조는 다음과 같다.
위 과정을 보면 핸들러 어댑터를 통해 실제 컨트롤러가 호출된다. 이 컨트롤러를 호출하는 곳에 비밀이 있다. 어노테이션 기반의 컨트롤러의 여러 파라미터를 만들어서 호출할 수 있는 핸들러 어댑터(RequestMappingHandlerAdapter)가 뭔가 converter와 관련이 있는 것이다. RequestMappingHandlerAdapter의 동작 방식은 다음과 같다.
- HttpServletRequest, HttpServletResponse, Model, @ModelAttribute, @RequestBody 등 컨트롤러로 전달해야 하는 데이터들은 ArgumentResolver가 생성한다. 이렇게 생성된 데이터들은 컨트롤러를 호출할 때 같이 넘겨준다. 스프링은 30개가 넘는 ArgumentResolver(정확히는 HandlerMethodArgumentResolver이다.)를 제공한다.
- ArgumentResolver는 supportsParameter()를 호출하여 해당 파라미터를 지원하는지 체크한다. 지원한다면 resolveArgument()를 호출하여 객체를 생성한다. 이렇게 생성된 객체는 컨트롤러 호출시 넘겨준다. 원하다면 이 인터페이스를 확장해 원하는 ArgumentResolver를 만들 수 있다.
- ReturnValueHandler(정확히는 HandlerMethodReturnValueHandler)는 ArgumentResolver와 비슷하게 응답 값을 변환하고 처리한다. 컨트롤러에서 String으로 뷰 이름을 반환해도 동작하는 이가 바로 ReturnValueHandler 때문이다. 스프링은 10여개가 넘는 ReturnValueHandler를 지원한다.
HTTP 메시지 컨버터는 바로 ArgumentResolver와 ReturnValueHandler에서 사용하게 된다. 즉, 컨트롤러의 어노테이션이나 파라미터에 @RequestBody나 HttpEntity가 있다면 ArgumentResolver는 HttpMessageConverter를 사용할 것이다. 혹은 컨트롤러가 반환형이 HttpEntity이거나 @ResponseBody 어노테이션을 사용한다면 ReturnValueHandler는 HttpMessageConverter를 사용하게 된다.
- @RequestBody, @ResponseBody의 경우 RequestResponseBodyMethodProcess를 사용한다. 반면 HttpEntity가 있으면 HttpEntityMethodProcess를 사용한다.
- 스프링은 HandlerMethodArgumentResolver, HandlerMethodReturnValueHandler, HttpMessageConverter 모두 인터페이스로 제공한다. 이들은 필요시 언제든지 기능을 확장할 수 있다. 하지만 필요한 기능은 대부분 스프링이 이미 제공하므로 확장할 일이 많진 않다.
출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1/dashboard
'BackEnd > Spring' 카테고리의 다른 글
[Spring MVC-2] 타임리프 - 기본 기능 (0) | 2023.09.25 |
---|---|
[Spring MVC-1] 스프링 MVC - 웹 페이지 만들기 (0) | 2023.09.24 |
[Spring MVC-1] 스프링 MVC - 구조 이해 (0) | 2023.09.22 |
[Spring MVC-1] MVC 프레임워크 만들기 (0) | 2023.09.19 |
[Spring MVC-1] 서블릿, JSP, MVC 패턴 (0) | 2023.09.12 |