본문 바로가기
📄 포스트

Spring 통합 테스트의 다중 ApplicationContext로 인한 Too many connections

by Gukin 2026. 4. 1.

개별 테스트는 통과, 전체 실행시 Too many connections 발생

여러 기능 브랜치가 개발 브랜치로 머지된 후, 그동안 기능 개발하면서 놓친 실패 테스트들을 점검하기 위해 전체 테스트를 실행했다. Too many connections 에러로그가 발생하면서 통합테스트들 일부가 실패하였다. 로그의 일부는 아래와 같다:

Error creating bean with name 'liquibase': Too many connections
  → Error creating bean with name 'databaseCleanupUtil'
  → Failed to load ApplicationContext

하지만, 각각 테스트 클래스들을 개별로 실행했을때는 모두 통과했다. 도대체 왜 동일한 코드에서 실행 범위만 다를 뿐인데 결과가 다를까?

 

왜 전체 실행시 에러가 발생할까?

일반적인 서버는 JVM 1개에 ApplicationContext(이하 컨텍스트)가 1개가 떠서 설정과 빈 등록이 이뤄진다. 이게 우리가 아는 일반적인 동작방식이다. 하지만 스프링 전체를 올려서 실행되는 통합테스트의 경우 1개의 JVM 내에 생성된 ApplicationContext가 1개가 아닐 수 있다. 즉 우리가 스프링이라고 부르는 존재가 하나의 JVM 내에 여러개가 존재할 수 있다는 의미다.

 

이는 컨텍스트 캐싱(Context Caching)과 관련되어 있는데 공식 문서에서는 유일성(unique)테스트 스위트(test suite) 의미를 이해하는게 캐싱 동작을 이해하는데 도움된다고 한다. 아래는 공식문서의 원문이며, 다음 섹션부터 천천히 그 의미와 이 이슈를 다루면서 느낀 인사이트를 공유해본다.

 

Once the TestContext framework loads an ApplicationContext (or WebApplicationContext) for a test, that context is cached and reused for all subsequent tests that declare the same unique context configuration within the same test suite. To understand how caching works, it is important to understand what is meant by "unique" and "test suite."

An ApplicationContext can be uniquely identified by the combination of configuration parameters that is used to load it. Consequently, the unique combination of configuration parameters is used to generate a key under which the context is cached. The TestContext framework uses the following configuration parameters to build the context cache key:

 

테스트를 위해 캐싱을 하는 이유

컨텍스트는 생성 비용이 크다. 여러 설정과 빈(Bean)들의 의존성 그래프를 만들고 주입 해주는 과정을 생각해보면 이해할 수 있다. 매 테스트 클래스마다 새롭게 생성해서 넣어주면 테스트간 독립성은 유지될 수 있지만 테스트 시간이 길어지는 트레이드 오프가 존재한다. 동일한 설정(configuration)을 갖는다면 굳이 새롭게 컨텍스트를 생성하는 시간을 낭비할 필요가 있을까? 하는 목적 때문에 컨텍스트를 캐싱하게 된다.

 

컨텍스트의 유일성(unique)은 어떻게 결정할까?

JUnit 기반의 테스트는 다음과 같은 순서로 실행된다:

  1. JUnit이 테스트 클래스 A를 선택 (순서가 보장되지 않음)
  2. 컨텍스트를 조회/생성 → ✅ 여기서 캐시를 조회
  3. A의 메서드들을 전부 실행 (순서가 보장되지 않음)
  4. JUnit이 테스트 클래스 B를 선택 (순서가 보장되지 않음)
  5. 이하 반복

 

테스트 클래스 내부의 테스트 메서드 순서는 보장되지 않지만 테스트 클래스는 묶어서 실행한다. 그 테스트 클래스가 컨텍스트를 조회하거나 생성하여 캐싱하는 요소들을 제공한다.

 

