본문 바로가기
Framework/Spring Boot

[Spring Boot] Testcontainers: 완벽히 고립된 통합 테스트의 마법

반응형

"분명히 내 컴퓨터에선 테스트가 100% 통과했는데, 젠킨스(CI)에 올리기만 하면 빌드가 실패해요!" 개발팀에서 하루가 멀다고 들리는 비명입니다. 20단계에서 배운 단위 테스트(Unit Test)는 순수한 자바 코드만 검증하므로 환경을 타지 않지만, 실제 쿼리가 날아가는지, Redis에 값이 잘 꽂히는지 확인하는 통합 테스트(Integration Test, @SpringBootTest)는 외부 인프라(MySQL, Redis)의 상태에 극도로 의존합니다. 철수의 노트북엔 MySQL 8.0이 있고 영희의 노트북엔 MySQL 5.7이 깔려있으며, 젠킨스 서버에는 아예 DB가 안 깔려있다면? 이 '테스트 환경 불일치'를 해결하기 위해 과거에는 H2 인메모리 DB로 흉내를 내봤지만, H2는 실제 MySQL 전용 함수나 락(Lock) 기능을 똑같이 재현하지 못해 반쪽짜리 테스트에 불과했습니다. "자바 테스트 코드를 딱 실행하는 순간, 백그라운드에서 진짜 MySQL 도커(Docker) 컨테이너를 하나 띄워서 거기에 테스트를 다 한 다음, 쿨하게 컨테이너를 죽여버리면 완벽하지 않을까?!" 이 천재적인 발상을 현실로 만든 프레임워크가 바로 Testcontainers(테스트컨테이너)입니다.

이번 [Spring Boot 실무 완벽 가이드] 시리즈에서는 "글로벌 IT 기업 테스트 표준!" Testcontainers를 활용한 무결점 통합 테스트 환경 구축에 대하여 다뤄보겠습니다.

🐳 1. Testcontainers의 철학: 진짜 인프라를 쓰되 1회용으로!

로컬 PC나 젠킨스 서버에 반드시 Docker(도커)가 깔려있어야만 작동합니다. 스프링이 뜨기 전에 자바 코드가 도커를 조종합니다.

Testcontainers 개념도
// 1. 의존성 추가 (build.gradle)
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'
// 2. 통합 테스트 코드 작성
@SpringBootTest
@Testcontainers // 🌟 JUnit에게 "지금부터 내가 지시하는 도커 컨테이너들을 띄워줘!" 라고 명령
class UserRepositoryIntegrationTest {

    // 🌟 이 어노테이션을 붙이면, 테스트 시작 전 진짜 MySQL 8.0 컨테이너가 짠! 하고 뜹니다.
    @Container
    static MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:8.0.32")
            .withDatabaseName("testdb")
            .withUsername("testuser")
            .withPassword("testpass");

    // 🌟 도커가 랜덤한 포트(예: 32847)로 DB를 띄우기 때문에, 스프링의 yml 설정(url)을 강제로 덮어씌워 줍니다.
    @DynamicPropertySource
    static void overrideProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
        registry.add("spring.datasource.username", mysqlContainer::getUsername);
        registry.add("spring.datasource.password", mysqlContainer::getPassword);
    }

    @Autowired
    private UserRepository userRepository;

    @Test
    @DisplayName("진짜 MySQL 환경에서 유저 저장 테스트를 수행한다")
    void testSaveUser() {
        // given
        User user = new User("철수");

        // when
        User savedUser = userRepository.save(user);

        // then
        assertThat(savedUser.getId()).isNotNull();
    }
}

⌛ 2. 실무 꿀팁: 컨테이너 부팅 시간(지연) 단축하기

Testcontainers는 완벽하지만 치명적인 단점이 하나 있습니다. 엄청나게 느립니다! 테스트 클래스(A_Test.java, B_Test.java...)가 실행될 때마다 매번 10초씩 걸려서 무거운 MySQL 컨테이너를 내렸다가 새로 띄운다면, 테스트 100개 돌리는 데 30분이 걸릴 것입니다. CI 파이프라인에서 이러면 뺨 맞습니다.

이 문제를 해결하기 위해 실무에서는 '싱글톤(Singleton) 컨테이너' 패턴을 사용합니다. 클래스마다 @Container를 달지 않고, 모든 통합 테스트 클래스가 상속받는 거대한 부모 클래스(AbstractIntegrationTest)를 하나 만듭니다. 그 부모 클래스의 static 블록에서 컨테이너를 딱 한 번만 띄워놓고(Start), 자식 클래스 수십 개가 그 하나의 컨테이너를 재사용하도록 만듭니다. 이렇게 하면 첫 부팅 10초만 감수하고 나머지 테스트는 번개처럼 빠르게 돌아갑니다. (물론 테스트 간의 데이터 침범을 막기 위해 매 테스트 직후 @AfterEach에서 TRUNCATE TABLE 쿼리로 데이터를 싹 청소해 주는 것은 필수입니다!)


🎯 3. 마무리 및 다음 단계

지금까지 어떤 PC나 CI 서버 환경이든 상관없이 100% 동일한 '진짜 DB/Redis' 환경을 보장하고, 테스트 종료 후 흔적도 없이 파괴되는 클린 테스트 아키텍처의 끝판왕, Testcontainers의 마법과 싱글톤 최적화 비법에 대해 상세히 다루어 보았습니다. 이제 더 이상 "내 컴퓨터에선 되는데요?"라는 변명은 통하지 않습니다.

우리는 완벽하게 동작하는 통합 테스트 코드까지 갖추었습니다. 그런데, 이 코드가 진짜 '좋은 코드'일까요? 동작은 잘하지만 변수 이름이 엉망진창이고, 한 메서드가 500줄이 넘어가며, 치명적인 보안 취약점(SQL 인젝션 등)이 숨어있다면 어떨까요? 시니어 개발자가 밤새 코드 리뷰(PR)를 해주지 않아도, "기계가 내 코드를 쓱 훑어보고 '여기 코드 스멜(악취)이 나요! 여기 버그 덩어리에요!' 라며 회초리를 때려주고 등급을 매겨주는 AI 같은 시스템"은 없을까요? 이어지는 49단계 포스팅에서는 "클린 코드의 경찰관!" CI/CD 파이프라인에 이식하는 정적 코드 분석의 절대 권력, SonarQube(소나큐브) 구축과 실무 품질 게이트(Quality Gate) 연동 가이드에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!

반응형