이전글
1. [Spring] xml configuration을 java configuration으로 변경하기 (1) ApplicationContext
지난 글에 이어서 web.xml과 dispatcher-servlet.xml을 자바 설정으로 변경할 것이다.
dispatcher-servlet.xml에 포함되어 있던 웹 관련 설정을 옮겨줄 것이다. 사실상 서블릿을 "포함"하는 웹 설정은 이 파일에서 정의되기 때문에 핵심 구성이라고 할 수 있다.
WebMvcConfigurer
를 구현하되 설정이 필요한 부분만 메소드 오버라이딩을 사용해 재정의해주면된다.
아래는 dispatcher-serlvet.xml의 설정을 다섯가지 섹션으로 나눠 본 것이다.
순서대로 옮겨보자!
addInterceptors(InterceptorResitry registry)
를 오버라이딩한다.
// xml
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/**/api/**"/>
<mvc:exclude-mapping path="/api/users/**"/>
<bean class="com.freeboard04_java_config.util.interceptor.AuthInterceptor"></bean>
</mvc:interceptor>
</mvc:interceptors>
// java
@Override
public void addInterceptors(InterceptorRegistry registry){
registry.addInterceptor(new AuthInterceptor())
.addPathPatterns("/**/api/**")
.excludePathPatterns("/api/users/**");
}
xml 설정 파일에 보면 인터셉터 내부에서 커스텀 클래스를 빈으로 정의하고 있다. 따라서 java 구성에서 Interceptor을 추가할 때 커스텀 클래스(AuthInterceptor)를 인자로 넣어준다.
configureMessageConverters(List<HttpMessageConverter<?>> converters)
와 addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers)
를 오버라이딩한다.
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
converters.add(new MappingJackson2HttpMessageConverter(objectMapper()));
}
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
objectMapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true);
return objectMapper;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(pageableHandlerMethodArgumentResolver());
}
@Bean
public PageableHandlerMethodArgumentResolver pageableHandlerMethodArgumentResolver() {
PageableHandlerMethodArgumentResolver pageableHandlerMethodArgumentResolver = new PageableHandlerMethodArgumentResolver();
pageableHandlerMethodArgumentResolver.setMaxPageSize(10000);
return pageableHandlerMethodArgumentResolver;
}
@ComponentScan
어노테이션을 사용하여 basePackages 내 컨트롤러를 빈으로 등록하도록 정의한다.
@Configuration
@EnableWebMvc
@ComponentScan(basePackages = {"com.freeboard04_java_config.api", "com.freeboard04_java_config.controller"})
public class WebConfig implements WebMvcConfigurer {
// 생략 ....
}
❗️ NOTE
@EnableWebMvc
어노테이션 기반의 SpringMvc를 구성할 때 필요한 빈 설정들을 자동으로 해주는 어노테이션. 기본적으로 등록해주는 빈 외에도 추가적으로 개발자가 필요로 하는 빈의 등록을 손쉽게 할 수 있도록 지원한다.web.xml의 <mvc:annotation-driven/> 태그와 동일한 일을 한다.
WebMvcConfigurer 인터페이스는 해당 어노테이션에서 제공하는 빈을 커스터마이징(설정)할 수 있는 기능을 제공하는 인터페이스이다. 인터페이스를 사용했지만 추상 클래스처럼 사용할 수 있는 이유는 각각의 메소드를 default로 작성했기 때문이다.
configureViewResolvers(ViewResolverRegistry registry)
를 오버라이딩한다.
ViewResolverRegistry에 원하는 리졸버 인스턴스를 넣어주면 되는데, 필자는 핸들바를 뷰템플릿으로 사용했기 때문에 "HandlebarsViewResolver"로 셋팅하였다.
//xml
<bean class="com.github.jknack.handlebars.springmvc.HandlebarsViewResolver">
<property name="prefix" value="/WEB-INF/view/"/>
<property name="suffix" value=".hbs"/>
</bean>
//java
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
HandlebarsViewResolver viewResolver = new HandlebarsViewResolver();
viewResolver.setPrefix("/WEB-INF/view/");
viewResolver.setSuffix(".hbs");
registry.viewResolver(viewResolver);
}
예외처리를 커스텀 설정으로 다루고 있기때문에 이를 빈으로 등록해줘야한다. 같은 configuration에서 쓰는게 좀 이상하긴 한데..🤔 xml에 있는 내용을 그대로 옮기는 작업을 하고 있으므로 일단은 이 클래스에서 빈으로 등록해주도록 하겠다. (원래는 분리하는게 맞는듯)
//xml
<bean class="com.freeboard04_java_config.util.exception.ExceptionHandler"></bean>
//java
@Bean
public ExceptionHandler exceptionHandler(){
return new ExceptionHandler();
}
❗️ NOTE
ref. https://galid1.tistory.com/532어떻게 WebMvcConfigurer 인터페이스를 구현하는 것만으로도 간편하게 설정이 추가되는걸까?
@EnableWebMvc → @Import(DelegatingWebMvcConfiguration.class)
DelegatingWebMvcConfiguration 클래스는 WebMvcConfigurationSupport를 상속받고 있다.
즉, 해당 클래스의 registry를 매개변수로 하는 메소드들은 WebMvcConfigurationSupport로부터 오버라이딩 된 것이다.registry에서는 해당 빈에 대한 설정 정보를 셋팅하는데 사용하는 클래스 정도로 이해하면 되겠다. 즉 어떤 빈을 셋팅하기 위한 도구일 뿐이고 직접 인스턴스로 어떤 특징을 가지는 것은 아닌 것 같다. 🤔
registry 설정이 끝나면 모든 설정 정보를 반환하고 이를 DispatcherServlet에 등록하는 것이다.
전체 코드는 다음과 같다.
@Configuration
@EnableWebMvc
@ComponentScan(basePackages = {"com.freeboard04_java_config.api", "com.freeboard04_java_config.controller"})
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry){
registry.addInterceptor(new AuthInterceptor())
.addPathPatterns("/**/api/**")
.excludePathPatterns("/api/users/**");
}
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
converters.add(new MappingJackson2HttpMessageConverter(objectMapper()));
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(pageableHandlerMethodArgumentResolver());
}
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
HandlebarsViewResolver viewResolver = new HandlebarsViewResolver();
viewResolver.setPrefix("/WEB-INF/view/");
viewResolver.setSuffix(".hbs");
registry.viewResolver(viewResolver);
}
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
objectMapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true);
return objectMapper;
}
@Bean
public PageableHandlerMethodArgumentResolver pageableHandlerMethodArgumentResolver() {
PageableHandlerMethodArgumentResolver pageableHandlerMethodArgumentResolver = new PageableHandlerMethodArgumentResolver();
pageableHandlerMethodArgumentResolver.setMaxPageSize(10000);
return pageableHandlerMethodArgumentResolver;
}
@Bean
public ExceptionHandler exceptionHandler(){
return new ExceptionHandler();
}
}
web.xml을 대체할 이 설정 파일은 @Configuration
어노테이션이 붙지않는다.
스프링 프레임워크가 시작될 때 WAS는 "WebApplicationInitializer"의 구현체를 찾아 오버라이딩된 onStartup
메소드를 실행시키기 때문에 "WebApplicationInitializer"를 구현하는 클래스로 설정을 옮겨주기만 한다.
config
패키지 하위에 "WebApplicationInitializer"를 구현하는 WebInitializer
클래스를 만들어 준다.
큰 범주에서 서블릿 설정과 필터 설정 두가지이므로 onStartup에서 호출할 메서드를 두 개 작성할 것이다.
디스패처 서블릿 설정을 등록하는 코드이다.
xml에서는 따로 명시하지 않는 경우에 dispatcher-servlet.xml 이라는 이름으로 자동 연결하므로 생략된 부분인데 자바 구성에서는 명시해주어야한다.
private void registerDispatcherServlet(ServletContext servletContext) {
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.register(ApplicationContext.class);
servletContext.addListener(new ContextLoaderListener(context));
AnnotationConfigWebApplicationContext webContext = new AnnotationConfigWebApplicationContext();
webContext.register(WebConfig.class);
ServletRegistration.Dynamic dispatcher = servletContext.addServlet("DispatcherServlet", new DispatcherServlet(webContext));
dispatcher.setLoadOnStartup(1);
dispatcher.addMapping("/*");
}
이 부분을 작성하면서 헷갈렸던 부분이 AnnotationConfigWebApplicationContext 클래스의 register 메소드를 들여다보아도 컴포넌트를 등록한다는 이야기 뿐이지 정확히 어떤 Configuration을 넘겨야하는지 써져있지 않음 😑..
결국 구글링해서 찾아보면 register 메소드의 인자로 RootConfig.class나 WebConfig.class를 넣는데(왠지 모르겠는데 클래스 이름은 거기서 거기임) 대체 그 설정파일이 뭔지는 안알려주고 있었다.
한참을 뒤진결과, context에 등록될 빈이 어떤 종류냐에 따라서 갈리는 것이었다.
즉, 웹과 상관없는 도메인, 레파지토리와 같은 빈들(ApplicationContext.class에 선언)은 위와같이 context에 등록 + ContextLoaderListener에 사용하며 웹과 관련된 컨트롤러와 같은 빈들(WebConfig.class에 선언)은 서블릿 생성에 사용한다.
사실 이 부분이 아직 어떻게 돌아가는지 헷갈리기 때문에 😢.. 코드를 더 자세히 읽어보아야 할 듯 하다.
필터 설정을 위한 메소드이다. web.xml에 있는 설정을 그대로 옮긴 것이기 때문에 어려운 부분은 없을 것이다.
private void registerCharacterEncodingFilter(ServletContext servletContext) {
FilterRegistration.Dynamic characterEncodingFilter = servletContext.addFilter("encodingFilter", new CharacterEncodingFilter());
characterEncodingFilter.setInitParameter("encoding", "UTF-8");
characterEncodingFilter.setInitParameter("forceEncoding", "true");
characterEncodingFilter.addMappingForServletNames(EnumSet.allOf(DispatcherType.class), true, "/*");
}
결과적으로 WebInitializer 클래스는 다음과 같다.
public class WebInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
registerDispatcherServlet(servletContext);
registerCharacterEncodingFilter(servletContext);
}
private void registerDispatcherServlet(ServletContext servletContext) {
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.register(ApplicationContext.class);
servletContext.addListener(new ContextLoaderListener(context));
AnnotationConfigWebApplicationContext webContext = new AnnotationConfigWebApplicationContext();
webContext.register(WebConfig.class);
ServletRegistration.Dynamic dispatcher = servletContext.addServlet("DispatcherServlet", new DispatcherServlet(webContext));
dispatcher.setLoadOnStartup(1);
dispatcher.addMapping("/*");
}
private void registerCharacterEncodingFilter(ServletContext servletContext) {
FilterRegistration.Dynamic characterEncodingFilter = servletContext.addFilter("encodingFilter", new CharacterEncodingFilter());
characterEncodingFilter.setInitParameter("encoding", "UTF-8");
characterEncodingFilter.setInitParameter("forceEncoding", "true");
characterEncodingFilter.addMappingForServletNames(EnumSet.allOf(DispatcherType.class), true, "/*");
}
}
dispatcher-servlet.xml 파일을 읽어들이던 테스트 코드 어노테이션을 변경하여 실행해보자.
//변경 전
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/applicationContext.xml", "file:src/main/webapp/WEB-INF/dispatcher-servlet.xml"})
//변경 후
@ContextConfiguration(classes = {ApplicationContext.class, WebConfig.class})
몇 개의 테스트가 깨지는 것을 확인했는데 에러코드는 다음과 같다.
핵심은 Json 문자열로 반환된 createdAt을 LocalDateTime 타입의 Object로 deserialize 할 수가 없다는 것이다.
ObjectMapper 및 Custom serializer/deserializer와 관련한 기본적인 내용은 [번역] Intro to the Jackson ObjectMapper + 예제 코드에서 확인할 수 있습니다.
config
패키지 하위에 jackson
패키지를 생성하고 LocalDateTimeDeserializer
클래스를 만들도록 하자
public class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
@Override
public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext context) throws IOException, JsonProcessingException {
JsonNode tree = jsonParser.getCodec().readTree(jsonParser);
int year = tree.get("year").asInt();
int month = tree.get("monthValue").asInt();
int dayOfMonth = tree.get("dayOfMonth").asInt();
int hour = tree.get("hour").asInt();
int minute = tree.get("minute").asInt();
int second = tree.get("second").asInt();
int nano = tree.get("nano").asInt();
return LocalDateTime.of(year, month, dayOfMonth, hour, minute, second, nano);
}
}
이 커스텀 LocalDateTimeDeserializer를 오류가 나는 오브젝트인 CommentDto의 LocalDateTime에 설정해준다.
더이상 테스트 코드가 깨지지 않는 것을 확인 할 수 있을 것이다.
다른 테스트 클래스들의 @ContextConfiguration
의 argument를 locations 대신 classes로 변경한 뒤 webapp 폴더 하위의 모든 설정파일을 제거한다.
톰캣을 띄워서 실제 웹에서도 잘 돌아가는지 확인해보자!!
모든 코드는 github에서 확인 하실 수 있습니다.
좋은 글 잘보고 갑니다~