synctest를 이용한 Go 동시성 코드 테스트

동시 Go 테스트에서 잠들지 마세요.

Page content

Go의 동시성 코드를 테스트하는 일은 항상 약간의 규율을 필요로 했습니다. 고루틴(Goroutine)은 가볍고, 채널(Channel)은 단순하며, 컨텍스트(Context) 취소는 관례적인(idiomatic) 방식입니다. 실제 Go 서비스에서는 백그라운드 워커와 타이머가 어디에나 존재합니다.

하지만 이를 신뢰할 수 있게 테스트하는 것은 코드를 작성하는 것보다 더 어렵습니다.

Testing concurrent Go code with synctest

우리가 흔히 접하는 나쁜 패턴은 다음과 같습니다.

go doSomething()

time.Sleep(100 * time.Millisecond)

if !done {
	t.Fatal("background work did not finish")
}

이 테스트는 노트북에서는 통과하지만 CI 환경에서는 실패할 수 있습니다. 혹은 6개월간 통과하다가 로드가 높은 런너에서 실패할 수도 있습니다. 또는 누군가 “안전하려면"라고 하며 수면 시간을 100밀리초에서 2초로 늘렸기 때문에 느려질 수도 있습니다.

이것은 좋은 테스트가 아닙니다. 이는 타이머에 대한 도박이며, 테스트 스위트가 커질수록 그 도박은 더 비싼 대가를 치르게 됩니다.

testing/synctest 패키지는 Go 개발자에게 비동식 및 시간 의존성 코드를 테스트하는 더 나은 방법을 제공합니다. 이 패키지는 테스트가 격리된 버블(bubble) 내부에서 실행되도록 하고, 그 버블에 가짜 시계(fake clock)를 제공하며, 버블 내부의 고루틴이 차단(blocked)될 때까지 대기하는 방법을 제공합니다.

그 결과는 단순하지만 강력합니다.

  • 임의의 수면(Sleep) 제거
  • 더 빠른 타임아웃 테스트
  • 더 결정론적인(deterministic) 동시성 테스트
  • 컨텍스트 취소에 대한 더 나은 테스트
  • 백그라운드 고루틴에 대한 더 나은 테스트
  • 덜 불안정한(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 blocked)될 때까지 대기합니다. 이는 추상적으로 들릴 수 있지만, 실용적인 효과는 이해하기 쉽습니다.

synctest.Test(t, func(t *testing.T) {
	time.Sleep(10 * time.Second)
})

이것은 테스트가 실제로 10초를 기다리게 하는 것이 아닙니다. synctest 버블 내부에서는, 버블이 차단되어 시간이 앞으로 나아가기를 기다리는 동안 시간이 즉시 진행될 수 있습니다. 이것이 바로 이 패키지의 핵심 비결입니다.

왜 동시성 Go 테스트는 불안정한가(Flaky)

일반적인 Go 테스트가 처음이라면, Go 단위 테스트: 구조 및 모범 사례에서 테스트 패키지, 테이블 기반 테스트, 목킹 패턴 등 이 기사가 구축하는 기초를 다룰 수 있습니다. 동시성 테스트가 불안정한(Flaky) 이유는 일반적으로 세 가지 중 하나입니다.

첫째, 스케줄러에 의존합니다. 고루틴은 사용자의 머신에서는 즉시 실행될 수 있지만, 나중에 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")
	}
}

이 테스트에는 두 가지 문제가 있습니다.

명확한 문제는 수면(Sleep)입니다. 10밀리초가 적절한 시간인지 보장할 수 없습니다.

덜 명확한 문제는 데이터 경쟁(Data Race)입니다. 테스트는 동기화 없이 하나의 고루틴에서 done을 쓰고 다른 고루틴에서 읽습니다.

이 특정 예시는 채널이나 sync.WaitGroup으로 수정할 수 있으며, 종종 그렇게 해야 합니다. 하지만 테스트 대상 코드가 타이머, 컨텍스트 마감선(Deadline), time.AfterFunc, 백그라운드 워커, 지연된 정리(Cleanup) 등을 사용하는 경우, 테스트는 여전히 어색해질 수 있습니다. 바로 여기서 testing/synctest가 도움이 됩니다.

핵심 아이디어: 테스트를 버블 내부에서 실행하기

synctest 버블은 내부에서 생성된 고루틴을 격리합니다.

