Spring Boot + TestContainer + Liquibase를 활용한 통합테스트

안녕하세요 뷰티샵 고객관리 프로그램 공비서 CRM을 개발하는 백엔드 개발자 레이입니다.

최근에는 공비서 CRM의 레거시 코드를 리팩토링하고 있습니다. 리팩토링에 앞서 Outside In 방식으로 테스트 코드를 작성하여 기존 코드를 보호를 하려고 합니다.

Outside In 방식으로 진행하는 이유는, 코드 구조가 도메인/객체 기반이 아닌 절차지향적인 트랜잭션 스크립트 방식의 비대한 서비스 메서드를 가지고 있는 상황이기 때문입니다. 더불어 레이어간의 경계가 불명확한 구조입니다. 컨트롤러에서 비즈니스 로직이 작성되고, 중복되는 코드가 다른 레이어에도 나타나는 등 레이어별로 단위 테스트를 작성하기 어려운 구조였습니다. 그래서 겉으로 드러난 엔드포인트(컨트롤러)에 대한 통합테스트를 작성하면서 점진적으로 사용자 스토리를 중심으로한 DDD로 리팩토링 하려고 합니다.

처음에 통합테스트 코드를 작성하면서 개발 환경 DB와 커넥션을 맺어 진행했습니다. 어느 날 특별한 이유 없이 테스트가 깨지는 상황이 생겼습니다. 원인을 분석해보니 테스트에 주로 사용하는 샵과 직원 정보를 다른 개발자 또는 QA팀에서 특정 데이터에 대한 변경 및 삭제를 했기 때문입니다.

테스트는 독립적이어야 하며 멱등성을 유지하는 것이 좋다고 알고 있습니다. 그래서 테스트 데이터베이스를 개발 환경 DB와 분리해야 할 필요가 있었습니다. H2 인메모리 DB, 도커 컨테이너 등 여러 선택지가 있지만 공비서팀은 TestContainer 라이브러리를 사용하기로 했습니다. 기술 선택에 있어 고려한 사항은 운영 DB와 유사한가, 어느 로컬 환경에서든 동일한 설정으로 테스트가 가능한가였고 TestContainer는 이에 가장 적합했습니다. 아무래도 컨테이너를 구동하는데 소요되는 시간이 있어 테스트 속도가 느려지는 것이 단점입니다. 그러나 TDD 사이클로 빠른 피드백을 받으며 점진적으로 리팩토링하면서 코드를 작성하는 것이 아닌, 엔드포인트가 정상적으로 동작하는지를 판단하기 위한 통합 테스트이므로 속도가 크게 중요하지 않다고 판단했습니다.

통합 테스트 데이터베이스 기술 비교는 다음과 같습니다.

통합 테스트 데이터베이스 비교

TestContainer를 통해 독립적이고 멱등성을 유지한 테스트가 가능해졌습니다. 그러나 저희는 또 다른 문제에 봉착했습니다. 기존의 개발DB는 이미 스키마와 데이터가 있어 테스트가 가능한데, TestContainer를 사용할 경우 스키마와 데이터가 없어 초기 설정이 필요했습니다. 공비서 CRM은 MyBatis를 사용하므로 ORM을 통한 스키마 구축이 불가능했습니다. 그래서 스키마와 초기 데이터 구축을 위해 DB 마이그레이션 도구인 Liquibase을 적용했습니다.

Liquibase는 Flyway와 대표적으로 거론되는 데이터베이스 형상관리, 마이그레이션 도구입니다. 공비서팀도 이 두 가지를 고려했는데, 최종적으로 Liquibase를 선택했습니다. 그 이유는 Liquibase가 조금 더 다양한 커맨드, 포맷, 기능을 지원하고 무료버전에서의 기능도 더 많았기 때문입니다. Flyway보다 사용이 어려운 단점이 있지만, 초기 설정만 잘 한다면 이 후에는 더 장점이 많은 기술이라 생각했습니다.

Liquibase와 Flyway 비교 참고 자료

이제 본론으로 Spring Boot 애플리케이션에서 TestContainer와 Liquibase를 활용하여 통합 테스트 환경을 구축하는 과정을 설명하겠습니다.

TestContainer

TestContainer 의존성 추가 (Gradle)

dependencies {
	// ...
	testImplementation "org.testcontainers:testcontainers:1.16.0"
	testImplementation "org.testcontainers:junit-jupiter:1.16.0"
	testImplementation "org.testcontainers:mariadb:1.16.0"
}

TestContainer JUnit Extension 설정

TestContainer를 테스트 환경에 적용할 때 테스트별 컨테이너를 사용하거나, 전역 테스트 컨테이너를 공유하여 사용할 수 있습니다. 테스트별 컨테이너를 사용하는 것이 이상적이나 테스트 속도가 많이 느려지기 때문에 보통 전역 테스트 컨테이너를 설정하여 공유하여 사용합니다. JUnit @AfterEach 픽스쳐에서 데이터 원자성을 보장하도록 설정(매번 테이블의 데이터 삭제)하거나, Spring @Transactional 어노테이션으로 간단하게 테스트 데이터를 롤백하도록 설정할 수 있습니다. (SpringTestApplicationContext Thread와 테스트 대상 ApplicationContext Thread의 불일치로 @Transactional이 의도치 않게 동작할 수 있으니 전자의 방식을 사용하는 것을 권장합니다.)

