테스트가 운영 DB를 오염시켰다 — 공용 DB를 버리고 Testcontainers 격리로

문제: 개발 DB에 남은 테스트 데이터가 운영으로 넘어갔다

통합 테스트를 공용 개발 DB에 연결해 실행하고 있었다. 이미 있는 DB를 쓰니 테스트 환경을 따로 구성하지 않아도 됐다. 대신 테스트가 테이블에 넣은 데이터는 실행이 끝난 뒤에도 남았다.

문제는 개발 DB의 데이터를 운영으로 이관할 때 드러났다. 개발 환경에서 정비한 마스터(기준정보) 테이블을 운영에 반영하는 작업이 있었고, 그 과정에서 테스트 레코드도 함께 넘어갔다. 테스트가 운영 DB에 직접 접속한 것은 아니지만, 개발 DB와 데이터 이관 작업을 거쳐 운영 데이터에 영향을 줬다.

테스트용 데이터와 이관할 기준정보가 같은 DB에 섞여 있는 구조를 바꿔야 했다. 테스트를 실행할 때마다 공용 DB에 흔적을 남기는 대신, 테스트가 사용할 저장소를 따로 마련하기로 했다.

도표 크게 보기

접근: 공용 DB에서 테스트용 PostgreSQL로

H2 같은 인메모리 DB도 대안이었다. 다만 PostgreSQL의 함수, 타입, SQL 동작을 확인하려면 같은 엔진을 쓰는 편이 목적에 맞았다. 나는 Testcontainers로 PostgreSQL 컨테이너를 띄우고 테스트의 접속 대상을 그쪽으로 바꾸는 방식을 택했다.

같은 엔진을 쓴다고 운영 환경 전체가 재현되지는 않는다. DB 버전과 확장 기능, 스키마, 설정, 데이터 규모는 별도로 맞춰야 한다. 여기서 얻으려던 것은 운영의 모든 조건을 복제하는 일이 아니라, 공용 개발 DB를 변경하지 않고 PostgreSQL에서 SQL을 검증할 환경이었다.

아래는 클래스가 컨테이너를 공유하는 설정 예제다. 당시 코드를 그대로 옮긴 것은 아니며, Testcontainers 2.x의 org.testcontainers.postgresql.PostgreSQLContainer를 사용해 재구성했다. 원문에 있던 PostgreSQL 9.6은 지원이 종료된 버전이므로 예제에서는 postgres:16-alpine을 사용한다. 실제 프로젝트에서는 검증 대상의 버전과 필요한 확장 기능에 맞춰 이미지를 선택해야 한다. 관련 테스트 의존성과 PostgreSQL JDBC 드라이버, Spring Boot 애플리케이션이 준비돼 있다고 가정한다.

