[코드가 환경을 모르는 구조 6/7] 컨테이너는 왜 폭발하는가

실제 DB에 쿼리하는 테스트의 가치
통합 테스트가 10분 넘게 걸리면, 개발자는 끝내 그걸 돌리지 않습니다. 테스트 피라미드의 흔한 조언은 단위 테스트를 많이, 통합 테스트를 적게, E2E를 최소한으로 두라는 것입니다. 이유는 단순합니다. 통합 테스트는 느리고 불안정하니까요. 실제 DB, 메시지 큐, Elasticsearch에 붙이려면 환경이 필요하고, 환경이 끼어드는 순간 CI는 복잡해지고 결과가 흔들리기 시작하며, 흔들리는 테스트는 결국 무시됩니다.
그럼에도 flex는 3년 넘게 Testcontainers로 실제 인프라에 붙은 통합 테스트를 유지해 왔습니다. MySQL, Elasticsearch, Redis, Kafka — Mock이 아니라 진짜 컨테이너를 띄워 테스트합니다. 근거는 단단합니다. HR 도메인의 쿼리 차이, 트랜잭션 경계, 인덱스 사용 여부, 격리 수준의 영향은 Mock이 드러내지 못합니다. 예컨대 SELECT ... FOR UPDATE가 실제로 잠그는 범위, 복합 인덱스가 정말 타는지는 엔진이 직접 돌려봐야 압니다.
그러나 이 선택에는 대가가 따랐습니다. 10분이면 개발자는 커밋을 올려놓고 다른 일로 넘어가고, 테스트가 깨졌다는 알림은 한참 뒤에 도착합니다. 테스트가 유효하려면 빠르거나 신뢰할 만하거나 둘 중 하나는 확고해야 합니다. flex는 "실제 DB를 쓰니 신뢰는 높되, 속도는 고쳐야 한다"는 방향을 택했습니다.
dirty context가 컨테이너를 복제한다
Testcontainers가 느려지는 본질은 Docker의 느린 기동이 아닙니다. 컨테이너 하나만 한 번 띄운다면 속도는 충분합니다. 진짜 문제는 컨테이너 수가 암암리에 불어난다는 데 있습니다.
Spring Boot의 테스트 프레임워크는 같은 설정의 ApplicationContext를 재사용합니다. 테스트 클래스마다 새 컨텍스트를 만드는 대신 같은 구성이면 캐시에서 꺼내 씁니다. 이 캐싱이 Testcontainers와 맞물리면 한 컨테이너를 여러 테스트가 공유할 수 있습니다. 문제는 캐싱이 깨지는 순간부터 시작됩니다.
Spring의 TestContext 프레임워크는 각 테스트 클래스가 요구하는 컨텍스트의 "모양"을 키로 만들어 캐시에 보관합니다. 이 키에는 @SpringBootTest가 읽는 설정 클래스 목록, 활성 프로파일, @TestPropertySource로 주입된 프로퍼티, 그리고 ContextCustomizer가 기여하는 값들이 들어갑니다. 같은 키면 재사용, 다른 키면 새 컨텍스트. 그런데 @MockBean이나 @SpyBean이 이 키에 기여합니다. 특정 빈을 모킹하겠다는 선언은 컨텍스트 구성을 바꾸는 행위라서, Spring은 "원본 빈이 있는 컨텍스트"와 "모킹된 빈이 있는 컨텍스트"를 다른 것으로 간주합니다. 그래서 모듈 A가 PaymentClient만 모킹하고 모듈 B가 NotificationClient만 모킹하면, 나머지 설정이 완벽히 같아도 컨텍스트는 두 개 가 생깁니다. @DynamicPropertySource의 일부 사용법, 모듈별로 다른 커스텀 오버라이드, 일부 @TestConfiguration도 같은 방식으로 키를 어긋나게 만듭니다. 새 컨텍스트가 만들어진다는 것은 그 컨텍스트에 결합된 Testcontainer도 새로 뜬다는 뜻입니다.
@DirtiesContext는 한 단계 더 나쁩니다. 이 어노테이션은 해당 테스트가 끝난 뒤 컨텍스트를 캐시에서 명시적으로 제거합니다. 다음 테스트가 같은 구성을 요구해도 캐시가 비어 있으니 처음부터 새로 만들어야 하고, 딸린 컨테이너도 새로 떠야 합니다. "dirty context"라는 이름은 여기서 나옵니다 — 캐시에서 떨궈졌거나 애초에 키가 달라 히트되지 않는 컨텍스트를 통칭하는 말입니다.

