테스트 코드를 왜 도입하게 되었나요?

2018년 2분기까지만 해도 연애의 과학 서비스에 테스트 코드가 없었습니다. 가장 불편했던 점은, 제가 쓴 코드가 의도한 대로 동작하는지를 직접 검증해야 했다는 것이었어요. api 호출 및 DB 세팅을 수동으로 했기 때문에 시간도 많이 걸렸고, 자동화된 테스트에 비해 실수가 발생할 가능성도 높았죠.

뿐만 아니라, 테스트 케이스 관리도 어려워졌습니다. 테스트 케이스를 한눈에 볼 수 없어 빠진 테스트 케이스를 확인하기 어려웠던 거죠. 저는 빠진 테스트 케이스가 있지 않을까 걱정되는 마음에 여러 번 수동 테스트를 작동시켰으며, 그로 인해 테스트 시간이 배로 늘어나곤 했어요.

또한 코드 리뷰를 하면서 코드 수정이 빈번하게 일어나는데, 그때마다 코드가 올바르게 동작하는지 확인하기 위해 수동으로 테스트를 해야 했습니다. 따라서 기능을 개발하는 데 드는 시간은 배로 늘어났죠!

코드 수정 → 직접 API 호출 → 리뷰할 때 빠꾸 먹음 → 시바신의 기운을 빌어 시바 → 코드 수정 → ... via GIPHY

마지막으로, 다른 사람이 작성한 코드가 의도한 대로 동작한다는 것을 알 수 있는 수단이 없었습니다. 코드 리뷰를 할 때, 다른 사람이 작성한 제품 코드가 올바른지 검증할 때 그저 제품 코드를 세심히 바라보는 수밖에 없었죠. 하지만 본래 인간이란 실수를 안 할 수 없는 존재이기 때문에, 코드 리뷰만으로는 코드의 정확도를 확신하기 어려웠어요.

우주의 기운을 빌어 코드를 리뷰하는 개발자의 모습이다. via GIPHY

그래서 이러한 불편함을 제거하고 코드의 정확성을 쉽게 판단하기 위해 저희는 유닛 테스트를 도입하게 되었습니다.

TDD는 무엇인가요?

연애의 과학 서버 프로젝트에 테스트 코드를 도입하면서, TDD로 개발하고자 하는 백엔드 개발자는 TDD 방법론을 따라 개발할 수 있게 되었습니다. TDD는 다음과 같은 세 가지 단계를 반복하며 개발하는 방법론이에요.

🚦
- RED: 실패하는 테스트 코드를 만든다. - GREEN: 테스트 코드가 성공하도록 가능한 빨리 제품 코드를 만든다. - REFACTOR: 앞 단계에서 발생한 중복을 제거한다.
  • 세 단계 중 RED, GREEN은 가능한 빨리 완료하는 것이 원칙입니다. 그래서 TDD 방법론으로 개발을 할 때는 최소한의 코드를 작성하게 됩니다.
  • 실패하는 테스트 코드는 내가 풀고자 하는 문제를 코드 형태로 표현한 결과물입니다. 경우에 따라서 추가하고자 하는 기능이 될 수도 있고, 고치고자 하는 버그가 될 수도 있습니다.
  • 그래서 실패하는 테스트 코드는 문제를 해결하는 데 실패한 것이 아닌, 문제를 해결하기 위한 첫 번째 과정으로 볼 수 있습니다.
  • 가능한 빨리 코드를 작성하는 과정에서는 코드 중복 등 여러 부작용이 발생할 수 있는데, 이것은 REFACTOR 과정에서 해결할 수 있습니다.

예를 들어, 연애의 과학 커뮤니티에서 인기 게시물을 판단하는 알고리즘을 작성한다고 가정해 볼게요. 요구사항을 간단히 하기 위해, 커뮤니티 글의 좋아요 개수가 10개 이상이면 핫 게시물로 판단한다고 가정하겠습니다.

먼저 테스트 코드를 다음과 같이 작성합니다. (커뮤니티 글은 CommunityPost라는 자바 빈 형태의 클래스로 정의되어 있다고 가정합니다.)

class TestCommunityService {
    @Test
    public void testIsHot() {
        CommunityPost post = new CommunityPost();
        post.setLike(10);
    
      assertTrue(CommunityService.isHot(post));
    }
}

테스트를 통과할 수 있는 가장 간단한 제품 코드를 작성합니다.

class CommunityService {
    public boolean isHot(CommunityPost post) {
        return true;
    }
}

이는 분명히 틀린 구현이기 때문에, 이 구현을 통과시키지 않도록 새 테스트 케이스를 작성합니다.

class TestCommunityService {
    
    // (생략)

