포스트

[업무일지] 공유해도 매번 새로 뜨던 Testcontainers 컨테이너

회원 관리 서버의 테스트 1,815건을 재보니 본체는 5.3분인데 스프링 컨텍스트 기동에 9.9분을 쓰고 있었습니다. 컨텍스트가 뜰 때마다 DB 컨테이너와 스키마를 처음부터 새로 만들었거든요. 컨테이너를 JVM당 한 벌로 공유하도록 바꿨는데, 빈으로 등록하면 스프링이 컨텍스트를 닫을 때 같이 닫아 버려서 static 홀더로 다시 짰습니다. 그래도 한 서버만 CI 시간이 그대로였고, 원인은 50개 클래스마다 JVM을 새로 띄우는 forkEvery 설정이었습니다. 지우자 13분대가 6분 41초가 됐습니다.

Java 21, Spring Boot 3.3.5, Testcontainers 1.20.4, MariaDB 10.6, GitHub Actions ubuntu-latest 러너 환경입니다. 백엔드 서버 네 개가 각자 Testcontainers로 통합 테스트를 돌립니다. 이 글의 클래스명과 설정값은 예시로 바꿨습니다.

Docker 런타임을 바꾸다가 테스트 시간을 재봤다

시작은 로컬 Docker 런타임을 Colima(Docker 엔진 29)로 바꾼 일이었습니다. 배치 서버의 DB 테스트가 실패 대신 skip으로 끝나고 빌드는 성공하더군요. Testcontainers 1.20.x가 붙는 Docker API 1.32를 Docker 29가 거부했고, disabledWithoutDocker = true 라서 실패가 아니라 skip이 된 겁니다.

다른 세 서버는 GitHub 러너에 Docker 29가 배포되기 시작한 2월(배포 공지)에 docker-java.properties 로 API 버전을 올려 둔 상태였고, 배치 서버만 그때 해당 테스트가 없어 빠졌던 거죠. 같은 설정을 넣고 CI에서 Docker에 못 붙으면 실패하는 가드 테스트를 붙이는 걸로 끝났습니다.

시간이 더 든 건 그다음입니다. 회원 관리 서버 전체 테스트를 로컬에서 돌렸더니 9분이 걸렸는데, 내역을 나눠 보니 좀 이상했습니다.

항목값
테스트 클래스 / 테스트447 / 1,815
테스트 본체 실행 시간 합5.3분
스프링 컨텍스트 기동52회, 평균 11.4초, 합 9.9분
MariaDB · LocalStack 컨테이너 기동각 29회

테스트 JVM 두 개가 나눠 돌린 합이라 9분보다 큽니다. 그래도 비율은 그대로예요. 테스트를 돌리는 시간보다 테스트할 준비를 하는 시간이 더 깁니다.

컨텍스트가 뜰 때마다 DB 컨테이너도 새로 떴다

컨테이너는 스프링 부트 3.1부터 들어온 @ServiceConnection 을 쓰는 흔한 모양의 빈이었습니다.

1
2
3
4
5
6
7
8
9
10
// Spring Boot 3.3.5, Testcontainers 1.20.4
@TestConfiguration(proxyBeanMethods = false)
public class TestContainerConfig {

    @Bean
    @ServiceConnection
    public MariaDBContainer<?> mariaDB() {
        return new MariaDBContainer<>("mariadb:10.6");
    }
}

컨테이너가 빈이니까 수명도 컨텍스트를 따라갑니다. @MockBean 조합이 다른 테스트 클래스는 컨텍스트를 새로 만들고, 그때마다 MariaDB도 새로 뜹니다. 새로 뜬 DB는 비어 있으니 ddl-auto: update 가 테이블 127개를 처음부터 만들고요.