다음과 같이 사용하십시오.

func TestSomethingConcurrent(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		// Test concurrent code here.
	})
}

버블 내부에서는 다음과 같습니다.

  • 테스트에 의해 시작된 고루틴은 버블에 속합니다.
  • 타이머와 수면은 가짜 시계를 사용합니다.
  • synctest.Wait는 백그라운드 활동이 안정화될 때까지 대기할 수 있습니다.
  • 테스트는 외부 고루틴, 실제 네트워크 I/O 또는 외부 프로세스에 의존하지 않아야 합니다.

버블은 마법이 아닙니다. 나쁜 동시성 설계를 좋게 만들지는 않습니다. 하지만 시간과 차단 동작이 더 결정론적인 통제된 환경을 테스트에 제공합니다.

테스트에서 time.Sleep의 문제점

테스트에서 실제 time.Sleep은 보통 두 가지 의미 중 하나를 나타냅니다.

I do not know how to wait for the event I actually care about.

또는:

I know what I care about, but the code under test does not expose a clean way to observe it.

두 가지 모두 심각하게 받아들여야 할 설계 신호입니다. 이는 프로덕션 코드가 더 깨끗한 관찰 가능성(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.Canceledcontext.DeadlineExceeded를 올바르게 전파하는 것은 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가 테스트를 즐겁게 만드는 방식입니다.

코드에는 현실적인 타임아웃 값을 유지하면서도 테스트를 빠르게 실행할 수 있습니다.

컨텍스트 취소 테스트

배경 고루틴과 경쟁하지 않고 명시적인 취소를 테스트할 수도 있습니다.

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는 버블 내부의 모든 다른 고루틴이 안정적으로 차단될 때까지 대기합니다.

일반적인 언어로 표현하면 다음과 같습니다.

Wait until the goroutines inside this test have reached a stable blocked point.

이는 테스트가 고루틴을 시작하고 그 고루틴이 완료되었거나 대기 중임을 알아야 할 때 유용합니다.

예를 들어:

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는 단순히 더 나은 수면이 아닙니다. 이는 버블 내부의 동기화 포인트이며, 이 차이는 처음 보는 것보다 더 중요합니다.

수면(Sleep)은 다음과 같습니다.

I hope enough time has passed.

Wait는 다음과 같습니다.

I want the bubble to reach a stable blocked state.

두 번째 방법은 경과된 시간에 대한 추측이 아닌 관찰 가능한 상태를 설명하므로 테스트에 훨씬 더 좋습니다.

synctest 버블의 가짜 시간

synctest 버블 내부에서는 time 패키지가 가짜 시계를 사용합니다.

가짜 시계는 고정된 시간에서 시작합니다. 이는 버블 내부의 모든 고루틴이 안정적으로 차단되어 무언가를 차단 해제하기 위해 시간이 앞으로 나아가야 할 때만 진행됩니다.

즉, 이 테스트는 빠릅니다.

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)
		}
	})
}

이는 1시간을 기다리는 것처럼 보입니다.

하지만 실제로는 그렇지 않습니다.

이는 다음을 테스트하는 데 유용합니다.

  • 타임아웃
  • 마감선
  • 재시도(Retry)
  • 백오프(Backoff)
  • 지연된 정리
  • 속도 제한
  • 타이머
  • 틱커(Ticker)
  • 컨텍스트 취소

하지만 중요한 규칙이 하나 있습니다. 가짜 시간은 버블 내부에서 time 패키지를 사용하는 코드에만 도움이 됩니다.

코드가 외부 시스템, 실제 네트워크 I/O 또는 버블 외부에서 측정된 시간에 의존한다면, synctest는 이를 결정론적으로 만들 수 없습니다.

재시도 루프 테스트

재시도 루프는 느리고 불안정한 테스트의 일반적인 원인입니다.

작은 재시도 헬퍼입니다.

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는 버블의 고루틴이 종료될 때까지 대기하므로 누수된 고루틴을 무시하기 어렵습니다. 백그라운드 고루틴이 결코 종료되지 않으면, 테스트는 조용히 작업을 남기지 않고 실패합니다. 이는 좋은 일입니다.