이는 다음 DefaultCacheAwareContextLoaderDelegate 클래스 loadContext 메서드의 143라인 부터 시작되는 아래 코드 스니펫을 통해 이해할 수 있다.

synchronized (this.contextCache) {
    context = this.contextCache.get(mergedConfig);  // 조회
    if (context != null) {
        return context;                              // 히트 → 반환
    }
    return this.contextCache.put(mergedConfig, ...); // 미스 → 생성 + 저장
}

 

mergedConfig 변수는 MergedContextConfiguration 타입 인스턴스다. 이 인스턴스가 캐시 키로 사용되는데 좀더 세부적인 항목들은 컨텍스트 캐싱(Context Caching) 스프링 공식문서에서 확인할 수 있다.

 

그 중에 하나는 MockBean(Spring Boot 3.4 /Spring Framework 6.2 이후는 MockitoBean)이다. 즉, MockBean이 다르게 붙어있으면 서로 다른 컨텍스트로 인식을 하고 캐시 미스가 발생해서 새로운 컨텍스트를 생성해서 캐싱을 하게 된다.

 

참고로 캐싱되는 컨텍스트의 개수는 32개가 최대이며 LRU 알고리즘을 따라 오래된 컨텍스트는 삭제된다.

 

컨텍스트가 여러 개일 때 발생할 수 있는 문제

MockBean으로 인한 다중 컨텍스트 문제는 많은 블로그에서 다뤄졌고, 대부분의 논의는 테스트 실행시간(Duration)에 집중되어있다. 하지만 이 글에서는 다중 컨텍스트로 인해 JVM 외부 요소와 접점에서 발생하는 문제의 근본적인 원인에 초점을 맞춰보고 싶다.

 

결론부터 말하면 전체 테스트 시 생성된 여러 컨텍스트가 순차적으로 MySQL 커넥션 연결을 시도했고, MySQL 커넥션 디폴트 값인 151개를 초과해서 테스트들이 실패했다.

 

테스트 중 생성된 컨텍스트의 개수는 아래의 로깅 옵션을 application.yml에 설정하면 발생하는 로그를 분석하여 확인할 수 있다. 현재 이슈에서는 MockitoContextCustomizer의 해시값이 서로 다른 6개의 컨텍스트로 인해 캐시미스가 발생했다.

logging.level.org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate: TRACE

 

로깅 설정시 주목해서 봐야할 로그 항목은 아래 두 가지다.

# 캐시 통계 요약 (DEBUG)
  Spring test ApplicationContext cache statistics:
  [DefaultContextCache@25c89a08 size = 6, maxSize = 32,
   parentContextCount = 0, hitCount = 974, missCount = 6]

# 캐시 미스 → 새 컨텍스트 저장 (TRACE)
  Storing ApplicationContext [1462354820] in cache under key
  [WebMergedContextConfiguration@4e26b3fd
    testClass = com.example.myapp.service.order.OrderServiceTest,
    classes = [com.example.myapp.MyApplication],
    activeProfiles = ["test"],
    contextCustomizers = [
      ...
      MockitoContextCustomizer@9b6366f,   ← 이 해시가 컨텍스트마다 다르면 캐시 미스
      ...
    ],
    contextLoader = SpringBootContextLoader,
    parent = null]

 

HikariCP 커넥션 풀의 개수는 25개이며 spring.liquibase.url을 별도로 설정하면 커넥션 풀 외에 Liquibase를 위한 커넥션도 1개 필요하게 된다. 따라서 총 26개 커넥션(HikariCP 25 + Liquibase 1) 이 컨텍스트마다 필요하여 전체 테스트를 실행했을때 적어도 156(26 * 6)개의 커넥션이 요구되었기 때문에 가장 나중에 생성된 컨텍스트의 통합테스트들은 실패할 수 밖에 없었다.

 

컨텍스트 수 * pool size <= max_connections // 6 * 26 = 156 > 151 ❌

 

