Testcontainers에 의한 docker container 생성 폭발을 막아라

앞서서
이 글은 플렉스팀에서 발생한, Testcontainers 도입 후 도달한 한 가지 한계 지점을 돌파하는 여정에 대한 이야기입니다. 비슷한 고생을 하고 계실 분에게, 저희는 이런 방법으로 해결을 시도해 보았다는 기록을 공유해 드립니다.
Testcontainers란?
Testcontainers는 docker를 사용하여 integration test 구성에 사용할 수 있게 해주고, 테스트 라이프 사이클에 docker의 라이프 사이클을 맞춰주는 훌륭한 도구입니다.
단순 docker command나 docker-compose file을 바탕으로 실행시키는 것에서 오는 일부 불편한 부분들, 랜덤 포트에 대한 관리, 실행 후 멈추기 등을 JUnit 등과 같은 테스트 프레임워크와 통합하여 클린 상태로 기동 후 정지까지 관리해 줍니다.
대부분의 언어와 프레임워크 환경과의 통합을 잘 제공해 주고, 서드파티 모듈들까지도 충실한 것이 큰 장점입니다.
플렉스팀에서 사용하는 Testcontainers
플렉스팀은 대략 3년가량 Testcontainers를 활용한 integration test를 활용하고 있습니다. 주로 백엔드의 database integration test 용도인데요.

이상적인 심플한 경우
flex backend에는 많은 JPA에 의존한 코드들이 있고, 이것을 온전히 이해하고 테스트하며 관리하는 데 큰 도움이 되고 있습니다.
JPA 이외에도, 단위테스트만으로는 이해하기 힘든 종류의 데이터베이스들의 동작을 이해하는 학습 테스트로도 많이 사용되는데요, elasticsearch나 redis, kafka 등도 잘 사용하고 있었습니다.
안타깝게도, 그날이 오기 전까지는요
Not enough memory
Testcontainers는 훌륭한 도구입니다.
하지만 그것을 방만하게 사용하는 패턴의 대가는, 수많은 testcontainer들의 기동과 그를 통해 고갈되는 메모리였습니다.
주로 문제가 발생한 것은 spring boot test에서 mockBean이나 spyBean 등을 사용하여 context dirty로 재사용이 불가능한 경우의 테스트들에서 이 문제가 극심했습니다.

mock bean 등에 의해 context dirty가 되는 경우 중복으로 생성되는 수많은 컨테이너
하나의 테스트 케이스마다 하나의 테스트 컨테이너의 구동이 필요했고, 더불어 gradle runner로 단순하게 기동시키는 경우, 그 테스트는 무려 순차적으로 실행되었죠.
문제는 이런 integration test가 한 repository에 하나만 있지 않았다는 것입니다.
플렉스팀에서 사용하는 모듈 구조는 대략 아래와 같습니다.

여기에서 각 도메인의 repository 모듈에 대개 CRUD를 테스트하는 Testcontainers가 DataJpaTest, DataJdbcTest 등을 이용해 작성됩니다.
service 모듈에서 트랜잭션이나 Hibernate의 특성을 활용한 비즈니스 로직을 작성하는 경우라면, service 모듈에도 integration test로 testcontainer를 필요로하는 테스트가 작성됩니다.
Hibernate가 아니더라도 redis나 외부 database의 특성을 사용하는 경우, 그것들도 포함되게 됩니다.

이렇게 많은 테스트 컨테이너를 바라지는 않았다
결론적으로 굉장히 많은 개수의 testcontainer가 테스트 시에 실행되게 됩니다.
물론 평상시에 이것이 항상 문제가 되지는 않습니다. 기본적으로는 도메인별로 개발이 진행되므로, gradle의 build cache를 통해 변경에 영향을 받는 모듈들만 테스트가 트리거됩니다.
하지만 가끔 그렇지 못한 경우에는 메모리 부족으로 인한 스로틀링으로, CI에서 테스트하는 시간이 합리적인 구간을 넘어, 20분가량 소요되는 일들도 발생하고 있었습니다.

대략 이런 상태인 것이죠.
이 스크린샷도 극히 일부에 지나지 않습니다
해결책을 궁리
dirty context 문제를 해결하는 것은 테스트 의존성을 정리하기만 하면 됩니다. 하지만, 이것도 너무 많은…테스트가 이미 존재하는 상황에서 한 땀 한 땀 해결하는 것은 만만치 않은 일이었습니다.
또한 이 문제를 해결한다고 해도 서로 다른 모듈에서 병렬적으로 수행되는 테스트에 의해 발생하는 testcontainer는 또 막을 수가 없었죠. 그렇다고 concurrency를 떨어뜨리면 전체 수행이 느려지는 트레이드 오프를 감수할 수밖에 없었죠.
이미 팀에서는 핫픽스 나갈 때는 CI test를 생략해도 괜찮지 않을까? 하는 의견이 슬슬 흘러나오는 순간이었습니다. 지금, 이 순간 이 문제를 해결하지 않으면 사람의 개별적인 판단으로 CI를 기다릴지 말지 결정하는 것을 허용하는 위험한 문화가 태어나려는 조짐을 느꼈습니다.
그래서 아예 다소 극단적인 방향성까지 열어두고 고민을 해봤습니다.
아예 전체 테스트에서 하나의 컨테이너만 만들고 재사용을 해버리면 어떨까?
Prototyping
아이디어가 떠올랐으니, 프로토타이핑을 해봅니다.
하나의 컨테이너를 전체 테스트에서 재사용하는 상황을 떠올려봅니다.
다른 애들은 괜찮은데, database가 걸립니다. RDB를 사용하고 있는 만큼, 스키마를 관리할 방법이 없다면, 기존까지 testcontainer JUnit test 등에서 사용하던 방식인 ddl-auto: create-drop 같은 건 곤란합니다. update 정도면 괜찮을 수도 있지만, 조금 더 고민해 봅시다…
무엇으로 컨테이너를 띄울지도 고민해 봅니다.
Testcontainers reuse
Reusable Containers (Experimental) - Testcontainers for Java
우선 가장 먼저 접근했던 방향은 zero effort로 가능해 보였던 testcontainers의 container reuse 기능이었습니다. 그냥 한번 생성한 컨테이너를 쭉 재사용하면 어떨까 하는 아이디어였죠
하지만, 이 간단한 해결책은 원하는 바를 이뤄주지 못했습니다.
- 재사용이 되는 범위를 제한할 수가 없다
- 설정이 같은 testcontainer들에 대해서 재사용되는 것을 막을 수가 없었습니다.
- init script 등으로 database schema 초기화를 하는 경우 실행 순서를 따로 제어하지 않으면 이미 있는 스키마와 충돌이 발생하기도 했고…
- 반대로 설정이 같으면, 다른 초기화 과정을 거친 컨테이너를 사용하는 시나리오는 지원할 수 없었습니다
- 이걸 위해서 컨테이너 설정을 전체 repo에서 맞춰간다? 는 것은 합리적인 노력의 사이즈를 넘을 것 같았습니다.
2. 테스트가 끝난 다음에 내려가지도 않음