깨지지 않는 API를 위한 Testcontainers 기반의 통합 테스트(Integration Test) 구축기
목업(Mock) 테스트의 맹점을 극복하고, 실제 환경과 동일한 Docker 컨테이너(Testcontainers)를 띄워 신뢰성 높은 통합 테스트 환경을 구축한 과정을 정리합니다.
깨지지 않는 API를 위한 Testcontainers 기반 통합 테스트 구축기
이전 글들에서 REST API의 예외 처리나 응답 구조를 탄탄하게 설계하는 방법에 대해 다루었는데요. 이렇게 기획한 API가 운영 환경에서도 '절대 깨지지 않는다'고 확신할 수 있을까요?
개발 초반에는 @MockBean을 활용한 단위 테스트(Unit Test)만으로도 충분하다고 느꼈습니다. 하지만 프로젝트 규모가 커지고 데이터베이스 매핑 로직이 복잡해질수록, 모킹(Mocking)된 객체로는 잡아내지 못하는 런타임 버그들이 스멀스멀 올라오기 시작했습니다.
이 글에서는 H2 인메모리 DB의 한계를 벗어나,운영 환경과 100% 동일한 인프라를 코드 레벨에서 띄워주는 Testcontainers를 도입한 경험을 공유합니다.
단위 테스트(Unit Test)의 배신
서비스 레이어를 테스트할 때 제가 가장 많이 작성했던 형태는 대략 이렇습니다.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Test
void 주문_생성_성공() {
// given
given(orderRepository.save(any())).willReturn(new Order(1L));
// when & then
// ...
}
}이런 방식은 로직 검증 속도가 무척 빠르다는 장점이 있습니다. 그러나 특정 쿼리 조건문이 DB의 방언(Dialect)과 맞지 않는다거나, 외래 키 제약 조건(FK Constraint)에 걸리는 문제 등 인프라와 얽힌 결함은 프로덕션 배포 이후에 터지는 경우가 잦았습니다.
H2 데이터베이스의 한계
이를 보완하기 위해 처음엔 비용이 들지 않고 환경 설정이 간단한 H2 인메모리 DB를 사용했습니다. 그러나 여기서도 문제가 발생하더군요.
- MySQL 고유의 함수(예:
DATE_FORMAT,JSON_EXTRACT) 사용 불가 - 특수한 인덱스(Full-Text Index 등)의 미지원
- DB 자체 설정에 따른 타임존이나 컬레이션(Collation) 이슈
로컬과 테스트 환경은 H2인데 프로덕션은 MySQL이나 PostgreSQL을 쓴다면, 결국 눈 감고 "잘 돌겠지" 기도하는 이른바 '기도 메타' 배포를 할 수밖에 없었습니다.
구원투수, Testcontainers 등장
이 간극을 메워준 것이 바로 Testcontainers입니다. 도커(Docker) 기반으로 운영 중인 DB, Redis, Message Queue 등을 테스트가 실행될 때 잠시 띄워주고 끝날 때 내려주는 라이브러리입니다.
의존성을 추가하는 방법은 생각보다 간단했습니다.
dependencies {
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'
}이후 추상 클래스에 Testcontainers 설정을 몰아두고, 실제 통합 테스트들이 이 클래스를 상속받게 구조를 잡았습니다.
@SpringBootTest
@ActiveProfiles("test")
@Testcontainers
public abstract class IntegrationTestSupport {
static final MySQLContainer<?> MY_SQL_CONTAINER;
static {
MY_SQL_CONTAINER = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass");
MY_SQL_CONTAINER.start();
}
@DynamicPropertySource
static void overrideProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MY_SQL_CONTAINER::getJdbcUrl);
registry.add("spring.datasource.username", MY_SQL_CONTAINER::getUsername);
registry.add("spring.datasource.password", MY_SQL_CONTAINER::getPassword);
}
}테스트 시간 단축을 위한 팁
초기 구축 시 가장 당황스러웠던 건 테스트가 시작될 때마다 도커 이미지를 받아오고 띄우느라 시간이 기하급수적으로 늘어난다는 점이었습니다. 제가 사용한 해결책은 컨테이너 싱글톤 패턴이었습니다.
위 코드 블록의 static 영역에서 컨테이너를 딱 한 번만 띄우고, 모든 테스트가 같은 컨테이너를 공유하도록 환경을 구성했습니다. 매 테스트 이후에는 @AfterEach 단에서 트랜잭션을 롤백하거나 테이블을 Truncate 처리하여 데이터 격리(Isolation)를 보장했습니다.
마무리하자면
의심의 여지가 큰 모킹 테스트나, 문법 파편화가 존재하는 임베디드 DB 대신 Testcontainers를 도입하고 나서부터, 배포 버튼을 누를 때 손끝의 떨림이 확연히 줄어들었습니다. 확실히 CI/CD 파이프라인에서 실제 DB 컨테이너가 뜨고 쿼리가 핑퐁되는 모습을 보면 심리적인 안정감이 상당합니다.
다음번에는 이렇게 작성된 테스트를 바탕으로 모니터링 툴(Sentry, Grafana)을 통해 실제 운영 환경의 에러를 어떻게 추적하고 개선했는지 다뤄보겠습니다.