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 기능이었습니다. 그냥 한번 생성한 컨테이너를 쭉 재사용하면 어떨까 하는 아이디어였죠

하지만, 이 간단한 해결책은 원하는 바를 이뤄주지 못했습니다.

  1. 재사용이 되는 범위를 제한할 수가 없다
  • 설정이 같은 testcontainer들에 대해서 재사용되는 것을 막을 수가 없었습니다.
    • init script 등으로 database schema 초기화를 하는 경우 실행 순서를 따로 제어하지 않으면 이미 있는 스키마와 충돌이 발생하기도 했고…
  • 반대로 설정이 같으면, 다른 초기화 과정을 거친 컨테이너를 사용하는 시나리오는 지원할 수 없었습니다
    • 이걸 위해서 컨테이너 설정을 전체 repo에서 맞춰간다? 는 것은 합리적인 노력의 사이즈를 넘을 것 같았습니다.

2. 테스트가 끝난 다음에 내려가지도 않음

  • 언제 재사용될지 모르니, 이 컨테이너를 내리는 것은 수동으로 제어해야 했습니다.
  • 한번 로컬에서 테스트 돌릴 때마다 컨테이너가 쌓여갑니다… 수동 제거를 같이 해줘야 했습니다.

그냥 gradle에 맡겨볼까?

docker gradle plugin, docker-compose gradle plugin도 있지만, 기존 testcontainer가 주는 기능성이 맘에 드는 것이 있었습니다. 바로 컨테이너의 라이프사이클을 직접 컨트롤할 수 있다는 것이죠.

reuse와는 다르게, gradle task가 끝날 때 훅을 넣어줘서 stop 시키는 것도 얼마든지 가능해 보였습니다.

그래서 testcontainer를 gradle lifecycle 관리하는 방향성으로 가닥을 잡고 프로토타입을 만들어봤습니다.

gradle이 testcontainer lifecycle을 관리해 주는 상태

로컬 기준이기는 하지만, 기존 10분 가까이 걸리던 테스트를 2분가량으로 단축하는 데 바로 성공해 버렸습니다?!

가능성의 맛을 봤으니 이제 제대로 만들어봅시다.

Gradle Magic

jvm 월드에서 개발을 업으로 삼는 분들이라면 모두 알고 계실 gradle은 빌드의 과정에 필요한 step들을 코드로 기술할 수 있는 훌륭한 도구입니다.

대신에 작성자 빼고는, 때로는 작성자를 포함해서 이것을 읽고 개선하기에 어려움이 따르는 도구이기에, 보통 각 팀에서는 repository를 셋업 한 한 명의 닌자가 만들어둔 베이스를 의존성만 업데이트하면서 사용하는 것이 일반적입니다.

하지만 플렉스팀에는 gradle build script를 지속적으로 개선해 나가고 있는 닌자가 있고, 그렇다면 해볼 만한 일이었습니다.

Gradle BuildService

Using Shared Build services

gradle에는 build service라는 기능이 있습니다. 서문에서부터 task 사이의 상태나 리소스를 공유하게 해주는 인터페이스라고 합니다. 딱 제가 찾던 그 녀석이네요.

이것을, container를 등록하고, 여러 integration test에서 사용할 수 있게 만들어주면 좋을 것 같네요.

무려 auto closable이라, gradle 실행이 끝나고 나면 컨테이너 정리도 알아서 해줍니다.

Gradle BuildService의 기묘한 제약사항

보통 생성자를 통해 인스턴스를 만드는 자바, Kotlin 세계의 문법과는 다르게 gradle은 독자적인 dsl을 가지고 있습니다. 보통 그래서 특정한 클래스의 인스턴스를 만드는 방법이 제한되게 됩니다.

buildService의 경우는 registerIfAbsent 라는 메서드 호출을 통해 생성 후 등록되는데, 이때 넘길 수 있는 것은 service의 이름과 class reference뿐입니다.

온갖 트릭을 써봤지만… generic type의 type reference는 등록할 수 없었습니다.

또한 등록 후 사용할 수 있도록 파라메터를 넘길 수는 있는데, 이 파라메터들은 serializable 해야 한다는 강력한 제약을 또 가지고 있었습니다.

네. testContainer instance는 serializable 하지는 않죠

이와 같은 제약사항 속에서 testcontainer의 라이프사이클 관리 및 접속 정보 추출을 위해서는 컨테이너 정보를 넘기는 것이 필요했습니다.

장고 끝에 선택한 방법은, abstract class constructor를 통해 testcontainer instance를 넘기는 방법이었습니다.

때로는 이와 같은 흑마술을 동원해 가면서, build service를 사용하는 testcontainer gradle plugin은 착착 완성되어 갔습니다.

조금 더 어려운 문제

