SpringBoot으로 구현한 애플리케이션의 DB를 H2Database(http://h2database.com)를 사용한다.

물론 다른 것을 사용할 수도 있다. 개발하고 있는 제품의 특성에 맞춰 h2Database를 선택했을 뿐.

h2database에 접속할 수 있는 웹콘솔로 h2console이 제공되는데, 이 녀석을 애플리케이션에서
함께 실행할 수 있는 방법을 찾고 있었다.

그러다가 찾은 정보가!

spring boot default H2 jdbc connection (and H2 console)

h2database 에 포함된 org.h2.server.web.WebServlet을 등록하면 콘솔을 빈으로 등록하고 웹상으로 접근이 가능한 것이다!

@Bean
public ServletRegistrationBean h2servletRegistration() {
    ServletRegistrationBean registration = new ServletRegistrationBean(new WebServlet());
    registration.addUrlMappings("/h2console/*");
    return registration;
}

위처럼 빈Bean 선언을 하고 실행을 하면! localhost:{port}/h2console으로 접근하면!



JDBC URL은 database 설정에서 url을 복사해서 넣으면 된다. 테스트하려면 jdbc:h2:mem:test

의 모습을 볼 수 있다.

애플리케이션에서는 스프링시큐리티를 이용하여 접근제어를 하고 있다.

로그인을 하면 다음과 같은 백지화면이 나타난다. 그 이유를 찾아보려고 개발자도구를 열어보니



와 같은 메시지가 뜨는 것을 확인한다. 재빨리 검색을 들어가서!

Content-Security-Policy Spring Security - stackoverflow

스프링시큐리티 설정코드 부분에

@Override
  protected void configure(HttpSecurity http) throws Exception {
    http
      // ...
      .headers()
        .addHeaderWriter(new StaticHeadersWriter("X-Content-Security-Policy","script-src 'self'"))
      // ...
  }

다음과 같이 "X-Content-Security-Policy" 선언을 해줘야 한다. 애플리케이션을 재시작하면!



참 쉽죠잉??


○ SpringBoot 레퍼런스 가이드 설정부분

# FLYWAY (FlywayProperties)
flyway.check-location=false # check that migration scripts location exists
flyway.locations=classpath:db/migration # locations of migrations scripts
flyway.schemas= # schemas to update
flyway.init-version= 1 # version to start migration
flyway.init-sqls= # SQL statements to execute to initialize a connection immediately after obtaining it
flyway.sql-migration-prefix=V
flyway.sql-migration-suffix=.sql
flyway.enabled=true
flyway.url= # JDBC url if you want Flyway to create its own DataSource
flyway.user= # JDBC username if you want Flyway to create its own DataSource
flyway.password= # JDBC password if you want Flyway to create its own DataSource

예제에 보면 1 이 등록된 것을 볼 수 있다. 하지만!!

Caused by: org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'flyway' on field 'initVersion': rejected value [1]; codes [typeMismatch.flyway.initVersion,typeMismatch.initVersion,typeMismatch.org.flywaydb.core.api.MigrationVersion,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [flyway.initVersion,initVersion]; arguments []; default message [initVersion]]; default message [Failed to convert property value of type 'java.lang.String' to required type 'org.flywaydb.core.api.MigrationVersion' for property 'initVersion'; nested exception is java.lang.IllegalStateException: Cannot convert value of type [java.lang.String] to required type [org.flywaydb.core.api.MigrationVersion] for property 'initVersion': no matching editors or conversion strategy found]
    at org.springframework.boot.bind.PropertiesConfigurationFactory.validate(PropertiesConfigurationFactory.java:296)
    at org.springframework.boot.bind.PropertiesConfigurationFactory.doBindPropertiesToTarget(PropertiesConfigurationFactory.java:255)
    at org.springframework.boot.bind.PropertiesConfigurationFactory.bindPropertiesToTarget(PropertiesConfigurationFactory.java:227)
    at org.springframework.boot.context.properties.ConfigurationPropertiesBindingPostProcessor.postProcessBeforeInitialization(ConfigurationPropertiesBindingPostProcessor.java:296)
    ... 26 more

ㅡ_-);; 그대로 따라해보면 저렇게 오류를 뱉는다.

