역사
Go 테스트 프레임워크는 대규모 코드베이스에서 CI 파이프라인의 증가하는 지속 시간을 해결하기 위해 t.Parallel()을 도입했습니다. 멀티코어 프로세서의 보편적인 채택 이전에, 테스트는 기본적으로 순차적으로 실행되었습니다. 프로젝트가 수천 개의 테스트로 확장됨에 따라 완전한 순차적 실행은 병목 현상이 되었고, 무제한 병렬 실행은 파일 설명자나 데이터베이스 연결과 같은 프로세스 자원을 고갈시킬 위험이 있었습니다. 디자인 목표는 전역 한계를 존중하는 내장된 선택적 동시성 모델을 제공하는 것이었으며, 개발자가 각 테스트 스위트를 위해 작업자 풀이나 복잡한 동기화를 수동으로 조정할 필요가 없게 만들었습니다.
문제
개발자가 t.Parallel()을 호출할 때, 테스트는 실행기에게 다른 테스트와 동시에 실행될 수 있음을 신호해야 합니다. 하지만 프레임워크는 자원 고갈을 방지하기 위해 (기본적으로 GOMAXPROCS로 설정되지만 -parallel 플래그를 통해 조정 가능) 엄격한 동시성 한도를 강요해야 합니다. 중첩된 하위 테스트의 경우에는 문제가 더욱 심각해집니다: 부모 테스트는 t.Run을 여러 번 호출할 수 있으며, 각 하위 테스트는 독립적으로 t.Parallel()을 호출할 수 있습니다. 솔루션은 전체 하위 테스트가 완료되기 전에 부모가 자신의 실행 슬롯을 해제하지 않도록 방지해야 하며, 깊이 중첩된 병렬 하위 테스트가 동일한 전역 풀에서 슬롯을 올바르게 획득하도록 해야 합니다. 이 과정에서 부모가 교착 상태에 빠지거나 제한을 초과하지 않도록 해야 합니다.
솔루션
testing 패키지는 -parallel 플래그 값에 맞춰 크기가 조정된 빈 구조체의 버퍼링된 채널(chan struct{})로 구현된 세마포어를 사용합니다. 이 채널은 패키지 내 모든 테스트에서 공유됩니다. 각 T 인스턴스는 이 parallel 채널에 대한 참조와 부모와의 협조를 위한 내부 signal 채널을 보유합니다.
t.Parallel()이 호출되면:
signal 채널을 닫아서 부모 t.Run 호출의 블로킹을 해제하여 부모가 하위 테스트가 동시에 실행되는 동안 계속 진행하거나 종료할 수 있도록 합니다.parallel 세마포어 채널에 데이터를 보내어 실행 슬롯을 획득합니다.t.Cleanup 훅이 실행되면 테스트 러너의 지연된 함수가 parallel 채널에서 수신하여 슬롯을 해제합니다.계층 구조의 경우, t.Run은 하위 테스트가 완전히 종료될 때까지 부모 고루틴을 sync.WaitGroup을 사용하여 차단합니다. 이것은 전체 하위 테스트가 완료될 때까지 부모가 자신의 슬롯을 보유(또는 대기)하도록 하여 깊이 중첩된 병렬 테스트가 모여서 전역 한도가 초과되지 않도록 보장합니다.
// 테스트 패키지 내부의 개념적 모델 type T struct { parallel chan struct{} // 공유 세마포어 signal chan struct{} // Parallel()이 호출되었음을 부모에게 신호 parent *T wg sync.WaitGroup // 하위 테스트를 기다림 } func (t *T) Parallel() { // 부모가 계속할 수 있도록 해제 close(t.signal) // 전역 풀에서 슬롯을 획득 t.parallel <- struct{}{} // 테스트가 끝나면 슬롯을 해제 t.Cleanup(func() { <-t.parallel }) } func (t *T) Run(name string, f func(t *T)) bool { t.wg.Add(1) sub := &T{parallel: t.parallel, signal: make(chan struct{})} go func() { defer t.wg.Done() f(sub) }() <-sub.signal // 하위 테스트가 시작되거나 Parallel을 호출할 때까지 대기 t.wg.Wait() // 완료 대기 return !sub.Failed() }
맥락
플랫폼 팀은 마이크로서비스 아키텍처에 대한 2,000개의 통합 테스트가 포함된 모노레포를 유지하고 있었습니다. 각 테스트는 Postgres 및 Redis에 대한 임시 Docker 컨테이너를 생성했습니다. 테스트를 순차적으로 실행하는 데 45분이 필요해 빠른 피드백이 불가능했습니다. 그러나 go test -parallel 100을 실행하면 CI 러너가 커널의 max_user_namespaces 한계를 초과하여 호스트가 충돌하고 빌드 캐시가 손상되었습니다.
문제
팀은 컨테이너 집약적인 테스트를 다섯 개의 동시 인스턴스로 제한하여 커널 한계를 준수해야 했고, 순수 단위 테스트는 최대 처리량을 위해 -parallel 32로 실행할 수 있어야 했습니다. Go의 표준 테스트 패키지는 호출당 단일 전역 -parallel 값만 허용하며, 동일한 실행 내에서 서로 다른 테스트 범주에 대해 다른 한계를 적용하는 방법이 내장되어 있지 않습니다.
고려된 솔루션
Bazel을 통한 외부 조정.*
테스트 셰이딩 및 리소스 태그 지정을 지원하는 Bazel로의 마이그레이션이 제안되었습니다 (예: tags = ["resources:postgres:1"]). 이는 스케줄러가 동시 데이터베이스 테스트를 정확히 제한할 수 있게 합니다. 그러나 이 과정에서 전체 빌드 시스템을 재작성해야 하며, go test의 단순성을 잃는다는 단점이 있었습니다. 학습 곡선이 가파르고, 로컬 개발 작업 흐름이 크게 변경되어 Bazel의 쿼리 언어에 익숙하지 않은 개발자들을 느리게 만들었습니다.
테스트 스위트 내 수동 세마포어.*
개발자들은 패키지 수준의 var dbSem = make(chan struct{}, 5)를 추가하고 모든 통합 테스트가 시작 부분에서 수동으로 획득하도록 하는 방법을 고려했습니다. 이는 세밀한 제어를 제공하지만 상당한 보일러플레이트를 도입하고, 테스트가 세마포어를 보유한 상태에서 패닉이 발생하면 교착 상태의 위험이 있었습니다. 또한 동시성 모델이 단편화되어 일부 테스트는 -parallel 플래그를 준수하고, 다른 테스트는 사용자 정의 세마포어를 준수하므로 디버깅이 어려워지고 자원 회계의 불일치가 발생했습니다.
CI 단계로의 빌드 태그 분리.*
팀은 빌드 태그를 사용하여 테스트를 분리하기로 선택했습니다. 모두에게 //go:build integration을 추가하고 단위 테스트는 표시하지 않았습니다. CI 파이프라인은 먼저 단위 테스트를 위해 go test -short -parallel 32 ./...를 실행한 다음, 별도로 go test -tags=integration -parallel 5 ./...를 실행했습니다. 이는 테스트 논리를 수정하지 않고 기존 Go 도구 체인 기능을 활용했습니다. 단점은 단위 테스트와 통합 테스트 간의 패키지 간 병렬성 손실이었지만, 단위 테스트가 세 분 안에 완료되었기 때문에 총 시간 (3m + 20m)은 수용 가능하고 안정적이었습니다.
선택된 솔루션 및 결과
그들은 빌드 태그 분리를 선택했습니다. 이는 최소한의 코드 변경—단순히 파일 헤더에 태그를 추가하는 것—을 요구하였고, 사용자 정의 동기화 없이 표준 testing 패키지의 세마포어를 자연스럽게 활용했습니다. CI는 안정적이게 되었고, 커널 한계를 준수했으며, 개발자는 로컬에서 여전히 go test -tags=integration -parallel 4를 실행하여 디버그할 수 있었습니다. 총 CI 시간은 45분에서 23분으로 감소하였고, 호스트 충돌이 완전히 중단되었습니다.
왜 t.Parallel() 호출 후 고루틴을 생성할 경우 때때로 해당 고루틴이 잘못된 테스트 출력에 로깅되거나 패닉에 빠지나요?
t.Parallel()이 호출되면 현재 테스트 고루틴은 세마포어에서 차단되고 부모 테스트 러너는 다음 테스트로 계속 진행합니다. 그러나 생성된 고루틴은 T 인스턴스를 상속받습니다. 만약 메인 테스트 함수가 고루틴이 여전히 실행 중인데 반환될 경우, 테스트 패키지는 T를 완료로 표시하고 출력 버퍼를 닫습니다. 이후 고립된 고루틴에서 t.Log 또는 t.Error를 호출하면 "테스트가 완료된 후 고루틴에서 로깅"이라는 패닉이 발생할 수 있습니다. 올바른 접근은 고루틴의 완료를 sync.WaitGroup을 사용하여 동기화하거나 t.Cleanup이 이를 기다리도록 보장하는 것입니다. t.Parallel()은 분리된 고루틴에 대해 자동으로 기다리지 않으며, 테스트 함수의 생애 주기만 실행기와 동기화합니다.
테스트 패키지는 어떻게 부모 테스트가 모든 하위 테스트(일부가 t.Parallel()을 호출할 수 있음)가 실행 완료되기 전에 자신의 병렬성 슬롯을 해제하지 않도록 하나요?
T 구조체는 sync.WaitGroup을 포함합니다. 하위 테스트를 생성하기 위해 t.Run이 호출될 때, 부모는 하위 테스트 고루틴을 실행하기 전에 t.wg.Add(1)을 호출하고, 하위 테스트는 완료 시 지연된 함수에서 t.wg.Done()을 호출합니다. 중요하게도 하위 테스트가 t.Parallel()을 호출하면 부모의 WaitGroup이 즉시 감소하여 부모가 자신의 함수 본체를 완료할 수 있게 하지만, 부모 테스트의 전체 완료는 마지막 t.wg.Wait()에 의해 차단되어 그 세마포어 토큰이 해제됩니다. 이것은 루트 병렬 테스트가 실행 슬롯을 보유하여 전체 직렬 및 병렬 하위 테스트가 종료될 때까지 기다리는 트리 구조의 대기를 형성하여 -parallel 한계가 활성 테스트 트리의 수를 정확하게 반영하게 합니다.
어째서 t.Setenv가 t.Parallel() 호출 후 패닉을 일으킬 수 있으며, 이는 Go의 병렬 테스트의 격리 모델에 대해 무엇을 드러내나요?
t.Setenv는 t.Parallel() 호출 후에 호출하면 패닉을 일으키는데, 그 이유는 환경 변수는 프로세스 전역 상태이기 때문입니다. 병렬 테스트는 같은 프로세스에서 동시에 실행되며, 하나의 테스트가 PATH를 수정하고 다른 테스트가 이를 읽으면 데이터 경합이 발생하고 비결정론적 동작의 결과가 발생할 수 있습니다. 이를 방지하기 위해 Go의 테스트 패키지는 테스트가 병렬로 실행될 경우 환경을 "얼어붙은" 상태로 마킹하며, t.Setenv나 os.Setenv를 통해 수정하려는 모든 시도는 패닉을 유발합니다. 이는 병렬 테스트가 단일 주소 공간 내에서 동시성을 위해 설계되었지만 불변의 공유 상태 또는 명시적 동기화를 가정하고 있다는 것을 드러냅니다. 후보들은 종종 t.Parallel()이 전역 프로세스 상태의 변형 금지를 의미하는 계약을 내포하고 있다는 점을 간과하며, 이는 테스트가 병렬이 아닐 경우만 상태를 복원하기 위해 t.Cleanup을 사용해야 하거나 전역 상태를 완전히 피하도록 테스트를 설계해야 한다는 것을 요구합니다.