JPA 테스트가 CI에서 OOM으로 죽은 이유: Spring 컨텍스트 캐시 키와 H2
kimenugi

JPA 테스트가 CI에서 OOM으로 죽은 이유: Spring 컨텍스트 캐시 키와 H2

백엔드와 API


리포지토리 테스트가 수백 개인 프로젝트에서, 어느 날부터 CI의 ./gradlew testOutOfMemoryError로 실패하기 시작했습니다. 실패하는 테스트는 실행할 때마다 달라져, 특정 테스트의 버그처럼 보이지도 않았습니다. 원인을 따라가 보니 @DataJpaTest와 H2 인메모리 DB, 그리고 Spring 테스트의 컨텍스트 캐시 키가 맞물려 있었습니다.

환경은 Spring Boot 2.7 / Spring Framework 5.3 기준입니다. 컨텍스트 캐시 메커니즘 자체는 이후 버전에서도 같습니다.

테스트마다 다른 H2 DB 이름 때문에

먼저 OOM이 난 스택을 봤습니다. 터지는 위치가 테스트의 쿼리나 데이터가 아니라, 새 Spring 컨텍스트를 빌드하며 classpath를 스캔하는 도중이었습니다.

java.lang.OutOfMemoryError
  at DefaultCacheAwareContextLoaderDelegate ...
  at ClassPathScanningCandidateComponentProvider ...

스택에 찍힌 두 클래스가 핵심 단서입니다.

  • DefaultCacheAwareContextLoaderDelegate: Spring 테스트가 캐시를 거쳐 ApplicationContext를 로드하는 부분입니다.
  • ClassPathScanningCandidateComponentProvider: 컨텍스트를 만들 때 지정한 패키지의 classpath를 훑어 빈으로 등록할 후보 클래스를 찾아내는 스캐너입니다. 공식 Javadoc은 이 클래스를 “기준 패키지로부터 후보 컴포넌트를 제공하며, 인덱스가 있으면 인덱스를 쓰고 없으면 classpath를 스캔한다”고 설명합니다. @ComponentScan이 “어디를 스캔할지”를 정하면, 실제 스캔을 수행하는 일꾼이 이 클래스입니다.

OOM은 테스트의 쿼리, 검증 코드가 아니라 새 컨텍스트를 만들며 classpath를 스캔하던 도중에 났습니다. 테스트가 컨텍스트를 너무 많이 만들고 있다는 신호였고, 테스트 설정에서 원인이 드러났습니다. 리포지토리 테스트들이 각각 다음과 같은 형태였습니다.

@DataJpaTest
@TestPropertySource(properties = [
    "spring.datasource.url=jdbc:h2:mem:orderTestDb;...",   // 테스트마다 고유한 이름
    "spring.jpa.hibernate.ddl-auto=create-drop",
])
class OrderRepositoryTest

테스트마다 jdbc:h2:mem: 뒤의 DB 이름을 다르게 줬습니다. 의도는 “테스트 간 DB를 격리하자”였습니다. 그런데 이 인라인 property가 Spring 컨텍스트 캐시의 키를 테스트마다 다르게 만들어, 컨텍스트가 전혀 재사용되지 않았습니다.

Spring 컨텍스트 캐시 키란?

Spring 테스트는 무거운 ApplicationContext를 테스트마다 새로 만들지 않으려고 캐시에 담아 재사용합니다. 이때 키는 단순한 문자열이 아니라 MergedContextConfiguration 객체입니다. 테스트 클래스의 모든 컨텍스트 설정을 병합한 것으로, 두 테스트의 이 객체가 equals()로 같으면 컨텍스트를 공유하고, 하나라도 다르면 별도 컨텍스트를 만듭니다.

키에 포함되는 주요 구성요소입니다.

구성요소출처
설정 클래스/XML, ContextLoader@ContextConfiguration, @SpringBootConfiguration
활성 프로파일@ActiveProfiles
@TestPropertySource 파일 경로locations
@TestPropertySource 인라인 propertyproperties ← 이번 원인
ContextCustomizer 집합@MockBean, @DynamicPropertySource

인라인 property가 한 글자만 달라도 다른 키 = 다른 컨텍스트입니다. 이는 Spring 프레임워크의 소스를 보면 알 수 있습니다. MergedContextConfigurationequals()hashCode()가 인라인 property 배열을 직접 비교하고 해시합니다.

// MergedContextConfiguration.equals()
if (!Arrays.equals(this.propertySourceProperties, otherConfig.propertySourceProperties)) {
    return false;   // 인라인 property가 다르면 다른 키
}

