기존에 작성하던 프로젝트를 Spring Boot 1.3.x에서 Spring Boot 1.4.1로 업그레이드 하던 중, DB 커넥션을 맺지 못하는 현상이 생겼다.

해결을 위해 예제 애플리케이션을 만드는 과정을 기록한다.

목표

  • 서브 모듈로 나눠 레이어별로 개발 및 테스트 환경 구축.
  • Spring Boot의 자동 설정을 사용.
  • Spring Data JPA / Hibernate / MySQL
  • 어노테이션 기반 설정
  • 내부 로직 공통 모듈화, 인터페이스 부분만 다른 웹 사이트와 JSON API 서버.

All-In-One(v.1)

조건을 간단히 하기 위해, 하나의 모듈과 패키지에 인터페이스 없이 웹 애플리케이션을 만들었다.

최소한의 설정으로도 DB 커넥션을 생성할 수 있음을 확인.

커밋 트리에서는 일부 중간 단계를 생략했지만, 기본적인 설정으로도 DB 커넥션을 설정할 수 있다. 코드는 springboot14v1에서 확인할 수 있다.

Maven 설정

parent 모듈 설정으로 JDK의 버전 1.8과 Spring Boot의 버전 1.4.1.RELEASE를 생략. 사용한 라이브러리는 spring-boot-dependencies 모듈에서 지정한 버전을 사용한다.

<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>
  <parent>
    <groupId>kr.lul.pages</groupId>
    <artifactId>spring-boot14</artifactId>
    <version>0.0.1-SNAPSHOT</version>
  </parent>
  <artifactId>spring-boot14-v1</artifactId>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
    </dependency>
  </dependencies>
</project>

애플리케이션 설정

최소 설정으로도 DB 커넥션은 생성할 수 있는데, 예를 들어 spring.datasource.driver-class-name이 없어도 커넥션을 맺을 수 있다.

그런데 spring.datasource.tomcat.validation-interval은 작동이 이상해, 설정하더라도 5초 단위로 spring.datasource.tomcat.validation-query가 실행된다. 원인은 불명. 확인은 MySQL의 general-query.log 파일로 할 수 있다(tail -f general-query.log).

# src/main/resources/application.yml
spring:
  datasource:
    driver-class-name: 'com.mysql.jdbc.Driver'
    url: jdbc:mysql://localhost/springboot14v1
    username: test
    password: testuser
    tomcat:
      test-while-idle: true
      validation-query: 'SELECT 1'
  jpa:
    open-in-view: false
    generate-ddl: true
    show-sql: true

패키지 분리(v2)

애플리케이션의 로직 레이어에 맞게 패키지를 분리한다.

코드는 springboot14v2에서 확인.

kr.lul.pages.spring.boot14.v2
├── business
│   ├── dto
│   │   └── FooDto.java
│   └── service
│       ├── FooService.java
│       └── FooServiceImpl.java
├── domain
│   └── Foo.java
├── jpa
│   ├── converter
│   │   └── InstantBigintConverter.java
│   ├── entity
│   │   └── FooEntity.java
│   └── repository
│       └── FooRepository.java
└── rest
    ├── Spring14Runner.java
    ├── controller
    │   ├── FooController.java
    │   └── FooControllerImpl.java
    └── resp
        ├── FooListResp.java
        └── FooResp.java

내장 DB를 사용한 테스트 문제

@DataJpaTest 어노테이션을 사용하면 H2 같은 내장 DB를 사용해 레포지토리를 테스트할 수 있다.

하지만 H2 의존성을 추가한 후 테스트를 실행하면 아래와 같은 에러가 발생한다. 에러메시지는 H2에 커넥션을 맺을 URL 정보를 찾을 수 없다는 것이지만, 다른 이유로 보인다.

package kr.lul.pages.spring.boot14.v2.jpa.repository;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.Instant;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import kr.lul.pages.spring.boot14.v2.jpa.entity.FooEntity;
import kr.lul.pages.spring.boot14.v2.rest.Spring14Runner;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = Spring14Runner.class)
@DataJpaTest
public class FooRepositoryTest {
  @Autowired
  private FooRepository fooRepository;

