synctest로 동시 실행되는 Go 코드 테스트하기
동기화된 Go 테스트에서 더 이상 잠들지 마세요.
동시성(Concurrent) Go 코드 테스트는 언제나 조금의 절제된 접근이 필요했습니다. 고루틴(Goroutine)은 저렴하고, 채널(Channel)은 단순하며, 컨텍스트 취소(Cancellation)는 아이디오파틱(Idiomatic)한 방법입니다 — 실제 Go 서비스에서는 백그라운드 워커와 타이머가 어디에나 존재합니다.
하지만 이를 신뢰할 수 있게 테스트하는 것은 작성하는 것보다 훨씬 어렵습니다.

보통 보이는 나쁜 패턴은 익숙합니다:
go doSomething()
time.Sleep(100 * time.Millisecond)
if !done {
t.Fatal("background work did not finish")
}
이 테스트는 개발자의 노트북에서는 통과하고 CI에서는 실패할 수 있습니다. 혹은 6개월 동안 통과하다가 로드된 러너(Loader)에서 실패할 수도 있습니다. 아니면 누군가가 “안전확보를 위해” 슬립(Sleep) 시간을 100밀리초에서 2초로 늘렸기 때문에 느릴 수도 있습니다.
이것은 좋은 테스트가 아닙니다 — 타이머와 하는 도박이며, 테스트 스위트가 커질수록 그 도박의 비용은 더 비싸집니다.
testing/synctest 패키지는 Go 개발자에게 비동기(Asynchronous) 및 시간 의존(Time-dependent) 코드의 다양한 형태를 테스트하는 더 나은 방법을 제공합니다. 이 패키지는 테스트가 격리된 버블(Bubble) 안에서 실행되도록 하고, 그 버블에 가짜 시계(Fake Clock)를 제공하며, 버블 안의 고루틴들이 차단(Block)될 때까지 기다릴 수 있는 방법을 제공합니다.
그 결과는 간단하지만 강력합니다:
- 임의의 슬립(Sleep) 제거
- 더 빠른 타임아웃 테스트
- 더 결정론적인(Deterministic) 동시성 테스트
- 컨텍스트 취소에 대한 더 나은 테스트
- 백그라운드 고루틴에 대한 더 나은 테스트
- 덜 불안정한(less flaky) CI
조금 견해가 강한 표현을 하자면: 동시성 Go 테스트가 실제 time.Sleep에 의존하고 있다면, 그 테스트를 의심스럽게 다루어야 할 것입니다.
testing/synctest란
testing/synctest는 동시성 코드를 테스트하기 위한 Go 표준 라이브러리 패키지입니다.
두 가지 주요 함수를 제공합니다:
package synctest
func Test(t *testing.T, f func(*testing.T))
func Wait()
synctest.Test는 격리된 테스트 버블 안에서 함수를 실행합니다. 그 버블 안에서 시작되는 모든 고루틴도 버블의 일부가 되며, 버블 안의 시간은 가짜입니다. 또한 time 패키지는 실제 벽시계(Wall Clock) 대신 그 가짜 시계를 기준으로 작동합니다.
synctest.Wait는 버블 안의 다른 모든 고루틴이 안정적으로(Durably) 차단될 때까지 대기합니다. 이는 추상적으로 들릴 수 있지만, 실용적인 효과는 이해하기 쉽습니다:
synctest.Test(t, func(t *testing.T) {
time.Sleep( 10 * time.Second)
})
이것은 테스트를 실제 10초를 기다리게 하지 않습니다. synctest 버블 안에서, 버블이 차단되어 시간이 흐르기만 하면 시간이 즉시 진행될 수 있습니다 — 이것이 이 패키지 뒤의 핵심적인 트릭입니다.
왜 동시성 Go 테스트가 불안정한(flaky)가
일반적인 Go 테스트에 신규라면, Go 단위 테스트: 구조 및 모범 사례에서 이 기사가 기반하는 테스트 패키지, 테이블 주도 테스트, 목(Mock) 패턴에 대해 다루고 있습니다. 동시성 테스트가 불안정한 이유는 보통 세 가지 중 하나입니다.
첫째, 스케줄러에 의존합니다. 고루틴은 사용자의 머신에서는 즉시 실행되고 CI에서는 나중에 실행될 수 있습니다.
둘째, 실제 시간에 의존합니다. 50밀리초 동안 슬립하는 테스트는 50밀리초가 백그라운드 작업이 완료되기에 충분한 시간이라고 가정합니다.
셋째, 상태를 너무 일찍 관찰합니다. 백그라운드 작업이 실제로 완료되기 전에 테스트가 결과를 확인합니다.
간단한 예시를 보여드리겠습니다:
func TestBackgroundWorkBad(t *testing.T) {
done := false
go func() {
done = true
}()
time.Sleep(10 * time.Millisecond)
if !done {
t.Fatal("background work did not finish")
}
}
이 테스트에는 두 가지 문제가 있습니다.
명백한 문제는 슬립입니다. 10밀리초가 올바른 시간이라는 보장은 없습니다.
덜 명백한 문제는 데이터 레이스(Data Race)입니다. 테스트는 동기화 없이 한 고루틴에서 done을 작성하고 다른 고루틴에서 읽습니다.
이 특정 예시로는 채널이나 sync.WaitGroup을 사용하여 수정할 수 있으며, 대개 그렇게 해야 합니다. 하지만 테스트 대상 코드가 타이머, 컨텍스트 데드라인, time.AfterFunc, 백그라운드 워커, 지연 정리(Cleanup)를 사용한다면 테스트는 여전히 어색해질 수 있습니다 — 그리고 이것이 정확히 testing/synctest가 도움이 되는 곳입니다.
핵심 아이디어: 버블 안에서 테스트 실행
synctest 버블은 그 안에서 생성된 고루틴을 격리합니다.
다음처럼 사용하십시오:
func TestSomethingConcurrent(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// 여기서 동시성 코드 테스트.
})
}
버블 안에서는:
- 테스트에 의해 시작된 고루틴은 버블에 속합니다.
- 타이머와 슬립은 가짜 시계를 사용합니다.
synctest.Wait를 사용하여 백그라운드 활동이 정착될 때까지 대기할 수 있습니다.- 테스트는 외부 고루틴, 실제 네트워크 I/O 또는 외부 프로세스에 의존하는 것을 피해야 합니다.
버블은 마법이 아닙니다. 나쁜 동시성 설계를 좋게 만들지 않습니다. 하지만 시간을 제어하고, 차단된 동작을 더 결정론적으로 만들어 주는 통제된 환경을 테스트에 제공합니다.
테스트에서 time.Sleep의 문제
테스트에서 실제 time.Sleep은 보통 두 가지 의미 중 하나를 나타냅니다:
실제로 관심 있는 이벤트에 대해 어떻게 대기해야 하는지 모릅니다.
또는:
무엇을关心 하는지 알고 있지만, 테스트 대상 코드가 그것을 관찰할 수 있는 깔끔한 방식을 노출하지 않습니다.
둘 다 심각하게 받아들일 가치가 있는 설계 신호입니다 — 프로덕션 코드에서 더 깔끔한 관찰 가능성(Observability) 또는 더 명시적인 조정 메커니즘이 도움이 될 수 있는 지점을 가리키기 때문입니다.
백그라운드에서 작업을 완료하는 함수를 고려해 보겠습니다:
type Worker struct {
out chan string
}
func NewWorker() *Worker {
return &Worker{
out: make(chan string, 1),
}
}
func (w *Worker) Start() {
go func() {
time.Sleep(5 * time.Second)
w.out <- "done"
}()
}
func (w *Worker) Result() <-chan string {
return w.out
}
나쁜 테스트는 다음과 같이 보일 수 있습니다:
func TestWorkerBad(t *testing.T) {
w := NewWorker()
w.Start()
time.Sleep(6 * time.Second)
select {
case got := <-w.Result():
if got != "done" {
t.Fatalf("got %q, want done", got)
}
default:
t.Fatal("worker did not finish")
}
}
이 테스트는 실제 6초를 기다립니다.
그것은 느립니다. 이러한 테스트가 많다면 스위트가 고통스러워집니다.
synctest를 사용한 더 나은 테스트는 가짜 시간을 즉시 진행시킬 수 있습니다:
func TestWorkerWithSynctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
w := NewWorker()
w.Start()
time.Sleep(5 * time.Second)
synctest.Wait()
select {
case got := <-w.Result():
if got != "done" {
t.Fatalf("got %q, want done", got)
}
default:
t.Fatal("worker did not finish")
}
})
}
테스트는 여전히 비즈니스 사실(워커는 5초 후 완료되어야 함)을 표현하지만, 이를 위해 실제 5초를 소비하지 않습니다. 이는 시간 의존 동작을 테스트하는 것과 개발자의 시간을 낭비하는 것 간의 차이입니다.
컨텍스트 타임아웃 테스트
testing/synctest의 최고의 용도 중 하나는 context.Context 데드라인과 타임아웃을 테스트하는 것입니다. 컨텍스트 취소와 타임아웃 버짓이 서비스 레이어 전반에 걸쳐 어떻게 구성되어야 하는지, context.Canceled와 context.DeadlineExceeded가 이러한 레이어를 통해 어떻게 올바르게 전파되는지는 각각 Go context.Context Correctly Used 및 Go 에러 핸들링 아키텍처: 경계와 패턴에서 다루고 있습니다. synctest를 사용하면 실제 시간이 지나는 없이 그 동작을 검증할 수 있습니다.
컨텍스트가 취소될 때까지 대기하는 간단한 함수입니다:
func WaitForCancel(ctx context.Context, done chan<- error) {
go func() {
<-ctx.Done()
done <- ctx.Err()
}()
}
synctest 없이 30초 타임아웃으로 이것을 테스트하려면 테스트를 느리게 하거나, 테스트 전용으로 타임아웃을 변경해야 합니다.
synctest를 사용하면 실제 타임아웃 지속 시간을 빠르게 테스트할 수 있습니다:
func TestWaitForCancelWithTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
done := make(chan error, 1)
WaitForCancel(ctx, done)
synctest.Wait()
select {
case err := <-done:
t.Fatalf("context canceled too early: %v", err)
default:
}
time.Sleep(30 * time.Second)
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("got %v, want %v", err, context.DeadlineExceeded)
}
default:
t.Fatal("context was not canceled")
}
})
}
이것은 synctest가 즐겁게 만들어 주는 테스트의 종류입니다.
코드를 현실적인 타임아웃 값을 유지하면서 빠르게 실행할 수 있습니다.
컨텍스트 취소 테스트
백그라운드 고루틴과 레이스(Race)를 일으키지 않으면서 명시적인 취소도 테스트할 수 있습니다.
func TestWaitForCancelWithExplicitCancel(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
WaitForCancel(ctx, done)
synctest.Wait()
select {
case err := <-done:
t.Fatalf("context canceled too early: %v", err)
default:
}
cancel()
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v, want %v", err, context.Canceled)
}
default:
t.Fatal("context was not canceled")
}
})
}
중요한 디테일은 synctest.Wait입니다.
테스트가 결과를 확인하기 전에 백그라운드 고루틴이 취소를 관찰하고 정착할 수 있는 기회를 제공합니다.
synctest.Wait의 역할
synctest.Wait는 버블 안의 다른 모든 고루틴이 안정적으로 차단될 때까지 대기합니다.
일반적인 언어로 말하면, 이것은:
이 테스트 안의 고루틴이 안정적인 차단 지점에 도달할 때까지 대기합니다.
이것은 테스트가 고루틴을 시작하고, 그 고루틴이 완료되었거나 대기 중임을 알아야 할 때 유용합니다.
예를 들어:
func TestWaitExample(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := false
go func() {
done = true
}()
synctest.Wait()
if !done {
t.Fatal("goroutine did not run")
}
})
}
이것은 의도적으로 작지만, 아이디어를 시연합니다.
synctest.Wait는 더 좋은 슬립이 아닙니다 — 그것은 버블 안의 동기화 포인트이며, 그 구분이 처음 보이는 것보다 더 중요합니다.
슬립은 말합니다:
충분한 시간이 지났기를 바랍니다.
Wait는 말합니다:
버블이 안정적인 차단 상태에 도달하기를 원합니다.
둘째는 경과 시간에 대한 추측이 아니라 관찰 가능한 조건을 묘사하기 때문에 테스트에 훨씬 더 좋습니다.
synctest 버블 안의 가짜 시간
synctest 버블 안에서는 time 패키지가 가짜 시계를 사용합니다.
가짜 시계는 고정된 시간에서 시작됩니다. 버블 안의 모든 고루틴이 안정적으로 차단되어 있고, 무언가를 해제(Unblock)하기 위해 시간이 진행되어야 할 때만 진작합니다.
즉, 이 테스트는 빠릅니다:
func TestFakeTime(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
time.Sleep(1 * time.Hour)
elapsed := time.Since(start)
if elapsed != time.Hour {
t.Fatalf("got %v, want %v", elapsed, time.Hour)
}
})
}
한 시간을 기다리는 것처럼 읽힙니다.
하지만 그렇지 않습니다.
이것은 다음을 테스트하는 데 유용합니다:
- 타임아웃
- 데드라인
- 재시도(Retry)
- 백오프(Backoff)
- 지연 정리
- 레이트 리밋(Rate Limit)
- 타이머
- 티커(Ticker)
- 컨텍스트 취소
하지만 하나의 중요한 규칙이 있습니다: 가짜 시간은 버블 안의 time 패키지를 사용하는 코드에만 도움이 됩니다.
코드가 외부 시스템, 실제 네트워크 I/O 또는 버블 밖에서 측정된 시간에 의존한다면, synctest는 그것을 결정론적으로 만들 수 없습니다.
재시도 루프 테스트
재시도 루프는 느리고 불안정한 테스트의 일반적입니다.
작은 재시도 헬퍼(Help)입니다:
func Retry(ctx context.Context, attempts int, delay time.Duration, fn func() error) error {
var last error
for i := 0; i < attempts; i++ {
if err := fn(); err != nil {
last = err
} else {
return nil
}
if i == attempts-1 {
break
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return last
}
일반적인 테스트는 스위트를 빠르게 유지하기 위해 지연 시간을 1밀리초로 줄일 수 있습니다.
그것은 최악은 아니지만, 테스트가 프로덕션 코드가 사용하는 실제 값을 더 이상 연습하지 않는다는 의미입니다.
synctest를 사용하면 실제 지연 시간을 유지할 수 있습니다:
func TestRetryEventuallySucceeds(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx := t.Context()
calls := 0
err := Retry(ctx, 3, 10*time.Second, func() error {
calls++
if calls < 3 {
return errors.New("temporary failure")
}
return nil
})
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
})
}
이 테스트는 두 번의 10초 대기 시간을 표현합니다.
여전히 빠르게 실행됩니다.
여기가 synctest가 테스트의 경제성을 변화시키는 곳입니다. 느린 CI를 피하기 위해 테스트 곳곳에 가짜로 작은 지속 시간을 흩뿌릴 필요가 더 이상 없습니다.
재시도 취소 테스트
재시도 지연 중 취소를 테스트할 수도 있습니다:
func TestRetryStopsWhenContextCanceled(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
errCh := make(chan error, 1)
go func() {
errCh <- Retry(ctx, 10, 10*time.Second, func() error {
return errors.New("temporary failure")
})
}()
synctest.Wait()
cancel()
synctest.Wait()
select {
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v, want %v", err, context.Canceled)
}
default:
t.Fatal("Retry did not return after cancellation")
}
})
}
이 테스트는 재시도 루프가 지연을 건너뛴 채 슬립하는 대신 취소에 반응하는지 확인합니다.
그것은 프로덕션에서 중요한 바로 그 종류입니다.
time.AfterFunc 테스트
time.AfterFunc는 또 다른 적합한 대상입니다.
정리를 일정하는 함수가 있다고 가정해 봅시다:
type Cache struct {
cleaned chan struct{}
}
func NewCache() *Cache {
return &Cache{
cleaned: make(chan struct{}, 1),
}
}
func (c *Cache) CleanupAfter(d time.Duration) {
time.AfterFunc(d, func() {
c.cleaned <- struct{}{}
})
}
func (c *Cache) Cleaned() <-chan struct{} {
return c.cleaned
}
테스트는 가짜 시간을 진행시킬 수 있습니다:
func TestCleanupAfter(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewCache()
cache.CleanupAfter(1 * time.Minute)
synctest.Wait()
select {
case <-cache.Cleaned():
t.Fatal("cleanup happened too early")
default:
}
time.Sleep(1 * time.Minute)
synctest.Wait()
select {
case <-cache.Cleaned():
default:
t.Fatal("cleanup did not happen")
}
})
}
이 테스트는 양쪽을 검증합니다:
- 정리는 지연 시간 전에 일어나지 않습니다.
- 정리는 지연 시간 후에 일어납니다.
그리고 실제 1분을 기다리지 않습니다.
티커 테스트
티커도 가짜 시간으로 테스트할 수 있지만, 주의하십시오. 티커는 긴 실행 루프에서 자주 사용되며, 긴 실행 루프는 깔끔한 셧다운 경로가 필요합니다.
작은 티커 기반 카운터입니다:
type Counter struct {
ticks int
done chan struct{}
}
func NewCounter() *Counter {
return &Counter{
done: make(chan struct{}),
}
}
func (c *Counter) Start(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
defer ticker.Stop()
defer close(c.done)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.ticks++
}
}
}()
}
func (c *Counter) Wait() {
<-c.done
}
func (c *Counter) Ticks() int {
return c.ticks
}
테스트는 다음과 같이 보일 수 있습니다:
func TestCounterTicks(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
counter := NewCounter()
counter.Start(ctx, 10*time.Second)
time.Sleep(35 * time.Second)
synctest.Wait()
cancel()
counter.Wait()
if counter.Ticks() != 3 {
t.Fatalf("ticks = %d, want 3", counter.Ticks())
}
})
}
이 예시에는 의도적인 설계 디테일이 있습니다: 워커에 셧다운 경로가 있습니다.
그것은 테스트에만 좋은 것이 아닙니다. 프로덕션에도 좋습니다.
테스트는 종종 고루틴이 실제로 멈출 수 있는지 여부를 드러냅니다.
synctest와 고루틴 누수
testing/synctest는 여기서 도움이 됩니다. synctest.Test는 버블 안의 고루틴이 종료될 때까지 대기하기 때문입니다. 이는 누출된 고루틴을 무시하기 어렵게 만듭니다. 백그라운드 고루틴이 절대 종료되지 않으면, 작업이 조용히 남겨지는 대신 테스트가 실패합니다 — 그리고 이것은 좋은 일입니다.
동시성 코드는 명확한 소유권(Ownership)이 있어야 합니다. 함수가 고루틴을 시작한다면, 그것을 멈추는 명시적인 방법이 있거나, 영원히 살아남을 수 있는 문서화된 이유가 있어야 합니다. 테스트에서는 “영원히"는 거의 수용되지 않습니다. 소유권과 동시성 한도(Concurrency Ceiling)는 이 기사가 함께 다루는 설계 습관입니다: Go에서 동시성 경계 설정은 버블이 밀리초 내에 포착하는 누수 경로를 가진 프로덕션 측 패턴을 다룹니다.
좋은 패턴은 다음과 같습니다:
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
그런 다음 컨텍스트가 취소되면 고루틴이 멈추도록 하십시오.
“안정적으로 차단됨(Durably Blocked)“이란 실무적으로 무슨 의미인가
공식 문서들은 “durably blocked"라는 용어를 사용합니다.
모든 런타임 디테일을 암기할 필요는 없지만, 실무적인 의미를 이해해야 합니다.
고루틴이 안정적으로 차단되었다고 하는 것은, 같은 synctest 버블 안의 무언가에 의해서만 해제될 수 있는 방식으로 차단되어 있다는 것을 의미합니다.
예를 들어:
- 버블 안에서 생성된 채널로부터 수신
- 버블 안에서 생성된 채널로 전송
- 버블과 관련된
sync.WaitGroup대기 time.Sleep으로 슬립- 특정 타이머 연산 대기
버블 밖의 무언가에 의해 해제될 수 있으므로 안정적으로 차단되지 않은 것들도 있습니다.
예를 들어:
- 네트워크 I/O
- 시스템 호출
- 외부 프로세스 연산
- 일부 뮤텍스(Mutex) 대기
- 버블 밖의 고루틴과의 상호작용
이것이 synctest 테스트가 자립적이고, 버블이 볼 수 없는 외부 동기화로부터 자유로워야 하는 이유입니다. 실제 네트워크와 통신하는 통합 테스트를 감싸는 래퍼로 synctest를 사용하지 마십시오.
synctest가 좋은 용도
testing/synctest는 비동기 동작 주위의 단위 테스트에 특히 좋습니다.
좋은 후보는 다음과 같습니다:
- 컨텍스트 취소
- 컨텍스트 타임아웃
- 재시도 루프
- 백오프 로직
- 지연 정리
- 타이머 기반 워커
- 티커 기반 루프
- 백그라운드 고루틴
- 타임아웃 동작
- 채널 조정
time.AfterFunc- 고루틴을 위한 결정론적인 대기
최고의 사용 사례는 어려운 부분이 시간 또는 스케줄링이고 외부 I/O가 아닌 코드입니다.
synctest가 좋은 용도가 아닌 것
testing/synctest는 모든 동시성 테스트의 대체재가 아닙니다.
모든 가능한 레이스에 대한 완전한 결정론적 스케줄러가 아닙니다.
레이스 탐지기(Race Detector)의 대체재가 아닙니다.
통합 테스트의 대체재가 아닙니다.
실제 네트워크 I/O를 결정론적으로 만들지 않습니다.
나쁜 고루틴 라이프사이클 설계를 수정하지 않습니다.
채널, 컨텍스트, 소유권, 셧다운을 무시할 수 있다는 것을 의미하지 않습니다.
적절한 레이어를 위해 synctest를 사용하십시오: 동시성 및 시간 의존 동작을 위한 결정론적 단위 테스트.
다른 레이어에는 다른 도구를 사용하십시오:
- 데이터 레이스 탐지를 위해
go test -race사용 - 실제 종속성을 위해 통합 테스트 사용
- 처리량과 경쟁을 위해 로드 테스트 사용
- 성능을 위해 벤치마크 사용
- 프로덕션 동작을 위해 트레이싱과 프로파일링 사용
synctest vs 레이스 탐지기
testing/synctest와 레이스 탐지기는 다른 문제를 해결합니다.
레이스 탐지기는 비안전한 동시성 메모리 접근을 찾습니다.
synctest는 테스트에서 비동기 타이밍과 대기를 제어하는 데 도움이 됩니다.
종종 둘 다 사용해야 합니다.
예를 들어, 올바른 동기화가 없다면 synctest 버블 안에서도 여전히 레이스입니다:
value := 0
go func() {
value = 1
}()
_ = value
synctest.Wait는 일부 테스트 패턴에 대해 동기화 포인트를 제공할 수 있지만, 코드 내의 모든 동시성 접근이 자동으로 안전하다는 의미는 아닙니다.
동시성 테스트를 다음과 같이 실행하십시오:
go test -race ./...
레이스 탐지기는 여전히 Go가 제공하는 최고의 도구 중 하나입니다. Go 린터: 코드 품질을 위한 필수 도구와 짝지어 사용하면 모든 동시성 코드베이스에 견고한 정적 분석 및 런타임 체크 기반을 제공합니다.
synctest vs 수동 가짜 시계
testing/synctest 이전에는 많은 팀이 수동 가짜 시계를 사용했습니다.
그것은 여전히 좋은 설계일 수 있습니다.
수동 시계 인터페이스는 다음과 같이 보일 수 있습니다:
type Clock interface {
Now() time.Time
After(time.Duration) <-chan time.Time
Sleep(time.Duration)
}
그런 다음 프로덕션 코드는 실제 시계를 사용하고, 테스트는 가짜 시계를 사용합니다.
이것은 명시적인 제어를 주지만, 비용이 있습니다:
- 더 많은 인터페이스
- 더 많은 배관(Plumbing)
- 더 많은 테스트 전용 추상화
- 코드가 가짜 시계를 우회할 수 있는 더 많은 방법
synctest가 매력적인 이유는 time 패키지를 사용하는 일반 코드가 테스트 버블 안에서 가짜 시간으로 실행될 수 있기 때문입니다.
그것은 많은 경우 시계 주입(Clock Injection)의 필요성을 줄여줍니다.
저의 의견: 프로덕션 코드를 더 단순하게 유지할 수 있을 때 synctest를 사용하십시오. 시계 제어가 도메인 설계의 일부이거나, synctest가 제공하는 것 밖에서 제어가 필요할 때만 주입된 시계를 사용하십시오. Go에서 의존성 주입 패턴에 대한 더 넓은 시각을 제공하며 — 테스트 가능한 추상화를 언제, 어떻게 주입하는지를 포함하여 — Go에서 의존성 주입: 패턴 및 모범 사례를 참조하십시오.
synctest vs 채널 및 WaitGroup
좋은 동기화를 synctest로 대체하지 마십시오.
코드가 완료 채널, 콜백, 또는 Wait 메서드를 노출할 수 있다면, 그것은 종종 좋은 설계입니다.
예를 들어:
type Server struct {
done chan struct{}
}
func (s *Server) Done() <-chan struct{} {
return s.done
}
테스트는 그것에 직접 대기할 수 있습니다.
synctest는 테스트 대상 동작이 시간, 컨텍스트 데드라인, 백그라운드 스케줄링 또는 비동기 콜백을 포함할 때 가장 유용합니다.
최고의 테스트는 종종 둘 다를 결합합니다:
- 프로덕션 코드는 명시적인 셧다운 또는 완료 신호를 가짐
- synctest는 실제 시간 대기를 제거
- Wait는 백그라운드 활동을 결정론적으로 만듦
일반적 실수
실수 1: 모든 테스트를 synctest로 감싸기
모든 곳에 synctest를 사용하지 마십시오. 코드가 동기식이라면, 일반 테스트 함수가 더 명확하며, 버블 래퍼를 추가하는 것은 테스트를 읽고 추론하기 더 어렵게 만드는 불필요한 기계 장치만 도입합니다.
실수 2: 버블 안에서 실제 네트워크 I/O 테스트
synctest 테스트를 자립적으로 유지하십시오. 테스트가 실제 네트워크 소켓, 외부 서비스, 데이터베이스, 또는 서브프로세스를 사용한다면, 그것은 synctest 버블 안에서가 아니라 통합 테스트에 속합니다. 단위 테스트에는 페이크(Fakes)를 사용하고, 버블 격리가 적용되지 않는 별도의 통합 테스트에 실제 종속성을 예약하십시오. 종속성이 HTTP 핸들러인 경우, Go 1.27의 httptest.NewTestServer는 실제 소켓에 대한 필요성을 완전히 제거합니다 — 그것은 인메모리 가짜 네트워크를 통해 서버를 실행하며, 이것은 버블이 정확히 필요로 하는 것이며, Go 1.27 업그레이드 가이드에서 보여지듯 synctest와 결정론적인 라운드트립 타이밍을 위해 짝을 이룹니다.
실수 3: 고루틴 누출
테스트가 고루틴을 시작한다면, 그것이 명확한 종료 경로를 가지는지 확인하십시오. 컨텍스트 취소, 닫힌 채널, 또는 명시적인 스톱 메서드를 사용하십시오 — 절대 멈추지 않는 고루틴은 프로덕션 냄새이자, synctest가 드러낼(숨기지 않을) 테스트 냄새입니다.
실수 4: 패키지 레벨 상태 의존
패키지 레벨 채널, 타이머, WaitGroup은 미묘한 방식으로 버블 격리를 깨뜨릴 수 있습니다. 모든 테스트 상태를 synctest.Test 함수 안에서 생성하여 모든 리소스가 버블에 속하고 그 수명이 테스트에 명확히 범위 설정되도록 하는 것이 좋습니다.
실수 5: 가짜 시간을 실제 시간으로 취급
가짜 시간은 결정론적 테스트를 위한 것이지, 성능 측정을 위한 것이 아닙니다. 즉시 1시간을 진행하는 테스트는 프로덕션의 CPU 비용, 락 경쟁, 메모리 사용량, 또는 실제 스케줄링 동작에 대해 유용한 것을 아무것도 알려 주지 않습니다 — 이러한 질문에는 벤치마크와 로드 테스트를 사용하십시오.
실수 6: 레이스 탐지기 무시
synctest는 go test -race의 대체재가 아니며, 두 도구는 다른 문제를 해결합니다. 버블만으로는 탐지할 수 없는 비안전한 동시성 메모리 접근을 포착하기 위해 synctest 테스트와 함께 레이스 탐지기를 실행하십시오.
실무 체크리스트
testing/synctest를 사용하여 테스트를 작성할 때 이 체크리스트를 사용하십시오.
synctest를 사용해야 하는 경우
- 코드가 고루틴을 시작하는 경우
- 코드가
time.Sleep을 사용하는 경우 - 코드가 타이머 또는 티커를 사용하는 경우
- 코드가 컨텍스트 데드라인을 사용하는 경우
- 코드가 재시도 또는 백오프 동작을 가진 경우
- 테스트가 현재 임의의 슬립을 사용하는 경우
- 테스트가 CI에서 불안정한 경우
- 테스트가 실제 시간을 기다리기 때문에 느린 경우
synctest를 피해야 하는 경우
- 코드가 동기식인 경우
- 테스트가 실제 네트워크 I/O에 의존하는 경우
- 테스트가 외부 프로세스에 의존하는 경우
- 테스트가 실제로 통합 테스트인 경우
- 성능을 측정하려는 경우
- 코드가 깔끔한 셧다운 경로를 가지지 않는 경우
이 패턴을 선호하십시오
func TestSomething(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Arrange.
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
// Act.
_ = ctx
// Let background work settle.
synctest.Wait()
// Advance fake time if needed.
time.Sleep(1 * time.Second)
synctest.Wait()
// Assert.
})
}
이 패턴은 간단합니다:
- 버블 안에서 설정
- 버블 안에서 작업 시작
- 백그라운드 활동이 정착할 때까지 대기
- 필요할 때만 가짜 시간 진행
- 동기화 후 어설트
실제 프로젝트에서 testing/synctest를 사용할 곳
보기를 위한 최고의場所は 보통 간단한 비즈니스 로직이 아닙니다.
다음과 같은 냄새가 나는 테스트를 찾아보십시오:
grep -R "time.Sleep" .
grep -R "time.After" .
grep -R "WithTimeout" .
grep -R "WithDeadline" .
grep -R "NewTicker" .
grep -R "AfterFunc" .
그리고 질문하십시오:
- 이 테스트는 실제 시간을 기다리기 때문에 느린가?
- 이 테스트는 고루틴이 이미 실행되었다고 가정하기 때문에 불안정한가?
- 이 테스트는 네트워크와 외부 프로세스와 격리될 수 있는가?
- 백그라운드 고루틴은 깔끔하게 중지될 수 있는가?
- 가짜 시간이 어설트를 더 명확하게 만드는가?
좋은 후보는 종종 다음에 있습니다:
- 워커 패키지
- 재시도 패키지
- 캐시 패키지
- 스케줄러 패키지
- 큐 컨슈머
- HTTP 클라이언트 래퍼
- 타임아웃 미들웨어
- 백그라운드 정리 코드
- 레이트 리미팅 코드
불안정한 테스트 하나부터 시작하십시오. 전체 코드베이스를 한 번에 마이그레이션하지 마십시오. 테스트 스위트가 비동기 코드와 함께 병렬 테이블 주도 테스트를 사용한다면, Go에서 병렬 테이블 주도 테스트는 t.Parallel() 패턴과 synctest 접근법과 자연스럽게 짝을 이루는 레이스 조건 함정을 다룹니다.
예시: 이전과 이후
현실적인 나쁜 테스트를 보여드리겠습니다:
func TestRetryBad(t *testing.T) {
calls := 0
err := Retry(context.Background(), 3, 500*time.Millisecond, func() error {
calls++
if calls < 3 {
return errors.New("temporary failure")
}
return nil
})
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
}
두 번의 재시도 지연이 발생하기 때문에 약 1초를 기다립니다.
나쁘게 들리지 않을 수 있지만, 많은 테스트와 여러 패키지로 곱셈해 보십시오. 느린 테스트는 개발자가 테스트를 덜 자주 실행하게 만듭니다.
이제 synctest 버전입니다:
func TestRetryWithSynctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
calls := 0
err := Retry(t.Context(), 3, 500*time.Millisecond, func() error {
calls++
if calls < 3 {
return errors.New("temporary failure")
}
return nil
})
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
})
}
테스트는 실제 지연 값을 유지하고, 스위트는 빠르게 유지되며, 의도가 더 명확해집니다. 이것이 testing/synctest의 주요 가치입니다.
synctest를 안전하게 채택하는 방법
점진적으로 채택할 것입니다.
단계 1: 불안정하거나 느린 동시성 테스트 찾기
실제 슬립과 타임아웃이 많은 테스트를 검색하십시오. 이전 섹션의 grep 명령은 코드베이스 전반에서 후보를 식별하는 좋은 시작점입니다.
단계 2: 하나의 패키지 선택
실제 외부 서비스를 필요로 하지 않지만 명확한 비동기 동작을 가진 패키지를 선택하십시오. 워커 패키지, 재시도 헬퍼, 타이머 기반 컴포넌트는 이상적인 첫 번째 대상입니다.
단계 3: 하나의 테스트 변환
테스트를 synctest.Test로 감싸고 임의의 슬립을 synctest.Wait, 가짜 시간 슬립, 또는 명시적인 동기화로 대체하십시오. 변환은 보통 작습니다 — 가장 어려운 부분은 고루틴이 깔끔한 셧다운 경로를 가지는지 확인하는 것입니다.
단계 4: 레이스 탐지기로 실행
변환 후 항상 go test -race ./...와 함께 실행하십시오. 통과하는 synctest 테스트는 코드가 레이스가 없다는 것을 의미하지 않습니다; 그것은 비동기 타이밍이 이제 결정론적이라는 것만 의미합니다.
단계 5: 고루틴 라이프사이클 검토
테스트가 시작한 모든 고루틴이 버블이 닫히기 전에 종료하는 방법이 있는지 확인하십시오. 없다면, synctest.Test는 그것을 조용히 무시하는 대신 누수를 드러낼 것입니다.
단계 6: 명확성이 개선되는 곳에서만 반복
패션(fashion)을 위해 테스트를 변환하지 마십시오. 좋은 synctest 테스트는 측정 가능한 속도, 읽기 용이성, 또는 불안정성 감소가 있어야 합니다 — 그렇지 않다면, 변환은 가치가 없었습니다.
제 의견 있는 규칙들
이것들을 실무적 규칙으로 사용하십시오.
규칙 1: 동시성 단위 테스트에서 임의의 슬립 금지
고루틴이 마침내 완료되기를 기다리는 슬립은 냄새입니다. 그것을 채널, WaitGroup, 콜백, synctest.Wait, 또는 가짜 시간으로 대체하십시오 — 조건을 기다리는 것이 아니라 충분한 시간이 지났기를 바라는 것이 아닌 것입니다.
규칙 2: synctest 테스트를 자립적으로 유지
고루틴, 채널, 컨텍스트, 타이머, 워커를 버블 안에서 생성하십시오. 테스트 사이에 누출되어 synctest를 유용하게 만드는 격리를 깨뜨릴 수 있는 패키지 레벨 공유 상태를 피하십시오.
규칙 3: synctest를 통합 테스트 래퍼로 사용하지 마십시오
테스트가 실제 데이터베이스, 실제 네트워크, 또는 외부 프로세스와 통신한다면, 매우 구체적인 이유가 없는 한 synctest 밖으로 유지하십시오.
규칙 4: 스케줄러의 행운이 아니라 동작을 테스트
목표는 고루틴이 실행되도록 강제하는 것이 아닙니다. 목표는 시스템이 의미 있는 상태에 도달한 후에 관찰 가능한 동작을 검증하는 것이며, synctest.Wait는 타이밍 가정에 의존하지 않고 그것을 가능하게 합니다.
규칙 5: 취소 경로를 명시적으로 유지
모든 백그라운드 고루틴은 셧다운 경로가 있어야 하며, 테스트는 컨텍스트를 취소하거나 채널을 닫고 나서 고루틴이 깔끔하게 종료되는지 확인함으로써 그 경로가 작동함을 증명해야 합니다.
마지막 생각들
testing/synctest는 작아 보이지만 테스트 클래스 작성 방식을 변화시키는 Go 기능 중 하나입니다. 그것은 좋은 동시성 설계, 레이스 탐지기, 또는 통합 테스트의 필요성을 대체하지 않습니다 — 하지만 많은 비동기 단위 테스트를 더 빠르고, 더 깔끔하게, 그리고 타이밍 행운에 덜 의존적이게 만듭니다.
그것이 중요한 이유는 동시성 코드는 이미 충분히 어렵기 때문입니다. 테스트는 불확실성을 줄여야지, 추가해서는 안 됩니다. 통합, 코드 구조, 데이터 액세스에 걸친 프로덕션 Go 패턴의 더 넓은 시각을 제공하면, 프로덕션에서의 앱 아키텍처를 참조하십시오.
실무적 교훈은 간단합니다:
고루틴, 타이머, 타임아웃, 재시도, 및 취소 주위의 결정론적 단위 테스트를 위해 synctest를 사용하십시오.
아주 좋은 이유가 없는 한 실제 슬립을 동시성 테스트에서 멀리 두십시오.
그 하나의 습관은 많은 Go 테스트 스위트를 더 빠르고 불안정하지 않게 만들 것입니다.
중요한 현재 사실은 다음과 같습니다: testing/synctest는 Go 1.25에서 일반적으로 사용 가능하게 되었고, synctest.Test와 synctest.Wait를 노출하며, 격리된 버블 안에서 테스트를 실행하고, 그 버블 안의 시간은 고루틴이 안정적으로 차단될 때만 진행하는 가짜 시계를 사용합니다.
출처
- https://pkg.go.dev/testing/synctest
- https://go.dev/blog/testing-time
- https://go.dev/blog/synctest
- [https://go.dev/blog/testing-time](“Testing Time (and other asynchronicities) - The Go Programming Language”)
- https://go.dev/doc/go1.25
- https://go.dev/blog/go1.25