포스트

SLASH 21 리뷰 - 테스트 커버리지 100%: 두 달의 비용, 1600개 테스트를 6초로, 그리고 100%여도 남는 버그

  
발표SLASH 21
연사이응준 (토스뱅크 Server Developer)
자료발표 영상 · SLASH 21

“클린 코더”에서 테스트 커버리지 100%를 강력히 요구한다는 문장을 읽고 “말도 안 된다”고 생각한 발표자가, 그냥 넘어가는 대신 불가능하다면 왜 그런지 입증해 보려고 실제로 해본 기록이다. 결과는 가능했고, 1년 6개월간 유지했다. 발표는 그 과정의 비용, 얻은 것, 테스트가 느려질 때 프로파일링으로 해결한 방법, 100%여도 남는 버그를 줄이려는 시도까지 이어진다. 내용은 발표 영상과 자동 생성 자막을 근거로 했고, 표현은 내 말로 바꿨다.

100%까지

대상은 토스 홈을 새로 만드는 프로젝트의 서버였다. 다른 서버들로부터 유저의 금융 정보를 수집해 보여주는 역할이고 Spring WebFlux + Kotlin으로 개발했다. 커버리지 측정을 시작한 시점에 약 50%, 프로덕션 코드 4,000라인에 테스트 코드 2,000라인이었다. 시간이 날 때마다 테스트를 썼고, 84%에 도달한 시점부터는 커버리지가 낮아지면 배포가 안 되게 했다. 2개월 동안 6,000라인의 테스트를 작성해 100%(instruction 기준)를 달성했다. 커밋 기준으로 보면 약 23%가 테스트 작성이었으니 그 정도의 시간을 썼을 것이다.

2019년 2월부터 2020년 7월까지 1년 6개월간 100%가 유지됐다. 떨어지면 빌드가 안 되게 설정돼 있으니 당연하기도 하다.

얻은 것

  • 배포에 대한 두려움이 거의 사라졌다. 테스트되지 않은 코드는 산출물에 없다는 확신이 있어서다. 테스트가 부족하던 시절에는 배포 뒤 어디선가 문제가 났다는 얘기만 들리면 내 코드 때문이 아닌가 확인했는데, 그런 일이 없어졌다.
  • 과감한 리팩토링. 영향도를 예측하기 어렵다는 이유로 망설이던 대규모 리팩토링을 할 수 있게 됐다. 하루에도 몇 번씩 라이브 배포가 나가는 와중에 전체 코드의 10% 이상을 수정하는 리팩토링을 별 걱정 없이 master에 머지했다. 언제라도 부담 없이 리팩토링할 수 있으니 괜찮은 수준의 가독성이 유지됐다.
  • 실행되지 않으면서 남아 있는 코드가 빠르게 사라졌다. 그런 코드까지 테스트를 쓰느니 지우는 쪽을 택하게 된다.
  • 프로덕션 코드에 대한 이해도가 높아졌다. 어떻게 동작하는지 모르는 코드의 테스트는 쓸 수 없다.
  • 테스트 작성이 점점 쉬워졌다. 노하우가 쌓이고, 언제나 기존 코드의 모든 케이스에 테스트가 있으니 막막할 때 참조할 수 있다.

필요한 것

첫째는 믿음이다. 테스트가 필요하다는 믿음. 테스트 코드 작성은 기능 구현보다 어렵고 시간과 노력이 많이 든다. 이 고도의 정신 노동을 자발적 믿음 없이 타인의 지시로 하는 것은 실패한다. 발표자는 테스트가 없으면 리팩토링할 수 없고, 리팩토링하지 않은 코드는 이해할 수 없게 되며, 이해할 수 없는 코드는 수정할 수 없다고 확신했기에 계속할 수 있었다. 또 하나의 믿음은 어떤 코드든 테스트할 수 있다는 것이다. 이 믿음이 없으면 조금만 어려운 코드를 만나도 “테스트 불가능한 코드”로 치고 포기한다.

둘째는 시간이다. 이미 높다면 추가 시간이 적지만 낮은 상태에서 끌어올리려면 상당한 시간이 든다. 프로젝트 초기부터 높게 유지하는 것이 가장 좋다.

셋째는 의지, 그런데 의지가 없어도 된다. 빨리 끝내고 싶은 마음에 테스트 없는 코드를 배포하는 유혹은 계속된다. Gradle JaCoCo 플러그인의 coverage verification으로 커버리지가 기준 미만이면 빌드가 실패하게 하면, 100%가 아니면 배포가 안 되므로 100%가 되지 않을 수 없다.

테스트가 느려진다

가장 큰 문제였다. 100% 달성 4개월 뒤 테스트가 400개를 넘자 전체 실행이 1분을 넘겼다. 개발자가 확인할 때마다, 배포할 때마다 1분씩 기다리는 것은 생산성 저하라 시급했다.