  @Before
  public void setUp() throws Exception {
    assertThat(this.fooRepository).isNotNull();
  }

  @Test
  public void testSave() throws Exception {
    // Given
    FooEntity expected = new FooEntity();
    Instant before = Instant.now();

    // When
    FooEntity actual = this.fooRepository.save(expected);

    // Then
    assertThat(actual).isNotNull();
    assertThat(actual.getId()).isGreaterThan(0);
    assertThat(actual.getCreate()).isGreaterThanOrEqualTo(before);
  }
}

추가한 H2 의존성 :

<dependency>
  <groupId>com.h2database</groupId>
  <artifactId>h2</artifactId>
  <scope>test</scope>
</dependency>

에러 메시지 :

java.lang.IllegalStateException: Failed to load ApplicationContext
	at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:124) ~[spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(DefaultTestContext.java:83) ~[spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.test.context.web.ServletTestExecutionListener.setUpRequestContextIfNecessary(ServletTestExecutionListener.java:189) ~[spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.test.context.web.ServletTestExecutionListener.prepareTestInstance(ServletTestExecutionListener.java:131) ~[spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:230) ~[spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.createTest(SpringJUnit4ClassRunner.java:228) [spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner$1.runReflectiveCall(SpringJUnit4ClassRunner.java:287) [spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) [junit-4.12.jar:4.12]
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.methodBlock(SpringJUnit4ClassRunner.java:289) [spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:247) [spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:94) [spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290) [junit-4.12.jar:4.12]
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71) [junit-4.12.jar:4.12]
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) [junit-4.12.jar:4.12]
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58) [junit-4.12.jar:4.12]
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268) [junit-4.12.jar:4.12]
	at org.springframework.test.context.junit4.statements.RunBeforeTestClassCallbacks.evaluate(RunBeforeTestClassCallbacks.java:61) [spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.test.context.junit4.statements.RunAfterTestClassCallbacks.evaluate(RunAfterTestClassCallbacks.java:70) [spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.junit.runners.ParentRunner.run(ParentRunner.java:363) [junit-4.12.jar:4.12]
	at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.run(SpringJUnit4ClassRunner.java:191) [spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:86) [.cp/:na]
	at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:38) [.cp/:na]
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:459) [.cp/:na]
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:678) [.cp/:na]
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:382) [.cp/:na]
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:192) [.cp/:na]
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration': Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'dataSource': Invocation of init method failed; nested exception is java.lang.IllegalStateException: Cannot determine embedded database for tests. If you want an embedded database please put a supported one on the classpath.
	at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:749) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:189) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1148) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1051) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:510) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:482) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractBeanFactory$1.getObject(AbstractBeanFactory.java:306) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:230) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:302) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:197) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:372) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1128) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1023) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:510) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:482) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractBeanFactory$1.getObject(AbstractBeanFactory.java:306) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:230) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:302) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:197) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.context.support.AbstractApplicationContext.getBean(AbstractApplicationContext.java:1076) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:851) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:541) ~[spring-context-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:761) ~[spring-boot-1.4.1.RELEASE.jar:1.4.1.RELEASE]
	at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:371) ~[spring-boot-1.4.1.RELEASE.jar:1.4.1.RELEASE]
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:315) ~[spring-boot-1.4.1.RELEASE.jar:1.4.1.RELEASE]
	at org.springframework.boot.test.context.SpringBootContextLoader.loadContext(SpringBootContextLoader.java:111) ~[spring-boot-test-1.4.1.RELEASE.jar:1.4.1.RELEASE]
	at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContextInternal(DefaultCacheAwareContextLoaderDelegate.java:98) ~[spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:116) ~[spring-test-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	... 25 common frames omitted
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'dataSource': Invocation of init method failed; nested exception is java.lang.IllegalStateException: Cannot determine embedded database for tests. If you want an embedded database please put a supported one on the classpath.
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1583) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:545) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:482) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractBeanFactory$1.getObject(AbstractBeanFactory.java:306) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:230) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:302) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:207) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1128) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1056) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:835) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:741) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	... 52 common frames omitted
Caused by: java.lang.IllegalStateException: Cannot determine embedded database for tests. If you want an embedded database please put a supported one on the classpath.
	at org.springframework.util.Assert.state(Assert.java:392) ~[spring-core-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.boot.test.autoconfigure.orm.jpa.TestDatabaseAutoConfiguration$EmbeddedDataSourceFactory.getEmbeddedDatabase(TestDatabaseAutoConfiguration.java:189) ~[spring-boot-test-autoconfigure-1.4.1.RELEASE.jar:1.4.1.RELEASE]
	at org.springframework.boot.test.autoconfigure.orm.jpa.TestDatabaseAutoConfiguration$EmbeddedDataSourceFactoryBean.afterPropertiesSet(TestDatabaseAutoConfiguration.java:154) ~[spring-boot-test-autoconfigure-1.4.1.RELEASE.jar:1.4.1.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1642) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1579) ~[spring-beans-4.3.3.RELEASE.jar:4.3.3.RELEASE]
	... 63 common frames omitted