그 다음에 중요한 것은 TestContainer가 실행되고 컨테이너 접속 정보를 Spring ApplicationContext가 알도록 설정해줘야 합니다. Spring Framework 5.2.5 이후 @DynamicPropertySource 어노테이션을 사용하거나 그 전 버전이면 ApplicationContextInitializer를 사용합니다.

공비서 CRM은 위의 방식 말고 JUnit BeforeAllCallback, AfterAllCallback 인터페이스를 구현하여 전역 테스트 컨테이너를 설정하고 테스트 클래스에서 @ExtendWith 어노테이션으로 주입 받아 사용하도록 구성했습니다. 이렇게 설정하면 스프링에 독립적인 JUnit 확장 클래스로 설정 가능합니다.

public class MariaDBExtension implements BeforeAllCallback, AfterAllCallback {

    public static JdbcDatabaseContainer<?> container;

    @Override
    public void beforeAll(final ExtensionContext context) throws Exception {
				// 운영 환경 DB 버전과 동일하게 설정
        final String IMAGE_NAME = "mariadb:10.2.14";
        container = new MariaDBContainer(IMAGE_NAME)
            .withDatabaseName("test")
            .withUsername("test")
            .withPassword("1q2w3e4r")
            .withUrlParam("characterEncoding", StandardCharsets.UTF_8.name())
            .withUrlParam("serverTimezone", "Asia/Seoul")
						.withCommand("mysqld", "--character-set-server=utf8mb4", "--collation-server=utf8mb4_general_ci");

        container.start();
      
				System.setProperty("spring.datasource.url", container.getJdbcUrl());
        System.setProperty("spring.datasource.username", container.getUsername());
        System.setProperty("spring.datasource.password", container.getPassword());
    }

    @Override
    public void afterAll(final ExtensionContext context) throws Exception {
        container.stop();
    }
}

@ExtendWith(MariaDBExtension.class) // TestContainer MariaDB 확장 클래스 등록
@Ignore // 통합테스트는 자동 테스트에서 제외되도록 Ignore 처리
@Transactional // 테스트 트랜잭션 선언시 테스트 메서드 종료 후 데이터 Rollback 처리됨(쓰레드 불일치 주의)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ControllerIntegraionTest {

	// 테스트 코드 ...

}

Liquibase

앞서 말씀드린 것처럼 ORM을 사용하지 않는 환경에서 TestContainer를 통해 독립적인 테스트 데이터베이스 환경을 구성할 때 스키마와 초기 데이터를 테스트 전에 구성해야 합니다.

Spring Boot에서는 기본적으로 classpath의 schema.sql과 data.sql 파일로 데이터베이스 초기화를 지원합니다.

