[Spring] 통합 테스트 속도 개선

2025. 12. 15. 14:07·spring

개요

CI 환경에서 통합 테스트가 실행될 때마다 약 10분 20초 가량이 소요되고 있었고, 테스트 케이스가 늘어날수록 전체 실행 시간은 계단식으로 증가하는 구조였습니다. 이는 테스트 클래스마다 Spring ApplicationContext가 반복적으로 생성되면서, 동일한 초기화 비용이 누적되고 있었기 때문입니다.

 

ApplicationContext와 테스트 컨테이너의 생명주기를 정리하고, 컨텍스트 캐시가 실제로 재사용될 수 있도록 테스트 구조를 재설계한 결과, CI 기준 전체 테스트 실행 시간을 약 5분 20초 수준으로 단축할 수 있었습니다. 기존 대비 약 49%의 시간 개선 효과를 얻을 수 있었고, 테스트 수가 증가하더라도 실행 시간이 선형적으로 증가하도록 구조를 바꿀 수 있었습니다.

문제 상황

통합 테스트 실행 시간이 비정상적으로 길었습니다.

프로젝트의 통합 테스트는 Elasticsearch Testcontainers를 포함한 @SpringBootTest 기반 테스트였습니다.
테스트 수가 늘어날수록 실행 시간이 선형이 아니라 계단식으로 증가했고, CI 환경에서는 명확한 병목으로 드러났습니다.

테스트 클래스마다 Spring Boot 애플리케이션이 매번 새로 부팅되고 있었고, 그 과정에서 동일한 Bean 그래프, 동일한 설정임에도 ApplicationContext가 재사용되지 않고 있었습니다.

즉, 문제는 테스트 컨테이너 자체가 아니라 Spring ApplicationContext가 공유되지 않고 있다는 점이었습니다.

 

개선 목표

통합 테스트에서 ApplicationContext를 공유하여 실행 시간을 단축해야 했습니다.

목표는 다음 조건을 만족하는 것이었습니다.

1. Elasticsearch를 포함한 실제 인프라를 사용하는 통합 테스트 구조는 유지합니다.
2. 테스트 격리 수준은 유지하되 ApplicationContext는 최대한 재사용합니다.
3. 테스트 실행 순서에 따라 성공/실패가 달라지는 비결정성을 제거합니다.
4. CI 기준으로도 안정적으로 동작해야 합니다.

결과적으로는 ApplicationContext 공유를 통해 전체 통합 테스트 실행 시간을 의미 있게 단축하는 것이 핵심 과제였습니다.

 

해결 과정

문제 원인 분석

가장 먼저 한 일은 정말로 컨텍스트가 매번 새로 뜨는가를 확인하는 것이었습니다.
이를 위해 ApplicationContext의 identity를 출력하고, Spring TestContext cache 로그를 활성화했습니다.

    //identity 확인하는 방법
    @Autowired
    private ApplicationContext applicationContext;
    
    @Test
    void printContextIdentity() {
        System.out.println(
                "ApplicationContext identity = " + System.identityHashCode(applicationContext)
        );
    }
logging:
    level:
        org.springframework.test.context.cache: DEBUG

그 결과, 테스트 클래스마다 서로 다른 ApplicationContext가 생성되고 있었고, 캐시 히트가 발생하지 않고 있었습니다.

Spring TestContext Framework는 ApplicationContext를 캐싱할 때 단순히 @SpringBootTest 여부만 보지 않습니다.
실제로는 다음 요소들이 캐시 키를 구성합니다.

  • locations
  • classes
  • contextInitializerClasses
  • contextCustomizers
  • contextLoader
  • parent
  • activeProfiles
  • propertySourceDescriptors
  • propertySourceProperties
  • resourceBasePath

이 중 하나라도 다르면, Spring은 다른 ApplicationContext로 판단합니다.

locations / classes

@ContextConfiguration 또는 @SpringBootTest에서 지정한 설정 소스입니다.

  • classes가 다르면 Bean 그래프 자체가 다르다고 판단
  • 테스트 클래스마다 @SpringBootTest(classes=…)가 달라지면 무조건 캐시 미스

제 경우에는 여기서는 큰 문제가 없었습니다.
모두 동일한 Application 클래스를 기준으로 컨텍스트를 띄우고 있었습니다.

activeProfiles

@ActiveProfiles("test") 같은 설정입니다.

  • profile 하나라도 다르면 캐시 키가 달라진다
  • test / local / integration 같은 프로파일 혼용 시 컨텍스트 공유 불가

이 역시 제 프로젝트에서는 비교적 잘 통일되어 있었습니다.