스프링부트 1.2.3 에서 Flyway와 관련된 ... 부분에서 initVersion 항목이 Integer 가 아니라 MigrationVersion으로 변경이 되었다. 스프링부트 1.2.3 에서 제공하는 flywayDB는 3.1 버전으로, Flyway.class의 코드를 살펴보면

/**
 * Sets the version to tag an existing schema with when executing baseline.
 *
 * @param initVersion The version to tag an existing schema with when executing baseline. (default: 1)
 * @deprecated Use setBaselineVersion() instead. Will be removed in Flyway 4.0.
 */
@Deprecated
public void setInitVersion(MigrationVersion initVersion) {
    LOG.warn("Flyway.setInitVersion() is deprecated. Use setBaselineVersion() instead. Will be removed in Flyway 4.0.");
    this.baselineVersion = initVersion;
}
 
/**
 * Sets the version to tag an existing schema with when executing baseline.
 *
 * @param initVersion The version to tag an existing schema with when executing baseline. (default: 1)
 * @deprecated Use setBaselineVersion() instead. Will be removed in Flyway 4.0.
 */
@Deprecated
public void setInitVersion(String initVersion) {
    LOG.warn("Flyway.setInitVersion() is deprecated. Use setBaselineVersion() instead. Will be removed in Flyway 4.0.");
    this.baselineVersion = MigrationVersion.fromVersion(initVersion);
}

에서 보는 것처럼, 1, 2, 3으로 정의하던 initVersion 관련 항목이 @Depercated 처리되었다.

initVersion 으로 설정하는 부분

String initVersionProp = properties.getProperty("flyway.initVersion");
if (initVersionProp != null) {
    LOG.warn("flyway.initVersion is deprecated and will be removed in Flyway 4.0. Use flyway.baselineVersion instead.");
    setBaselineVersion(MigrationVersion.fromVersion(initVersionProp));
}

baselineVersion

String baselineVersionProp = properties.getProperty("flyway.baselineVersion");
if (baselineVersionProp != null) {
    setBaselineVersion(MigrationVersion.fromVersion(baselineVersionProp));
}

flyway의 릴리즈 노트를 보면 init 관련항목들이 baseline으로 변경된 것을 확인할 수 있었다. 두둥.

Issue 860 Deprecated init(), use baseline() instead.
Issue 860 Deprecated initVersion, use baselineVersion instead.
Issue 860 Deprecated initDescription, use baselineDescription instead.
Issue 860 Deprecated initOnMigrate, use baselineOnMigrate instead.
Issue 860 Deprecated FlywayCallback.beforeInit(), use FlywayCallback.beforeBaseline() instead.
Issue 860 Deprecated FlywayCallback.afterInit(), use FlywayCallback.afterBaseline() instead.
Issue 860 Deprecated MigrationState.PREINIT, use MigrationState.BELOW_BASELINE instead.
Issue 860 Deprecated MigrationType.INIT, use MigrationType.BASELINE instead.

◎ 정리

  • initVersion 보다는 baselineVersion으로 옮겨가자.
  • MigrationVersion 은 LATEST와 EMPTY 로 변환가능...
  • 스프링부트에서 사용한 외부 라이브러리에서 오류가 발생한다면, 외부라이브러리와 관련된 가이드를 살펴보자.
    • 스프링부트 레퍼런스 가이드에 반영이 안되어 있을수도 있다.


깔끔하게 보고 싶으면 아래 링크에서 보라.
https://gist.github.com/ihoneymon/56dd964336322eea04dc


19:04:00 ERROR c.i.i.s.s.system.MailServiceImpl - >> Occur Exception: Failed messages: com.sun.mail.smtp.SMTPSendFailedException: 530 5.7.0 Must issue a STARTTLS command first. a11sm6769399pdj.54 - gsmtp

