테스트를 진행할때 Mock데이터나 fixture를 준비하는 과정은 다소 번거롭습니다.
Testcontainer를 사용하여 테스트 환경의 DB를 격리할 수 있으나, API 레이어를 경유하는 E2E 통합 테스트(예: @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)와 TestRestTemplate이나 WebClient를 활용한 실제 HTTP 요청 시나리오)에서는 트랜잭션 경계가 모호해져 @Transactional 롤백 처리가 불완전하거나 예상치 못한 동작을 보일 수 있기 때문에 또 다른 고려 사항이 필요합니다. 이는 MockMvc처럼 서버를 모킹하는 통합 테스트에서는 @Transactional로 충분히 효과적인 롤백을 지원하지만, E2E에서는 실제 네트워크 레이어(포트 바인딩, HTTP 헤더 처리, 보안 필터 등)를 포함해 전체 스택을 검증해야 하므로 MockMvc가 커버하지 못하는 배포 환경의 문제(예: 포트 충돌, 타임아웃, 실제 인증 흐름)를 테스트할 때 특히 유용합니다.
본 솔루션에서는 flyway와 Testcontainers를 사용합니다.
dependencies {
testImplementation "org.springframework.boot:spring-boot-testcontainers"
testImplementation "org.testcontainers:junit-jupiter"
testImplementation "org.testcontainers:mysql"
testImplementation "org.flywaydb:flyway-core"
}
먼저 각 테스트 클래스들이 사용할 기반이 되는 클래스를 만들고 개선해보겠습니다.
public abstract class FlywaySanitizer {
@Autowired
Flyway flyway;
@BeforeEach
void resetDb() {
flyway.clean();
flyway.migrate();
}
}
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // E2E를 위한 실제 서버 포트 설정 (DEFINED_PORT는 고정 포트로 충돌 위험이 있으므로 RANDOM_PORT 추천)
@Testcontainers
class TestClassA extends FlywaySanitizer { // A: MySQL 컨테이너 사용
@Container
@ServiceConnection // Spring Boot에 자동 연결
private final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.0.32");
@Autowired
private TestRestTemplate restTemplate; // E2E에서 실제 HTTP 클라이언트로 사용 (MockMvc 대신)
// 테스트 메서드들...
@Test
void testWithMySQL() {
// 실제 포트로 HTTP 요청 로직 (예: restTemplate.postForEntity(...))
}
}
@Testcontainers: 테스트 컨테이너를 사용할 것임을 명시합니다.MySQLContainer<?> MYSQL: 각 테스트 클래스마다 서로 다른 테스트 컨테이너를 사용합니다. 이로써 클래스간 DB 환경을 격리할 수 있습니다.resetDb(): @BeforeEach로 인해 각 테스트 메서드가 실행될때마다 DB 테이블 전체를 DROP하고 flyway 마이그레이션을 다시 진행합니다.문제가 없어 보이나요? resetDb()로 인해 같은 클래스의 각 테스트 메서드가 병렬 실행이 되면, 테스트 진행중 테이블이 모두 날아가버리는 대참사가 발생할 수 있습니다.
이를 방지하기 위해서 아래와 같이 build.gradle에 추가할 수도 있습니다:
test {
// 병렬 실행 활성화
systemProperty "junit.jupiter.execution.parallel.enabled", "true"
// 각 클래스 내부 메서드는 한 스레드에서 순차 실행 보장(직렬 보장)
systemProperty "junit.jupiter.execution.parallel.mode.default", "same_thread"
// 서로 다른 테스트 클래스는 병렬로 실행
systemProperty "junit.jupiter.execution.parallel.mode.classes.default", "concurrent"
}
그러나 모든 테스트 메서드마다 테이블 DROP 및 재생성이 반복되므로 테스트 시간을 생각한다면 적절하지 않을 수 있습니다.
이에 관해서는 정답이 없다고 생각하고, flyway V__ 및 R__의 수나 필요 데이터의 수를 저울질하여 결정해야합니다.
특히 E2E 테스트처럼 RANDOM_PORT로 실제 서버를 띄워 RestTemplate으로 엔드포인트를 호출할 때는 트랜잭션 외부의 비동기 처리나 필터 체인에서 발생하는 DB 변경이 @Transactional 롤백을 피할 수 있어 Flyway 리셋이 더 안정적입니다.
💡 참고로 Spring Boot는 컨텍스트 부팅 시 자동으로 flyway.migrate()를 수행하므로, resetDb()에서 명시적으로 호출하는 migrate()는 이를 다시 수행하는 동작입니다. 즉, @BeforeEach에서 clean()+migrate()를 실행하면 부팅 이후 모든 스키마가 다시 재구성됩니다.
여기서 약간의 타협을 할 수도 있습니다.
클래스간 데이터 격리성만 보장하고, 클래스 내의 메서드들 간에는 직접 데이터 오염을 염두에 두어 개발하는 것입니다.
이 접근은 MockMvc를 사용하는 가벼운 통합 테스트에서 특히 효과적이며, @Transactional 어노테이션을 클래스나 메서드에 적용해 롤백으로 간단히 격리성을 보장할 수 있습니다. MockMvc는 서버를 모킹하므로 E2E의 네트워크 오버헤드가 없어 빠르지만, 실제 포트나 HTTP 전송 지연 같은 배포 문제를 테스트하지 못하는 단점이 있어 필요 시 E2E로 보완하세요.
그에 따라 다음과 같이 코드를 변경할 수 있습니다:
@SpringBootTest
@AutoConfigureMockMvc // MockMvc를 위한 설정 (E2E 대신 사용 시)
@Transactional // 롤백으로 메서드 간 격리 보장
@Testcontainers
class TestClassA { // FlywaySanitizer 상속 제거
@Container
@ServiceConnection // Spring Boot에 자동 연결
private final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.0.32");
@Autowired
private MockMvc mockMvc; // MockMvc 클라이언트 주입
// 테스트 메서드들...
@Test
void testWithMySQL() {
// MockMvc를 사용한 HTTP 시뮬레이션 로직 (예: mockMvc.perform(...))
}
}
이 경우, 각 테스트 메서드마다 DB 초기화가 일어나지 않으므로 병렬처리도 가능하고 병럴처리로 인한 속도 향상 외에도, 병목이 되던 DB 초기화의 삭제로 속도 향상이 한 번 더 일어납니다. 그러나 한 클래스 내의 각 테스트 메서드에서 사용되는 데이터간 격리성은 테스트 코드 수준에서 보장해야합니다.
@Transactional을 사용하면 대부분의 경우 롤백으로 충분하지만, 비동기나 외부 의존성이 개입된 E2E 시나리오에서는 여전히 데이터 오염 위험이 있으므로 Flyway 리셋을 고려하세요.
예를 들면, AUTO_INCREMENT에 의해 생성되는 PK 값이 매 테스트마다 달라질 수 있고, UNIQUE 제약 조건에 의해 같은 내용의 데이터가 들어가지 못 할 수도 있습니다.
이런 경우 다음과 같이 코드 수준에서 제어해야 합니다:
@Test
@DisplayName("유저 생성 테스트 1")
void 유저_생성_테스트_1() throws Exception {
AdminCreateRequest adminCreateRequest = new AdminCreateRequest(
"유저생성테스트1",
"user1@example.com",
"testPass12!@",
"01022223333"
);
// ... 생략
}
@Test
@DisplayName("유저 생성 테스트 2")
void 유저_생성_테스트_2() throws Exception {
AdminCreateRequest adminCreateRequest = new AdminCreateRequest(
"유저생성테스트2",
"user1@example.com",
"testPass12!@",
"01088889999"
);
// ... 생략
}
두 테스트 메서드가 병렬로 실행이 될 테니, 어떤 데이터가 먼저 들어갈지 알 순 없습니다.
테이블 상에서 UNIQUE제약 조건이 들어가 있든, 애플리케이션 레벨에서 중복 검사를 하든, 이메일 중복을 허용하지 않는다고 가정해봅시다.
두 테스트 모두에서 유저의 이메일로 user1@example.com을 사용하므로, 둘 중 하나의 테스트는 실패하게 될 것입니다.
따라서 번거롭지만 테스트별로 이러한 데이터를 임의로 하드코딩하여 지정하든가, 아니면 RandomUtil 클래스를 하나 만들어 무작위성이 보장되는 데이터를 생성하는 팩토리 패턴을 사용하는 것도 한 방법일 것 같습니다.
저는 편의상 테스트 시간을 포기하고 resetDb()를 사용하여 데이터간 정합성을 높이는 쪽을 선택했습니다.
대부분의 테스트케이스에서 사용되지만, 실제 운영에선 사용하지 않을 기본 데이터가 필요한 경우가 있습니다.
예컨대 슈퍼관리자 계정이라든가, 권한코드, 테스트를 위해 필요한 테스트계정의 서비스 내 재화 같은 것들이 있겠죠.
Flyway는 커뮤니티 버전에서도 Repeatable Migration(R__)기능을 제공하는데요, 기존의 Versioned Migration과의 차이는 다음과 같습니다:
| 구분 | 설명 | 예시 |
|---|---|---|
| V__ (Versioned Migration) | DB 스키마 변경(DDL) — 한 번만 실행되어야 함 | 테이블 추가, 컬럼 변경 등 |
| R__ (Repeatable Migration) | 참조 데이터/시드 데이터(DML) — 내용이 바뀌면 다시 실행되어야 함 | 기본 권한, 슈퍼관리자, 지역코드, 국가리스트 등 |
예를 들어 슈퍼관리자 계정과 기본 권한을 미리 만들어야 할 때:
-- R__seed_super_admin.sql
INSERT INTO admin (email, password, role)
VALUES ('admin@admin.com', '{bcrypt}...', 'SUPER')
ON DUPLICATE KEY UPDATE role = VALUES(role);
과 같은 식으로 사용이 가능합니다. R__ 은 항상 V__의 실행이 모두 끝나고 난 뒤에 실행되는 것이 보장되기 때문에, 이를 염두에 두고 작성해야합니다. 또, R__간의 순서는 보장되지 않습니다.
R__의 특성을 활용해 테스트 기반 데이터로 활용할 수 있다는게 주 요지입니다.
🦑 개인적인 생각으로는, 테스트 전역적으로 필요한 데이터만 R__로 처리하고, 각 테스트 메서드마다 특화된 준비 데이터는
@SQL을 사용하는 것이 더 바람직하다고 생각합니다.
DB변경이 누적되는 경우 큰 오버헤드가 발생할 수 있기 때문입니다.
# local profile
# flyway-local.yaml
spring:
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:configs/flyway/history
validate-on-migrate: true
clean-disabled: true # 운영환경에서는 꼭 true
logging:
level:
org.flywaydb: DEBUG
org.springframework.jdbc.datasource.init.ScriptUtils: DEBUG
local 프로파일에서는 위와 같은 옵션으로 flyway를 사용하고 있는데요, test에서 사용하기 위해서는 flyway-test.yaml을 하나 더 만들어주어야 합니다.
# test profile
# flyway-test.yaml
spring:
flyway:
enabled: true
baseline-on-migrate: true
validate-on-migrate: true
clean-disabled: false
locations:
- classpath:configs/flyway/history # V__* 스키마 마이그레이션
- classpath:configs/flyway/setup # R__/추가 시드/테스트용 데이터
logging:
level:
org.flywaydb: DEBUG
org.springframework.jdbc.datasource.init.ScriptUtils: DEBUG
classpath:configs/flyway/history는 src/ 패키지의 것을 같이 공유하도록 하고, classpath:configs/flyway/setup은 테스트 패키지 내에 다음과 같이 따로 넣어두었습니다.

또, 다음과 같이 @ActiveProfiles를 테스트에 지정하는 것을 잊지 말아야합니다:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // E2E 시 실제 포트 사용
@Testcontainers
@ActiveProfiles("test")
public class AdminIntegrationTest extends FlywaySanitizer {
// ...
}