1차: 컨텍스트 로딩 제거. 원인은 Spring 애플리케이션 컨텍스트 로딩이었고 비중이 압도적이라 다른 것은 무시해도 됐다. 대부분의 Spring 웹 테스트 프레임워크가 컨텍스트 없이 HTTP API를 테스트하는 standalone 기능을 제공하고 WebTestClient도 그렇다. 다만 main 함수 실행 시 Spring이 기동되는지 테스트할 때는 컨텍스트 로딩만 빼기 어려워, MockK의 static mocking으로 Spring을 모킹해 실제로 기동하지 않고 호출만 확인했다.

2차: 프로파일링. 1년 가까이 지나 1,600개를 넘자 다시 1분을 넘겼다. 이번에는 IntelliJ 내장 async profiler로 테스트를 프로파일링했다.

원인대응
SLF4J 초기화 (설정 파일 파싱)테스트에 불필요한 로깅 설정 제거
Handlebars 컴파일캐싱
Jackson ObjectMapper 생성싱글턴으로 대체
ByteBuddy 초기화 (더미 데이터 생성용)너무 느려 제거
Kotlin reflection 초기화 (::class 호출 등)유발하는 호출 제거
MockK (ByteBuddy + Kotlin reflection 초기화에 1초 이상, 모킹마다 mock 클래스 생성에 수십 ms)static mocking 한 곳을 빼고 모두 제거. 모킹이 필요하면 직접 객체를 구현
순차 실행으로 CPU 활용 부족JUnit 5 설정으로 클래스 단위 병렬 실행 (함수 단위는 이득이 작고 동시성 문제로 실패하는 케이스가 생김)

MockK를 쓰면 안 된다고 생각하는 것은 아니지만 얼마나 개선되는지 보기 위해 일단 최대한 제거했다. 개선 뒤 40초 미만이 됐다. 그리고 회사에 2019년형 MacBook Pro 16인치를 구매 요청해 그날 받았고 테스트가 단번에 2.5배 빨라졌다. 돈으로 해결할 수 있는 것은 돈으로 해결하는 게 최고라고 했다. IntelliJ 기준 6초대가 됐다.

정말로 테스트하기 어려운 케이스

도전 전에는 DB, 네트워크, 프레임워크, 랜덤, 시간 의존 테스트가 어려울 거라 생각했는데 대체로 모킹으로 해결됐다. 진짜 어려운 것은 Kotlin이 생성하는 바이트코드였다. 기준이 line이 아니라 instruction 커버리지라 모든 바이트코드 명령이 커버돼야 한다.

예: nullable Person에서 non-null 프로퍼티 name을 얻어 반환하고 null이면 기본값 “아무개”를 반환하는 함수(person?.name ?: "아무개"). null인 경우와 아닌 경우 둘만 테스트하면 될 것 같지만 리포트에는 커버되지 않은 브랜치가 남는다. person.name이 null인 경우까지 테스트돼야 하기 때문이다. 바이트코드를 Java로 역컴파일하면 name이 null인지 검사하는 분기가 있다. Kotlin에서는 non-null이 보장되지만 JVM 레벨에서는 아니라서 검사 코드가 생성된다. 그런데 Kotlin에서는 non-null 값이 null일 수 없으므로 그 경우를 테스트하기가 대단히 어렵다. 결국 엘비스 연산자를 if문으로 바꿨다. 단지 커버리지를 올리기 위해 멀쩡한 코드를 고치는 것이니 자연스럽지는 못하다. 이런 케이스가 여럿 있지만 거의 다 우회책이 있고, 어떻게도 안 되면 커버리지 도구의 파일 제외 기능으로 너무 시간을 쓰지 말고 제외한다.

왜 99%가 아니라 100%인가

100% 규칙은 단순하다. 새 코드 중 커버되지 않은 것이 조금이라도 있으면 언제나 실패하고, 리포트가 빠진 곳을 알려주니 그 테스트를 쓰면 된다. 99% 규칙은 복잡하다. 새 코드가 커버되지 않아도 전체가 99%면 통과하고, 새 코드가 없어도 99% 미만이면 실패한다. 100줄 중 99줄이 커버된 상태에서 A가 테스트 없이 한 줄을 더해도 99%로 통과하는데, B가 불필요한 코드 한 줄을 지워 99줄 중 98줄이 되면 98.9%로 실패하고, B가 A의 코드에 대한 테스트를 써야 한다. 이런 어색한 상황을 피하고 싶었다.

100%여도 버그는 있다

