개발 운영 환경과 비슷한 로컬 환경 구축하기 (feat. TestContainer)

현재 진행하고 있는 "오디"프로젝트는 다음과 같이 3가지 환경으로 구성되어 있다.

각 환경에 대해 간단히 설명하자면

Local 환경은 각자 기능을 개발 해 PR을 보내는 용도.

Dev 환경은 EC2로 애플리케이션이 실행되고 있어

Local 환경에서 개발된 PR을 Merge하면 Dev 서버로 배포하여 클라이언트와 서버가 기능 동작을 테스트하는 용도.

Prod 환경도 EC2로 애플리케이션이 실행되어 있고 실제 운영을 위한 아키텍처로 구성이 되어 있으며

Dev에서 테스트한 기능들이 문제가 없을 때 배포하여 실제 서비스를 하는 용도로 사용하고 있다.

 

문제 상황

그리고 각 환경들은 다음과 같이 각자 다른 DB를 사용하고 있다.

현재 필자의 프로젝트는 스키마 변경 사항이 생길 때마다 직접 각 환경의 DB에 들어가서 수동으로 스키마를 관리 해줘야하는 것이 불편해

DB 마이그레이션 툴인 FlyWay를 사용해 스키마 변경에 대해 스키마 파일로 형상관리를 하고 이를 통해 마이그레이션하고 있다.

하지만 JPA를 사용함으로써 RDB에 종속적이지 않게 쿼리는 작성하지만

개발, 운영 환경의 스키마 변경을 위해서 로컬 환경에서 아래와 같이 DB에 종속적인 쿼리 DDL, DML을 작성하게 된다.

바로 이 때 문제가 발생했다.

H2를 다음과 같이 MYSQL 모드로 실행하더라도 H2에서는 호환이 되지 않는 쿼리들이 존재했다.

jdbc:h2:mem:database?serverTimezone=Asia/Seoul;MODE=MYSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE

