로컬 DB를 스프링 테스트 DB로 활용해도 괜찮을까? - 스프링 부트에 MySQL Testcontainers 적용하기

박계현·2025년 12월 1일
post-thumbnail

들어가며

스프링 부트로 테스트를 작성할 때 @SpringBootTest 어노테이션과 @Transactional 을 함께 사용하면 테스트가 끝나고 기본 롤백에 되도록 설정되어 있습니다. 그런데 테스트 데이터가 하나씩 남아있는 문제가 발생했습니다.

원인이 뭘까, 역시 이래서 @Transactional 쓰지 말라는 사람들이 있는걸까? 한참 분석하던 끝에 그냥 제가 특정 클래스에 @Transactional을 빼먹었었다는 것을 깨달았습니다(ㅎㅎㅎ;;).

좀 허무하지만 이런 걸 겪고 나니 만약 기존에 다른 데이터가 들어가 있던 로컬 DB 라던가, 여러 개발자가 함께 쓰는 테스트용 서버에서 스프링 테스트를 돌리다가 누군가의 실수로 인해 DB에 자꾸 데이터가 쌓이게 되는 문제가 발생할 수도 있지 않을까 하는 생각을 하게 되었습니다.

해결책

@Transactional을 안 쓰기

하나는 @Transactional을 안 쓰고 명시적 DB 클린업을 돌리는 것입니다. 그런데 truncate table 같은 걸로 매번 테이블을 밀어버리면, 데이터를 남겨두고 싶을 때(예를 들어 프론트엔드 분들을 위해 좀 진짜 같은 목 데이터를 잔뜩 넣어놓거나 했을 때), 테스트 한 번 잘못 돌렸다가는 전부 같이 다 날라가고, 그럼 또 다시 넣는 작업을 해야한다는 문제점이 남아있습니다.

@Transactional을 쓰냐 안 쓰냐는, 저 같은 경우 아직 경험이 부족해서 뭐가 더 좋은지 잘 모르겠으나, 이 경우와는 살짝 다른 종류의 논쟁이라는 생각이 들어 여기서 더 다루지 않습니다.

H2를 테스트용 DB로 쓰기

두 번째는 H2와 같은 메모리에서 동작하는 RDBMS를 사용하는 것입니다. 스프링 부트 앱을 실행할 때는 MySQL을, 테스트를 실행할 때는 H2를 사용하도록 할 수 있습니다. H2는 메모리에 데이터를 저장하기 때문에 실행이 끝나면 모든 데이터가 사라집니다. MySQL 등 별도로 사용할 DB가 있는 경우 완전히 격리시킬 수 있기 때문에 유용합니다.

프로파일을 이용한 DB 분리

application.yml 혹은 application-test.yml에서 테스트 전용 DB를 설정합니다.

# application-test.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create-drop

테스트 클래스에서는 @ActiveProfiles("test")를 붙여 H2를 사용하도록 합니다.

@SpringBootTest
@ActiveProfiles("test")
class UserServiceTest {
    // 테스트 코드
}

이렇게 하면 테스트 실행 시 메모리 DB가 생성되고, 테스트 종료 후 자동으로 삭제되어 기존 데이터와 완전히 격리됩니다.

다만, MySQL이랑 아주 약간 동작에 차이가 있을 수 있다는 문제가 있습니다. H2는 기본 ANSI SQL을 지원하도록 구현이 되어있어서 MySQL 전용 함수는 지원하지 않거나, 동작 방식이 다른 경우가 종종 있습니다. 그래서 H2에서 테스트가 통과해도, 실제 MySQL에서는 쿼리 오류가 발생하거나 결과가 달라질 수 있고 그 반대도 마찬가지 입니다.

Testcontainers 활용

Testcontainers는 이름처럼 테스트를 위한 컨테이너를 자동으로 띄워서 실제 환경과 동일하면서도 독립된 환경에서 테스트할 수 있게 해줍니다. 대신 단점으로는 매번 테스트용 컨테이너를 띄워야하기 때문에 테스트 실행 시간이 다소 느려집니다.

Testcontainers 추가

스프링에서 Testcontainers 사용을 위해 의존성을 추가해줍니다.

dependencies {
    testImplementation 'org.testcontainers:junit-jupiter:1.21.3' # <- 저는 이거 나중에 제거합니다
	testImplementation 'org.testcontainers:mysql:1.21.3'
}

AbstractIntegrationTest 작성

모든 테스트에서 동일하게 컨테이너를 사용할 것이기 때문에, 저는 통합 환경을 구성해보겠습니다. 통합 환경을 구성하는 방법은 여러 가지가 있지만 여기서는 추상 클래스를 작성하고 이를 상속받아 활용하는 방법을 소개하겠습니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
public abstract class AbstractIntegrationTest {
    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.37")
            .withDatabaseName("test")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
        registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
    }
}

테스트가 추상 클래스를 상속받도록 적용

@SpringBootTest
@Transactional
class CommentServiceTest extends AbstractIntegrationTest {