멀티모듈 프로젝트에서 이 문제는 곱셈으로 자랍니다. 모듈 수십 개가 각자 통합 테스트를 돌리고, 각 모듈이 dirty context를 만 들면 그만큼 컨테이너가 뜹니다. CI 서버의 메모리는 포화되고, Docker 데몬은 허덕이고, 전체 CI 시간은 길어집니다. 10분대 빌드의 내부 프로파일을 뜯어 보면 실제 테스트 실행 시간보다 컨테이너 기동 시간이 더 길었습니다.
"테스트가 느리니까 건너뛰자"는 위험 신호
이 상황이 오래 지속되면 조직에 위험한 순간이 찾아옵니다. "이번 PR은 작은 변경이니 통합 테스트는 스킵 하겠다"는 메시지. 처음엔 예외였던 말이 점점 기본값이 되고, 끝내 "이 저장소에서는 아무도 통합 테스트를 돌리지 않는다"로 귀결됩니다. 이 상태에 들어선 테스트 스위트는 있어도 없는 것과 같습니다.
속도가 신뢰를 결정하는 구조를 직시해야 합니다. 빠르면 자주 돌리고, 자주 돌리면 깨짐을 빨리 발견하고, 빨리 발견하면 고치기 쉽습니다. 느리면 덜 돌리고, 덜 돌리면 깨짐이 누적되고, 누적된 깨짐은 원인을 좁히기 어려워지며, 결국 테스트 자체에 대한 신뢰가 사라집니다. 이 역방향의 순환이 CI에서 일어나는 "테스트 부식"입니다.
flex에서 내린 판단은 이것이었습니다. Mock으로 돌아가지 않는다. 실제 DB도 버리지 않는다. 그렇다면 남은 길은 하나 — 컨테이너는 공유하고, 격리는 스키마 수준으로 내린다. 본편 1화에서 본 "단일 JVM + 논리적 스키마 격리" 패턴을, 이번엔 테스트 인프라에 그대로 가져옵니다.
Gradle BuildService — 빌드 수준 싱글턴이라는 답
구현 방향은 이렇습니다. MySQL 컨테이너 하나를 Gradle 빌드 전체가 공유하고, 모듈마다 독립적인 논리 스키마를 붙인 뒤, 각 모듈의 테스트는 자기 스키마에만 접근한다. 남는 질문은 하나입니다. "누가 그 컨테이너의 생애주기를 관리하는가?"
Gradle 8 이후 공식적으로 제공되는 BuildService가 그 답입니다. BuildService는 빌드 전체에서 단 하나의 인스턴스만 살아 있는 객체로, Task 간 상태를 안전하게 공유하고 빌드가 끝나면 자동으로 close()를 호출합니다. 테스트 컨테이너를 BuildService 안에 두면, 컨테이너는 빌드 시작 시점에 한 번 뜨고 빌드 종료 시점에 한 번 정리됩니다. 그 사이의 모든 Task는 같은 컨테이너에 접속합니다.
flex는 이 원리를 flex-gradle-plugins 산하의 testcontainers-v2-jdbc-plugin에 담았습니다. 핵심 추상은 JdbcTestContainerBuildService<T> 인터페이스입니다. T는 JdbcDatabaseContainer의 하위 타입(MySQLContainer, PostgreSQLContainer 등)이고, 이 BuildService가 컨테이너의 수명 관리, 데이터베이스 프로비저닝, 접속 정보 노출을 맡습니다.
abstract class JdbcTestContainerBuildService<T : JdbcDatabaseContainer<*>> :
BuildService<JdbcTestContainerBuildService.Param>, AutoCloseable {
interface Param : BuildServiceParameters {
val projectPath: Property<String>
val baseImageName: Property<String>
}
}시그니처가 말하는 바는 간결합니다. "컨테이너는 빌드 스코프에서 하나, 파라미터는 프로젝트 경로와 베이스 이미지 이름." 이 인터페이스가 BuildService의 수명에 완전히 편승하므로, 플러그인 사용자는 컨테이너 기동 시점을 신경 쓸 필요가 없습니다. BuildService는 첫 Task가 접속할 때 컨테이너를 지연 기동하고, 마지막 Task가 끝난 뒤 빌드 종료 시점에 정리합니다.