모듈 분리(v3)

각 레이어를 따로 다룰 수 있도록 모듈로 분리한다. 모듈로 나누면 모듈(레이어)별로 다른 테스트 설정을 사용 할 수 있다. 예를 들면 JPA 모듈은 필요에 따라 임베디드 DB를 사용할 수도 있고, 실재 DB에 붙어 사용할 수도 있다.

코드 : springboot14/v3

모듈 구성 :

spring-boot14-v3                                  # 전체 프로젝트 관리 모듈
├── spring-boot14-v3-business                     # 비지니스 로직(서비스) 모듈
│   └──kr/lul/pages/spring/boot14/v3
│      └── business
│          ├── dto
│          │   └── FooDto.java
│          └── service
│              ├── FooService.java
│              └── FooServiceImpl.java
├── spring-boot14-v3-domain                       # 도메인 오브젝트 모듈
│   └── kr/lul/pages/spring/boot14/v3
│       └── domain
│           └── Foo.java
├── spring-boot14-v3-jpa                          # DB 매핑(JPA) 모듈
│   └── kr/lul/pages/spring/boot14/v3
│       └── jpa
│           ├── converter
│           │   └── InstantBigintConverter.java
│           ├── entity
│           │   └── FooEntity.java
│           └── repository
│               └── FooRepository.java
└── spring-boot14-v3-rest
    └── kr/lul/pages/spring/boot14/v3
        └── rest
            ├── Spring14Runner.java
            ├── controller
            │   ├── FooController.java
            │   └── FooControllerImpl.java
            └── resp
                ├── FooListResp.java
                └── FooResp.java

kr.lul.pages.spring.boot14.v3를 프로젝트의 기본 패키지로 하고, 그 밑에 각 모듈용 패키지를 둔다.

이 구성에서는 의존성이

domain <- jpa <- business <- rest

의 직선적인 형식을 가지지만, business 모듈을 공유해 HTML의 웹 애플리케이션을 추가로 개발하는 경우도 생각해볼 수 있다.

JPA 모듈 테스트

JUnit 테스트를 @org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest로 설정하면, 실재 DB가 아닌 테스트용 메모리 DB를 사용할 수 있다.

메인 JDBC Driver인 MySQL 의존성을 지우고, Apache Derby를 테스트 스코프로 추가한다.

<!-- Maven -->
<dependency>
  <groupId>org.apache.derby</groupId>
  <artifactId>derby</artifactId>
  <scope>test</scope>
</dependency>

spring-boot-autoconfigure 모듈에 있는 org.springframework.boot.autoconfigure.jdbc.EmbeddedDatabaseConnection의 코드를 보면 테스트용 메모리 DB로 H2도 사용할수 있는 것으로 보이지만, 테스트용 JDBC 모듈로 H2(h2database)를 사용하면 DataSource 인스턴스를 생성하지 못하면서 위의 에러가 발생한다.

로그 설정 문제

src/main/resources/application.yml에서 root 로거의 레벨을 error로 설정하고 config/application.yml에서 warn으로 설정을 덮어쓰면 덮어쓴 값이 무시된다.