    @Autowired
    private CommentService commentService;

    @Autowired
    private CommentRepository commentRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PostRepository postRepository;

    @Test
    @DisplayName("게시글에 댓글을 작성할 수 있다.")
    void canCreateComment() {
    User user = User.create("testUser", "test@email.com", "TEST", "test-id");
    userRepository.save(user);

    // 생략

결과 확인

아까와 비슷한 상황을 재현하기 위해 일부러 유니크 값인 username이 겹치도록 User를 하나 로컬 MySQL에 생성해놓고 테스트를 실행해보겠습니다. (Docker 엔진이 실행 중이어야 합니다)

  • Testcontainers 적용 전

  • Testcontainers 적용 후

로컬 DB에 저장된 값이랑 상관 없이 테스트가 통과하는 모습입니다.

(테스트용 컨테이너가 동작하는 모습)

이제 다른 모든 통합 테스트가 위 추상 클래스를 상속 받도록 수정해주면 끝입니다.

테스트 클래스마다 컨테이너를 띄우고 내렸다가 커넥션을 잃는 문제

잘된 줄 알고 마무리하려 했는데 각 테스트 클래스를 단독으로 실행하면 문제없이 통과하던 테스트가 전체 테스트 실행에서 실패해버리는 문제가 발생하였습니다. 살펴보면 두 번째 컨테이너를 다시 띄우고 나서 DB와의 커넥션에서 타임아웃이 발생하는 것을 확인할 수 있었습니다.

@SpringBootTest는 성능상 이점을 위해 기본적으로 동일 환경에서 캐시된 ApplicationContext를 재활용하는데, 컨테이너가 내려갔다 올라갔다 하는 건 모르기 때문에, 기존 MySQL 컨테이너로 커넥션풀을 만들어 놓고, 해당 컨테이너가 죽었음에도, 동일한 커넥션풀을 이용하려고 하는 것이 원인으로 생각됐습니다.

@DirtiesContext 로 테스트마다 컨텍스트를 강제 초기화 시킬 수도 있는데 이러면 굳이 테스트 클래스마다 새로운 DB 컨테이너를 띄워야하고, 그럴 이유가 없는 상태에서 안 그래도 느린데 몇 배로 느려지게 됩니다.

문제는 static 으로 선언된 컨테이너는 재사용이 된다고 알고 있는데도 컨테이너를 자꾸 새로 띄우고 있었습니다. .withReuse(true)MySQLContainer 생성에 붙여줘도 결과는 똑같았습니다.

알고 보니 진짜 원인은 @Container 에 있었습니다. 공식 문서를 살펴보면 다음과 같이 나와있습니다:

  • Instead of starting and stopping the Postgres container using the @BeforeAll and @AfterAll callback methods, we have added the @Testcontainers annotation to the class and the @Container annotation on the static PostgreSQLContainer field.

  • The @Testcontainers extension will look for all container typed fields in the class containing the @Container annotation. If the field is a static field then that container will be started once before running all tests of the test instance and will be stopped after executing all of them. If the field is an instance field then a new container is started before every test method and stopped after executing the test.

출처: 공식 문서

static 필드로 선언된 컨테이너는 모든 테스트(every test)에서 사용되고, 멤버 필드로 선언된 컨테이너는 각 테스트마다 새로 생성된다고 설명되어 있습니다. 근데 이 모든 테스트가 하나의 테스트 클래스 파일 내에서만 적용이 됩니다. 그래서 AbstractIntegrationTest를 상속받는 모든 클래스마다 컨테이너가 새로 실행된 후 제거되고, 커넥션풀은 이 사실이 업데이트 되지 않아서 생기는 문제였습니다.

JUnit을 통한 라이프사이클 기능을 사용하지 않고, 클래스에서 직접 static 컨테이너를 생성하고, 클래스가 로드될 때 한 번 실행시켜주는 방식으로 해결하였습니다.

최종 수정된 AbstractIntegrationTest

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;

@SpringBootTest
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {
    static final MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.37")
            .withDatabaseName("test")
            .withUsername("test")
            .withPassword("test");

    static {
        mysql.start();
    }

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

이렇게 되면 사실상 testImplementation 'org.testcontainers:junit-jupiter:1.21.3' 라이브러리도 사용하지 않는거라 저는 일단 그냥 제거해줬습니다.

마치며

testcontainers는 꼭 한 번 써보고 싶긴 했는데, 마침 기회가 되서 좋았습니다. 대신 생각보다 이런 저런 문제를 많이 만나서 머리 아팠지만, 결국 또 해결하고 나니 뿌듯합니다. 스프링 내부 동작에 대해 복습하게 된 것도 좋았던 것 같습니다. MySQL 컨테이너를 static으로 바꾼 후에는 테스트 속도도 크게 신경 쓰이지 않을 만큼 빨라졌습니다.

참고 자료

profile
안녕하세요! 차근차근 성장하는 소프트웨어 엔지니어 박계현입니다😊

0개의 댓글