propertySourceDescriptors / propertySourceProperties

@TestPropertySource, @DynamicPropertySource,
또는 테스트 전용 property override가 여기에 해당합니다.

이 부분이 흔하게 컨텍스트 캐시를 깨뜨리는 요인입니다.

 

제가 Testcontainers를 쓰면서 @DynamicPropertySource로 spring.elasticsearch.uris를 주입하고 있었을 때,

  • 테스트 클래스 A: http://localhost:32781
  • 테스트 클래스 B: http://localhost:32814

처럼 포트가 달라졌고, Spring 입장에서는 환경이 다른 두 애플리케이션으로 인식했습니다.

contextCustomizers

contextCustomizers에는 다음이 포함됩니다.

  • @DynamicPropertySource
  • @MockBean, @MockitoBean, @SpyBean
  • Spring Boot 테스트 지원에서 자동으로 추가되는 커스터마이저
  • Testcontainers + ServiceConnection에서 추가되는 커스터마이저

테스트 클래스마다 Bean override 방식이 조금이라도 다르면 Spring은 컨텍스트를 공유하지 않습니다.

  • 어떤 테스트는 MockBean을 사용하고
  • 어떤 테스트는 사용하지 않거나
  • Mock 대상이 다르거나
  • DynamicPropertySource가 하나라도 다르면

→ contextCustomizers가 달라지고 캐시 키가 달라집니다.

contextInitializerClasses

컨텍스트 초기화 시점에 개입하는 클래스들입니다.

Spring Boot + Testcontainers + ServiceConnection 조합에서는
여기에도 자동으로 초기화 로직이 들어갑니다.

테스트마다 초기화 방식이 달라지면 이 또한 캐시 키 불일치의 원인이 됩니다.

contextLoader / parent / resourceBasePath

이 세 가지는 보통은 잘 변하지 않지만,

  • Web 환경 테스트와 비 Web 테스트 혼용
  • @WebAppConfiguration 사용 여부 차이
  • ContextHierarchy 사용

같은 경우에는 캐시 키가 달라집니다.

이 중 하나라도 달라지면 Spring은 다른 컨텍스트로 판단합니다.

 

저 같은 경우 문제의 핵심은 DynamicPropertySource와 Testcontainers가 만들어내는 환경의 변화였습니다.

 

컨텍스트 생성 순서

Spring Boot 통합 테스트에서 컨텍스트가 생성되는 흐름은 다음 순서를 따릅니다.

먼저 JUnit이 테스트 클래스를 발견합니다.

그 다음 SpringExtension이 개입하여 TestContextManager를 초기화합니다.
이 시점에 Spring은 “이 테스트에 필요한 ApplicationContext가 이미 있는가”를 캐시에서 조회합니다.

캐시 키는 앞서 언급한 여러 설정 요소를 조합해서 만들어집니다.

캐시 미스가 발생하면 Spring은 다음 순서로 컨텍스트를 생성합니다.

  1. Environment 구성
  2. PropertySource 등록
    • application.yml
    • application-test.yml
    • DynamicPropertySource
    • ServiceConnection에서 제공하는 연결 정보
  3. ContextCustomizer 적용
  4. BeanDefinition 로딩
  5. Bean 인스턴스화
  6. ApplicationContext refresh
  7. ApplicationReadyEvent 발행

중요한 것은DynamicPropertySource나 ServiceConnection은 Environment 구성 단계에서 컨텍스트의 정체성을 바꾼다는 것입니다.
특히 DynamicPropertySource는 테스트 클래스 단위로 값이 달라질 수 있기 때문에 캐시 키를 깨뜨리는 주요 원인이 됩니다.

 

ServiceConnection으로 통합된 환경 구축

    @Container
    @ServiceConnection
    protected static final ElasticsearchContainer ELASTICSEARCH =
            new ElasticsearchContainer(
                    "docker.elastic.co/elasticsearch/elasticsearch:8.19.6"
            )
                    .withEnv("xpack.security.enabled", "false")
                    .withEnv("discovery.type", "single-node")
                    .withCommand(
                            "bash", "-c",
                            "elasticsearch-plugin install --batch analysis-nori && exec elasticsearch"
                    );

@ServiceConnection을 사용해 컨텍스트 공유 조건을 만족시켰습니다

Spring Boot 3.x에서 도입된 @ServiceConnection은 Testcontainers와 Spring Environment를 연결하는 공식적인 방법입니다.

이 어노테이션을 사용하면 Spring이 직접 컨테이너를 관리하고, 연결 정보를 Environment에 자동으로 주입합니다.

 