@SpringBootTest
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class UserRepositoryTest {

    @Container
    static PostgreSQLContainer postgres =
        new PostgreSQLContainer("postgres:16-alpine")
            .withDatabaseName("test");

    @DynamicPropertySource
    static void datasource(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    // 아래의 데이터 준비·테스트 코드를 여기에 넣는다.
}

Testcontainers는 노출한 컨테이너 포트를 호스트의 사용 가능한 임의 포트에 연결한다. 위 예제는 포트나 호스트를 직접 조합하지 않고 getJdbcUrl()로 접속 정보를 얻는다. @DynamicPropertySource는 그 값을 Spring의 데이터소스 설정에 전달한다.

공용 DB 접속 정보 대신 컨테이너를 실행할 환경이 필요해졌다. 로컬과 CI(지속적 통합) 환경에서 Docker 호환 런타임에 접근할 수 있어야 하고, 이미지 다운로드와 DB 기동 시간도 감수해야 한다. 테스트 실행 전에 DB를 수동으로 설치하는 절차를 컨테이너 준비 절차로 바꾼 셈이다.

구현: 컨테이너 수명과 데이터 초기화를 나누어 판단한다

JUnit Jupiter용 Testcontainers 확장은 @Container 필드의 선언 방식에 따라 수명을 관리한다. 아래 비교는 컨테이너 재사용 기능이나 외부 데이터 볼륨 없이 순차 실행하는 경우다.

방식 컨테이너 수명 테스트 데이터 비용
Restarted 인스턴스 필드로 선언해 메서드마다 시작·종료 각 컨테이너에 초기 데이터 준비 DB 기동 반복
Shared static 필드로 선언해 클래스의 메서드끼리 공유 앞 메서드가 남긴 데이터가 이어질 수 있음 클래스 내 기동 비용 절감

@DirtiesContext는 별개의 기능이다. Spring 컨텍스트를 캐시에서 제거하고 닫도록 지정하며, JUnit 확장이 관리하는 컨테이너를 직접 재시작하거나 DB 데이터를 지우지는 않는다. DB가 다시 만들어지면서 접속 정보가 바뀌는 구성이라면, Spring 데이터소스도 유효한 접속 정보를 사용하도록 수명을 맞춰야 한다. 위 Shared 예제의 static 선언만 지워서 Restarted로 바꿀 수는 없다. 정적 프로퍼티 등록 메서드와 컨테이너 시작 시점도 함께 조정해야 한다.

Shared의 간섭을 확인하려면 데이터 준비와 실행 순서가 드러나야 한다. 다음 코드는 앞 클래스에 넣는 재현 예제다. JdbcTemplate으로 초기 데이터 2건을 한 번만 만들며, 테스트 트랜잭션의 자동 롤백이나 메서드 사이의 초기화는 적용하지 않는다.

@Autowired
JdbcTemplate jdbc;

@BeforeAll
static void prepareData() {
    var source = new DriverManagerDataSource(
        postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
    var setup = new JdbcTemplate(source);
    setup.execute("create table users (id integer primary key, name text)");
    setup.update("insert into users (id, name) values (1, 'first'), (2, 'second')");
}

@Test
@Order(1)
void 데이터를_추가한다() {
    jdbc.update("insert into users (id, name) values (3, 'third')");
    assertThat(userCount()).isEqualTo(3);
}

@Test
@Order(2)
void 초기_상태를_기대하면_실패한다() {
    assertThat(userCount()).isEqualTo(2); // 실제 3건: 의도한 실패
}

int userCount() {
    return jdbc.queryForObject("select count(*) from users", Integer.class);
}

첫 메서드의 변경이 남아 두 번째 메서드는 실패한다. @TestMethodOrder(MethodOrderer.OrderAnnotation.class)는 이 현상을 재현할 순서를 고정한다. 순서를 지정한 것은 독립성을 확보하는 해결책이 아니라, 간섭을 눈으로 확인하기 위한 장치다. 이 예제가 보여 주는 것은 메서드 사이의 상태 의존성이다. 이를 곧바로 개별 업무 연산의 멱등성 문제와 같은 뜻으로 쓰지는 않는다.

실제 프로젝트에서는 기동 비용을 줄이기 위해 Shared를 선택했다. 공유 상태에서 생기는 간섭을 먼저 확인하고 테스트 데이터가 충돌하지 않도록 작성했다. 다만 당시 모든 테스트에 일괄 초기화나 롤백을 적용했다고 확인할 기록은 없어, 독립성을 완전히 보장했다고 표현하지는 않는다.

Shared를 쓰더라도 메서드마다 데이터를 정리하고 같은 초기 상태를 준비할 수 있다. Spring의 테스트 트랜잭션 롤백도 대안이지만, 별도 스레드나 HTTP 요청의 서버 측 트랜잭션까지 함께 되돌린다고 가정해서는 안 된다. 컨테이너를 공유할지와 변경 데이터를 어떻게 정리할지는 나누어 결정할 문제다.

반복되는 설정은 JUnit 확장과 커스텀 어노테이션으로 묶었다. 테스트 클래스마다 접속 설정을 복사하는 양을 줄이려는 목적이었다. 설정을 공통화해도 컨테이너의 시작·종료와 데이터 초기화 책임은 남는다.

결과

도입한 통합 테스트의 접속 대상을 공용 개발 DB에서 컨테이너 DB로 옮겼다. 해당 테스트가 개발 DB에 데이터를 쌓고, 그 데이터가 기준정보 이관에 섞이는 경로를 없앴다. 이관 과정의 모든 데이터 오류를 해결했다는 의미는 아니다.

PostgreSQL을 테스트 과정에서 준비하게 되면서 공용 DB 접속 정보를 받아 테스트를 실행하던 의존도 줄었다. Shared를 택해 클래스 안에서 컨테이너 기동을 반복하지 않았지만, 실행 시간의 전후 측정값은 남아 있지 않아 절감 수치를 제시하지 않는다. CI 전체의 빌드 간 간섭이나 운영 환경과의 완전한 일치까지 검증한 성과로 확대하지 않았다.

학습

오염이 전달되는 경로까지 본다. 테스트가 운영 DB에 직접 연결하지 않아도 개발 데이터의 이관 과정에서 영향을 줄 수 있다. 이 사례에서는 테스트 저장소를 분리해 개발 DB에 테스트 레코드가 남는 지점을 바꿨다.

외부 환경과의 격리, 테스트 사이의 독립성은 따로 확인한다. 일회용 컨테이너를 도입한 것만으로 Shared 안의 데이터가 초기화되지는 않는다. 두 번째 테스트의 실패가 그 차이를 보여 줬다.

선택한 방식의 남은 책임을 명시한다. Shared를 선택하면 기동 횟수는 줄지만 데이터 준비·정리 정책이 필요하다. 다음에 같은 구성을 만들 때는 초기 상태로 복구하는 절차와 테스트 순서를 바꿔도 결과가 같은지 확인하는 기준을 함께 정하려 한다.

← 전체 글 목록