들어가며
최근 회사에서 자진해서 욕심내어 했던 사례를 하나 소개합니다.
테스트 컨테이너를 도입하여 실제 서비스 DB인 postgresql로 테스트를 할 수 있도록 하는 일이었습니다.
프로젝트의 테스트 DB가 h2 인메모리로 사용 중이었는데요. 도입하기 매우 가볍고 빠르다는 장점이 있었지만, 여러 단점들도 많았습니다.
대표적으로 있었던 단점이 이런것들이 있었는데요.
- H2에서 테스트할 수 있는 쿼리만 사용할 수 있음.
- `ON CONFLICT` 등의 쿼리는 사용할 수 없음.
- `DISTINCT`을 사용할 때 H2에서는 동작하던 쿼리가 Postgresql에서는 오류를 일으키기도 함.
- Postgresql에서 사용하던 설정이나 컬럼 등을 사용할 수 없음.
위의 이유 때문에 테스트를 신뢰할 수 없는 경우도 종종 생겼습니다.
매번 이미 테스트를 했는데도 클라우드 환경에서 동작이 문제가 있는지 확인해야하고 특정 쿼리를 사용하면 간단하게 풀릴 수 있는 문제를 해결하기 위해 성능이 저하되거나 코드가 복잡해지는 등의 문제를 겪어서 테스트 컨테이너를 도입하였습니다.
저도 공식문서와 다른 분들이 작성해주신 블로그를 가장 많이 참고하며 구현하였습니다. 이에 대한 설명은 이미 다른 블로그에서 많이 다루었기 때문에 저는 새로운 인사이트를 얻게된다면 추후 새로이 글을 작성해보겠습니다.
- Testcontainers for Java
- 저는 Kotlin 코드로 바꾸어 진행하였습니다. 이전에 H2 DB를 연결시켜주었던 대신 정의한 Test container 의 url, port 등을 설정해주면 되었습니다.
참고: 테스트 환경
- Kotlin/Spring
- Kotest 단위 테스트를 위해 사용하고 있는 프레임워크
- Cucumber API 테스트를 위해 사용하고 있는 프레임워크
- Exposed 프레임워크
테스트 컨테이너를 도입하며 발생한 문제...
생각보다 도입하는 것은 어렵지 않았기 때문에 빠르게 `Postgresql` 과 `Redis` 에 적용하였는데요.
근데 예상치못하게 도입 이후 발생한 문제들이 있었습니다.
- 동시성 문제 : 하나의 DB 컨테이너를 띄우고 그 안에서 계속 테스트를 하다보니 데이터의 일관성이 깨지는 문제점이 있었습니다.
- 여러건의 데이터를 조회하는 경우 기대 데이터가 유효하지 않아졌습니다. (데이터 5개를 기대하고 페이지를 조회했을 때, 훨씬 많은 데이터가 조회될 수 있음)
- 난수로 생성하는 데이터들에서 충돌이 발생할 확률이 증가하였습니다.
- 매번 테이블이 초기화 되는 것을 상정하고 상수로 생성했던 데이터들의 수정이 필요했습니다.
- 테스트 시간 지연 : H2 로 테스트하는 것과는 달리 Postgresql로 테스트를 했을 때, 테스트 시간이 훨씬 지연되었습니다.
- 전체 테스트 완료시각까지 지연되며 개발속도/배포 속도가 느려짐
- 연쇄적으로 Github workflow 가 자동으로 취소기도함 (기본적으로 Github workflow timeout은 30분입니다)
테스트를 빠르게 만드는 방법들
위의 문제를 체감하며 팀 내에 문제를 어떻게 해결하면 좋을지 상의했는데요.
거기서 받은 몇가지 제안과 검색을 통해 다음의 방법들을 시도해보았습니다.
테스트 컨테이너 스펙 조정
테스트 컨테이너도 도커 컨테이너기 때문에 띄울때 커맨드로 추가로 옵션을 줄 수 있었습니다.
아래는 예시 코드입니다.
public PostgreSQLContainer container = new PostgreSQLContainer<>()
.withCommand("postgres -c max_prepared_transactions=10");
아래는 직접 설정한 항목들입니다. 아래 말고 다른 커멘드를 활용해서 효율적으로 구성할 수 있습니다.
- fsync=off: 파일 동기화를 비활성화하여 데이터베이스의 쓰기 성능을 높입니다. (일부 데이터 손실 가능성 있습니다)
- synchronous_commit=off: 커밋 시 동기화를 비활성화하여 성능을 높입니다. (일부 데이터 손실 가능성 있습니다)
- full_page_writes=off: 모든 페이지를 다시 기록하는 것을 비활성화하여 성능을 높입니다. (일부 데이터 손실 가능성 있습니다)
- shared_buffers=1024MB: 공유 버퍼 크기를 1024MB로 설정하여 메모리 성능을 조정합니다.
- work_mem=1024MB: 쿼리 정렬 및 해시 연산에 사용할 메모리를 1024MB로 설정합니다.
- max_connections=100: 최대 연결 수를 100개로 설정합니다.
- autovacuum=off: autovacuum 을 비활성화합니다.
병렬작업을 하여 작업 속도를 높입니다.
- max_worker_processes=5: 최대 워커 프로세스 수를 5개로 설정합니다.
- max_parallel_workers=N: 최대 병렬 작업 프로세스 수를 N개로 설정합니다.
- max_wal_size=128MB: WAL(Write-Ahead Logging) 최대 크기를 128MB로 설정합니다.
- checkpoint_timeout=30min: 체크포인트 간격을 30분으로 설정합니다.
대량 데이터 입력 개선을 위한 Exposed `batchInsert` 도입
테스트 속도를 개선하기 위해 리팩토링했던 부분 중 하나입니다.
테스트의 Given 부분에서 기존에는 반복문을 돌며 DB에 insert를 해주었는데요. 이에 대해서 insert를 한꺼번에 하도록 수정하였습니다.
기존에 테스트를 위한 라이브러리로 JetBrain의 Exposed 를 사용하고 있었는데, 여기서 batchInsert 라는 함수가 있습니다.
SomeTable.batchInsert(dataList) { data ->
this[SomeTable.column1] = data.value1
this[SomeTable.column2] = data.value2
this[SomeTable.column3] = data.value3
}
이를 잘 사용하면 코드 정돈에도 유리하고, 속도가 개선되는 효과를 얻을 수 있습니다.
다만 주의해야할 것은 exposed에서 지원하는 이 함수는, 실제로 DB에 여러건을 insert하는 쿼리를 실행시키는 것이 아니라 한 트랜젝션에서 실행되는 것이라는 점입니다.
NOTE: The batchInsert function will still create multiple INSERT statements when interacting with your database. You most likely want to couple this with the rewriteBatchedInserts=true (or rewriteBatchedStatements=true) option of your relevant JDBC driver, which will convert those into a single bulkInsert. You can find the documentation for this option for MySQL here and PostgreSQL here.
출처 : https://stackoverflow.com/questions/57612163/kotlin-exposed-batch-insert-not-working-as-documented
컨테이너 생성시 `rewriteBatchedInserts` 옵션을 켜면 batchInsert를 효율적으로 할 수 있습니다. 참고링크
추가로 `shouldReturnGeneratedValues` 옵션을 false 로 변경하여 반환되는 데이터를 받지 않음으로서 테스트 시간을 단축할 수 있었습니다. (UUID를 사용하였음) 참고링크
- 당시 측정한 사항
- 평균 19%가량 개선됨을 확인했습니다.
- 특정 테스트의 경우 다음의 변화를 보였습니다.
- GetAllXXXByXXXSpec : 615 ms → 498 ms
- XXXUpdateXXXSpec : 492 ms → 401 ms
테스트 환경 설정을 통해 테스트를 병렬적으로 실행
Kotest에서 제공하는 병렬 실행 기능을 최대한 활용해 테스트 시간을 줄일 수 있습니다.
아래 코드는 예제 코드입니다.
object TestProjectConfig : AbstractProjectConfig() {
// 각 Spec 마다 독립적인 인스턴스 생성 (테스트 간 간섭 최소화)
override val isolationMode: IsolationMode = IsolationMode.InstancePerSpec
// 병렬 실행 시 최대 10개의 테스트를 동시에 실행하도록 설정
override val parallelism: Int = 10
// 동시에 실행 가능한 테스트 스펙의 최대 개수를 제한
@ExperimentalKotest
override val concurrentSpecs: Int = 10
// 각 스펙 내에서 동시에 실행 가능한 테스트를 제한
@ExperimentalKotest
override val concurrentTests: Int = 5
}
- isolationMode: 각 테스트 인스턴스가 독립적으로 실행될지, 혹은 동일한 인스턴스를 공유할지를 결정합니다.
- InstancePerLeaf 옵션은 각 "leaf"(테스트 케이스)마다 독립적인 인스턴스를 생성하여 실행하므로 테스트 간에 영향을 주지 않는 것이 특징입니다.
- SingleInstance 는 모든 인스턴스를 싱글톤으로 생성합니다. deprecated 되었습니다.
- InstancePerSpec 으로 설명하면 전테 스펙(테스트 클래스) 내의 모든 테스트에 대해 하나의 인스턴스를 생성합니다.
- InstancePerTest 로 설정하면 각 테스트 함수마다 새로운 인스턴스를 생성해 독립성을 보장합니다. 테스트 간의 의존성 문제가 발생하지 않도록 하고, 테스트 데이터를 격리하는 데 유리합니다.
- parallelism 병렬 실행시 최대 몇개의 테스트를 동시에 실행하도록 설정할 것인지 결정합니다.
- concurrentSpecs 동시에 실행 가능한 테스트 스펙의 최대 개수를 제한합니다.
- concurrentTests 스펙 내에서 동시에 실행 가능한 테스트 개수를 제한합니다.
이를 설정할 때 주의해야할 것이 있습니다.
실제로 DB데이터를 조회하거나 삽입하는 테스트에서는 보수적으로 설정하는 것이 좋다는 것입니다.
다만 Mock을 사용하는 테스트는 병렬적으로 실행되어도 문제가 없으므로 해당 테스트가 실행될 환경에 맞추어 증가시켜두면 시간을 절약할 수 있습니다.
기타 진행했던/고려했던 방법들
위에 작성한 방법 외에도 리팩토링을 굉장히 많이 했습니다. 반복되는 코드를 정리하고, 속도를 위해 불필요한 테스트 는 물론 불필요한 테스트 데이터 삭제하였습니다.
그 외에도 트랜잭션을 활용하여 테스트 속도를 단축할 수 있다는 아이디어가 있었지만,
이에 대해서는 확실히 개선된다는 테스트 해보지 못하였기 때문에 메모로만 남깁니다.
- 트랜잭션 롤백 사용
- transaction 블록 안에서 데이터 추가, 테스트 진행 후 rollback을 호출하여 데이터를 초기화합니다.
- 데이터를 삭제하는 대신 사용하면 좀 더 효율적일 수 있습니다.
마무리하며...
이 글을 통해 테스트 컨테이너 도입 후 느려진 테스트의 문제점을 체크하고, 개선한 내용을 정리해보았는데요.
테스트 컨테이너 도입은 실제 서비스 환경과 최대한 일치하는 테스트 환경을 제공하여 테스트의 신뢰도를 향상시킬 수 있었고, 도입 후 디버깅/검토하는 과정에서 테스트 속도의 중요성을 새삼 깨닫게 되었습니다.
테스트 속도는 서비스 개발과 배포의 전반적인 속도에 직접적인 영향을 미칩니다.
따라서 버그를 최소화하는 테스트와 빠른 개발 속도 간의 균형을 잘 맞추는 것이 매우 중요하다고 생각합니다.
그럼 이 글은 여기서 마무리하도록 하겠습니다 👋