광고
~ / posts / developer
💻 개발자

Testcontainers 수명 고르는 법, CI 시간과 커넥션 한도 계산

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초로 놓았어요. 두 값은 장비마다 크게 다르니 테스트 로그 타임스탬프로 재서 바꿔 넣으세요.

스프링 통합 테스트 40클래스의 기동 시간 (예시 가정)
컨테이너 기동컨텍스트 생성
0100200300400클래스마다 컨테이너·컨텍스트 · 컨테이너 기동 160초클래스마다 컨테이너·컨텍스트 · 컨텍스트 생성 240초클래스마다 컨테이너·컨텍스트싱글턴 + 컨텍스트 40종 · 컨테이너 기동 4초싱글턴 + 컨텍스트 40종 · 컨텍스트 생성 240초싱글턴 + 컨텍스트 40종싱글턴 + 컨텍스트 1종 · 컨테이너 기동 4초싱글턴 + 컨텍스트 1종 · 컨텍스트 생성 6초싱글턴 + 컨텍스트 1종재사용 + 컨텍스트 1종 · 컨테이너 기동 0초재사용 + 컨텍스트 1종 · 컨텍스트 생성 6초재사용 + 컨텍스트 1종

재사용은 로컬 두 번째 실행부터의 값이에요. CI 에서는 쓰지 않아요

단위: 초 · 자료: 컨테이너 1회 4초·컨텍스트 1회 6초 가정으로 직접 계산 (테스트 실행 시간 제외)
표로 보기
구분컨테이너 기동컨텍스트 생성
클래스마다 컨테이너·컨텍스트160240
싱글턴 + 컨텍스트 40종4240
싱글턴 + 컨텍스트 1종46
재사용 + 컨텍스트 1종06

클래스마다 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 가 나면, 캐시된 컨텍스트들이 커넥션 풀을 붙잡고 있는 거예요. 숫자 세 개로 계산돼요.

캐시에 살아 있는 컨텍스트마다 히카리 풀이 10개씩 열려 있으니, 컨텍스트가 10종을 넘는 순간 컨테이너 하나의 한도를 다 써요.

캐시된 컨텍스트 수별 PostgreSQL 커넥션 점유
히카리 기본 풀 10테스트용 풀 2max_connections 기본값
01002003004001종5종10종15종20종32종1종 · 히카리 기본 풀 10 10개5종 · 히카리 기본 풀 10 50개10종 · 히카리 기본 풀 10 100개15종 · 히카리 기본 풀 10 150개20종 · 히카리 기본 풀 10 200개32종 · 히카리 기본 풀 10 320개3201종 · 테스트용 풀 2 2개5종 · 테스트용 풀 2 10개10종 · 테스트용 풀 2 20개15종 · 테스트용 풀 2 30개20종 · 테스트용 풀 2 40개32종 · 테스트용 풀 2 64개641종 · max_connections 기본값 100개5종 · max_connections 기본값 100개10종 · max_connections 기본값 100개15종 · max_connections 기본값 100개20종 · max_connections 기본값 100개32종 · max_connections 기본값 100개100

32종은 캐시 상한이라 그 이상은 오래된 컨텍스트가 닫히며 더 늘지 않아요

단위: 개 · 자료: HikariCP 기본 풀 10, 스프링 컨텍스트 캐시 최대 32, PostgreSQL max_connections 기본 100으로 직접 계산
표로 보기
구분히카리 기본 풀 10테스트용 풀 2max_connections 기본값
1종102100
5종5010100
10종10020100
15종15030100
20종20040100
32종32064100

해결은 세 갈래예요. 첫째, 테스트 프로필에서 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") 처럼 기존 옵션을 유지해 덧붙여요. 셋째, 근본 원인인 컨텍스트 종류를 줄여요. 풀 크기와 동시성 계산은 스프링 배치 파티셔닝 커넥션 풀 계산 글에도 있어요.

운영에서 자주 나는 문제와 확인 순서