위의 메시지가 나타난다면, compile "com.sun.mail:javax.mail" 의존성을 추가하자.

○ build.gradle

dependencies {
  //.. 중략
  compile "org.springframework:spring-context-support"
  /**
   * @see http://docs.spring.io/spring-boot/docs/current-SNAPSHOT/reference/htmlsingle/#boot-features-email
   *
   * Javax 관련 의존성: com.sun.mail:javax.mail 과 javax.mail:javax.mail-api 두개만 존재한다.
   * @see https://github.com/spring-projects/spring-boot/blob/master/spring-boot-dependencies/pom.xml
   * 를 살펴보면 javax.mail:mail 관련한 의존성이 빠져있는 것을 볼 수 있다. ㅡ_-)> 흠...
   */
  compile "com.sun.mail:javax.mail"
  //.. 중략
}

spring-context-support, javax.mail 에 대한 의존성을 추가한다.

○ properties.yml

mail:
  host: smtp.gmail.com
  port: 587
  protocol: smtp
  default-encoding: UTF-8
  username: your-email@domain
  password: password
  smtp:
    start-tls-enable: true
    auth: true

○ MailConfig

메일발송에 사용할 템플릿엔진으로는 Thymeleaf(http://www.thymeleaf.org/doc/articles/springmail.html)를 참고하여 작성하였다. 기존에 사용하던 메일설정을 활용했다.

import java.util.Properties;

import javax.validation.constraints.NotNull;

import lombok.Data;
import lombok.ToString;

import org.hibernate.validator.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.thymeleaf.templateresolver.TemplateResolver;

/**
 *
 * @author jiheon
 *
 * @see <a
 *      href="http://www.thymeleaf.org/doc/articles/springmail.html">http://www.thymeleaf.org/doc/articles/springmail.html</a>
 * @see <a
 *      href="http://stackoverflow.com/questions/25610281/spring-boot-sending-emails-using-thymeleaf-as-template-configuration-does-not">http://stackoverflow.com/questions/25610281/spring-boot-sending-emails-using-thymeleaf-as-template-configuration-does-not</a>
 */
@Data
@Configuration
@ConfigurationProperties(prefix = "mail", locations = { "classpath:properties/properties.yml" })
@ToString
public class MailConfig {
    private static final String MAIL_DEBUG = "mail.debug";
    private static final String MAIL_SMTP_STARTTLS_REQUIRED = "mail.smtp.starttls.required";
    private static final String MAIL_SMTP_AUTH = "mail.smtp.auth";
    private static final String MAIL_SMTP_STARTTLS_ENABLE = "mail.smtp.starttls.enable";

    @Data
    public static class Smtp {
        private boolean auth;
        private boolean startTlsRequired;
        private boolean startTlsEnable;
    }

    @NotBlank
    private String host;
    private String protocol;
    private int port;
    private String username;
    private String password;
    private String defaultEncoding;
    @NotNull
    private Smtp smtp;

    @Bean
    public JavaMailSender mailSender() {
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost(getHost());
        mailSender.setProtocol(getProtocol());
        mailSender.setPort(getPort());
        mailSender.setUsername(getUsername());
        mailSender.setPassword(getPassword());
        mailSender.setDefaultEncoding(getDefaultEncoding());
        Properties properties = mailSender.getJavaMailProperties();
        properties.put(MAIL_SMTP_STARTTLS_REQUIRED, getSmtp().isStartTlsRequired());
        properties.put(MAIL_SMTP_STARTTLS_ENABLE, getSmtp().isStartTlsEnable());
        properties.put(MAIL_SMTP_AUTH, getSmtp().isAuth());
        properties.put(MAIL_DEBUG, true);
        mailSender.setJavaMailProperties(properties);
        return mailSender;
    }

    @Bean
    public TemplateResolver emailTemplateResolver() {
        TemplateResolver emailTemplateResolver = new ClassLoaderTemplateResolver();
        emailTemplateResolver.setPrefix("mails/");
        emailTemplateResolver.setSuffix(".html");
        emailTemplateResolver.setTemplateMode("HTML5");
        emailTemplateResolver.setCharacterEncoding("UTF-8");
        emailTemplateResolver.setCacheable(true);
        return emailTemplateResolver;
    }
}

○ MailServiceTest

@Slf4j
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
@ActiveProfiles("test")
@WebAppConfiguration
public class MailServiceTest {

    @Autowired
    private MailService mailService;
    @Test
    public void test() {
        ThymeleafMailMessage mailMessage = new ThymeleafMailMessage("test");
        mailMessage.setFrom("test-email@domain");
        mailMessage.setTo("test-email@domain");
        mailMessage.setSubject("[Test] mailTest");
        mailMessage.addAttribute("name", "Guest");
        mailMessage.addAttribute("imageResourceName", "imageResourceName");
        mailMessage.setEncoding("UTF-8");

        mailService.send(mailMessage);
    }

}

○ MailMessage 클래스들

● MailMessage

import java.io.File;
import java.util.Calendar;
import java.util.Collections;
import java.util.List;

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;

import org.springframework.mail.SimpleMailMessage;

import com.google.common.collect.Lists;

@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class MailMessage extends SimpleMailMessage {
    private static final long serialVersionUID = 1830106734321133565L;

    private List<File> attachments;
    private String encoding;
    private boolean htmlContent;

    public MailMessage() {
        super();
        super.setSentDate(Calendar.getInstance().getTime());
        this.attachments = Lists.newArrayList();
    }

    public MailMessage addAttachment(File file) {
        if (null != file) {
            this.attachments.add(file);
        }
        return this;
    }

    public MailMessage removeAttachment(File file) {
        if (null != file && this.attachments.contains(file)) {
            this.attachments.remove(file);
        }
        return this;
    }

    public List<File> getAttachments() {
        return Collections.unmodifiableList(this.attachments);
    }

    public boolean isMultipart() {
        return !this.attachments.isEmpty();
    }
}

● ThymeleafMailMessage

import java.util.Map;

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;

import com.google.common.collect.Maps;

/**
 * Thymeleaf MailMessage
 *
 * @author jiheon
 *
 */
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class ThymeleafMailMessage extends MailMessage {
    private static final long serialVersionUID = -2313892947287620959L;
    @Getter
    private final String templateName;
    private final Map<String, Object> attributes;

    public ThymeleafMailMessage(String templateName) {
        this.templateName = templateName;
        this.attributes = Maps.newHashMap();
        super.setHtmlContent(true);
    }

    public ThymeleafMailMessage addAttribute(String key, Object value) {
        this.attributes.put(key, value);
        return this;
    }

    public ThymeleafMailMessage removeAttribute(String key) {
        if (this.attributes.containsKey(key)) {
            this.attributes.remove(key);
        }
        return this;
    }

    public Map<String, Object> getAttributes() {
        return java.util.Collections.unmodifiableMap(this.attributes);
    }
}

○ MailService

● interface MailService

import java.util.List;

import org.springframework.mail.MailException;

/**
 * MailService
 *
 * @author jiheon
 *
 */
public interface MailService {

    void send(MailMessage message) throws MailException;

    void send(List<MailMessage> messages) throws MailException;
}

● class MailServiceImpl

import java.util.List;
import java.util.Locale;

import javax.annotation.PostConstruct;
import javax.mail.MessagingException;
import javax.mail.Session;
import javax.mail.internet.MimeMessage;

import lombok.extern.slf4j.Slf4j;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

@Slf4j
@Service
public class MailServiceImpl implements MailService {
    @Autowired
    private JavaMailSender mailSender;
    @Autowired
    private SystemMailService systemMailService;
    @Autowired
    private TemplateEngine templateEngine;

    @PostConstruct
    public void setUp() {
        if (systemMailService.hasSystemMailConfig()) {
            mailSender = systemMailService.getMailSender();
        }
        log.debug("MailSender: {}", mailSender);
    }

    @Override
    public void send(MailMessage message) throws MailException {
        sendMail(message);
    }

    private void sendMail(MailMessage message) {
        try {
            log.debug(">> Send mailMessage: {}", message);
            if (ThymeleafMailMessage.class.isAssignableFrom(message.getClass())) {
                doThymeleafMailMessageSend((ThymeleafMailMessage) message);
            } else {
                doSend(message);
            }
        } catch (Exception e) {
            log.error(">> Occur Exception: {}", e.getMessage());
        }
    }

    private void doThymeleafMailMessageSend(ThymeleafMailMessage thymeleafMailMessage) throws MessagingException {
        Context context = new Context(Locale.getDefault());
        context.setVariables(thymeleafMailMessage.getAttributes());
        thymeleafMailMessage.setText(templateEngine.process(thymeleafMailMessage.getTemplateName(), context));

        doSend(thymeleafMailMessage);
    }

    private void doSend(MailMessage message) throws MessagingException {
        try {
            MimeMessage mimeMessage = new MimeMessage(Session.getInstance(System.getProperties()));
            MimeMessageHelper helper = getMimeMessageHelper(message, mimeMessage);
            mailSender.send(helper.getMimeMessage());
        } catch (MessagingException e) {
            throw e;
        }
    }

    @Override
    public void send(List<MailMessage> messages) throws MailException {
        for (MailMessage mailMessage : messages) {
            sendMail(mailMessage);
        }
    }

    private MimeMessageHelper getMimeMessageHelper(MailMessage message, MimeMessage mimeMessage)
            throws MessagingException {
        MimeMessageHelper mimeMessageHelper = makeMessageHelper(message, mimeMessage);
        mimeMessageHelper.setFrom(message.getFrom());
        mimeMessageHelper.setTo(message.getTo());
        if (null != message.getCc()) {
            mimeMessageHelper.setCc(message.getCc());
        }
        if (null != message.getBcc()) {
            mimeMessageHelper.setBcc(message.getBcc());
        }

        mimeMessageHelper.setSubject(message.getSubject());
        mimeMessageHelper.setText(message.getText(), message.isHtmlContent());
        mimeMessageHelper.setSentDate(message.getSentDate());
        return mimeMessageHelper;
    }

    private MimeMessageHelper makeMessageHelper(MailMessage message, MimeMessage mimeMessage) throws MessagingException {
        MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, message.isMultipart());
        if (StringUtils.hasText(message.getEncoding())) {
            helper = new MimeMessageHelper(mimeMessage, message.isMultipart(), message.getEncoding());
        }
        return helper;
    }

}