어노테이션을 사용하며 중요한 점은 다음 두 가지였습니다.

첫 째, @ServiceConnection을 사용하는 순간 DynamicPropertySource는 제거해야 합니다.
둘 째, Elasticsearch 설정 Bean이 테스트 환경에서도 결정적으로 생성될 수 있도록 조건 분기가 필요했습니다.

 

특히 ElasticsearchConfig에서 인증 정보를 무조건 요구하고 있던 부분이 문제였습니다.
테스트 컨테이너는 보안을 끈 상태였기 때문에 username/password가 없는 경우를 허용하도록 설정을 수정했습니다.

이로써 ApplicationContext 생성이 테스트 순서와 무관하게 항상 성공하도록 만들 수 있었습니다.

결과

ApplicationContext 공유를 통해 약 49%의 실행 시간 개선을 달성했습니다

모든 설정을 정리한 후 다시 테스트를 실행해보니, 이전에는 테스트 클래스마다 Spring Boot 애플리케이션이 매번 부팅되던 구조가
단 한 번만 부팅되고 재사용되는 구조로 바뀌었습니다.

Elasticsearch 컨테이너 역시 한 번만 기동되었고, 테스트 간에는 인덱스 초기화만 수행하도록 조정했습니다.

현재는 각 테스트에서 필요한 데이터를 개별적으로 삽입하고 삭제하는 방식이지만, 다음 단계로는 통합 테스트 시작 시점에만 대량 데이터를 bulk insert로 한 번 적재하고, 테스트 전반에서 이를 재사용한 뒤 전체 테스트 종료 시 정리하는 방식으로 개선할 계획입니다. 이를 통해 테스트 데이터 준비 비용을 더 줄이고, 전체 테스트 실행 시간을 추가로 단축하는 것을 목표로 하고 있습니다.

 

레퍼런스

스프링 컨텍스트 캐싱

https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/ctx-management/caching.html

 

Context Caching :: Spring Framework

ApplicationContext lifecycle and console logging When you need to debug a test executed with the Spring TestContext Framework, it can be useful to analyze the console output (that is, output to the SYSOUT and SYSERR streams). Some build tools and IDEs are

docs.spring.io

 

ServiceConnection

https://yonghwankim-dev.tistory.com/608

 

SpringBoot 3.1 TestContainer

개요이 글에서는 SpringBoot 3.1 이상 버전의 프레임워크에서 테스트 컨테이너를 구현하는 방법에 대해서 소개합니다. 3.1 버전 이전까지 SpringBoot 프레임워크에서 테스트 컨테이너를 실행하기 위해

yonghwankim-dev.tistory.com

'spring' 카테고리의 다른 글

[Spring] spring-core 직접 구현해보기 - (1)  (5) 2026.01.02
[Spring] spring-core 직접 구현해보기 - (0)  (5) 2025.12.31
[SNS] 레디스 세션 스토리지를 활용한 인증 인가  (2) 2025.10.01
[spring] 1:1 실시간 채팅 구현  (2) 2025.09.17
[Spring] virtual thread vs webflux  (3) 2025.08.11
'spring' 카테고리의 다른 글
  • [Spring] spring-core 직접 구현해보기 - (1)
  • [Spring] spring-core 직접 구현해보기 - (0)
  • [SNS] 레디스 세션 스토리지를 활용한 인증 인가
  • [spring] 1:1 실시간 채팅 구현
ksngh
ksngh
웹 백엔드 개발 블로그입니다. https://github.com/ksngh
  • ksngh
    featherdale
    ksngh
  • 전체
    오늘
    어제
    • 분류 전체보기 (66)
      • 데이터베이스 (10)
      • spring (11)
      • redis (7)
      • ELK (11)
      • 회고 (6)
      • 기타 (12)
        • java (2)
        • 디자인패턴 (2)
        • 영어 (1)
        • 자바스크립트 (1)
        • graphQL (2)
        • 블록체인 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    레디스
    Spring Core
    nori tokenizer
    연말 회고
    데이터베이스
    단위 테스트
    엘라스틱서치
    Elastic Search
    PostgreSQL
    엘라스틱 서치
    Elasticsearch
    Redis
    NORI
    대용량데이터베이스
    자료구조
    대용량 데이터 베이스
    gof
    조인의 종류
    spring
    단위테스트
    Spy
    graphql
    회고
    elastic search in action
    Mock
    디자인패턴
    NoriTokenizer
    엘라스틱 서치 인 액션
    core
    인기검색어 구현
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
ksngh
[Spring] 통합 테스트 속도 개선
상단으로

티스토리툴바