컨테이너를 만들고 라이프사이클 관리하는 것은 되었습니다. 하지만 이제 고민이 되는 것은, 스키마를 초기화시킬 방법입니다.

지금까지는 다소 간단히 ddl-auto 등을 사용해서 회피하곤 했었는데, 그것은 테스트마다 새로운 컨테이너를 만들어내는 상황을 전제할 수 있었기 때문에 가능했지만, 컨테이너를 재사용하는 상황에서는 지속할 수 없습니다.

이제는 좀 더 제대로 된 방법을 고민해 볼 순간이 왔습니다.

오래된 유산

플렉스팀에서는 아주 초기부터 ddl을 Liquibase라는 도구로 관리해 왔습니다.

관리 과정이 아주 순탄하지만은 않았기때문에, 모든 ddl이 재현할 수 있는 상태라고는 이야기하기 어려웠지만, 이미 팀에는 Liquibase를 사용하여 ddl을 초기화하고 버전관리 하는 프랙티스가 자리 잡고있었습니다.

그런데 때로는 ddl-auto로 생성되는 스키마가 표현력이 부족하여 관리되는 Liquibase script의 제약을 가지지 못하는 경우들이 종종 있었습니다.

이 문제를 해결해 볼 순간이라는 생각이 들었습니다.

ddl-auto: validate를 testcontainer에도 적용한다! 괜찮은 발상 같았습니다.

팀에서 사용하고 있는 liquibase gradle plugin의 내부를 조금 들여다보았습니다.

아… 조금 안타깝게도 extension으로 주입되는 값들에 대한 결합도가 너무 높았습니다.

LiquibaseTask.groovy liquibase/liquibase-gradle-plugin

어쩔 수 없이 조금 재작성했습니다.

이렇게 먼저 선언해 두었던 mysql container에 variant로 database를 생성하고

이렇게 가져다 씁니다

한 docker container에 variant를 생성하고, 서로 다른 테스트에서 재사용하는 케이스

이제 이런 형상이 되었습니다.

돌려봤더니 아주 잘 됩니다.

좀 빠른 것 같네요? 원래는 얼마나 걸렸는지를 한번 비교해 봅니다.

integration test의 수행만 이루어진 경우였는데, 137개의 task만 실행되었는데도 8분 넘게 소요되고 있었습니다.

전체 테스트 수행에 2분 24초. 이 정도면 아주 만족할 만한 수치인 것 같습니다.

무엇이 개선된 것인가?

로컬이 아닌 CI 환경에서의 차이를 비교해 봤습니다.

현재 플렉스팀에서는 범용 jvm backend repository에 대해서 16G 메모리와 cpu request 4 limit 16 정도로 리소스를 부여하고 있었습니다.

오른쪽의 메트릭이 as is인데, 메모리가 임계지점까지 사용되면서 cpu를 제대로 활용하지 못하는 모습입니다.

왼쪽의 개선된 성능 쪽을 보면, 메모리를 더 적은 용량을 사용하면서 cpu를 더 공격적으로 사용하는데, 리미트 가까이 사용하면서 throttling이 걸리는 것을 확인할 수 있었습니다.

CI에서 생성되던 testcontainer의 개수가 줄어들면서 메모리 사용량이 극적으로 개선되었습니다. 이에 따라 cpu를 더 부여하면 더 개선된 성능을 기대해 볼 만한 점도 보였습니다.

결론

우리팀의 사례는 상당히 극단적인 예제라고 생각합니다. 이렇게 많은 integration test가 동시에 실행되는 환경이 일반적으로는 많이 존재하지 않을지도 모릅니다.

하지만 저희는 이러한 구성을 통해 여러 가지 효용을 얻고 있고, 그로 해 케이던스를 점점 높여가고 있다고 생각합니다.

저희가 사용 중인 다른 방법론들에 대해서는 또 다음 기회에 이야기할 수도 있을 것 같은데요, 혹시 궁금하신 분들은 커피챗으로 방문하시어 이야기를 나눠보는 시간을 가져봐도 좋을 것 같습니다.

감사합니다.

🚀플렉스팀 채용 공고 바로가기

글이 마음에 드셨나요?
공유하기
페이스북링크드인트위터
flex가 궁금하다면? 지금 무료로 체험해 보세요
flex가 궁금하다면? 지금 무료체험하기
  • 2024. 7. 25
    아마존이 일하는 법 : 더 나은 의사결정을 돕는 6 pager 문서 작성법

    논리와 아이디어로 설득을 이끌어내는 문서는 어떻게 구성할까?

  • 2024. 7. 30
    [버핏서울] 성장 속도가 빠른 조직의 숙제, 운영 리스크와 성과관리 루틴 모두 잡는 방법

    피트니스 스타트업 버핏서울의 성장 체계, flex가 함께 만듭니다.