[Spring Boot] 9. 스프링 웹 MVC-1:

Spring MVC

스프링 MVC 자동 완성

스프링 부트는 대부분의 어플리케이션에서 아주 잘 동작하는 Spring MVC의 자동 완성을
지원한다. 자동설정은 스프링의 기본값들 위에 다음과 같은 기능을 추가한다.

  1. Support for ‘HttpMessageConverters’
  2. Inclusion of ‘ContentNegotiatingViewResolver’ and ‘BeanNameViewResolver’ beans
  3. Support for serving static resources, including support for WebJars
  4. Automatic registration of ‘Converter’, ‘GenericConverter’, and ‘Formatter’ beans.
  5. Automatic registration of ‘MessageCodesResolver’
  6. Static ‘index.html’ support.
  7. Custom ‘Favicon’ support
  8. Automatic use of a ‘ConfigurableWebBindingInitializer’ bean

case study

지금까지 했던 기능을 이용해서 스프링 WebMvC개발을 해보자.
새로 프로젝트 만든후 간단한 src/test에 테스트 클래스를 만든다.

새로 막 만든 UserControllerTest.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package me.rkaehdaos.studyspringmvc.user;

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(UserController.class) //slice test
public class UserControllerTest {

@Autowired
MockMvc mockMvc;
}

테스트 포스트에서 봤듯이 SpringRunner를 이용해서 테스트를하고 통합테스트가 아닌 슬라이스 테스트를 위해 @WebMvcTest를 사용한다. 테스트 안에는 테스트에서 주입받아 사용할 MockMvc를 정의한다.
현재 UserController.class생성하지 않아서 에러가 나니 IDE 도움을받아서 src/main쪽에 UserController.class를 생성한다.

IDE의 도움으로 만들어진 UserController.java
1
2
3
4
5
6
package me.rkaehdaos.studyspringmvc.user;

public class UserController {

}

다시테스트클래스로 돌아와서 간단한 hello test를 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package me.rkaehdaos.studyspringmvc.user;

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.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;

@RunWith(SpringRunner.class)
@WebMvcTest(UserController.class) //slice test
public class UserControllerTest {

@Autowired
MockMvc mockMvc;

@Test
public void hello() throws Exception {
mockMvc.perform(get("/hello"))
.andExpect(status().isOk())
.andExpect(content().string("hello"))
;
}
}
  • mockMvc.perform(get까지만 타이핑 하여도 인텔리 J가 static 임포트로 ‘org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get’를 임포트 해준다.
  • status, content도 알아서 자동완성
  • ide의 에러정정 기능으로 throws Exception까지 완성된다.

테스트를 완성하면 실패가 뜬다. (당연하다.)

1
2
3
java.lang.AssertionError: Status
Expected :200
Actual :404

테스트가 만들어졌으니 이제 테스트에 맞게 컨트롤러를 만들어본다.

TDD(?)로 만들어진 UserController.java
1
2
3
4
5
6
7
8
@RestController
public class UserController {

@GetMapping
public String hello() {
return "hello";
}
}

테스트를 하면 이제 성공이 된다.
이렇듯이 아무런 설정파일이 없이 스프링 MVC개발을 바로 시작할 수 있다.
이것은 지난번 포스팅에서 봤던 자동 포스팅 덕분이다.

스프링 부트 autoconfigure 모듈의 spring.factories안에 설정된 자동설정 키값에서
설정된 WebMvcAutoConfiguration이라는 Configuration클래스를 적용하도록 되어 있기 떄문이다.

WebMvcAutoConfiguration.java의 시작부분
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Configuration
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class, WebMvcConfigurer.class })
@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)
@AutoConfigureAfter({ DispatcherServletAutoConfiguration.class,
TaskExecutionAutoConfiguration.class, ValidationAutoConfiguration.class })
public class WebMvcAutoConfiguration {

public static final String DEFAULT_PREFIX = "";

public static final String DEFAULT_SUFFIX = "";

private static final String[] SERVLET_LOCATIONS = { "/" };

@Bean
@ConditionalOnMissingBean(HiddenHttpMethodFilter.class)
@ConditionalOnProperty(prefix = "spring.mvc.hiddenmethod.filter", name = "enabled", matchIfMissing = true)
public OrderedHiddenHttpMethodFilter hiddenHttpMethodFilter() {
return new OrderedHiddenHttpMethodFilter();
}

@Bean
@ConditionalOnMissingBean(HttpPutFormContentFilter.class)
@ConditionalOnProperty(prefix = "spring.mvc.formcontent.filter", name = "enabled", matchIfMissing = true)
public OrderedFormContentFilter formContentFilter() {
return new OrderedFormContentFilter();
}
  • 이 자동설정 클래스덕에 아무런 설정없이 스프링 WebMvc 프로그램을 작성할 수 있다.

