TestContainer 도입기 feat.Spring Test Context Cache

Shaun·2025년 10월 13일

testcontainer

목록 보기
1/1
post-thumbnail

나는 테스트 코드를 사랑한다.

테스트 코드를 맛보기 전까지는 필요성을 못느꼈지만 작성후에 필요성을 많이 느꼈다. 간단하게나마 왜 테스트 코드가 필요한지 간략하게 설명한다.

TestCode는 왜 필요할까

1. 리팩토링에 대한 두려움이 없음

  • 테스트 코드가 없으면 리팩토링을 하고 난 후에 이게 같은 기능을 하는 보장 할 수 가 없음 + 불안함

  • 다 postman으로 쏴봐야하는 번거로움

2. 구현된 기능들이 항상 같은 기능을 한다는것을 보장 할 수 없음.

3. 엣지케이스를 작성하다보면 생각하지 못한 에러를 발견 할 수 있음

이렇게 크게 3가지로 생각한다. 나는 주로 Presentation, Service,Persistence, 로 구분하고 외부요인은 최대한 @MockitoBean 으로 처리하는 방식으로 작성한다.

이런 방식에도 문제가 없다고 생각하던 찰나에 '결제' 시스템을 구현하게 되었다. 결제 시스템에서는 단순히 데이터 저장,조회가 아닌 따로 pg사도 호출해야했고, redis 비동기처리 후 데이터 저장, 실패로직 수행등등 복잡하면서도 하나하나 중요한 시스템이였다. 이런 결제 시스템 처럼 중요도가 높은 작업을 하면서 느낀점이 Mock처리로 검증만 하는게 아닌 정말 실제 서비스 운영과 같은 상황 에서 테스트가 해보고 싶었다.

그러던 와중에 "TestContainer" 라는 것을 발견 했고 내가 원하는것을 충족 시켜줄 수 있다고 생각했다.

TestContainers

1. TestContainer가 하는 일 (Postgre 예시)

요청: 테스트 코드에서 new PostgreSQLContainer()처럼 필요한 서비스를 요청함
실행: PC에 설치된 Docker를 이용해 PostgreSQL 컨테이너를 즉석에서 새로 띄운다
대기: 컨테이너가 완전히 구동되어 연결을 받을 준비가 될 때까지 기다림
연결 정보 전달: 무작위로 할당된 포트 번호나 접속 주소 등의 정보를 테스트 코드에 전달해 준다.
자동 삭제: 테스트가 끝나면 방금 띄웠던 컨테이너를 스스로 깨끗하게 삭제한다.

2. TestContainer 도입 이유

  • 높은 신뢰도 : 인메모리 DB 같은 '가짜'가 아닌, 운영 환경과 동일한 '진짜' Redis, Kafka, PostgreSQL 위에서 테스트를 수행한다. 따라서 테스트의 신뢰도가 비교할 수 없을 만큼 높아짐

  • 완벽한 격리 및 일관성 : 모든 테스트는 항상 새롭고 깨끗한 상태의 DB나 큐에서 시작된다. 다른 테스트의 영향을 전혀 받지 않아 안정적이고 일관된 테스트 환경이 보장된다.

  • CI/CD 파이프라인과의 완벽한 통합 : Jenkins, GitHub Actions 같은 자동화 서버에서도 별도 설정 없이 깔끔하게 동작하여, 빌드 및 테스트 자동화가 매우 쉬워진다

결론

-> 실제와 어플리케이션에서 필요한것들을 testContainer로 띄워서 동작하게 함으로 실제 운영과 같은 환경에서 테스트 할 수 있다.

-> 내 결제 시스템에서는 토스페이먼츠를 wireMock 으로 하나 띄우고 나머지 db, redis 도 각각 testContainer로 띄워서 테스트 코드를 진행 했다.

wireMock 도입이유

  • 외부 환경으로부터의 완벽한 독립: 인터넷이 끊겨도, 실제 API가 점검 중이어도 내 테스트는 언제나 안정적으로 실행됨

  • 빠르고 예측 가능한 테스트: 네트워크 지연 없이 메모리 안에서 즉각적으로 응답하므로 테스트 속도가 비약적으로 향상되고, 언제나 프로그래밍된 대로만 응답하여 결과 예측이 가능하다.

  • 모든 예외 상황 테스트 가능: WireMock의 가장 강력한 장점입니다. 실제로는 재현하기 힘든 온갖 종류의 네트워크 오류, 서버 오류, 비즈니스 로직 오류(잔액 부족 등)를 손쉽게 시뮬레이션하여 내 코드의 방어 로직을 꼼꼼하게 테스트할 수 있다.

3.TestContinaer 단점

  • 아무래도 Docker를 직접 활용해 띄우는거라 많은 자원 소모와 실행 속도 저하라는 문제가 있다.

  • TestCode가 Docker라는 외부실행환경에 의존적이다.