    @Test
    public void testIsHotForNonHotPost() {
        CommunityPost post = new CommunityPost();
        post.setLike(9);
    
      assertFalse(CommunityService.isHot(post));
    }
}

이제 테스트 코드의 통과를 위해 제품 코드를 다음과 같이 수정합니다.

class CommunityService {
    public boolean isHot(CommunityPost post) {
        return post.getLike() >= 10;
    }
}

지금 단계에서 10이라는 상수가 중복됨을 확인할 수 있습니다. 만약 인기 게시물의 기준이 좋아요 10개에서 20개로 바뀌었을 때, 이와 연관된 상수(10과 9)를 일일이 변경하는 건 어렵겠죠? 지금까지 작성한 코드에서 중복을 제거하기 위해 10을 상수로 정의합니다.

class CommunityService {

    public static final int HOT_POST_LIKE_THRESHOLD = 10;

    public boolean isHot(CommunityPost post) {
        return post.getLike() >= HOT_POST_LIKE_THRESHOLD;
    }
}

class TestCommunityService {
    
    @Test
    public void testIsHot() {
        CommunityPost post = new CommunityPost();
        post.setLike(CommunityService.HOT_POST_LIKE_THRESHOLD);
    
      assertTrue(CommunityService.isHot(post));
    }

    @Test
    public void testIsHotForNonHotPost() {
        CommunityPost post = new CommunityPost();
        post.setLike(CommunityService.HOT_POST_LIKE_THRESHOLD - 1);
    
      assertFalse(CommunityService.isHot(post));
    }
}

이와 같은 과정을 봤을 때 단계가 너무 작다고 느껴질 수도 있습니다. 사실 지금과 같은 예시에서는 코드의 양이 많지 않아 해결 과정이 명확하게 보이죠. 하지만, 실제 코드의 경우 코드의 양이 매우 많아 문제의 해결 방법이 명확히 보이지 않을 뿐만 아니라, 문제가 무엇인지조차 명확하지 않을 때도 있었어요. 그럴 때 작은 단계로 나눈 뒤 할 수 있는 가장 쉬운 행동부터 하나씩 취해 나가는 게 큰 도움이 되었답니다.

연과 백엔드 팀에서 테스트 코드를 적용한 방법

먼저 백엔드 팀에서 테스트 코드를 작성하기 위해 사용하는 라이브러리를 살펴보겠습니다.

  • 연애의 과학 서버는 Java로 개발되어 있기 때문에 JUnit 5 및 Mockito를 이용하고 있습니다.
  • DB로 MongoDB를 사용하고 있으며, 서버에서 DB로 질의를 할 때 spring-data-mongodb 라이브러리를 사용하고 있습니다.
  • 실제 DB에 접속하지 않고도 DB 시뮬레이션을 하기 위해 fongo라는 MongoDB의 in-memory Java 구현체를 사용하고 있습니다.
  • 마지막으로 Spring repository의 의존성 주입을 원활하게 하기 위해 spring-test 라이브러리를 사용하고 있습니다.

테스트 클래스는 다음과 같이 구성되어 있습니다.

@ExtendWith(SpringExtension.class)	// Spring Bean을 이 클래스에 주입시키는 등 초기화를 위해 사용
@ContextConfiguration(classes = TestConfig.class)	// fongo를 사용하기 위해 테스트용 Spring Configuration을 설정
public class TestCommunityService {

    CommunityService communityService;

    @Autowired
    CommunityPostRepository communityPostRepository;

    // 기타 멤버 변수 (Mock object 및 테스트 코드에 주입시킬 Bean 등)

    @BeforeEach
    public void setUp() {
        // 초기화 작업
    }

    @Test
    @DisplayName("테스트 케이스에 대한 설명")
    public void testCase() {
        // 테스트 케이스
    }

    private CommunityPost assumeCommunityPost() {
        // 테스트 대상 함수를 실행하기 전에, 필요한 자료를
        // 가상의 DB에 추가하고 추가한 자료를 리턴하는 작업
    }

    @AfterEach
    public void teardown() {
        // 테스트 케이스에서 썼던 자원 정리 작업
    }
}

테스트 케이스 내부는 다음과 같이 구성되어 있습니다.

    @Test
    @DisplayName("테스트 케이스에 대한 설명")
    public void testCase() {
        // arrange - 테스트 코드에 필요한 자료를 설정
        CommunityPost communityPost = assumeSingleCommunityPost();

        // act - 테스트 대상 함수를 실행
        boolean result = CommunityService.isHot(post);

        // assert - 테스트 대상 함수가 의도한 결과를 만들었는지 확인
        assertFalse(result);
    }

