Trawling

단위 테스트의 주된 목적은 다른 사람들이 내 코드를 마구 망가뜨리지 못하게 하는 것입니다

이 글은 단위 테스트가 복잡한 버그를 찾아내는 도구라기보다, 끊임없이 코드가 바뀌는 조직에서 기존 동작과 설계를 보존하는 안전장치라고 주장합니다. 통합 테스트와 무작위 테스트의 한계를 비교하고, 2010년대에 CI와 DVCS가 확산되면서 단위 테스트가 대중화된 배경과 실용적인 무작위 테스트 방법을 설명합니다.

본문

하드웨어를 많이 다뤄 온 글쓴이는 소프트웨어 테스트의 관행이 하드웨어 엔지니어의 관점에서는 이상하게 보인다고 말합니다. 하드웨어에서는 버그가 치명적인 결과로 이어지는 경우가 많기 때문에, 시간이 걸리더라도 문제를 고친 뒤 출시해야 한다는 압력이 큽니다. 반면 소프트웨어에서는 “나중에 고치면 된다”는 생각이 널리 퍼져 있고, 그 결과 프로그램은 언제나 나중에 고쳐야 할 버그를 품은 채 배포됩니다. 글쓴이는 의사결정권자가 버그의 비용을 실제보다 낮게 보는 경우도 있고, 그렇지 않더라도 하드웨어처럼 눈에 보이는 폐기물 더미나 규제 요건이 없으면 예방 비용을 설득하기 어렵다고 봅니다.

단위 테스트는 버그를 얼마나 잘 찾습니까

글쓴이는 단위 테스트(Unit Test)가 중요하지 않다고 말하는 것이 아니라, 단위 테스트의 주된 목적을 일반적으로 설명하는 방식에 의문을 제기합니다. 단위 테스트는 복잡한 버그를 찾아내는 데는 그다지 강하지 않습니다. 첫째, 어려운 버그와 성능 문제의 상당수는 여러 컴포넌트가 상호작용하는 과정에서 발생하기 때문에, 여러 팀의 컴포넌트를 함께 실행하는 통합 테스트(Integration Test)가 필요합니다. 하지만 통합 테스트를 실제로 운영하는 조직은 드뭅니다. 많은 팀은 자신의 “단위” 바깥을 건드리지 않기 위해 의존성을 모킹(mocking)하는 데 큰 노력을 기울입니다. 모킹을 쉽게 하려고 추상 클래스를 도입하거나, 테스트마다 API 스텁(stub)을 만들어 두는 식입니다. 이렇게 하면 호출부뿐 아니라 각 테스트의 스텁까지 함께 수정해야 하므로 API 변경이 더 어려워집니다.

둘째, 까다로운 버그를 찾으려면 다양한 입력 조합이 필요합니다. 그러나 일반적인 단위 테스트는 소수의 입력과 그에 대응하는 출력을 코드에 하드코딩합니다. 하드웨어 분야에서는 테스트 대상(Design Under Test)에 무작위 입력을 생성하는 방식이 흔합니다. 이 방식은 어떤 입력 분포를 사용할지 고민하게 만들고, 대부분의 입력이 코드에서 빠르게 거부되는 쓸모없는 값이 되지 않도록 합니다. 또한 프로그램이 낸 결과가 정말 올바른지 판정하는 방법도 필요합니다. 단순히 현재 실행 결과를 정답으로 하드코딩하는 방식과는 다른 접근입니다.

“영역 표시”로서의 테스트

그렇다면 단위 테스트가 통합 테스트나 입력 무작위화보다 버그 발견에 덜 효과적인데도 왜 업계 전반에 퍼졌을까요. 글쓴이의 답은 단위 테스트의 핵심 목적이 버그를 박멸하는 데 있지 않고, 모든 것이 통제되지 않은 채 바뀌는 환경에서 다른 사람들이 자신의 코드를 함부로 망가뜨리지 못하게 하는 데 있다는 것입니다. 글의 도발적인 표현을 빌리면, 테스트는 코드의 “영역 표시(territory marking)”입니다.

소프트웨어 조직에서는 모든 것을 계속 바꾸라는 요구가 나오고, 코드 소유권이 없는 “공유 소유권(shared ownership)” 문화가 생깁니다. 이때 무엇인가를 “망가뜨렸다”는 것은 자연법칙이 아니라 사회적 관계에 의해 결정됩니다. 변경한 사람이 나중에 고치도록 요구받을 때 비로소 망가뜨린 것이 됩니다. 단위 테스트는 이 상황에서 몇 가지 실용적인 방어막을 제공합니다. 작성하기 쉽고, 실행 비용이 낮으며, 빠르게 끝나기 때문에 커밋을 막는 게이트로 넣어도 큰 반발이 적습니다. 조직은 비교적 적은 비용으로 품질에 대응하고 있다는 표시를 얻을 수 있고, 개발자는 테스트가 깨지는 한 다른 사람의 동작을 일방적으로 제거하거나 바꾸기 어렵게 만들 수 있습니다.

