# Testcontainers Spring Boot: 고통 없는 통합 테스트
> Spring Boot 3.4에서 Testcontainers를 구성하는 완전한 가이드입니다. 신뢰할 수 있고 재현 가능한 통합 테스트를 위해 Docker 컨테이너에서 PostgreSQL, Redis, Kafka를 실행합니다.
- Published: 2026-03-19
- Updated: 2026-05-01
- Author: SharpSkill
- Tags: testcontainers, spring boot, integration testing, docker, postgresql
- Reading time: 14 min
---
통합 테스트는 Spring Boot 애플리케이션 개발에서 큰 과제입니다. 실제 PostgreSQL 데이터베이스나 Kafka 브로커에 대해 테스트하려면 무거운 인프라를 유지해야 합니다. Testcontainers는 테스트 중에 Docker 컨테이너를 온디맨드로 실행함으로써 이 문제를 해결하며, 격리되고 재현 가능한 환경을 보장합니다.
> **Spring Boot 3.4와 Testcontainers**
>
> Spring Boot 3.4는 자동 구성이 포함된 네이티브 Testcontainers 지원을 포함하고 있습니다. `spring-boot-testcontainers` 의존성은 설치를 크게 단순화하고 테스트 간 컨테이너 재사용을 가능하게 합니다.
## Testcontainers와 Spring Boot 통합 이해하기
Testcontainers는 테스트 실행 중에 Docker 컨테이너를 시작하기 위한 Java API를 제공합니다. 외부 의존성을 모킹하거나 공유 테스트 데이터베이스를 유지하는 대신, 각 테스트 실행은 자체 격리된 인스턴스를 받습니다.
아키텍처는 세 가지 주요 구성 요소에 의존합니다: Docker를 제어하는 Testcontainers 라이브러리, 각 기술(PostgreSQL, Redis, Kafka)을 위한 전문 모듈, 그리고 연결 매개변수를 자동으로 주입하는 Spring Boot 통합입니다.
```xml
org.springframework.boot
spring-boot-testcontainers
test
org.testcontainers
postgresql
test
org.testcontainers
junit-jupiter
test
org.testcontainers
testcontainers-bom
1.20.4
pom
import
```
Testcontainers BOM은 프로젝트에서 사용되는 모든 모듈 간의 버전 일관성을 보장합니다.
## PostgreSQL을 사용한 기본 구성
가장 일반적인 사용 사례는 실제 PostgreSQL 데이터베이스로 테스트하는 것입니다. Spring Boot 3.4는 두 가지 접근 방식을 제공합니다: 자동 구성을 위한 `@ServiceConnection` 어노테이션 또는 `@DynamicPropertySource`를 통한 수동 구성입니다.
```java
// UserRepositoryIntegrationTest.java
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class UserRepositoryIntegrationTest {
// Starts a PostgreSQL container before tests
@Container
@ServiceConnection
static PostgreSQLContainer> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
);
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndRetrieveUser() {
// Given: a user to persist
User user = new User();
user.setEmail("test@example.com");
user.setName("Test User");
// When: save and retrieve
User saved = userRepository.save(user);
Optional found = userRepository.findById(saved.getId());
// Then: user is correctly persisted
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("test@example.com");
}
@Test
void shouldFindUserByEmail() {
// Given: a user in database
User user = new User();
user.setEmail("search@example.com");
user.setName("Search User");
userRepository.save(user);
// When: search by email
Optional found = userRepository.findByEmail("search@example.com");
// Then: user is found
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Search User");
}
}
```
`@ServiceConnection` 어노테이션은 컨테이너 유형을 자동으로 감지하고 해당 Spring 속성(`spring.datasource.url`, `spring.datasource.username` 등)을 구성합니다. 이 접근 방식은 반복적인 구성 코드를 제거합니다.
> **컨테이너 라이프사이클**
>
> 정적 필드에 `@Container`를 사용하면 컨테이너는 클래스의 모든 테스트 전에 한 번 시작되고 마지막 테스트 후에 중지됩니다. 테스트당 하나의 컨테이너를 원하는 경우 비정적 인스턴스 필드를 사용해야 합니다.
## @DynamicPropertySource를 사용한 수동 구성
일부 시나리오에서는 주입된 속성에 대한 더 세밀한 제어가 필요합니다. `@DynamicPropertySource` 어노테이션은 구성 값을 명시적으로 정의할 수 있게 해줍니다.
```java
// OrderRepositoryIntegrationTest.java
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryIntegrationTest {
@Container
static PostgreSQLContainer> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
)
// Specific container configuration
.withDatabaseName("orders_test")
.withUsername("test_user")
.withPassword("test_password")
// SQL initialization script
.withInitScript("db/init-orders.sql");
// Manual injection of dynamic properties
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// JDBC URL generated dynamically with mapped port
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
// Additional properties if needed
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
@Autowired
private OrderRepository orderRepository;
@Autowired
private EntityManager entityManager;
@Test
void shouldPersistOrderWithItems() {
// Given: an order with items
Order order = new Order();
order.setOrderNumber("ORD-2026-001");
order.setStatus(OrderStatus.PENDING);
OrderItem item = new OrderItem();
item.setProductId(1L);
item.setQuantity(2);
item.setUnitPrice(BigDecimal.valueOf(29.99));
order.addItem(item);
// When: save the order
Order saved = orderRepository.save(order);
entityManager.flush();
entityManager.clear();
// Then: order and its items are persisted
Order found = orderRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getItems()).hasSize(1);
assertThat(found.getItems().get(0).getQuantity()).isEqualTo(2);
}
}
```
초기화 스크립트 `withInitScript`는 테스트 실행 전에 스키마를 준비하거나 참조 데이터를 삽입합니다.
## 완전한 Spring Boot 통합 테스트
모든 구성 요소가 로드된 상태로 전체 애플리케이션을 테스트하려면 `@SpringBootTest`가 `@DataJpaTest`를 대체합니다. 이 구성은 PostgreSQL 컨테이너와 함께 완전한 Spring 컨텍스트를 시작합니다.
```java
// UserServiceIntegrationTest.java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserServiceIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
);
@Autowired
private UserService userService;
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldCreateUserViaApi() {
// Given: a creation request
CreateUserRequest request = new CreateUserRequest(
"api@example.com",
"API User",
"securePassword123"
);
// When: call the REST API
ResponseEntity response = restTemplate.postForEntity(
"/api/users",
request,
UserResponse.class
);
// Then: user is created successfully
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().email()).isEqualTo("api@example.com");
}
@Test
void shouldRetrieveUserById() {
// Given: an existing user
UserResponse created = userService.createUser(
new CreateUserRequest("retrieve@example.com", "Retrieve User", "password")
);
// When: retrieve by ID
ResponseEntity response = restTemplate.getForEntity(
"/api/users/" + created.id(),
UserResponse.class
);
// Then: user is returned
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().name()).isEqualTo("Retrieve User");
}
}
```
자동 구성된 `TestRestTemplate`은 임의의 포트에서 시작된 서버를 가리키며, 병렬 테스트 간의 포트 충돌을 방지합니다.
## 테스트 간 컨테이너 재사용
Docker 컨테이너 시작에는 몇 초가 소요됩니다. 테스트 실행을 가속화하기 위해 Spring Boot 3.4는 중앙 집중식 구성을 통해 컨테이너를 재사용할 수 있게 해줍니다.
```java
// TestcontainersConfiguration.java
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
// Reusable container bean across all tests
@Bean
@ServiceConnection
PostgreSQLContainer> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
.withReuse(true)
.withLabel("reuse.UUID", "e06d7a87-7d7d-472e-a047-7c2c6d4b5f7a");
}
@Bean
@ServiceConnection
RedisContainer redisContainer() {
return new RedisContainer(DockerImageName.parse("redis:7-alpine"))
.withReuse(true)
.withLabel("reuse.UUID", "b3c8f9d2-4a5e-4c8d-9f2a-1b3c5d7e9f0a");
}
}
```
테스트는 동일한 컨테이너를 공유하기 위해 이 구성을 가져옵니다.
```java
// ProductServiceIntegrationTest.java
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class ProductServiceIntegrationTest {
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@Test
void shouldCacheProductDetails() {
// Given: a product in database
Product product = new Product();
product.setName("Cached Product");
product.setPrice(BigDecimal.valueOf(99.99));
productRepository.save(product);
// When: two successive calls
ProductDto first = productService.getProductById(product.getId());
ProductDto second = productService.getProductById(product.getId());
// Then: second call uses cache
assertThat(first).isEqualTo(second);
}
}
```
컨테이너 재사용을 활성화하려면 `~/.testcontainers.properties`에 구성을 추가해야 합니다:
```properties
# ~/.testcontainers.properties
testcontainers.reuse.enable=true
```
> **데이터 정리**
>
> 컨테이너를 재사용하면 데이터가 실행 간에 유지됩니다. 각 테스트 전에 테이블을 정리하려면 `@Sql` 또는 `@BeforeEach`를 사용하거나 테스트 클래스별로 다른 스키마를 구성하는 것이 좋습니다.
## Redis와 분산 캐시를 사용한 테스트
Testcontainers는 캐시 기능 테스트를 위해 Redis를 지원합니다. Redis 모듈은 즉시 사용 가능한 사전 구성된 컨테이너를 제공합니다.
```java
// CacheServiceIntegrationTest.java
@SpringBootTest
@Testcontainers
class CacheServiceIntegrationTest {
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer(
DockerImageName.parse("redis:7-alpine")
);
@Autowired
private CacheService cacheService;
@Autowired
private StringRedisTemplate redisTemplate;
@Test
void shouldStoreAndRetrieveFromCache() {
// Given: a value to cache
String key = "user:123";
String value = "{\"id\":123,\"name\":\"Cached User\"}";
// When: store in cache
cacheService.put(key, value, Duration.ofMinutes(10));
// Then: value is retrievable
String cached = cacheService.get(key);
assertThat(cached).isEqualTo(value);
}
@Test
void shouldExpireAfterTtl() throws InterruptedException {
// Given: a value with short TTL
String key = "expiring:key";
cacheService.put(key, "temporary", Duration.ofSeconds(1));
// When: wait for expiration
Thread.sleep(1500);
// Then: key has expired
String cached = cacheService.get(key);
assertThat(cached).isNull();
}
@Test
void shouldIncrementCounter() {
// Given: a counter key
String counterKey = "page:views:homepage";
// When: multiple increments
Long first = redisTemplate.opsForValue().increment(counterKey);
Long second = redisTemplate.opsForValue().increment(counterKey);
Long third = redisTemplate.opsForValue().increment(counterKey);
// Then: counter increments correctly
assertThat(first).isEqualTo(1);
assertThat(second).isEqualTo(2);
assertThat(third).isEqualTo(3);
}
}
```
Spring Boot는 `@ServiceConnection`을 통해 `RedisContainer`를 자동으로 감지하고 `spring.data.redis.host`와 `spring.data.redis.port`를 구성합니다.
## Kafka와 비동기 메시징을 사용한 테스트
이벤트 기반 애플리케이션은 실제 Kafka 브로커로 테스트해야 합니다. Testcontainers는 테스트에 적합한 단일 노드 클러스터를 시작하는 Kafka 모듈을 제공합니다.
```java
// OrderEventIntegrationTest.java
@SpringBootTest
@Testcontainers
@EmbeddedKafka(partitions = 1, topics = {"order-events"})
class OrderEventIntegrationTest {
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);
@Autowired
private KafkaTemplate kafkaTemplate;
@Autowired
private OrderEventConsumer orderEventConsumer;
@Test
void shouldPublishAndConsumeOrderEvent() throws Exception {
// Given: an order event
OrderEvent event = new OrderEvent(
"ORD-2026-100",
OrderEventType.CREATED,
LocalDateTime.now()
);
// When: publish to Kafka
kafkaTemplate.send("order-events", event.orderId(), event).get();
// Then: event is consumed (with timeout)
await()
.atMost(Duration.ofSeconds(10))
.untilAsserted(() -> {
assertThat(orderEventConsumer.getReceivedEvents())
.hasSize(1)
.first()
.extracting(OrderEvent::orderId)
.isEqualTo("ORD-2026-100");
});
}
}
```
```java
// OrderEventConsumer.java
@Component
public class OrderEventConsumer {
private final List receivedEvents = new CopyOnWriteArrayList<>();
@KafkaListener(topics = "order-events", groupId = "test-group")
public void consume(OrderEvent event) {
receivedEvents.add(event);
}
public List getReceivedEvents() {
return List.copyOf(receivedEvents);
}
public void clear() {
receivedEvents.clear();
}
}
```
Awaitility 라이브러리는 타임아웃이 있는 비동기 어설션을 처리하여 테스트에서 취약한 `Thread.sleep` 호출을 방지합니다.
## Docker Compose를 사용한 다중 컨테이너 구성
여러 상호 의존적인 서비스가 필요한 복잡한 애플리케이션의 경우 Testcontainers는 Docker Compose 파일을 지원합니다.
```yaml
# src/test/resources/docker-compose-test.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- "5432"
redis:
image: redis:7-alpine
ports:
- "6379"
localstack:
image: localstack/localstack:3.0
environment:
SERVICES: s3,sqs
DEFAULT_REGION: eu-west-1
ports:
- "4566"
```
```java
// FullStackIntegrationTest.java
@SpringBootTest
@Testcontainers
class FullStackIntegrationTest {
@Container
static DockerComposeContainer> environment = new DockerComposeContainer<>(
new File("src/test/resources/docker-compose-test.yml")
)
.withExposedService("postgres", 5432)
.withExposedService("redis", 6379)
.withExposedService("localstack", 4566)
.waitingFor("postgres", Wait.forListeningPort())
.waitingFor("redis", Wait.forListeningPort())
.waitingFor("localstack", Wait.forLogMessage(".*Ready\\.$", 1));
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
// PostgreSQL configuration
String postgresHost = environment.getServiceHost("postgres", 5432);
Integer postgresPort = environment.getServicePort("postgres", 5432);
registry.add("spring.datasource.url",
() -> "jdbc:postgresql://" + postgresHost + ":" + postgresPort + "/testdb");
registry.add("spring.datasource.username", () -> "testuser");
registry.add("spring.datasource.password", () -> "testpass");
// Redis configuration
String redisHost = environment.getServiceHost("redis", 6379);
Integer redisPort = environment.getServicePort("redis", 6379);
registry.add("spring.data.redis.host", () -> redisHost);
registry.add("spring.data.redis.port", () -> redisPort);
// LocalStack S3 configuration
String localstackHost = environment.getServiceHost("localstack", 4566);
Integer localstackPort = environment.getServicePort("localstack", 4566);
registry.add("aws.s3.endpoint",
() -> "http://" + localstackHost + ":" + localstackPort);
}
@Autowired
private FileStorageService fileStorageService;
@Test
void shouldUploadFileToS3() {
// Given: a file to upload
byte[] content = "Test file content".getBytes();
String fileName = "test-file.txt";
// When: upload to S3 via LocalStack
String url = fileStorageService.upload(fileName, content);
// Then: file is accessible
assertThat(url).contains(fileName);
byte[] downloaded = fileStorageService.download(fileName);
assertThat(downloaded).isEqualTo(content);
}
}
```
## 모범 사례 및 성능 최적화
효율적인 Testcontainers 실행을 위해서는 빌드 시간을 줄이기 위한 몇 가지 최적화가 필요합니다.
```java
// AbstractIntegrationTest.java
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {
// Shared container across all inheriting classes
@Container
@ServiceConnection
protected static PostgreSQLContainer> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
)
.withReuse(true);
@Autowired
protected JdbcTemplate jdbcTemplate;
@BeforeEach
void cleanDatabase() {
// Clean tables in order to respect FK constraints
jdbcTemplate.execute("TRUNCATE TABLE order_items CASCADE");
jdbcTemplate.execute("TRUNCATE TABLE orders CASCADE");
jdbcTemplate.execute("TRUNCATE TABLE users CASCADE");
}
}
```
```java
// UserIntegrationTest.java
class UserIntegrationTest extends AbstractIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldCreateUser() {
// PostgreSQL container already started via parent class
User user = new User();
user.setEmail("inherited@test.com");
user.setName("Inherited Test");
User saved = userRepository.save(user);
assertThat(saved.getId()).isNotNull();
}
}
```
추상 클래스는 컨테이너 구성과 데이터베이스 정리를 중앙 집중화하여 코드 중복을 방지합니다.
```properties
# src/test/resources/application-test.properties
# Test-specific configuration
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false
# Disable Flyway/Liquibase if using ddl-auto
spring.flyway.enabled=false
# Reduced connection pool for tests
spring.datasource.hikari.maximum-pool-size=5
spring.datasource.hikari.minimum-idle=2
```
> **가벼운 Docker 이미지**
>
> 더 가볍고 빠르게 시작되는 Alpine 이미지(`postgres:16-alpine`, `redis:7-alpine`)를 선호하는 것이 좋습니다. 테스트의 경우 전체 이미지와의 기능적 차이는 무시할 수 있는 수준입니다.
## 데이터베이스 마이그레이션 테스트
Testcontainers는 실제 데이터베이스에 대해 Flyway 또는 Liquibase 마이그레이션을 테스트하는 데 탁월합니다.
```java
// FlywayMigrationTest.java
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class FlywayMigrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer> postgres = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine")
);
@Autowired
private Flyway flyway;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldApplyAllMigrations() {
// Given: migrations applied at startup
// When: check state
MigrationInfoService info = flyway.info();
// Then: all migrations are applied
assertThat(info.pending()).isEmpty();
assertThat(info.applied()).isNotEmpty();
}
@Test
void shouldCreateExpectedTables() {
// Given: migrations applied
// When: query system tables
List tables = jdbcTemplate.queryForList(
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = 'public' AND table_type = 'BASE TABLE'",
String.class
);
// Then: expected tables exist
assertThat(tables).contains("users", "orders", "order_items", "products");
}
@Test
void shouldHaveCorrectColumnTypes() {
// Given: users table created
// When: verify schema
List