테스트 컨테이너(Testcontainers)를 활용해 통합 테스트 수행하기

lango·2024년 3월 17일
3
post-thumbnail

들어가며

 이전부터 사라마라 프로젝트를 개발하며 테스트를 위한 DB를 인메모리 DB H2가 아닌 실제 DB로 MariaDB를 사용하고 있었는데요. 아직도 한참 부족하지만, 계층별로 작성해둔 테스트 케이스가 100개를 넘어가면서 테스트를 위한 실제 DB에 강하게 의존하고 있던 문제를 들여다 볼 여유가 생겼네요.

 실제 DB와의 접점이 발생하는 비즈니스 계층의 통합 테스트는 MariaDB에게 강하게 의존하고 있기에 MariaDB의 생명주기에 크게 영향을 받게 되어요. 현재는 아직 운영 전 개발 단계라서 로컬 환경에 구축된 Docker를 이용해 MariaDB 컨테이너를 이용해 테스트 DB로 사용하고 있는데요. 전체 테스트를 수행하거나 통합 테스트를 수행할 때 해당 MariaDB 컨테이너가 실행되고 있지 않다면 테스트가 실패하게 되는 아이러니한 상황이 지속되고 있었어요. 그래서 인텔리제이의 test로 전체 테스트하기 전에 항상 Docker Desktop을 확인하게 되는 불필요한 습관(?)이 생겨버렸네요.

 그렇게 테스트 DB 환경에 크게 구애받지 않고 통합 테스트를 수행할 수 있는 여러 정보나 자료를 찾아보던 중에 테스트 컨테이너(Testcontainers)를 알게 되었고, 이를 도입해보기로 마음 먹게 되었는데요. 테스트 컨테이너를 활용한다면 실제 DB와의 깊은 의존을 벗어날 뿐더러, 실제 서비스와 유사한 환경은 유지한 채로 통합 테스트를 진행할 수 있을 것이라고 느껴졌기 때문이에요. 그래서! 이번 글에서는 개발하고 있던 프로젝트에 테스트 컨테이너를 도입하고, 조악한 성능 최적화까지의 과정을 간단하게 기록해 보아요.




통합 테스트를 수행하는데 굳이 테스트 컨테이너를 사용해야 할까?

 테스트 컨테이너는 잠시 내려놓고 제가 지향하는 통합 테스트 방식을 간단히 짚고 넘어가야 할 것 같아요.

 테스트 컨테이너를 도입하기로 결정한 이유를 설명하려면, 현재 제가 개발하고 있는 통합 테스트가 어떤 방식인지로 거슬러 올라가 이야기 해보아야 하는데요. 아직까지는 여러가지로 경험이 부족한 주니어 개발자이기 때문에 런타임 시점에 발생할 수 있는 방대하고 많은 이슈들을 프로덕션 코드 레벨에서 정확하게 Stubbing(혹은 Mocking)하기 어려운 수준의 실력이라고 느끼고 있어요.

 그래서 핵심 비즈니스에 대한 테스트 코드를 개발할 때는 Classicist(고전파)의 자세로 실제 운영 환경과 최대한 유사하게 만들어 테스트하는 통합 테스트 방식을 종종 이용하고 있어요. 물론 외부 시스템 영향과 의존이 클 경우라면, Mock도 적극적으로 활용한답니다. 개발하는 시스템 아키텍처 수준이나 규모에 따라 Classicist의 입장에 서야할지, Mockist의 입장에 서야할지를 결정한다기보다는 상황에 맞추어 Classicist와 Mockist의 입장을 병행해가며 개발하려고 하고 있어요.

💡 그래서 테스트 컨테이너를 꼭 사용해야 할까?
앞서 이야기한대로 최대한 실제 운영 환경과 유사한 조건으로 테스트 환경을 구축하여 통합 테스트를 진행하기 위해서 실제 MariaDB로 테스트를 수행하고 있어요. 그런데, 개발자가 비즈니스를 개발하는 것과 동시에, 통합 테스트를 수시로 수행해야 할 때 개발자의 로컬 환경마다 MariaDB가 필요하다는 점은 현재는 크게 문제가 되지 않겠지만, 앞으로 프로젝트 완성도가 높아질 수록 신경써야할 부분으로 판단했기에 테스트 컨테이너를 도입하기로 결정한 가장 큰 이유라고 말할 수 있어요.




