지난 배포 과정(DayOne-서비스를-배포해보자) 중 spring을 build하는 과정에서 에러가 발생했습니다.
build 과정에서 에러 정보를 자세하기 보기 위해서 ./gradlew build -i
로 진행한 결과 다음과 같았습니다.
테스트 과정에서 db가 생성되지 않아서 entityManagerFactory bean을 생성하는 것이 불가능해 발생한 예외상황이었습니다.
로컬 환경에서는 docker에 mysql 컨테이너를 실행하여 테스트를 진행해왔습니다. 하지만 ec2에서는 테스트 시에는 mysql에 접근할 수 없기 때문에 앞선 에러가 발생하는 것입니다. 그렇다면 테스트 시에 mysql를 접속할 수 있게 되면 build 과정에서 문제가 발생하지 않다는 것을 의미했습니다. 현재 한정된 자원에서 테스트를 동작시킬 수 있는 방안을 3가지 정도를 추려보았습니다.
현재 Dayone 서비스의 시스템 구조도는 다음과 같습니다.
운영 서버에서 db 서버로 통신하면 api를 처리하는 방식을 가지고 있습니다. 테스트를 진행하는 과정에서 mysql과 연동되면 build가 실패하는 일은 발생하지 않는 것이기에 DB서버에 테스트 용 database를 만드는 방안을 떠올렸습니다.
가장 걱정이 되는 부분은 현재 DB 서버의 경우 t2.mirco의 인스턴스를 이용하여 cpu 개수가 1개이기에 실사용자가 서비스를 이용하는 과정에서 새로운 버전의 배포가 발생해 build 과정이 병행된다면 cpu 사용률이 높아질 것으로 예측되어 사용자들의 요청 응답이 늦어지거나 혹은 build 과정이 오래 걸리는 문제가 발생할 것이라 생각했습니다. 그래서 해당 방법은 직면한 문제를 해결할 수 있으나 잠정적으로 성능 문제를 유발할 수 있다는 문제점을 인식했습니다.
Spring boot 같은 경우 기본적으로 H2 메모리 데이터베이스를 지원해주고 있습니다. 그렇기에 아래와 같이 의존성과 yml 설정을 추가한다면 간단하게 memory db를 구성할 수 있습니다.
build.gradle
dependencies {
implementation 'com.h2database:h2'
}
application-test.yml
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:dayone;MODE=MySQL
이와 같이 설정하고 build를 수행하면 끝인 방법입니다. 어떻게 보면 해당 방식이 따로 database를 만들거나 할 필요 없이 memory에서 처리를 하는 거다 보니 가장 효율적인 방안이 될 수 있습니다.
하지만 운영환경에서는 mysql을 이용하고 build 과정에서는 H2를 이용하는 것은 근본적으로 올바른 해결책은 아니라고 느껴졌습니다. 물론 H2를 mysql mode로 실행할 수 있지만 이 마저도 mysql과 완전히 동일하게 동작하는 것은 아니기에 언제든지 build는 성공하지만 실 운영에서는 실패하는 상황이 발생할 수 있습니다. 그래서 해당 방법은 보류했습니다.
테스트 컨테이너는 java 코드를 통해서 도커 컨테이너를 제어하여 통합테스트를 도와주는 라이브러리 입니다. 테스트 컨테이너를 이용하면 실제 db를 구축하지 않고도 도커 컨테이너를 활용하여 DB 인스턴스를 실행합니다. 이러한 점은 현재 리소스가 한정적인 제게 매력적인 방안으로 다가왔습니다.
하지만 테스트 컨테이너를 잘못 활용하게 되면 테스트 속도가 많이 늘어질 수 있습니다. 테스트 컨테이너는 기본적으로 각 테스트 마다 격리성을 보장하기 위해 컨테이너를 올렸다 내렸다 반복해 테스트 속도가 느린 문제가 있습니다. 실제로 테스트 컨테이너를 도입하기에 앞서서 로컬 환경에서 적용하고 실행한 결과보면 다음과 같습니다.
현재 결과만 보면 두 배정도 더 많은 시간이 걸리는 것처럼 보이지만 해당 정보에는 컨테이너가 생성되고 소멸되는 시간은 포함되지 않아 사실상 3배, 4배더 많은 시간이 걸리는 것으로 볼 수 있습니다.
테스트 컨테이너를 이용하는 방안과 h2를 이용하는 방안 중 속도가 느리더라도 운영환경과 동일한 테스트 환경을 구성하는 것이 더 중요하다고 판단을 했고 대신 테스트 컨테이너를 이용하는 방안을 고도화 하고자 했습니다.
테스트 컨테이너를 사용하면서 가장 큰 병목은 테스트 실행 전에 컨테이너를 생성하고 삭제하는 과정이었습니다. 이를 개선하기 위해 컨테이너를 매번 새로 띄우는 대신, 한 번만 생성한 후 내부 데이터를 초기화하는 방식으로 테스트 간의 격리성을 보장하고자 했습니다.
@Container
MySQLContainer mysql = new MySQLContainer("mysql:8.0.27")
.withDatabaseName("dayoneTest")
.withUsername("root")
.withPassword("mysql");
초기에는 위와 같이 테스트 컨테이너를 이용했다면 컨테이너를 한번만 띄우기 위해서는 아래와 같이 static를 활용했습니다.
private static final MySQLContainer MYSQL_CONTAINER;
static {
MYSQL_CONTAINER = new MySQLContainer("mysql:8.0.27")
.withDatabaseName("dayone")
.withUsername("root")
.withPassword("mysql");
MYSQL_CONTAINER.start();
}
그리고 테스트 간 격리성을 보장하고자 EntitiyManager를 활용해 테스트 진행 후 모든 테이블을 truncate하는 방식을 채택했습니다.
@Component
public class DataCleaner {
private static final String TRUNCATE_FORMAT = "TRUNCATE TABLE %s";
private static final String CAMEL_CASE_REGEX = "([a-z])([A-Z]+)";
private static final String SNAKE_CASE_REGEX = "$1_$2";
private List<String> tableNames;
@PersistenceContext
private EntityManager entityManager;
@PostConstruct
public void findDatabaseTableNames() {
tableNames = entityManager.getMetamodel().getEntities().stream()
.filter(DataCleaner::isEntityClass)
.map(DataCleaner::convertCamelCaseToSnakeCase)
.collect(Collectors.toList());
}
private static boolean isEntityClass(final EntityType<?> e) {
return e.getJavaType().getAnnotation(Entity.class) != null;
}
private static String convertCamelCaseToSnakeCase(final EntityType<?> e) {
return e.getName().replaceAll(CAMEL_CASE_REGEX, SNAKE_CASE_REGEX).toLowerCase();
}
@Transactional
public void clear() {
entityManager.flush();
entityManager.clear();
truncate();
}
private void truncate() {
for (String tableName : tableNames) {
entityManager.createNativeQuery(String.format(TRUNCATE_FORMAT, tableName))
.executeUpdate();
}
}
}
덕분에 비교적 테스트 속도가 처음에 컨테이너가 생성되는 시간만 제외하고는 기존의 테스트 속도와 동일하게 처리되었습니다.
테스트 컨테이너를 적용하고 ec2에서 build를 하는데 다음과 같은 에러가 발생했습니다.
해당 에러를 찾아보니 https://www.baeldung.com/java-exceptionininitializererror 글에 접근할 수 있었고 다음과 같이 설명되어 있습니다.
The ExceptionInInitializerError indicates that an unexpected exception has occurred in a static initializer.
static으로 초기화 하는 과정에서 예상치못한 에러가 발생했다는 것을 의미했으며 테스트 컨테이너가 생성하는 과정에서 문제가 발생했다는 것을 유추할 수 있었습니다. 원인을 분석한 결과, 테스트 컨테이너가 생성되는 과정에서 Docker에 접근하지 못해 컨테이너가 정상적으로 생성되지 않은 것이 문제의 원인이었습니다.
// 도커 설치
sudo apt install docker.io
// ec2 user를 docker를 접근할 수 있는 권한 주기
sudo chmod 666 /var/run/docker.sock
테스트 컨테이너를 이용하기 위해서는 도커가 필요하기 때문에 먼저 도커를 다운로드 했습니다. 도커를 다운로드 한 이후에는 EC2 사용자가 docker에 접근할 수 있게 권한을 부여해주어야 합니다.
마지막으로 다시 build를 실행하면 다음과 같은 결과를 얻을 수 있습니다.
이번에 운영환경과 동일한 테스트 환경을 구축하는 과정에서 새로운 기술인 테스트 컨테이너를 처음 도입해 보았습니다. 일단은 직면한 문제를 빠르게 해결하기 위해서 테스트 컨테이너 사용법만 익히고 내부로는 어떻게 동작을 하는지 파악하지 못한 점이 아쉬웠던 것 같고 프로젝트가 조금 여유로워 질 때 한번 deep dive를 해보고자 합니다. 그래도 build 과정이 원활하게 동작한 것은 큰 다행입니다.