1500개 테스트를 작성하며 나는 무엇을 얻었나

최근 룩핀 서버팀과 제 개발자 커리어 모두에 있어 매우 의미있는 일이 있었습니다. 지난 2016년, 룩핀 서버의 저장소를 개설하며 첫번째 테스트 코드를 작성한 이후 시간이 흘러 어느새 1500개의 assert(테스트 검사)문이 쌓이는 큰 마일스톤을 달성했다는 것입니다.

일이 바빴음에도 테스트를 놓치지않아 다양한 부분에서 코드의 안정을 확보한 것도 다행이지만 우리 팀이 중간중간 어렵더라도 테스트 작성 철학을 놓치지 않고 지금까지 잘 유지해왔다는 것이 더욱 뜻 깊게 느껴집니다. 철학을 공감하고 함께 만들어간 실력있는 우리 팀원들 덕분입니다.

테스트를 현업에 적극 도입하며 저는 어떤걸 얻었을까요? 이번 글에서는 제가 느꼈던 그간의 성과와 교훈을 기록으로 남겨보고자 합니다.

테스트를 도입해야하는 이유

테스트의 장점에 대해 부정하는 개발자는 많지 않을 것입니다. 심지어 테스트 작성 경험이 없는 개발자도 그렇습니다. 테스트를 도입하면 무엇이 좋을까요? 단연 첫째는 예상치 못 한 버그 발생 확률을 줄여주는 데 있습니다.

코드는 기능이 추가될 수록 높은 확률로 결합이 발생합니다. 이 문제를 적절히 관리하지 않는다면 잘 되던 기능이 뜬금없이 안 되는 현상이 발생하기도 합니다. 이럴 때 테스트 코드를 적절한 위치에 배치하기만 해도 신 기능과 구 기능 모두의 안정을 ‘개발 과정 중’ 보장할 수 있습니다.

여기서 가장 중요한 포인트는 바로 따옴표 안에 있는 ‘개발 과정 중’ 에 이러한 잠재적 오류를 교정할 수 있다는 것입니다. 배포 후에 뒤늦게 오류를 감지한다는 것은 그 전과 비교해 수반해야할 비용의 큰 차이가 있습니다.

실제 서비스 도중 장애 발생 시 쏟아지는 팀원들의 온갖 애정어린(..) 말들을 들어보신 분들이라면 이런 사실이 얼마나 소중한지 굳이 설명하지 않아도 아실 것입니다.

두 번째로 코드 전반의 큰 변화가 필요한 신 기능 개발이나 기술 부채 상환에도 비교적 자신있게 대응할 수 있다는 것입니다.

업력이 오래되고 비즈니스가 커질수록 필연적으로 큰 폭의 변화가 필요한 일들이 발생하게 됩니다. 특히 High Availablity 가 매우 중요한 서비스일수록 구조 변경시 일부 기능의 중단 사태는 그 대가가 너무 가혹하며 저는 개인적으로 이 문제는 테스트 코드를 작성하는 것 외엔 별다른 해결책이 떠오르지 않는 것 같습니다. (타이트한 QA가 대안일 수 있으나 분명히 한계가 있습니다)

저는 테스트 덕분에 많은 부분에서 수월한 업무 처리를 경험할 수 있었습니다. 높은 수준의 테스트 커버리지는 아니었을지라도 테스트 커버가 되는 코드와 그렇지 않은 코드의 종종 발생하는 버그 발생률은 극과 극이었고, 작은 조직 규모와 3년 가까운 시간에 빗대보면 평균 버그 발생률도 무척 적었던 것 같습니다.

비즈니스적으로 중요하지 않은 일이지만 퍼포먼스나 기술 부채 상환을 위한 일들을 진행할 때도 큰 도움이 되었습니다. 이미 잘 동작하고 있는 테이블 구조를 미래의 확장 가능성에 대비할 수 있도록 일부 마이그레이션을 진행하거나 클래스 간 독립성을 유지하기 위한 메소드 변경이 이뤄지는 코드 리팩토링도 비교적 깔끔히 해결할 수 있었습니다.

만약 테스트가 없었다면? 그 대가를 책임질 준비가 안 되있는 저는 기술 부채 상환보다는 매일 별 일이 없길 기도하며 다른 일들을 찾아봤을 것 같네요.

갑자기 천재지변이 일어나 특정 이유로 장애가 발생하더라도 항시 대응해야하는 개발자에게 테스트가 더 높은 질의 ‘저녁이 있는 삶’을 제공하는 것도 매우 큰 메리트입니다. 하루종일 회사일만 달고 지낼 수는 없는 거니깐요!

그렇다고 지금 갑자기 테스트 코드를 짜는게 맞는 걸까요?

현실에서 많이 듣는 이 질문은 더 정확히는 ‘하루하루 생존의 위협을 받으며 기능 개발에도 시간이 부족한 스타트업에 테스트 작성이 정말 가치있는 일일까?’ 가 되겠네요.

