[Test] H2 대신 Testcontainers 도입하기
보통 나와 같은 학생들이 프로젝트를 진행할 때 테스트 코드를 짤 때 어떤걸 고려하면 좋을까? 일단 안짠다. (마감 기한 맞추고 버그 잡다보면 테스트 코드는 뒷전이다.) 하지만 테스트 코드는
sunm2n.tistory.com
저번에 Testcontainers 도입을 하였다.
그 당시에는 필자는 postgres만 도입을 한 상태였고 추후에 redis도 필요하여 redis도 추가하게 되었는데 동일하게 하면 되겠지? 했지만
약간의 추가적인 세팅이 필요했다.
동작 방식에 대해서 알아보고 왜 세팅에서 차이가 발생했는지 알아보자.
1. @ServiceConnection의 내부 동작 원리
// 1단계: Spring Boot가 테스트 시작 시 @ServiceConnection 스캔
@Container
@ServiceConnection // ← Spring Boot: "이게 뭐지?"
protected static final PostgreSQLContainer<?> POSTGRES_CONTAINER = ...;
**Spring Boot의 사고 과정:**
// Spring Boot 내부 (의사 코드)
void processServiceConnection(Container container) {
// 1. 컨테이너의 타입 확인
Class<?> containerType = container.getClass();
// containerType = PostgreSQLContainer.class
// 2. 이 타입에 맞는 ConnectionDetailsFactory 찾기
ConnectionDetailsFactory factory =
findFactoryFor(containerType);
// "PostgreSQLContainer용 Factory가 있나?"
// → 있음! PostgreSQLConnectionDetailsFactory 발견!
// 3. Factory를 사용해서 연결 정보 생성
DataSourceConnectionDetails details =
factory.createConnectionDetails(container);
// 4. Spring Environment에 자동 주입
environment.setProperty("spring.datasource.url",
details.getJdbcUrl());
environment.setProperty("spring.datasource.username",
details.getUsername());
environment.setProperty("spring.datasource.password",
details.getPassword());
}
이렇기 때문에 자동 주입으로 인해 너무나 편하게 구현하였다.
그러나 redis(valkey)에서 약간의 이슈가 발생했다.
Spring Boot 소스코드를 보면
spring-boot-testcontainers/src/main/java/
└── org.springframework.boot.testcontainers.service.connection/
├── postgres/
│ └── PostgresJdbcConnectionDetailsFactory.java
├── mysql/
│ └── MySqlJdbcConnectionDetailsFactory.java
├── mongodb/
│ └── MongoConnectionDetailsFactory.java
├── kafka/
│ └── KafkaConnectionDetailsFactory.java
└── redis/
└── RedisConnectionDetailsFactory.java // ← 있긴 한데...
RedisConnectionDetailsFactory 내부를 보면
@ServiceConnection(RedisConnectionDetails.class)
class RedisConnectionDetailsFactory
extends ContainerConnectionDetailsFactory<RedisContainer, RedisConnectionDetails> {
// ← RedisContainer를 처리! GenericContainer는 처리 안함!
}
2. 왜 GenericContainer는 안 되는가?
타입 매칭 문제 때문이다.
// PostgreSQL: 성공
@ServiceConnection
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(...);
// Spring Boot 내부 매칭:
if (container instanceof PostgreSQLContainer) { // ← true!
usePostgreSQLConnectionDetailsFactory();
}
// Valkey: 실패
@ServiceConnection
GenericContainer<?> valkey = new GenericContainer<>("valkey/valkey:9-alpine");
// Spring Boot 내부 매칭:
if (container instanceof PostgreSQLContainer) { // false
if (container instanceof MySQLContainer) { // false
if (container instanceof RedisContainer) { // false! ← RedisContainer가 아님!
if (container instanceof GenericContainer) { // true이지만...
// GenericContainer용 Factory가 없음!
// GenericContainer는 너무 범용적이라 어떤 서비스인지 알 수 없음
throw new ConnectionDetailsNotFoundException();
}
Testcontainers 라이브러리를 확인하면
// testcontainers-java 라이브러리
org.testcontainers.containers/
├── PostgreSQLContainer.java ✅ 있음
├── MySQLContainer.java ✅ 있음
├── MongoDBContainer.java ✅ 있음
├── KafkaContainer.java ✅ 있음
├── GenericContainer.java ✅ 있음 (범용)
└── RedisContainer.java ❌ 없음!
아니 왜 없는거야? 라는 생각이 들었다.
그래서 왜 없는걸까?
Testcontainers 개발자들의 판단:
// PostgreSQL: 복잡한 JDBC URL 필요
PostgreSQLContainer container = new PostgreSQLContainer<>();
String jdbcUrl = container.getJdbcUrl();
// jdbc:postgresql://localhost:52341/test?stringtype=unspecified¤tSchema=public
// Redis: 단순함
GenericContainer redis = new GenericContainer<>("redis:7");
String host = redis.getHost();
int port = redis.getMappedPort(6379);
// 그냥 host:port만 있으면 끝!
뭐 이런 이유 때문이라고 한다.
그렇다면 어떻게 해야 할까
먼저 필자가 처음에 실패한 과정부터 설명하겠다.
3. ConnectionDetailsNotFoundException 발생
// 1. Spring Boot 테스트 시작
@SpringBootTest
class MyTest extends IntegrationTestSupport { }
// 2. @ServiceConnection 처리 시도
@ServiceConnection
GenericContainer<?> VALKEY_CONTAINER = ...;
// 3. Spring Boot 내부
ConnectionDetailsFactory factory = findFactoryFor(GenericContainer.class);
// → 없음!
// 4. 예외 발생
throw new ConnectionDetailsNotFoundException(
"Cannot find ConnectionDetailsFactory for container type: " +
"class org.testcontainers.containers.GenericContainer"
);
전체 예외를 보면
java.lang.IllegalStateException: Failed to load ApplicationContext
Caused by: ConnectionDetailsNotFoundException:
No ConnectionDetailsFactory found for container type: GenericContainer
결국 @DynamicPropertySource 에서 발생하는 문제이기 때문에 이를 해결하기 위해 다른 매커니즘을 도입했다.
4. @DynamicPropertySource의 다른 메커니즘
@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", VALKEY_CONTAINER::getHost);
registry.add("spring.data.redis.port", () -> VALKEY_CONTAINER.getMappedPort(6379));
registry.add("spring.data.redis.password", () -> "");
}
//내부동작 방식
// Spring TestContext Framework
class DynamicPropertyRegistrar {
void processTest(Class<?> testClass) {
// 1. @DynamicPropertySource 메서드 찾기
Method method = findDynamicPropertySourceMethod(testClass);
// 2. 메서드 실행 전에 PropertyRegistry 생성
DynamicPropertyRegistry registry = new DynamicPropertyRegistry();
// 3. 메서드 호출
method.invoke(null, registry);
// 이때 우리 코드가 실행됨:
// registry.add("spring.data.redis.host", VALKEY_CONTAINER::getHost);
// 4. Registry에 등록된 프로퍼티들을 Environment에 추가
for (PropertySource property : registry.getPropertySources()) {
environment.addPropertySource(property);
}
}
}
실행 순서를 보면
1. Testcontainers가 컨테이너 시작
VALKEY_CONTAINER.start()
→ Valkey 컨테이너 실행
→ 동적 포트 52341 할당됨
2. @DynamicPropertySource 메서드 실행
registerProperties(registry)
3. registry.add() 호출마다 PropertySource 생성
registry.add("spring.data.redis.host", VALKEY_CONTAINER::getHost)
→ PropertySource {
name: "spring.data.redis.host",
value: Supplier(() -> VALKEY_CONTAINER.getHost())
}
4. Spring이 Environment 구성
environment.getProperty("spring.data.redis.host")
→ Supplier 실행
→ VALKEY_CONTAINER.getHost() 호출
→ "localhost" 반환
5. RedisConnectionFactory 생성
new LettuceConnectionFactory(
host: "localhost", // ← 우리가 주입한 값
port: 52341 // ← 우리가 주입한 값
)
그리고 application-dev.yml을 보면
application-dev.yml:
spring:
data:
redis:
password: ${REDIS_PASSWORD:} # ← 플레이스홀더!
다음과 같은 이유로 password 또한 필요했다.
**Spring Boot 프로퍼티 해결 과정:**
// Spring Boot가 RedisConnectionFactory 생성 시
@ConfigurationProperties("spring.data.redis")
class RedisProperties {
private String host;
private int port;
private String password; // ← 이 값이 필요!
// Setter 호출 시
public void setPassword(String password) {
// password = "${REDIS_PASSWORD:}"
// 플레이스홀더 해결 시도
String resolved = environment.resolvePlaceholders(password);
if (resolved.startsWith("${")) {
// 해결 실패!
throw new PlaceholderResolutionException(
"Could not resolve placeholder 'REDIS_PASSWORD'"
);
}
this.password = resolved;
}
}
**해결:**
registry.add("spring.data.redis.password", () -> "");
// 이제:
environment.getProperty("spring.data.redis.password")
→ "" (빈 문자열)
// application-dev.yml의 플레이스홀더 해결:
${REDIS_PASSWORD:}
→ REDIS_PASSWORD 환경변수 없으면
→ spring.data.redis.password 찾기
→ "" 발견!
→ 해결 성공!
**6. @ServiceConnection vs @DynamicPropertySource 비교**
**@ServiceConnection (자동):**
@ServiceConnection
PostgreSQLContainer<?> postgres = ...;
// Spring Boot가 자동으로:
// 1. PostgreSQLContainer 타입 인식
// 2. PostgreSQLConnectionDetailsFactory 찾기
// 3. ConnectionDetails 생성
// 4. Environment에 자동 주입
// - spring.datasource.url
// - spring.datasource.username
// - spring.datasource.password
5. @ServiceConnection vs @DynamicPropertySource 비교
**@ServiceConnection (자동):**
@ServiceConnection
PostgreSQLContainer<?> postgres = ...;
// Spring Boot가 자동으로:
// 1. PostgreSQLContainer 타입 인식
// 2. PostgreSQLConnectionDetailsFactory 찾기
// 3. ConnectionDetails 생성
// 4. Environment에 자동 주입
// - spring.datasource.url
// - spring.datasource.username
// - spring.datasource.password
장점:
- 한 줄로 끝
- 타입 안전
- Spring Boot가 모든 설정 자동 처리
단점:
- Spring Boot가 지원하는 컨테이너 타입만 가능
- GenericContainer는 지원 안함
**@DynamicPropertySource (수동):**
GenericContainer<?> valkey = ...;
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.data.redis.host", valkey::getHost);
r.add("spring.data.redis.port", () -> valkey.getMappedPort(6379));
}
// 우리가 수동으로:
// 1. 컨테이너 정보 가져오기
// 2. Spring 프로퍼티 이름 알기
// 3. 직접 매핑
장점:
- 모든 컨테이너 타입 사용 가능
- 커스터마이징 자유로움
단점:
- 수동 설정 필요
- 프로퍼티 이름을 정확히 알아야 함
6. 최종 정리
PostgreSQL (전용 컨테이너):
PostgreSQLContainer
↓
@ServiceConnection 인식
↓
PostgreSQLConnectionDetailsFactory 자동 호출
↓
spring.datasource.* 자동 설정
↓
성공! ✅
Valkey (범용 컨테이너):
GenericContainer
↓
@ServiceConnection 시도
↓
GenericContainer용 Factory 없음!
↓
ConnectionDetailsNotFoundException ❌
↓
@DynamicPropertySource로 수동 설정
↓
spring.data.redis.* 수동 주입
↓
성공! ✅
다음과 같이 문제를 해결하여 redis(valkey)를 testcontainers로 도입하는 과정을 성공하였다.
물론 이와 같이 세팅하는 과정에서 상당히 귀찮고 어렵지만 세팅이 완료되었을 때는 동일한 환경에서 테스트를 할 수 있다는 큰 장점이 있다.
물론 요즘같이 바이브 코딩이 유행하고 빠르게 MVP를 찍어내는게 중요한 시대에서 테스트 코드는 등한시 된다.
물론 그게 무조건 나쁘다는 것은 절대 아니다.
빠르게 시장에 서비스를 내놓고 유저들의 반응을 확인하는 것은 매우 중요하다.
테스트 코드를 한 줄 더 적을 시간에 더 빠르게 출시하고 유저들의 반응에 따른 업데이트를 하며 서비스의 방향성을 잡는 것이 더 중요하다고
필자는 여전히 생각한다.
그러나 테스트 코드를 작성하는 시간 조차 코스트라고 판단하여 작성하지 않는 것과 테스트 코드를 작성해보지 않고 어떠한 고민도 해보지
않아 할줄 모르는 것은 큰 차이 인거 같다.
만약에 서비스가 정말 성공적으로 커지게 되면 언젠간 반드시 필요할 때가 올 것이다.
그때 평소에 이와 같이 어떻게 하면 더 좋은 테스트 코드 작성을 할 수 있을까에 대한 고민을 해본 사람과 안해본 사람의 차이는 클 것이다.
그렇기에 나와 같이 아직 공부를 하는 학생이라면 테스트 코드 작성에서도 어떻게 테스트 코드를 작성할지 테스트 코드가 동작하는 환경을
어떻게 관리할지에 대한 다양한 고민을 해보면 좋을거 같다.
'tech > project' 카테고리의 다른 글
| [Auth] 로그인 구현 대신 SaaS 서비스 도입에 대한 고찰 (0) | 2026.01.22 |
|---|---|
| [Auth] 로그인 화면 주도권을 누구에게 줄 것인가 (0) | 2026.01.19 |
| [Test] H2 대신 Testcontainers 도입하기 (0) | 2026.01.13 |
| [postgreSQL] varchar VS text 무엇이 더 좋을까 (0) | 2026.01.07 |
| [token] 토큰을 어떻게 저장하는 것이 좋을까? (1) | 2025.12.22 |