// MergedContextConfiguration.hashCode()
result = 31 * result + Arrays.hashCode(this.propertySourceProperties);

@TestPropertySource(properties = ["spring.datasource.url=...고유..."])의 배열이 여기 들어가므로, 테스트마다 H2 이름이 다르면 키가 전부 달라집니다.

회수되지 않는 컨텍스트가 힙을 채움

원인을 잡은 뒤, 정말 이 설정 때문에 OOM이 나는지 재현하며 GC 로그를 찍어 확인했습니다. 컨텍스트 캐시 구현은 org.springframework.test.context.cache.ContextCache이고, 여기서는 세 가지 성질이 같이 문제를 만들었습니다.

  • 재사용 0%: 키가 테스트마다 다르므로 조회는 매번 캐시 미스가 되고, 그때마다 새 컨텍스트를 빌드합니다.
  • 강한 참조로 동시 상주: 캐시는 기본 maxSize=32까지 컨텍스트를 강한 참조로 보관합니다. JPA 컨텍스트 하나에는 전체 엔티티 메타모델, H2, 커넥션 풀이 들어 있어 무겁습니다. 이런 컨텍스트가 32개까지 동시에 살아 있게 됩니다.
  • 단일 fork CI: 한 JVM에서 수백 개의 테스트가 도는 동안 컨텍스트가 한도까지 누적됩니다.

이 양상은 GC 로그에 그대로 드러났습니다. 아래는 실제 로그에서 핵심 GC 라인만 발췌한 것입니다. 초반에는 Young GC가 힙을 회수하지만 회수 후 사용량이 계속 올라가고, 후반에는 Full GC를 돌려도 사용량이 거의 그대로입니다.

[초반] 153M->38M(3072M)  …  421M->206M(3072M)   ← 회수 후가 계속 상승
[후반] Pause Full 3068M->3068M(3072M)            ← 풀 GC가 돌아도 안 내려감

Young GC와 Full GC

JVM 힙은 객체를 Young(젊은) 영역과 Old(늙은) 영역으로 나눠 관리합니다. Young GC는 Young 영역만 청소해 빠르고 자주 일어나고, Full GC는 Old를 포함한 힙 전체를 청소해 느립니다. 위 로그의 Pause Young이 Young GC, Pause Full이 Full GC입니다.

Full GC를 돌려도 사용량이 내려가지 않는다는 것은, 힙에 있는 객체가 전부 살아 있어 회수할 수 없다는 뜻입니다. 메모리 누수가 아니라 강한 참조로 잡힌 컨텍스트들이 힙을 가득 채운 상태입니다. 그 상태에서 다음 컨텍스트를 빌드하며 classpath를 스캔하는 순간 OutOfMemoryError가 납니다.

새 컨텍스트마다 전체 스키마 생성이 반복됨

테스트마다 고유한 H2 이름은 단순한 캐시 미스가 아니라, 그때마다 컨텍스트, 스키마를 통째로 새로 만든다는 뜻입니다. 테스트 클래스 하나가 추가될 때마다 다음이 반복됩니다.

  • classpath 스캔과 빈 생성
  • 전체 엔티티에 대한 Hibernate 메타모델 빌드
  • 빈 H2에 전체 테이블을 만드는 스키마 DDL

특히 마지막 스키마 DDL의 비용이 큽니다. 엔티티가 수백 개라면 매 컨텍스트마다 그만큼의 CREATE TABLE을 인덱스, 외래키까지 새로 실행합니다. 컨텍스트가 수백 개로 쪼개지면 이 전체 스키마 생성이 수백 번 되풀이됩니다.

수백 번 반복되던 이 작업이, 컨텍스트를 공유하면 몇 번으로 줄어듭니다. 그래서 캐시 키를 통일하면 OOM이 사라질 뿐 아니라 테스트가 크게 빨라집니다.

컨텍스트를 새로 만들 때마다 classpath 스캔, Hibernate 메타모델 빌드, 전체 스키마 DDL이라는 무거운 3단계가 반복되는데, 테스트마다 새 컨텍스트면 수백 번 반복되어 느리고, 컨텍스트를 공유하면 몇 번으로 줄어 빨라짐을 보여주는 도식

H2 인메모리 DB는 이름이 같으면 같은 DB임

H2 인메모리 DB는 같은 JVM 안에서 이름으로 식별됩니다. jdbc:h2:mem:sharedDb는 이름이 같으면 물리적으로 같은 DB입니다.

그래서 서로 다른 컨텍스트가 같은 DB 이름을 공유하면, 각자 create-drop으로 스키마를 만들고 지우다가 한 컨텍스트가 종료될 때 다른 컨텍스트의 테이블까지 drop해 충돌할 수 있습니다.

  • 같은 컨텍스트를 공유하는 테스트 → 같은 DB 이름 (캐시 재사용)
  • 컨텍스트 설정이 다른 테스트 → 다른 DB 이름 (스키마 충돌 방지)

즉 “단일 컨텍스트 = 단일 DB 이름”입니다.

캐시 키를 같게 만드는 방법

@DataJpaTest는 메서드마다 트랜잭션을 롤백하므로 테스트별 고유 DB가 애초에 필요하지 않습니다. 격리는 롤백이 이미 보장합니다. 따라서 고유 H2 이름을 없애고 설정을 하나로 고정하면 됩니다.

이를 위해 공통 설정을 묶은 합성(메타) 애너테이션을 만듭니다. Spring TestContext는 @DataJpaTest@TestPropertySource가 메타 애너테이션으로 올라가 있어도 병합해서 인식합니다.

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@DataJpaTest
@TestPropertySource(properties = [
    "spring.datasource.url=jdbc:h2:mem:sharedRepoDb;DB_CLOSE_DELAY=-1",  // 고정된 단일 이름
    "spring.jpa.hibernate.ddl-auto=create-drop",
])
annotation class RepositoryJpaTest

이제 각 테스트는 고유 설정 대신 이 애너테이션 하나만 붙입니다.

@RepositoryJpaTest
class OrderRepositoryTest

@RepositoryJpaTest
class MemberRepositoryTest

두 테스트의 property 블록이 글자 단위로 동일하므로 MergedContextConfiguration이 같아지고, 컨텍스트 하나를 공유합니다. 수백 개로 쪼개졌던 컨텍스트가 설정 그룹 수만큼으로 줄어듭니다.

테스트마다 다른 H2 이름을 주면 캐시 키가 갈려 컨텍스트가 여러 개 쌓여 힙이 가득 차 OOM이 나는 반면, 공통 애너테이션으로 단일 H2 이름을 쓰면 키가 같아져 컨텍스트 하나를 재사용함을 비교한 도식

한 가지 주의할 점은, 컨텍스트 설정을 바꾸는 요소가 있으면 그룹을 나눠야 한다는 것입니다. 예를 들어 @EnableJpaAuditing 적용 여부, @MockBean, @ActiveProfiles, @Import 등은 키를 바꾸므로, 그런 테스트는 같은 그룹에 넣을 수 없습니다. 설정 그룹별로 애너테이션을 따로 두고, 각 그룹에 다른 H2 이름을 주면 됩니다.

당장 쓸 수 있는 완화책

근본 수정 전에 CI를 빠르게 안정화해야 한다면, 동시 상주 컨텍스트 수를 제한하는 방법이 있습니다.

-Dspring.test.context.cache.maxSize=8

기본 32를 줄이면 한 번에 살아 있는 컨텍스트가 적어져 힙 압력이 내려갑니다. 동작은 그대로지만, 그만큼 컨텍스트 재생성이 늘어 약간 느려집니다. 근본 원인(키가 쪼개진 것)을 고치는 것은 아니므로 임시방편입니다.

정리

이번 문제의 흐름은 결국 이랬습니다.

테스트마다 다른 H2 url → 캐시 키 분열 → 컨텍스트 재사용 0% → 무거운 컨텍스트 누적 → 힙 고갈 → (다음 컨텍스트의) classpath 스캔 중 OOM

  • CI 테스트 OOM이 컨텍스트 빌드 단계(classpath 스캔) 에서 났다면, 테스트 로직이 아니라 컨텍스트가 너무 많이 만들어지는 것을 의심한다
  • Spring 테스트의 컨텍스트 캐시 키는 MergedContextConfiguration이고, 여기에 @TestPropertySource 인라인 property가 포함된다
  • @DataJpaTest마다 다른 H2 DB 이름을 주면 키가 전부 달라져 컨텍스트 재사용이 0%가 되고, 무거운 컨텍스트가 캐시에 쌓여 OOM과 속도 저하를 함께 부른다
  • @DataJpaTest는 롤백으로 격리되므로 고유 DB가 불필요하다. 공통 메타 애너테이션으로 설정을 고정해 키를 통일하면 컨텍스트를 공유한다
  • H2 인메모리는 이름으로 식별되므로 “단일 컨텍스트 = 단일 DB 이름” 규칙을 지킨다

참고 문헌