본문으로 건너뛰기

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 요청을 하는 부분이 있습니다.

graph LR U[User] --> GW[API Gateway] GW --> AS[A Service] GW --> BS[B Service] GW --> CS[C Service] GW --> DS[D Service] AS --> BS DS --> BS

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. 보다 나은 테스트 환경을 만들자