컨텍스트 하나가 뜨는 데 걸린 24.7초를 쪼개 보니 컨테이너 기동이 약 7초, 스프링 초기화와 빈 DB에 스키마를 만드는 데 약 15초였습니다. 메모리도 문제였습니다. 기본 설정(테스트 JVM 4개, 컨텍스트 캐시 32)으로 돌리면 MariaDB 18개와 LocalStack 4개가 동시에 떠서 Colima VM 4GB가 바닥났습니다.

격리는 이미 롤백과 TRUNCATE가 맡고 있었다

컨테이너를 JVM당 한 벌로 줄이려면 먼저 확인할 게 있었습니다. 테스트끼리 DB를 나눠 써도 되는가.

코드를 보니 컨테이너가 격리를 맡고 있지 않았습니다. 리포지토리 테스트는 @DataJpaTest 라 테스트마다 롤백되고, 통합 테스트는 베이스 클래스의 @AfterEach 가 테이블을 TRUNCATE합니다. 스키마는 ddl-auto: update 라서 같은 DB에 여러 번 적용해도 결과가 같습니다. create-drop, @DirtiesContext, 테스트용 Flyway, 다중 데이터소스도 없었습니다.

컨테이너를 컨텍스트마다 새로 띄워야 할 이유는 코드 어디에도 없었던 거죠. 그래서 공유해도 통합 테스트의 성격은 그대로라고 판단했습니다.

빈으로 등록하면 공유가 안 된다

처음 설계는 “static 필드에 띄우고 @ImportTestcontainers 나 @ServiceConnection 으로 연결한다” 였습니다. 컨테이너 인스턴스를 static으로 들고 있으면 컨텍스트가 여러 번 떠도 한 번만 시작될 거라고 봤거든요.

구현하다가 이게 안 된다는 걸 알았습니다. 스프링 부트의 TestcontainersLifecycleBeanPostProcessor는 빈 소멸 시점에 Startable 인 빈을 close() 합니다. 빈 정의에 destroy 메서드가 있으면 프레임워크가 닫고, destroyMethod = "" 로 비워 두면 이 후처리기가 대신 닫습니다.

@ImportTestcontainers 도 피해 갈 수 없었습니다. 이 애너테이션이 가져온 static 필드는 ContainerFieldsImporter가 빈 정의로 등록하거든요. 등록되는 순간 위 후처리기의 대상이 됩니다.

컨테이너가 빈으로 등록되는 순간 형태와 상관없이 컨텍스트와 수명을 같이 합니다. 컨텍스트 캐시에서 하나만 밀려나도 공유하던 컨테이너가 꺼지고, 남은 컨텍스트들은 죽은 포트를 물게 됩니다.

그래서 컨테이너를 빈으로 등록하지 않았습니다. static 홀더가 JVM당 한 번 띄우고, 테스트 베이스 클래스는 @DynamicPropertySource 로 접속 정보만 받습니다. 종료는 Testcontainers의 Ryuk이 JVM이 끝날 때 처리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public final class SharedTestContainers {

    private static final MariaDBContainer<?> MARIADB =
            new MariaDBContainer<>("mariadb:10.6")
                    .withCommand("mysqld", "--max_connections=500");

    static {
        MARIADB.start();
    }

    private SharedTestContainers() {}

    public static void registerDataSource(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", MARIADB::getJdbcUrl);
        registry.add("spring.datasource.username", MARIADB::getUsername);
        registry.add("spring.datasource.password", MARIADB::getPassword);
    }
}

@SpringBootTest
public abstract class IntegrationTest {

    @DynamicPropertySource
    static void sharedContainers(DynamicPropertyRegistry registry) {
        SharedTestContainers.registerDataSource(registry);
    }
}

말로만 믿기 어려워서 컨텍스트 캐시 크기를 1로 줄이고 돌려 봤습니다. 컨텍스트가 다섯 번 뜨고 닫히는 동안 MariaDB는 한 번만 떴습니다.

공유 DB가 드러낸 격리 결함

공유로 바꾸자 그동안 컨테이너가 따로 떠서 가려져 있던 것들이 나왔습니다. 세 가지를 손봐야 했고, 그중 둘은 이슈에 없던 것이었습니다.