동시성 코드는 명확한 소유권을 가져야 합니다. 함수가 고루틴을 시작한다면, 이를 멈추기 위한 명시적인 방법이 있거나 영원히 살아남는 것이 허용되는 문서화된 이유가 있어야 합니다. 테스트에서 “영원히"는 거의 허용되지 않습니다.

좋은 패턴은 다음과 같습니다.

ctx, cancel := context.WithCancel(t.Context())
defer cancel()

그런 다음 컨텍스트가 취소되면 고루틴이 멈추도록 합니다.

실무에서 “안정적으로 차단"된다는 의미

공식 문서에서는 “안정적으로 차단(durably blocked)“이라는 용어를 사용합니다.

모든 런타임 세부 사항을 외울 필요는 없지만, 실용적인 의미를 이해해야 합니다.

고루틴은 동일한 synctest 버블 내부의 무언가에 의해서만 차단 해제될 수 있는 방식으로 차단되어 있을 때 안정적으로 차단됩니다.

예시에는 다음이 포함됩니다.

  • 버블 내부에서 생성된 채널에서 수신
  • 버블 내부에서 생성된 채널로 전송
  • 버블과 관련된 sync.WaitGroup 대기
  • time.Sleep으로 수면
  • 특정 타이머 작업 대기

버블 외부의 무언가가 이를 차단 해제할 수 있으므로 안정적으로 차단되지 않는 것들도 있습니다.

예시에는 다음이 포함됩니다.

  • 네트워크 I/O
  • 시스템 호출
  • 외부 프로세스 작업
  • 일부 뮤텍스 대기
  • 버블 외부의 고루틴과의 상호 작용

이것이 synctest 테스트가 자체적으로 포함되며 버블이 볼 수 없는 외부 동기화로부터 자유로워야 하는 이유입니다. 실제 네트워크와 대화하는 통합 테스트의 래퍼로 synctest를 사용하지 마십시오.

synctest가 좋은 곳

testing/synctest는 비동식 동작 주변의 단위 테스트에 특히 좋습니다.

좋은 후보에는 다음이 포함됩니다.

  • 컨텍스트 취소
  • 컨텍스트 타임아웃
  • 재시도 루프
  • 백오프 로직
  • 지연된 정리
  • 타이머 기반 워커
  • 틱커 기반 루프
  • 백그라운드 고루틴
  • 타임아웃 동작
  • 채널 조정
  • time.AfterFunc
  • 고루틴을 위한 결정론적인 대기

가장 좋은 사용 사례는 어려운 부분이 외부 I/O가 아닌 시간 또는 스케줄링인 코드입니다.

synctest가 좋지 않은 곳

testing/synctest는 모든 동시성 테스트를 대체하는 것이 아닙니다.

이는 모든 가능한 경쟁을 위한 완전한 결정론적 스케줄러가 아닙니다.

이는 경쟁 탐지기의 대체제가 아닙니다.

이는 통합 테스트의 대체제가 아닙니다.

이는 실제 네트워크 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 채널 및 WaitGroups

좋은 동기화를 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)를 사용하고, 버블 격리가 적용되지 않는 별도의 통합 테스트에는 실제 종속성을 예약하십시오.

실수 3: 고루틴 누수

테스트가 고루틴을 시작한다면, 명확한 종료 경로가 있는지 확인하십시오. 컨텍스트 취소, 닫힌 채널 또는 명시적인 종료 메서드를 사용하십시오. 결코 멈추지 않는 고루틴은 프로덕션 냄새이자 synctest가 숨기지 않고 드러낼 테스트 냄새입니다.

실수 4: 패키지 수준의 상태에 의존하기

패키지 수준의 채널, 타이머 및 WaitGroup은 미묘한 방식으로 버블 격리를 깨뜨릴 수 있습니다. 모든 테스트 상태를 synctest.Test 함수 내부에서 생성하여 모든 리소스가 버블에 속하고 그 수명 주기가 테스트에 명확하게 스코핑되도록 하는 것을 선호하십시오.

실수 5: 가짜 시간을 실제 시간으로 취급하기

가짜 시간은 성능 측정이 아닌 결정론적 테스트를 위한 것입니다. 1시간을 즉시 진행하는 테스트는 프로덕션의 CPU 비용, 락 경쟁, 메모리 사용량 또는 실제 스케줄링 동작에 대해 유용한 정보를 알려주지 않습니다. 이러한 질문에는 벤치마크 및 부하 테스트를 사용하십시오.

