TestContainer 로 멱등성있는 integration test 환경 구축하기

Riiid Teamblog
Riiid Teamblog KR
Published in
13 min readMar 5, 2021

By 박성은

박성은님은 Riiid의 Back-end Engineer로 Realtor, Push Notification Server 업무를 담당하고 있습니다.

테스트 환경과 멱등성

테스트 환경은 프로젝트 설정을 할 때 가장 중요한 부분 중 하나입니다.

가장 어렵고 귀찮은 작업이기도 하지만 처음 한번만 고생하면 추후 테스트 작성 시에 걱정 없이 아주 깔끔한 테스트 코드를 짤 수 있게 됩니다. 하지만 그만큼 프로젝트 환경 설정에서 가장 많은 시간을 들이게 되고, 많은 시행착오를 겪는 구간 중 하나라고 볼 수 있습니다.

테스트 환경을 만드는 과정에서 신경써야 할 부분은 다양하겠지만 그중에서도 특히나 주의해야할 부분 중 하나는 바로 멱등성입니다. 멱등성을 간과한 경우에는 예상치 못한 상황에서 다른 테스트 혹은 외부 모듈로 인해 테스트가 간헐적으로 실패할 수 있으며, 이 경우 실패 구간을 찾기 매우 어렵다는 특징을 가지고 있기 때문입니다.

여기서 멱등성(idempotent)이란?

연산을 여러 번 적용하더라도 결과가 바뀌지 않는 성질을 뜻합니다.

쉽게 말해서 여러 번 함수를 실행하더라도 늘 같은 결과가 나와야 한다는 의미입니다.

멱등성은 특히나 테스트에서 매우 중요한 개념으로 자리잡고 있는데, 바로 멱등성을 지키는 것이 테스트 전체의 생산성에 아주 큰 영향을 주기 때문입니다.

멱등성을 간과했던 사례

작년 신규 서비스 출시 준비로 모두가 바쁘게 개발을 하고 있었던 때의 일이었습니다.

위기가 찾아오는 순간이 늘 그렇듯 다들 매우 바쁘게 일하고 있던 순간에 갑자기 모든 테스트가 깨진 적이 있었습니다. 다만 이상한 점은 코드를 추가하거나 변경한 것이 전혀 없는데도 갑자기 모든 CI 와 테스트가 깨지는 점이었습니다.

다들 정신없이 일하고 있는 와중이었지만 깨진 테스트를 잡기 위해 여러 구성원이 모여 몇 시간동안 분석하였고, 그 결과 원인은 예상치도 못한 곳에 있었습니다.

원인은 바로, 다른 micro service 컴포넌트를 테스트에서 실제로 참조하고 있었는데 중간에 해당 컴포넌트의 설정이 바뀐 것이었습니다.

문제가 된 부분은 당시 바쁘게 프로젝트 설정을 하면서 다른 컴포넌트를 mocking 하기보다 직접 dev 환경의 서버를 호출하는 것이 쉽겠다는 판단 하에 작업했던 부분이었습니다. 당시에는 큰 문제가 생기지 않을 것이라 예상하고 작업하였으나 결국 문제가 발생하게 되었고, 급하게 dev 환경에 올라가 있는 해당 컴포넌트 서버를 롤백하여 해결하였습니다.

하지만 그 이후에도 해당 컴포넌트 서버에 변경이 생길때마다 잘 돌아가던 테스트가 깨지기 시작하였고 그로 인해 팀의 생산성이 크게 저하되는 경험을 하게 되었습니다.

로컬 환경에서 잘 실행되던 테스트가 누군가의 환경이나 CI 에서는 깨지기 시작하니 모두들 테스트를 신뢰할 수 없게되고, 테스트를 실행하는 것에 대한 극심한 피로감을 겪기 시작했습니다.

결국 우여곡절을 거쳐 출시하기는 했지만 추후 망가진 테스트를 고치는 데에 기존보다 훨씬 더 많은 시간과 리소스를 쏟아야만 했습니다.

원인과 해결방안

위의 사례에서 원인을 꼽아본다면 바로 dev 환경의 micro service component 가 매번 일정하게 응답을 내려줄 것이라고 가정한 점이라고 볼 수 있습니다. 바로 테스트 환경에서 외부 모듈에 대한 멱등성이 유지되길 기대했던 것이죠.

이처럼 흔히 멱등성이 깨지기 쉬운 구간 중 하나는 바로 외부 모듈이라고 볼 수 있습니다.

그리고 이를 해결하기 위해서는 테스트 환경 구축과 코드 작성에 있어 멱등성을 반드시 고려해야 합니다.

테스트의 멱등성에 관련한 모든 내용을 다루기에는 너무나도 방대하기 때문에, 여기서는 실제로 뤼이드에서 TestContainer 를 이용해서 외부 모듈에 대한 멱등성을 유지하는 방법을 소개하려고 합니다.