커버리지 100%가 버그를 굉장히 많이 줄였지만 여전히 가끔 있었다. 실수로 테스트를 잘못 쓰거나, 요구사항을 오해해 잘못된 테스트와 구현을 만들거나, 컴포넌트 간 협업이 잘못돼 버그가 생겼다. 세 빈틈에 각각 시도했다.

잘못 쓴 테스트 → 뮤테이션 테스팅. sum(0, 0) == 0, sum(1, 0) == 1 두 케이스는 커버리지 100%지만 +-로 바꿔도 통과한다. 프로덕션 코드를 무작위로 조작해도 테스트가 통과하면 테스트가 부족하다고 판단하는 기법이 뮤테이션 테스팅이고 JVM용 구현이 PIT다. PIT는 +-로 바꾸고 함수가 항상 0을 리턴하게도 바꿔서, 뮤테이션 커버리지가 50%라고 알려준다. 단점은 굉장히 느리다는 것이다. 전체 코드에 돌리니 몇 시간이 지나도 끝나지 않았고 FAQ에도 비싼 작업이며 무한 루프를 유발할 수 있다고 적혀 있다. 도입하지는 못했지만 특별히 중요한 로직에만 적용하는 것은 검토 중이라고 했다.

요구사항 오해 → 테스트에서 스펙 문서 생성. 가장 확실한 해결책은 요구사항 작성자가 직접 인수 테스트를 쓰는 것이지만 보통 개발자가 아니다. 반대로 개발자의 테스트가 스펙 문서가 되게 해서 작성자에게 리뷰받는 시도를 했다. BDD 도구 Cucumber는 생성 문서가 읽기 좋지 않고 커스터마이징도 어려웠으며 무엇보다 모든 테스트를 JUnit 5로 쓰고 싶었다. 그래서 JUnit 5의 TestExecutionListener로 테스트 이름을 수집해 Markdown 스펙 문서를 만드는 도구를 직접 만들었다. “1, 3, 5, 7, 8, 10, 12월은 31일까지 있다” 같은 테스트 이름을 읽어 문서를 만들고 부가 설명과 자동 표 기능을 넣어 보기 좋게 했다. 작성자 리뷰로 오류를 발견한 성과도 있었지만 지속하지는 못했다. 보기 좋은 문서를 만들려면 테스트를 쓸 때마다 상당한 노력이 들어야 하는데 항상 그렇게 하지 못했고, 품질이 일정하지 않으니 결국 읽지 않는 문서가 됐다. 적당한 노력으로 괜찮은 문서가 나오는 방법은 아직 고민 중이다.

컴포넌트 간 협업 실패 → Consumer-Driven Contract. 다른 서버가 "2"를 보냈는데 내 서버가 "11"로 받으면 버그다. 컨슈머가 요구사항을 machine-readable하게 기술하고 프로바이더가 그것을 만족하는지 테스트하는 기법이다. Spring Cloud Contract와 Pact를 조사했고 토스뱅크는 Pact를 택했다. Pact에는 컴포넌트 간 계약을 관리하는 Pact Broker가 있고 Spring Cloud Contract에는 해당하는 것이 없었다.

결론

테스트 커버리지는 얼마든지 높일 수 있고 비용은 감당 가능한 수준이다. 습관이 들지 않는다면 인간의 의지를 믿지 말고 커버리지가 낮으면 빌드가 실패하게 하라. 부작용은 적고 효과는 확실하다. 테스트는 빨라야 하고 느리면 프로파일링으로 원인을 찾아 고쳐야 한다. 100%라도 버그는 나므로 개선을 위한 연구와 실행이 필요하다. 그리고 100%는 생각보다 할 만하다.

리뷰

“의지를 믿지 말고 빌드를 실패시켜라”가 발표의 핵심 조언이다. 100% 대 99%의 논증도 결국 규칙이 단순해야 도구가 강제할 수 있다는 이야기다. 개인의 실천기처럼 보이지만 실제 내용은 “테스트 문화를 사람이 아니라 파이프라인에 위임하는 법”이다.

테스트 성능 프로파일링 표가 이 발표에서 가장 재사용 가치가 높다. MockK의 초기화 비용, ByteBuddy, Kotlin reflection, ObjectMapper 생성 같은 항목은 Kotlin + Spring 프로젝트에서 그대로 확인해 볼 수 있다. 특히 “모킹 프레임워크를 제거하고 직접 객체를 구현했다”는 부분은 테스트 더블을 어디까지 프레임워크에 맡길지에 대한 답을 하나 준다.

실패한 시도를 그대로 발표한 점이 좋다. PIT는 너무 느려 도입하지 못했고, 스펙 문서 생성기는 품질이 일정하지 않아 읽히지 않는 문서가 됐다. 성공담만 있었다면 “100%는 할 만하다”는 결론이 덜 믿겼을 것이다. 4년 뒤 토스 메이커스 컨퍼런스에서 나온 Spring 구동 시간 줄이기가 이 발표의 프로파일링 부분과 이어 읽힌다.

