[Spring] 테스트 코드 성능 개선기 (속도, 메모리)
문제상황
사내에서 진행 중인 프로젝트의 테스트 코드와 관련된 문제는 꽤 오래전부터 체감되어왔다.
1. 테스트 코드 OOM(메모리 이슈)
https://seongonion.tistory.com/140 에서 적은 바 있던 내용이다.
해당 문제를 분석하며 파악한 문제점은 다음과 같았다.
- @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분 이상 줄어들었다.