첫째는 커넥션 수입니다. 캐시된 컨텍스트마다 Hikari 풀(기본 10개)이 같은 DB 하나에 붙습니다. 설계에서는 테스트 풀을 줄이거나 MariaDB max_connections 를 올리거나 둘 중 하나를 고르자고 했는데, 계산해 보니 하나로는 모자랐습니다. 풀을 5로 줄여도 CI 캐시 32개가 다 차면 160개라 기본 상한 151을 넘거든요. 풀을 5로 줄이고 상한도 500으로 올렸습니다.

둘째는 엔티티 리스너였습니다. 이력을 남기는 엔티티 리스너가 정적 유틸에 담긴 ApplicationContext 로 리포지토리를 찾는데, 그 정적 필드는 마지막에 생성된 컨텍스트를 가리킵니다. 캐시된 컨텍스트를 재사용하는 테스트에서는 리스너가 다른 컨텍스트의 리포지토리로 저장하고 커밋합니다.

처음 보는 문제는 아니었습니다. 테스트 다섯 곳이 이미 각자 정적 유틸을 다시 묶는 코드를 들고 있었어요. 그 코드가 없던 테스트 하나가 공유 DB에서 깨졌습니다. 다른 컨텍스트가 커밋한 행은 같은 DB에 있는데, 테스트 트랜잭션은 REPEATABLE READ 스냅샷이라 그 커밋이 안 보여서 건수 단언이 틀렸습니다. 재바인딩을 테스트 베이스의 @BeforeEach 로 올리고 다섯 곳의 중복 코드를 지웠습니다.

셋째는 @Nested 테스트의 컨텍스트가 둘로 갈린 경우였습니다. 내부 static @TestConfiguration 이 바깥 클래스에서만 감지돼서 바깥과 @Nested 가 서로 다른 컨텍스트를 썼고, 바깥에 주입된 리포지토리는 테스트 트랜잭션 밖에서 바로 커밋했습니다. 첫 CI에서 19건이 실패했고 @Import 로만 등록해 컨텍스트를 하나로 합쳤습니다.

정리하다 보니 쓰지 않는 컨테이너도 둘 나왔습니다. 셀러용 API 서버의 Redis 컨테이너는 설정 클래스가 호스트를 직접 읽는 바람에 떠 있기만 했고, 회원 관리 서버의 LocalStack은 쓰는 테스트가 0개였습니다. Redis는 실제로 연결했고 LocalStack은 지웠습니다.

한 서버만 CI 시간이 그대로였다

세 서버에 반영하고 CI 시간을 비교했습니다.

서버 · 잡기존공유 후 (develop 머지 후)
회원 관리 · 통합 테스트약 12분 45초9분 5초
회원 관리 · 리포지토리 테스트약 7분 10초4분 24초
셀러용 API · 테스트약 13분 30초13분 23초

회원 관리 서버는 29%, 39% 줄었는데 셀러용 API 서버는 그대로입니다. 로컬에서 보니 MariaDB 컨테이너가 여전히 4~5개 떴습니다.

build.gradle 에 이 설정이 있었습니다.

1
2
3
4
5
6
7
tasks.named('test') {
    maxHeapSize = '2g'
    jvmArgs '-XX:+UseG1GC', '-XX:MaxMetaspaceSize=512m'

    // 테스트 클래스 N개마다 JVM 재시작 (메모리 누수 방지)
    forkEvery = 50
}

forkEvery = 50 은 테스트 클래스 50개마다 테스트 JVM을 버리고 새로 띄웁니다. JVM이 새로 뜨면 static 홀더도 컨텍스트 캐시도 처음부터 다시 시작하죠. 클래스가 224개니까 JVM이 4~5번 새로 떴고, 공유 컨테이너도 딱 그만큼 다시 떴던 겁니다.