남는 질문

  • 커버리지 100% 강제를 팀 전체에 확산했는지, 개인 프로젝트 수준에서 끝났는지. 다른 개발자가 합류했을 때 반발은 없었는지.
  • 엘비스 연산자를 if로 바꾸는 식의 우회가 코드 전반에 얼마나 퍼졌는지. instruction 대신 line 커버리지를 기준으로 삼았다면 이 문제를 피하면서 얻는 것을 거의 다 얻지 않았을지.
  • Pact 도입 이후 컨슈머-프로바이더 계약이 실제로 배포 파이프라인을 막은 사례.
  • 테스트 이름에서 스펙을 생성하는 시도는 최근의 LLM 기반 문서화로 다시 해볼 만하다. 발표자가 이후에 다시 시도했는지.

참고

  1. 1 SLASH 21 리뷰 - SRE 사례 소개: Redis 리밸런싱 ASK 에러, Memcached 재분배 실패, Prometheus가 바꾼 GC 패턴
  2. 2 SLASH 21 리뷰 - 결제 시스템의 SDK와 API 디자인: 4단계를 2단계로, DELETE·PUT을 버린 이유, 한글 enum
  3. 3 SLASH 21 리뷰 - MySQL HA & DR Topology: MMM, 대칭 DR 구성, 바이너리 로그 필터, super_read_only 안전장치
  4. 4 SLASH 21 리뷰 - 테스트 커버리지 100%: 두 달의 비용, 1600개 테스트를 6초로, 그리고 100%여도 남는 버그
  5. 5 SLASH 21 리뷰 - 토스 서비스를 구성하는 서버 기술: 두 데이터센터 사이의 트래픽 이동, Istio 도입 후 남은 것, Kafka 두 클러스터
  6. 6 SLASH 22 리뷰 - Java Native Memory Leak 원인을 찾아서: RSS와 NMT의 2GB 차이, jemalloc 프로파일, C2 컴파일러, Graal JIT
  7. 7 SLASH 22 리뷰 - 토스뱅크의 완전히 새로운 대출 시스템: Flyway + Hibernate validate, 대외기관 파이프라인, 연동 서킷과 대기열
  8. 8 SLASH 22 리뷰 - 지속 성장 가능한 코드를 만들어가는 방법: import 문이 보내는 신호로 패키지·레이어·모듈을 다시 보기
  9. 9 SLASH 22 리뷰 - 왜 은행은 무한스크롤이 안되나요: 채널계가 거래내역을 직접 갖기 위한 여덟 가지 방어
  10. 10 SLASH 23 리뷰 - 은행 최초 코어뱅킹 MSA 전환기 (feat. 지금 이자 받기): 80회 DML을 50회로, MCI 대비 170배, 빅뱅 없는 전환
  11. 11 SLASH 23 리뷰 - 토스뱅크의 모던 FEP: 전문을 Kotlin 어노테이션으로, 리플렉션 코덱, Netty 논블로킹, Redis 폴링으로 액티브-액티브
  12. 12 SLASH 23 리뷰 - 분산 추적 체계 & 로그 중심으로 Observability 확보하기: 좋은 로그의 조건, 글로벌 trace ID, TCP 전문에 문맥 심기, 헤더 라우팅 디버깅 환경
  13. 13 SLASH 23 리뷰 - 토스는 Gateway 이렇게 씁니다: 목적별 게이트웨이, 패스포트, 요청 서명 검증, YAML 라우트와 게이트웨이 봇
  14. 14 SLASH 23 리뷰 - 연결되면 비로소 보이는 것들: Pinpoint 코루틴 플러그인을 만들기 위해 코루틴 바이트코드를 읽다
  15. 15 SLASH 23 리뷰 - 프로파일러로 시스템 성능 향상시키기: Pinpoint, 힙 덤프, jemalloc, async-profiler, strace, 그리고 커널 버전
  16. 16 SLASH 24 리뷰 - Next 코어뱅킹, MSA와 MySQL로 여는 평생 무료 환전 시대: Oracle을 버린 이유, 30ms 환전, 자정에도 멈추지 않는 잔액 대사
  17. 17 SLASH 24 리뷰 - 대규모 사용자 기반의 마이데이터 서비스 안정적으로 운영하기: 클러스터 단위 서킷 코디네이터, 웹소켓 얼리 리턴, 7일 배치 분산
  18. 18 SLASH 24 리뷰 - 토스뱅크가 차세대를 하지 않는 이유, 지속 가능한 마이그레이션 전략: 스트랭글러 피그, 6단계 사이클, 컴포지트 분할 정복, 병렬 실행 비교 검증
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다