테스트 컨테이너 도입해보기

 테스트 컨테이너(Testcontainers)도커(Docker) 컨테이너를 이용해 실제 서비스와의 통합 테스트를 수행하기 위한 경량화된 컨테이너 라이브러리라고 설명할 수 있을 것 같아요. 인메모리 DB가 아닌 실제 운영 DB(여기서는 MariaDB)와 동일한 환경을 구축한 후, 로컬 도커 컨테이너를 활용해 테스트 수행 시 컨테이너를 실행, 테스트 종료 시에는 컨테이너를 종료해주는 작업을 자동화해주어요.

 이러한 테스트 컨테이너는 Java나 Go, Rust와 같은 다양한 언어를 지원하고 있는데 제가 개발한 프로젝트는 Java 언어로 개발했다 보니, 테스트 컨테이너도 Java 전용 라이브러리를 사용했어요.

🧐 테스트 컨테이너를 사용하기 위한 기술적인 내용들은 너무나 잘 정리된 자료들과 문서들이 이미 많아서 이론적인 내용을 소개하는 것은 생략할게요. 다만, 튜토리얼로는 최범균님의 유튜브 영상 중 Testcontainers로 MariaDB 통합 테스트하기라는 영상을 조심스럽게 추천해 봅니다. 해당 영상으로 테스트 컨테이너에 대한 이해도를 높이는데 큰 도움이 되었어요.


테스트 컨테이너(Testcontainers) 기본 설정하기

 가장 먼저 테스트 컨테이너 사용을 위해서는 로컬 PC에 도커(Docker)가 설치되어 있어야 해요. 테스트 컨테이너 환경설정 전에 도커 설치 여부를 확인해보면 좋겠네요. 저는 이전부터 Docker Desktop을 사용중이었기에 별도로 도커를 설치하진 않았어요.

 도커를 설치한 후에는 테스트 컨테이너 설정을 위한 코드를 작성해주어야 하는데요. 수십 라인까지도 작성할 필요없이 몇 줄만 작성해도 손쉽게 사용해볼 수 있답니다.

테스트 컨테이너 의존성 추가하기

 공식 문서를 살펴보니 Testcontainers 설정을 위한 JUnit5와 MaraiaDB과 관련된 1.19.7 버전 의존성을 필요로 하고 있네요. Build.gradle에 추가해주었어요.

testImplementation "org.testcontainers:junit-jupiter:1.19.7"
testImplementation "org.testcontainers:mariadb:1.19.7"

💡 의존성 전이 효과로 인해 컨테이너의 핵심 모듈(org.testcontainers:testcontainers:1.19.7)을 별도로 추가하지 않아도 JUnit이나 MariaDB 모듈 라이브러리의 추가만으로 테스트 컨테이너 핵심 의존성을 사용할 수 있게 된답니다. 테스트 컨테이너의 라이브러리별 의존성 전이 여부는 공식 문서의 Shaded dependencies 구문에서 찾아볼 수 있었네요.

테스트 설정파일 수정하기

 공식 문서의 내용 중 JDBC 관련 내용을 살펴보니, MariaDB를 사용하기 위한 JDBC 드라이버 및 연결 URL 정보를 기입하라고 안내하고 있었어요. 아래와 같이 application-test.yml 설정 파일을 수정했어요.

datasource:
  driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
  url: jdbc:tc:mariadb:10.11://databsename

@TestContainers 어노테이션과 @Container 어노테이션을 이용해 테스트 컨테이너 실행하기

 기초적인 환경설정을 마쳤다면 프로덕션 코드에 테스트 컨테이너를 사용하도록 명시하면 됩니다. 바로 @TestContainers 어노테이션과 @Container 어노테이션을 사용하면 테스트 메서드마다 컨테이너가 실행되고 테스트 코드를 수행한 뒤, 컨테이너는 종료되어요. 저는 통합 테스트 수행을 위해 별도로 개발해둔 IntegrationTestSupport라는 클래스 상단에 @TestContainers 어노테이션을 명시하여 테스트 컨테이너를 사용하는 테스트 클래스임을 선언해주었어요.

 그리고 10.11 버전의 MariaDB 이미지로 실행되는 컨테이너를 사용하도록 @Container 어노테이션을 MariaDBContainer 객체를 생성하는데 명시해주었어요.

@Testcontainers
@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class IntegrationTestSupport {
    @Container
    MariaDBContainer MARIADB_CONTAINER = new MariaDBContainer("mariadb:10.11");
}