이유에 대해 후술하겠지만 지극히 제 경험에 의하면, 놀랍게도 실제 로직 코드와 테스트 코드를 함께 작성하는게 로직 코드만 작성하는 것보다 기능 구현에 걸리는 시간이 훨씬 더 빠르다는 사실입니다.

신 기능 개발이라는 회사 내 의사결정 싸이클의 기준으로 본다면 사실 테스트는 그 맥을 같이 합니다. 구현의 대상인 기획안은 공통점이 있습니다. 기능의 필요 배경기대 동작입니다. 간단한 유닛 테스트 1개에 이 모든 요소가 대응됩니다. 특정 기능의 동작을 위한 배경을 세팅하고, 기대 결과를 검사 하는 것이죠.

이 외에 쉽게 예측 가능한 에러 발생 상황이 떠오른다면 이 또한 유닛 테스트 최소 1개로 대응할 수 있습니다. 실패 시 정확한 실패 상황을 트리거하는 테스트 코드만 작성하면 되는 것이죠. 좀 더 디테일한 기획안에는 이러한 실패 상황에 대한 기획안도 담겨져 있는 경우가 많습니다. (회원가입 실패 시 어떤 항목에서 어떻게 틀렸는지 값마다 다른 뷰를 렌더하는 것도 그 일종이라고 볼 수 있습니다)

그치만 실제로 도입해보려면 막막한걸요..

TDD 라는 용어를 한 번쯤 들어보셨을 겁니다. 쉽게 설명하면 테스트 먼저 작성하고 그에 맞는 실제 로직 코드를 ‘끼워 맞춰’ 작성하는 것이죠.

‘끼워 맞춘다’ 는 느낌이 초반에 매우 어색하게 느껴질 수 있지만 테스트가 규격에 맞도록 정확하게 기대한 값을 체크하고 있다면 이 결과에 맞춰 로직 코드를 작성해도 결과가 보장됩니다. TDD를 통한 구현은 기존의 구현 방식과는 좀 달라 테스트 통과 시 상당히 짜릿한 경험까지도 들게 만드는 것 같습니다.

많은 서버 개발자 분들은 json 을 리턴하는 컨트롤러 라우팅을 구현할 때 구현 완료 후 잘 나오나 직접 api 를 호출하여 결과를 눈으로 확인한 경험이 있을 것입니다.

TDD 상황에 익숙해지면 테스트 코드를 작성하기만 해도 이런 디버깅 상황을 과감히 생략할 수 있는 확신이 생깁니다. 바로 이런 디버깅 과정의 생략이 개발 시간을 대폭 단축하는 효과를 가지게 됩니다.

테스트를 작성함으로써 안정도 얻고 개발도 빨리 끝낼 수 있다니, 이정도면 한 번 생각해볼만 하겠죠?

좋은 테스트 코드 짜기

좋은 테스트 코드라는 것이 여러 범주가 있고 개인과 팀의 특징이나 노하우가 반영된 것이다보니 교과서처럼 뚜렷히 정리된 글들이 많지는 않습니다. 하지만 이번 섹션에서는 적어도 제가 느꼈던 시행착오들을 소개해드릴까 합니다.

테스트 상황이 아닌 동작을 기준으로 분리

테스트를 작성할 때 상황을 먼저 설정하고 그 다음 검사문을 작성하다보니 나도 모르게 놓치기 쉬운 부분이라고 생각합니다.

둘을 분리한다는 것은 큰 차이가 있습니다. 상황은 특정 상황의 여러 동작 집합이기 때문에 상황을 기준으로 본다면 검사 대상이 훨씬 많아질 수 있기 때문입니다.

이 경우, 쓸데없이 라인 수가 길어지고 중복되는 테스트가 발생합니다. 이런 상태가 반복, 누적되면 테스트 코드 읽기나 리팩토링에도 큰 걸림돌이 될 수 있습니다.

예를 들어 결제완료 시 적립금을 만들어주는 테스트를 가정해보겠습니다. 결제 완료 라는 상황에 들어갈 수많은 동작이 있을 수 있습니다. 적립금 뿐만 아니라 푸시 발송, 장바구니 내역 삭제, 재고 차감 등 무궁무진합니다.

이 경우, 결제 완료라는 상황을 중복하여 만들더라도 테스트 대상은 모두 분리되어 관리되는 것이 좋습니다. 그리고 그런 습관이 테스트 코드를 기반으로 중복을 줄이고 결합을 느슨하게 만드는 ‘testable code’ 구현을 용이하게 합니다.

검사문은 최대한 상세하게

검사문을 어느 정도까지 상세하게 작성하느냐 하는 것은 사실 넓은 의미로 보자면 주어진 시간에 따른 개발자의 타협 정도에 따라 달렸다고 볼 수 있습니다. 너무 바쁠때는.. 어쩌면 메소드 실행만 해봤다라는 테스트만 작성해도 실행 가능성에 대해선 보장이 된다고 할 수 있겠죠.