컨텍스트 1개에서는 성공
컨텍스트 6개에서는 실패

 

 

즉 개별 테스트들을 실행했을때 생성되는 컨텍스트가 1개이기 때문에 문제가 없지만 모든 테스트를 테스트 스위트로 잡고 실행했을때 생성된 컨텍스트가 6개이기 때문에 그제서야 문제가 발생한 것이다. 그리고 테스트 자체에서 발생한 문제가 아닌 외부와의 연결고리에서 문제가 터진 것이다.

 

Redis, Kafka는 문제가 없나?

Testcontainers기반으로 테스트를 구축해두었고, 프로덕션과 유사하게 Redis와 Kafka 또한 통합 테스트에 활용되도록 환경 설정은 해둔 상태다. 하지만, 실제 프로덕션에서 Redis, Kafka의 활용도가 낮을 뿐만 아니라 관련 테스트도 불필요한 상태다.

 

다만, 컨텍스트마다 커넥션이 얼마나 증가할 수 있는지 정도만 체크했다. Redis의 경우 디폴트 maxClients 값은 10,000이며 현재 테스트 환경에도 동일하게 적용되어있다. 실제 테스트 중 몇개의 커넥션이 연결되는지 체크하기 위해 다음 2초 주기로 발생하는 모니터링 스크립트를 실행하여 확인했다. 결과적으로 컨텍스트마다 1개의 커넥션이 생성되었다.

 

 for i in $(seq 1 300); do
    echo "$(date '+%H:%M:%S') $(docker exec <컨테이너명> redis-cli INFO clients 2>/dev/null | grep connected_clients)"
    sleep 2
  done

Redis 클라이언트는 Lettuce를 사용하고 있는데 Lettuce의 기본 동작은 여러 스레드가 하나의 TCP 커넥션을 공유한다. 이 사실을 통해 컨텍스트에 따라 커넥션이 급격하게 증가할 수 있는 구조가 아님을 확인할 수 있다.

 

Kafka 브로커가 허용하는 최대 커넥션 수는 2,147,483,647로 문제가 되지 않았다. 오히려 OS의 파일 디스크립터(fd)의 생성 개수 제한에 영향을 받을 가능성이 높았다. 확인해보니 현재 호스트에서는 100만개의 파일 디스크립터를 생성할 수 있어 Kafka 자체의 커넥션 연결로 문제가 될만한 사항은 아니었다.

 

해결 방법

max_connections를 올리기

가장 단순한 해결 방법은 MySQL의 max_connections를 디폴트값인 151에서 문제가 발생하지 않는 선 이상으로 높이는 방법이다. 공식문서에는 최소값 1에서 최대값 100,000까지 허용하는 것으로 나와있다.

 

MySQL 커뮤니티 버전은 기본으로 one-thread-per-connection 방식의 스레드 핸들링 모델을 사용한다. 각 스레드는 별도의 공간(stack, connection buffer, result buffer)을 필요로 하기 때문에 커넥션 수 증가는 메모리 사용량 증가와 연결된다. 따라서, 근본 원인인 컨텍스트 증가 문제를 해결하지 않고 단순히 max_connections 수를 올리는 것은 당장의 증상만 완화하는 해결책이다.

 

AWS에서도 RDS MySQL을 운영중이라면 기본값 이상으로 올리지 않도록 권고하고 있다. 필요에 따라 서버의 스펙을 같이 올려야 하며 max_connections 수를 계산하는 방법은 AWS 공식문서를 통해 확인할 수 있다.

 

컨텍스트 통합

일부 테스트를 확인해보니 데이터를 넣고 실제 Bean을 주입해서 컨텍스트를 통합할 여지가 있었다. 그럼에도 불구하고 이전에 MockBean을 이용했던 이유는 컨텍스트가 급격하게 증가할 거라는 인지가 없어 테스트 편의성을 위함이었다. 현재의 문제인식을 통해 트레이드오프를 고민한다면 실제 데이터를 삽입해서 MockBean의 개수를 줄이는 쪽을 선택할 것이다.

 

