🧪

Flyway로 Testcontainer에서 기본데이터 구축하기

최민석·2025-10-27

Flyway로 Testcontainer에서 기본데이터 구축하기

테스트를 진행할때 Mock데이터나 fixture를 준비하는 과정은 다소 번거롭습니다.

Testcontainer를 사용하여 테스트 환경의 DB를 격리할 수 있으나, API 레이어를 경유하는 E2E 통합 테스트(예: @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)와 TestRestTemplate이나 WebClient를 활용한 실제 HTTP 요청 시나리오)에서는 트랜잭션 경계가 모호해져 @Transactional 롤백 처리가 불완전하거나 예상치 못한 동작을 보일 수 있기 때문에 또 다른 고려 사항이 필요합니다. 이는 MockMvc처럼 서버를 모킹하는 통합 테스트에서는 @Transactional로 충분히 효과적인 롤백을 지원하지만, E2E에서는 실제 네트워크 레이어(포트 바인딩, HTTP 헤더 처리, 보안 필터 등)를 포함해 전체 스택을 검증해야 하므로 MockMvc가 커버하지 못하는 배포 환경의 문제(예: 포트 충돌, 타임아웃, 실제 인증 흐름)를 테스트할 때 특히 유용합니다.

고려해야 할 요소

  • 각 테스트 메서드간 병렬처리 시 데이터 격리성을 어떻게 보장할 것인가?
  • 격리성 보장과 테스트 시간에 대한 트레이드 오프 비용을 어떻게 타협할 것인가?

build.gradle

본 솔루션에서는 flywayTestcontainers를 사용합니다.

dependencies {
	testImplementation "org.springframework.boot:spring-boot-testcontainers"
	testImplementation "org.testcontainers:junit-jupiter"
	testImplementation "org.testcontainers:mysql"
	testImplementation "org.flywaydb:flyway-core"
}

테스트 DB 초기화 전략

1. 격리성을 보장하고, 병렬실행 및 처리속도를 포기하기

먼저 각 테스트 클래스들이 사용할 기반이 되는 클래스를 만들고 개선해보겠습니다.

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()를 실행하면 부팅 이후 모든 스키마가 다시 재구성됩니다.

2. 테스트를 가속하고, 격리성을 일부 포기

여기서 약간의 타협을 할 수도 있습니다.

클래스간 데이터 격리성만 보장하고, 클래스 내의 메서드들 간에는 직접 데이터 오염을 염두에 두어 개발하는 것입니다.

이 접근은 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__) 사용하기

대부분의 테스트케이스에서 사용되지만, 실제 운영에선 사용하지 않을 기본 데이터가 필요한 경우가 있습니다.

예컨대 슈퍼관리자 계정이라든가, 권한코드, 테스트를 위해 필요한 테스트계정의 서비스 내 재화 같은 것들이 있겠죠.

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변경이 누적되는 경우 큰 오버헤드가 발생할 수 있기 때문입니다.

profiles 설정

# 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/historysrc/ 패키지의 것을 같이 공유하도록 하고, classpath:configs/flyway/setup은 테스트 패키지 내에 다음과 같이 따로 넣어두었습니다.

Repeatable Migration

또, 다음과 같이 @ActiveProfiles를 테스트에 지정하는 것을 잊지 말아야합니다:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)  // E2E 시 실제 포트 사용
@Testcontainers
@ActiveProfiles("test")
public class AdminIntegrationTest extends FlywaySanitizer {
  // ...
}