import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.fail;

import org.junit.Test;
import org.quartz.CronExpression;
import org.quartz.CronScheduleBuilder;

/**
 * Quartz CronExpression 정규식 처리를 이해하기 위한 테스트 코드
 * 
 * @author ihoneymon
 * @see http://quartz-scheduler.org/api/2.2.0/org/quartz/CronExpression.html
 */
public class QuartzCronExpressionTest {

    /**
     * Null은 예외발생
     */
    @Test(expected = IllegalArgumentException.class)
    public void testNullCronExpressionException() {
        assertThat(CronExpression.isValidExpression(null), is(false));
        fail();
    }

    /**
     * 빈값이나 cron 정규식 표현이 아니라면 실패
     */
    @Test
    public void testEmptyCronExpressionException() {
        assertThat(CronExpression.isValidExpression(""), is(false));
        assertThat(CronExpression.isValidExpression("1 1+2 1*2"), is(false));
        assertThat(CronExpression.isValidExpression("testWrongChronExpression"), is(false));
    }

    /**
     * Quartz CronExpression 중에 DayOfMonth와 DayOfWeek에 대한 확인 DayOfMonth와
     * DayOfWeek의 표현식에 대해서는 신경을 써야한다.
     * 
     * Second Minute Hour DayOfMonth Month DayOfWeek (optional)Year
     */
    @Test
    public void testDayOfMonthAndDayOfWeekCronExpression() {
        /**
         * same DayOfMonth & DayOfMonth is valid fail DayOfMonth & DayOfMonth is
         * all: false DayOfMonth & DayOfMonth is none: valid fail
         */
        assertThat(CronExpression.isValidExpression("* * * * * *"), is(false));
        assertThat(CronExpression.isValidExpression("* * * ? * ?"), is(false));

        /**
         * DayOfMonth is all and DayOfMonth is none: valid success DayOfMonth is
         * all and DayOfMonth is define: valid fail
         */
        assertThat(CronExpression.isValidExpression("* * * * * ?"), is(true));
        assertThat(CronExpression.isValidExpression("* * * * * MON"), is(false));
        assertThat(CronExpression.isValidExpression("* * * * * 2"), is(false));
        assertThat(CronExpression.isValidExpression("* * * * * 2,5"), is(false));

        /**
         * DayOfMonth is none and DayOfMonth is all: valid success DayOfMonth
         * required none DayOfMonth DayOfMonth가 none(?)인 경우에는 DayOfMonth에 허용되는
         * 정규식은 대부분 통과
         */
        assertThat(CronExpression.isValidExpression("* * * ? * *"), is(true));
        assertThat(CronExpression.isValidExpression("* * * ? * MON"), is(true));
        assertThat(CronExpression.isValidExpression("* * * ? * MON-FRI"), is(true));
        assertThat(CronExpression.isValidExpression("* * * ? * MON-FRI,SUN"), is(true));
        assertThat(CronExpression.isValidExpression("* * * ? * MON#2"), is(true));
        assertThat(CronExpression.isValidExpression("* * * ? * 2#2"), is(true));
        assertThat(CronExpression.isValidExpression("* * * ? * 2#2,1#2"), is(false));

        /**
         * DayOfMonth에
         */
        assertThat(CronExpression.isValidExpression("* * * 1 * ?"), is(true));
        assertThat(CronExpression.isValidExpression("* * * 1-3 * ?"), is(true));
        assertThat(CronExpression.isValidExpression("* * * 1,6,9 * ?"), is(true));
        assertThat(CronExpression.isValidExpression("* * * 1 * *"), is(false));
        assertThat(CronExpression.isValidExpression("* * * 1-3 * *"), is(false));
        assertThat(CronExpression.isValidExpression("* * * 1,6,9 * FRI"), is(false));
        /**
         * DayOfMonth use L(last) special character
         */
        assertThat(CronExpression.isValidExpression("* * * L * ?"), is(true));
        assertThat(CronExpression.isValidExpression("* * * L * *"), is(false));
        assertThat(CronExpression.isValidExpression("* * * L * MON"), is(false));
        /**
         * DayOfMonth에 L은 단독으로 사용되거나 '-nth day'만 허용
         */
        assertThat(CronExpression.isValidExpression("* * * 15L * *"), is(false));
        assertThat(CronExpression.isValidExpression("* * * L15 * *"), is(false));
        assertThat(CronExpression.isValidExpression("* * * 15L * MON"), is(false));
        assertThat(CronExpression.isValidExpression("* * * 15L * ?"), is(false));
        assertThat(CronExpression.isValidExpression("* * * L-3 * ?"), is(true));
        assertThat(CronExpression.isValidExpression("* * * L-3 * *"), is(false));
        assertThat(CronExpression.isValidExpression("* * * L-3 * MON"), is(false));

        /**
         * DayOfMonth use W(week: mon-fri) special character
         */
        assertThat(CronExpression.isValidExpression("* * * W * *"), is(false));
        assertThat(CronExpression.isValidExpression("* * * 15W * *"), is(false));
        assertThat(CronExpression.isValidExpression("* * * 15W * ?"), is(true));
        assertThat(CronExpression.isValidExpression("* * * 15W,16W * ?"), is(true));
        assertThat(CronExpression.isValidExpression("* * * 15W-17W * ?"), is(true));
        assertThat(CronExpression.isValidExpression("* * * 15W/2W * ?"), is(true));

        /**
         * DayOfMonth use LW(last-week: mon-fri) special character
         */
        assertThat(CronExpression.isValidExpression("* * * LW * *"), is(false));
        assertThat(CronExpression.isValidExpression("* * * LW * ?"), is(true));
        assertThat(CronExpression.isValidExpression("* * * 1LW * ?"), is(false));
        assertThat(CronExpression.isValidExpression("* * * LW2 * ?"), is(false));
        assertThat(CronExpression.isValidExpression("* * * LW * MON"), is(false));
        assertThat(CronExpression.isValidExpression("* * * LW * 2"), is(false));
    }