○ 정리

  • compile "com.sun.mail:javax.mail" 을 선언하지 않고 테스트를 돌려보면

    19:04:00 ERROR c.i.i.s.s.system.MailServiceImpl - >> Occur Exception: Failed messages: com.sun.mail.smtp.SMTPSendFailedException: 530 5.7.0 Must issue a STARTTLS command first. a11sm6769399pdj.54 - gsmtp

다음과 같은 메시지를 볼 수가 있다. ㅡ_-);; 이것 땜시 반나절을 삽질을 해버렸네... 두둥...


스프링부트 기본배너

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::   v1.3.0.BUILD-SNAPSHOT

스프링애플리케이션에서 배너와 관련된 설정

Class 정의

public static void main(String[] args) {
    SpringApplication app = new SpringApplication(MySpringConfiguration.class);
    app.setShowBanner(false);
    app.run(args);
}

외부설정 정의

spring.main.show_banner=false

배너 변경

SpringBoot가 실행될때 배너를 보여주게 되는데, 이를 변경하고 싶을 때는

  • banner.txt 생성
    • banner.txt 파일을 root classpath(src/main/resource)에 위치
    • 별도의 위치에 두고 외부설정(properties or yaml)으로 banner.location 을 선언하고 위치를 정의

배너변경 결과