실수 6: 경쟁 탐지기 무시

synctestgo 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.
	})
}

이 패턴은 단순합니다.

  • 버블 내부에서 설정
  • 버블 내부에서 작업 시작
  • 백그라운드 활동이 안정화될 때까지 대기
  • 필요할 때만 가짜 시간 진행
  • 동기화 후 주장(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의 병렬 테이블 기반 테스트는 synctest 접근 방식과 자연스럽게 짝 짓는 t.Parallel() 패턴과 경쟁 상태 함정을 다룹니다.

예시: 이전 및 이후

현실적인 나쁜 테스트입니다.

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: 명확성이 향상되는 곳에서만 반복

패션을 위해 테스트를 변환하지 마십시오. 좋은 synctest 테스트는 대체한 버전보다 측정 가능한 속도가 빠르고, 읽기 쉽고, 덜 불안정해야 합니다. 그렇지 않다면, 변환은 가치가 없었습니다.

제 주관적인 규칙

이를 실용적인 경험 법칙으로 사용하십시오.

규칙 1: 동시성 단위 테스트에서 임의의 수면 금지

고루틴이 아마도 완료되기를 기다리는 수면은 냄새입니다. 이를 채널, WaitGroup, 콜백, synctest.Wait 또는 가짜 시간으로 대체하십시오. 충분한 시간이 지났기를 바라는 대신 조건을 기다리는 것이 무엇인지 중요하지 않습니다.

규칙 2: synctest 테스트를 자체적으로 포함되게 유지

버블 내부에서 고루틴, 채널, 컨텍스트, 타이머 및 워커를 생성하십시오. 테스트 간에 누수되고 synctest를 유용하게 만드는 격리를 깨뜨릴 수 있는 패키지 수준의 공유 상태를 피하십시오.

규칙 3: synctest를 통합 테스트 래퍼로 사용하지 않기

테스트가 실제 데이터베이스, 실제 네트워크 또는 외부 프로세스와 대화한다면, 매우 구체적인 이유가 없는 한 synctest 외부에 유지하십시오.

규칙 4: 스케줄러 운이 아닌 동작 테스트

목표는 고루틴을 실행하도록 강요하는 것이 아닙니다. 시스템이 의미 있는 상태에 도달한 후 관찰 가능한 동작을 검증하는 것이 목표이며, synctest.Wait는 타이밍 가정에 의존하지 않고 이를 가능하게 합니다.

규칙 5: 취소 경로를 명시적으로 유지

모든 백그라운드 고루틴은 종료 경로를 가져야 하며, 테스트는 컨텍스트를 취소하거나 채널을 닫고 고루틴이 깨끗하게 종료되는지 확인하여 그 경로가 작동함을 증명해야 합니다.

최종 생각

testing/synctest는 작아 보이지만 테스트 클래스의 작성 방식을 변화시키는 Go 기능 중 하나입니다. 이는 좋은 동시성 설계, 경쟁 탐지기 또는 통합 테스트의 필요성을 대체하지는 않지만, 많은 비동식 단위 테스트를 더 빠르고, 더 깔끔하며, 타이밍 운에 덜 의존하도록 만듭니다.

이는 동시성 코드가 이미 충분히 어렵기 때문에 중요합니다. 테스트는 불확실성을 줄여야지 추가해서는 안 됩니다. 통합, 코드 구조 및 데이터 접근을 넘어 프로덕션 Go 패턴에 대한 더 넓은 시각을 위해 프로덕션의 앱 아키텍처를 참조하십시오.

실용적인 결론은 단순합니다.

Use synctest for deterministic unit tests around goroutines, timers, timeouts, retries, and cancellation.
Keep real sleeps out of concurrent tests unless you have a very good reason.

이 하나의 습관은 많은 Go 테스트 스위트를 더 빠르고 덜 불안정하게 만들 것입니다.


현재 중요한 사실은 다음과 같습니다: testing/synctest는 Go 1.25에서 일반적으로 사용 가능해졌으며, synctest.Testsynctest.Wait를 노출하고, 테스트를 격리된 버블 내부에서 실행하며, 그 버블 내부의 시간은 고루틴이 안정적으로 차단될 때만 진행하는 가짜 시계를 사용합니다.

출처

구독하기

시스템, 인프라, AI 엔지니어링에 관한 새 글을 받아보세요.