    @Test(expected = RuntimeException.class)
    public void testCronBuilder() {
        assertThat(CronExpression.isValidExpression("* * * LW * *"), is(false));
        CronScheduleBuilder.cronSchedule("* * * LW * *").build();
    }
}

CronExpression.isValidExpression() 에서 false가 발생한 정규식을 CronScheduleBuilder.cronSchedule()를 사용하여 스케줄을 생성하려하면 RuntimeException이 발생한다.

SpringBoot 1.2.0.RELEASE 부터 추가된 @SpringBootApplication.

/**
 * Indicates a {@link Configuration configuration} class that declares one or more
 * {@link Bean @Bean} methods and also triggers {@link EnableAutoConfiguration
 * auto-configuration} and {@link ComponentScan component scanning}. This is a convenience
 * annotation that is equivalent to declaring {@code @Configuration},
 * {@code @EnableAutoConfiguration} and {@code @ComponentScan}.
 *
 * @author Phillip Webb
 * @since 1.2.0
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Configuration
@EnableAutoConfiguration
@ComponentScan
public @interface SpringBootApplication {

}

1.1.9.RELEASE 까지는 Application Endpoint에 클래스에

@Configuration
@EnableAutoConfiguration
@ComponentScan
public class Application{
//...
}

3개 애노테이션을 사용해야했던 것을 하나로 줄여주었다.


1. 문제발생

스프링부트 1.1.9.RELEASE 버전을 사용할 때, 정상적으로 동작하던 파일업로드 기능이 스프링부트 1.2.1로 업그레이드하면서 동작하지 않는 문제가 발생했다. 다음과 같은 형태로 multipartConfigElement와 multipartResolver 빈을 설정해두었고, ajax 파일업로드를 처리하기 위한 목적으로 jQuery Form plugin(https://github.com/malsup/form)을 사용하고 있다.

@Bean
MultipartConfigElement MultipartConfigElement() {
    MultipartConfigFactory factory = new MultipartConfigFactory();

    factory.setMaxFileSize(getMaxUploadFileSize());
    factory.setMaxRequestSize(getMaxUploadFileSize());

    return factory.createMultipartConfig();
}

@Bean
public CommonsMultipartResolver multipartResolver() {
    return new CommonsMultipartResolver();
}

컨트롤러는 다음과 같이 메서드를 구현했다.

@RequestMapping(value = "/file-upload", method = RequestMethod.POST)
    public ModelAndView fileUpload(@RequestParam(value = "file") MultipartFile file) throws IOException {
}

다음과 같은 예외가 발생한다.

org.springframework.web.bind.MissingServletRequestParameterException: Required MultipartFile parameter 'file' is not present
    at org.springframework.web.method.annotation.RequestParamMethodArgumentResolver.handleMissingValue(RequestParamMethodArgumentResolver.java:253) ~[spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.resolveArgument(AbstractNamedValueMethodArgumentResolver.java:94) ~[spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:77) ~[spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:162) ~[spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:129) ~[spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:110) ~[spring-webmvc-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandleMethod(RequestMappingHandlerAdapter.java:777) ~[spring-webmvc-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:706) ~[spring-webmvc-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:85) ~[spring-webmvc-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:943) [spring-webmvc-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:877) [spring-webmvc-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:966) [spring-webmvc-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:868) [spring-webmvc-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:707) [javax.servlet-api-3.1.0.jar:3.1.0]
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:842) [spring-webmvc-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:790) [javax.servlet-api-3.1.0.jar:3.1.0]
    at org.eclipse.jetty.servlet.ServletHolder.handle(ServletHolder.java:800) [jetty-servlet-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1669) [jetty-servlet-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:88) [spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1652) [jetty-servlet-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.springframework.boot.actuate.autoconfigure.EndpointWebMvcAutoConfiguration$ApplicationContextHeaderFilter.doFilterInternal(EndpointWebMvcAutoConfiguration.java:288) [spring-boot-actuator-1.2.0.RELEASE.jar:1.2.0.RELEASE]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1652) [jetty-servlet-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:330) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.invoke(FilterSecurityInterceptor.java:118) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.access.intercept.FilterSecurityInterceptor.doFilter(FilterSecurityInterceptor.java:84) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:113) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.session.SessionManagementFilter.doFilter(SessionManagementFilter.java:103) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:113) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:154) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:45) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.authentication.www.BasicAuthenticationFilter.doFilter(BasicAuthenticationFilter.java:150) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:199) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:110) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:57) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:87) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:50) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:342) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:192) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:160) [spring-security-web-3.2.5.RELEASE.jar:3.2.5.RELEASE]
    at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1652) [jetty-servlet-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.springframework.web.filter.HiddenHttpMethodFilter.doFilterInternal(HiddenHttpMethodFilter.java:77) [spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1652) [jetty-servlet-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.springframework.boot.actuate.trace.WebRequestTraceFilter.doFilterInternal(WebRequestTraceFilter.java:100) [spring-boot-actuator-1.2.0.RELEASE.jar:1.2.0.RELEASE]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1652) [jetty-servlet-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.springframework.boot.actuate.autoconfigure.MetricFilterAutoConfiguration$MetricsFilter.doFilterInternal(MetricFilterAutoConfiguration.java:90) [spring-boot-actuator-1.2.0.RELEASE.jar:1.2.0.RELEASE]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:107) [spring-web-4.1.3.RELEASE.jar:4.1.3.RELEASE]
    at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1652) [jetty-servlet-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:585) [jetty-servlet-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:143) [jetty-server-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:577) [jetty-security-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.server.session.SessionHandler.doHandle(SessionHandler.java:223) [jetty-server-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1125) [jetty-server-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:515) [jetty-servlet-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:185) [jetty-server-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1059) [jetty-server-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:141) [jetty-server-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:97) [jetty-server-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.server.Server.handle(Server.java:497) [jetty-server-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:310) [jetty-server-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:248) [jetty-server-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.io.AbstractConnection$2.run(AbstractConnection.java:540) [jetty-io-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:620) [jetty-util-9.2.4.v20141103.jar:9.2.4.v20141103]
    at org.eclipse.jetty.util.thread.QueuedThreadPool$3.run(QueuedThreadPool.java:540) [jetty-util-9.2.4.v20141103.jar:9.2.4.v20141103]
    at java.lang.Thread.run(Thread.java:745) [na:1.7.0_67]

컨트롤러에서 파라메터로 지정한 @RequestParam(value = "file") MultipartFile file 받지 못하는 증상이 생긴 것이다. 이걸 보고 계속 헛다리를 짚은 것이 jetty 9.x 를 사용해서 그런 것인가 하는 말도 안되는 추측을 하면서 문제의 해결방법에서 멀어지는 상황에 놓이게 된다.

ㅡ_-);; 이때 조금만 더 곰곰히 생각해봤으면 어땠을까 하지만, 정작 문제가 발생했을 때는 ‘이런 상황이 왜 생기는거야?’라는 해결책을 찾는데에만 함몰되어 있어다.

2. 문제해결방법

난 이 문제가 Jetty에 문제이거나 스프링부트 1.2.0 의 문제라고 생각했다. 그래서 얼마전 출시된 1.2.1 로 업그레이드를 했는데도 동일한 문제가 발생하는 것을 접하면서 답답함을 금치못했다. 그런데, 딱히 이와 관련된 버그나 이슈는 없었다. 왜?
그래서 인터넷 검색을 들어간다.

이럴 떄는 발생한 예외로그를 그대로 복사해서 놓는 것도 하나의 방법이라면 방법이다.
세계 어디에서나 같은 개발환경을 기반으로 개발하는 개발자들이 겪는 오류는 같다. ㅎㅎ

그러다가 스택오버플로우에서 내가 겪고 있는 상황과 같은 문제를 겪고 있는 어느 개발자의 질문을 발견한다.
Spring mvc: HTTP Status 400 - Required MultipartFile parameter ‘file’ is not present - Stackoverflow

그 질문에 달린 답변은 간단했다.

<bean id="multipartResolver" class="**org.springframework.web.multipart.commons.CommonsMultipartResolver**" />

<bean id="multipartResolver" class="org.springframework.web.multipart.support.StandardServletMultipartResolver" />

으로 변경하면 된다고 한다. 그래서 그렇게 했다.

@Bean
public MultipartResolver multipartResolver() {
    return new StandardServletMultipartResolver();
}

그랬더니 된다. 됐다. 문제해결!

3. 문제원인

스프링부트 1.2.0.RELEASE부터 서블릿 3.1이 적용되었다.

  • Spring Boot 1.2.0 released

    Spring Boot now uses Servlet 3.1 when running with an embedded servlet container. Tomcat 8, Jetty 9 and Undertow 1.1 are all supported options. In addition, WebSocket support has been improved and is now automatically configured for all supported servers. If you need to stick to Servlet 3.0, Tomcat 7 and Jetty 8 are still supported.

서블릿 3.1….
이 부분이 중요하다. +_+)
문제해결하는데 사용된 org.springframework.web.multipart.support.StandardServletMultipartResolver을 열어보면 다음과 같은 주석이 있다.

/**
 * Standard implementation of the {@link MultipartResolver} interface,
 * based on the Servlet 3.0 {@link javax.servlet.http.Part} API.
 * To be added as "multipartResolver" bean to a Spring DispatcherServlet context,
 * without any extra configuration at the bean level (see below).
 *
 * <p><b>Note:</b> In order to use Servlet 3.0 based multipart parsing,
 * you need to mark the affected servlet with a "multipart-config" section in
 * {@code web.xml}, or with a {@link javax.servlet.MultipartConfigElement}
 * in programmatic servlet registration, or (in case of a custom servlet class)
 * possibly with a {@link javax.servlet.annotation.MultipartConfig} annotation
 * on your servlet class. Configuration settings such as maximum sizes or
 * storage locations need to be applied at that servlet registration level;
 * Servlet 3.0 does not allow for them to be set at the MultipartResolver level.
 *
 * @author Juergen Hoeller
 * @since 3.1
 */