  • HiddenHttpMethodFilter는 스프링 3부터 제공해주는 스프링 프레임워크의 필터다. 데이터를 수정 삭제할때 put,patch, delete를 사용하는데 이를 지원하지 않는 브라우저(특히 옛날 브라우저)는 GET하고 POST밖에 사용을 못한다. 이런 브라우저에서 put, patch를 사용하기 위해 쓰는 필터가 ‘HiddenHttpMethodFilter’다.이는 form태그를 이용해서 데이터를 전송할때 POST로 전송하면서 _method라는 히든 폼 필드를 추가해서 메소드방법을 전달한다. 예를들어 PUT을 GET/POST만 되는 브라우저에서 사용하기 위해서

    1
    <input type="hidden" name="_method" value="put">

    위의 코드 같은 형태를 사용해서 REST방식을 사용할수 있도록 설정할때 이 필터를
    사용한다. 일반적인 스프링 프레임워크에서 개발 할때는 이런 필터를 등록하기 위해선
    web.xml에 필터를 등록했어야하지만 스프링부트에서는 자동설정으로 가능하다.
    컨트롤러에서 그저 ‘@DeleteMapping’, ‘@PutMapping’, ‘@PatchMapping’ 매핑으로
    간단하게 끝낼 수 있다.

  • HttpPutFormContentFilter도 비슷한 부류의 스프링 프레임워크의 필터이다.
    서블릿 스펙에는 form data를 POST로 보낼 수 있으나 PUT, PATCH에서는 불가능하다. 이 필터는 스프링 3.1부터 도입되었던 필터로 이 필터는 request의 body의 form data를 읽어와서 application/x-www-form-urlencoded 컨텐츠의 HTTP PUT, PATCH를 인터셉트해서 ServletRequest로 Wrapping해서 Servlet.getParameter*()등의 메소드로 폼데이터를 만들수 있게 해준다.
    –> 한마디로 PUT,PATCH request를 POST request 같은 형태로 이용할 수 있도록 formdata를 만든다는 이야기

‘HttpPutFormContentFilter’는 Deprecated 되었다.
스프링부트 2.1에서 사용하는 스프링 프레임워크 5.1부터 Deprecated되고,
대신 DELETE 핸들링이 포함된 FormContentFilter로 대체되었다.
FormContentFilter는 원래있던 HttpPutFormContentFilter에
DELETE의 핸들링이 추가 된 것이다.

Spring MVC 확장 : ‘@Configuration’ + WebMvcConfigurer

스프링 부트가 제공하는 여러 기본 기능 외에 개발자 자신이 추가로 확장및 설정하고
싶을때는 다음과 같이 설정 파일을 만든다.

1
2
3
4
5
6
7
8
package me.rkaehdaos.studyspringmvc.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
}
  • 여기에 ‘@EnableWebMvc’을 쓰면 절대 안된다.
    이것은 스프링 자동설정을 멈추고 여기서 내가 커스텀하게 모든것을 세팅하겠다는 뜻이라 스프링 부트의 장점이 전부 사라지게 된다.
  • 제일 귀찮은 설정이 ContentNegotiatingViewResolver 설정.. 설정할게 많아서
  • 왠만하면 자동제공말고 이렇게 추가 확장할 일은 거의 없다.

1. Support for ‘HttpMessageConverters’

