개발자취

[Spring] 테스트 코드 성능 개선기 (속도, 메모리)

SeongOnion 2023. 5. 18. 18:50
728x90

문제상황

사내에서 진행 중인 프로젝트의 테스트 코드와 관련된 문제는 꽤 오래전부터 체감되어왔다.

 

1. 테스트 코드 OOM(메모리 이슈)

https://seongonion.tistory.com/140 에서 적은 바 있던 내용이다.

 

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

어느 날 CI 파이프라인 중 테스트 실행 단계에서 지속적으로 OOM 에러가 나며 빌드에 실패하는 현상을 발견했다. 확인을 위해 로컬 환경에서 테스트 실행 시 현상이 재현되진 않았으나, 신기하게

seongonion.tistory.com

 

 

해당 문제를 분석하며 파악한 문제점은 다음과 같았다.

  • @SpringBootTest 를 사용하는 테스트 코드들이 ApplicationContext 캐싱 hit rate가 낮아 매번 새로운 컨텍스트를 생성한다.
  • 이로 인해 스프링부트를 한 번 실행시킬 때 필요한 데이터들을 테스트 컨텍스트 개수만큼 메모리에 올리게 된다.
  • develop 브랜치 기준 테스트 컨텍스트는 총 30번 뜬다.
  • 메모리가 당연히 부족하다.

 

2. 테스트 코드 수행시간이 너무 오래 걸린다.

위 처럼 IDE에 표기되는 순수 테스트코드 수행 시간은 15551ms(15초)이지만,

테스트 컨텍스트 로딩 시간을 포함한 전체 테스트코드 수행시간은 110878ms(110초)임을 확인했다.

 

다시 말해, 전체 테스트코드 수행 시간 중, 순수 테스트 코드 수행 시간이 차지하는 비율은 14.02%로, 나머지 85.98%의 시간인 95,327ms(95초)가 컨텍스트 로딩으로 낭비되고 있다.

 

해결해보자

위에서 언급한 메모리 이슈와 속도 문제 모두 근본적으론 테스트 컨텍스트가 지나치게 많이, 자주 로드되는 것이었다.

 

따라서, 테스트 컨텍스트 캐시 hit rate를 최대한 높이는 것을 목표로 잡고 작업하였다.

 

1. @MockBean 제거

 

@SpringBootTest에서 @MockBean을 사용하면 해당 빈은 테스트 컨텍스트에 Mock Bean으로만 등록되고, 실제 객체의 빈은 등록되지 않는다.

 

따라서, 이후 다른 @SpringBootTest에서 해당 클래스의 실제 객체 빈을 사용해야한다면, 이전의 테스트 컨텍스트를 캐싱하지 못하고 실제 객체 빈이 등록된 새로운 테스트 컨텍스트를 로드한다. (https://huisam.tistory.com/entry/springBootTest 참고)

 

이는 테스트 컨텍스트 캐시의 hit rate를 크게 저해하는 요소가 된다.

 

따라서, @SpringBootTest를 통해 통합테스트를 진행하는 테스트 코드에서 불필요한 @MockBean을 제거하고 서비스코드가 아닌 데이터를 통해 Mocking 작업을 해주도록 변경하였다.

 

예시코드)

기존)

@SpringBootTest
class MailControllerTest {

    @MockBean
    private MailRepository mailRepository;

    @Test
    @DisplayName("메일 데이터를 조회한다.")
    void getMailTest() {
        // given
        given(mailRepository.save(any(Mail.class)).willReturn(mailFixture());

        //when
        ResultActions perform = mockMvc.perform(
            get("/mails"));

        //then
        perform.andExpect(status().isOk())

    }
}

개선)

@SpringBootTest
class MailControllerTest {

    @Autowired
    private MailRepository mailRepository;

    @Test
    @DisplayName("메일 데이터를 조회한다.")
    void getMailTest() {
        // given
        // 레포지토리 자체를 Mocking하는 것이 아닌 데이터를 직접 저장하여 Mocking
        mailRepository.save(mailFixture());

        //when
        ResultActions perform = mockMvc.perform(
            get("/mails"));

        //then
        perform.andExpect(status().isOk())
    }

}

 

2. 단위테스트로 대체 가능한 테스트는 단위테스트로 대체

 