정리하면,

  • Servlet 3.0 Part API를 바탕으로 한 MultipartResolver 인터페이스 구현체
  • bean 을 선언할 때 별다른 확장설정을 하지 않고 “multipartResolver”로 선언
  • multipart와 관련된 업로드 파일 최대크기, 저장위치 등 설정을 하고 싶다면
    • web.xml에서 하거나
    • Servlet 등록하는 과정에서 MultipartConfigElement을 선언하면서 설정
    • @MultipartConfig라는 애노테이션 사용
  • Servlet 3.0 에서는 MultipartResolver 레벨에서 설정하는 것을 허용치 않는다.

이다. 결국은

@Bean
public MultipartConfigElement multipartConfigElement() {
    MultipartConfigFactory factory = new MultipartConfigFactory();

    factory.setMaxFileSize(getMaxUploadFileSize());
    factory.setMaxRequestSize(getMaxUploadFileSize());

    return factory.createMultipartConfig();
}

@Bean
public MultipartResolver multipartResolver() {
    return new StandardServletMultipartResolver();
}

의 형태로 정의하면 된다는 것이다. ㅇㅋ~




<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>myproject</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.2.0.BUILD-SNAPSHOT</version>
    </parent>

    <!-- Additional lines to be added here... -->
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

    <!-- (you don't need this if you are using a .RELEASE version) -->
    <repositories>
        <repository>
            <id>spring-snapshots</id>
            <url>http://repo.spring.io/snapshot</url>
            <snapshots><enabled>true</enabled></snapshots>
        </repository>
        <repository>
            <id>spring-milestones</id>
            <url>http://repo.spring.io/milestone</url>
        </repository>
    </repositories>
    <pluginRepositories>
        <pluginRepository>
            <id>spring-snapshots</id>
            <url>http://repo.spring.io/snapshot</url>
        </pluginRepository>
        <pluginRepository>
            <id>spring-milestones</id>
            <url>http://repo.spring.io/milestone</url>
        </pluginRepository>
    </pluginRepositories>
</project>

위의 pom.xml을 만들고나서

$ mvn dependency:tree

를 실행하면,

honeymon@test $ mvn dependency:tree
[INFO] Scanning for projects...
[INFO]                                                                         
[INFO] ------------------------------------------------------------------------
[INFO] Building myproject 0.0.1-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO] 
[INFO] --- maven-dependency-plugin:2.9:tree (default-cli) @ myproject ---
[INFO] com.example:myproject:jar:0.0.1-SNAPSHOT
[INFO] \- org.springframework.boot:spring-boot-starter-web:jar:1.2.0.BUILD-SNAPSHOT:compile
[INFO]    +- org.springframework.boot:spring-boot-starter:jar:1.2.0.BUILD-SNAPSHOT:compile
[INFO]    |  +- org.springframework.boot:spring-boot:jar:1.2.0.BUILD-SNAPSHOT:compile
[INFO]    |  +- org.springframework.boot:spring-boot-autoconfigure:jar:1.2.0.BUILD-SNAPSHOT:compile
[INFO]    |  +- org.springframework.boot:spring-boot-starter-logging:jar:1.2.0.BUILD-SNAPSHOT:compile
[INFO]    |  |  +- org.slf4j:jcl-over-slf4j:jar:1.7.7:compile
[INFO]    |  |  |  \- org.slf4j:slf4j-api:jar:1.7.7:compile
[INFO]    |  |  +- org.slf4j:jul-to-slf4j:jar:1.7.7:compile
[INFO]    |  |  +- org.slf4j:log4j-over-slf4j:jar:1.7.7:compile
[INFO]    |  |  \- ch.qos.logback:logback-classic:jar:1.1.2:compile
[INFO]    |  |     \- ch.qos.logback:logback-core:jar:1.1.2:compile
[INFO]    |  \- org.yaml:snakeyaml:jar:1.14:runtime
[INFO]    +- org.springframework.boot:spring-boot-starter-tomcat:jar:1.2.0.BUILD-SNAPSHOT:compile
[INFO]    |  +- org.apache.tomcat.embed:tomcat-embed-core:jar:8.0.15:compile
[INFO]    |  +- org.apache.tomcat.embed:tomcat-embed-el:jar:8.0.15:compile
[INFO]    |  +- org.apache.tomcat.embed:tomcat-embed-logging-juli:jar:8.0.15:compile
[INFO]    |  \- org.apache.tomcat.embed:tomcat-embed-websocket:jar:8.0.15:compile
[INFO]    +- com.fasterxml.jackson.core:jackson-databind:jar:2.4.4:compile
[INFO]    |  +- com.fasterxml.jackson.core:jackson-annotations:jar:2.4.4:compile
[INFO]    |  \- com.fasterxml.jackson.core:jackson-core:jar:2.4.4:compile
[INFO]    +- org.hibernate:hibernate-validator:jar:5.1.3.Final:compile
[INFO]    |  +- javax.validation:validation-api:jar:1.1.0.Final:compile
[INFO]    |  +- org.jboss.logging:jboss-logging:jar:3.1.3.GA:compile
[INFO]    |  \- com.fasterxml:classmate:jar:1.0.0:compile
[INFO]    +- org.springframework:spring-core:jar:4.1.3.RELEASE:compile
[INFO]    +- org.springframework:spring-web:jar:4.1.3.RELEASE:compile
[INFO]    |  +- org.springframework:spring-aop:jar:4.1.3.RELEASE:compile
[INFO]    |  |  \- aopalliance:aopalliance:jar:1.0:compile
[INFO]    |  +- org.springframework:spring-beans:jar:4.1.3.RELEASE:compile
[INFO]    |  \- org.springframework:spring-context:jar:4.1.3.RELEASE:compile
[INFO]    \- org.springframework:spring-webmvc:jar:4.1.3.RELEASE:compile
[INFO]       \- org.springframework:spring-expression:jar:4.1.3.RELEASE:compile
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 3.397s
[INFO] Finished at: Mon Jan 05 23:11:55 KST 2015
[INFO] Final Memory: 12M/152M
[INFO] ------------------------------------------------------------------------
honeymon@test $