😅 @BeforeEach 및 @AfterEach 어노테이션에서도 사용할 수 있는데요?
@BeforeEach 어노테이션이 명시된 setUp() 메서드에서는 컨테이너를 실행하는 start() 구문을, @AfterEach 어노테이션이 명시된 tearDown() 메서드에서는 컨테이너를 종료하는 stop() 구문을 추가하여 테스트 케이스별로 컨테이너를 실행하고 종료할 수도 있는데요. 매번 작성해주어야할 start() 및 stop()의 중복을 피하기 위해 @Container 어노테이션을 적극 활용했어요.

 위처럼 작성해준 뒤 테스트를 수행하고 Docker Desktop을 살펴보면 Testcontainers를 통해 10.11 버전의 MariaDB 컨테이너가 열심히 생성되고 삭제되는 것을 확인해볼 수 있어요!

🚨 오잉? 테스트 성능 저하 발생!

 그런데, 테스트 메서드마다 컨테이너를 매번 실행하고 종료하게 되니 120개 가량의 테스트를 수행하는데 3분이 넘는 오랜 시간이 소요되었네요. 굳이 테스트 케이스마다 컨테이너를 새로 띄울 필요는 없을 것 같아 컨테이너 실행 빈도를 조정하는 것이 좋아 보였어요.


실행된 테스트 컨테이너를 재사용하도록 개선하기

 대략 120개 정도 되는 테스트 케이스를 검증하는데 3분이라는 시간은 꽤나 바람직한 상황이 아닌 것 같아서 컨테이너를 생성하는 MARIADB_CONTAINER 객체에 static 필드를 추가로 선언해주었어요.

@Testcontainers
@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class IntegrationTestSupport {
    @Container
    static MariaDBContainer MARIADB_CONTAINER = new MariaDBContainer("mariadb:10.11");
}

 static 키워드를 추가함으로 통합 테스트를 수행하는 테스트 클래스마다 컨테이너를 새로 실행하기 때문에 모든 테스트 메서드에서 컨테이너를 실행하는 비용을 덜어낼 수 있었어요.

 매번 컨테이너를 실행하느라 3분 정도 소요되었던 전체 테스트 수행 시간이 약 2초 정도까지 줄어들었네요. 테스트 컨테이너 도입 전후의 전체 테스트 수행 결과를 비교해보면 모두 약 2초 정도로 큰 차이가 없었어요.

  • 테스트 컨테이너 도입 전: 2sec 502ms
  • ❌ 테스트 컨테이너 도입 후(테스트별 컨테이너 생성): 3min 14sec
  • ✅ 테스트 컨테이너 도입 후(클래스별 컨테이너 생성): 2sec 351ms

🤔 컨테이너를 최초 1번만 생성한다면 성능상 이점이 있지 않을까요?
사실 컨테이너의 재사용성을 극단적으로 올리기 위해 전체 테스트를 수행할 때 컨테이너를 딱 1번만 생성되도록 할 수도 있겠지만, 테스트 독립성이 침해될 수도 있다고 우려되어 일단은 클래스 별로 컨테이너를 생성하여 테스트 하도록 하였어요.




마치며

 이전부터 사용하고 팠던 Testcontainers를 드디어 직접 학습하고 적용해보았는데요. 기존에 사용하고 있던 로컬 MariaDB 컨테이너의 종속성을 끊어냈지만, 도커에 새로운 의존을 부여했다는 점이 인상깊었다고 볼 수 있겠네요. 게다가 실제 운영 DB 환경과 유사한 형태로 테스트 환경을 구성할 수 있어 테스트의 신뢰도는 높아졌지만, 테스트 코드의 성능 저하를 야기할 수 있는 가능성이 열리게 되어 신경써야 할 관리포인트 또한 늘어났다고 생각해요.

 비슷한 이유로 새로운 기술을 사용해본다는 맥락 하나만으로 특정 기술을 도입한다는 것은 트레이드 오프를 적절히 고려해야 함을 새삼 다시 한번 느끼게 되었어요. 심지어 팀과 조직 내에서 관리되는 시스템이나 프로그램이라면 더더욱요.

 그런데, 개인이 모든 책임을 지는 프로젝트보다는 동료나 선배와 협업하거나 실무에서 나의 요구사항을 반영하기 위해 간절한 마음으로 고민하거나 정리해보는 과정은 생각보다 값진 시간인 것 같아요. 내가 무언가를 주도했던 결과가 승인이 아닌 반려가 되더라도요! 물론 원하는 것을 얻어내지 못했다는 실망과 좌절도 있겠지만, 개인 프로젝트였다면 하지 않았을 문서 정리라던가, 동료를 설득하기 위해 침튀기는 토론 속에서 깊이 있는 소통 시간을 가지는 등 무언가를 시도했다는 것 그 자체로 지속 성장하는 증거가 되어준다고 생각합니다.




참고자료

profile
찍어 먹기보단 부어 먹기를 좋아하는 개발자

0개의 댓글