테스트를 삭제하거나 작성자의 동의 없이 잔인하게 바꿔 통과시키는 일은 사회적으로도 부자연스럽습니다. 따라서 변경자는 테스트를 계속 통과시키거나 원 작성자와 대화해야 합니다. 글쓴이는 개발자들이 단위 테스트를 적극적으로 지지하는 이유도 여기에 있다고 봅니다. 테스트가 복잡한 버그를 찾아내기 때문이 아니라, 자신이 추가한 동작이 다른 사람이 코드를 수정한 뒤에도 남아 있을 것이라고 기대할 수 있기 때문입니다. 이 구조는 관리자와 개발자 모두에게 받아들여질 수 있고, 아무 테스트 없이 빠르게 바꾼 뒤 서로의 변경을 되돌리느라 시간을 쓰는 것보다 실제 변경 속도를 높여 줄 수 있습니다.

테스트가 코드베이스를 두 배로 만드는 이유

글쓴이는 단위 테스트가 평균적으로 코드베이스 규모를 대략 두 배로 만든다는 관찰도 이 관점에서 설명합니다. 테스트는 코드를 한 번 더 작성하는 일과 비슷합니다. 첫 번째 작성본은 공유 소유권 아래 누구나 손댈 수 있지만, 두 번째 작성본인 테스트는 함부로 제거하거나 바꾸기 어렵습니다. 테스트는 “이 코드가 여전히 존재하고, 원래 의도와 비슷한 일을 한다”는 내용을 반복해서 적어 두는 영역 표시가 됩니다.

버그를 찾기 위한 테스트라면 테스트 코드가 실제 코드보다 훨씬 길 수도 있고 짧을 수도 있습니다. 반면 영역을 표시하기 위한 테스트의 양은 표시하려는 코드 영역의 크기에 비례하는 경향이 있습니다. 이 때문에 테스트가 코드의 의미를 깊이 검증하지 않더라도, 조직 안에서 코드의 구조와 동작을 보존하는 역할을 할 수 있다고 설명합니다.

왜 2010년대에 확산됐습니까

글쓴이는 단위 테스트가 2010년대에 폭발적으로 퍼진 이유를 분산 버전 관리 시스템(DVCS)의 대중화와 연결합니다. DVCS는 코드베이스 전체를 쉽게 브랜치하고 병합할 수 있게 했습니다. 이런 변경이 반복되는데 지속적 통합(Continuous Integration, CI)과 단위 테스트가 없다면 코드가 어떤 상태가 될지 생각해 보라는 것입니다. CI와 DVCS는 비슷한 시기에 거의 보편화됐습니다. 애자일(Agile) 개발자들 사이에서는 DVCS가 널리 쓰이기 전부터 단위 테스트가 인기를 얻고 있었지만, 이후 두 흐름이 함께 급격히 확산됐다고 설명합니다. 글쓴이는 단위 테스트가 없던 Linux 커널이 DVCS의 초기 대표적 사용 사례였고, 최근에야 단위 테스트를 추가하기 시작했다는 점도 함께 언급합니다.

하드웨어 관점에서는 전담 검증팀을 두고 통합 테스트와 무작위 테스트를 운영하는 편이 자연스럽습니다. 이런 방식은 실제 버그를 많이 찾고, 방치했을 때의 비용이 검증팀을 운영하는 비용보다 큰 버그도 발견할 수 있습니다. 그러나 소프트웨어 조직은 이미 커밋한 코드에 버그를 제보하는 팀을 반기지 않을 수 있습니다. 팀은 이미 다음 작업으로 이동했기 때문입니다. 검증팀이 사용할 API를 안정적으로 유지해야 한다는 부담도 생깁니다. 모든 것을 계속 바꾸려는 조직에서 API 안정성을 요구하는 일 자체가 속도를 늦추는 것으로 인식됩니다.

글쓴이는 여기서 소프트웨어 품질의 일반 원칙을 도출합니다. 품질 개선은 프로그램이 바뀌는 속도를 높여 줄 때에만 가치 있는 것으로 받아들여진다는 것입니다. 품질을 높이지만 이른바 “velocity”를 낮추는 활동은 조직에서 쉽게 거부됩니다. 하드웨어에서는 버그를 없애는 일이 중요하지만, 소프트웨어에서는 변경 속도를 유지하거나 높이는 방식의 품질 개선만 널리 채택됩니다. 단위 테스트는 이 조건을 만족하는 드문 방법입니다.

무작위 테스트를 실용적으로 운영하는 방법

글쓴이는 까다로운 코드를 제대로 만들고 싶다면 무작위 테스트(randomizer)를 권장합니다. 반드시 여러 머신에서 24시간 내내 실행할 필요는 없고, 감당할 수 있는 만큼 수만 번 정도 실행해도 됩니다. 다만 입력이 실제로 무작위라면 CI 결과가 비결정적이 되어, 실패할 때까지 재실행하는 문제가 생길 수 있으므로 결정론적 시드(deterministic seed)를 사용해야 합니다. 그러면 특정 입력 집합을 CI에 넣어 코드가 다시 망가지는 것을 막을 수 있습니다. 글쓴이는 ThreadSanitizer(TSan)를 CI에서 실행할 때 생기는 문제와 관련해, 결정론적 스레드 스케줄러를 만들면 도움이 되지 않겠느냐는 의문도 제시합니다.

