본문 바로가기
Development

Testcontainers로 Spring Boot 통합 테스트에 진짜 DB와 Kafka 띄우기 - @ServiceConnection, 컨테이너 재사용, CI 설정

¯¯\_(ツ)_/¯·2026년 9월 4일·조회 2

통합 테스트를 H2 인메모리 DB로 돌려놓고 운영은 PostgreSQL로 하는 프로젝트를 자주 본다. 나도 예전엔 그렇게 했다. 빠르고 편하니까. 문제는 방언(dialect) 차이에서 터진다. H2에선 통과하던 쿼리가 운영에서 문법 오류를 내거나, ON CONFLICT, JSONB, 윈도우 함수 같은 DB 고유 기능을 쓰는 순간 테스트가 무용지물이 된다. 결국 테스트 그린은 안심을 못 준다.

Testcontainers는 이 간극을 실제 컨테이너로 메운다. 테스트가 시작될 때 도커로 진짜 PostgreSQL, MariaDB, Kafka를 띄우고, 끝나면 버린다. Spring Boot 3.1부터는 @ServiceConnection 하나로 컨테이너의 접속 정보를 스프링 컨텍스트에 자동 연결하므로 @DynamicPropertySource로 URL을 손수 넘길 필요가 없다. 아래에서 의존성 추가, PostgreSQL/MariaDB/Kafka 예제, 컨텍스트 캐시와 컨테이너 재사용으로 느린 테스트를 잡는 법, CI에서 도커를 붙이는 법을 차례로 살펴본다.

1. Testcontainers란 무엇인가

Testcontainers는 테스트 코드에서 도커 컨테이너를 프로그래밍적으로 띄우고 내리는 라이브러리다. 로컬이나 CI에 도커 데몬만 있으면 된다. 테스트가 필요로 하는 미들웨어(관계형 DB, 메시지 브로커, 검색엔진 등)를 일회용 컨테이너로 만들어 쓰고 버리므로, 운영과 같은 엔진과 같은 버전으로 검증할 수 있다.

핵심 개념은 세 가지다. 컨테이너 객체PostgreSQLContainer처럼 이미지 한 종류를 감싼 자바 객체다. 수명주기는 이 객체를 언제 start/stop 하느냐의 문제이고, 접속 정보 연결은 컨테이너가 무작위로 배정받은 포트와 자격증명을 스프링에 어떻게 알려주느냐의 문제다. Spring Boot 3.1의 @ServiceConnection이 마지막 문제를 대신 풀어준다.

2. 의존성 추가

Testcontainers는 BOM으로 버전을 관리한다. Spring Boot의 의존성 관리를 쓰면 BOM이 이미 포함되어 있어 개별 모듈은 버전 없이 선언한다. Gradle 기준 예시다.

dependencies {
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:junit-jupiter'
    testImplementation 'org.testcontainers:postgresql'
    testImplementation 'org.testcontainers:mariadb'
    testImplementation 'org.testcontainers:kafka'
}

spring-boot-testcontainers@ServiceConnection과 관련 자동설정을 제공한다. 이걸 빼면 어노테이션을 못 찾는다. junit-jupiter@Testcontainers/@Container JUnit 5 확장을 제공한다. 그 아래 세 줄은 각각 PostgreSQL, MariaDB, Kafka 모듈이다.

Maven이면 아래처럼 넣는다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>

3. PostgreSQL을 @ServiceConnection으로 띄우기

@Testcontainers는 컨테이너 필드의 start/stop을 JUnit 5 수명주기에 걸어주는 확장이다. @Container를 붙인 필드가 그 대상이다. 필드가 static이면 테스트 클래스당 한 번 뜨고, 인스턴스 필드면 테스트 메서드마다 뜬다. 통합 테스트는 거의 항상 static이 맞다.

@ServiceConnection은 컨테이너 타입을 보고 알맞은 ConnectionDetails 빈을 만들어 컨텍스트에 등록한다. PostgreSQLContainer라면 JdbcConnectionDetails가 생겨서 spring.datasource.*를 손대지 않아도 데이터소스가 컨테이너를 가리킨다.

