.NET 8의 TimeProvider와 단 한 줄의 SynchronizationContext 수정만으로, 실제 대기 시간(sleep) 때문에 지연되던 재시도 로직(retry-logic) 유닛 테스트의 시간을 수 초씩 단축할 수 있습니다. 한때 7초가 걸리던 테스트가 이제는 수십 밀리초 만에 완료되며, 이 변경 사항은 프로덕션 환경의 실행 성능에는 아무런 영향을 주지 않습니다.
지연이 문제가 되는 이유
많은 재시도 헬퍼(retry helpers)는 지수 백오프(exponential backoff) 방식을 사용합니다. 예를 들어 1초 대기, 그 다음 2초, 4초와 같은 식입니다. 프로덕션 환경에서는 실패한 엔드포인트를 계속해서 공격하는 것을 방지하는 역할을 합니다. 하지만 테스트 스위트에서는 동일한 코드가 실제 시계(real clock)를 기준으로 Task.Delay를 호출하므로, 테스트 스레드가 실제로 잠들게 됩니다. 이를 수십 개의 테스트에 곱하면, 기능적인 이득 없이도 CI(지속적 통합) 파이프라인의 실행 시간이 몇 분씩 늘어납니다. 이러한 "대기 세금(sleep tax)"은 순전한 시간 낭비입니다.
TimeProvider의 작동 방식
.NET 8에서는 시스템 시계에 대한 추상화 계층인 TimeProvider를 도입했습니다. 현재 시간이 필요하거나 지연이 필요한 클래스는 DateTime.UtcNow나 Task.Delay를 직접 호출하는 대신 TimeProvider 인스턴스를 전달받을 수 있습니다. 프로덕션에서는 실제 시계로 전달되는 TimeProvider.System을 전달합니다. 테스트에서는 FakeTimeProvider를 제공합니다. 이 가짜(fake) 객체를 사용하면 Advance(TimeSpan)를 통해 시간을 수동으로 앞당길 수 있습니다. 내부적으로 Task.Delay는 제공자(provider)를 읽기 때문에, 가짜 시계를 앞으로 이동시키면 대기 중인 모든 지연 작업이 즉시 완료됩니다.
아이디어는 간단합니다. 실제 시계를 제어 가능한 시계로 교체한 다음, 테스트 중인 코드가 재개될 시점까지 시간을 건너뛰는 것입니다.
xUnit SynchronizationContext의 함정
이 개념은 콘솔 앱에서는 잘 작동하지만, xUnit에서는 데드락(deadlock)을 유발했습니다. 재시도 헬퍼가 지연을 기다리는 동안 테스트에서 Advance()를 호출했기 때문입니다. xUnit은 계속되는 작업(continuation)을 캡처하여 테스트 스레드에서 실행하는 자체 SynchronizationContext를 설치합니다. Advance()가 시계를 앞으로 돌렸을 때, 계속되는 작업이 테스트 스레드가 아닌 스레드 풀(thread pool)로 큐에 쌓였습니다. 테스트 스레드는 계속해서 시간을 앞당겼고, 시뮬레이션된 시계는 다음 재시도가 실행되어야 할 시점을 지나쳐 버렸습니다. 새로운 타이머는 미래의 시점으로 설정되었지만, 시계를 다시 움직일 스레드가 남아있지 않아 그 시점에 도달할 수 없었습니다. 결국 테스트는 무한정 대기 상태에 빠졌습니다.
단 한 줄의 해결책
테스트 시작 부분에 단 한 줄을 추가하면 기대했던 동작이 복구됩니다:
SynchronizationContext.SetSynchronizationContext(null);
커스텀 컨텍스트를 해제하면 await의 계속되는 작업이 스레드 풀에서 실행되도록 강제하며, 여기서 가짜 타이머의 콜백이 실행될 수 있습니다. 이 변경을 통해 동일한 테스트 시간이 7초에서 약 36밀리초로 단축되었습니다.
더 넓은 이점들
유휴 대기 시간을 없애는 것 외에도, 가짜 시계를 사용하면 시간대(time-zone)에 민감한 로직을 테스트하기 쉬워집니다. 실제 시계가 자정이 될 때까지 기다리지 않고도 일일 할당량(daily quota)이 정확한 현지 자정에 초기화되는지 확인할 수 있습니다. 동일한 접근 방식은 현재 시간에 따라 분기되는 모든 코드에 적용 가능합니다. 제공자를 주입하고, Thread.Sleep이나 Task.Delay에 대한 숨겨진 호출을 피하면 결정론적(deterministic)이고 빠른 테스트를 얻을 수 있습니다.
주의할 점
- 모든 지연은 주입된
TimeProvider를 통해 전달되어야 합니다. 엉뚱한 곳에서Thread.Sleep이나 직접적인Task.Delay를 호출하면 여전히 실제 시계를 호출하게 되어 지연이 다시 발생합니다. - 의존하고 있는 라이브러리가 내부적으로 제공자를 노출하지 않고
Task.Delay를 호출한다면, 더 침습적인 shim(심) 없이는 타이밍을 제어할 수 없습니다. 이런 경우 이점은 제한적일 수 있습니다. - 숨겨진 대기 시간을 찾아내려면 테스트 폴더에서
Task.Delay와Thread.Sleep을 검색(grep)해 보세요. 이러한 호출을 제공자를 사용하도록 리팩터링하는 것이 가짜 시계의 효과를 유지하는 유일한 방법입니다.
요약
시스템 시계를 TimeProvider로 교체하고 xUnit의 SynchronizationContext를 비활성화하면, 느릿느릿하던 재시도 테스트가 거의 즉각적인 체크로 변합니다. 이를 통해 CI 리소스를 확보하고 시간 의존적인 코드를 더 쉽게 검증할 수 있습니다. 투입되는 노력은 단 몇 줄의 코드뿐이지만, 그 대가는 테스트 실행당 절약되는 수 초의 시간으로 나타납니다.
