문제점

대부분의 부서가 비슷하겠지만 유닛 테스트 작성이 정말 중요해지고 있다. 유닛 테스트는 개발할 때뿐만 아니라 여러 배포 과정에서도 자주 수행되는 요소 중 하나이다.

(내가 짠 코드를 나도 100% 믿지 못하기 때문에..)

우리 부서에 몸집이 상당히 커진 legacy 프로젝트가 하나 있는데, 나름 이 프로젝트도 유닛 테스트는 꼼꼼히 작성되어 있는 편이다. 하지만 문제는 거기서 발생했다. 몸집이 너무 커진 프로젝트다 보니 처음부터 테스트를 꼼꼼히 작성하지 않아, 테스트 코드에 대한 설계가 주먹구구식으로 되어있었다. 이 프로젝트는 약 600개가량의 테스트가 작성되어 있고, 이 테스트를 모두 수행하는 데 6분 이상의 시간이 소요되고 있는 실정이었다.

우리는 보통 아래 4가지 경우마다 유닛 테스트를 실행시키다 보니 테스트 속도가 느리면 개발 효율이 안 좋아질 수밖에 없다.

  • 개발한 코드가 문제없이 돌아가는지 확인할 때
  • PR이 생성되었을 때
  • master 브런치에 코드를 머지 할 때
  • release 브런치에 코드를 머지 할 때

특히 PR 단계에서의 테스트 수행은 코드 리뷰 등의 사유로 코드에 수정이 필요해질 경우, 코드를 수정하여 push할 때마다 테스트가 끝나기를 오랫동안 기다려야 한다. 그렇기 때문에 코드에 사소한 문제가 있을 경우 수정해달라고 요청하기에도 미안한 그런 상황이 발생되곤 한다.

마침 회사 교육 수료를 위한 과제의 일환으로 테스트 속도 개선을 주제로 프로젝트를 계획할 일이 생겨, 이 legacy 프로젝트가 왜 테스트 속도가 느린지 분석하고 개선하는 기회를 가져볼까 한다.

 


 

원인분석

이 프로젝트는 Spring boot 1.x로 개발되었고, 유닛 테스트의 유형은 크게 2가지였다.

  1. 실제 서버를 띄워 여러 인터렉션을 테스트하는 방법 (SpringBootTest, AutoConfigureMockMvc)
  2. 실제 서버는 띄우지 않고, mocking을 통해 테스트하는 방법

문제는 1번 방법에서 발생하였다. 1번 방식으로 작성된 테스트가 약 40개 정도 되었는데, 테스트마다 서버를 새로 띄우다 보니 서버가 새로 띄워지는데 많은 시간이 소요되는 것이었다. (내 컴퓨터에서는 서버가 새로 띄워지는데 보통 30초 정도 소요되었다.)

이렇게 서버를 띄워서하는 방식의 테스트가 이 프로젝트의 경우 4가지 케이스가 존재하였다. (보통은 이렇진 않다.)

  • A class: contorller 객체를 테스트하기 위해
  • B class: service 객체를 테스트하기 위해
  • C class: WireMock을 활용하여 다른 마이크로 서비스들과의 http 통신을 stubing 하여 테스트
  • D class: C와 유사한데 service 객체를 그냥 mocking 시켜버린 테스트

A classB class는 dummy DB data를 활용하여 테스트하기 위한 것으로 보인다. 우리는 실제 서버를 띄워 유닛 테스트를 하는 경우 보통 C class의 방식을 활용하는 편이다.

따라서 1번 방법의 테스트 케이스들은 모두 위 4개 중 하나를 상속하고 있어서, 각 케이스마다 서버를 한 번만 띄우도록 개선해도 어마어마한 속도 개선이 가능할 것 같다는 생각을 했다.

 


해결방법

왜 각 테스트 class마다 같은 부모 class를 상속함에도 한번 띄운 서버를 재사용하지 않고 계속 서버를 새로 띄우는지 여러 가지 구글링을 했다.

구글링을 하면서 알아낸 사실은 SpringBootTest는 context caching을 한다는 것이었다. 따라서 context에 변경이 없는 한 한번 띄운 서버를 재사용하는 것이 기본이라고 했다. 하지만 이 프로젝트의 테스트들은 대부분의 테스트가 새로 서버를 띄워서 테스트를 하고 있는 상황이었다.

그렇다면 a라는 테스트 클래스와 b라는 테스트 클래스에 무슨 차이가 있어서 context가 변경되었는가에 초점을 맞춰서 생각해 보았다.

MockBean

범인은 @MockBean 이였다.

@MockBean을 사용할 경우 기존에 선언된 Bean을 mocking된 객체로 변경시켜야 하기 때문에 context에 변경이 생겼다고 인식하고 새로운 서버를 띄우는 것이었다.

Annotation that can be used to add mocks to a Spring ApplicationContext. (중략) When @MockBean is used on a field, as well as being registered in the application context, the mock will also be injected into the field.
https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/test/mock/mockito/MockBean.html

