대규모 개발의 테스트 — 수백만 건의 테스트와, 그것을 지탱하는 설계
수만 명이 단일 모노레포에 하루 수만 건 커밋하고, 수백만 건의 테스트가 도는 규모가 되면 테스트의 문제는 질적으로 바뀐다. hermetic성・테스트 크기 분류・Bazel 계열의 캐시・test impact analysis라는 실행 기반의 이야기와, DI・헥사고날 아키텍처・Humble Object라는 테스트하기 쉬운 설계의 이야기는, 사실 같은 질문에 답하고 있다.
수십 명의 팀이 수백 건의 테스트를 돌리는 것과, 수만 명의 엔지니어가 단일한 거대 코드베이스 (모노레포, monorepo) 에 하루 수만 건의 커밋을 하며 수백만 건의 테스트를 실행하는 것은, 테스트를 둘러싼 문제의 질이 다르다. Google 이나 Meta 같은 조직은 빌드 시스템・실행 기반・플레이키 관리를 전문화함으로써 이 규모의 테스트를 계속 돌려왔다. 그리고 이 규모를 지탱하는 것은 실행 기반만이 아니다 —— 테스트하기 쉬운 설계라는, 언뜻 별개의 주제로 보이는 실천이, 근저에서는 같은 질문에 답하고 있다.
모노레포라는 전제 — Google 과 Meta 의 거대 테스트 스위트 문화
Google 과 Meta 는 사업의 거의 전체를 단일 모노레포로 관리하는 운영으로 알려져 있다. Google 의 사내 빌드 시스템 Blaze (2004 년경부터 사내 사용, 2015 년에 Bazel 로 오픈소스화) 와, Meta 의 Buck (2013 년 오픈소스화, 2023 년에 Rust 제 후계 Buck2 로 이행) 는, 모두 모노레포 전체를 단일 빌드 그래프로 다루고 테스트 타깃도 그 안에 포함하는 사상을 공유한다.
이 규모에서는 「변경할 때마다 테스트를 전부 실행한다」 는 발상 자체가 비현실적이 된다. 수백만 건을 매번 재실행하면 CI 큐는 막히고, 피드백은 몇 시간 단위로 지연된다. 모노레포 운영의 핵심은 「변경에 관련된 테스트만을, 확실히 캐시를 활용하면서 실행한다」 는 구조를 빌드 시스템에 내장하는 것에 있다.
hermetic tests — 밀폐성이 모든 것의 토대가 된다
대규모 테스트 스위트의 신뢰성을 지탱하는 핵심 개념이 hermetic (밀폐적) 한 테스트 이다. 이것은 저서 Software Engineering at Google 에서도 중심적으로 다뤄지는 원칙으로, 다음과 같이 정의된다.
테스트는, 그 설정・실행・뒷정리에 필요한 정보를 모두 자기 자신 안에 포함하고 있어야 한다.
hermetic 한 테스트는 외부의 공유 데이터베이스・실제 네트워크・실행 환경 고유의 상태에 의존하지 않는다. 디스크 접근이 필요하다면 실제 디스크가 아니라 인메모리 파일시스템을 사용하는 등의 형태로 환경에서 격리된다. hermetic 성은 이전 기사 플레이키 테스트와의 싸움 에서 다룬 플레이키의 주요 원인 (외부 의존・네트워크・상태 공유) 을 구조적으로 배제하며, 후술할 캐시를 안전하게 작동시키는 전제 조건이 된다.
테스트 크기 분류 — small / medium / large
Google 은 테스트를 「무엇을 하는 코드인가」 가 아니라 「어떻게 실행되고, 무엇이 허용되는가」 라는 관점으로 분류하는 체계를 사용한다.
| 분류 | 실행 환경의 제약 | 전형적인 실행 시간 |
|---|---|---|
| small | 단일 프로세스・단일 스레드, 네트워크・디스크 I/O 금지 | 수십 밀리초〜 |
| medium | 단일 머신 내, localhost 이외로의 네트워크 호출 금지 | 수 초 정도 |
| large | 여러 머신에 걸쳐도 됨 | 수십 초〜수 분 |
크기는 「단위 테스트인가 통합 테스트인가」 라는 축과는 독립적이며, 실행 시에 부과되는 환경적 제약만으로 정해진다. Google 이 권장하는 배분의 기준은 small 약 80%・medium 약 15%・large 약 5% 로 알려져 있으며, 이전 기사 테스트의 형태 에서 다룬 테스트 피라미드의 정신을, 모노레포의 실행 제약이라는 축으로 재정식화한 것으로 이해할 수 있다.
리모트 캐시와 test impact analysis
Bazel・Buck2 에 공통되는 핵심 기능이 리모트 캐시 이다. 빌드・테스트의 입력 (소스・의존 버전・컴파일러 플래그) 을 모두 해시화하여, 동일한 입력 해시에 이미 캐시된 결과가 있으면 재실행하지 않고 그대로 재사용한다. 이 캐시가 안전하게 작동하는 전제가 바로 앞 절의 hermetic 성이다. Buck2 는 Buck (Java 구현) 의 Rust 후계로, Bazel 과 마찬가지로 Starlark (Python 유사 설정 언어) 를 채택하고 있어, Blaze・Bazel・Buck2 는 같은 계보의 도구군으로 이야기된다.
캐시와 나란한 또 하나의 핵심 기술이 test impact analysis (테스트 영향 분석) 이다. 정적인 방법은 빌드 그래프를 따라가 변경된 파일로부터 영향을 받을 수 있는 테스트 타깃을 기계적으로 특정한다 (Bazel・Buck2 의 query 기능). 동적・통계적인 방법은 과거의 실행 이력으로부터 「코드 변경의 패턴」 과 「실제로 실패한 테스트」 의 상관관계를 머신러닝으로 학습한다. Meta 가 2018 년에 발표한 Predictive Test Selection 은 그 대표 사례로, 실행할 테스트를 좁힘으로써 테스트 실행의 인프라 비용을 절반으로 줄이면서도, 개별 테스트 실패의 95% 이상, 결함을 포함한 코드 변경의 99.9% 이상을 계속 검출할 수 있다고 보고되었다. 같은 연구는 테스트의 플레이키함도 모델에 반영하고 있다는 점에서도 참조된다.
분산 실행과 빌드 그래프
수백만 건 규모의 테스트는 단일 머신의 순차 실행으로는 도저히 시간 안에 끝나지 않는다. Bazel・Buck2 는 의존 관계가 없는 테스트 타깃끼리는 병렬로 실행해도 된다는 것을 빌드 그래프로부터 기계적으로 보장하고, 다수의 워커 머신에 리모트 실행 으로 분산시킨다. 「변경에 영향을 미치는 테스트만을, 다수의 머신에 분산하여 병렬 실행한다」 는 운영은, 캐시・test impact analysis・분산 실행이 같은 빌드 그래프라는 추상을 공유하고 있기 때문에 성립한다. 이 병렬화 또한 hermetic 성 (테스트끼리 공유 상태를 갖지 않는 것) 을 전제로 한다.
규모가 늘리는 플레이키의 절대수
규모가 커질수록, 이전 기사에서 다룬 플레이키 테스트의 절대수도 늘어난다. Google 이 2016 년에 공개한 조사 (John Micco 등) 에 따르면, 사내 테스트의 약 16% 가 어떤 수준에서든 플레이키한 거동을 보인 반면, 항상 플레이키한 (거의 매번 결과가 흔들리는) 테스트는 약 1.5% 정도였다고 한다. 이것은 개별 팀의 태만이 아니라 규모에 수반되는 구조적 과제이며, 대규모 조직은 지속적인 재실행을 통한 플레이크 검출, 플레이크율이 임계값을 넘은 테스트의 자동 플래그 지정, 알려진 플레이키 실패를 CI 의 합격 판정에서 제외하는 로직 등 전용 구조를 갖추고 있다.
규모가 커질수록 드러나는 과제는 그 외에도 있다 —— hermetic 하지 않은 테스트가 섞여 들어가면 캐시의 신뢰성 자체가 무너지는 캐시의 일관성, 모노레포의 성장에 따라 빌드 그래프 자체가 비대해지는 의존 그래프의 비대화, 「이 테스트의 책임자는 누구인가」 가 애매해지는 소유권의 희박화, 그리고 리모트 캐시・리모트 실행에 최적화된 환경과 개별 개발자의 로컬 경험 사이의 괴리 이다.
실행 기반만으로는 부족하다 — 이음매 (seam) 라는 발상
지금까지의 구조는 모두, 개별 테스트가 hermetic 하다는 것 —— 진짜 DB 나 네트워크에 의존하지 않고 교체 가능하다는 것 —— 을 토대로 하고 있다. 하지만 코드가 애초에 교체 가능하게 쓰여 있지 않다면, hermetic 성은 그림의 떡이 된다. 여기서 실행 기반의 이야기는 설계의 이야기로 연결된다.
Michael Feathers 는 저서 『Working Effectively with Legacy Code』 (2004) 에서, 테스트가 없는 레거시 코드에 테스트를 추가하는 기법을 체계화하고, 그 핵심에 이음매 (seam) 라는 개념을 두었다. 이음매란 「실제 코드를 편집하지 않고 동작을 바꿀 수 있는 곳」 이며, 의존 객체를 교체할 수 있는 생성자 인수 (객체의 이음매), 링크 시에 교체 가능한 의존 (링크의 이음매), 매크로에 의한 교체 (전처리기의 이음매) 등이 있다. 이음매가 없는 코드 —— 모든 것이 new 로 하드코딩되고 정적 메서드나 전역 상태에 직접 의존하는 코드 —— 에는, 테스트의 입구도 hermetic 성을 실현하는 입구도 존재하지 않는다.
의존성 주입과 헥사고날 아키텍처
이음매를 만드는 가장 일반적인 수단이 의존성 주입 (DI) 이다.
// DI 없음: 의존을 내부에서 생성 → 테스트에 실제 SMTP 서버가 필요
class OrderService {
private EmailSender sender = new SmtpEmailSender();
}
// DI 있음: 의존을 외부에서 주입 → 테스트에서는 Fake 로 교체 가능
class OrderService {
OrderService(EmailSender sender) { this.sender = sender; }
}
헥사고날 아키텍처 (포트 & 어댑터) 는 Alistair Cockburn 이 2005 년에 제창했다. 코어 로직을 중심에 두고, 외부와의 상호작용은 「포트」 라는 인터페이스를 통해 이루어지며, 구체적인 기술과의 연결은 「어댑터」 가 담당한다. Cockburn 의 동기는 「애플리케이션이 사용자・자동 테스트 어느 쪽에서도 동등하게 구동될 수 있고, 실행 시의 디바이스나 DB 로부터 분리되어 개발・테스트할 수 있게 한다」 는 것이었다. 이 아키텍처는 훗날 클린 아키텍처 (Robert C. Martin) 나 어니언 아키텍처 (Jeffrey Palermo) 로 변주되지만, 모두 「의존의 방향을 코어로 향하게 한다」 는 발상을 공유한다.
Humble Object 와 함수형 코어・명령형 셸
Humble Object 패턴은 테스트하기 어려운 부분의 내용물을 최대한 비워두는 사고방식이다. 원형은 Feathers 가 2002 년에 발표한 논문 “The Humble Dialog Box” 로 거슬러 올라가며, GUI 의 로직을 별도 클래스로 빼내고 다이얼로그 자체는 「표시만 하는 겸손한 클래스」 로 만들 것을 제안했다. 이 사고방식은 Steve Freeman 과 Nat Pryce 의 『Growing Object-Oriented Software, Guided by Tests』 (2009, 통칭 GOOS 책) 에서 일반화되어, 데이터베이스 접근 계층이나 외부 API 호출 계층 등 「테스트하기 어려운 경계」 전반에 적용 가능한 범용 패턴으로 자리 잡았다. MVP・MVVM 의 「View 를 얇게 유지한다」 는 설계도 그 구현 형태 중 하나다.
함수형 코어・명령형 셸 은 Gary Bernhardt 가 2012 년의 컨퍼런스 토크 “Boundaries” (SCNA 2012) 에서 널리 알린 설계 스타일로, 순수 함수만으로 구성되는 「코어」 와, I/O 나 부작용을 담당하는 얇은 「셸」 로 나눈다. 코어는 목이나 스텁이 필요 없이 빠르고 결정적으로 테스트할 수 있다. Bernhardt 는 이 스타일이 Cockburn 의 포트 & 어댑터에서 착상을 얻었다고 인정하고 있으며, DI・헥사고날・Humble Object・함수형 코어는 모두 「부작용을 경계로 밀어내고, 로직을 순수하게 유지한다」 는 동일한 원리의 서로 다른 표현이다.
전역 상태가 테스트를 망가뜨리는 이유와, 마찰이라는 경고
싱글턴이나 전역 변수는, 테스트 간 상태 공유로 인한 실행 순서 의존, 교체 불가능, 병렬 실행 시의 경합, 생성자에 나타나지 않는 암묵적 의존이라는 4 가지 이유로 테스트를 어렵게 만든다. DI 나 헥사고날 아키텍처는 이 「암묵적 의존」 을 「명시적 의존」 으로 변환하는 수단이라고 바꿔 말할 수 있다.
테스트를 작성할 때의 마찰은 설계상의 경고로 읽을 수 있다.
| 마찰 | 시사하는 문제 |
|---|---|
| 설정이 이상하게 길다 | 책임이 과다한 클래스 |
| 목투성이가 된다 | 추상화 수준의 불일치 |
| 특정 순서로만 테스트가 통과한다 | 전역 상태에 대한 숨은 의존 |
| 시각・난수 때문에 결과가 바뀐다 | 부작용이 경계로 밀려나 있지 않음 |
유지보수성이라는 또 하나의 주전장 — 테스트 냄새와 DAMP
수백만 건 규모의 테스트는, 작성 방식이 나쁘면 유지보수 비용이 높은 부채가 된다. Gerard Meszaros 는 2007 년의 저서 『xUnit Test Patterns: Refactoring Test Code』 에서 68 종의 패턴과 테스트 냄새 (test smells) 의 카탈로그를 제시했다.
| 테스트 냄새 | 내용 |
|---|---|
| Fragile Test | 사소한 변경 (동작에 영향을 주지 않는 리팩터링 등) 으로 테스트가 깨진다 |
| Mystery Guest | 테스트의 성패를 좌우하는 중요한 데이터가, 테스트 외부 (공유 픽스처・DB 의 기존 레코드 등) 에 숨어 있다 |
| Assertion Roulette | 하나의 테스트에 대량의 어서션이 늘어서 있어, 실패 시 어느 것이 원인인지 판별할 수 없다 |
| General Fixture | 개별 테스트가 필요로 하지 않는 불필요한 데이터까지 포함한 공통 픽스처를 돌려쓴다 |
| Test Run War | 여러 테스트 (혹은 병렬 실행 시의 여러 프로세스) 가 같은 공유 자원 (DB・파일・포트) 을 다툰다 |
프로덕션 코드의 세계에서 공유되는 DRY (중복을 배제하라) 에 대해, 테스트 코드의 세계에서는 DAMP (Descriptive And Meaningful Phrases) 라는 대항적인 표어가 자리 잡고 있다. 설정의 공통화를 지나치게 진행하면, 어떤 테스트를 이해하기 위해 여러 헬퍼를 거슬러 올라가 읽어야 하는 상황이 생기고, 그것이 Mystery Guest 를 낳는다. 실무상으로는 「양자는 많은 경우에 양립한다」 는 절충적인 이해가 일반적이며, 무의미한 중복은 배제하되 테스트의 의도를 전달하는 구조까지 과도하게 추상화하지는 않는다는 것이 실천적 지혜다.
목의 과도한 사용도 유지보수성을 해친다. 테스트가 호출 순서・횟수까지 검증하는 과잉 명세화 는, 내부 구현의 무해한 리팩터링으로 테스트를 깨뜨린다. 「자신이 테스트하고 있는 대상의 직접적인 협력자만을 목으로 하고, 값 객체는 실물을 사용한다」 는 절제가 널리 권장된다. 테스트 데이터 생성에는 전형적인 예를 준비하는 Object Mother 와, 기본값을 가지면서 필요한 속성만 덮어쓸 수 있는 Test Data Builder 라는 2 가지 패턴이 있으며, 후자는 General Fixture 를 피하기 쉬워 근래 더 널리 채택되고 있다.
규모와 설계는, 사실 같은 질문에 답하고 있다
언뜻 보면, 모노레포의 실행 기반 (hermetic 성・캐시・test impact analysis) 과 개별 클래스의 설계 (DI・헥사고날・Humble Object) 는 별개의 주제로 보인다. 하지만 hermetic 성은 「의존을 교체할 수 있는 설계」 가 있어야 비로소 실현되며, small 테스트가 8 할을 차지할 수 있는 것은 「부작용을 경계로 밀어낸」 설계가 있기 때문이고, 수백만 건의 테스트를 인간이 계속 리뷰하고 소유할 수 있는 것은 가독성이 높은 —— DAMP 를 의식한 —— 테스트가 있기 때문이다. 대규모 개발의 테스트가 가르쳐 주는 것은, 「어떻게 실행할 것인가」 라는 기반의 문제와 「어떻게 쓸 것인가」 라는 설계의 문제가, 규모가 커질수록 분리할 수 없게 된다는 바로 그 한 점이다.
이 기사에서 다음 기사로
수백만 건의 테스트를 지탱하는 실행 기반과 설계는, 모두 인간이 쌓아 올린 공학적인 답이었다. 하지만 2020 년대, 테스트의 생성・수복・평가에까지 AI 가 관여하기 시작했다. 마지막 기사에서는, 생성 AI 가 테스트를 어떻게 바꾸고 있는지 —— 그리고 무엇을 바꾸지 않았는지 —— 를 본다.