-> 하지만, 결제라는 중요도가 높은 시스템을 실제 운영과 같은 서버에서 테스트를 해 더 견고한 시스템을 만드는 게 중요하다고 생각함. 자원 소모 많고 좀 느리면 어떤가? 안전한 시스템을 만드는 게 났다고 생각한다. 단위 테스트 같은경우는 기존에 하던 방식대로 Mocking으로 빠르게 해결하고 통합테스트를 TestContainer를 통해서 하면 될것 같다고 생각한다.

TestContainer 전략

TestContainer를 그럼 몇개나 어떻게 띄워야할까? 처음에는 데이터의 독립성 + 서로 데이터가 꼬이지 않기위해 각각 테스트 마다 띄우면 된다고 생각했다.

하지만 이게 왠걸 내가 필요한게 redis,mysql,wiremock 이라고 가정하고 테스트 코드가 100개면 3개의 TestContainer가 100번 껏다켜졌다 하게된다. CPU 사용량이 99.9를 찍으며 생각보다 테스트 시간이 너무 오래 걸리게 되었다. 테스트 자체는 0.1초만에 끝나는데 컨테이너 띄우는데 시간이 더 커지게 되므로 배보다 배꼽이 더 상황이 됬다.

그래서 나는 TestContainer를 한개씩만 띄우고 테스트 클래스가 끝날때마다 데이터 정리를 확실하게 하는 전략으로 가기로 했다.

내 테스트 코드는 두가지로 나뉘어졌다. Redis만, Redis,wiremock, mysql 를 필요로 하는 테스트 코드로 나뉘어졌다.

package com.example.donation.support;

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;
import org.testcontainers.containers.BindMode;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.wiremock.integrations.testcontainers.WireMockContainer;
import com.redis.testcontainers.RedisContainer;

@SpringBootTest
@ActiveProfiles(TestContainersConfig.TEST_PROFILE)
@Testcontainers
public abstract class TestContainersConfig {

    protected static final String TEST_PROFILE = "test";

    private static final String MYSQL_IMAGE = "mysql:8.0";
    private static final String WIREMOCK_IMAGE = "wiremock/wiremock:3.9.2";

    private static final String DB_NAME = "test";
    private static final String DB_USERNAME = "test";
    private static final String DB_PASSWORD = "test";

    @Container
    protected static MySQLContainer<?> mysql = new MySQLContainer<>(MYSQL_IMAGE)
            .withDatabaseName(DB_NAME)
            .withUsername(DB_USERNAME)
            .withPassword(DB_PASSWORD);

    @Container
    protected static RedisContainer redis = new RedisContainer(
            RedisContainer.DEFAULT_IMAGE_NAME.withTag("7-alpine"));

    @Container
    protected static WireMockContainer wiremock = new WireMockContainer(WIREMOCK_IMAGE)
            .withClasspathResourceMapping("wiremock", "/home/wiremock", BindMode.READ_ONLY);

    @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.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver");

        // MySQL dialect 설정 추가
        registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MySQLDialect");

        // Redis Container 설정
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", redis::getFirstMappedPort);

        // WireMock URL을 Feign Client에 동적 주입
        registry.add("toss.api.base-url", () -> wiremock.getBaseUrl() + "/v1/payments");
        registry.add("toss.billing.base-url", () -> wiremock.getBaseUrl() + "/v1/billing");
    }
}
package com.example.donation.support;

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.springframework.transaction.annotation.Transactional;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import com.redis.testcontainers.RedisContainer;

// H2 + Redis TestContainer
@SpringBootTest
@ActiveProfiles("test")
@Transactional
public abstract class RedisTestContainerSupporter {

    @Container
    protected static RedisContainer redis = new RedisContainer(
            RedisContainer.DEFAULT_IMAGE_NAME.withTag("7-alpine"));

    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        // Redis Container 설정
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", redis::getFirstMappedPort);
    }
}

처음에 그래서 난 설정을 이렇게 두가지로 나눴다. Redis만 필요한 테스트 코드들은 이렇게 따로 설정을 만들어서 해주면 자원 낭비도 줄이겠지하면서 아주 뿌듯해 했다. 하지만 결과는 내가 생각했던거와 달랐다. 내 예상에는 결과적으로 redis를 하나만(설정이 같으니) 띄워서 같이 사용하게 될 줄 알았지만 redis가 여러개 뜨는 이슈가 발생 했다.

왜 이런 이슈가 일어날까? 그 이유로는 static변수를 간과 했었다. 그리고 하나 또 간과한게 있었는데 Spring Test Context Cache 동작 원리에 대해서 잘 몰랐기 때문에 일어난 자원 이슈도 있엇다. 일단 SpringTestContextCache에 대해 간략하게 알아보자!

Spring Test Context Cache 란?

  • 여러 테스트 클래스에서 동일한 설정을 공유할 경우, 애플리케이션 컨텍스트를 재사용하여 테스트 실행 속도를 획기적으로 높여주는 캐싱 메커니즘

  • 테스트 설정 정보를 조합하여 캐시 키 같은 설정 인지 구분한다

SpringTestContextCache Flow

1. 테스트 실행 전 - Context 설정 분석

