들어가며
통합 테스트에서 가장 골치 아픈 문제는 외부 의존성 관리입니다. "내 로컬에서는 되는데 CI에서 안 돼요"라는 말을 몇 번이나 들어보셨나요? H2 같은 인메모리 DB로 테스트하면 실제 PostgreSQL과 동작이 달라 운영 환경에서 버그가 발생하기도 합니다. Testcontainers는 Docker 컨테이너를 프로그래밍 방식으로 띄워 Docker 실전 가이드로 컨테이너 기초 익히기 실제 인프라와 동일한 환경에서 통합 테스트를 실행할 수 있게 해주는 라이브러리입니다. 이번 글에서는 Testcontainers의 핵심 개념부터 실전 활용법, CI/CD 연동까지 다루겠습니다.
1. Testcontainers 핵심 개념
Testcontainers는 JUnit 5 테스트 실행 시 Docker 컨테이너를 자동으로 시작/종료해주는 Java 라이브러리입니다.
왜 Testcontainers인가?
| 방식 | 장점 | 단점 |
|---|---|---|
| H2 인메모리 DB | 빠름, 설정 간편 | 실제 DB와 동작 차이, 방언 불일치 |
| 로컬 Docker 수동 실행 | 실제 환경과 동일 | 수동 관리, CI 환경 설정 복잡 |
| 외부 테스트 서버 | 실제 환경과 동일 | 공유 자원 충돌, 네트워크 의존 |
| Testcontainers | 실제 환경 + 자동 관리 + 격리 | Docker 필수, 단위 테스트보다 느림 |
의존성 추가 (build.gradle)
dependencies {
// Testcontainers BOM
testImplementation platform('org.testcontainers:testcontainers-bom:1.19.7')
// 코어
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
// 모듈별 컨테이너
testImplementation 'org.testcontainers:postgresql'
testImplementation 'org.testcontainers:kafka'
// Spring Boot Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
}
2. @Container / @Testcontainers 기본 사용법
PostgreSQL 컨테이너 테스트
@Testcontainers
@SpringBootTest
class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withInitScript("schema.sql"); // resources 아래 초기화 SQL
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
void 사용자를_저장하고_조회할_수_있다() {
// given
User user = User.builder()
.name("홍길동")
.email("hong@example.com")
.build();
// when
User saved = userRepository.save(user);
Optional<User> found = userRepository.findByEmail("hong@example.com");
// then
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("홍길동");
assertThat(saved.getId()).isNotNull();
}
@Test
void PostgreSQL_전용_기능을_테스트할_수_있다() {
// H2에서는 불가능한 PostgreSQL JSONB 컬럼 테스트
userRepository.saveWithMetadata(
"test@example.com",
"{\"role\": \"admin\", \"department\": \"engineering\"}"
);
List<User> admins = userRepository.findByMetadataRole("admin");
assertThat(admins).hasSize(1);
}
}
어노테이션 설명
- @Testcontainers: JUnit 5 확장으로, @Container 필드의 라이프사이클을 자동 관리합니다.
- @Container: 테스트 시작 전 컨테이너를 시작하고, 테스트 종료 후 정리합니다.
- static 필드: 클래스 단위 공유 (모든 테스트 메서드가 같은 컨테이너 사용)
- 인스턴스 필드: 테스트 메서드마다 새 컨테이너 (완전 격리, 느림)
- @DynamicPropertySource: 컨테이너의 동적 포트/주소를 Spring 프로퍼티에 주입합니다.
3. 다양한 컨테이너 활용
Redis 컨테이너
@Testcontainers
@SpringBootTest
class CacheServiceIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private CacheService cacheService;
@Autowired
private StringRedisTemplate redisTemplate;
@Test
void 캐시를_저장하고_조회할_수_있다() {
cacheService.put("user:1", "홍길동");
String cached = cacheService.get("user:1");
assertThat(cached).isEqualTo("홍길동");
}
@Test
void 캐시_TTL이_정상_동작한다() throws InterruptedException {
cacheService.putWithTTL("temp:1", "임시 데이터", Duration.ofSeconds(2));
assertThat(cacheService.get("temp:1")).isNotNull();
Thread.sleep(2500);
assertThat(cacheService.get("temp:1")).isNull();
}
}
Kafka 컨테이너
@Testcontainers
@SpringBootTest
@EmbeddedKafka(partitions = 1, topics = {"order-events"})
class OrderEventIntegrationTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0"))
.withKraft();
@DynamicPropertySource
static void configureKafka(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Autowired
private KafkaTemplate<String, OrderEvent> kafkaTemplate;
@Autowired
private OrderEventConsumer orderEventConsumer;
@Test
void 주문_이벤트를_발행하고_소비할_수_있다() throws Exception {
// given
OrderEvent event = new OrderEvent("order-123", "CREATED", 50000);
// when
kafkaTemplate.send("order-events", event.orderId(), event).get();
// then
await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
assertThat(orderEventConsumer.getProcessedEvents())
.anyMatch(e -> e.orderId().equals("order-123"));
});
}
}
여러 컨테이너 조합
@Testcontainers
@SpringBootTest
class FullIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@DynamicPropertySource
static void configureAll(DynamicPropertyRegistry registry) {
// PostgreSQL
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
// Redis
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
// Kafka
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Test
void 주문_생성_시_DB_저장_캐시_업데이트_이벤트_발행이_모두_동작한다() {
// 전체 통합 시나리오 테스트
}
}
4. Spring Boot 3.1+ ServiceConnection 방식
Spring Boot 3.1부터 @ServiceConnection 어노테이션으로 @DynamicPropertySource 보일러플레이트를 제거할 수 있습니다.
@Testcontainers
@SpringBootTest
class ModernTestcontainersTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Container
@ServiceConnection
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
// @DynamicPropertySource 불필요!
// Spring Boot가 자동으로 프로퍼티를 설정
@Autowired
private UserRepository userRepository;
@Test
void 자동_연결_설정이_동작한다() {
User user = userRepository.save(new User("test", "test@example.com"));
assertThat(user.getId()).isNotNull();
}
}
5. 테스트 격리 전략
방법 1: @Transactional 롤백
@SpringBootTest
@Transactional // 각 테스트 후 자동 롤백
class UserServiceTest {
@Test
void 사용자_생성_테스트() {
// 이 테스트의 DB 변경은 자동으로 롤백됨
}
}
방법 2: @Sql로 데이터 초기화
@SpringBootTest
@Sql(scripts = "/cleanup.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
class OrderServiceTest {
@Test
void 주문_목록_조회() {
// cleanup.sql이 먼저 실행된 후 테스트 진행
}
}
-- cleanup.sql
DELETE FROM orders;
DELETE FROM users;
ALTER SEQUENCE orders_id_seq RESTART WITH 1;
방법 3: TestExecutionListener 커스텀
public class DatabaseCleanupListener implements TestExecutionListener {
@Override
public void beforeTestMethod(TestContext testContext) {
DataSource dataSource = testContext.getApplicationContext()
.getBean(DataSource.class);
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
// 모든 테이블 TRUNCATE
stmt.execute("SET REFERENTIAL_INTEGRITY FALSE");
ResultSet rs = stmt.executeQuery(
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_SCHEMA = 'PUBLIC'");
while (rs.next()) {
stmt.execute("TRUNCATE TABLE " + rs.getString(1));
}
stmt.execute("SET REFERENTIAL_INTEGRITY TRUE");
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
// 사용
@SpringBootTest
@TestExecutionListeners(
listeners = DatabaseCleanupListener.class,
mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
class CleanDatabaseTest { }
6. 컨테이너 재사용으로 속도 최적화
Testcontainers의 가장 큰 단점은 속도입니다. 컨테이너 재사용으로 이를 해결할 수 있습니다.
방법 1: Singleton Container Pattern
// 추상 베이스 클래스로 컨테이너 공유
public abstract class AbstractIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES;
static final GenericContainer<?> REDIS;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb");
POSTGRES.start();
REDIS = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
REDIS.start();
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.data.redis.host", REDIS::getHost);
registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
}
}
// 모든 통합 테스트가 이 클래스를 상속
@SpringBootTest
class UserServiceIntegrationTest extends AbstractIntegrationTest {
@Test
void 테스트() { /* ... */ }
}
@SpringBootTest
class OrderServiceIntegrationTest extends AbstractIntegrationTest {
@Test
void 테스트() { /* ... */ }
}
방법 2: testcontainers.properties로 재사용
# src/test/resources/testcontainers.properties
testcontainers.reuse.enable=true
// 컨테이너에 reuse 활성화
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // 테스트 종료 후에도 컨테이너 유지
// 주의: 컨테이너가 종료되지 않으므로 수동 정리 필요
// docker ps로 확인 후 docker rm으로 제거
7. CI/CD에서 Testcontainers 사용
GitHub Actions 설정
# .github/workflows/test.yml GitHub Actions CI/CD 파이프라인 구축 완벽 가이드
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Cache Gradle packages
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
# Docker는 ubuntu-latest에 기본 설치됨
- name: Run tests
run: ./gradlew test --info
env:
TESTCONTAINERS_RYUK_DISABLED: false
GitLab CI 설정
# .gitlab-ci.yml
integration-test:
image: gradle:8.7-jdk21
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
TESTCONTAINERS_HOST_OVERRIDE: docker
script:
- gradle test --info
Testcontainers Cloud (원격 Docker)
# 로컬에 Docker가 없어도 테스트 가능
# Testcontainers Cloud가 원격 Docker 환경 제공
# 설정 (testcontainers.properties)
testcontainers.cloud.token=your-cloud-token
8. 실전 통합 테스트 예제: 주문 시스템
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Container
@ServiceConnection
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void setUp() {
orderRepository.deleteAll();
}
@Test
void 주문_생성_API_통합_테스트() 헥사고날 아키텍처에서의 어댑터 테스트 전략 {
// given
CreateOrderRequest request = new CreateOrderRequest(
"user-1",
List.of(
new OrderItemRequest("product-1", 2, 15000),
new OrderItemRequest("product-2", 1, 30000)
)
);
// when
ResponseEntity<OrderResponse> response = restTemplate.postForEntity(
"/api/orders", request, OrderResponse.class);
// then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().totalAmount()).isEqualTo(60000);
assertThat(response.getBody().status()).isEqualTo("CREATED");
// DB 검증
List<Order> orders = orderRepository.findAll();
assertThat(orders).hasSize(1);
assertThat(orders.get(0).getItems()).hasSize(2);
}
@Test
void 주문_조회_시_캐시가_동작한다() Redis 캐시 전략 실전 가이드 {
// given - 주문 생성
Order order = orderRepository.save(
Order.create("user-1", List.of(
OrderItem.of("product-1", 1, 10000)
))
);
// when - 첫 번째 조회 (DB 조회 + 캐시 저장)
ResponseEntity<OrderResponse> first = restTemplate.getForEntity(
"/api/orders/" + order.getId(), OrderResponse.class);
// when - 두 번째 조회 (캐시 HIT)
ResponseEntity<OrderResponse> second = restTemplate.getForEntity(
"/api/orders/" + order.getId(), OrderResponse.class);
// then
assertThat(first.getBody()).isEqualTo(second.getBody());
}
}
마치며
Testcontainers는 통합 테스트의 신뢰성과 재현성을 크게 향상시킵니다. 핵심 포인트를 정리합니다.
- 실제 인프라와 동일한 환경에서 테스트하므로 "내 로컬에서는 되는데" 문제가 사라집니다.
- @Testcontainers + @Container로 컨테이너 라이프사이클을 자동 관리할 수 있습니다.
- Spring Boot 3.1+의 @ServiceConnection으로 @DynamicPropertySource 보일러플레이트를 제거할 수 있습니다.
- Singleton Container 패턴이나 withReuse(true)로 테스트 속도를 최적화하세요.
- CI/CD에서도 Docker만 있으면 바로 사용 가능합니다. GitHub Actions의 ubuntu-latest에는 Docker가 기본 설치되어 있습니다.
단위 테스트로 비즈니스 로직을, Testcontainers 통합 테스트로 인프라 연동을 검증하는 이중 전략이 가장 효과적입니다. Spring Boot 테스트 전략으로 단위/통합 테스트 완성하기 테스트 피라미드에서 적절한 비율을 유지하면서 활용해보세요.