Theory

HttpMessageConverter는 스프링 부트가 아닌 스프링 프레임워크에서 제공한다.
전통적인 화면 표시를 동반하는 웹 어플리케이션이 아니라 최근의 REST API 웹서비스는 HTTP 통신을 이용해서 XML과 JSON타입의 정보를 주고받은 웹 어플리케이션이 많다. 구체적으로는 HTTP 요청과 HTTP 응답의 바디 부분에 XML과 JSON형식의 정보를 설정해서 통신한다.

스프링 MVC에서는 어떤식으로 구현이 될까?
HTTP 요청의 바디 정보를 컨트롤러 메서드의 인수로 받아, HTTP 응답의 바디에 설정할 정보를 직접 반환하는 식이 된다.

당연히 HTTP 요청/응답의 바디와 자바 오브젝트를 서로 변환해줄 필요가 있는데 이렇게 변환해주는것이 바로 HttpMessageConverter다.
한마디로 정리하자면 HTTP 요청 본문을 객체로 변경하거나, 객체를 HTTP 응답 본문으로 변경할 때 사용 하며 스프링에서는 @RequestBody, @ResponseBody와 같이 사용된다. (물론 @RestController를 사용시엔 @ResponseBody 사용안해도 된다.)

HttpMessageConverter는 인터페이스이며 여러 구현 클래스가 존재한다.

  • MarshallingHttpMessageConverter
    XML타입 HTTP 메세지를 스프링의 O/X 매핑을 사용해 변환

    O/X매핑: 객체와 XML을 매핑하는 기능으로 스프링의 여러곳에 사용된다.

  • Jaxb2RootElementHttpMessageConverter
    XML타입 HTTP 메세지를 JAXB로 변환

    JAXB: XML문서와 JAVA 객체간 상호 교환하는 자바 JSR222 표준사양

  • MappingJacsonHttpMessageConverter
    JSON형식의 HTTP메세지를 Jackson으로 변환

    Jackson: JSON과 객체간 상호교환을 해주는 오픈소스

스프링 MVC는 이 HttpMessageConverter를 사용한다.
의미 있는 기본값들이 포함되어 있다. 예를들어 Jackson 라이브러리를 사용해서 JSON으로 변화가능한 객체나, JackSon XML 확장이나 혹은 JAXB를 사용하여 XML로 변환할수 있는 객체들은 자동으로 변환되며 디폴트로 String은 UTF-8로 인코딩된다.

만약 커스터마이징 하려면 스프링부트의
HttpMessageConverters를 이용해서 커스터마이징 할 수 있다.
‘HttpMessageConverters’는 ‘Iterable<HttpMessageConverter<?>>’의 구현체로 기존
‘HttpMessageConverter’를 추가하거나 병합하기 위한 편한 방법을 제공한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.springframework.boot.autoconfigure.web.HttpMessageConverters;
import org.springframework.context.annotation.*;
import org.springframework.http.converter.*;

@Configuration
public class MyConfiguration {

@Bean
public HttpMessageConverters customConverters() {
HttpMessageConverter<?> additional = ...
HttpMessageConverter<?> another = ...
return new HttpMessageConverters(additional, another);
}
}

어떤 ‘HttpMessageConverter’도 가능하며 오버라이딩도 가능하다.
특별한 커스터마이징이 필요없으면 그냥 놔두고 개발해도 큰 지장이 없다. 꼭 커스터마이징이 필요하면 HttpMessageConverters reference를 참조한다.

Example

위의 예제에서 User를 생성하는 테스트를 작성해본다.