도커 연결, Ryuk 기동, 이미지 받기, 준비 신호, 두 번째 클래스 실패, 스위트 후반 실패 여섯 단계를 증상과 확인할 곳으로 정리한 체크리스트
앞 단계가 막혀 있으면 뒤 단계 증상은 의미가 없어요
  1. 도커에 못 붙음. docker info 부터 봐요. 설정 문서대로 설정은 환경변수 → 홈의 .testcontainers.properties → 클래스패스 순으로 읽혀요. 도커 런타임을 바꿨다면 홈 파일에 저장된 옛 연결 전략 줄을 지워요. Colima·Podman 은 지원 환경 문서의 DOCKER_HOST·TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE·TESTCONTAINERS_HOST_OVERRIDE 를 맞춰요.
  2. Ryuk 이 안 뜸. Ryuk 은 특권 컨테이너로 떠야 해요. CI 가 막으면 TESTCONTAINERS_RYUK_DISABLED=true 로 끄되, kill -9·잡 취소 때 컨테이너가 남으니 파이프라인 끝에 org.testcontainers 라벨로 지우는 단계를 둬요. 루트 없는 Podman 도 끄라고 안내돼 있어요.
  3. 이미지를 못 받음. 429 면 다음 절의 풀 한도, 멈춘 것 같으면 pull.timeout(기본 120초)·pull.pause.timeout(기본 30초)을 봐요. 사내 레지스트리는 이미지 이름 치환 문서의 hub.image.name.prefix 로 연결해요.
  4. 준비 신호가 안 옴. Timed out waiting for container 는 컨테이너가 죽었거나 기다리는 신호가 안 온 경우예요. 실패 때 getLogs() 를 남기면 원인이 보여요. 직접 띄운 GenericContainer 는 기본이 "첫 포트가 열릴 때까지 60초"라 대기 전략 문서의 HTTP·로그·헬스체크 대기로 바꾸고, 에뮬레이션으로 돌리면 withStartupTimeout 을 늘려요.
  5. 두 번째 클래스부터 연결 거부. 단독으로는 통과하는데 스위트에서만 깨지면, JUnit 확장이 클래스 끝에 멈춘 static 컨테이너를 스프링이 캐시한 컨텍스트가 계속 가리키는 경우예요. 스프링 부트 문서가 직접 경고하는 조합이에요. 로그의 데이터소스 포트와 떠 있는 컨테이너의 매핑 포트가 다르면 확실해요. 컨테이너를 스프링 빈이나 @ImportTestcontainers 로 옮기거나 싱글턴으로 바꾸고, 공유 DB 데이터는 테스트마다 롤백하거나 비워요.
  6. 스위트 후반에만 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시간에 돌릴 수 있는 파이프라인 수 (예시 가정)
익명 · 이미지 3개익명 · 이미지 3개 · 8회8회익명 · 이미지 2개익명 · 이미지 2개 · 12회12회개인 계정 · 이미지 3개개인 계정 · 이미지 3개 · 16회16회개인 계정 · 이미지 2개개인 계정 · 이미지 2개 · 25회25회

Pro 이상 계정이나 사내 미러를 쓰면 이 한도와 무관해져요

단위: 회 · 자료: 도커 허브 공식 한도(익명 100·개인 200회/6시간)로 직접 계산. 잡 4개·캐시 없음·같은 IP 가정
표로 보기
구분파이프라인 수
익명 · 이미지 3개8
익명 · 이미지 2개12
개인 계정 · 이미지 3개16
개인 계정 · 이미지 2개25

익명이면 6시간에 파이프라인 8번이 한도예요. 싼 대응부터 하면 CI 에서 docker login, 러너 이미지 캐시나 사내 미러(hub.image.name.prefix, Ryuk 이미지도 함께 옮김), 잡마다 필요한 이미지만 띄우게 테스트 나누기 순이에요.

도입 전에 따질 것 — 체크리스트

환경

테스트 구조

무엇까지 Testcontainers 로 볼지

광고
광고

자주 묻는 질문

싱글턴과 스프링 빈 방식 중 무엇이 더 빠른가요?

컨텍스트가 한 종류면 둘 다 컨테이너를 한 번만 띄워 비슷해요. 여러 종류면 스프링 빈 방식은 컨테이너가 늘고, 싱글턴은 커넥션이 쌓여요. 컨텍스트 종류를 줄이는 것이 먼저예요.

테스트용 커넥션 풀을 2로 줄여도 괜찮나요?

대부분의 통합 테스트는 한 스레드에서 돌아서 커넥션 1~2개면 충분해요. 비동기 작업이나 별도 트랜잭션을 동시에 여는 테스트만 예외라, 커넥션 대기 타임아웃이 나는 클래스만 풀을 키워요.

재사용을 켰는데 매번 새 컨테이너가 떠요.

재사용은 코드의 withReuse(true) 와 홈의 .testcontainers.properties 에 testcontainers.reuse.enable=true 가 둘 다 있어야 해요. 재사용 문서대로 @Container 대신 직접 start() 를 부르고 멈추지 않아야 하며, 설정이 조금이라도 바뀌면 새로 띄워요.

Ryuk 을 꺼도 되나요?

특권 컨테이너를 못 쓰면 꺼야 해요. 대신 강제 종료·잡 취소 때 컨테이너가 남으니 라벨로 정리하는 단계를 둬요. 잡이 끝나면 머신이 사라지는 일회용 러너라면 영향이 작아요.

H2 로 돌리던 테스트를 전부 옮겨야 하나요?

아니에요. 운영 DB 전용 문법(upsert, JSON 타입)이나 락·격리 수준에 기대는 리포지토리 테스트부터 옮기고, 단순 CRUD 와 서비스 로직 테스트는 그대로 둬요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 Testcontainers 동작 원리 — Ryuk·대기 전략·재사용 해시에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#Testcontainers 설정#통합 테스트 속도#싱글턴 컨테이너#스프링 테스트 컨텍스트 캐시#ServiceConnection#Ryuk 비활성화#도커 허브 풀 한도#too many clients already
← 이전 글테라폼 parallelism 값 고르는 법, depends_on·create_before_destroy 판단표다음 글 →FlashAttention 버전 고르기, GPU별 FA2·FA3·FA4와 학습 비용
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →