개발자취

[Spring] 스프링 테스트코드 메모리(OOM)이슈 해결 아닌 회피기

SeongOnion 2023. 4. 2. 16:50
728x90

어느 날 CI 파이프라인 중 테스트 실행 단계에서 지속적으로 OOM 에러가 나며 빌드에 실패하는 현상을 발견했다.

 

확인을 위해 로컬 환경에서 테스트 실행 시 현상이 재현되진 않았으나, 신기하게도 swagger를 업데이트 해주는 openapi3 task 중 현상이 발생했다. (왜 그런지 아시는 분? ㅠ)

처음에는 테스트코드에서 Mocking을 위해 읽어들인 파일 쪽에 문제가 생겨 발생한 것으로 추측하였으나, 확인 결과 해당 코드는 큰 문제가 없음을 확인했다. (byte로 직접 쓰는 부분이 없었다)

 

다행스럽게도 로컬환경에서 재현이 되었기 때문에 Heap Dump를 떠서 누수 원인을 분석해볼 수 있었다.

Heap Dump 확인 결과, org.hibernate.metamodel.internal.MetamodelImpl 객체가 전체 503.2MB 중 140.8MB를 점유하고 있었고, 해당 객체는 데이터 모델의 메타데이터를 저장해놓는 JPA Metamodel 인터페이스의 hibernate 구현체임을 확인하였다.

 

첫 번째 접근

현재 소스코드 상, 여러 테스트 코드 사이에서 오염된 데이터들을 청소해주기 위해 매번 @BeforeEachdrop table && create table SQL을 실행시켜주고 있다.

@BeforeEach
public void beforeEach() {
    ScriptUtils.executeSqlScript("sql/reset_table.sql");
    ...
}
-- reset_table.sql
drop table if exists 'table';
create table 'table' (
    ...
);

...

이는 테스트코드에서 데이터 초기화를 위해 일반적으로 사용하는 @Transactional 사용 시 발생 가능한 문제들을 원천적으로 차단하기 위한 목적이 있었다. (참고: https://tecoble.techcourse.co.kr/post/2020-08-31-jpa-transaction-test/)

 

하지만, 매번 테이블을 전체 DROP -> 새로 CREATE 하는 작업에서 매번 데이터 모델의 메타데이터를 새로 로드하는게 아닐까? 하는 의심을 했고 해당 부분을 drop && create table 대신 delete from table 쿼리로 바꾸었다.

-- reset_table.sql

delete from 'table';
...

하지만 결과는.. OOM 이슈가 동일하게 발생하였고, Heap Dump 결과 또한 이전과 차이가 없었다.

 

두 번째 접근

코난식 때려맞추기는 그만두고, 문제의 원인인 hibernate의 MetamodelImpl 객체가 어떤 식으로 생성되고 동작하는지 살펴봤다.

MetamodelImpl 객체는 앞서 언급했듯, 데이터 모델의 메타데이터를 관리하는 JPA Metamodel 인터페이스의 hibernate 구현체이다.

 

그리고 hibernate 기반의 Spring Data JPA 라이브러리를 사용하게 되면, 해당 객체의 정보를 기반으로 Spring Data JPA 모듈의 JpaMetamodel 객체를 생성하게 된다.

JpaMetamodel 생성자 참고

위 코드에서 볼 수 있듯, JpaMetamodel은 of 생성자에서 Metamodel 인터페이스 객체를 인자로 받는데 중요한 점은 static 객체로 생성한 ConcurrentHashMap을 통해 Metamodel을 캐싱한다는 점이다.

 

하지만, Heap Dump에서 메모리를 과점유하고 있던 hibernate의 MetamodelImpl은 그렇지 못하다.

hibernate의 MetamodelImpl 생성자 참고

MetamodelImpl 객체는 별 다른 캐싱 과정 없이 데이터베이스 SessionFactory를 받아 매번 새로운 인스턴스를 생성하게 된다.

 

이는 즉, (SpringBootTestApplicationContext가 새로 로드될 때마다) +( 특정 데이터 모델을 테스트 빈으로 등록할 때마다) 해당 Context에서 필요로 되는 데이터 모델의 메타데이터가 매번 새로 생성된다는 말이었고, 이로 인한 데이터 과점유가 발생한 것이었다.

 

해결

분석 결과, 해당 문제는 잘못된 환경 설정의 문제라기보단 프로젝트 볼륨이 커지고 테스트 코드의 갯수가 증가하는 상황에서 캐싱에 대한 고려없이 @SpringBootTest 를 지나치게 남용해 발생한 문제로 보인다.

 

물론 이전부터 테스트 소요시간이 체감될 정도로 증가하고 있음은 느꼈으나, 이것이 메모리 이슈까지 유발하고 있을 줄은 몰랐다..

 

테스트 Context의 캐싱 hit rate를 높일 수 있도록 또 다른 조치가 필요할 듯하다.

 

우선 외양간부터 고치자는 격으로 테스트 환경에서의 최대 힙 사이즈를 증가시켜놓았다.

// build.gradle

test {
    maxHeapSize = "1024m"
    ...
}

사실 해당 문제 해결을 위해 서치 중 스택오버플로우에서 가장 처음으로 봤던 답변이

"테스트 환경에서 메모리 사이즈를 증가시키는 것에 불편함을 느끼지 말라" 였다. (캡처해서 올리고 싶은데 못찾겠음)

 

나는 불편함을 참지 못하고 원인을 찾아 열심히 삽질을 한 끝에 결국 불편함을 감수하게 되었지만, 그래도 나름 보람 있는 삽질이었다.

 

번외

전체 테스트 코드 실행 시, SpringBootTest의 새로운 ApplicationContext가 로드될 때마다 HikariPool 또한 함께 새로 생성되고 있다.

 

컨텍스트가 새로 로드될 때 빈들 또한 함께 생성되기 때문에, HikariPool 생성 역시 해당 빈들에 의존성을 가지므로 새로 생성되는 것이 논리적으론 맞아보인다.

 

프로젝트 하나에 DataSource 3개가 연결되어서 그런건가 싶긴한데, 매번 100개가 넘어가는 HikariPool이 생성되는 것이 자연스러운 것인지 아니면 뭔가 내부적으로 설정이 잘못 되어있는 것인지 조금 더 확인이 필요할 듯 싶다.

 

잘못 분석한 내용이 혹시나 있다면 말씀 부탁드립니다!