현재 참여하고 있는 투룻 프로젝트는 MySQL과 S3를 사용하고 있다. 이러한 서비스들을 이용하다 보니 테스트 코드에서 몇가지 문제점이 발생하였는데 이를 해결하기 위해 프로젝트에서 Testcontainers를 도입해보았다. 개선이 적용된 프로젝트의 현재 버전을 보니 테스트의 신뢰성을 높이는 의미 있는 개선이었다고 생각하는데, 이번 포스팅에서는 Testcontainers를 도입하여 우리의 문제를 해결한 사례를 작성해보고자 한다.
현재 투룻 프로젝트에서는 배포 환경에서 데이터베이스로 MySQL을 운용하고있다. 하지만 테스트에서는 H2 내장 데이터베이스를 사용하고 있었는데, 그러다보니 H2와 MySQL의 동작방식이 달라서 문제가 발생한 적이 있다. 테스트 환경에서 통과하는 기능이 배포환경에서 통과하지 않거나 배포 환경에서 잘 동작하는 기능이 테스트를 통과하지 못하는 사례가 있었던 것이다.
H2와 MySQL 불일치로 인한 문제 지점들
- MySQL InnoDB Locking 메커니즘 (Record Lock, Gap Lock 등) Deadlock 발생 상황을 H2로 재현하기 어려움
- H2와 MySQL에서 Java의 특정 속성 (ex. UUID) 데이터베이스 칼럼으로 매핑하는 방식이 다름
- 특정 함수 및 프로시저 지원 범위의 차이
- H2와 MySQL의 상이한 DDL, 함수, 프로시져 문법으로 이한 Flyway 스크립트 검증 불가
또한 투룻 프로젝트에서는 S3를 사용하여 사용자 업로드 이미지를 관리하고 있다. 그러나 테스트 환경에서는 AWS S3 대신 Stubbing을 사용하여 테스트를 수행해왔다. Stubbing은 단순히 S3 API 호출을 Mocking하여 반환값을 설정하는 방식이었기 때문에 실제 S3와 동일한 동작을 보장하지 못하는 한계가 있었다.
Mock 객체로 검증할 수 없었던 S3 동작
- S3 인증 정보 오류
- S3 네트워크 지연, 시간 초과
위와 같은 로컬 환경과 배포 환경의 불일치는 CI 파이프라인에서 시스템 안정성을 검증하지 못하는 이슈를 발생시킨다. 실제로 우리 프로젝트에서는 MySQL과 H2의 잠금 메커니즘의 차이로 인해 테스트에서는 통과하는 로직이 배포 환경에서 예외(Deadlock)를 발생시킨 적이 있고, MySQL과 H2의 DDL, 프로시져, 함수등의 문법이 달라 테스트 해보지 못한 flyway 스크립트 동작은 여러 핫픽스 PR을 만들어내기도 했다.
예외가 발생하기만 하면 다행일지도 모르겠다. 하지만 환경 간 불일치로 발생한 예외는 추적이 어렵다는 특성 또한 가지고 있다. 이처럼 환경 불일치로 발생한 문제는 발견 자체가 어렵고, 문제를 바로잡는 과정도 복잡하고 시간이 많이 걸린다. 테스트 코드라는 안전 장치가 있음에도 불구하고 우리 프로젝트에서 이슈는 다양한 형태로 계속 반복됐고, 그 결과, 테스트에 대한 신뢰성이 낮아지게 되었다. 테스트 결과를 신뢰하지 못하게 되니, 중요한 행사를 앞둔 배포 과정에서 팀 분위기는 더욱 예민해지기 일수였다. 여러 관심사를 한꺼번에 검토하며 작업이 진행됐고, 이로 인해 피로감과 스트레스가 더욱 심화되는 상황이 반복됐다.
환경 불일치로 인해 테스트의 신뢰성이 떨어지는 문제가 해결되어야 한다. 팀에서 나와 동료 백엔드 개발자 한명이 해당 문제를 해결하는 담당자로 선정되었고 우리는 회의를 통해서 다양한 방안들을 모색했다. 글의 제목에서도 알 수 있듯이 우리는 Testcontainers를 도입하는 것으로 문제를 해결하기로 결정했는데, 다른 방안들의 장단을 따져보는 것이 우리의 결론에 도달할 수 있도록 많은 도움이 되었던 것 같다. 우리가 도출한 방안들과 그 장단점을 살펴보자.
테스트용 외부 서비스 인스턴스를 구축하고 관리하는 방식이다. 테스트용 MySQL 데이터베이스를 EC2에 설치하고 로컬에서 사용될 S3 역시 별도로 구축하는 것이다. 해당 방식은 실제 환경과 거의 동일한 환경을 제공할 수 있는 장점과 함께 모든 개발자가 동일한 환경을 공유하므로 환경편차가 줄어든다는 장점이있다. 하지만 로컬테스트 시 EC2까지 네트워크 연결이 필요함으로 테스트 속도가 느려질 수 있으며 인프라 관리에 오버헤드가 존재한다는 단점이 있다.
우리는 테스트 속도와 테스트를 위한 인프라 비용 추가라는 단점에 공감하여 해당 방식을 채택하지 않았다.
cf. 테스트 속도가 그렇게 중요한가?
테스트 속도 한 30-40초 더 차이나는게 그렇게 큰 비용인가 생각이 들 수도 있다. 하지만 테스트는 개발자에게 피드백을 주는 장치이자 안정적인 기능 통합을 위한 파이프라인 요소이다. 개발자에게 빠른 피드백을 줄 수 있다는 점과, 파이프라인 병목을 야기할 수 있다는 점을 생각해보면 테스트 속도는 충분히 중요하게 고려되어야 하는 지점이다.
현재 투룻 프로젝트에서는 local - dev(개발) - prod(운영) 세 단계의 인프라를 운용중이다. 해당 방식은 개발 환경에서 사용되는 인프라를 로컬 테스트에서 사용하는 방식이다. 별도의 인프라 구축 없이 실제 운영환경과 비교적 유사한 환경을 모방할 수 있지만 로컬에서의 변경 사항을 개발 환경에서 마음대로 조작하기 어렵고 개발 환경과 격리되지 않은 테스트 환경으로 인해 데이터 무결성 문제가 발생할 수 있으며, 네트워크 비용 문제가 해소되지 않는다는 단점이 있다.
개발자들이 테스트 실행 전 로컬에서 환경을 셋업한 뒤 테스트를 실행시키는 방법이다. 네트워크 지연 없이 빠른 테스트를 수행할 수 있으며, 다른 환경과 독립된 환경에서 테스트를 할 수 있어 스키마나 데이터 변경 등 테스트 작업의 격리와 자유도가 올라간다는 것이 장점이다. 다만 개발자별로 로컬 인프라 설치, 버전 관리의 부담이 발생하며 개발자 PC 환경에 따라 설정 편차가 발생할 수 있다는 단점이 있다.
MySQL, S3등의 인스턴스를 임베딩하여 실행 하는 방식이다. 외부 의존성이 줄어들어 CI/CD나 개발 PC 세팅이 간단해질 수 있다는 장점이 있지만 완벽한 호환을 보장하기 어려울 수 있다. Embedded 솔루션이 전체 기능을 다 지원하지 못하거나 특정 기능에 제한이 있는 경우를 파악해야 하며 이로 인한 테스트 신뢰성 저하가 있을 수 있다.
이번 포스팅의 주제인 Testcontainers를 사용하는 방식이다. 일관되고 격리된 테스트 환경을 제공하며 컨테이너의 생성 파괴가 자동으로 관리된다. 또한 데이터베이스, 메시지 브로커, 검색엔진, 클라우드 서비스 등 다양한 서비스를 위한 모듈을 제공하고 있으며 Junit의 테스트 라이프사이클과 자연스럽게 연동된다는 장점이 있다. 우리 팀원들은 테스트 신뢰도를 높이면서 테스트 속도와 일관된 환경, 격리성을 보장하는 Testcontainers를 최종 솔루션으로 채택했다.
| 번호 | 방법 | 장점 | 단점 |
|---|---|---|---|
| 1 | 로컬용 인프라 구축 | - 실제 환경과 거의 동일한 환경 제공 - 모든 개발자가 동일한 환경을 공유하여 환경 편차 감소 | - 네트워크 연결로 인해 테스트 속도 저하 - 인프라 관리 오버헤드 - 추가 인프라 비용 발생 |
| 2 | 개발환경의 인프라를 로컬에서 사용 | - 별도 인프라 구축 필요 없음 - 운영 환경과 유사한 환경 제공 | - 개발 환경과의 격리 부족으로 데이터 무결성 문제 발생 가능 - 네트워크 비용 문제 발생 - 로컬 변경 사항을 개발 환경에서 마음대로 조작하기 어려움 |
| 3 | 로컬 환경에서 MySQL, Local S3 실행 | - 네트워크 지연 없이 빠른 테스트 가능 - 독립된 환경에서 테스트 가능 - 테스트 작업의 격리와 자유도 향상 | - 로컬 인프라 설치 및 버전 관리 부담 - 개발자 PC 환경에 따라 설정 편차 발생 |
| 4 | Embedded 솔루션 사용 | - 외부 의존성 감소 - CI/CD 및 개발 PC 설정 단순화 | - 완벽한 호환 보장 어려움 - 특정 기능 제한 가능 - 테스트 신뢰성 저하 우려 |
| 5 | Testcontainers 사용 (채택) | - 일관되고 격리된 테스트 환경 제공 - 컨테이너 생성 및 파괴 자동 관리 - 다양한 서비스 지원 (DB, 메시지 브로커 등) - JUnit과 자연스러운 연동 | - 컨테이너 생성에 따른 초기 테스트 실행 시간 약간 증가 |
우리는 도커 컨테이너를 활용하여 테스트 환경을 구성하는 테스트컨테이너를 솔루션으로서 채택했다. 그런데 테스트컨테이너는 무엇일까? 테스트 컨테이너와 도커 컨테이너를 직접 운용하는 방식은 어떤 차이가 있을까? 조금 더 자세히 알아보자.
Testcontainers 공식 홈페이지
Testcontainers는 테스트 시에 일시적으로 실행 가능한 테스트용 Docker 컨테이너를 손쉽게 관리할 수 있는 오픈소스 Java 라이브러리이다. 모킹이나 복잡한 환경 설정 없이 실제 서비스 환경을 재현하기 쉽도록 도와주며 이를 통해 개발자는 H2와 같은 인메모리 DB 대신 실제 DBMS를 테스트 환경에서 쉽게 사용할 수 있다. 또한 CI/CD 환경에서도 도커의 이식성과 격리성을 바탕으로 일정한 환경을 재현할 수 있어 테스트 신뢰도를 크게 향상 시켜준다.
위의 테스트 컨테이너 설명을 살펴보면 테스트 컨테이너가 도커 컨테이너 기반으로 동작함을 알 수 있다. 컨테이너를 통해 실제 환경과 비슷한 의존성을 주입하여 테스트 신뢰성을 올리는 것이 테스트컨테이너의 방식인 것이다. 그렇다면 Docker Container를 개발자가 직접 활용하여 테스트하는 것과는 차이점이 크게 없을 것 같기도 하다. Docker 컨테이너를 직접 활용하는 것과 테스트컨테이너는 어떤 차이점이 있을까? 테스트 컨테이너의 주요 특징을 추가로 알아보자.
일관된 환경 제공
Testcontainers는 일관된 환경을 제공한다.
로컬 환경이나 CI 환경에서 동일한 Docker 이미지를 기반으로 테스트를 실행하기 때문에, 환경 편차를 최소화할 수 있다.
자동 관리
Testcontainers는 Docker 컨테이너를 기동하고 clean up하는 과정을 자동으로 관리해준다.
다양한 모듈 지원
Testcontainers는 데이터베이스(MySQL, PostgreSQL, Oracle 등), 메시지 브로커(Kafka, RabbitMQ), 검색엔진(Elasticsearch), 클라우드 서비스 에뮬레이터(LocalStack) 등 다양한 서비스를 위한 모듈을 제공한다.
유연한 통합
JUnit과 자연스럽게 연동하여 테스트 라이프사이클에 맞춰 컨테이너 시작/종료를 할 수 있다.
자바 코드레벨에서 컨테이너를 손쉽게 구성
자바 코드레벨에서 컨테이너를 구성하여 테스트 컨테이너 객체를 사용하고 있음을 인지하기 쉽도록 한다.
또한 컨테이너 생명 주기 관리, 구동 방식, 포트 설정등의 여러 작업에서 추상적이고 관용적인 API를 제공한다.
@SpringBootTest
@Testcontainers // 컨테이너 생명주기를 JUnit 테스트와 연동
class MenuGroupServiceTest {
@Container // JUnit과 자동으로 컨테이너 생명 주기를 관리
private static final MySQLContainer<?> mySQLContainer =
new MySQLContainer<>("mysql:8")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpassword");
@Autowired
private MenuGroupRepository menuGroupRepository;
@Autowired
private MenuGroupService menuGroupService;
}
요약하면 테스트컨테이너는 자바 코드레벨에서 컨테이너의 생명 주기, 구동 방식, 포트 설정등을 쉽게 관리할 수 있고, 관리를 자동화할 수 있다. Docker Container를 직접 운용하는 경우 개발자가 감수해야 하는 여러 비용들을 추상적이고 관용적인 API를 통해 해결해주는 것이다. Docker Container와 개발자 사이에 사용하기 쉬운 인터페이스를 제공하는 것이 Docker Container와 Testcontainers 사이의 차이점이라고도 이해할 수 있겠다.
지금까지 우리 팀의 문제 상황과 이를 해결하기 위해 Testcontainers를 도입하기로 결정했던 과정, 그리고 Testcontainers가 무엇인지 살펴보았다. 이어지는 글에서는 Testcontainers를 실제로 도입하는 방식과 고려해야 하는 지점들을 살펴보자. 참고로 Testcontainers는 공식 문서 가이드가 잘 정리되어있는 것 같다. 우리 팀은 공식 문서 가이드를 살펴보며 Testcontainers를 도입했고 후술하는 과정들은 공식 문서를 참고했음을 먼저 알린다. (Testcontainers Getting started with Testcontainers in a Java Spring Boot Project)
먼저 로컬환경에 도커를 설치해야 한다. 도커 설치 방법은 공식 사이트를 참고하자
다음으로 테스트컨테이너 의존성을 추가해주자.
testImplementation "org.testcontainers:testcontainers:1.20.4"
// 추가 모듈
testImplementation "org.testcontainers:junit-jupiter:1.20.4"
testImplementation "org.testcontainers:mysql"
testImplementation 'org.testcontainers:localstack'
Testcontainers에서는 각 서비스마다 더 특화된 기능이 추가된 모듈을 제공한다. 투룻 프로젝트에서는 Junit과 MySQL, S3를 위한 Localstack(AWS 인프라를 로컬 환경에서 테스트할 수 있게 도와주는 라이브러리) 모듈을 더 추가했다.
테스트에서 실제로 컨테이너를 사용하는 방식을 살펴보자.
먼저 H2대신 MySQL 컨테이너를 사용하는 방식이다. Testcontainers의 MySQL 모듈을 활용하면 실제 MySQL 데이터베이스를 로컬 환경에서 컨테이너로 실행할 수 있다.
@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource(properties = {"spring.config.location = classpath:application-test.yml"})
class SomeIntegrationTest {
private static final MySQLContainer<?> mysqlContainer =
new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpassword");
@BeforeAll
static void startContainer() {
mysqlContainer.start(); // 컨테이너 수동 시작
}
@AfterAll
static void stopContainer() {
mysqlContainer.stop(); // 컨테이너 수동 종료
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
registry.add("spring.datasource.username", mysqlContainer::getUsername);
registry.add("spring.datasource.password", mysqlContainer::getPassword);
}
@Test
void someTest() {
System.out.println("JDBC URL: " + mysqlContainer.getJdbcUrl());
System.out.println("Username: " + mysqlContainer.getUsername());
System.out.println("Password: " + mysqlContainer.getPassword());
// 실제 데이터베이스 연동 테스트 수행
}
}
위 테스트 코드는 Spring Boot 테스트 환경에서 Testcontainers의 MySQL 컨테이너를 사용하는 테스트이다. DynamicPropertySource를 활용해 Spring 애플리케이션 컨텍스트에 동적으로 MySQL 컨테이너의 설정을 주입하고 있다. 이처럼 테스트 코드를 작성하면 우리가 정의한 테스트 컨테이너를 활용한 통합테스트가 가능해진다.
테스트용 MySQL 컨테이너가 실행되는 모습
테스트 컨테이너 도커 컨테이너가 실행되고 있는 모습
내장 H2가 아닌 MySQL 컨테이너 기반으로 datasource가 설정된 모습
cf1> ryuk 컨테이너란??
테스트 컨테이너가 실행되는 동안 docker desktop을 확인하면 ryuk 컨테이너가 활성화되어 있는 것을 확인할 수 있다.
Ryuk는 Testcontainers에서 컨테이너 정리를 담당하는 내부 서비스 컨테이너이다. Testcontainers는 테스트 실행 중 생성된 Docker 컨테이너와 네트워크를 적절히 관리하고 테스트 종료 후 정리(clean-up)하기 위해 Ryuk 컨테이너를 사용한다. Ryuk 컨테이너는 Testcontainers가 시작될 때 자동으로 시작되어 사용된 테스트 컨테이너들의 리소스 정보를 받고 테스트 종료 시 정리 작업을 수행한다.
cf2> @Testcontainers, @Container
@BeforeAll static void startContainer() { mysqlContainer.start(); // 컨테이너 수동 시작 } @AfterAll static void stopContainer() { mysqlContainer.stop(); // 컨테이너 수동 종료 }위처럼 반복되는 컨테이너 생명주기 관리 코드를 작성하기 귀찮다면 @Testcontainers 애너테이션과 @Container 애너테이션을 함께 사용해볼 수 있다. 두 애너테이션을 함께 사용하면 수동으로 컨테이너를 관리하지 않고 Junit 테스트 라이프사이클에 컨테이너를 연동시킬 수 있다. 번거로운 코드를 생략해주기에 도입하는데에 위화감이 없는 것처럼 보일 수 있지만 주의해야할 점도 있다. 이어지는 글에서 살펴보자.
두 애너테이션을 사용하면 Junit 테스트 메서드의 생명주기에 맞춰 컨테이너가 생성 및 종료된다. 즉, 모든 테스트 메서드가 실행되기 전 컨테이너가 하나 생성되고 메서드 종료 후 컨테이너가 파괴된다.(테스트 인스턴스 설정에 따라 상이할 수 있음) 만약 15개의 테스트를 돌린다면 15번의 컨테이너 실행, 연결 및 종료 과정이 일어나는 것이다.
컨테이너를 테스트마다 새로 생성하는 이러한 동작은 어찌보면 @DirtiesContext 애너테이션과 맥락이 비슷하다. 극강의 격리 수준을 제공하지만 테스트 실행속도는 다음처럼 매우 느려질 수 있다.

매번 컨테이너를 띄우고, 연결하고, 파괴하는 사이클을 반복하면 15개의 테스트를 돌리는데 2분이라는 오랜 시간이 걸린다. 테스트 속도를 개선하고 싶다면 테스트 컨테이너를 효율적으로 재사용해야 한다.
어느 단위로 재사용할지 선택은 여러분들의 몫이다.
우리 투룻팀에서는 모든 테스트에서 단 한번 컨테이너를 초기화하는 전략을 택했다.
테스트 컨테이너를 효율적으로 재사용하려면 모든 테스트에서 동일한 컨테이너를 사용하도록 구성해야 한다. 이렇게 하면 컨테이너 생성 및 종료로 인한 시간과 리소스 낭비를 줄일 수 있다. 아래는 Testcontainers에서 컨테이너 재사용을 위해 고려할 수 있는 세 가지 주요 방법이다.
Testcontainers는 데이터베이스 컨테이너를 손쉽게 사용할 수 있도록 JDBC 지원 기능을 제공한다. 이를 활용하면 application.yml에 데이터베이스 연결 정보를 고정하여 Testcontainers를 사용할 수 있다. 예를 들어, MySQL 컨테이너를 설정하여 Spring Boot 애플리케이션과 연동할 수 있다.
spring:
datasource:
url: jdbc:tc:mysql:8.0:///testdb
username: testuser
password: testpassword
Testcontainers는 컨테이너를 실행 후 캐싱하여 다음 테스트 실행 시 재사용할 수 있는 "Reusable Containers" 기능을 제공한다. 이 기능을 활성화하려면 로컬 시스템의 홈 디렉토리에 .testcontainers.properties 파일을 생성하고, 재사용 설정을 추가해야 한다.
testcontainers.reuse.enable=true
컨테이너가 종료되지 않고 유지되기 때문에 모든 테스트 실행에서 동일한 컨테이너를 재사용한다.
.testcontainers.properties 파일을 로컬에 작성해야 하므로 환경 통일성이 떨어질 수 있다.Singleton Containers 패턴은 컨테이너를 정적(static)으로 선언하여 테스트 클래스 간 재사용할 수 있도록 구성하는 방식이다. 이 방식을 적용하려면 추상 클래스를 생성하고, 정적 필드로 컨테이너를 정의한다.
public abstract class AbstractIntegrationTest {
private static final MySQLContainer<?> mysqlContainer =
new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpassword");
static {
mysqlContainer.start();
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
registry.add("spring.datasource.username", mysqlContainer::getUsername);
registry.add("spring.datasource.password", mysqlContainer::getPassword);
}
}
테스트 클래스는 이 추상 클래스를 상속받아 컨테이너를 재사용하며, 모든 테스트에서 동일한 컨테이너를 사용한다. 특히 Localstack과 같이 여러 컨테이너를 동시에 사용하는 경우에도 적합하다.
우리 팀은 코드 기반의 명확성과 여러 컨테이너의 통합 관리를 중요하게 생각하여 Singleton Containers 패턴을 선택했다. 이 방식을 통해 모든 테스트에서 컨테이너를 공유하며, Localstack과 같은 다양한 컨테이너를 통합적으로 관리할 수 있었다.
또한 이 방식을 통해 컨테이너 생성 및 종료의 비용을 줄였으며, 코드 레벨에서 컨테이너의 구성과 설정을 명확히 파악할 수 있었다. Singleton Containers 패턴은 성능 최적화와 유지보수성 향상을 동시에 만족시키는 최적의 솔루션이었다.
Testcontainers는 많은 장점을 가지고 있지만 몇 가지 단점도 존재한다. 도커 컨테이너를 사용하여 테스트 환경을 구성하기에 로컬 환경의 리소스 고려가 필요하고 테스트 수행 시간이 증가할 수 있는 것이다. 하지만 충분한 리소스와 네트워크 환경이 확보된 상황이라면 멱등성을 보장하는 결정적인 테스트 작성을 도와주는 Testcontainers 도입을 고려해볼 수 있겠다.
투룻팀에서는 Testcontainers 도입을 통해 개별 로컬과 배포 환경차이로 인한 예외 상황은 이제 재발하지 않게되었다. 테스트 컨테이너 대문 페이지의 다음 글처럼 복잡한 목킹과 환경설정에 애먹고 있는 개발자들이라면 테스트 컨테이너 도입을 권하며 마무리한다.
No more need for mocks or complicated environment configurations. Define your test dependencies as code, then simply run your tests and containers will be created and then deleted.