일부 Bean은 대체할 수 있었지만 일부 테스트 클래스에는 MyBatis 인터셉터(Row-Level Security를 MyBatis 인터셉터로 구현된 레거시 패턴)와 공통 모듈에서 오는 의존성이 복잡하게 엮여서 어쩔 수 없이 사용하는 MockBean들이 존재했다. 이 부분은 추후에 레거시를 걷어내면서 작업해야 하는 전수작업으로 판단되어 진행하지 않았다.

 

HikariCP pool size 축소(25 → 5)

테스트는 순차 실행이라 25개 커넥션이 동시에 필요한 상황은 아니다. 5개라는 숫자에 특별한 근거는 없고, 컨텍스트 수 x pool size <= max_connections를 만족하는 선에서 충분히 낮게 잡았다.

 

Lazy-MockBean 적용

레퍼런스 조사 중 흥미로운 내용이 있어서 간단하게 공유한다. 바로 Lazy-MockBean 방식인데, 기존 테스트 방식은 모든 의존성을 고려해서 MockBean으로 교체해준다. 이 의존성 그래프가 복잡하기 때문에 MockBean 설정이 다른 경우 새로운 컨텍스트를 만드는 건데, 여기서는 테스트 대상만 MockBean으로 교체하게 되어 별도의 컨텍스트 생성을 하지 않게 된다.

 

예를 들어, 테스트 클래스 OrderService는 내부적으로 DeliveryService와 CouponService를 사용한다고 가정해본다. 이때 DeliveryService는 또 내부적으로 CouponService를 사용한다고 할 때 DeliveryService를 MockBean으로 처리하면 별도의 컨텍스트가 떠서 CouponService에 주입되는 Bean도 MockBean으로 처리해준다. 해당 글에서 제시한 방법은 OrderService에서 사용되는 DeliveryService만 Mocking으로 처리하고 CouponService에 주입되는 DeliveryService는 실제 Bean이 주입되는 방식이다.

 

 

저자가 제시한 대로 이 방식은 컨텍스트를 줄일 수 있다는 장점은 있지만 MockBean 처리의 완벽한 독립성이 트레이드오프이므로 사용할 때는 충분한 고려가 필요하다. 테스트 관점에서 컨텍스트를 줄여서 얻는 이점보다 의존성 처리로 인한 내재된 테스트 Integrity의 불확실성은 너무 큰 단점으로 생각되어 적용하지 않았다.

 

결과

컨텍스트를 6개 중 2개를 제거하고, HikariCP 풀사이즈를 25에서 5로 변경한 결과 필요한 커넥션 수를 156개에서 24개(Liquibase 포함)로 줄일 수 있었다. 이는 실패했던 121개의 테스트를 모두 성공시키는 결과로 이어졌다.

 

레퍼런스

  1. SpringBootTest @MockBean의 실행과정과 context reload | Taes-k DevLog (2022)
  2. Context Caching | Spring Framework
  3. @MockitoBean and @MockitoSpyBean | Spring Framework
  4. Too many connections | MySQL 8.4 Reference Manual
  5. Context not being reused in tests when MockBeans are used | spring-boot issue #7174
  6. DefaultCacheAwareContextLoaderDelegate.java | spring-framework
  7. Test Execution Order | JUnit 5 User Guide
  8. The New MySQL Thread Pool | MySQL Official Blog
  9. How MySQL Uses Memory | MySQL 8.4 Reference Manual
  10. How do I increase the max connections of my Amazon RDS for MySQL or Amazon RDS for PostgreSQL instance? | AWS Knowledge Center
  11. Amazon RDS에 대한 할당량 및 제약 조건 | Amazon RDS
  12. Redis client handling | Redis Docs
  13. Lettuce - Advanced Java Redis client | Lettuce Reference Guide