JSONTest
1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void createUser_JSON() throws Exception {
String userJson = "{\"username\":\"GeunChang\",\"password\":\"1234\" }";
mockMvc.perform(post("/users/create")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.accept(MediaType.APPLICATION_JSON_UTF8)
.content(userJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username", is(equalTo("GeunChang"))))
.andExpect(jsonPath("$.password", is(equalTo("1234"))))
;
}
  • post로 유저 정보 JSON 객체를 보낸다.
  • MediaType은 스프링 프레임워크에서 제공하는 상수 프레임워크의 일부다.
  • 클라이언트가 원하는 타입을 accept에 기록한다.
User.java
1
2
3
4
5
6
@Data //Lombok으로 Getter/Setter
public class User {
private Long id;
private String username;
private String password;
}
  • User는 Consist 객체이다.
컨트롤러
1
2
3
4
5
@RestController
public class UserController {
@PostMapping("/users/create")
public User create(@RequestBody User user) {return user;}
}
  • ‘@RestController’이므로 ‘@ResponseBody’는 필요없다.
  • HTTP 메세지바디에 JSON으로 들어오므로 @RequestBody로 해서 매핑했다.
  • 스프링 부트가 자동으로 기본 컨버터를 사용해서 매핑한다
  • 스프링 부트가 accept의 타입에 맞춰서 뷰리졸버 자동
  • User의 Setter로 값을 설정한다.
  • 그대로 리턴한다.

테스트가 정상 작동됨을 확인할 수 있다.
이처럼 스프링 부트의 자동 설정이 스프링 WebMVC에도 많이 설정 되어 있다.

2. Inclusion of ‘ContentNegotiatingViewResolver’ and ‘BeanNameViewResolver’ beans

base spring schema

스프링 MVC를 기존에 사용해서 뷰를 렌더링 했다면 기본적으로 jsp로 렌더링을 하곤 했을 것이다.
이제 REST API를 개발하면서 @ResponseBody와 함께 JSON, XML로 변환했다
하지만 rss, pdf, doc등 계속 다른 타입의 결과를 만들게 확장된다면…
그리고 이걸 각각의 api로 만들게 된다면 너무 크기도 많아지고 사용하기 불편하다.
또한 같은 api에서 각각의 리퀘스트 핸들러로 처리하기에도 유지보수 하기 불편하다.

Spring MVC에서는 View단 처리를 하는 Resolver가 다수 존재하는데
이때 사용되는 것이 ‘org.springframework.web.servlet.view.ContentNegotiatingViewResolver’다.
예를들어 GET /members/{memberId}와 같이 사용자 정보를 보여주는 URI가 있다고 하면

  • GET /members/{memberId} –> HTML 페이지로 응답
  • GET /members/{memberId}.json –> JSON 객체로 응답
  • GET /members/{memberId}.jsonp –> JSON 객체를 Padding과 함께 응답
    이런식으로 하나의 URI를 통해 다양항 contentType으로 응답할 수 있게 도와준다.

    지금 설명한 것은 어디까지나 설명을 위한 것이다. 위의 .json등도 스프링3에서 처음 등장했을때저런 것이며 현재는 .json이런식으로 하면 매핑도 안된다.

스프링 부트의 View Resolve

위에서 테스트 했던 예제는 잘 작동 했다 이는 accept의 ‘MediaType.APPLICATION_JSON_UTF8’를 보고 ContentNegotiatingViewResolver가 자동으로 JSON으로 응답한 것이다. 그러면 accept의 type을 바꿔보면 어떨까?

accept를 xml로 바꾼 XML 응답 테스트
1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void createUser_XML() throws Exception {
String userJson = "{\"username\":\"GeunChang\",\"password\":\"1234\" }";
mockMvc.perform(post("/users/create")
.contentType(MediaType.APPLICATION_JSON_UTF8) //MediaType: 스프링프레임워크 상수 클래스
.accept(MediaType.APPLICATION_XML) //응답의 타입. 줘도되고 안줘도 되나 주는게 확실함.
.content(userJson)) //응답본문에 넣을거
.andExpect(status().isOk())
.andExpect(xpath("/User/username").string("GeunChang"))
.andExpect(xpath("/User/password").string("1234"))
;
}

스프링 부트가 알아서 해주니까 성공하겠지?? 라고 생각하면 오산!
406 에러가 똭! 뜨면서 ‘org.springframework.web.HttpMediaTypeNotAcceptableException
‘ 예외가 발생한다. 이런 에러가 발생한 경우는 해당하는 MediaType을 처리할 HttpMessageConverter가 없는것이다.
이럴수가 ? 스프링부트는 자동설정을 해주지 않던가? 왜 저게 처리가 안되었을까?

스프링부트은 HttpMessageConverters에 대해서 자동설정을 제공하는데
이는 HttpMessageConvertersAutoConfiguration클래스다.

HttpMessageConvertersAutoConfiguration클래스 첫부분
1
2
3
4
5
6
7
8
@Configuration
@ConditionalOnClass(HttpMessageConverter.class)
@AutoConfigureAfter({ GsonAutoConfiguration.class, JacksonAutoConfiguration.class,
JsonbAutoConfiguration.class })
@Import({ JacksonHttpMessageConvertersConfiguration.class,
GsonHttpMessageConvertersConfiguration.class,
JsonbHttpMessageConvertersConfiguration.class })
public class HttpMessageConvertersAutoConfiguration {

여기에서 Import하고 있는 JacksonHttpMessageConvertersConfiguration클래스에 그 해답이 달려있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

@Configuration
class JacksonHttpMessageConvertersConfiguration {

@Configuration
@ConditionalOnClass(ObjectMapper.class)
@ConditionalOnBean(ObjectMapper.class)
@ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, havingValue = "jackson", matchIfMissing = true)
protected static class MappingJackson2HttpMessageConverterConfiguration {

@Bean
@ConditionalOnMissingBean(value = MappingJackson2HttpMessageConverter.class, ignoredType = {
"org.springframework.hateoas.mvc.TypeConstrainedMappingJackson2HttpMessageConverter",
"org.springframework.data.rest.webmvc.alps.AlpsJsonHttpMessageConverter" })
public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter(
ObjectMapper objectMapper) {
return new MappingJackson2HttpMessageConverter(objectMapper);
}

}

@Configuration
@ConditionalOnClass(XmlMapper.class)
@ConditionalOnBean(Jackson2ObjectMapperBuilder.class)
protected static class MappingJackson2XmlHttpMessageConverterConfiguration {

@Bean
@ConditionalOnMissingBean
public MappingJackson2XmlHttpMessageConverter mappingJackson2XmlHttpMessageConverter(
Jackson2ObjectMapperBuilder builder) {
return new MappingJackson2XmlHttpMessageConverter(
builder.createXmlMapper(true).build());
}

}

}
  • 클래스 안에는 ‘MappingJackson2HttpMessageConverterConfiguration’,’MappingJackson2XmlHttpMessageConverterConfiguration’ 설정 2가지가 존재한다.
  • 첫번째 있는 ‘MappingJackson2HttpMessageConverterConfiguration’는 JSON메세지를 HTTP메세지로 바꿔주는 컨버터다. 이것이 있어서 처음 JSON테스트가 성공했었고 2번째 테스트에서도 JSON데이터의 요청이 성공된 이유다.
  • 두번째 있는 ‘MappingJackson2XmlHttpMessageConverterConfiguration’은 JSON메세지를 XML로 바꿔주는 컨버터다. 이게 있어야 2번째 테스트의 응답이 성공할 수 있는데. 이 컨버터가 없어서 에러가 발생한 것이다.
  • MappingJackson2XmlHttpMessageConverterConfiguration의 어노테이션을 보면 @ConditionalOnClass(XmlMapper.class) 어노테이션을 볼 수 있다. 이 ‘com.fasterxml.jackson.dataformat.xml.XmlMapper’가 클래스패스에 없기떄문에 조건이 만족이 안되서 컨버터 자동설정이 안된 것이다.

XML메세지 컨버터가 추가 되도록 의존성을 추가하자.

pom.xml에 Jackson Dataformat XML을 추가했다.
1
2
3
4
5
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.9.7</version>
</dependency>

이제 테스트를 돌리면 훌륭하게 성공하는 것을 볼 수 있다.
테스트를 성공하면 리퀘스트 리스폰스 정보가 하나도 안보이는데 이게 보고 싶으면 직접적으로 ‘.andDo(print())’를 추가하면 성공/실패에 상관없이 무조건 출력된다.

Related POST

공유하기