이 글쓴이가 사용하는 무작위 테스트의 범위는 내부 함수가 아니라 수천에서 수만 줄 규모의 “모듈”입니다. 모듈 수준에서는 자주 바뀌는 내부 구현보다 안정적인 API를 대상으로 할 수 있기 때문입니다. 무작위 테스트 작성에는 일주일 또는 몇 주가 걸릴 수 있지만, 일단 완성하면 테스트 대상 코드에 대해 하드웨어에 가까운 신뢰성을 얻을 수 있다고 설명합니다. 드물게 재현되는 버그를 찾고 싶다면 일정 기간 24시간 내내 실행할 수도 있습니다. 다만 조직이 원하지 않는다면 진지한 통합 테스트는 개인 한 명이 추진하기 어렵고, 무작위 테스트는 조직 전체의 지원이 없어도 개인이 도입해 효과를 얻을 수 있다고 말합니다. 글쓴이는 이 방법을 향후 “DDT(Development-Driven Testing)”라는 글에서 더 설명할 가능성도 언급합니다.

에이전트가 작성하는 테스트

후반부에서는 오늘날의 코딩 에이전트(Agent)가 인간 개발자의 관행을 학습한 결과, 테스트 코드로 코드베이스를 대략 두 배로 만드는 경향을 보인다고 말합니다. 에이전트가 작성하는 테스트가 다소 어리석더라도 현재로서는 없는 것보다는 낫다고 봅니다. 에이전트는 코드에 대해 아무것도 모르는 채 투입된, 빠르고 영리하지만 새로 온 개발자와 비슷합니다. 그런 존재가 대부분의 변경을 수행하면 기능과 구조가 빠르게 사라질 수 있습니다. 에이전트는 테스트 실패를 존중하도록 만들어져 있는 경우가 많으므로, 파괴적인 변경을 막는 가드레일이 됩니다.

다만 테스트는 불필요하게 복잡한 구조가 추가되는 것까지 막아 주지는 않습니다. 오히려 그런 구조가 테스트로 보호되어 이후 리팩터링이 어려워질 수 있습니다. 글쓴이는 인간 개발자도 같은 일을 하지만, 에이전트는 목표가 좋은 방향이든 최악의 방향이든 그 지점에 더 빨리 도달하게 만든다고 마무리합니다.

Reddit 반응

  • @u/tanin47 — 저에게 테스트는 수동 테스트조차 대체하지 않습니다. 주된 목적은 미래의 회귀를 막는 것입니다. 사람들이 서로의 코드를 짓밟는다고는 생각하지 않습니다. 품질 높은 코드 변경도 다른 사람의 코드베이스에서 일부 사례를 놓칠 수 있습니다.
  • @u/Zenneth014 — 아, 그렇군요. 우리가 좋아하는 동료들이 보이는, 이상하고 공격적이며 반사회적인 우월감의 전형입니다. 테스트를 “코드의 의도를 더 잘 설명하는 것”이라고 표현할 수도 있었는데, 친절한 글쓴이는 이렇게 표현하기로 했습니다.
  • @u/pickle9977 — 단위 테스트의 핵심은 책임 회피(cya)입니다. 아무도 새로운 엉터리 기능을 프로덕션에 밀어 넣기 전에 자신이 무언가를 망가뜨리지 않았는지 확인하는 데 시간을 쓰고 싶어 하지 않습니다. 이렇게 하면 우리 모두 “누가 알았겠습니까, 그 문제에 대한 테스트를 작성하겠습니다”라고 말할 수 있습니다. 그러다 어느 날 일어나 보면 우리가 만든 무한한 버그를 테스트하느라 CI 빌드가 무한히 걸립니다.
  • @u/teerre — 그렇게 단순하지는 않다고 생각합니다. 테스트는 미래의 잘못된 변경을 막아야 합니다. 특히 LLM이 작성한 테스트의 문제는 무관한 구현 세부사항을 테스트해서 올바른 변경을 더 어렵게 만든다는 점입니다. 저는 최근 LLM에게 코드를 작성해 달라고 할 때 테스트를 비활성화하기 시작했습니다. 테스트가 무엇보다 소음에 가깝기 때문입니다. 누군가 제 my.irrelevant.module을 테스트하지 않았다는 사실은 실제로 좋은 일입니다. 그 모듈이 중요하지 않다는 뜻이기 때문입니다. 그 판단이 틀렸을 수도 있지만, 똑같이 엉터리인 테스트 5,000개가 있는 코드베이스에서 무엇이 중요한지 찾는 것보다는 훨씬 쉽게 발견할 수 있습니다.
  • @u/erebuxy — 아닙니다. 핵심은 그 엉망인 것을 단단하게 만드는 것입니다. 그러려면 모든 버그를 문서화하고 기능으로 정의해야 합니다.

원문: Yosef K. / 번역·요약: Trawling

참고 자료

오류 제보