백엔드 팀에서 테스트 코드를 작성할 때 지켜야 할 원칙은 다음과 같습니다.

  • 풀고자 하는 문제가 명확한 테스트 코드를 작성합니다.

    • 테스트 코드를 볼 때, 무엇을 가정했을 때 어떤 결과가 나오기를 기대하는지 한눈에 파악할 수 있도록 테스트 케이스 안의 코드는 10줄을 넘지 않게 합니다.
    • 항상 성공하는 테스트는 풀고자 하는 문제가 명확하지 않다고 판단하여 지양합니다.
  • 가독성을 위해 테스트 구조를 지키며 함수 내 코드를 짧게 유지합니다.

    • @Test로 annotate된 테스트 케이스는 테스트 케이스끼리 배치하며, 테스트 케이스 내부는 arrange - act - assert 구조를 지킵니다.
    • 테스트 대상 함수를 실행하기 전 세팅 과정이 복잡할 때는, 테스트 클래스 내 assume() 함수를 정의하거나 Fixture 클래스를 만들어 세팅 과정을 관리합니다.
  • 이외에도 다른 코드와 독립적인 코드, 제품 코드의 구조를 해치지 않는 코드 등을 작성하기 위해 노력합니다.

테스트 코드를 작성해야 한다는 원칙과는 달리, TDD는 팀에서 강제하지는 않습니다. TDD를 팀 내에서 지켜야 한다는 규칙보다는, TDD가 가진 장점 중 점진적으로 문제를 접근하여 큰 문제를 해결한다는 가치를 중시하려고 하는 것이죠. 큰 문제를 해결하기 어려워하는 팀원이 있을 경우 문제를 잘게 쪼개어 해결을 시도할 수 있도록 조언해줌으로써, TDD를 어려워하는 팀원들도 점진적인 문제 해결 방법을 습득할 수 있도록 돕고 있어요.

테스트 코드 및 TDD로 어떤 장점을 얻었나요?

자동화된 테스트 코드를 써서 얻은 가장 큰 장점은, 테스트 코드가 그 누구보다 빠른 피드백을 준다는 것이었습니다. 즉 의도한 대로 코드를 작성했는지 테스트 코드를 돌리며 빠른 피드백을 얻을 수 있었죠. 실제 앱으로 테스트하는 것보다는 테스트 코드를 작동시켜 보는 것이 의도와 다른 제품 코드를 작성했음을 더 빨리 알 수 있는 방법이니까요.

또한 리팩토링 등 코드를 수정할 때 어떤 부분에서 문제가 발생하는지를 빨리 알 수 있었습니다. 오류가 발생한다면 테스트 코드가 그 오류를 어느 정도 잡을 수 있을 것이라고 기대할 수 있었어요. 그래서 팀원들도 코드가 잘 작동하지 않는 것에 대한 불안감을 덜 느낄 수 있게 되었죠!

테스트 코드를 도입하면서 개발 속도가 늦어지는 것에 대한 불안감도 있었으나, 테스트 코드를 작성하는 노하우가 어느 정도 쌓였을 때는 수동으로 테스트를 진행했을 때보다 오히려 개발 속도가 더 빨라짐을 경험하기도 했습니다. 결과적으로는 테스트 코드를 도입하면서 생산성이 크게 증가했고, 큰 버그 없이 서버를 개발 및 배포할 수 있었습니다.

오레와... 나 자신을... 신지떼이루...!!!! 타다다다타타다ㅏㄱvia GIPHY

TDD 역시 빠른 피드백을 받는다는 것이 가장 큰 장점이었습니다. 테스트 코드를 제품 코드보다 먼저 작성하면서, 풀고자 하는 문제를 내가 올바로 이해했는지에 대해 확인할 수 있었죠. 테스트 코드를 작성하려면 우선 문제를 잘 이해해야 할 뿐 아니라 문제의 세부적인 사항도 고려해야 하기 때문에, 풀고자 하는 문제에 대해 내가 무엇을 모르는지, 기획에서 어떤 부분이 빠져 있는지 알 수 있었어요.

또한 큰 문제를 잘게 쪼개고 점진적으로 해결하는 과정에서 큰 문제를 해결하는 일에 대한 두려움이 해소되었습니다. TDD 방법론을 따르면서 코드 작성을 시작할 때에는 가능한 간단한 테스트 케이스와 제품 코드부터 시작하게 되니까요. 그래서 기능 전체를 한 번에 짜려고 하는 것보다 부담감이 훨씬 덜 들고, 더 도전적으로 문제를 해결하는 태도를 가질 수 있었습니다.

🙌
연애의 과학 프로덕트팀에서는 백엔드 엔지니어를 채용중이에요 프로덕트와 서버, DevOps를 사랑하고 능력있는 Pro-duck들이 모여, 연애의 과학을 만들고 있어요. 지금 연애의 과학팀에 합류하세요! → 채용공고 바로가기