들어가며
안녕하세요! 최근 저는 플러피(Fluffy)라는 온라인 시험 제작 및 관리 서비스를 개발하고 있습니다. 이번 글에서는 플러피의 테스트 환경에서 겪었던 문제들을 소개하고, 이를 해결하기 위해 Testcontainers를 도입한 경험을 공유해보려고 합니다.
기존 테스트 환경에서 겪었던 문제
실제 환경과 테스트 환경의 차이로 인한 문제
플러피는 서비스 중인 환경에서 데이터베이스로 PostgreSQL을 사용하고 있습니다. 테스트 환경에서는 인메모리 데이터베이스인 H2를 사용하게 됩니다. 문제는, H2와 PostgreSQL의 문법 차이로 인해 테스트 환경에서는 문제없이 통과되던 코드가 실제 서비스 환경에서는 예외를 일으키는 상황이 발생한 것입니다. 예를 들어, H2에서는 GROUP BY를 사용할 때, GROUP BY에 존재하지 않는 컬럼을 SELECT에 포함해도 문제가 발생하지 않습니다. 하지만 실제 서비스 환경인 PostreSQL에서는 GROUP BY에 없는 것을 SELECT할 경우 예외를 일으키게 됩니다. 이 문제로 서비스가 중단되었던 경험이 있습니다.
테스트 환경 구성의 비효율과 한계
플러피는 캐싱과 분산락 기능을 위해 Valkey를 사용하고 있습니다. 이 기능을 사용하는 코드를 테스트하기 위해서는 Valkey가 실행 중이어야 했습니다. 테스트 시 직접 Docker로 Valkey 컨테이너를 띄운 후 진행하는 테스트는 번거롭기도 하고, 테스트 환경마다 Valkey 버전이 달라 테스트 결과가 일관되지 않을 수 있는 문제가 있었습니다. 또한 CI 환경에서는 수동으로 Docker 컨테이너를 띄우기 어렵기 때문에 해당 환경에서는 테스트를 진행할 수 없는 문제가 있었습니다. 이로 인해 일부 테스트는 @Disabled로 처리해야 했습니다.
Testcontainers란 무엇인가?
Testcontainers 소개
Testcontainers는 테스트 코드 내에서 필요한 외부 서비스(데이터베이스, 캐시, 메세지 브로커 등)을 Docker 컨테이너 형태로 간편하게 띄우고 관리할 수 있는 라이브러리입니다. 테스트 실행 시 필요한 컨테이너가 자동으로 생성되고, 테스트 종료 후 자동으로 정리되기 때문에 환경 구성과 관리가 매우 간편합니다.
Testcontainers의 장점
Testcontainers는 테스트 실행 시마다 동일한 환경이 자동으로 구성되기 때문에 로컬 환경과 CI 환경 등 어디서든 동일한 조건에서 테스트를 수행할 수 있습니다. Docker만 설치되어 있다면, 별도의 환경 설정이나 인프라 준비 없이 필요한 서비스 컨테이너를 즉시 사용할 수 있습니다. Testcontainers는 PostgreSQL, Redis, Kafka 등 다양한 모듈을 지원하며, Java, Kotlin, Go 등 여러 언어에서도 사용할 수 있어 유연한 확장성을 갖추고 있습니다. Github Actions 등 대부분의 CI 환경에서 기본적으로 Docker를 지원하므로, CI 환경에서도 손쉽게 테스트 환경 구성 및 실행이 가능합니다. Testcontainers는 Netflix, Spotify, Uber 등 여러 글로벌 기업에서도 사용할만큼 안정성이 검증되어 있습니다.
Testcontainers를 사용한 이유
플러피는 다음과 같이 기존 테스트 환경에서 발생하는 문제들을 해결하기 위해 Testcontainers를 도입했습니다.
- H2와 PostgreSQL 간의 문법 차이로 인해 발생하는 문제 방지: 실제 운영 환경과 동일한 PostgreSQL 컨테이너를 사용해 테스트함으로써, 데이터베이스 환경 차이로 인한 문제를 사전에 발경하고 예방한다.
- Valkey 환경의 일관성 확보 및 관리 편의성 개선: 매번 수동으로 Valkey 컨테이너를 띄우는 번거로움을 해소하고, 테스트마다 서로 다른 버전을 사용하는 문제를 방지한다.
- CI 환경에서의 테스트 자동화 지원: 별도 설정 없이 Testcontainers가 컨테이너를 자동 관리하므로, CI 환경에서도 손쉽게 통합 테스트를 수행한다.
Testcontainers 사용하기
Testcontainers 적용 환경 및 의존성 설정
현재 플러피 서비스는 Spring Boot 3과 JUnit 5를 사용하고 있습니다. 운영 환경에서는 PostgreSQL과 Valkey를 사용하고 있습니다. Testcontainers를 사용하기 위해서는 testcontainers 의존성을 추가해야 합니다. Redis의 경우 기본적으로 포함되어 있지만 다른 모듈들을 사용하기 위해서는 해당 모듈의 의존을 추가해야 합니다. 예를 들어, PostgreSQL 모듈을 사용하기 위해서는 testcontainers:postgresql 의존성을 추가하면 됩니다. JUnit 5의 라이프 사이클에 맞춰 편의 기능을 제공하는 testcontainers:junit-jupiter 의존성도 추가해줍니다.
dependencies {
testImplementation "org.testcontainers:testcontainers:1.20.5" // testcontainers
testImplementation 'org.testcontainers:postgresql:1.20.5' // PostgreSQL 모듈
testImplementation "org.testcontainers:junit-jupiter:1.20.5" // JUnit 5 연동
}
junit-jupiter 의존성을 추가하면, @Testcontainers와 @Container 어노테이션을 사용할 수 있습니다. 테스트 클래스에@Testcontainers 어노테이션을 추가하면, 테스트 클래스 내에서 선언한 @Container 필드를 인식하게 됩니다. 정적 필드인 경우 해당 컨테이너는 테스트 인스턴스의 모든 테스트를 실행하기 전에 한 번 시작되고, 모든 테스트를 실행한 후 중지됩니다. 필드가 인스턴스 필드인 경우 모든 테스트 메서드 전에 새 컨테이너가 시작되고 테스트를 실행한 후 중지됩니다.
클래스 단위로 컨테이너 관리하기
우선, 가장 일반적인 방식인 테스트 클래스 단위에서 컨테이너를 생성하고 종료하는 방법을 소개합니다. 테스트 클래스에 @Testcontainers 어노테이션을 붙이고, 컨테이너를 정의하는 필드에 @Container 어노테이션을 추가합니다. 컨테이너 필드에는 사용할Docker 이미지를 지정하며, 필드에 static 키워드를 추가해야 테스트 클래스가 로드될 대 컨테이너가 한 번만 생성됩니다. 또한, @DynamicPropertySource를 활용하면 컨테이너가 생성된 후 동적으로 애플리케이션 설정 값(Spring Property)을 등록할 수 있습니다.
이 방식은 테스트 클래스 단위로 컨테이너를 생성하고 종료하는 구조이기 때문에, 클래스 내의 모든 테스트 메서드는 동일한 컨테이너를 공유합니다. 따라서 이전 테스트에서 생성한 데이터를 이후에 실행되는 다른 테스트에 영향을 줄 수 있습니다. 상황에 맞게 메서드 실행 전후에 데이터를 초기화해서 문제를 해결할 수 있습니다.
참고로, @ServiceConnection을 사용할 경우 @DynamicPropertySource를 사용할 필요 없이, 자동으로 Property를 설정해줍니다.
@SpringBootTest
@Testcontainers
public class ExampleTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void postgresProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void test1() {
// 첫 번째 테스트
}
@Test
void test2() {
// 두 번째 테스트
}
}
테스트 성능 개선: 싱글톤 컨테이너 패턴
메서드마다 또는 클래스마다 컨테이너를 생성하고 종료하는 방식은 독립적인 환경을 구성해줘 테스트 간 영향을 줄일 수 있습니다. 하지만 컨테이널르 생성하는 시간이 추가로 소요되기 때문에 테스트 수행 시간이 많이 길어질 수 있습니다. 이런 경우, 싱글톤 컨테이너 패턴을 사용하면 보다 빠르게 테스트를 수행할 수 있습니다.
싱글톤 컨테이너 패턴은 하나의 Spring Context에서 단일 컨테이너를 생성하고, 해당 컨테이너를 여러 테스트 클래스에서 공유하는 방식입니다. 예를 들어, 컨테이너를 생성하는 AbstractIntegrationTest라는 추상 클래스를 두고, 각 테스트 클래스에서 이 추상 클래스를 상속받아 사용합니다.
먼저, 잘못 사용하기 쉬운 예시부터 보겠습니다.
@SpringBootTest
@Testcontainers
public abstract class AbstractIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void postgresProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
// 처음 사용하는 테스트 클래스는 정상 작동
class ExampleTest extends AbstractIntegrationTest {
@Test
void test1() {
// 첫 번째 테스트
}
}
// 이후부터는 컨테이너가 종료되어 테스트 실패
class ExampleTest2 extends AbstractIntegrationTest {
@Test
void test2() {
// 두 번째 테스트
}
}
테스트 클래스별로 컨테이너를 생성하던 방식을 추상 클래스로 변경한 코드입니다. 이 경우, 전체 테스트를 실행하면 첫 번째 테스트 클래스는 정상적으로 실행되지만, 이후 테스트 클래스들은 컨테이너 종료로 인해 실패하게 됩니다. @Testcontainers와 @Container를 사용하면, 기본적으로 테스트 클래스 단위로 컨테이너가 생성 및 종료되는데, 이때 Spring Context는 기본적으로 재사용됩니다. 즉, 컨테이너는 종료되었지만, 기존 Spring Context가 다음 테스트 클래스에서도 그대로 사용되면서, 이미 종료된 컨테이너를 참조해 오류가 발생하는 문제입니다.
이 문제를 해결하기 위해, @Testcontainers와 @Container를 사용하지 않고, 컨테이너를 직접 관리할 수 있습니다.
@SpringBootTest
public abstract class AbstractIntegrationTest {
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine");
static {
postgres.start();
redis.start();
}
// 각자의 방식에 맞게 수정해서 사용
@DynamicPropertySource
static void postgresProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", redis::getFirstMappedPort);
registry.add("spring.data.redis.ssl.enabled", () -> "false");
}
}
테스트 컨테이너를 실행하는 부분은 있지만 종료하는 부분은 없습니다. JVM 프로세스가 종료되면 Ryuk 컨테이너가 사용한 테스트 컨테이너를 자동으로 종료시켜줍니다. 이러한 이유로 따로 종료를 명시하지 않으셔도 됩니다.
이제 하나의 Spring Context에서 PostgreSQL과 Redis를 하나씩 생성하고 공유할 수 있습니다.
Testcontainers 설정 분리
AbstractIntegerationTest 클래스 내에서 컨테이너 생성 및 설정 코드를 직접 작성하는 것은 여러 면에서 좋지 않을 수 있습니다. 예제에서는 AbstractIntegrationTest 추상 클래스 내에 Testcontainers 설정 코드만 포함되어 있지만, 실제 프로젝트에서는 다양한 설정이 존재할 수 있습니다. 이 경우 다른 설정 코드와 Testcontainers 코드가 섞여 가독성이 떨어질 수 있습니다. 또한 Testcontainers 설정 코드가 변경될 때마다 AbstractIntegrationTest를 수정하는 것은 바람직하지 않습니다.
Testcontainers 설정 코드 분리를 위해서 ApplicationContextInitializer와 @ContextConfiguration을 사용할 수 있습니다. ApplicationContextInitializer는 Spring Context가 초기화될 때 실행되는 콜백 인터페이스입니다. Spring 컨텍스트가 테스트에 필요한 특정 설정을 로드하도록 할 수 있습니다. @ContextConfiguration은 Spring의 테스트 환경을 설정하는 어노테이션입니다. initializers를 통해 ApplicationContextInitializer를 구현한 클래스를 사용할 수 있습니다.
// PostgreSQL 컨테이너 설정
public class PostgreSQLContainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
private static final PostgreSQLContainer<?> POSTGRESQL = new PostgreSQLContainer<>("postgres:16-alpine");
static {
POSTGRESQL.start();
}
@Override
public void initialize(@NotNull ConfigurableApplicationContext context) {
Map<String, String> properties = Map.of(
"spring.datasource.url", POSTGRESQL.getJdbcUrl(),
"spring.datasource.username", POSTGRESQL.getUsername(),
"spring.datasource.password", POSTGRESQL.getPassword()
);
// 설정된 프로퍼티를 Spring 컨텍스트에 적용
TestPropertyValues.of(properties).applyTo(context);
}
}
// Redis 컨테이너 설정
public class RedisContainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
private static final GenericContainer<?> REDIS = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
static {
REDIS.start();
}
@Override
public void initialize(@NotNull ConfigurableApplicationContext context) {
Map<String, String> properties = Map.of(
"spring.data.redis.host", REDIS.getHost(),
"spring.data.redis.port", String.valueOf(REDIS.getFirstMappedPort()),
"spring.data.redis.ssl.enabled", "false"
);
TestPropertyValues.of(properties).applyTo(context);
}
}
위에서 분리한 PostgreSQLContainerInitializer와 RedisContainerInitializer 클래스를 @ContextConfiguration의 initailizers에서 사용합니다. 이를 통해 Testcontainers 설정을 분리할 수 있습니다.
@SpringBootTest
@ContextConfiguration(initializers = {
RedisContainerInitializer.class,
PostgreSQLContainerInitializer.class
})
public abstract class AbstractIntegrationTest {
// 다른 설정들이 존재할 수 있음.
}
참고로, withReuse(true)를 사용하면 컨테이너를 재사용할 수 있습니다. 재사용 옵션을 사용하면 컨테이너가 종료되지 않고 재사용되기 때문에, 동일하게 테스트 수행 시간을 단축할 수 있습니다 .하지만 이 방식은 현재 실험(Experimental) 단계이기 때문에 주의가 필요하며, 다루지 않겠습니다.
마치며
이번 글에서는 Testcontainers를 사용하여 테스트 환경을 개선하는 방법을 알아보았습니다. Testcontainers를 사용하면 테스트 환경을 실제 서비스 환경과 유사하게 구성할 수 있어, 테스트 코드의 신뢰도를 높일 수 있었습니다. 또한 CI에서도 외부 의존성을 사용할 수 있어, 문제없이 테스트할 수 있었습니다.
테스트 클래스 단위로 컨테이너를 생성하는 것과 싱글톤 컨테이너 패턴을 사용하는 것 모두 이전에 생성된 데이터가 다음 테스트에 남아있어 영향을 줄 수 있습니다. 이를 해결하기 위해서는 각 테스트 메서드 실행 전에 데이터를 초기화하는 작업이 필요합니다. 저는 보통 Extension을 만들어 데이터를 초기화하는 작업을 수행하고 있습니다. 이는 다음 글에서 소개해보겠습니다.
저는 개인적으로 Testcontainers를 사용하고부터 테스트에 대한 신뢰도가 높아져 테스트 코드 작성에 더욱 열정적으로 다가갈 수 있었습니다. 여러분도 한 번 사용해보기실 권장드립니다. 무조건 푹 빠지실 겁니다. 읽어주셔서 감사합니다. 잘못된 부분에 대한 지적이나 질문은 언제든 환영합니다.
fluffy/server/src/test/java/com/fluffy/support at 6fdccbb890bfbee258b09cd17a16acc3882a1c45 · alstn113/fluffy
온라인 시험 문제 제작 및 관리 서비스 - 플러피. Contribute to alstn113/fluffy development by creating an account on GitHub.
github.com
참고
'서버' 카테고리의 다른 글
Presigned URL과 CDN으로 이미지 업로드 & 조회 개선 (0) | 2025.03.07 |
---|---|
테스트 후 데이터 정리를 통해 테스트 간 데이터 독립성 보장 (0) | 2025.03.03 |
분산락을 이용한 중복 생성 문제 해결 (2) | 2025.02.23 |
로컬에서 AWS Private Subnet에 있는 인스턴스에 접속하는 방법 (0) | 2025.02.13 |
AWS ElastiCache로 Redis(or Valkey) 운영하기 (0) | 2025.02.10 |