예를 들면 특정 Hash 을 리턴하는 메소드를 위한 유닛 테스트를 작성한다고 가정해봅시다. (ruby 기준으로 작성되었습니다)

assert_instance_of Hash, my_object.my_method

먼저 해당 메소드의 리턴 타입이 Hash 타입인지 체크하는 테스트를 작성했습니다. 시작은 나쁘지 않습니다. 가뜩이나 바쁜데 이런 테스트를 만들었다는게 뭐 별건가요.

놀랍게도 이 테스트를 작성한 것 만으로도 메소드에 대한 많은 부분을 보장할 수 있게 되었습니다. 하지만 치명적인 단점이 있으니, Hash의 내용물 까지는 보장하지 못 하고 있습니다.

assert_equal 'bar', my_object.my_method['foo']

foo 키의 값이 ‘bar’ 가 나오는지 검사하는 특정 키-값 테스트를 작성했습니다. 이 테스트가 의미있는 것은 Hash의 키-값을 테스트하기도 하면서 위의 Hash 타입 테스트를 일부 포괄하고 있다는 점입니다.

하지만 키가 여러개로 늘어난다면 그만큼 assert 문을 계속 추가해야할까요?

expected = {'foo' => 'bar'}
assert_equal expected, my_object.my_method

결과값을 그대로 넣어 테스트했습니다. 굉장히 타이트합니다.

Hash 타입이며, 현재 단계의 모든 키-값을 체크하고, 앞으로의 키들의 추가,삭제에 대한 변동 가능성까지 모두 포함되어 있습니다. 테스트 코드를 이 정도의 상세함까지 다룬다면 앞선 테스트와는 달리 개발자에게 일종의 ‘확신’이 생깁니다. 그 확신이 쌓일 때 큰 폭의 수정이 필요한 일들도 비교적 척척 해낼 수 있게 됩니다.

너무 타이트하게 느껴져서 오는 거부감이었을까요. 실제로 저는 2번처럼 여러개의 키-값 검사문을 작성하곤 했습니다. 주로 Hash 내용물이 작을 때 그런 판단을 하게 되는 것 같습니다. 하지만 Hash 는 시간이 지나며 점점 커지기 마련이었고, 새 키가 추가될 때 기존의 테스트는 성공합니다. 새 키의 대한 테스트는 존재하지 않았는데도요. 그렇게 결국엔 상세한 검사문 작성으로 다시 회귀하게 되더라구요.

그 외에 이런 상세한 테스트를 작성할 필요가 있는 경우는 많습니다.

  • Array 타입에서 값 뿐만 아니라 특정 순서까지도 보장해야 하는 경우
  • 실수형을 체크할때 소수점 몇 자리에서 반올림을 할 것이냐 등

이 외에..

  • 서드파티 라이브러리나 외부 API가 결합된 테스트 작성 시 외부 결과에 대한 적절한 목업 객체 생성을 통해 테스트 대상 영역을 명확히 구분하기
  • 테스트 실패 시 실패 스펙에서 바로 디버깅이 가능하도록 디버깅 툴 실행 타이밍 조절하기
  • 서버 테스트의 경우 컨트롤러, 모델, 모듈의 역할 및 테스트 검사 범위 명확히 구분하기
  • 중복 상황 재생성을 줄이기 위해 상황별로 1차 묶어 1차 공통 setup, 테스트별로 파생하여 2차 묶어 2차 setup 을 진행하는 식으로 테스트 구성하기
  • 기본 목업 속성 원칙은 random-generated 이나 특별히 다뤄야하는 edge 케이스에 대해서는 따로 상황을 만들어 별도의 테스트 작성하기
  • 테스트 기반의 커버리지 페이지, api docs, api example 등 뽑아낼 수 있는 정보들은 모두 뽑아내기
  • CI 툴과 GitHub 연동 후 테스트 통과해야만 merge 가 가능하도록 룰셋팅

등등 좋은 테스트 환경을 위한 여러 시행착오가 많았던 것 같습니다.

마치며

이처럼 테스트 도입 후 느낀 생각과 좋은 구현 방법에 대해 짧게나마 정리해봤습니다.

서비스가 3년차에 접어들었지만 팀 차원에서 여전히 좋은 테스트에 대한 고민과 시행착오는 계속되고 있고 관련하여 부족한 부분도 많습니다. 하지만 분명한 것은, 테스트 덕분에 오늘보다 훨씬 더 나은 내일이 기다리고 있다는 사실 아닐까요.

테스트를 고민하는 분들께 이 글이 도움이 좀 됬을까요? 제가 처음 테스트를 도입할 당시 이런 문서가 있었으면 적어도 저는 매우 많은 시행착오를 줄일 수 있었을 것 같습니다.


룩핀 개발팀에서 유능한 개발자분들을 채용중에 있습니다. recruit@lookpin.co.kr 로 부담없이 이력서 보내주세요!