DB로 알아보는 Integration Test 방법들

먼저 테스트 환경을 위해 가장 많이 쓰이는 외부 의존성을 떠올리자면 가장 먼저 떠오르는 것은 무엇보다 DB 라고 할 수 있습니다. DB를 테스트 환경에서 실행하는 방법은 여러가지가 있습니다.

1. Local에 실제 DB 를 띄우기

Local 환경에 실제 DB 를 띄워서 테스트함으로써 실제와 거의 유사한 환경에서 테스트할 수 있지만 동시에 여러 테스트가 이루어지거나 테스트가 끝났음에도 테스트용 데이터가 남아있는 문제들로 인하여 멱등성 관리가 매우 어렵습니다. 특히 DDL 을 테스트하는 경우 매번 drop 을 해줘야 하는 부담 또한 존재하고 있습니다.

2. in-memory DB 활용하기

주로 H2 와 같은 in-memory DB 를 사용하고 ORM 을 통해서 특정 DB 의 종속성을 해결합니다. 매우 빠르게 동작하는 장점이 있지만, 특정 DB 에 특화된 기능을 테스트하거나 DDL 을 명시할 때 실제 동작과는 다른 SQL 을 작성해야한다는 문제가 있습니다.

3. 사용하고자 하는 DB 의 Embedded Library 사용하기

in-memory DB 를 사용하였을 때의 문제점 중 하나인, 특정 DB 에 종속적인 기능을 테스트하기 어려운 이슈를 해결할 수 있는 방법입니다. 일반적으로 실제 DB 코드를 경량화해서 제공하며 in-memory DB 와 거의 동일한 방식으로 구현할 수 있습니다.

다만 DB 별로 Embedded Library 가 존재하지 않는 경우도 있고 특정 버전 혹은 특정 OS 가 지원되지 않기도 합니다. 또한 DB 별로 라이브러리가 다르기 때문에 DB 별로 구현을 다르게 해야 하는 문제점도 존재합니다.

MongoDB : https://github.com/flapdoodle-oss/de.flapdoodle.embed.mongo

Mysql : https://github.com/wix/wix-embedded-mysql

Postgresql : https://github.com/zonkyio/embedded-postgres

4. Docker Compose 활용하기

Docker가 나온 이후로 in-memory DB 를 지원하지 않는 경우에도 도커 이미지만 있다면 테스트 환경을 구축할 수 있게 되었습니다. 기존에는 누군가 똑같이 포팅한 in-memory DB 가 있어야만 그 DB 를 테스트환경에 넣을 수 있었지만 이제는 컨테이너 이미지만으로도 production 과 유사한 환경에서 테스트를 할 수 있습니다.

그야말로 완벽한 솔루션처럼 보이지만 docker-compose 파일을 따로 관리해야 하고, docker 컨테이너와의 통신을 설정하기 어렵다는 불편함이 여전히 존재하고 있습니다. 예를 들어 docker-compose 에서 port 를 바꾼다면 코드에서도 한번 더 port를 바꿔줘야 합니다. 이는 결국 random port 를 통한 local 포트 충돌 방지 및 parallel 테스트를 매우 하기 어렵게 만듭니다.

5. TestContainer 활용하기

사실 동작 원리는 Docker Compose 와 다를 바 없지만 docker-compose 와 같은 외부 설정 파일 없이 Java 언어만으로 docker container 를 활용한 테스트 환경을 설정할 수 있습니다.

특히 compose 를 활용할때에 어려운 부분인 container 와의 통신 또한 언어 레벨에서 처리할 수 있습니다.

따라서 container 에 변경사항이 생기더라도 여러 곳을 변경할 필요없이 하나의 코드로 관리할 수 있습니다.

TestContainer 란?

https://github.com/testcontainers/testcontainers-java/

Testcontainers is a Java 8 library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

쉽게 말하면 테스트를 위해 Docker Container 를 실행시켜주는 자바 라이브러리입니다.

그럼 바로 예시부터 살펴보겠습니다. (여기서는 Kotlin, Spring, Gradle kotlin DSL 환경을 기준으로 작성하였습니다.)

Postgresql TestContainer Example

가장 먼저 build.gradle.kts 에 의존성을 추가합니다.

dependencies {
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
}

그리고 테스트 모듈에 TestContainer 를 위한 설정을 작성합니다.