public class ATestControllerTest extends AClass {

    @Test
    public void testAddUser() {
    ...
public class BTestControllerTest extends AClass {

    @MockBean
    private AService aService;

    @Test
    public void testCreateRoom() {
    ...

위 케이스에서 @MockBean객체 1개 때문에 ATestControllerTest 이후 BTestControllerTest를 수행하기 위해선 새로 서버를 띄워야하는 것이다.

그래서 context 통일을 위해 여러 클래스에 거쳐 선언된 @MockBean 객체들을 부모 클래스로 이동시켜줄까 싶었지만, 그렇게 되면 다른 클래스에서는 원치 않는 mocking을 처리해야 할 것 같아 다른 방식을 고민하게 되었다.

SpyBean

내가 필요한 것은 mocking을 했을 경우는 mocking된 값으로 반환하고, mocking 하지 않았을 경우에는 정상적으로 동작하게끔 하는 것이었다. 그렇게 된다면 모든 객체를 부모 클래스에 선언하게 되면 context를 통일 시킬 수 있다고 생각했다.

그것에 대한 답은 @SpyBean이었다. @SpyBean은 정확히 내가 원하는 대로 동작하는 콘셉트를 가졌다.

따라서 기존에 존재한 모든 @MockBean 객체들을 @SpyBean 객체로 변경해 주었고, 모두 부모 클래스에 선언하게끔 수정하였다. 그 과정에서 발생한 여러 문제점들을 고쳐나갔다. (기존에 제대로 mocking 되지 않아 exception이 발생한다던가, mockBean과 spyBean의 mocking 방식의 차이라던가...)

@RunWith(SpringRunner.class)
@SpringBootTest(classes = ServiceApplication.class)
@TestPropertySource(properties = {"spring.profiles.active=test", "active.profiles=test"})
@AutoConfigureMockMvc
@ContextConfiguration
@Transactional
@Category(value = { AClass.class })
public abstract class AClass {

    @Autowired
    protected MockMvc mvc;

    ...

    @SpyBean
    protected AService aService;

    @SpyBean
    protected BService bService;
    ...

프로젝트의 크기가 생각보다 크고 로직도 많이 복잡했기 때문에 수정이 간단하지는 않았다...

개선에 들어간 노력 대비 얼마큼의 아웃풋이 발생하는지 확인하기 위해 A class 방식에 대해서 먼저 개선해 보았다.

(사실 젤 간단하게 수정할 수 있는 케이스였다.)

개선결과

A class 방식의 테스트 개수는 총 11개였고, 개선 전 소요시간은 2분이었다. 고작 11개의 테스트를 수행하는 데 2분이나 걸렸던 것이다.

개선 후 테스트 소요시간은 약 40초 정도였다. 약 67% 속도 개선이 된 것이다.

노력 대비 발생하는 이점이 크다고 판단하여 나머지 B, C, D class에 대해서도 개선을 진행하였다. 개선된 결과는 아래 표와 같았다.

Class 테스트 수 개선 전 소요시간 개선 후 소요시간
A Class 11 2분 40초
B Class 9 2분 11초 48초
C Class 16 2분 54초
D Class 5 1분 42초 34초

대부분 60% 이상의 속도 개선 효과를 볼 수 있었다. 그도 그럴 것이 대부분의 시간이 서버를 띄우는데 소요되었고, 테스트 자체의 속도는 굉장히 빠른 편이었다.

전체 유닛 테스트(약 600개)에 대해서는 개선 전 6분 정도 소요되었는데,
개선 후에는 2분 40초 정도 소요되었다. 50%가 넘게 개선된 것이다.

(사실 2분 40초도 너무 느리다..)


결론

사실 이 프로젝트는 내가 메인으로 개발하고 있는 프로젝트는 아니었다. 하지만 가끔 자잘한 수정을 할 때마다 머지 하기까지 오랜 시간이 소요되는 것에 불편함을 많이 느끼고 있었다.
(가끔 개발하는 사람도 답답한데 메인으로 개발하시는 분은 얼마나 답답하셨을까..)

CI/CD 툴을 Github Action으로 변경하고 테스트 수행 시간이 미약하게나마 빨라진 편이였지만, 이전에 Circle CI를 사용할 때는 테스트 단계에서 18분이 소요된 경우도 있었다. (CPU 성능에 따라 영향을 많이 받는 것 같다)

+82의 민족인 만큼 속도가 느리면 많은 답답함을 느끼는 편인데 이번 개선을 통해 어마어마한 시간을 절약하게 된 것 같아서 많이 뿌듯했다.

context caching에 대한 개념도 알 수 있었고, 테스트라고 대충 만들지 말고 테스트 코드도 꼼꼼한 설계가 필요하다는 것을 생각해 볼 수 있는 좋은 기회가 되었다.

'Server > Test' 카테고리의 다른 글

Wiremock 사용법 (with Groovy Spock)  (0) 2022.10.23
Spock 병렬 테스트하기  (0) 2022.10.23
복사했습니다!