11:26:18,781 |-INFO in ch.qos.logback.classic.gaffer.ConfigurationDelegate@1c4e4471 - About to instantiate appender of type [ch.qos.logback.core.ConsoleAppender]
11:26:18,783 |-INFO in ch.qos.logback.classic.gaffer.ConfigurationDelegate@1c4e4471 - Naming appender as [STDOUT]
11:26:18,921 |-WARN in ch.qos.logback.core.ConsoleAppender[STDOUT] - This appender no longer admits a layout as a sub-component, set an encoder instead.
11:26:18,921 |-WARN in ch.qos.logback.core.ConsoleAppender[STDOUT] - To ensure compatibility, wrapping your layout in LayoutWrappingEncoder.
11:26:18,921 |-WARN in ch.qos.logback.core.ConsoleAppender[STDOUT] - See also http://logback.qos.ch/codes.html#layoutInsteadOfEncoder for details
11:26:18,924 |-INFO in ch.qos.logback.classic.gaffer.ConfigurationDelegate@1c4e4471 - Setting level of logger [org.thymeleaf] to INFO
11:26:18,931 |-INFO in ch.qos.logback.classic.gaffer.ConfigurationDelegate@1c4e4471 - Setting level of logger [org.springframework.web] to DEBUG
11:26:18,931 |-INFO in ch.qos.logback.classic.gaffer.ConfigurationDelegate@1c4e4471 - Setting level of logger [org.springframework] to INFO
11:26:18,931 |-INFO in ch.qos.logback.classic.gaffer.ConfigurationDelegate@1c4e4471 - Setting level of logger [org.hibernate] to INFO
11:26:18,931 |-INFO in ch.qos.logback.classic.gaffer.ConfigurationDelegate@1c4e4471 - Setting level of logger [org.eclipse.jetty] to INFO
11:26:18,931 |-INFO in ch.qos.logback.classic.gaffer.ConfigurationDelegate@1c4e4471 - Setting level of logger [jndi] to INFO
11:26:18,934 |-INFO in ch.qos.logback.classic.gaffer.ConfigurationDelegate@1c4e4471 - Setting level of logger [ROOT] to DEBUG
11:26:18,937 |-INFO in ch.qos.logback.classic.gaffer.ConfigurationDelegate@1c4e4471 - Attaching appender named [STDOUT] to Logger[ROOT]

  _   _     U  ___ u  _   _   U _____ u __   __  __  __    U  ___ u  _   _     
 |'| |'|     \/"_ \/ | \ |"|  \| ___"|/ \ \ / /U|' \/ '|u   \/"_ \/ | \ |"|    
