MockHttpServletRequest: HTTP Method = GET Request URI = /hello/1234 Parameters = {} Headers = {} Body = <no character encoding set> Session Attrs = {}
Handler: Type = me.nimkoes.MyHelloController Method = public me.nimkoes.UserInfo me.nimkoes.MyHelloController.hello(int)
Async: Async started = false Async result = null
Resolved Exception: Type = null
ModelAndView: View name = null View = null Model = null
FlashMap: Attributes = null
MockHttpServletResponse: Status = 200 Error message = null Headers = {Content-Type=[application/json;charset=UTF-8]} Content type = application/json;charset=UTF-8 Body = {"id":1234,"name":null} Forwarded URL = null Redirected URL = null Cookies = []
URI 정보에서 argument 를 추출해내는 것과 관련해서 @PathVariable 이외에 @MatrixVariable 이 있다.
MatrixVariable api 문서를 보면 이것 역시 @RequestMapping annotation 이 붙은 handler method 에 사용할 수 있고, URI path 상에 name-value 쌍으로 이루어진 내용을 참고해서 argument 로 사용할 수 있다.
자세한 내용은 문서를 보는게 가장 좋겠지만 쉽지 않은 일일 수 있기 때문에, 자주 사용하는 것 위주로 정리를 할 계획이다.
개인적으로 활용하는 파트도 중요하지만 그 앞의 원리와 설정에 대해 아는게 더 중요하다고 생각한다.
handler method 의 argument 와 return type 을 실제로 활용하는 방법에 대해 정리하기 앞서 누가 어디서 뭘 어떻게 했길래 가능한건지부터 살펴보려 한다.
디버그 모드로 서버를 실행한 다음 한줄씩 실행 과정을 따라가 보면서 확인해보기 위해 가장 기본적인 형태의 handler method 를 만들고 DispatcherServlet 클래스의 doService 메소드에 break point 를 걸어두었다.
package me.nimkoes;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MyHelloController {
@GetMapping("/hello")
@ResponseBody
public String hello() {
return "hi";
}
}
HTTP GET 요청으로 /hello 를 보내면 "hi" 라는 문자열을 HTTP response body 에 실어 보내는 handler method 이다.
DispatcherServlet 의 doService 메소드에 있는 doDispatch 안으로 들어간다.
하는 일을 보면 크게
1. 요청을 처리 할 handler method 찾기 :: getHandler
2. 요청을 처리할 수 있는 adapter 찾기 :: getHandlerAdapter
3. 실제로 요청을 처리하기 :: ha.handle
4. 응답으로 보낼 결과 처리하기 :: processDispatchResult
정도인 것으로 보인다.
지금은 실제로 handler method 를 처리할 때 argument 를 어떻게 처리하는지 알아보는 것을 목적으로 하고 있기 때문에
// Actually invoke the handler. 라고 되어있는 ha.handle 의 안쪽으로 들어가 봤다.
한줄씩 실행하다보니 관련된 것 같아 보이는 부분을 발견 하였다.
argument 를 처리할 수 있는 argumentResolver 와 return value 를 처리하는 returnValueHandlers 를 등록해 주는 부분이 있었다.
한가지 의문은 등록 해준적도 없는 저 구현체들은 어디서 나왔냐는 건데, 실제로 handler method 를 실행하는 invokeHandlerMethod 메소드는 RequestMappingHandlerAdapter 클래스에 정의되어 있는데, 이 클래스에 getDefaultArgumentResolvers 메소드와 getDefaultReturnValueHandlers 메소드에서 등록해주고 있다.
그리고 parameterNameDiscoverer 를 등록하는 부분이 있는데, 아무래도 paramter 의 이름을 찾는 일을 하는 것 같다.
실제로 등록되는 클래스를 보면 Reflection 이나 Local 변수와 관련된 Table 정보를 참고해서 parameter name 을 찾는 것 같아 보인다.
이렇게 전처리?를 다 하고나면, 정말로 handler method 를 실행하는 부분인 invocableMethod.invokeAndHandle 메소드 에서 요청 처리를 시작 한다.
invokeAndHandle 은 요청을 처리한 다음 앞서 등록한 returnValueHandlers 로 return value 를 처리하도록 되어 있다.
getMethodArgumentValues 로 args 들을 추출해낸 다음 main 메소드 실행하 듯 doInvoke 를 호출하는 것을 볼 수 있다.
this.resolvers 에는 앞서 default 로 자동 등록해준 26개의 argument 를 처리할 수 있는 구현체들의 LinkedList 이다.
지금 만든 handler method 는 parameter 가 없기 때문에 return EMPTY_ARGS 를 실행 하겠지만, 만약 그렇지 않았다면 처리 가능한 parameter 인지 검사하고, 처리 가능한 경우 arg 배열에 하나씩 그 값을 추가 한다.
각 구현체가 어떻게 argument 를 처리하는지 까지는 다루지 않고 어떻게 사용하는지에 대해서만 정리할 예정이다.
드디어 모든 준비가 끝나면 doInvoke(args) 를 실행해서 handler method 를 실행 한다.
java Reflection 을 사용해서 method 의 modifier 가 (접근 제한자) public 이 아닌 경우 public 접근이 가능하도록 수정한 다음 handler method 를 실행 한다.
테스트 해보기 위해 만든 handler method 의 반환 값인 "hi" 문자열이 returnValue 에 담겼고, 이제 returnValueHandlers 의 handleReturnValue 로 이 값을 처리한다.
이후 처리되는 과정도 한줄씩 실행해보면서 보았지만 더 깊은 내용을 정리하는건 좋지 않다고 생각했다.
지금은 DispatcherServlet 의 doService 메소드를 시작점으로 break point 를 잡았지만, 사실 DispatcherServlet 은 단지 하나의 Servlet 일 뿐이라는 것을 생각해보면 filter, interceptor 등 전후에 실행하는 Servlet lifecycle 을 따라야 한다는 것 정도만 기억하고 넘어가도 괜찮다는 생각이다.
아무튼, 기나긴 여정을 지나 실행 결과를 받아볼 수 있었다.
본 글을 마무리 하자면, 맨 위에 목록으로 정리 한 handler method 의 Argument 와 Return value 를 별다른 설정 없이 너무나 당연한 권리 찾듯 사용하는게 어떻게 가능했는지에 대해 간략하게 살펴 보았다.
어떻게 해서 사용할 수 있었는지 이제는 알기 때문에 어떻게 활용할 수 있는지에 대해 정리해 보려 한다.
MyHelloController 를 제외한 Person, PersonFormatter, GreetingInterceptor, AnotherInterceptor 를 삭제하고 WebConfig 의 Interceptor, ResourceHandler, Jaxb 설정도 삭제했다.
MyHelloControllerTest 클래스의 내용도 모두 삭제 했다.
MyHelloController 에 다음과 같이 handler method 를 하나 등록 하였다.
package me.nimkoes;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class MyHelloController {
@RequestMapping("/hello")
@ResponseBody
public String hello() {
return "hello";
}
}
@Controller annotation 을 사용해서 component scan 대상으로 만들고, 그 안에 @RequestMapping annotation 을 사용해서 uri 를 지정해주면 handler method 가 된다.
만약 11라인의 @ResponseBody 를 사용하지 않으면 return 하는 문자열에 해당하는 view 를 찾고, 사용 할 경우 반환하는 값을 HTTP response body 에 실어 응답 데이터로 보내게 된다.
추가로 @RequestMapping 을 사용해서 uri 만 지정할 경우 모든 종류의 HTTP method 를 허용하게 된다.
HTTP method 의 종류로는 GET, POST, HEAD, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH 등이 있다.
만약 특정한 요청에 대해서만 허용하게 하려면 @RequestMapping annotation 에 값을 추가하면 된다.
package me.nimkoes;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class MyHelloController {
@RequestMapping(value = "/hello", method = RequestMethod.GET)
@ResponseBody
public String hello() {
return "hello";
}
}
RequestMethod 는 enum 타입으로 HTTP request method 를 정의하고 있다.
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.bind.annotation;
/**
* Java 5 enumeration of HTTP request methods. Intended for use with the
* {@link RequestMapping#method()} attribute of the {@link RequestMapping} annotation.
*
* <p>Note that, by default, {@link org.springframework.web.servlet.DispatcherServlet}
* supports GET, HEAD, POST, PUT, PATCH and DELETE only. DispatcherServlet will
* process TRACE and OPTIONS with the default HttpServlet behavior unless explicitly
* told to dispatch those request types as well: Check out the "dispatchOptionsRequest"
* and "dispatchTraceRequest" properties, switching them to "true" if necessary.
*
* @author Juergen Hoeller
* @since 2.5
* @see RequestMapping
* @see org.springframework.web.servlet.DispatcherServlet#setDispatchOptionsRequest
* @see org.springframework.web.servlet.DispatcherServlet#setDispatchTraceRequest
*/
public enum RequestMethod {
GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE
}
지금은 @RequestMapping annotation 을 사용해서 HTTP 의 어떤 타입의 요청을 처리할지 mehotd 값을 지정해 주었지만 한가지 타입의 요청만 처리하도록 한다면 @GetMapping 과 같은 다른 annotation 을 사용해서 코드를 간결하게 작성할 수 있다.
package me.nimkoes;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class MyHelloController {
@GetMapping("/hello")
@ResponseBody
public String hello() {
return "hello";
}
}
@GetMapping 을 포함하여 @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping 등이 있다.
그리고 이 모든 HTTP request method 에 매칭하는 annotation 은 @RequestMapping annotation 을 meta annotation 으로 사용하고 있다.
@GetMapping 을 대표로 문서를 보면 @RequestMapping(method = RequestMethod.GET) 의 축약 표현이라고 정의되어 있다.
추가로 @RequstMapping 은 method 가 아닌 class 레벨에 작성할 수도 있다.
package me.nimkoes;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
@RequestMapping(method = RequestMethod.GET)
public class MyHelloController {
@RequestMapping("/hello")
@ResponseBody
public String hello() {
return "hello";
}
@PostMapping
@ResponseBody
public String hi() {
return "hi";
}
}
class 레벨에 @ReauestMapping 으로 GET 요청만 받을 수 있도록 정의 했기 때문에 이 안에 작성된 모든 handler 는 GET 요청만 처리할 수 있게 된다.
실제로 그런지 궁금해서 일부러 @PostMapping 을 추가하고 테스트 코드를 작성해봤다.
사용하기에 따라 굉장히 복잡한 규칙을 만들 수 있겠지만 그런 경우가 얼마나 있을까 싶은 생각이다.
package me.nimkoes;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MyHelloController {
@GetMapping("/hello?")
public String hello_case_1() { return "hello case 1"; }
@GetMapping("/hello/?")
public String hello_case_2() { return "hello case 2"; }
@GetMapping("/hello/??")
public String hello_case_3() { return "hello case 3"; }
@GetMapping("/hello/?/?")
public String hello_case_4() { return "hello case 4"; }
@GetMapping("/hello*")
public String hello_case_5() { return "hello case 5"; }
@GetMapping("/hello/*/test")
public String hello_case_6() { return "hello case 6"; }
@GetMapping("/hello/*/te*st")
public String hello_case_7() { return "hello case 7"; }
@GetMapping("/hello/**")
public String hello_case_8() { return "hello case 8"; }
@GetMapping("/hello/**/test")
public String hello_case_9() { return "hello case 9"; }
}
어떻게 사용할 수 있는지 테스트 해보기 위해 몇가지 경우만 만들어 보았다.
package me.nimkoes;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
@RunWith(SpringRunner.class)
@WebMvcTest
public class MyHelloControllerTest {
@Autowired
MockMvc mockMvc;
@Test
public void helloTest() throws Exception {
mockMvc.perform(get("/hello1")) .andExpect(content().string("hello case 1"));
mockMvc.perform(get("/hello!")) .andExpect(content().string("hello case 1"));
mockMvc.perform(get("/hello/z")) .andExpect(content().string("hello case 2"));
mockMvc.perform(get("/hello/nk")) .andExpect(content().string("hello case 3"));
mockMvc.perform(get("/hello/n/k")) .andExpect(content().string("hello case 4"));
mockMvc.perform(get("/hello")) .andExpect(content().string("hello case 5"));
mockMvc.perform(get("/helloTest")) .andExpect(content().string("hello case 5"));
mockMvc.perform(get("/hello/nimkoes/test")) .andExpect(content().string("hello case 6"));
mockMvc.perform(get("/hello/nk/te_some_st")) .andExpect(content().string("hello case 7"));
mockMvc.perform(get("/hello/nk/some")) .andExpect(content().string("hello case 8"));
mockMvc.perform(get("/hello/nk/some/another")) .andExpect(content().string("hello case 8"));
mockMvc.perform(get("/hello/nk/some/another/test")).andExpect(content().string("hello case 9"));
}
}
uri 패턴 매핑 방법 중에 확장자를 매핑하는 방법도 있다.
이 방법은 요청 uri 가 특정 파일의 확장자를 포함하는 경우인데 /hello.* 와 같은 uri 패턴을 사용할 경우 /hello.json, /hello.zip 등의 uri 를 사용할 수 있는 것을 뜻한다.
하지만 확장자를 사용한 uri 패턴을 사용할 경우 RFD 공격과 같은 보안 이슈가 있기 때문에 권장하는 방법이 아니며, Spring Boot 에서는 이런 uri 의 요청을 사용하지 못하도록 막고 있다.
@RequestMapping 또는 @RequestMapping 을 meta annotation 으로 사용하는 @GetMapping 과 같은 annotation 을 조금 더 자세히 보면 consumes 와 produces 라는 값을 설정할 수 있다.
// 생략...
package org.springframework.web.bind.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
// 생략...
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
public @interface RequestMapping {
// 생략...
/**
* The consumable media types of the mapped request, narrowing the primary mapping.
* <p>The format is a single media type or a sequence of media types,
* with a request only mapped if the {@code Content-Type} matches one of these media types.
* Examples:
* <pre class="code">
* consumes = "text/plain"
* consumes = {"text/plain", "application/*"}
* </pre>
* Expressions can be negated by using the "!" operator, as in "!text/plain", which matches
* all requests with a {@code Content-Type} other than "text/plain".
* <p><b>Supported at the type level as well as at the method level!</b>
* When used at the type level, all method-level mappings override
* this consumes restriction.
* @see org.springframework.http.MediaType
* @see javax.servlet.http.HttpServletRequest#getContentType()
*/
String[] consumes() default {};
/**
* The producible media types of the mapped request, narrowing the primary mapping.
* <p>The format is a single media type or a sequence of media types,
* with a request only mapped if the {@code Accept} matches one of these media types.
* Examples:
* <pre class="code">
* produces = "text/plain"
* produces = {"text/plain", "application/*"}
* produces = MediaType.APPLICATION_JSON_UTF8_VALUE
* </pre>
* <p>It affects the actual content type written, for example to produce a JSON response
* with UTF-8 encoding, {@link org.springframework.http.MediaType#APPLICATION_JSON_UTF8_VALUE} should be used.
* <p>Expressions can be negated by using the "!" operator, as in "!text/plain", which matches
* all requests with a {@code Accept} other than "text/plain".
* <p><b>Supported at the type level as well as at the method level!</b>
* When used at the type level, all method-level mappings override
* this produces restriction.
* @see org.springframework.http.MediaType
*/
String[] produces() default {};
}
consumes 와 produces 모두 header 의 Mediatype 을 참조해서 handler 를 매핑하는데 사용한다.
차이가 있다면 consumes 는 HTTP 요청의 Content-Tpye 을 사용하고 produces 는 Accept 를 사용 한다.
package me.nimkoes;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class MyHelloController {
@GetMapping(value = "/hello", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public String hello() {
return "hello";
}
}
HTTP GET 요청을 받는 /hello handler method 는 consumes 로 APPLICATION_JSON_UTF8_VALUE 를 사용하고 있다.
사실 MediaType 타입의 값 대신 문자열을 사용해도 되지만 오타가 있을 경우 찾아내기 매우 어려울 수 있기 때문에 되도록 정의 된 상수를 사용하는걸 권장한다.
APPLICATION_JSON_UTF8_VALUE 는 "application/json;charset=UTF-8" 값이 할당되어 있다.
그래서 이 handler 는 HTTP GET 방식의 uri 가 /hello 여야 하기도 하지만, Content-Type 이 json 형식이라고 명시되어 있어야 매핑할 수 있다.
28라인에서 차라리 accept 를 설정하지 않았다면 테스트는 오히려 성공 했을 텐데, 이 요청에 대한 응답 결과 json 형식의 데이터를 기대한다고 명시했기 때문에 406 Not Acceptable 오류가 발생 한다.
MockHttpServletRequest: HTTP Method = GET Request URI = /hello Parameters = {} Headers = {Content-Type=[application/json], Accept=[application/json]} Body = <no character encoding set> Session Attrs = {}
생략...
MockHttpServletResponse: Status = 406 Error message = null Headers = {} Content type = null Body = Forwarded URL = null Redirected URL = null Cookies = []
생략...
java.lang.AssertionError: Status Expected :200 Actual :406
정리하면 @RequestMapping 또는 이 annotation 을 meta annotation 으로 사용하는 @GetMapping, @PostMapping 등을 사용할 때 consumes 와 produces 값을 추가로 설정할 수 있는데,
consumes 는 HTTP request header 의 Content-Type 을 참조하고, produces 는 HTTP request header 의 Accept 값을 참조 한다.
Content-Type 이 일치하지 않는 경우 415 Unsupported Media Type 오류가 발생하고
Accept 값이 일치하지 않는 경우 406 Not Acceptable 오류가 발생 한다.
@RequestMapping 에 설정할 수 있는 값으로 headers 가 있다.
// 생략...
package org.springframework.web.bind.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
// 생략...
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
public @interface RequestMapping {
// 생략...
/**
* The headers of the mapped request, narrowing the primary mapping.
* <p>Same format for any environment: a sequence of "My-Header=myValue" style
* expressions, with a request only mapped if each such header is found
* to have the given value. Expressions can be negated by using the "!=" operator,
* as in "My-Header!=myValue". "My-Header" style expressions are also supported,
* with such headers having to be present in the request (allowed to have
* any value). Finally, "!My-Header" style expressions indicate that the
* specified header is <i>not</i> supposed to be present in the request.
* <p>Also supports media type wildcards (*), for headers such as Accept
* and Content-Type. For instance,
* <pre class="code">
* @RequestMapping(value = "/something", headers = "content-type=text/*")
* </pre>
* will match requests with a Content-Type of "text/html", "text/plain", etc.
* <p><b>Supported at the type level as well as at the method level!</b>
* When used at the type level, all method-level mappings inherit
* this header restriction (i.e. the type-level restriction
* gets checked before the handler method is even resolved).
* @see org.springframework.http.MediaType
*/
String[] headers() default {};
// 생략...
}
이 값은 HTTP request 에 특정한 header 가 있는 경우 또는 그 header 의 값이 특정한 값인 경우에만 handler 를 매핑해주고 싶을 때 사용할 수 있다.
package me.nimkoes;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class MyHelloController {
@GetMapping(value = "/hello", headers = HttpHeaders.AUTHORIZATION)
@ResponseBody
public String hello() {
return "hello";
}
}
이 경우 HTTP request header 에 AUTHORIZATION 이 있는 경우 매핑될 수 있다.
MockHttpServletRequest: HTTP Method = HEAD Request URI = /hello Parameters = {} Headers = {} Body = <no character encoding set> Session Attrs = {}
Handler: Type = me.nimkoes.MyHelloController Method = public java.lang.String me.nimkoes.MyHelloController.hello()
Async: Async started = false Async result = null
Resolved Exception: Type = null
ModelAndView: View name = null View = null Model = null
FlashMap: Attributes = null
MockHttpServletResponse: Status = 200 Error message = null Headers = {Content-Type=[text/plain;charset=UTF-8], Content-Length=[5]} Content type = text/plain;charset=UTF-8 Body = Forwarded URL = null Redirected URL = null Cookies = []
MockHttpServletRequest: HTTP Method = OPTIONS Request URI = /hello Parameters = {} Headers = {} Body = <no character encoding set> Session Attrs = {}
Handler: Type = org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping$HttpOptionsHandler Method = public org.springframework.http.HttpHeaders org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping$HttpOptionsHandler.handle()
Async: Async started = false Async result = null
Resolved Exception: Type = null
ModelAndView: View name = null View = null Model = null
FlashMap: Attributes = null
MockHttpServletResponse: Status = 200 Error message = null Headers = {Allow=[GET,HEAD,POST,OPTIONS]} Content type = null Body = Forwarded URL = null Redirected URL = null Cookies = []
우선 메시지 컨버터는 HTTP request body 또는 response body 에 담긴 데이터를 원하는 형태로 변환하여 사용할 수 있는 기능이다.
MVC 에서 Controller 역할을 하는 handler method 가 요청 정보를 읽을 때는 @RequestBody annotation 을 사용하고, 응답 정보를 보낼 때는 @ResponseBody annotation 을 사용한다.
앞서 만들었던 MyHelloController 에 HTTP Get 요청을 처리할 수 있는 handler method 를 하나 추가했다.
package me.nimkoes;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MyHelloController {
@GetMapping("/hello")
public String hello(@RequestParam("name") Person person) {
System.out.println("handler method execute !!");
return "hello " + person.getName();
}
@GetMapping("/message")
public String message(@RequestBody Person person) {
return "hello person";
}
}
아래쪽에 HTTP GET 방식의 /message 요청을 처리할 수 있는 handler method 를 하나 추가했는데,
@RequestBody annotation 을 사용해서 HTTP request body 의 데이터를 Person 타입의 객체로 변환하도록 했다.
그리고 @ResponseBody annotation 을 사용해서 "hello person" 을 응답 데이터 본문에 실어야 하는데 사용하지 않았다.
그 이유는 MyHelloController 클래스에 @RestController annotation 을 작성했기 때문이다.
이 annotation 을 Controller 역할을 하는 class 에 붙여주면 이 안에 정의한 handler method 는 모두 @ResponseBody annotation 을 작성한 것과 같은 효과를 가진다.
실제로 @RestController annotation 이 어떻게 구현되어 있는지 보면 @ResponseBody annotation 이 붙어있는 것을 볼 수 있다.
/*
* Copyright 2002-2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.bind.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
import org.springframework.stereotype.Controller;
/**
* A convenience annotation that is itself annotated with
* {@link Controller @Controller} and {@link ResponseBody @ResponseBody}.
* <p>
* Types that carry this annotation are treated as controllers where
* {@link RequestMapping @RequestMapping} methods assume
* {@link ResponseBody @ResponseBody} semantics by default.
*
* <p><b>NOTE:</b> {@code @RestController} is processed if an appropriate
* {@code HandlerMapping}-{@code HandlerAdapter} pair is configured such as the
* {@code RequestMappingHandlerMapping}-{@code RequestMappingHandlerAdapter}
* pair which are the default in the MVC Java config and the MVC namespace.
*
* @author Rossen Stoyanchev
* @author Sam Brannen
* @since 4.0
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
/**
* The value may indicate a suggestion for a logical component name,
* to be turned into a Spring bean in case of an autodetected component.
* @return the suggested component name, if any (or empty String otherwise)
* @since 4.0.1
*/
@AliasFor(annotation = Controller.class)
String value() default "";
}
@RestController 는 @ResponseBody 도 있지만 @Controller 타입이기도 한 것을 확인할 수 있다.
만약 @RestController 를 사용하지 않고 그냥 @Controller 를 사용했다면, 다음과 같이 응답 결과를 HTTP response body 영역에 실어 보내야 하는 handler method 에 대해 @ResponseBody annotation 을 명시해 주어야 한다.
그렇지 않고 String 타입의 값을 반환하려 한다면 우선 그 문자열에 매칭되는 View 반환을 시도하게 되어 원하는 결과를 받아볼 수 없을 수 있다.
package me.nimkoes;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class MyHelloController {
@GetMapping("/hello")
@ResponseBody
public String hello(@RequestParam("name") Person person) {
System.out.println("handler method execute !!");
return "hello " + person.getName();
}
@GetMapping("/message")
@ResponseBody
public String message(@RequestBody Person person) {
return "hello person";
}
}
테스트 해보기 위해 @ResponseBody 를 제거 하자마자 똑똑한 IDEA 가 일치하는 View 가 없다고 경고를 보여주고 있다.
실제로 요청을 보낸 결과 다음과 같이 500 에러를 받았다.
{
"timestamp": "2021-07-08T12:45:00.765+0000",
"status": 500,
"error": "Internal Server Error",
"message": "Error resolving template [hello person], template might not exist or might not be accessible by any of the configured Template Resolvers",
"path": "/message"
}
서버에서도 TemplateEngine 쪽에서 오류가 발생한걸 볼 수 있었다.
2021-07-08 21:45:00.763 ERROR 16036 --- [io-8080-exec-10] org.thymeleaf.TemplateEngine : [THYMELEAF][http-nio-8080-exec-10] Exception processing template "hello person": Error resolving template [hello person], template might not exist or might not be accessible by any of the configured Template Resolvers
Spring 에는 따로 등록하지 않아도 기본적으로 제공해주는 메시지 컨버터가 있다.
- 바이트 배열 컨버터
- 문자열 컨버터
- Resource 컨버터
- Form 컨버터 (html form data 를 map 형태로 변환)
메시지 컨버터를 등록해서 사용하는 방법은 WebMvcConfigurer 인터페이스를 구현한다는 것은 동일하지만
1. configureMessageConverters 메소드를 재정의 하거나
2. extendMessageConverters 메소드를 재정의 하는
두 가지 방법이 있다.
이 두 방법은 차이가 있는데 configurerMessageConverters 를 재정의하면 자동 등록 된 메시지 컨버터를 무시하고 이곳에 등록한 메시지 컨버터만 사용 한다는 것을 의미하고, extendMessageConverters 를 재정의하면 자동 등록 된 메시지 컨버터에 더해서 메시지 컨버터가 추가 된다는 점이다.
/**
* Configure the {@link HttpMessageConverter HttpMessageConverters} to use for reading or writing
* to the body of the request or response. If no converters are added, a
* default list of converters is registered.
* <p><strong>Note</strong> that adding converters to the list, turns off
* default converter registration. To simply add a converter without impacting
* default registration, consider using the method
* {@link #extendMessageConverters(java.util.List)} instead.
* @param converters initially an empty list of converters
*/
default void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
}
/**
* A hook for extending or modifying the list of converters after it has been
* configured. This may be useful for example to allow default converters to
* be registered and then insert a custom converter through this method.
* @param converters the list of configured converters to extend.
* @since 4.1.3
*/
default void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
}
하지만 사실 이렇게 직접 메시지 컨버터를 추가하는 일도 흔한 경우는 아니다.
왜냐하면 그 외 일반적으로 많이 사용하는 JAXB2, Jackson2, Jackson, Gson, Atom, RSS 등 메시지 컨버터는 pom.xml 에 depondency 로 해당 의존성이 추가되어 있는 경우 자동으로 등록 되기 때문이다.
다시 말해서 classpath 에 이 라이브러리가 존재 한다면 자동으로 등록 되어 사용할 수 있다는 것을 뜻한다.
classpath 에 라이브러리가 있으면, pom.xml 에 dependency 로 의존성만 추가가 되어있으면 자동으로 메시지 컨버터를 등록해주는 곳은 WebMvcConfigurationSupport 에 구현되어 있다.
WebMvcConfigurationSupport 클래스는 Spring Boot 가 아닌 Spring Web MVC 에서 제공하는 기능이다.
이 클래스 파일에서 관련된 주요한 내용만 추려 보았다.
// 생략...
public class WebMvcConfigurationSupport implements ApplicationContextAware, ServletContextAware {
// 생략..
static {
ClassLoader classLoader = WebMvcConfigurationSupport.class.getClassLoader();
romePresent = ClassUtils.isPresent("com.rometools.rome.feed.WireFeed", classLoader);
jaxb2Present = ClassUtils.isPresent("javax.xml.bind.Binder", classLoader);
jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) &&
ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader);
jackson2XmlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader);
jackson2SmilePresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.smile.SmileFactory", classLoader);
jackson2CborPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.cbor.CBORFactory", classLoader);
gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader);
jsonbPresent = ClassUtils.isPresent("javax.json.bind.Jsonb", classLoader);
kotlinSerializationJsonPresent = ClassUtils.isPresent("kotlinx.serialization.json.Json", classLoader);
}
// 생략..
/**
* Adds a set of default HttpMessageConverter instances to the given list.
* Subclasses can call this method from {@link #configureMessageConverters}.
* @param messageConverters the list to add the default message converters to
*/
protected final void addDefaultHttpMessageConverters(List<HttpMessageConverter<?>> messageConverters) {
messageConverters.add(new ByteArrayHttpMessageConverter());
messageConverters.add(new StringHttpMessageConverter());
messageConverters.add(new ResourceHttpMessageConverter());
messageConverters.add(new ResourceRegionHttpMessageConverter());
if (!shouldIgnoreXml) {
try {
messageConverters.add(new SourceHttpMessageConverter<>());
}
catch (Throwable ex) {
// Ignore when no TransformerFactory implementation is available...
}
}
messageConverters.add(new AllEncompassingFormHttpMessageConverter());
if (romePresent) {
messageConverters.add(new AtomFeedHttpMessageConverter());
messageConverters.add(new RssChannelHttpMessageConverter());
}
if (!shouldIgnoreXml) {
if (jackson2XmlPresent) {
Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.xml();
if (this.applicationContext != null) {
builder.applicationContext(this.applicationContext);
}
messageConverters.add(new MappingJackson2XmlHttpMessageConverter(builder.build()));
}
else if (jaxb2Present) {
messageConverters.add(new Jaxb2RootElementHttpMessageConverter());
}
}
if (kotlinSerializationJsonPresent) {
messageConverters.add(new KotlinSerializationJsonHttpMessageConverter());
}
if (jackson2Present) {
Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.json();
if (this.applicationContext != null) {
builder.applicationContext(this.applicationContext);
}
messageConverters.add(new MappingJackson2HttpMessageConverter(builder.build()));
}
else if (gsonPresent) {
messageConverters.add(new GsonHttpMessageConverter());
}
else if (jsonbPresent) {
messageConverters.add(new JsonbHttpMessageConverter());
}
if (jackson2SmilePresent) {
Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.smile();
if (this.applicationContext != null) {
builder.applicationContext(this.applicationContext);
}
messageConverters.add(new MappingJackson2SmileHttpMessageConverter(builder.build()));
}
if (jackson2CborPresent) {
Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.cbor();
if (this.applicationContext != null) {
builder.applicationContext(this.applicationContext);
}
messageConverters.add(new MappingJackson2CborHttpMessageConverter(builder.build()));
}
}
// 생략..
}
위에 언급한 기본 제공하는 메시지 컨버터들은 Spring 뿐만 아니라 Spring Boot 에서도 동일하게 적용 된다.
Spring Boot 관련해서 내용을 추가 하자면 Spring Boot 는 spring-boot-starter-web 을 통해 spring-boot-starter-json 이 의존성으로 추가 되고, 이 starter json 이 Jackson2 를 가져오도록 되어 있다.
그래서 Spring Boot 를 쓰면 별도의 메시지 컨버터를 등록하거나 dependency 에 의존성 추가 없이 json 형태의 데이터를 위한 메시지 컨버터를 바로 사용할 수 있다.
실제로 메시지 컨버터를 사용하는 예제를 만들어 본다.
앞서 만들었던 /message handler method 를 다음과 같이 수정하여 문자열 데이터를 잘 처리하는지 확인해보자.
package me.nimkoes;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MyHelloController {
@GetMapping("/hello")
public String hello(@RequestParam("name") Person person) {
System.out.println("handler method execute !!");
return "hello " + person.getName();
}
@GetMapping("/message")
public String message(@RequestBody String body) {
return "Hello, " + body;
}
}
HTTP request body 에 문자열을 전달 받은 내용을 사용해서 response body 에 담아 보내는 handler mothod 로 수정했다.
MockHttpServletRequest: HTTP Method = GET Request URI = /message Parameters = {} Headers = {} Body = nimkoes Session Attrs = {}
Handler: Type = me.nimkoes.MyHelloController Method = public java.lang.String me.nimkoes.MyHelloController.message(java.lang.String)
Async: Async started = false Async result = null
Resolved Exception: Type = null
ModelAndView: View name = null View = null Model = null
FlashMap: Attributes = null
MockHttpServletResponse: Status = 200 Error message = null Headers = {Content-Type=[text/plain;charset=UTF-8], Content-Length=[14]} Content type = text/plain;charset=UTF-8 Body = Hello, nimkoes Forwarded URL = null Redirected URL = null Cookies = []
의도한 대로 잘 동작하는 것을 확인할 수 있었다.
그럼 한가지 의문이 생기는데, request 와 response 에 대해 어떤 형태의 데이터를 원하는지 어떻게 알 수 있을까.
결론부터 얘기하면 HTTP 의 header 정보를 참고하여 어떤 형태의 데이터가 요청 본문에 들어오고, 이 요청은 어떤 형태의 데이터를 응답 본문에 담아야 할지 결정한다.
HTTP header 정보를 사용하여 json 형태의 데이터를 객체로 변환하고, 변환 된 객체 정보를 json 형태로 응답 하는 handler method 를 만들어 보았다.
@GetMapping("/jsonMessage")
public Person jsonMessage(@RequestBody Person person) {
System.out.println(person.getName());
person.setName("My name is " + person.getName());
return person;
}
이 handler 는 json 형식으로 된 데이터를 HTTP request body 에 담아 보내면,
Person 타입으로 변환해서 person 객체에 담고, 이 객체를 다시 json 형식으로 변환하여 HTTP response body 에 담아 보내도록 할 것이다.
정상적으로 동작하는지 확인하기 위한 테스트 코드를 작성했다.
package me.nimkoes;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class MyHelloControllerTest {
// @WebMvcTest annotation 을 정의하면 MockMvc 를 주입받을 수 있다.
@Autowired
MockMvc mockMvc;
// Spring Boot 를 사용하여 기본적으로 등록 된 bean 중 Jackson 이 제공하는 ObjectMapper 를 사용
@Autowired
ObjectMapper objectMapper;
@Test
public void jsonMessage() throws Exception {
Person person = new Person();
person.setName("nimkoes");
// 객체를 json 문자열로 변환
String jsonString = objectMapper.writeValueAsString(person);
this.mockMvc.perform(get("/jsonMessage")
.contentType(MediaType.APPLICATION_JSON_UTF8) // context type 을 사용하여 request 데이터가 json 형식임을 알려줌
.accept(MediaType.APPLICATION_JSON_UTF8) // accept 를 사용하여 response 데이터가 json 형식이기를 바란다고 알려줌
.content(jsonString)) // request body 에 jsonString 입력
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("My name is nimkoes"));
}
}
MockHttpServletRequest: HTTP Method = GET Request URI = /jsonMessage Parameters = {} Headers = {Content-Type=[application/json;charset=UTF-8], Accept=[application/json;charset=UTF-8]} Body = {"name":"nimkoes"} Session Attrs = {}
Handler: Type = me.nimkoes.MyHelloController Method = public me.nimkoes.Person me.nimkoes.MyHelloController.jsonMessage(me.nimkoes.Person)
Async: Async started = false Async result = null
Resolved Exception: Type = null
ModelAndView: View name = null View = null Model = null
FlashMap: Attributes = null
MockHttpServletResponse: Status = 200 Error message = null Headers = {Content-Type=[application/json;charset=UTF-8]} Content type = application/json;charset=UTF-8 Body = {"name":"My name is nimkoes"} Forwarded URL = null Redirected URL = null Cookies = []
의도한 대로 동작하는 것을 확인할 수 있었다.
테스트 코드에 사용한 jsonPath 에 대한 내용은 다음 두 링크를 통해 확인할 수 있다.
의존성을 추가 했으니 Spring oxm 이 제공하는 marshaller 를 bean 으로 등록 한다.
현재 사용중인 WebMvcConfigurer 를 구현하고 있는 설정파일인 WebConfig 파일에 등록 했다.
package me.nimkoes;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.CacheControl;
import org.springframework.oxm.jaxb.Jaxb2Marshaller;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public Jaxb2Marshaller jaxb2Marshaller() {
Jaxb2Marshaller jaxb2Marshaller = new Jaxb2Marshaller();
// jaxb 에서 사용하는 @XmlRootElement annotation 의 위치를 알려줘야 변환이 가능하다.
jaxb2Marshaller.setPackagesToScan(Person.class.getPackage().getName());
return jaxb2Marshaller;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new GreetingInterceptor())
.addPathPatterns("/hi")
.order(0);
registry.addInterceptor(new AnotherInterceptor()).order(-1);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/m/**") // 어떤 패턴의 요청을 처리할지 정의
.addResourceLocations("classpath:/m/", "file:/Users/nimkoes/files/", "/myStatic/") // 리소스를 찾을 위치
.setCacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES)); // 리소스가 변경되지 않은 동안 캐싱을 유지할 시간
}
}
Jaxb2Marshaller 를 등록한 내용 중에 @XmlRootElement annotation 에 대해서는 다음에 내용을 추가하겠다.
그리고 Person 클래스에 @XmlRootElement annotation 을 작성해 주었다.
package me.nimkoes;
import javax.xml.bind.annotation.XmlRootElement;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@XmlRootElement
public class Person {
String name;
}
이번에 xml 데이터를 주고 받는 테스트를 해보기 위해 별도의 handler method 를 사용하지 않고, 앞서 json 테스트를 해보기 위해 만들었던 /jsonMessage handler method 를 재사용 하려 한다.
똑같은 handler method 이지만 header 정보와 body 영역의 데이터 형식에 따라 똑같은 요청에 대해 어떻게 결과가 달라지는지 확인해 볼 수 있다.
package me.nimkoes;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.StringWriter;
import javax.xml.transform.Result;
import javax.xml.transform.stream.StreamResult;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.oxm.Marshaller;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class MyHelloControllerTest {
// @WebMvcTest annotation 을 정의하면 MockMvc 를 주입받을 수 있다.
@Autowired
MockMvc mockMvc;
@Autowired
Marshaller marshaller;
@Test
public void xmlMessage() throws Exception {
Person person = new Person();
person.setName("nimkoes");
// 객체를 xml 문자열로 변환
StringWriter stringWriter = new StringWriter();
Result result = new StreamResult(stringWriter);
marshaller.marshal(person, result);
String xmlString = stringWriter.toString();
this.mockMvc.perform(get("/jsonMessage")
.contentType(MediaType.APPLICATION_XML) // context type 을 사용하여 request 데이터가 xml 형식임을 알려줌
.accept(MediaType.APPLICATION_XML) // accept 를 사용하여 response 데이터가 xml 형식이기를 바란다고 알려줌
.content(xmlString)) // request body 에 xmlString 입력
.andDo(print())
.andExpect(status().isOk())
.andExpect(xpath("person/name").string("My name is nimkoes"));
}
}
다른 테스트 코드는 삭제하고 xml 메시지 컨버터를 테스트 할 테스트 코드만 남겨 두었다.
Marshaller 타입의 객체를 주입 받으면, dependency 에 등록한 spring oxm 의 실제 구현체로 등록한 bean 인 Jaxb2Marshaller 객체가 들어온다.
그리고 이 구현체를 가지고 Person 타입 객체를 문자열로 만드는데 사용했다.
MockHttpServletRequest: HTTP Method = GET Request URI = /jsonMessage Parameters = {} Headers = {Content-Type=[application/xml;charset=UTF-8], Accept=[application/xml]} Body = <?xml version="1.0" encoding="UTF-8" standalone="yes"?><person><name>nimkoes</name></person> Session Attrs = {}
Handler: Type = me.nimkoes.MyHelloController Method = public me.nimkoes.Person me.nimkoes.MyHelloController.jsonMessage(me.nimkoes.Person)
Async: Async started = false Async result = null
Resolved Exception: Type = null
ModelAndView: View name = null View = null Model = null
FlashMap: Attributes = null
MockHttpServletResponse: Status = 200 Error message = null Headers = {Content-Type=[application/xml]} Content type = application/xml Body = <?xml version="1.0" encoding="UTF-8" standalone="yes"?><person><name>My name is nimkoes</name></person> Forwarded URL = null Redirected URL = null Cookies = []
똑같은 handler method 를 사용 했지만 header 의 context type 과 accept 정보에 따라 사용 된 메시지 컨버터가 달라지고, 요청 응답 데이터도 원하는 형식으로 쉽게 바꿔 사용할 수 있었다.
이것 역시 WebMvcConfigurer 인터페이스를 오버라이딩 해서 핸들러를 추가할 수 있다.
package me.nimkoes;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new GreetingInterceptor())
.addPathPatterns("/hi")
.order(0);
registry.addInterceptor(new AnotherInterceptor()).order(-1);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/m/**") // 어떤 패턴의 요청을 처리할지 정의
.addResourceLocations("classpath:/m/") // 리소스를 찾을 위치
.setCacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES)); // 리소스가 변경되지 않은 동안 캐싱을 유지할 시간
}
}
테스트 내용이 의도한 대로 동작 하였으며, 아까와 달리 Header 에 추가 등록한 Cache-Control 에 대한 max-age 값도 600초 (10분) 으로 정상적으로 설정된 것을 볼 수 있었다.
추가로 addResourceHandler 로 리소스 핸들러를 등록할 때, 리소스를 찾는 경로로 classpath 뿐만 아니라 file system 을 줄 수도 있다.
그리고 classpath 또는 file 과 같은 값을 주지 않고 그냥 경로만 입력할 경우 src/main/webapp 경로를 사용 한다.
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/m/**") // 어떤 패턴의 요청을 처리할지 정의
.addResourceLocations("classpath:/m/", "file:/Users/nimkoes/files/", "/myStatic/") // 리소스를 찾을 위치
.setCacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES)); // 리소스가 변경되지 않은 동안 캐싱을 유지할 시간
}
위와 같이 작성한 경우 src/main/webapp/myStatic/ 에서도 /m/** 패턴의 요청에 대해 리소스를 찾는 경로로 사용할 수 있다.
문서에 따르면 handler method 의 구현 내용을 수정하지 않고 공통으로 처리 할 작업들을 정의할 수 있다고 되어 있다.
이 인터페이스는 세가지 default method 를 가지고 있는 것으로 보아 굳이 구현하지 않아도 괜찮아 보인다.
package org.springframework.web.servlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.web.method.HandlerMethod;
public interface HandlerInterceptor {
/**
* Intercept the execution of a handler. Called after HandlerMapping determined
* an appropriate handler object, but before HandlerAdapter invokes the handler.
* <p>DispatcherServlet processes a handler in an execution chain, consisting
* of any number of interceptors, with the handler itself at the end.
* With this method, each interceptor can decide to abort the execution chain,
* typically sending a HTTP error or writing a custom response.
* <p><strong>Note:</strong> special considerations apply for asynchronous
* request processing. For more details see
* {@link org.springframework.web.servlet.AsyncHandlerInterceptor}.
* <p>The default implementation returns {@code true}.
* @param request current HTTP request
* @param response current HTTP response
* @param handler chosen handler to execute, for type and/or instance evaluation
* @return {@code true} if the execution chain should proceed with the
* next interceptor or the handler itself. Else, DispatcherServlet assumes
* that this interceptor has already dealt with the response itself.
* @throws Exception in case of errors
*/
default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
return true;
}
/**
* Intercept the execution of a handler. Called after HandlerAdapter actually
* invoked the handler, but before the DispatcherServlet renders the view.
* Can expose additional model objects to the view via the given ModelAndView.
* <p>DispatcherServlet processes a handler in an execution chain, consisting
* of any number of interceptors, with the handler itself at the end.
* With this method, each interceptor can post-process an execution,
* getting applied in inverse order of the execution chain.
* <p><strong>Note:</strong> special considerations apply for asynchronous
* request processing. For more details see
* {@link org.springframework.web.servlet.AsyncHandlerInterceptor}.
* <p>The default implementation is empty.
* @param request current HTTP request
* @param response current HTTP response
* @param handler handler (or {@link HandlerMethod}) that started asynchronous
* execution, for type and/or instance examination
* @param modelAndView the {@code ModelAndView} that the handler returned
* (can also be {@code null})
* @throws Exception in case of errors
*/
default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable ModelAndView modelAndView) throws Exception {
}
/**
* Callback after completion of request processing, that is, after rendering
* the view. Will be called on any outcome of handler execution, thus allows
* for proper resource cleanup.
* <p>Note: Will only be called if this interceptor's {@code preHandle}
* method has successfully completed and returned {@code true}!
* <p>As with the {@code postHandle} method, the method will be invoked on each
* interceptor in the chain in reverse order, so the first interceptor will be
* the last to be invoked.
* <p><strong>Note:</strong> special considerations apply for asynchronous
* request processing. For more details see
* {@link org.springframework.web.servlet.AsyncHandlerInterceptor}.
* <p>The default implementation is empty.
* @param request current HTTP request
* @param response current HTTP response
* @param handler handler (or {@link HandlerMethod}) that started asynchronous
* execution, for type and/or instance examination
* @param ex exception thrown on handler execution, if any
* @throws Exception in case of errors
*/
default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable Exception ex) throws Exception {
}
}
전체적인 실행 흐름을 다음과 같이 그림을 그려볼 수 있다.
preHandle 에서는 보통 Servlet 에서 사용하던 Filter 와 비슷하지만 보다 구체적인 작업을 할 수 있다.
예를 들어 preHandler 의 메소드 파라미터 중에 Object handler 가 있는 것을 볼 수 있다.
즉, Filter 와 다르게 어떤 class 의 어떤 handler method 가 호출 될 것인지에 대한 정보를 전달 받기 때문에 요청에 대해 보다 구체적인 처리가 가능하다.
preHandle 에서 한가지 더 눈여겨 볼 점은 return type 이 boolean 이라는 것인데, 이 메소드 실행 결과 true 를 반환하면 다음 interceptor 의 preHandler 등 처리를 진행하지만, 그렇지 않고 false 를 반환하면 요청 처리를 더 이상 진행하지 않고 멈춘다.
handler method 까지 실행을 마치고 나서 view 를 렌더링 하기 직전에 postHandle 을 실행 한다.
이 postHandle 은 ModelAndView 타입의 객체를 매개변수로 받는다.
그렇기 때문에 이 곳에서는 model 객체에 추가 정보를 넣거나 view 를 변경하는 등의 작업을 할 수 있다.
afterCompletion 은 veiw 렌더링을 마친 다음으로 모든 요청 처리가 끝난 다음에 호출 된다.
그리고 preHandler 의 반환 값이 true 인 경우 postHandle 실행과 상관 없이 실행 된다.
어떠한 요청 처리 전 후에 필요한 작업을 한다는 점에서 Servlet 의 Filter 와 Spring 의 HandlerInterceptor 는 별다른 차이가 없어 보일 수 있다.
하지만 앞서 정리했던 것처럼 Filter 는 HandlerInterceptor 만큼의 정보를 알고 있지 않다.
그렇다고 해서 무조건 HandlerInterceptor 를 쓰는게 좋은것은 아니다.
만약 전후처리 내용이 Spring 과 밀접한 관련이 있다면 당연히 HandlerInterceptor 를 사용하는게 좋고 또 그래야 하겠지만, 그렇지 않은 성격의 전후처리라면 여전히 Servlet 의 Filter 를 사용하는게 문맥상 더 자연스럽다고 생각한다.
예를 들어 XSS 공격은 특정 handler 에 대한 것도 아니고 Spring 과 무관한 내용이므로 HandlerInterceptor 가 아닌 Servlet Filter 에서 처리하도록 구현 되어야 한다.
그럼 마지막으로 HandlerInterceptor 를 구현해보자.
구현하는 방법은 이 인터페이스를 구현하는 클래스를 만들고 Interceptor 로 등록해주면 된다.
이 요청을 의도한 대로 처리할 수 있도록 handler method 를 다음과 같이 수정할 수 있다.
package me.nimkoes;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MyHelloController {
@GetMapping("/hello/{name}")
public String hello(@PathVariable String name) {
return "hello " + name;
}
}
@PathVariable annotation 을 사용해서 url path 의 값을 가져와 사용할 수 있다.
handler method 를 위와 같이 수정하면 테스트가 성공하지만 Formatter 를 사용하도록 고쳐보자.
Formatter 는 서두에 설명해 두었지만, 문자열을 객체로, 객체를 문자열로 변환할 수 있는 인터페이스이다.
즉, 문자열인 name 에 대해 다음과 같이 객체로 변환해서 입력받을 수 있다.
package me.nimkoes;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MyHelloController {
@GetMapping("/hello/{name}")
public String hello(@PathVariable("name") Person person) {
return "hello " + person.getName();
}
}
변환에 사용 할 Person 클래스를 정의 한다.
package me.nimkoes;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Person {
String name;
}
현재 이 상태에서는 name 이 Person 타입의 객체에 들어갈 수 없다.
왜냐하면 컴퓨터는 이걸 어떻게 집어 넣어야 하는지 알 수 없기 때문이다.
그래서 Formatter 를 만들어서 어떻게 변환해야 하는지 알려주기 위해 Formatter<Person> 인터페이스를 구현하는 PersonFormatter 클래스를 새로 정의 했다.
package me.nimkoes;
import java.text.ParseException;
import java.util.Locale;
import org.springframework.format.Formatter;
public class PersonFormatter implements Formatter<Person> {
@Override
public Person parse(String text, Locale locale) throws ParseException {
Person person = new Person();
person.setName(text);
return person;
}
// 현재 사용하고 있지 않지만 임시로 toString 을 반환하도록 구현
@Override
public String print(Person object, Locale locale) {
return object.toString();
}
}
이렇게 만든 Formatter 를 등록하는 방법은 크게 두 가지가 있다.
하나는 WebMvcConfigurer 를 사용해서 addFormatter 로 추가해 주는 방법과
Spring Boot 를 사용할 경우 bean 으로 등록해서 Formatter 를 자동 등록하는 방법이다.
우선 WebMvcConfigurer 를 사용해서 Formatter 를 등록해 보자.
WebMvcConfigurer 인터페이스를 구현하는 설정 파일을 하나 만들어 준다.
package me.nimkoes;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addFormatter(new PersonFormatter());
}
}
addFormatter 에는 Formatter 뿐만 아니라 Converter 도 추가로 등록할 수 있다.
Converter 는 Formatter 보다 일반적인 경우에 사용할 수 있다.
예를 들면 변환 대상이 굳이 문자열이 아니어도 사용할 수 있다. Formatter 에 비해 더 좋아 보이는 듯 해보이기도 하지만 Web 과 관련해서는 문자열을 객체로 변환하는 경우가 더 많기 때문에 Formatter 를 더 많이 사용하게 된다.
Formatter 를 추가했기 때문에 이제는 Spring 은 문자열을 Person 타입의 객체로 어떻게 변환해야 하는지 알게 된다.
다시 한 번 테스트 코드를 실행해보면 이번엔 성공하는 것을 볼 수 있다.
MockHttpServletRequest: HTTP Method = GET Request URI = /hello/nimkoes Parameters = {} Headers = [] Body = null Session Attrs = {}
Handler: Type = me.nimkoes.MyHelloController Method = me.nimkoes.MyHelloController#hello(Person)
Async: Async started = false Async result = null
Resolved Exception: Type = null
ModelAndView: View name = null View = null Model = null
FlashMap: Attributes = null
MockHttpServletResponse: Status = 200 Error message = null Headers = [Content-Type:"text/plain;charset=UTF-8", Content-Length:"13"] Content type = text/plain;charset=UTF-8 Body = hello nimkoes Forwarded URL = null Redirected URL = null Cookies = []
또한 Formatter 는 PathVariable 이 아닌 RequestParam 으로 전달해도 사용 할 수 있다.
RequestParam 은 query string 이라고 생각하면 된다.
테스트 코드의 GET 요청을 query string 을 사용하도록 수정한다.
package me.nimkoes;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import static org.junit.Assert.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
@RunWith(SpringRunner.class)
@WebMvcTest
public class MyHelloControllerTest {
// @WebMvcTest annotation 을 정의하면 MockMvc 를 주입받을 수 있다.
@Autowired
MockMvc mockMvc;
@Test
public void hello() throws Exception {
this.mockMvc.perform(get("/hello")
.param("name", "nimkoes"))
.andDo(print())
.andExpect(content().string("hello nimkoes"));
}
}
handler method 도 @PathVariable 대신 @RequestParam 을 사용하도록 수정 한다.
package me.nimkoes;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MyHelloController {
@GetMapping("/hello")
public String hello(@RequestParam("name") Person person) {
return "hello " + person.getName();
}
}
MockHttpServletRequest: HTTP Method = GET Request URI = /hello Parameters = {name=[nimkoes]} Headers = [] Body = null Session Attrs = {}
Handler: Type = me.nimkoes.MyHelloController Method = me.nimkoes.MyHelloController#hello(Person)
Async: Async started = false Async result = null
Resolved Exception: Type = null
ModelAndView: View name = null View = null Model = null
FlashMap: Attributes = null
MockHttpServletResponse: Status = 200 Error message = null Headers = [Content-Type:"text/plain;charset=UTF-8", Content-Length:"13"] Content type = text/plain;charset=UTF-8 Body = hello nimkoes Forwarded URL = null Redirected URL = null Cookies = []
의도한 대로 동작했고 테스트도 성공 했다.
마지막으로 Spring Boot 를 사용할 때 Formatter 를 등록할 수 있는 방법을 사용해 본다.
무조건 이렇게 할 필요는 없다. 왜냐면 지금 사용중인 프로젝트도 Spring Boot 프로젝트라는 사실을 잊지 말아야 한다.
Spring Boot 프로젝트면 사용할 수 있는 방법이 하나 더 있을 뿐이다.
이 방법은 Formatter 를 bean 으로 등록하면 자동으로 Formatter 에 등록이 되는 방법이다.
그래서 WebMvcConfigurer 를 구현해서 addFormetter 에 추가해 주지 않아도 된다.
WebConfig 파일을 삭제 해버려도 되지만 내용만 지우기로 했다.
package me.nimkoes;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
}
그리고 PersonFormatter 를 @Component annotation 을 사용해서 Component Scan 의 대상이 될 수 있도록 하여 bean 으로 등록한다.
package me.nimkoes;
import java.text.ParseException;
import java.util.Locale;
import org.springframework.format.Formatter;
import org.springframework.stereotype.Component;
@Component
public class PersonFormatter implements Formatter<Person> {
@Override
public Person parse(String text, Locale locale) throws ParseException {
Person person = new Person();
person.setName(text);
return person;
}
// 현재 사용하고 있지 않지만 임시로 toString 을 반환하도록 구현
@Override
public String print(Person object, Locale locale) {
return object.toString();
}
}
Spring Boot 버전이 올라가면서 구현이 조금 바뀐것 같긴 하지만, bean 으로만 등록 되어 있어도 자동으로 Formatter 로 등록해 주는 부분을 찾아 보았다.
Spring Boot 2.1.1.RELEASE 버전 기준으로 org.springframework.boot:spring-boot-autoconfigure 의 META-INF 하위 spring.factories 파일에 정의 된 자동 설정 중 WebMvcAutoConfiguration 클래스가 있다.
이 클래스는 @ConditionalOnMissingBean({WebMvcConfigurationSupport.class}) 가 정의되어 있다.
이 annotation 의 의미는 WebMvcConfigurationSupport 타입의 bean 이 없으면 자동설정을 한다는 것을 의미한다.
그리고 이 클래스는 @EnableWebMvc annotation 을 작성하면 @Import 하는 DelegatingWebMvcConfiguration.class 클래스가 상속 받은 클래스이다.
정리하면 @EnableWebMvc annotation 을 사용하지 않고 Spring Boot 의 Web 자동 설정을 사용하면 WebMvcAutoconfiguration 클래스를 실행해서 Web 관련 자동 설정을 해준다는 것을 의미한다.
beanFactory 에 등록 된 bean 의 타입 중 Formatter 타입의 bean 을 FormatterRegistry 에 자동으로 추가해주는 부분이다.
이런 내용이 있어서 @Component 로 Formatter<Person> 을 구현 한 PersonFormatter 클래스가 Formatter 로 자동 등록 될 수 있는 것이다.
다시 테스트 코드로 돌아가서 실행해보면 예상과 달리 테스트가 실패 한다.
하지만 애플리케이션을 구동해서 브라우저를 통해 실행해보면 의도한 대로 잘 동작하는 것을 확인할 수 있다.
그 이유는 테스트 코드에 @WebMvcTest annotation 을 사용했기 때문이다.
이 annotation 은 slice 테스트를 위한 것으로 Web 과 관련 된 bean 만 등록해서 테스트를 진행 한다.
@Component 는 Web 과 관련 된 bean 으로 인식하지 않기 때문에 의도한 결과를 받아보지 못한 것이다.
slice 테스트 관련된 대표적인 annotation 으로는 @WebMvcTest, @WebFluxTest, @DataJpaTest, @JsonTest, @RestClientTest 등이 있다.
그리고 @WebMvcTest 는 @Controller, @ControllerAdvice, @JsonComponent, Converter, GenericConverter, Filter, WebMvcConfigurer, HandlerMethodArgumentResolver 등에 대해서만 bean 으로 등록 한다.
직접 PersonFormatter 를 테스트에 사용 할 bean 이라고 명시해주는 방법도 있지만 모든 bean 을 등록해서 테스트를 진행하도록 수정해 보았다.
@WebMvcTest 대신 @SpringBootTest annotation 을 사용 했고, 이렇게 하면 더이상 MockMvc 에 대한 의존성을 주입받을 수 없기 때문에 @AutoConfigureMockMvc annotation 을 추가로 작성해 주었다.
package me.nimkoes;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class MyHelloControllerTest {
// @WebMvcTest annotation 을 정의하면 MockMvc 를 주입받을 수 있다.
@Autowired
MockMvc mockMvc;
@Test
public void hello() throws Exception {
this.mockMvc.perform(get("/hello")
.param("name", "nimkoes"))
.andDo(print())
.andExpect(content().string("hello nimkoes"));
}
}
MockHttpServletRequest: HTTP Method = GET Request URI = /hello Parameters = {name=[nimkoes]} Headers = {} Body = null Session Attrs = {}
Handler: Type = me.nimkoes.MyHelloController Method = public java.lang.String me.nimkoes.MyHelloController.hello(me.nimkoes.Person)
Async: Async started = false Async result = null
Resolved Exception: Type = null
ModelAndView: View name = null View = null Model = null
FlashMap: Attributes = null
MockHttpServletResponse: Status = 200 Error message = null Headers = {Content-Type=[text/plain;charset=UTF-8], Content-Length=[13]} Content type = text/plain;charset=UTF-8 Body = hello nimkoes Forwarded URL = null Redirected URL = null Cookies = []
모든 bean 을 등록 했기 때문에 @Component annoation 에 대해서도 bean 으로 등록 되었고, @EnableWebMvc 를 사용하지 않은 Spring Boot 자동 설정 내용을 기준으로 PersonFormatter 를 FormatterRegistry 에 등록해 주어 정상적으로 테스트가 성공하는 것을 확인할 수 있다.
An opinionated WebApplicationInitializer to run a SpringApplication from a traditional WAR deployment. Binds Servlet, Filter and ServletContextInitializer beans from the application context to the server.
To configure the application either override the configure(SpringApplicationBuilder) method (calling SpringApplicationBuilder.sources(Class...)) or make the initializer itself a @Configuration. If you are using SpringBootServletInitializer in combination with other WebApplicationInitializers you might also want to add an @Ordered annotation to configure a specific startup order.
Note that a WebApplicationInitializer is only needed if you are building a war file and deploying it. If you prefer to run an embedded web server then you won't need this at all.
이 클래스에 대한 마지막 설명을 보면 embedded web server 를 사용하려 한다면 굳이 이 클래스를 사용 할 필요가 없다고 하고 있다.
왜냐하면 war 포맷으로 packaging 한다는 것은 웹 애플리케이션 외부에 있는 WAS 에서 실행한다는 의미가 강하고, jar 포맷으로 packaging 한다는 것은 웹 애플리케이션 내부에 있는 WAS 에서 실행한다는 의미이기 때문이다.
IDE 설정을 local 에 설치한 tomcat 으로 맞춰준 다음 실행하면 이번에는 다음과 같이 외부 WAS 에 war 파일이 배포되어 실행되는 것을 확인할 수 있다.
조금 잘려 나오긴 했지만, 자바 애플리케이션을 구동하여 실행했을 때와 로그를 비교해보면 독립 실행 했을 때와 WAS 위에 배포했을 때의 다른 점을 로그를 통해 알 수 있다.
독립 실행 했을 때는 'Spring' 배너가 로깅 되는 것을 시작으로 WAS 에 대한 port 정보 등이 로깅 되었었는데, WAS 에 war 를 배포했을 때는 WAS 가 구동하는 로그가 먼저 출력 되고 Sping boot 애플리케이션을 구동하고 나서는 WAS 에 대한 port 정보가 출력되지 않고 있는것을 볼 수 있다.
마지막에 다룬 war 배포 관련하여 maven 에 대해 정리할 때 관련있는 내용을 조금 다룬적이 있었다.
링크로 남겨둔 게시물의 아래쪽에 정리 된 'war packaging custom' 내용을 참고해도 좋을것 같다.