(참고: https://docs.spring.io/spring-boot/docs/2.1.x/reference/html/howto-database-initialization.html#:~:text=Spring Boot can automatically create,sql %2C respectively.)

이 방법을 이용하기 위해 기존 개발DB를 대상으로 mysqldump를 사용하거나 DB 툴로 schema를 export 하는 등 여러 방법을 사용했지만 예기치 못한 예외 상황들이 발생했습니다. 대표적으로는 다음과 같습니다.

문제들을 하나씩 해결해가면서 적용을 했었지만 이 방법을 사용하지 않기로 했습니다. 이후 애플리케이션이 고도화 되면서 DB 스키마가 변경될 경우 schema.sql 파일을 유지보수하기가 어려울 것 같았습니다.

그래서 DB 형상관리/마이그레이션 도구를 도입하기로 결정했고, Liquibase를 선택한 것입니다.

Liquibase Gradle Plugin을 활용한 기존 DB 스키마 구축

Liquibase는 ChangeLog 파일을 통해 데이터베이스 변경분에 대해 형상관리를 진행합니다. 대부분의 가이드나 블로그 글 등은 초기 데이터베이스를 구축하면서 Liquibase를 사용하는 것인데요. 레거시 환경에서는 기존 DB내용이 있어 ChangeLog를 기존 데이터베이스 내용과 맞춰 생성할 필요가 있습니다.

Liquibase 공식 사이트에 레거시 데이터베이스가 있을 경우 적용하는 방법을 가이드하고 있습니다.

참고: https://docs.liquibase.com/workflows/liquibase-community/existing-project.html

문서 내용을 간략히 정리하자면 다음의 프로세스를 거치면 쉽게 적용할 수 있습니다.

# generateChangeLog를 통해 기존 DB의 스키마를 liquibase changelog 파일로 생성
$ liquibase --changelogFile=db-changelog.1.0.0.xml generateChangeLog

# changeLogFile의 내용을 대상 DB와 싱크를 맞춰보고 DATABASECHANGELOG 테이블에 입력할
# INSERT SQL문을 출력하여 sync 검증
$ liquibase --changelogFile=db-changelog.1.0.0.xml changelogSyncSQL

# 실제로 sync 처리를 하여 DATABASECHANGELOG 테이블 생성 및 changeLogFile 내용을 입력
$ liquibase --changelogFile=db-changelog.1.0.0.xml changelogSync

위의 방법은 Liquibase를 따로 설치를 해야 하는 번거로움이 있어 gradle plugin을 통해 바로 명령을 수행할 수 있도록 구성했습니다.

Gradle 플러그인을 통해 gradle [task] 명령어로 Liquibase Task를 수행할 수 있습니다. build.gradle 파일에 다음과 같이 설정하면 됩니다.

buildscript {
	//...
	repositories {
		mavenCentral()
	}
  // 플러그인 추가
	dependencies {
		classpath "org.liquibase:liquibase-gradle-plugin:2.0.3"
	}
}

// 플러그인 적용
apply plugin: 'org.liquibase.gradle'

dependencies {

	//...
  // liquibase 적용
	liquibaseRuntime 'org.liquibase:liquibase-core:4.4.3'
	liquibaseRuntime 'org.mariadb.jdbc:mariadb-java-client:2.7.3'
	liquibaseRuntime 'ch.qos.logback:logback-core:1.2.3'
	liquibaseRuntime 'ch.qos.logback:logback-classic:1.2.3'

	testImplementation 'org.mariadb.jdbc:mariadb-java-client:2.7.3'
	testImplementation 'org.liquibase:liquibase-core:4.4.3'

}

test {
	useJUnitPlatform()
}

liquibase {
	activities {
		main {
			changeLogFile 'src/main/resources/db/changelog/db.changelog-master.xml'
			url 'jdbc:mariadb://기존DB:3306/test-db?characterEncoding=UTF-8&serverTimezone=Asia/Seoul'
			username 'username'
			password 'password'
			driver 'org.mariadb.jdbc.Driver'
			logLevel 'debug'
		}
	}
}

plugin을 통해 gradle 명령을 수행하여 기존 DB로부터 changeLogFile을 생성 하고 liquibase를 적용해 봅니다.

$ gradle generateChangeLog
$ gradle changelogSyncSQL
$ gradle changelogSync

generateChangeLog 명령을 통해 build.gradle 설정에서 changeLogFile에 설정된 파일에 기존 DB 내용으로 changeLog를 생성 됩니다.

검증 목적으로 changelogSyncSQL 명령으로 DATABASECHANGELOG 테이블에 입력할 SQL문을 출력합니다. 이 때 에러가 없으면 앞서 생성한 changeLog 파일이 유효하다고 판단하면 됩니다.

changelogSync를 수행하면 실제로 DATABASECHANGELOG 테이블에 changeLog 파일 내용이 입력이 되어 앞으로 Liquibase를 사용하면서 데이터베이스 변경분을 추적할 수 있게 됩니다.

TestContainer에 Liquibase를 통한 테스트 DB 구축

Liquibase에는 Spring Integration 패키지가 있어 Spring 설정과 통합이 가능합니다. 다음과 같이 SpringLiquibase를 응답하는 Bean 설정을 통해 초기 스키마 및 데이터 구축이 가능합니다.

@Configuration
@EnableTransactionManagement
@Profile("test")
public class TestDataBaseConfig {

    @Bean
    public DataSource dataSource() {
        DriverManagerDataSource ds = new DriverManagerDataSource();
        ds.setUrl(container.getJdbcUrl());
        ds.setUsername(container.getUsername());
        ds.setPassword(container.getPassword());
        ds.setSchema(container.getDatabaseName());
        return ds;
    }

    @Bean
    public SpringLiquibase springLiquibase(DataSource dataSource) {
        SpringLiquibase liquibase = new SpringLiquibase();
        liquibase.setTestRollbackOnUpdate(true);
        liquibase.setDataSource(dataSource);
        liquibase.setDefaultSchema(container.getDatabaseName());
        liquibase.setChangeLog("classpath:/db/init/db.changelog-init.xml");
        return liquibase;
    }

}

결론

독립적인 테스트 환경 구성으로 외부 영향에 의해 테스트가 실패되지 않도록 TestContainer로 테스트 DB 구축을 진행했습니다. 또 구축되는 테스트 DB에 ORM을 사용하지 않으므로 Liquibase를 통해 DB 스키마 설정 및 초기 데이터 구축을 설정했습니다.

이를 바탕으로 인수테스트를 만들어 레거시 코드를 보호한 후 리팩토링을 거치면서 서비스 확장에 대비할 수 있게 되었습니다.


저희와 함께 하고 싶은 분들은 헤렌의 채용 담당자 에게 커피챗을 요청해 보세요! 헤렌은 현재 다양한 개발 직군을 적극적으로 채용하고 있습니다 🚀

헤렌 채용 │ HERREN CAREERS