/| |_| |\    | | | |<|  \| |>  |  _|"    \ V / \| |\/| |/   | | | |<|  \| |>   
U|  _  |u.-,_| |_| |U| |\  |u  | |___   U_|"|_u | |  | |.-,_| |_| |U| |\  |u   
 |_| |_|  \_)-\___/  |_| \_|   |_____|    |_|   |_|  |_| \_)-\___/  |_| \_|    
 //   \\       \\    ||   \\,-.<<   >>.-,//|(_ <<,-,,-.       \\    ||   \\,-. 
(_") ("_)     (__)   (_")  (_/(__) (__)\_) (__) (./  \.)     (__)   (_")  (_/  


11:26:19 DEBUG o.s.w.c.s.StandardServletEnvironment - Adding [servletConfigInitParams] PropertySource with lowest search precedence
11:26:19 DEBUG o.s.w.c.s.StandardServletEnvironment - Adding [servletContextInitParams] PropertySource with lowest search precedence
11:26:19 DEBUG o.s.w.c.s.StandardServletEnvironment - Adding [jndiProperties] PropertySource with lowest search precedence
11:26:19 DEBUG o.s.w.c.s.StandardServletEnvironment - Adding [systemProperties] PropertySource with lowest search precedence
11:26:19 DEBUG o.s.w.c.s.StandardServletEnvironment - Adding [systemEnvironment] PropertySource with lowest search precedence

위와 같은 결과를 볼 수 있다.

참고사항

SpringBoot

이미지 to ASCII CODE image


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();
}

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


1···5678910

+ Recent posts