몇 개의 테스트는 사실상 타 클래스와 협력하지 않는 단위테스트의 기능만 하고 있었음에도, @SpringBootTest + @MockBean으로 테스트 되고 있었다.

 

당연히 컨텍스트가 불필요하게 새로 뜰 확률이 매우매우 높다.

 

이런 잘못된 테스트들도 모두 수정해주었다.

 

예시코드)

기존)

@SpringBootTest
class MailVerifierTest {

    @Autowired
    MailVerifier mailVerifier;

    @MockBean
    DeletedMailRepository deletedMailRepository;

    @Test
    @DisplayName("삭제된 메일을 이동하면 예외 발생")
    void throw_exception_if_move_deleted_mail() {
        // given
        Mail mail = mock(Mail.class);
        given(mail.getId())
                .willReturn(1L);

        DeletedMail deletedMail = deletedMailFixtureBuilder()
                .mailId(mail.getId())
                .build();

        given(deletedMailRepository.findByMailId(anyLong()))
                .willReturn(Optional.of(deletedMail));

        // when & then
        assertThatThrownBy(() -> mailVerifier.verifyMovable(mail))
                .isInstanceOf(RuntimeException.class);
    }

 

개선)

@ExtendWith(MockitoExtension.class) // @SpringBootTest 제거
class MailVerifierTest {

    // @InjectMocks & @Mock 사용
    @InjectMocks
    MailVerifier mailVerifier;

    @Mock
    DeletedMailRepository deletedMailRepository;

    @Test
    @DisplayName("삭제된 메일을 이동하면 예외 발생")
    void throw_exception_if_move_deleted_mail() {
        // given
        Mail mail = mock(Mail.class);
        given(mail.getId())
                .willReturn(1L);

        DeletedMail deletedMail = deletedMailFixtureBuilder()
                .mailId(mail.getId())
                .build();

        given(deletedMailRepository.findByMailId(anyLong()))
                .willReturn(Optional.of(deletedMail));

        // when & then
        assertThatThrownBy(() -> mailVerifier.verifyMovable(mail))
                .isInstanceOf(RuntimeException.class);
    }

 

개선결과

위 두 가지 간단한 기준을 갖고 리팩토링을 했음에도, 굉장히 많은 코드에서 수정이 일어났다.

 

그만큼 여지껏 잘못된 테스트 코드들을 멋모르고 작성했음을 체감했다.

 

1. 메모리 이슈 해결

 

리팩토링 후, 테스트 컨텍스트는 기존 총 30번 -> 6번 로드 되도록 개선되었다.

 

6번 중에서도, 4번은 @DataJpaTest를 수행하는 과정에서 로드되는 컨텍스트이므로, 속도나 메모리 측면에서 크게 영향을 끼치지 않았다.

 

결과적으로, 메모리 이슈를 회피하기 위해서 설정했던 테스트의 maxHeapSize를 기본값(512mb)으로 내리더라도 문제가 발생하지 않음을 확인했다.

// build.gradle

test {
    maxHeapSize = "1024m" // 제거해도 문제 없음!
    ...
}

 

2. 테스트 수행 속도 개선

개선 후, 순수 테스트코드 시간은 15947ms(15.9초), 테스트 컨텍스트 로딩시간을 포함한 전체 테스트코드 수행시간은 34943ms(34.9초)가 소요된 것으로 측정되었다.

 

이를 개선 전과 비교하여 정리하면 다음과 같다.

  개선 전 개선 후
전체 테스트코드 수행 시간 (컨텍스트 로딩 시간 포함) 110,878ms 34,943ms
순수 테스트코드 수행 시간 15,551ms 15,947ms
컨텍스트 로딩 시간(전체 수행시간 - 순수 수행시간) 95,327ms 18,996ms
컨텍스트 로딩 시간이 차지하는 비율 약 86% 약 54%

 

해당 결과를 통해, 리팩토링을 통해 컨텍스트 로딩으로 인해 낭비되는 시간이 약 80% 감소하였음을 확인하였다. (((95327 - 18996) / 95327) * 100)

 

당연스럽게도, CI 파이프라인이 도는 시간도 2분 이상 줄어들었다.

개선 전
개선 후