
개요
최근 공통 모듈인 IRestfulApiService를 리팩터링하며, 멀티스레드 환경에서 발생할 수 있는 치명적인 런타임 에러(InvalidOperationException, ArgumentNullException)를 추적하고 해결했습니다.
본 문서에서는 싱글톤으로 관리되는 HttpClient의 전역 상태를 수정할 때 발생하는 사이드 이펙트를 공유하고, 왜 CancellationToken 주입 방식이 현대적 .NET 환경에서 필수적인 설계인지 코드 레벨에서 상세히 다룹니다
코드 설명
- 아래 코드는 HttpRequest(DTO)를 인자로 전달해 HttpClient로 외부 서버와 통신하는 로직입니다.
- 안전하게 리팩터링을 하기 위해 *V2 클래스를 만들었습니다
public class RestfulApiServiceV2(HttpClient httpClient) : IRestfulApiService
{
public async Task<Result<bool>> RequestRestFullApiAsync(HttpRequest httpRequest)
{
// 💩 Timeout setter에서 이슈 발생
httpClient.Timeout = httpRequest.TimeOut ?? TimeSpan.FromMinutes(10);
var responseResult = await httpClient.SendAsync(request);
if (responseResult.IsSuccessStatusCode)
{
return Result.Ok(true);
}
return Result.Fail<bool>();
}
// ..
}
단위 테스트 작성
Spec
- xUnit 테스트 프레임워크
- 검증은 Shouldly 패키지 (Fluent Assertions은 라이센스 이슈)
- MockHttp 패키지 사용하여 HttpClient 테스트
Race Condition을 확인하기 위해 Task 2개를 생성하여 동시에 실행합니다. 이때 Race Condtion을 확인하기 위해 HttpClient 호출시 100ms 지연을 줬습니다.
public class RestfulApiServiceV2Tests
{
private ITestOutputHelper _output;
private readonly MockHttpMessageHandler _mockHttp;
public RestfulApiServiceV2Tests(ITestOutputHelper output)
{
_output = output;
_mockHttp = new MockHttpMessageHandler();
}
[Fact]
public async Task HttpClient_Timeout_Should_Have_RaceCondition_SideEffectTest()
{
// Arrange
var req1 = new HttpRequest { Method = HttpMethod.Post, Url = "https://api.test.com/fail", Content = new StringContent("data1") };
var req2 = new HttpRequest { Method = HttpMethod.Post, Url = "https://api.test.com/fail", Content = new StringContent("data2") };
_mockHttp.When("*").Respond(async () =>
{
await Task.Delay(100); // 📌 HttpClient 호출시 100ms 지연
return new HttpResponseMessage(HttpStatusCode.OK);
});
var sut = new RestfulApiServiceV2(_mockHttp.ToHttpClient());
// Act
var task1 = sut.RequestRestfulApiAsync(req1);
var task2 = sut.RequestRestfulApiAsync(req2);
// Assert
await Task.WhenAll(task1, task2);
}
}
💩 테스트 실패
System.InvalidOperationException : This instance has already started one or more requests. Properties can only be modified before sending the first request.

🔎 원인 분석
아직 task1의 통신이 끝나지 않은 상태에서 task2가 HttpClient (싱글톤 객체)의 Timeout(공유 필드)를 변경하려고 한 게 원인이었습니다.


- HttpClient는 인스턴스가 생성된 후 단 한 번이라도 SendAsync 같은 요청을 시작하면 내부적으로 _operationStarted를 true로 바꿉니다.
- 이 플래그가 세워지는 순간, set_Timeout 호출 시 CheckDisposedOrStarted()에 의해 즉시 예외가 터집니다.
- 즉, 📌싱글톤이나 공유 객체로 HttpClient를 쓰면서 Timeout을 수정하는 것은 프레임워크 수준에서 금지된 설계였던 것입니다.
- 상황
- Thread1 : HttpClient 요청시 100ms 지연 중인 상태 (_operationStarted = true)
- Thread2 접근해서 Timeout setter 호출
- 아직 Thread1이 지연중인 상태(_operationStarted = true)에서 Timeout (공유 속성)을 변경하려고 하니 System.InvalidOperationException 예외 발생
- Thread1 : HttpClient 요청시 100ms 지연 중인 상태 (_operationStarted = true)
✅ 해결
httpClient.Timeout의 setter 호출하는 방식 대신 CancellationTokenSource을 사용하도록 했습니다.
public async Task<Result<bool>> RequestRestfulApiAsync(HttpRequest httpRequest)
{
// Timeout setter 대신 CancellationTokenSource 사용
using var cts = new CancellationTokenSource(httpRequest.TimeOut ?? TimeSpan.FromMinutes(10));
var responseResult = await httpClient.SendAsync(request, cts.Token);
if (responseResult.IsSuccessStatusCode)
{
return Result.Ok(true);
}
return Result.Fail<bool>();
}
결과적으로 예외없이 테스트 통과했습니다.


포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!