@Component
class PostgresqlTestContainer {

// 사실 테스트가 모두 진행된 후에는 TestContainer를 모니터링하고 있던
// Ryuk Container가 알아서 container를 종료시킵니다.
// 실제로 테스트를 실행시키고 docker ps를 해보시면 2개의 컨테이너가 실행되는 것을 확인할 수 있습니다.
@PreDestroy
fun stop() {
POSTGRES_CONTAINER.stop()
}
companion object {
@Container
@JvmStatic
val POSTGRES_CONTAINER: PostgreSQLContainer<*> = PostgreSQLContainer<Nothing>("postgres:alpine")
.apply { withDatabaseName(DATABASE_NAME) }
.apply { withUsername(USERNAME) }
.apply { withPassword(PASSWORD) }
.apply { start() }
const val DATABASE_NAME: String = "database_name"
const val USERNAME: String = "root"
const val PASSWORD: String = "password"
}
}

공식 document 에 나온 내용과 다르다고 생각하시는 분도 있으실 수 있습니다.(https://www.testcontainers.org/test_framework_integration/junit_5/)

여기서는 Spring 과 integration 하기 위해서 직접 생명주기를 제어하는 방식으로 처리하였습니다. (https://www.testcontainers.org/test_framework_integration/manual_lifecycle_control/)

이렇게 처리할 경우, abstract class 를 만들어서 테스트 클래스 생성마다 넣어주거나 매번 container 를 선언할 필요 없이 test suite 와 생명주기가 같게 설정할 수 있습니다.

마지막으로 가장 중요한 부분으로 DataSource 를 설정해줍니다.

@Configuration
class TestDataSource {

// 간혹 설정에 따라 dataSource가 testContainer보다 먼저 bean이 생성되는 경우가 있어 DependsOn으로 설정하였습니다.
@Bean
@DependsOn("postgresqlTestContainer")
fun dataSource(): DataSource =
DataSourceBuilder.create()
.url(
"jdbc:postgresql://localhost:" +
"${PostgresqlTestContainer.POSTGRES_CONTAINER.getMappedPort(5432)}" +
"/${PostgresqlTestContainer.DATABASE_NAME}"
)
.driverClassName("org.postgresql.Driver")
.username(PostgresqlTestContainer.USERNAME)
.password(PostgresqlTestContainer.PASSWORD)
.build()
}

위 방식이 기존 in-memory DB 나 docker-compose 와 다른 점은 DataSource 를 설정할 때 미리 정해둔 port 로 접근하는 것이 아니라, test container 가 실행된 random port 로 접근하여 local 포트간의 충돌을 방지하고 parallel test 를 가능하게 하는 점입니다.

여기서 또 한가지 중요한 것은 동일한 방식으로 다른 테스트 환경도 자유롭게 추가할 수 있다는 점입니다.

Kafka TestContainer Example

현재 테스트환경에 Kafka를 한번 추가해보겠습니다.

@Component
class KafkaTestContainer {
@PreDestroy
fun stop() {
KAFKA_CONTAINER.stop()
}
companion object {
@Container
@JvmStatic
val KAFKA_CONTAINER: KafkaContainer = KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:5.4.3"))
.apply { start() }
}
}

TestContainer 로 띄운 Kafka 에 topic을 생성해보도록 하겠습니다.

@Configuration
class KafkaTestConfiguration {
@Bean
fun kafkaTestAdmin(): KafkaAdmin =
mapOf(
AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG to KafkaTestContainer.KAFKA_CONTAINER.bootstrapServers
).let { config -> KafkaAdmin(config) }
@Bean
fun testTopic(): NewTopic = TopicBuilder.name("test.topic")
.partitions(1)
.build()
}

아까와 동일하게 TestContainer 설정에서 config 를 가져와서 쉽게 설정할 수 있습니다.

만약 TestContainer 설정을 바꾸더라도 코드로 설정을 참조하고 관리할 수 있기 때문에 테스트 환경설정을 온전히 Java 혹은 kotlin 코드로만 관리할 수 있습니다.

마치며

여기서는 TestContainer 를 이용하여 parallel 한 테스트 환경에서도 외부 모듈에 대한 멱등성을 유지하며 테스트 코드를 작성할 수 있도록 하는 방법을 소개드렸습니다.

이처럼 멱등성이 유지되는 테스트 설정은 간헐적인 테스트 실패를 방지함으로써 생산성을 높여줍니다.

여러가지 시행착오를 겪은 이후, 현재 팀 내의 테스트는 모두 멱등성을 유지하며 작성되고 있으며 테스트 환경 뿐만 아니라 코드의 품질을 올리기 위한 다양한 방법들을 찾아 개선하고 있습니다.

--

--

Riiid Teamblog
Riiid Teamblog KR

교육 현장에서 실제 학습 효과를 입증하고 그 영향력을 확대하고 있는 뤼이드의 AI 기술 연구, 엔지니어링, 이를 가장 효율적으로 비즈니스화 하는 AIOps 및 개발 문화 등에 대한 실질적인 이야기를 나눕니다.