다만 "빌드 수준 싱글턴"이 실제로 의미하는 범위는 실행 환경에 따라 생각보다 좁습니다. Gradle --parallel을 켜면 서로 다른 프로젝트의 Task들이 별도 worker JVM에서 동시에 도는데, BuildService는 이 worker들을 가로질러 하나의 인스턴스로 유지됩니다. 그러나 IntelliJ가 여러 Gradle 루트를 동시에 리프레시하듯 서로 다른 빌드가 동시에 돌면, 각 빌드마다 별도의 BuildService가 생기고 결과적으로 컨테이너도 각자 하나씩 뜹니다. configuration cache가 활성화된 경우에는 BuildService를 직렬화해 보관했다가 재사용하므로, 되살아난 컨테이너 핸들과 실제 도커 데몬의 상태가 어긋나 지 않는지 한 번 더 확인해야 합니다. CI는 또 다른 얘기입니다. 매 파이프라인이 fresh JVM으로 시작하는 CI에서는 빌드 종료와 함께 BuildService가 close()되고 컨테이너가 정리되며, 다음 파이프라인은 다시 처음부터 뜹니다. 즉, 로컬 재실행에서 누리던 재사용 이득이 CI에서는 제한적으로만 나타납니다. 이 격차를 메우는 것이 7화에서 볼 스냅샷 캐시의 역할입니다.
하나의 컨테이너, 여러 개의 논리 스키마
컨테이너 공유만으로는 부족합니다. 모듈 A의 테스트가 모듈 B의 데이터를 보면 안 되고, 한 모듈 안에서도 병렬 실행이 서로를 오염시키면 안 됩니다. 격리는 필수이고, 그 격리는 컨테이너가 아니라 데이터베이스 수준에서 긋습니다.
BuildService는 모듈마다 "이 모듈이 쓸 데이터베이스 이름"을 프로비저닝합니다. 관례로는 모듈 경로에서 파생한 문자열을 씁니다. 예컨대 :domain:employee:impl 모듈은 flex_test_domain_employee_impl이라는 데이터베이스를 얻습니다. BuildService는 이 이름으로 CREATE DATABASE 한 뒤, 해당 모듈의 테스트 Task에 커넥션 정보(URL, username, password)를 노출합니다.
// 테스트 Task에 주입되는 접속 정보 (예)
interface JdbcDatabaseConnectionInfo {
val url: Property<String>
val username: Property<String>
val password: Property<String>
val databaseName: Property<String>
}
스키마 초기화는 Liquibase가 담당합니다. 각 모듈은 자기 데이터베이스에 대한 changelog를 쥐고 있고, 테스트 시작 전 Liquibase가 그 DB에 스키마를 적용합니다. 모듈 A의 changelog는 A의 DB에만, 모듈 B의 changelog는 B의 DB에만 작용합니다. 같은 컨테이너를 공유하지만, 논리적으로 각 테스트는 자기 우주에서 돕니다.
속도는 컨테이너 공유에서, 재현성은 스키마 격리에서 옵니다. 둘을 한 번에 가져가는 것이 플러그인의 핵심입니다. 그리고 이 설계는 본편 5화와 6화의 원리를 그대로 따릅니다. 테스트 코드는 "어떤 데이터베이스를 쓰는가"를 모릅니다. 자기 모듈의 Liquibase changelog와 자기 테스트 시나리오만 알 뿐입니다. 커넥션 정보, 즉 "어디서"는 BuildService가 주입합니다. 프로덕션에서 Helm values가 하던 역할을, 테스트에서는 BuildService가 맡는 셈입니다.
이 편의 결론과 다음으로
6화를 한 문장으로 요약하 면 이렇습니다. Testcontainers의 근본 병목은 dirty context로 인한 컨테이너 복제이고, 이를 Gradle BuildService라는 "빌드 수준 싱글턴"으로 뒤집으면 한 컨테이너를 공유하면서도 모듈별 스키마 격리가 가능하다. 이 전환 덕에 통합 테스트의 가치를 속도의 제단에 바치지 않아도 됩니다. 실제 DB의 신뢰를 유지하면서 CI 시간을 2분대로 되돌릴 수 있습니다.
그러나 이 설계는 끝이 아닙니다. 질문이 남습니다. MySQL 8.0과 8.4를 동시에 테스트해야 한다면 어떻게 할까요. CDC 파이프라인에서 writer와 reader를 다른 DB로 테스트하고 싶다면요. Liquibase가 매번 돌면 스키마 생성 시간이 새로운 병목이 되지 않을까요. 다음 편에서 이 질문들에 답하고, 그 답들이 어떻게 시리즈 전체의 결론으로 이어지는지 짚어 봅니다.
다음 화에서는,
variant와 스냅샷 캐시. 테스트 인프라가 프로덕션을 닮아야 하는 이유, 그리고 이 시리즈가 남긴 것에 대해 이야기 합니다.

