본문으로 건너뛰기

SpringBoot 테스트 개선해보기 (feat. mock)

· 약 9분

지금하고 있는 프로젝트에서 초기에는 테스트가 거의 없는 수준이였지만, 지금은 어느덧 약 800 여개의 테스트 케이스가 CI를 통해 검증되고 있는데요. 이렇게 테스트 케이스가 나날이 많아지면서 여기서 생긴 문제점과 이를 어떻게 해결했는지 공유해보고자 합니다.

(아직 Line Coverage가 16% 정도 밖에 되지 않아 갈 길이 머네요... 😵‍💫)

짧디짧은 서비스 구조 설명​

서비스는 그레이들 멀티 모듈을 활용한 여러 서비스를 모듈화해두었습니다.

gradle-multi-module
ㄴㅡ a-service
ㄴㅡ b-service
ㄴㅡ c-service
ㄴㅡ d-service
ㄴㅡ ...

시작은 작은 구멍에 불과했다.​

시작은 테스트 3~400개가 되었을 무렵부터 였습니다. 평소와 다름 없이 기능 개발을 위해 별도의 feature 브랜치를 만들고 비즈니스 로직과 테스트 코드를 추가했는데 개발 장비에서 잘 돌던 테스트가 CI가 실패하기 시작했습니다. 로그를 보니 OOM이 발생하는 것이였는데요. 여기서 테스트 케이스가 많아져서 그런가 보다... 라는 짧은 생각으로 다음과 같은 처리를 했습니다. 😅

test {
maxHeapSize = "1G"
}

그리고 구멍은 더 커지기 시작했다.​

그리고 다시 700개가 넘어갈 무렵 문제는 다시 시작되었습니다. 이 글을 작성하는 시점에는 이 문제에 대한 원인을 알고 있기 때문에 다시 시작되었다기보다는 고이 덮어둔 문제(?)가 커질때로 커진 것이죠. CI 로그를 확인해보았고, 이는 역시나 OOM이 발생해 있었습니다.

여기서 여러가지 생각이 들었습니다.

  • 메모리가 1G가 넘어갈 만큼 테스트가 많은가?
  • 무거운 테스트가 있는가?

메모리가 1G가 넘어갈 만큼 테스트가 많은가?​

테스트가 수천개, 수만개를 가진 오픈소스들을 보면 1000개도 안된 테린이(?) 수준이였기 때문에 사실상 결코 많은 수준이 아니였습니다.

무거운 테스트가 있는가?​

물론, 주로 통합 테스트 @SpringBootTest를 활용한 통합테스트를 작성하고 있습니다. 하지만, 주기적으로 데이터베이스 초기화 등으로 상태를 가지는 객체들을 일괄적으로 비워주고 있기 때문에 문제가 된다고 생각되진 않았습니다.

어디가 문제인가?​

다행스럽게도 로컬 환경에서도 손쉽게 재현이 가능했기 때문에 특정 모듈에 대한 전체 테스트 실행하여 힙 덤프를 떠보았습니다.

Alt as-is (tool: https://www.eclipse.org/mat/)

거대한 DefaultListableBeanFactory 수십개...​

스크린샷에 전부 표현되지 않았지만, 상당히 많은 빈 팩토리가 여러개 생긴 것을 볼 수 있습니다. 대략 50MB 사이즈의 빈 팩토리가 20개만 생겨도 1000MB이기 때문에 상당히 비정상적인 경우라고 볼 수 있을 것 같습니다.

DefaultListableBeanFactory는 왜 많아지는가?​

공식문서를 확인해본 결과, 다음과 같은 목차가 있는 것을 알 수 있습니다. (목차만 봐도 느낌이 쌔합니다...)

Context Caching

Alt test-context-cache (참고: https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#testcontext-ctx-management-caching)

대략 요약을 해보자면, 테스트에서 ApplicationContext를 재사용하기 위해 테스트에 적용된 설정들을 참고하여 이를 결정합니다. 여기서 문제가 되었던 부분은 @MockBean이였습니다. 지금 생각해보면, 스프링 빈을 자연스럽게 mock으로 만들어주는데 이게 어떻게 동작하는걸까를 깊게 생각해보지 않는 것이 결정적인 문제였던 것 같습니다. 🤣

우리는 왜 @MockBean를 사용하는가?​

우선 @MockBean은 mockito에서 제공해주는 mock을 스프링부트에서 테스트시에 스프링 빈을 만들때 mock으로 만들어주는 애노테이션입니다. (보다 자세한 내용은 MockitoPostProcessor, MockitoTestExecutionListener를 찾아보시면 더욱 도움이 될 것 같습니다.)

저도 그렇지만, 프로젝트 대부분의 테스트 코드의 @MockBean을 사용하는 부분이 특정되어 있습니다. 이 부분은 서비스 구조와 연관이 있는데요. 모듈로 분리된 여러 서비스는 다양하게 서로의 정보를 주고 받는데, 이 중 서로 http 요청을 하는 부분이 있습니다.

Classicist 측면에서 접근해보자면 테스트에 활용할 각각의 테스트 서버들을 준비해두고 이를 활용하면 됩니다. 하지만, 아직 테스트 숙련도가 높지도 않고, 비용/시간/우선순위면에서도 그렇게 해야할 만큼 고도화가 필요한 상황이 아니였기에 적절한 해결책은 아니였습니다.

그렇디면 이를 어떻게 해야할까요? API 요청에 대한 클래스들을 미리 mock으로 만들 순 없을까요?

BeanPostProcessor​

스프링에는 BeanPostProcessor라는 것이 있습니다. 이에 대한 자세한 내용은 Spring - @Autowired는 어떻게 동작하는 걸까?을 참고하시면 됩니다.

정말 다행스럽게도 우리는 공통적으로 open-feign을 활용하여 API 요청을 하고 있습니다. 그렇기에 별도의 BeanPostProcessor를 만들고 만들어진 open-feign을 선별하여 mock으로 바꿔주면 테스트마다 별도의 mock을 만들지 않고 다음과 같이 사용할 수 있습니다.

//@MockBean
@Autowired // 👈 👈
private AServiceFeign feign;

물론, 팀에 이 사실을 전파하지 않는다면 어떻게 이게 되지? 라고 할 수 있겠네요...

결과는...?​

결과는 성공적이였습니다.

Alt to-be

maxHeapSize을 더 늘려보고 테스트해보면 정확한 heap 차이를 알 수 있겠지만, 1G이라고 볼 때 약 5배이상 메모리 절감을 보여줬고, ApplicationContext 또한 새로 만들지 않았기 때문에 속도 측면에서도 약 2배 가량 빨라진 것을 볼 수 있었습니다.

AS-IS​

Alt speed-as-is

TO-BE​

Alt speed-to-be

배운점​

  1. 테스트 코드도 비즈니스 코드 작성하듯이 더 많은 고민을 해보자
  2. 부가적인 설정은 그냥 따라오지 않는다
  3. 보다 나은 테스트 환경을 만들자