Testcontainers 통합 테스트가 수십 클래스로 늘어나면 문제가 한꺼번에 나와요. 빌드가 몇 분씩 늘고, 두 번째 테스트 클래스부터 연결이 거부되고, 스위트 후반에만 "too many clients" 가 뜨고, CI 에서는 이미지 풀이 429 로 막혀요. 결론부터 말하면 스프링 테스트라면 컨테이너는 싱글턴이나 스프링 빈으로 한 번만 띄우고, 컨텍스트 설정 종류를 줄이고, 테스트용 커넥션 풀을 작게 잡는 것이 대부분의 문제를 한 번에 풀어요.
이 글은 내부 구조 대신 정해야 하는 값에 집중해요. 컨테이너 수명 선택이 빌드 시간과 DB 커넥션 수에 어떤 숫자로 돌아오는지, CI 에서 무엇이 먼저 막히는지를 2026년 10월 28일 기준 공식 문서(testcontainers-java 2.0.5, 스프링·도커 허브 문서)로 확인해 계산했어요.
컨테이너 수명이 왜 첫 번째 결정인가
Testcontainers 테스트에서 가장 비싼 일은 쿼리가 아니라 컨테이너를 띄우는 일이에요. PostgreSQL 모듈은 PostgreSQLContainer 소스에서 보듯 "ready to accept connections" 로그가 두 번 찍힐 때까지 최대 60초를 기다려요. 스프링 부트 테스트라면 애플리케이션 컨텍스트를 만드는 시간이 더 붙어요. 그래서 속도는 컨테이너를 몇 번, 컨텍스트를 몇 번 만드느냐로 정해져요.
JUnit 5 통합 문서에 따르면 인스턴스 필드의 @Container 는 테스트 메서드마다, static 필드는 클래스마다 시작·종료돼요. 수동 수명 관리 문서의 싱글턴 패턴은 기반 클래스 static 블록에서 start() 만 부르고, 스위트가 끝나면 Ryuk 이 치워요. 스프링 부트 Testcontainers 문서는 컨테이너를 @Bean 으로 선언하면 다른 빈보다 먼저 시작하고 다른 빈을 모두 정리한 뒤 멈춘다고 설명해요. 대신 컨테이너가 컨텍스트마다 하나씩 만들어져요. 여기에 실행이 끝나도 살려 두는 재사용까지 다섯 가지가 선택지예요.
수명 판단표 — 상황별로 무엇을 고르나
| 상황 | 고를 것 | 이걸 고르는 이유 | 치르는 대가 |
|---|---|---|---|
| 스프링 없이 JDBC·jOOQ 로 리포지토리만 검증 | static @Container | 클래스 안에서만 공유해 간단하고 안전 | 클래스 수만큼 기동 |
| 테스트가 DDL·서버 설정을 바꿔 되돌리기 어려움 | 인스턴스 필드 @Container | 테스트끼리 완전히 분리 | 테스트 수만큼 기동, 가장 느림 |
@SpringBootTest 가 여러 클래스, 컨텍스트 설정이 거의 같음 | @Bean + @ServiceConnection | 스프링이 시작·종료 순서를 보장 | 컨텍스트가 늘면 컨테이너도 늘어남 |
@SpringBootTest 가 여러 클래스, 목 빈·프로필이 클래스마다 다름 | 싱글턴 (기반 클래스 static 블록) | 컨텍스트가 몇 개든 컨테이너는 하나 | 컨텍스트마다 커넥션 풀이 쌓임 |
| 로컬에서 같은 테스트를 하루 수십 번 반복 | 위 선택 + withReuse(true) | 두 번째 실행부터 기동 생략 | CI 에서는 금지, 데이터가 남음 |
놓치기 쉬운 줄은 셋째·넷째예요. 스프링 빈 방식은 컨텍스트가 클래스마다 갈리면 그 수만큼 PostgreSQL 이 뜨고, 싱글턴은 컨테이너가 하나인 대신 아래 계산처럼 커넥션이 쌓여요. 어느 쪽이든 컨텍스트 종류를 줄이는 것이 공통 해법이에요.
@ServiceConnection 이 만든 접속 정보는 스프링 부트 문서에 따르면 spring.datasource.url 같은 프로퍼티보다 우선해요. application-test.yml 의 주소를 고쳐도 반영되지 않는 이유가 이것이니, 지원하지 않는 서비스만 @DynamicPropertySource 로 넘기세요.
숫자로 보는 기동 비용 — 40클래스 예시
예시 가정이에요. @SpringBootTest 통합 테스트가 40클래스, 클래스마다 테스트 10개가 있어요. 이미지는 받아 둔 상태로 컨테이너 준비 4초, 컨텍스트 생성 6초로 놓았어요. 두 값은 장비마다 크게 다르니 테스트 로그 타임스탬프로 재서 바꿔 넣으세요.
재사용은 로컬 두 번째 실행부터의 값이에요. CI 에서는 쓰지 않아요
표로 보기
| 구분 | 컨테이너 기동 | 컨텍스트 생성 |
|---|---|---|
| 클래스마다 컨테이너·컨텍스트 | 160 | 240 |
| 싱글턴 + 컨텍스트 40종 | 4 | 240 |
| 싱글턴 + 컨텍스트 1종 | 4 | 6 |
| 재사용 + 컨텍스트 1종 | 0 | 6 |
클래스마다 static 컨테이너를 두고 @DynamicPropertySource 로 포트를 넘기면, 포트가 클래스마다 달라 컨텍스트도 클래스마다 새로 만들어져요. 컨테이너 40 × 4초 = 160초, 컨텍스트 40 × 6초 = 240초, 합쳐서 400초예요. 컨테이너만 싱글턴으로 바꾸고 컨텍스트가 클래스마다 다르면 244초라 기대만큼 안 줄어요. 둘 다 하나로 묶어야 10초예요. 메서드마다 컨테이너를 띄우면 기동만 400 × 4초 = 1,600초예요.
컨텍스트가 왜 갈리는지는 스프링 테스트 컨텍스트 캐싱 문서의 캐시 키 목록을 보면 알 수 있어요. @DynamicPropertySource 메서드, @MockitoBean·@MockitoSpyBean 같은 빈 오버라이드, @ActiveProfiles, @TestPropertySource 가 모두 키에 들어가요. 목 빈 하나만 달라도 컨텍스트가 하나 더 생기니, 목 빈은 기반 클래스로 모으고 프로필은 한두 개로 줄여요.
컨텍스트가 몇 종인지는 org.springframework.test.context.cache 로그를 DEBUG 로 올리면 캐시 통계로 보여요. 같은 문서는 빌드 도구가 테스트마다 JVM 을 새로 띄우면 캐시가 아예 작동하지 않는다고도 적어요. 그레이들 forkEvery 를 1로 두었다면 "1종" 계산이 의미가 없어져요.
컨텍스트 캐시와 커넥션 한도 — too many clients 계산
싱글턴으로 바꾼 뒤 스위트 후반에만 FATAL: sorry, too many clients already 가 나면, 캐시된 컨텍스트들이 커넥션 풀을 붙잡고 있는 거예요. 숫자 세 개로 계산돼요.
- 스프링 테스트 컨텍스트 캐시는 기본 최대 32개이고, 넘치면 가장 오래 안 쓴 것부터 닫아요(같은 캐싱 문서).
- HikariCP 설정 설명에서
maximumPoolSize기본값은 10이고,minimumIdle은 기본값이 최대 크기와 같아서 놀고 있어도 10개를 채워 둬요. - PostgreSQL 연결 설정 문서에서
max_connections기본값은 보통 100이에요.
캐시에 살아 있는 컨텍스트마다 히카리 풀이 10개씩 열려 있으니, 컨텍스트가 10종을 넘는 순간 컨테이너 하나의 한도를 다 써요.
32종은 캐시 상한이라 그 이상은 오래된 컨텍스트가 닫히며 더 늘지 않아요
표로 보기
| 구분 | 히카리 기본 풀 10 | 테스트용 풀 2 | max_connections 기본값 |
|---|---|---|---|
| 1종 | 10 | 2 | 100 |
| 5종 | 50 | 10 | 100 |
| 10종 | 100 | 20 | 100 |
| 15종 | 150 | 30 | 100 |
| 20종 | 200 | 40 | 100 |
| 32종 | 320 | 64 | 100 |
해결은 세 갈래예요. 첫째, 테스트 프로필에서 spring.datasource.hikari.maximum-pool-size 를 2~3으로 낮춰요. 통합 테스트는 대개 한 스레드로 돌고, 풀 2면 캐시가 32종으로 차도 64개예요. 둘째, 컨테이너 한도를 올려요. PostgreSQL 모듈은 postgres -c fsync=off 로 띄우니 withCommand("postgres", "-c", "fsync=off", "-c", "max_connections=300") 처럼 기존 옵션을 유지해 덧붙여요. 셋째, 근본 원인인 컨텍스트 종류를 줄여요. 풀 크기와 동시성 계산은 스프링 배치 파티셔닝 커넥션 풀 계산 글에도 있어요.
운영에서 자주 나는 문제와 확인 순서
- 도커에 못 붙음.
docker info부터 봐요. 설정 문서대로 설정은 환경변수 → 홈의.testcontainers.properties→ 클래스패스 순으로 읽혀요. 도커 런타임을 바꿨다면 홈 파일에 저장된 옛 연결 전략 줄을 지워요. Colima·Podman 은 지원 환경 문서의DOCKER_HOST·TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE·TESTCONTAINERS_HOST_OVERRIDE를 맞춰요. - Ryuk 이 안 뜸. Ryuk 은 특권 컨테이너로 떠야 해요. CI 가 막으면
TESTCONTAINERS_RYUK_DISABLED=true로 끄되,kill -9·잡 취소 때 컨테이너가 남으니 파이프라인 끝에org.testcontainers라벨로 지우는 단계를 둬요. 루트 없는 Podman 도 끄라고 안내돼 있어요. - 이미지를 못 받음. 429 면 다음 절의 풀 한도, 멈춘 것 같으면
pull.timeout(기본 120초)·pull.pause.timeout(기본 30초)을 봐요. 사내 레지스트리는 이미지 이름 치환 문서의hub.image.name.prefix로 연결해요. - 준비 신호가 안 옴.
Timed out waiting for container는 컨테이너가 죽었거나 기다리는 신호가 안 온 경우예요. 실패 때getLogs()를 남기면 원인이 보여요. 직접 띄운GenericContainer는 기본이 "첫 포트가 열릴 때까지 60초"라 대기 전략 문서의 HTTP·로그·헬스체크 대기로 바꾸고, 에뮬레이션으로 돌리면withStartupTimeout을 늘려요. - 두 번째 클래스부터 연결 거부. 단독으로는 통과하는데 스위트에서만 깨지면, JUnit 확장이 클래스 끝에 멈춘 static 컨테이너를 스프링이 캐시한 컨텍스트가 계속 가리키는 경우예요. 스프링 부트 문서가 직접 경고하는 조합이에요. 로그의 데이터소스 포트와 떠 있는 컨테이너의 매핑 포트가 다르면 확실해요. 컨테이너를 스프링 빈이나
@ImportTestcontainers로 옮기거나 싱글턴으로 바꾸고, 공유 DB 데이터는 테스트마다 롤백하거나 비워요. - 스위트 후반에만 too many clients. 앞 절의 커넥션 계산이에요.
앞 단계 문제는 뒤 단계 증상으로 위장해요. 로그의 맨 처음 실패부터 보세요. 쿼리 수까지 검증해야 하는 리포지토리 테스트라면 JPA N+1 해결 방법 고르기 글처럼 진짜 DB 에서 쿼리를 세는 쪽이 의미가 있어요.
CI 에서 먼저 막히는 한도 — 도커 허브 풀 횟수
도커 허브 사용량 문서에 따르면 로그인하지 않은 풀은 6시간에 IPv4 주소 하나(또는 IPv6 /64)당 100회, 무료 개인 계정은 200회이고, Pro·Team·Business 는 무제한이에요. Testcontainers 는 기본적으로 로컬에 없는 이미지만 받아서, 이미지 캐시가 남지 않는 러너에서 문제가 커요.
예시 가정이에요. 파이프라인 한 번에 병렬 잡 4개가 돌고, 잡마다 캐시 없이 PostgreSQL·Redis·Ryuk 이미지 3개를 받아요. 러너들은 같은 NAT 공인 IP 하나로 나가요. 그러면 파이프라인 한 번에 4 × 3 = 12회를 써요.
익명이면 6시간에 파이프라인 8번이 한도예요. 싼 대응부터 하면 CI 에서 docker login, 러너 이미지 캐시나 사내 미러(hub.image.name.prefix, Ryuk 이미지도 함께 옮김), 잡마다 필요한 이미지만 띄우게 테스트 나누기 순이에요.
도입 전에 따질 것 — 체크리스트
환경
- 개발자 PC 의 도커 런타임을 정했다. 도커 데스크톱 라이선스 안내상 직원 250명 이상이거나 연 매출 1,000만 달러 이상인 회사는 유료 구독이 필요하고, 대안 런타임은 Testcontainers 가 늘 시험하는 대상이 아니에요.
- CI 러너에서 도커 소켓을 쓸 수 있고, 특권 컨테이너가 허용되는지 확인했다. 안 되면 Ryuk 을 끄고 정리 단계를 넣는다.
- 도커 허브 한도를 계산했고, 로그인 또는 미러를 정했다.
테스트 구조
- 컨테이너 수명을 위 판단표로 정했고, 컨텍스트 캐시 통계로 컨텍스트 종류 수를 확인했다.
- 테스트 프로필의 커넥션 풀을 2~3으로 낮췄다.
- 테스트마다 데이터를 지우거나 롤백하는 규칙을 정했다.
- 빌드 도구가 테스트 클래스마다 JVM 을 새로 띄우지 않는다.
무엇까지 Testcontainers 로 볼지
- 진짜 DB 가 아니면 검증할 수 없는 SQL 방언·락·트랜잭션만 통합 테스트로 두고, 비즈니스 규칙은 단위 테스트로 남긴다. 목을 쓰는 쪽 설정은 Mockito 목 메이커 선택표 글에 정리해 두었어요.
- 운영과 같은 메이저 버전 태그(
postgres:17처럼)를 고정하고,latest를 쓰지 않는다. - JUnit 확장은 병렬 테스트 실행을 지원하지 않으니, 병렬로 돌리려면 싱글턴을 공유하고 스키마를 테스트마다 나눈다.
자주 묻는 질문
싱글턴과 스프링 빈 방식 중 무엇이 더 빠른가요?
컨텍스트가 한 종류면 둘 다 컨테이너를 한 번만 띄워 비슷해요. 여러 종류면 스프링 빈 방식은 컨테이너가 늘고, 싱글턴은 커넥션이 쌓여요. 컨텍스트 종류를 줄이는 것이 먼저예요.
테스트용 커넥션 풀을 2로 줄여도 괜찮나요?
대부분의 통합 테스트는 한 스레드에서 돌아서 커넥션 1~2개면 충분해요. 비동기 작업이나 별도 트랜잭션을 동시에 여는 테스트만 예외라, 커넥션 대기 타임아웃이 나는 클래스만 풀을 키워요.
재사용을 켰는데 매번 새 컨테이너가 떠요.
재사용은 코드의 withReuse(true) 와 홈의 .testcontainers.properties 에 testcontainers.reuse.enable=true 가 둘 다 있어야 해요. 재사용 문서대로 @Container 대신 직접 start() 를 부르고 멈추지 않아야 하며, 설정이 조금이라도 바뀌면 새로 띄워요.
Ryuk 을 꺼도 되나요?
특권 컨테이너를 못 쓰면 꺼야 해요. 대신 강제 종료·잡 취소 때 컨테이너가 남으니 라벨로 정리하는 단계를 둬요. 잡이 끝나면 머신이 사라지는 일회용 러너라면 영향이 작아요.
H2 로 돌리던 테스트를 전부 옮겨야 하나요?
아니에요. 운영 DB 전용 문법(upsert, JSON 타입)이나 락·격리 수준에 기대는 리포지토리 테스트부터 옮기고, 단순 CRUD 와 서비스 로직 테스트는 그대로 둬요.
정리
- 기동 시간은 컨테이너 횟수 × 컨텍스트 횟수로 정해져요. 40클래스 예시에서 400초가 싱글턴과 컨텍스트 1종으로 10초가 됐어요.
- static
@Container와 스프링 컨텍스트 캐시를 함께 쓰면 두 번째 클래스부터 깨질 수 있어요. 스프링 빈이나 싱글턴으로 바꿔요. - 캐시된 컨텍스트마다 커넥션 풀이 살아 있어서, 기본 풀 10이면 컨텍스트 10종에서 PostgreSQL 한도 100에 닿아요. 테스트용 풀을 2~3으로 낮추세요.
- CI 에서는 도커 허브 풀 한도, Ryuk 특권 권한, 재사용 금지를 먼저 확인하고, 문제가 생기면 도커 연결 → Ryuk → 이미지 → 준비 신호 → 컨텍스트 순서로 내려가세요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 Testcontainers 동작 원리 — Ryuk·대기 전략·재사용 해시에서 도식과 코드로 단계별로 풀었어요.