@SpringBootTest
@Testcontainers
class MemberRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    MemberRepository memberRepository;

    @Test
    void 저장하고_조회한다() {
        Member saved = memberRepository.save(new Member("jins", "jinschoi@example.com"));
        assertThat(memberRepository.findById(saved.getId())).isPresent();
    }
}

여기서 한 가지 자주 걸린다. @ServiceConnection은 접속 정보만 넘긴다. 스키마는 만들어 주지 않는다. JPA를 쓴다면 테스트용 application.yml에서 spring.jpa.hibernate.ddl-auto=create-drop으로 엔티티로부터 스키마를 만들거나, Flyway/Liquibase 마이그레이션을 태워야 한다. 운영과 동일성을 노린다면 마이그레이션을 그대로 돌리는 쪽을 권한다. 그래야 마이그레이션 스크립트 자체도 함께 검증된다.

실행하면 로그에 다음과 비슷한 출력이 뜬다. 이미지 pull은 최초 1회만 발생한다.

o.t.d.DockerClientProviderStrategy : Found Docker environment with local Unix socket (unix:///var/run/docker.sock)
o.t.utility.RyukResourceReaper      : Ryuk started - will monitor and terminate Testcontainers containers
🐳 [postgres:16-alpine] : Creating container for image: postgres:16-alpine
🐳 [postgres:16-alpine] : Container postgres:16-alpine started in PT2.3S

Ryuk은 Testcontainers가 함께 띄우는 정리용 사이드카 컨테이너다. 테스트 JVM이 죽으면 남은 컨테이너를 대신 지워준다. CI에서 이게 권한 문제로 막히는 경우가 있는데, 그 대처는 5절에서 다룬다.

4. MariaDB와 Kafka도 같은 방식

MariaDB는 컨테이너 클래스만 바꾸면 된다. MariaDBContainer 역시 @ServiceConnectionJdbcConnectionDetails로 인식한다.

@Container
@ServiceConnection
static MariaDBContainer<?> mariadb =
        new MariaDBContainer<>("mariadb:11.4");

Kafka는 org.testcontainers.kafka.KafkaContainer(Apache Kafka, KRaft 모드로 주키퍼가 필요 없다)를 쓴다. Confluent 이미지를 쓰려면 org.testcontainers.kafka.ConfluentKafkaContainer를 선택한다. 예전 org.testcontainers.containers.KafkaContainer는 폐기 예정이므로 새 패키지를 쓴다. @ServiceConnectionKafkaConnectionDetails를 만들어 spring.kafka.bootstrap-servers를 자동으로 채운다.

import org.testcontainers.kafka.KafkaContainer;

@SpringBootTest
@Testcontainers
class OrderEventTest {

    @Container
    @ServiceConnection
    static KafkaContainer kafka =
            new KafkaContainer("apache/kafka:3.8.0");

    @Autowired
    KafkaTemplate<String, String> kafkaTemplate;

    @Test
    void 이벤트를_발행한다() {
        kafkaTemplate.send("orders", "order-created");
    }
}

여러 컨테이너가 필요한 테스트라면 필드를 여러 개 두면 된다. 각 컨테이너가 서로 다른 ConnectionDetails로 매핑되므로 충돌하지 않는다.

5. @ServiceConnection이 안 될 때 @DynamicPropertySource

@ServiceConnection이 지원하지 않는 미들웨어이거나, 컨테이너가 노출하는 값을 직접 프로퍼티에 매핑해야 할 때가 있다. 이때는 @DynamicPropertySource로 컨테이너 값을 스프링 환경에 밀어 넣는다. 컨테이너가 시작된 뒤 포트가 정해지므로 값은 람다(공급자)로 넘긴다.

@Container
static GenericContainer<?> redis =
        new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

@DynamicPropertySource
static void redisProps(DynamicPropertyRegistry registry) {
    registry.add("spring.data.redis.host", redis::getHost);
    registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}

둘 다 되는 상황이라면 @ServiceConnection을 우선한다. 코드가 짧고, SSL 등 부가 설정도 어노테이션으로 얹을 수 있기 때문이다. @DynamicPropertySource는 지원 밖 컨테이너의 탈출구로 남겨두는 편을 권한다.

6. 느린 테스트 잡기 (1) 컨텍스트 캐시와 공통 베이스 클래스

Testcontainers 통합 테스트가 느린 첫째 원인은 컨테이너를 너무 자주 띄우는 것이다. 스프링 테스트 컨텍스트는 설정이 같으면 클래스 간에 캐시되어 재사용된다. 컨테이너를 공통 베이스 클래스의 static 필드에 두고 모든 통합 테스트가 이를 상속하면, 컨테이너도 컨텍스트도 한 번만 만들어 여러 테스트 클래스가 공유한다.

@SpringBootTest
@Testcontainers
public abstract class IntegrationTestSupport {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    @Container
    @ServiceConnection
    static KafkaContainer kafka =
            new KafkaContainer("apache/kafka:3.8.0");
}
class MemberServiceTest extends IntegrationTestSupport { ... }
class OrderServiceTest extends IntegrationTestSupport { ... }

주의할 점은 컨텍스트 캐시가 설정 단위로 갈린다는 것이다. 어떤 테스트에 @MockBean이나 다른 @TestPropertySource가 붙으면 그 클래스는 별도 컨텍스트를 새로 만든다. 그러면 캐시 효과가 깨지고 컨테이너 관련 초기화가 다시 돈다. 통합 테스트의 설정을 되도록 균일하게 유지하는 게 속도에 직접 영향을 준다.

또한 스프링 빈 방식과 @Testcontainers 확장 방식은 수명주기 순서 보장이 다르다. 공식 문서는 컨텍스트 캐시가 중요할 때 스프링 빈 관리(@Bean 또는 @ImportTestcontainers)를 권한다. 위처럼 static 필드 + 베이스 클래스 조합도 실무에서 널리 쓰이지만, 컨테이너 기동 순서에 민감한 테스트라면 빈 방식을 검토한다.

7. 느린 테스트 잡기 (2) 컨테이너 재사용(reuse)

둘째 원인은 테스트를 돌릴 때마다 컨테이너가 새로 뜨고 지워지는 것이다. 로컬에서 개발 중 반복 실행할 때는 컨테이너 재사용으로 이 시간을 없앨 수 있다. 재사용을 켜면 설정이 동일한 컨테이너를 종료하지 않고 남겨 다음 실행에서 그대로 붙는다.

켜는 방법은 두 단계다. 먼저 컨테이너에 withReuse(true)를 준다.

static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
                .withReuse(true);

그다음 사용자 홈의 프로퍼티 파일에서 기능을 활성화한다. 클래스패스의 프로퍼티 파일로는 켜지지 않는다는 점을 기억한다.

$ echo 'testcontainers.reuse.enable=true' >> ~/.testcontainers.properties

또는 환경변수 TESTCONTAINERS_REUSE_ENABLE=true로도 켤 수 있다. 재사용이 동작하면 테스트가 끝나도 컨테이너가 살아 있고, 두 번째 실행부터 기동 로그 없이 곧바로 기존 컨테이너에 접속한다. docker ps로 확인해 보면 테스트가 끝난 뒤에도 컨테이너가 남아 있다.

$ docker ps
CONTAINER ID   IMAGE                COMMAND                  STATUS
3f2a1c9d8e4b   postgres:16-alpine   "docker-entrypoint.s..."   Up 4 minutes

재사용에는 함정이 있다. 살아남은 컨테이너는 이전 실행의 데이터를 그대로 갖고 있다. 각 테스트가 자기 데이터를 트랜잭션 롤백이나 @Sql 초기화로 정리하지 않으면, 남은 데이터 때문에 테스트가 서로 오염된다. 또 공식 문서는 재사용을 CI에 쓰지 말라고 명시한다. 재사용은 로컬 반복 개발용이고, 컨테이너를 남기는 특성이 일회성 CI 환경과 맞지 않는다. 실험적 기능이라는 점도 감안한다.

8. CI에서 도커 붙이기 (Docker-in-Docker)

Testcontainers는 실행 환경에 도커 데몬을 요구한다. CI 러너에 도커가 있으면 그대로 돌아간다. GitHub Actions의 ubuntu-latest 러너는 도커가 기본 설치되어 있어 추가 서비스 없이 통합 테스트가 실행된다.

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
      - run: ./gradlew test

GitLab CI처럼 도커가 별도로 필요한 환경은 dind(Docker-in-Docker) 서비스를 붙인다.

test:
  image: eclipse-temurin:21-jdk
  services:
    - docker:dind
  variables:
    DOCKER_HOST: "tcp://docker:2375"
    DOCKER_TLS_CERTDIR: ""
  script:
    - ./gradlew test

CI에서 자주 만나는 문제 두 가지가 있다. 하나는 Ryuk 정리 컨테이너가 권한 제약으로 실패하는 경우다. 이럴 때 환경변수 TESTCONTAINERS_RYUK_DISABLED=true로 Ryuk을 끈다. CI 잡은 끝나면 러너째 폐기되므로 정리를 CI에 맡겨도 무방하다. 다른 하나는 매 실행마다 이미지를 새로 pull 하느라 느려지는 것이다. 러너의 도커 레이어 캐시를 잡 사이에 보존하도록 캐시 설정을 두면 pull 시간을 줄일 수 있다. 소요 시간은 이미지 크기와 네트워크에 따라 다르지만, 캐시가 없는 최초 실행에서 이미지 pull이 테스트 시간의 큰 몫을 차지한다.

9. 정리

운영과 같은 엔진으로 검증하려면 H2 대신 Testcontainers로 실제 PostgreSQL, MariaDB, Kafka를 띄운다. 접속 정보는 Spring Boot 3.1+의 @ServiceConnection에 맡기고, 지원 밖 컨테이너만 @DynamicPropertySource로 처리한다. 느린 테스트는 공통 베이스 클래스로 컨텍스트와 컨테이너를 공유해 잡고, 로컬 반복 개발에서는 컨테이너 재사용을 켠다. 재사용은 CI에는 쓰지 않는다. CI에는 러너에 도커를 붙이고 필요하면 Ryuk을 끈다.

자주 묻는 질문

@ServiceConnection은 Spring Boot 어느 버전부터 쓸 수 있나?

Spring Boot 3.1부터 제공된다. spring-boot-testcontainers 의존성을 test 스코프로 추가하면 컨테이너 타입에 맞는 ConnectionDetails 빈이 자동 등록되어 spring.datasource나 spring.kafka.bootstrap-servers 같은 프로퍼티를 손대지 않아도 된다.

@ServiceConnection과 @DynamicPropertySource는 언제 무엇을 쓰나?

PostgreSQL, MariaDB, Kafka처럼 ConnectionDetails가 매핑되는 컨테이너는 @ServiceConnection을 쓴다. 코드가 짧고 SSL 등 부가 설정도 어노테이션으로 얹을 수 있다. 지원되지 않는 컨테이너나 값을 직접 프로퍼티로 매핑해야 할 때만 @DynamicPropertySource를 쓴다.

테스트마다 컨테이너가 새로 떠서 느리다. 어떻게 줄이나?

컨테이너를 공통 베이스 클래스의 static 필드에 두고 모든 통합 테스트가 상속하게 하면 스프링 컨텍스트 캐시 덕에 컨테이너가 한 번만 뜬다. 로컬 반복 개발에서는 withReuse(true)와 ~/.testcontainers.properties의 testcontainers.reuse.enable=true로 컨테이너 재사용을 켜면 두 번째 실행부터 기동 시간이 사라진다.

컨테이너 재사용(reuse)을 CI에서도 쓰면 되나?

권장하지 않는다. 공식 문서가 재사용은 CI에 적합하지 않다고 명시한다. 재사용은 컨테이너를 종료하지 않고 남기는 방식이라 일회성 CI 환경과 맞지 않고, 실험적 기능이다. CI에서는 러너의 도커를 그대로 쓰고 매 실행 컨테이너를 새로 띄운다.

CI에서 Ryuk 컨테이너 때문에 테스트가 실패한다.

Ryuk은 테스트 JVM 종료 후 남은 컨테이너를 정리하는 사이드카다. 권한 제약이 있는 CI에서는 환경변수 TESTCONTAINERS_RYUK_DISABLED=true로 끌 수 있다. CI 잡은 끝나면 러너째 폐기되므로 정리를 CI에 맡겨도 문제없다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.