이 문제를 해결하기 위해서 mysql 전용 주석인 /*! ~ *!로 해결을 해보려 했지만

버전 파일들이 늘어나다가 어느 순간부터 V3의 주석은 읽어 쿼리를 실행해주지만, V5의 주석은 읽지 못하는 문제가 발생해

ddl-auto가 validate인 dev 서버가 다운되는 일이 생겼다.

 

로컬에서 mysql을 띄워 테스트할 때는 또 모든 버전 파일의 주석을 읽어 문제없이 실행이 됐는데

배포 후에는 주석을 읽지 못하는 문제를 추측하기에는 Flyway가 호환되는 MySQL의 버전이 달라서 일거라고 추측이 되기도한다.

 

하여튼, 이러한 문제들로 인해 로컬 환경과 개발, 운영 환경을 통일하기로 했다.

 

우선 문제가 발생한 건 로컬 서버가 사용하는 DB와 개발, 운영 서버의 DB가 달라서 발생한 문제이기에

로컬에서도 H2 대신 MySQL을 사용하기로 했다.

또, "내 컴퓨터에선 되는데? ㅋㅋ"를 방지하기 위해 팀원들과도 동일한 로컬 환경을 구성하고자 했다.

이를 위해서 팀원별로 각자 MySQL을 세팅해야하는 번거로움이 있었는데, 이 번거로움을 해결해소 하면서

환경을 통일하는 방법들에 대해 포스팅하고자 한다.


Test Containers (테스트 컨테이너)란?

https://testcontainers.com/guides/introducing-testcontainers/

 

What is Testcontainers, and why should you use it?

This guide will introduce you to Testcontainers and explains what kind of problems Testcontainers solves.

testcontainers.com

공식 문서에서는 위와 같이 설명을 하고 있는데 간단히 요약하자면

Test Containsers는 Docker 컨테이너로 실제 환경에서 사용하는 서비스를 실행 시켜

실제 환경과 동일한 유형의 서비스와 통신하는 테스트를 작성할 수 있도록 도와주는 테스트 라이브러리이다.

 

Test Containsers 기반 테스트를 실행하기 위한 유일한 요구사항은 Docker Desktop이 설치되어 실행 중이여야 한다는 것이다.

이 외에는 개발자가 직접 컨테이너를 실행하고 종료할 필요 없이

Test Containsers 라이브러리가 개발자가 설정한 데로 알아서 컨테이너 생명주기를 관리해준다.

 

테스트 컨테이너 적용하기

테스트 컨테이너를 적용하는 방법은 간단하다.

 

우선 테스트 컨테이너 라이브러리를 사용하기 위한 아래 의존성들을 추가해주자.

(의존성은 해당 공식문서를 참고했다. https://java.testcontainers.org/ )

implementation 'org.testcontainers:testcontainers-bom:1.20.2'

testImplementation 'org.testcontainers:mysql'
testImplementation "org.testcontainers:junit-jupiter"

 

그리고 애플리케이션 용 DB로 사용할 컨테이너냐, 테스트용으로 사용할 컨테이너냐에 따라 코드가 다를 수 있는데

자세한 건 다음 목차들에서 설명하고 2가지 방법으로 컨테이너를 적용할 수 있다.

 

컨테이너 객체 생성 후 Bean으로 등록해 테스트 컨테이너를 적용하는 방법

 

다음과 같이 설정 클래스를 추가해 DataSourse를 설정데로 Bean 등록하는 방식이다.

코드 레벨에서 DataSourse 설정을 했기에 application.yml에 추가적인 Datasource 설정은 필요 없다.

 

application.yml 설정으로 테스트 컨테이너를 적용하는 방법

 

다음과 같이 설정 클래스를 추가로 만들어줄 필요 없이 yml상으로 Datasource를 설정해주면 된다. 아주 간단하다. 👍🏻

url은 jdbc:tc:도커이미지://호스트/데이터베이스이름 형식으로 작성해야 한다.

host를 생략도 가능한데 생략하면 포트는 랜덤하게 실행되며, tc 키워드가 있어야 랜덤으로 생성된 포트와 연결이 된다.

 

 

application.yml 설정이 테스트 컨테이너를 적용하기 아주 간단하긴 하지만 세부적인 설정을 할 순 없다.

필자의 경우에는 시간과 관련된 중요한 로직들이 있어 timzone 설정을 한국 시간으로 설정해줘야 했기 때문에

세부적인 설정이 필요해 컨테이너 객체를 생성하는 방법을 사용했다.


로컬 애플리케이션 DB를 MySQL로 통일하기

로컬 애플리케이션의 DB에서도 테스트 컨테이너를 사용할 수 있다.

하지만 테스트 컨테이너는 마운트가 지원되지 않아 보이는 것으로 보아

로컬 애플리케이션 재시작 시에도 DB 데이터를 유지하고 싶다면 적합하진 않아보인다.

그래서 이름부터 테스트 컨테이너인가 싶기도 하다.

(이 문제 외에 추가로 문제가 있다면 알려주세요 .. 🙇🏼‍♂️)

 

하지만 H2 처럼 로컬 애플리케이션 재시작 시에 DB 데이터를 초기화하고 싶다면 테스트 컨테이너를 적용하는게

DB 환경을 일치하는데 아주 쉽고 간편하다.

 

필자는 Docker Compose + docker gradle을 사용한 DB 연결과 테스트 컨테이너를 사용한 DB 연결

2가지 방법에 대해 설명하려한다.

 

Docker Compsoe + docker gradle을 사용한 DB 연결

이 방식을 사용하면 팀원들간 설정이 공유된다는 점이 편한데

애플리케이션 실행 전 마다 항상 컨테이너를 CLI로 실행시켜줘야한다는 점은 불편하다.

필자는 이를 docker gradle을 사용해 인텔리제이의 애플리케이션 실행 버튼을 클릭 하면

docker compose up을 자동으로 해주고 애플리케이션이 실행되도록 할 것이다.

 

1. 프로젝트 루트 경로에 다음과 같이 도커 컴포즈 파일을 추가해준다.

(세부 설정은 필요에 맞게 하면된다.)

// docker-compose-local.yml

services:
  db:
    image: mysql:8.0.35
    container_name: mysql-local-db
    restart: always
    ports:
      - "53306:3306"
    environment:
      MYSQL_ROOT_HOST: '%'
      MYSQL_ROOT_PASSWORD: 1234
      MYSQL_DATABASE: ody

 

2. docker-gradle 플러그인 추가 ( 플러그인 저장소 링크  : https://github.com/palantir/gradle-docker)

plugins {
	... 생략
    id 'com.palantir.docker-compose' version '0.36.0'
}

dockerCompose {
    dockerComposeFile 'docker-compose-local.yml'
}
... 생략

 

이제 인텔리제이의 애플리케이션 실행 구성을 편집해 실행 전 마다 해당 플러그인의 task가 실행되도록 설정할 것이다.

3. 인텔리제이 상단  바 -> 실행 구성 편집 클릭

 

4. 옵션 수정 클릭 -> 실행 작업 전에 추가 클릭

 

5. + 버튼 클릭 후 Gradle 작업 실행 클릭

 

6. 다음과 같이 작업 입력

 

7. gradle 작업이 추가된 것을 확인하면 적용 클릭

 

이제 애플리케이션을 실행할 때마다 알아서 docker compose up이 실행되고 애플리케이션이 실행된다 !

 

❗️FlyWay를 사용 시 주의 사항

컨테이너를 띄워 DB를 연결하는데 FlyWay로 스키마를 초기화할 경우

FlyWay가 해당 컨테이너에 접근을 못하는 문제가 있다.

Exception in thread "main" org.flywaydb.core.api.FlywayException: Unable to obtain Jdbc connection from DataSource|

 

다음과 같이 yml 설정을 추가해주면 Flyway가 컨테이너 DB에 접근이 가능하게 된다.

spring:
  flyway:
    url: jdbc:mysql://localhost:53306/ody
    user: root
    password: 1234
    connect-retries: 30
  jpa:
    hibernate:
      ddl-auto: create-drop

connect-retires를 추가해 데이터베이스에 연결을 시도할 때의 최대 재시도 횟수를 지정해줬다.

해당 옵션을 추가해주지 않으면 애플리케이션 실행 시 DB 컨테이너가 연결 준비가 되기도 전에

flyway가 스키마를 초기화하려 하기 때문에 에러가 발생한다.

 

테스트 컨테이너를 사용한 DB 연결

이 방식을 사용하면 위 방식의 flyway 문제가 발생하지 않는다.

또한 아주 간단하고 편하게 DB 연결을 할 수 있다.

 

우선 의존성 수정이 필요하다.

'org.testcontainers:mysql' 의존성을 테스트 뿐 아니라 애플리케이션에서도 사용할 것이기에 implementation으로 수정해주자.

implementation 'org.testcontainers:testcontainers-bom:1.20.2'
implementation 'org.testcontainers:mysql'

testImplementation "org.testcontainers:junit-jupiter"

 

 

"테스트 컨테이너 적용하기" 목차에서 설명한데로 자신에게 맞는 적용방법을 사용하면 된다.

필자의 경우에는 db 서버 시간 설정을 위해 아래와 같이 Bean으로 등록하는 방식을 사용했다.

@Profile("local")
@Configuration
public class TestContainersMySQLConfig {


    private static final MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:8.0.35")
                .withDatabaseName("ody")
                .withUsername("root")
                .withPassword("1234")
                .withCommand("--default-time-zone=+09:00");;

    static {
        mysqlContainer.start();
    }

    @Bean
    @Primary
    public DataSource dataSource() {
        return DataSourceBuilder.create()
                .url(mysqlContainer.getJdbcUrl())
                .username(mysqlContainer.getUsername())
                .password(mysqlContainer.getPassword())
                .driverClassName(mysqlContainer.getDriverClassName())
                .build();
    }
}

 

DataSource를 빈 등록했 기 때문에 yml 설정은 추가로 하지 않아도 된다.

사실 이게 연결 끝이다.

그냥 애플리케이션 실행 버튼을 눌르면 컨테이너가 띄워지고 종료하면 컨테이너가 종료된다.

 


테스트 코드를 MySQL로 통일하기

"테스트 컨테이너 적용하기" 목차에서 설명한데로 자신에게 맞는 적용방법을 사용하면 된다.

필자의 경우에는 db 서버 시간 설정을 위해 아래와 같이 Bean으로 등록하는 방식을 사용했다.

@Testcontainers
@TestConfiguration
public class MySQLTestContainersConfig {

    @Container
    private static final MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:8.0.35")
            .withDatabaseName("ody")
            .withCommand("--default-time-zone=+09:00")
            .withCommand("--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --default-time-zone=+09:00");

    static {
        mysqlContainer.start();
    }

    @Bean
    @Primary
    public DataSource dataSource() {
        return DataSourceBuilder.create()
                .url(mysqlContainer.getJdbcUrl())
                .username(mysqlContainer.getUsername())
                .password(mysqlContainer.getPassword())
                .driverClassName(mysqlContainer.getDriverClassName())
                .build();
    }
}

테스트 컨테이너의 어노테이션들을 설명하면 다음과 같다.

 

@TestContainers

클래스 레벨에 선언하며 테스트 컨테이너의 확장 기능을 사용하기 위한 어노테이션이다.

 

@Container

필드 레벨에선언하며 컨테이너 생명 주기를 관리하기 위한 어노테이션이다.

인스턴스 필드에 사용하면 각 테스트마다 컨테이너가 실행돼 무수히 많은 컨테이너들이 존재하는 것을 볼 수 있다. (테스트 시간도 길어짐..)

Static 필드에 사용하면 하나의 컨테이너를 공유해 테스트할 수 있다.

 

이제 테스트 컨테이너를 사용해 테스트하려는 클래스들이 해당 테스트 설정을 import 하기만 하면 된다.

@Import(MySQLTestContainersConfig.class)
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
public abstract class BaseServiceTest {
	... 생략
}

 

그럼 테스트 실행할 때마다 컨테이너가 실행되고 테스트가 끝나면 컨테이너는 자동으로 제거된다.

개발, 운영 환경과 DB를 통일하면서 아주 편리하게 테스트가 가능해졌다 !

 

하지만 확실히 인 메모리 DB인 H2를 사용할 때마다 테스트 속도가 느려지긴 한다

 

주의 사항

@DataJpaTest 사용 시 내장 DB 사용 문제

Repository 테스트를 진행할 경우 @DataJpaTest를 사용하는 사람들이 많을 텐데

DataSource를 직접 지정해줬다 하더라도 @DataJpaTest는 기본적으로 내장 DB를 사용한다.

 

따라서 아래와 같이 @AutoConfigureTestDatabase 어노테이션 옵션을 NONE으로 설정해

데이터 베이스 자동 구성 설정을 실제 지정한 DataSource를 사용하도록 해야한다.

@Import(MySQLTestContainersConfig.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
public abstract class BaseRepositoryTest {
	... 생략
}

 

내장 DB를 사용하지 않고 테스트 할 시 ID가 1로 초기화 되지 않는 문제

내장 DB는 메모리에서 동작하며 각 테스트 실행마다 새로운 데이터베이스 인스턴스를 생성하기 때문에 ID가 항상 1로 초기화 된다.

반면 위 설정에서 테스트 컨테이너는 하나만 실행하고 모든 테스트코드에서 해당 컨테이너를 사용해 테스트하도록 했다.

그렇기 때문에 ID가 1부터 시작하지 않고 롤백된 데이터의 ID 다음으로 시작해 테스트가 실패하는 케이스가 생긴다.

이 문제를 해결하기 위해 필자는 엔티티 매니저로  "ALTER TABLE 테이블명 AUTO_INCREMENT = 1" 쿼리를 실행 시켜

ID를 1로 초기화하는 DataCleaner를 추가해 각 메서드 실행 전마다 해당 로직이 실행되도록 해결했다.

@Import(MySQLTestContainersConfig.class, DatabaseCleaner.class)
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
public abstract class BaseServiceTest {
	... 생략
    
    @Autowired
    private DatabaseCleaner databaseCleaner;

    @BeforeEach
    void cleanUp() {
        databaseCleaner.cleanUp();
    }
}