// TestA 실행 시
@SpringBootTest  // ← 이 어노테이션을 Spring이 분석
class TestA {
    @Autowired
    private UserService userService;
}

// Spring이 내부적으로 하는 일:
// 1단계: MergedContextConfiguration 생성
MergedContextConfiguration config = new MergedContextConfiguration(
    testClass = TestA.class,
    locations = [],  // XML 설정 파일 경로
    classes = [Application.class],  // @SpringBootApplication 클래스
    contextInitializers = [],
    activeProfiles = [],
    propertySourceLocations = [],
    propertySourceProperties = [],
    contextCustomizers = [],
    mockBeans = [],  // ← 비어있음!
    spyBeans = []    // ← 비어있음!
    // ... 기타 설정들
);

// 2단계: 이 config 객체를 Key로 사용
String cacheKey = config.hashCode();  // 예: "12345"

2. 캐시 조회

// Spring Test Framework 내부 동작
public class SpringTestContextBootstrapper {
    
    private ContextCache cache = new DefaultContextCache();
    
    public ApplicationContext getContext(MergedContextConfiguration config) {
        // 1. 캐시에서 찾아보기
        ApplicationContext cachedContext = cache.get(config);
        
        if (cachedContext != null) {
            System.out.println("캐시 히트! 재사용");
            return cachedContext;
        }
        
        // 2. 캐시 미스 - 새로 생성
        System.out.println("캐시 미스! 새로 생성");
        ApplicationContext newContext = createContext(config);
        
        // 3. 캐시에 저장
        cache.put(config, newContext);
        
        return newContext;
    }
}

SpringTestContextCache 재사용 여부

  • @SpringBootTest 어노테이션의 속성 (e.g., webEnvironment)
  • @ActiveProfiles 로 지정된 프로파일
  • @ContextConfiguration 에 명시된 설정 파일 위치
  • @TestPropertySource 로 추가된 프로퍼티
  • @DynamicPropertySource 메서드
  • @MockBean, @SpyBean 의 목록

TestContainers 병렬실행 전략

TestContainer를 싱글톤으로 한개씩 띄워 테스트 코드를 하나하나씩 돌리면 속도가 매우느리다. 그럼 어떻게 해결해야 할까??

나는 크게 두가지 방식으로 나눠봤다

1. JVM-Scoped Singleton(프로세스 병렬 실행)

  • MaxParallelForks 옵션을 사용하는 방식, 완전히 격리된 여러 개의 JVM 프로세스를 생성
  • JVM으로 격리된 TestContainers가 여러개 뜬다.
  • 리소스가 많이 들어가지만 프로세스가 병렬로 실행되는거기떄문에 격리성 보장이 높음

2.JUnit Jupiter Parallel(스레드 병렬 실행)

  • 단 하나의 컨테이너 세트를 모든 테스트 스레드가 공유하게 된다. 따라서 리소스를 매우 효율적으로 사용할 수 있음.
  • 하지만 그만큼 테스트 격리성이 떨어짐, 하나의 JVM 프로세스 안에서 여러 스레드는 같은 메모리 공간을 공유하기 때문에 다음과 같은 문제가 발생할 수 있다.

데이터 청소

여러 테스트가 동시에 싱글톤 TestContainer를 사용하기 때문에 테스트가 끝난 후 데이터 정리는 필수다.

처음에는 멋 모르고 @DirtiesContext를 사용하다. 스프링 테스트 컨텍스트 캐시의 혜택을 의도적으로 포기하여 테스트 간의 완벽한 격리를 보장하는 역활을 하는 강력한 녀석이지만 그 만큼 테스트가 아~주 느려진다.(그만큼 새로 만들떄마다 컨텍스트를 다시 만들어야 하니까)

그래서 싱글톤을 TestContainer를 띄워주고 테스트가 끝날떄마다 @Transactional 로 정리해주고 Redis Stream 비동기는 @AfterEach로 데이터 청소하는 방법을 선택 했다.

결론

  • 내가 다르게 TestContainer 설정했기 때문에 캐싱 효율도 떨어진다는 문제점도 있었고 그만큼 테스트 속도도 느려졌다.

  • 그래서 이러한 캐싱효율까지 참고 + 기존전략 까지 고려해서 싱글톤 패턴으로 딱 한개씩만 띄우기로 결정

  • 스레드 병렬은 자원을 아낄 수 는 있지만 한 컨테이너를 여러 쓰레드가 같이 사용하기 때문에 잔여데이터나, 데이터 정합성이 안맞아 테스트 코드 검증이 어려울 수 있다고 판단. 프로세스 병렬실행은 자원소모가 크지만, 그만큼 격리되서 정확한 테스트 코드 검증이 가능하므로 이 방법으로 진행함, 자원소모가 큰것도 테스트 코드가 돌때만 잠깐 그런거라 이정도 트레이드 오프는 괜찮다고 판단해 최종적으로 프로세스 병렬실행 + TestContainer 방법으로 최종 결정

profile
호주쉐프에서 개발자까지..

0개의 댓글