이 설정이 들어온 경위를 찾아봤습니다. 2025년 말 아키텍처 테스트를 도입할 때 예방 차원으로 넣은 것이었고, OOM이 났다는 기록은 없었습니다. 그때 전제는 “클래스 83개 정도라 재시작은 1~2번” 이었는데 클래스가 세 배 가까이 늘면서 맞지 않게 됐습니다.

측정 한 번으로는 확정할 수 없어서, OOM이 나면 컨텍스트 캐시 상한부터 걸기로 정해 두고 지운 뒤 CI로 재봤습니다.

 기존forkEvery 제거
CI 테스트 잡13분 23초 ~ 13분 42초6분 41초
로컬 전체 실행 (CI 조건) 2분 8초, 1,489건 통과
MariaDB · Redis 컨테이너 기동각 4~5회각 1회
컨텍스트 기동 24회, 캐시 축출 없음

GC 로그(-Xlog:gc)로 메모리도 봤습니다. OOM과 Full GC는 없었고, GC 후 살아 있는 힙은 최대 1.1~1.4GB로 최대 힙 2GB 안에 들어왔습니다. 여유는 30% 정도입니다. 러너는 2 vCPU, 메모리 7.8GiB였습니다.

OOM이 나도 forkEvery는 되살리지 않는다

JVM을 다시 버리기 시작하면 공유 컨테이너 효과가 0으로 돌아갑니다. 메모리를 잡아먹는 건 캐시에 쌓인 컨텍스트들이라, OOM이 나면 캐시 상한으로 대응하기로 했습니다.

1
2
3
4
// OOM 이 났을 때 넣을 설정이다. 지금은 넣지 않았다.
tasks.named('test') {
    systemProperty 'spring.test.context.cache.maxSize', '16'
}

힙 2GB도 그대로 둡니다. 러너 7.8GiB 안에 테스트 JVM과 컨테이너가 같이 들어가야 하거든요. 이 판단은 작업 기록에만 있고 build.gradle 주석은 아직 없어서, 기록을 안 찾아보면 같은 설정이 다시 들어올 수 있습니다.

남겨 둔 것

컨텍스트 수 자체는 줄이지 못했습니다. 남은 기동 시간 대부분은 테스트 클래스마다 다른 @MockBean 조합에서 나옵니다. 외부 연동 mock을 베이스 클래스로 모으면 컨텍스트가 줄지만, 실제 빈으로 검증하던 테스트가 mock으로 바뀌어 깨질 수 있습니다. 합쳐도 되는 테스트를 가려내는 비용이 커서 세 서버 모두 보류했습니다. 부트 3.3.5라 @MockitoBean 도 아직 못 씁니다.

CI 효과는 로컬(9~18분 → 4분 46초)보다 작았습니다. 로컬은 컨텍스트 캐시가 4라 재기동이 잦았고, CI는 캐시 32에 잡도 나뉘어 있어 원래 기동 횟수가 적었거든요. 착수 근거로 삼은 9.9분도 로컬 조건에서 잰 숫자였습니다.

엔티티 리스너가 정적 유틸로 빈을 찾는 구조는 테스트 쪽에서 매번 다시 묶는 걸로 막았을 뿐입니다. 근본적으로는 운영 코드에서 리스너를 스프링 빈으로 주입받게 바꿔야 하는데 이번 범위에서는 뺐습니다.

분석에서 놓친 것도 있습니다. 스케줄러 서버의 배치 모듈은 고칠 대상에 넣었는데 열흘 전에 이미 바뀌어 있었습니다. 분석에 쓴 로컬 체크아웃이 3주 넘게 최신 코드를 못 받고 있었는데, 진단할 때 그걸 알아채지 못했습니다.

마지막으로, 회원 관리 서버는 아직 CI Redis 하나를 같이 씁니다. 지금은 CI 병렬 fork가 꺼져 있어 괜찮지만, 켜기 전에 fork별 Redis로 바꿔야 fork끼리 서로 flushAll 하는 일이 없습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.