의 형태로 추가된 의존성을 확인할 수 있다. WEB MVC를 사용하여 웹 애플리케이션을 만드는데 필요한 스프링부트의 기본적인 요소들을 살펴볼 수 있다. 스프링부트 web 에서는 내장형 컨테이너로 톰캣을 기본탑재하고 있다.

요런식으로 스프링부트에 추가되는 'Starter POMs'들이 가지고 있는 의존성을 엿볼 수 있다.

입출력 관련 데이터를 관리할 때 가장 신경쓰이는 요소중에 하나가 생성(생성자, 생성일시)과 최종수정(최종수정자, 최종수정일)이다.
JPA에는 Auditing 이라고 하여, 인터페이스로 선언된 기능을 구현해두면 자동으로 엔티티에 필요한 데이터를 입력하여 등록하는 작업을 JPA에서 처리해주는 것이 가능하다.

현재 프로젝트에서 SpringBoot를 사용중인데 설정과 관련된 부분들을 JavaConfig로 처리하다보니 이와 관련된 정보가 없다.
생각보다 설정이 간단하다.

● AuditableEntity

@MappedSuperclass
@EntityListeners(value = { AuditingEntityListener.class })
public class AuditableEntity extends AbstractAuditable<Member, Long> {
    private static final long serialVersionUID = 359326673134570560L;
}

● Auditing target Entity

@Entity
public class AuditingTargetEntity extends AuditableEntity {

}

● SpringSecurityAuditorAware

현재 프로젝트는 스프링시큐리티를 이용하여 접근제어를 하고 있다.

JPA에서 사용할 Auditor(현재 작업중인 대상)에 관한 정보를 가져오는 과정을 정의한 AuditorAware<T> 인터페이스를 이용하여 스프링시큐리티의 SecurityContextHolder에서 관련 사용자 정보를 가져오도록 구현한다.

public class SpringSecurityAuditorAware implements AuditorAware<Member> {
    @Override
    public Member getCurrentAuditor() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (null == authentication || !authentication.isAuthenticated()) {
            return null;
        }
        return (Member) authentication.getPrincipal();
    }
}

● AuditingConfig 선언

@Configuration
@EnableJpaAuditing
public class AuditingConfig {
    @Bean
    SpringSecurityAuditorAware auditorAware() {
        return new SpringSecurityAuditorAware();
    }
}

● 정리

참 쉽죠?

● 참고


+ Recent posts