보통 나와 같은 학생들이 프로젝트를 진행할 때 테스트 코드를 짤 때 어떤걸 고려하면 좋을까?
일단 안짠다. (마감 기한 맞추고 버그 잡다보면 테스트 코드는 뒷전이다.)
하지만 테스트 코드는 굉장히 중요하고 요즘 바이브 코딩이 대세가 되면서 오히려 테스트 코드를 먼저 작성하여 AI가 짜준 코드를 검증하는
도구로 많이 사용한다. (TDD 방식)
테스트 시 가장 기본적인 것은 운영 환경의 데이터베이스에 절대 접근을 하면 안되고 별도로 분리된 테스트 용 데이터베이스에 접근을
해야하는 것이 기본중에 기본이다.
그리고 이를 위해 보통 가벼운 H2를 많이 쓴다.
하지만 오늘은 다른 방식으로도 테스트 용 데이터베이스를 설정하는 방법을 소개해보고자 한다.
Testcontainers
Testcontainers is an opensource library for providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.
testcontainers.com
바로 테스트 시 도커 컨테이너로 별도의 테스트 db를 띄우는 방법이다.
이 테스트 방식은 넓은 의미에서는 통합 테스트(Integration Testing)의 일종이며 현대적인 DevOps 및 클라우드 네이티브 개발 환경에서는 컨테이너 기반 테스팅(Container-based Testing) 또는 이를 지원하는 라이브러리의 이름을 따서 Testcontainers 패턴이라고 부른다.
하지만 이 방식이 추구하는 핵심 철학이자 가장 정확한 개념적 용어는 Production Parity(운영 환경 일치성) 를 위한 테스트 전략이다.
1. 핵심 개념: Production Parity (운영 환경 일치성)
개발자가 가장 많이 겪는 악몽인 내 로컬(H2)에서는 되는데 배포 서버(PostgreSQL)에서는 왜 안 되지? 라는 문제를 해결하기 위한 접근법이다.
- 기존 방식 (In-memory Database):
- 가볍고 빠르지만, 실제 운영 DB(PostgreSQL, MySQL 등)의 흉내(Mocking)만 낸다.
- DB마다 다른 문법(Dialect)이나 특정 기능(JSON 타입, 지리 정보 함수 등)을 완벽히 지원하지 못한다.
- Testcontainers 방식:
- Docker를 이용해 운영 환경과 100% 동일한 바이너리를 실행한다.
- 거짓 양성(False Positive: 테스트는 통과했는데 실제로는 에러)을 원천 차단한다.
2. 아키텍처 및 동작 원리
이 방식이 동작하는 흐름(Workflow)은 아래와 같다.
- Test Start: JUnit(테스트 프레임워크)이 테스트를 시작한다.
- Docker API Call: Testcontainers 라이브러리가 로컬에 설치된 Docker 데몬에게 "PostgreSQL 17버전 컨테이너 하나 띄워줘"라고 명령을 보낸다.
- Container Up: Docker가 컨테이너를 실행하고, 사용할 수 있는 포트(Port)를 랜덤으로 할당한다.
- Spring Boot Connect: 스프링 부트가 실행되면서 방금 뜬 컨테이너의 IP와 포트 정보를 주입받아 연결(DataSource 설정)한다.
- Test Execution: 테스트 코드가 실제 DB에 쿼리를 날리며 검증한다.
- Teardown (Ryuk): 테스트가 끝나면 'Ryuk'이라는 사이드카 컨테이너가 떴던 DB 컨테이너를 말끔히 삭제(Kill & Remove)하여 자원을 정리한다.
3. 기술적 상세 설명
A. Ephemeral Infrastructure (임시 인프라)
테스트를 위해 영구적인 DB 서버를 구축해두는 것이 아니라 테스트가 실행되는 그 순간에만 인프라(DB)를 코드(Code)로 정의하여 생성하고 끝나면 파괴하는 방식이다. 이를 통해 테스트의 격리성(Isolation)을 보장한다.
B. Dynamic Port Binding (동적 포트 바인딩)
도커 컨테이너를 띄울 때 5432:5432로 고정하지 않고, 호스트의 남는 포트 아무거나(예: 32789) 매핑한다. 이렇게 하면 병렬로 여러 빌드가 돌아가거나(CI 환경), 이미 로컬에 다른 Postgres가 떠 있어도 포트 충돌이 절대 발생하지 않는다.
C. Singleton Pattern in Tests
- Anti-Pattern: 각 테스트 클래스마다 컨테이너를 띄우고 끄는 방식 (오버헤드가 큼)
- Best Practice: JVM이 시작될 때(Static) 딱 1번만 컨테이너를 띄우고, 모든 테스트 클래스가 이 하나의 DB 인스턴스를 공유해서 사용한다. 데이터 오염 문제는 @Transactional로 롤백시켜 해결한다.
여기서 C를 고려하지 않는다면 각 테스트 클래스마다 컨테이너가 켜져 무한 생성 문제가 발생한다. 그럼 이를 어떻게 해결할까?
4. 컨테이너 무한 생성 문제 해결법: Abstract Base Class + Static
여러 개의 컨테이너가 뜨는 문제를 해결하려면 모든 테스트 클래스가 상속받는 부모 클래스를 만들고 그곳에서 static으로 컨테이너를 1번만 띄우는 방식을 사용해야 한다.
JVM 레벨에서 static 필드는 클래스 로딩 시 딱 한 번만 초기화된다는 점을 이용하는 것이다.
구현 방법
최신 스프링 부트를 사용 중이라면 @ServiceConnection을 사용하여 설정이 매우 간편해졌다.
1. build.gradle 의존성 추가
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers' // 스프링 부트 3.1 이상
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'
2. 통합 테스트용 추상 부모 클래스 생성 (IntegrationTestSupport)
이 클래스를 만드는 것이 핵심이다.
이 클래스는 예제일 뿐 상황에 맞게 디테일한 것들은 수정을 하면 된다.
package com.example.demo.common;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.test.context.ActiveProfiles;
import org.junit.jupiter.api.Disabled;
@SpringBootTest // 통합 테스트 환경
@Testcontainers // Testcontainers 활성화
@ActiveProfiles("test") // application-test.yml 설정 사용
public abstract class IntegrationTestSupport {
// static으로 선언하여 클래스 로딩 시 1번만 생성 & 공유됨
@ServiceConnection // 스프링 부트가 알아서 url, username, password를 주입해줌
static final PostgreSQLContainer<?> POSTGRES_CONTAINER;
static {
// 운영 환경과 동일한 도커 이미지 버전 지정
POSTGRES_CONTAINER = new PostgreSQLContainer<>("postgres:17-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
// 컨테이너 수동 시작 (생명주기 관리)
POSTGRES_CONTAINER.start();
}
}
3. 실제 테스트 코드
class MemberRepositoryTest extends IntegrationTestSupport {
@Autowired
private MemberRepository memberRepository;
@Test
void 회원가입_테스트() {
// 이미 떠 있는 PostgreSQL 컨테이너를 사용하여 테스트 진행
}
}
5. 추가적인 최적화 팁 (꿀팁)
한 단계 더 나아가 속도를 극한으로 올리고 싶다면 다음 방법을 고려할 수 있다.
1) @Transactional은 필수
테스트가 끝날 때마다 데이터가 롤백되어야 다음 테스트에 영향을 주지 않는다. 위 코드에서는 @SpringBootTest가 있지만, 테스트 메서드마다 데이터 정합성을 맞추려면 @Transactional을 붙이거나 IntegrationTestSupport에 붙여두는 것이 좋다.
2) Testcontainers Reuse (재사용) 기능
로컬 개발 환경에서는 테스트를 돌릴 때마다 컨테이너를 끄지 않고 계속 켜두면 속도가 훨씬 빠르다. (H2만큼 빨라짐)
1. 홈 디렉토리(~/.testcontainers.properties)에 다음 설정을 추가
testcontainers.reuse.enable=true
2. 자바 코드에서 .withReuse(true)를 추가
static {
POSTGRES_CONTAINER = new PostgreSQLContainer<>("postgres:15-alpine")
.withReuse(true) // 이 옵션 추가
.withDatabaseName("testdb")
// ...
POSTGRES_CONTAINER.start();
}
이렇게 하면 테스트가 끝나도 도커 컨테이너가 죽지 않고 살아있어서다음번 테스트 실행 시 즉시 연결된다.
5. test.yml 주의사항
필자와 같은 경우에는 프로젝트를 할 때 application.yml을 prod, dev, test, 공통 이렇게 4개로 나눈다 그 중에 test.yml을 어떻게 설정해야 할까?
나처럼 application-test.yml을 따로 분리했다면 설정 우선순위(Override) 문제만 잘 정리하면 된다.
가장 중요한 포인트는 YML에 적혀 있는 고정된 DB 주소(URL)를 Testcontainers가 실행될 때 생성된 랜덤 주소로 바꿔치기해야 한다는 점이다.
Testcontainers는 매번 실행될 때마다 포트 번호가 바뀐다. (예: 5432 -> 32891). 하지만 application-test.yml에는 보통 localhost:5432라고 고정되어 있어 충돌이 날 수 있다.. 이 충돌을 해결하는 2가지 방법이 있다.
5-1. Spring Boot 3.1 이상 (추천 하는 방식)
@ServiceConnection 사용하기
가장 최신이자 세련된 방법이다. 이 어노테이션을 붙이면 스프링 부트가 알아서 "아, 이 컨테이너가 PostgreSQL이구나? 그럼 YML에 있는 설정은 무시하고, 방금 뜬 이 컨테이너의 IP, Port, ID, PW를 주입해 줄게!" 라고 동작한다.
- application-test.yml: DB 연결 정보(url, username, password)는 지우거나 남겨둬도 상관없다. (어차피 무시됨)
- 단, ddl-auto 같은 JPA 설정은 남겨둬야 함
- IntegrationTestSupport (테스트 부모 클래스):
@SpringBootTest
@Testcontainers
@ActiveProfiles("test") // application-test.yml을 읽음
public abstract class IntegrationTestSupport {
@Container
@ServiceConnection // 핵심! 이 어노테이션이 YAML 설정을 덮어씁니다.
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine");
}
원리: @ServiceConnection이 spring.datasource.url, username, password 프로퍼티를 동적으로 가로채서 주입한다. application-test.yml에 무엇이 적혀있든 이 설정이 먼저 적용된다.
5-2. Spring Boot 3.1 미만 또는 명시적 설정
@DynamicPropertySource 사용하기
만약 @ServiceConnection이 동작하지 않거나, 더 명시적으로 제어하고 싶다면 이 방법을 쓴다. "동적으로 프로퍼티를 갱신하겠다"는 뜻
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
public abstract class IntegrationTestSupport {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
// YAML 설정을 덮어쓰는 메서드
@DynamicPropertySource
static void overrideProperties(DynamicPropertyRegistry registry) {
// YAML의 'spring.datasource.url'을 컨테이너의 실제 주소(getJdbcUrl)로 교체
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
5-3. application-test.yml 정리하기
Testcontainers를 도입했다면 application-test.yml은 이렇게 관리하는 것이 가장 깔끔하다.
1. DB 연결 정보는 '더미(Dummy)'로 두거나 지워라. 어차피 Testcontainers가 덮어쓸 것이기 때문에 실제 값은 중요하지 않다. 하지만 "이 테스트는 DB가 필요해"라는 걸 명시하기 위해 남겨두기도 한다.
# src/test/resources/application-test.yml
spring:
datasource:
# Testcontainers가 이 값들을 덮어씌웁니다.
# 비워둬도 되지만, 헷갈리지 않게 명시하려면 이렇게 적으세요.
url: jdbc:postgresql://testcontainers-will-override-this
username: unused
password: unused
driver-class-name: org.postgresql.Driver
jpa:
# 이건 Testcontainers가 안 해줍니다. 꼭 설정해야 해요!
hibernate:
ddl-auto: create-drop # 테스트 시작 시 테이블 생성, 종료 시 삭제
show-sql: true
properties:
hibernate:
format_sql: true
2. ddl-auto 설정은 필수: Testcontainers는 깡통 DB(Empty DB)를 띄운다. (테이블X). 그래서 application-test.yml에 jpa.hibernate.ddl-auto: create-drop (혹은 update) 설정을 둬서 스프링이 실행될 때 엔티티를 보고 테이블을 쫙 깔아주도록 해야 한다. 이 설정이 없으면 "Table not found" 에러가 난다.
이렇게 하면 YAML 역할이 축소가 되어 application-test.yml에서는 DB 주소 설정보다는 JPA 설정(ddl-auto, show-sql)에 집중하면 된다.
6. H2와 Testcontainers 비교
| 특징 | H2 (In-memory) | Testcontainers (Docker) |
| 정확성 | 낮음 (DB 고유 기능 미지원) | 매우 높음 (운영 환경과 동일) |
| 속도 | 매우 빠름 (밀리초 단위) | 상대적으로 느림 (컨테이너 부팅 시간 필요) |
| 리소스 | 적음 (JVM 힙 메모리 사용) | 많음 (Docker 엔진 및 별도 프로세스) |
| 용도 | 단순 CRUD, 로직 검증 | 복잡한 쿼리, 동시성 이슈, 특수 타입 검증 |
| 설정 | 매우 쉬움 (build.gradle 추가 끝) | Docker 설치 및 환경 구성 필요 |
만약 정말 간단한 테스트를 할 것이라면 굳이 Testcontainers 방식을 도입할 필요는 없다.
그러나 나는 실제 운영하는 데이터베이스의 환경에서 테스트를 하고 싶거나 최근 나온 데이터베이스들은 기본적인 RDB 기능 뿐만 아니라
다양한 기능들을 제공해준다. 만약 그 기능을 테스트하고 싶다면 예외없이 이 방식을 사용해야 될 것이다.
물론 애초에 테스트용 디비를 따로 띄우는 것 또한 방법이지만 그럼 클라우드 비용이 그만큼 더 들어간다.
또한 Testcontainers 방식으로 다른 nosql 데이터베이스들 (mongoDB, redis 등등)도 테스트를 할 수 있다.
바이브 코딩이 유행하면서 테스트의 중요성도 더욱 올라간 현 시점에서 한번쯤 고려해볼만한 가치가 있는 방법이라고 생각한다.
모든 것은 trade-off이기 때문에 그 부분을 잘 고려해서 도입을 할지 말지 결정하길 바란다.
'tech > project' 카테고리의 다른 글
| [Auth] 로그인 구현 대신 SaaS 서비스 도입에 대한 고찰 (0) | 2026.01.22 |
|---|---|
| [Auth] 로그인 화면 주도권을 누구에게 줄 것인가 (0) | 2026.01.19 |
| [Test] redis(valkey)도 Testcontainers 도입하기 (0) | 2026.01.17 |
| [postgreSQL] varchar VS text 무엇이 더 좋을까 (0) | 2026.01.07 |
| [token] 토큰을 어떻게 저장하는 것이 좋을까? (1) | 2025.12.22 |