C# AsyncLocal로 비동기 흐름에서 컨텍스트 유지하기
비동기 코드를 작성하다 보면 이런 고민이 생긴다. "요청별로 고유한 값을 어디서든 접근하고 싶은데, 어떻게 해야 하지?"
HttpContext에 넣자니 서비스 계층까지 전달하기 번거롭고, 매개변수로 일일이 넘기자니 시그니처가 복잡해진다. 이럴 때 AsyncLocal<T>이 해답이 될 수 있다.
1. AsyncLocal이란?
AsyncLocal<T>은 .NET에서 비동기 호출 흐름(ExecutionContext) 단위로 값을 유지하는 저장소다.
쉽게 말하면:
- 같은 비동기 호출 체인에서 어디서든 꺼내 쓸 수 있는 "작은 전역 변수"
- 하지만 스레드 전역(ThreadLocal)이 아니라 비동기 흐름 전역
ThreadLocal vs AsyncLocal
| 특성 | ThreadLocal | AsyncLocal |
|---|---|---|
| 범위 | 스레드 단위 | 비동기 흐름 단위 |
| await 이후 | 스레드가 바뀌면 값 소실 | 값 유지 |
| Task.Run | 새 스레드면 값 소실 | 기본적으로 값 전파 |
// ThreadLocal - await 이후 문제 발생 가능
ThreadLocal<string> _threadLocal = new();
_threadLocal.Value = "test";
await Task.Delay(100);
// 스레드가 바뀌면 _threadLocal.Value가 null일 수 있음
// AsyncLocal - await 이후에도 유지
AsyncLocal<string> _asyncLocal = new();
_asyncLocal.Value = "test";
await Task.Delay(100);
// 스레드가 바뀌어도 _asyncLocal.Value는 "test"2. 기본 사용법
컨텍스트 홀더 정의
public static class CorrelationContext
{
public static readonly AsyncLocal<string?> CorrelationId = new();
}값 설정 및 읽기
// 호출 흐름 시작 시 설정
CorrelationContext.CorrelationId.Value = "REQ-12345";
// 어디서든 읽기
Console.WriteLine(CorrelationContext.CorrelationId.Value); // "REQ-12345"
// 호출 흐름 종료 시 정리
CorrelationContext.CorrelationId.Value = null;3. 동작 원리 이해하기
await 이후에도 값 유지
CorrelationContext.CorrelationId.Value = "REQ-A";
Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}");
await Task.Delay(100);
// 스레드가 바뀌었을 수 있지만 값은 유지
Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}");
Console.WriteLine(CorrelationContext.CorrelationId.Value); // "REQ-A"Task.Run에서도 전파
CorrelationContext.CorrelationId.Value = "REQ-A";
await Task.Run(() =>
{
// 다른 스레드지만 값이 전파됨
Console.WriteLine(CorrelationContext.CorrelationId.Value); // "REQ-A"
});전파 차단하기
ExecutionContext.SuppressFlow()로 전파를 막을 수 있다:
CorrelationContext.CorrelationId.Value = "REQ-A";
using (ExecutionContext.SuppressFlow())
{
await Task.Run(() =>
{
// 전파가 차단되어 null
Console.WriteLine(CorrelationContext.CorrelationId.Value); // null
});
}
// 블록 밖에서는 정상
Console.WriteLine(CorrelationContext.CorrelationId.Value); // "REQ-A"4. 실전 예시: ASP.NET Core 미들웨어
컨텍스트 홀더
public static class RequestContext
{
public static readonly AsyncLocal<string?> CorrelationId = new();
public static readonly AsyncLocal<long?> UserId = new();
}미들웨어에서 설정
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next) => _next = next;
public async Task Invoke(HttpContext context)
{
// 요청 시작 시 설정
var correlationId = context.TraceIdentifier ?? Guid.NewGuid().ToString("N");
RequestContext.CorrelationId.Value = correlationId;
try
{
// 응답 헤더에도 추가
context.Response.Headers["X-Correlation-Id"] = correlationId;
await _next(context);
}
finally
{
// 반드시 정리!
RequestContext.CorrelationId.Value = null;
}
}
}미들웨어 등록
var app = builder.Build();
app.UseMiddleware<CorrelationIdMiddleware>();서비스에서 활용
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger) => _logger = logger;
public async Task PlaceOrderAsync(OrderRequest request)
{
// 미들웨어에서 설정한 값을 서비스에서 바로 사용
_logger.LogInformation(
"Placing order, CorrelationId={Cid}",
RequestContext.CorrelationId.Value);
await ProcessOrderAsync(request);
}
private async Task ProcessOrderAsync(OrderRequest request)
{
// 내부 메서드에서도 동일한 값 접근
_logger.LogInformation(
"Processing order, CorrelationId={Cid}",
RequestContext.CorrelationId.Value);
await Task.Delay(100);
}
}5. 로깅과 함께 사용하기
로그 스코프 활용
public async Task PlaceOrderAsync(OrderRequest request)
{
using (_logger.BeginScope(new Dictionary<string, object>
{
["CorrelationId"] = RequestContext.CorrelationId.Value ?? "N/A"
}))
{
_logger.LogInformation("Order received");
await ProcessOrderAsync(request);
_logger.LogInformation("Order completed");
}
}Serilog와 함께
// Serilog 설정에서 Enricher 추가
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.Enrich.With<CorrelationIdEnricher>()
.CreateLogger();
public class CorrelationIdEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var correlationId = RequestContext.CorrelationId.Value ?? "N/A";
logEvent.AddPropertyIfAbsent(
propertyFactory.CreateProperty("CorrelationId", correlationId));
}
}6. 병렬 요청 테스트
AsyncLocal이 요청간에 섞이지 않는지 테스트해보자:
static async Task Main()
{
// 두 요청을 동시에 처리
var taskA = HandleRequestAsync("REQ-A");
var taskB = HandleRequestAsync("REQ-B");
await Task.WhenAll(taskA, taskB);
}
static async Task HandleRequestAsync(string id)
{
CorrelationContext.CorrelationId.Value = id;
try
{
Console.WriteLine($"[{id}] Start on thread {Thread.CurrentThread.ManagedThreadId}");
await Task.Delay(50);
Console.WriteLine($"[{CorrelationContext.CorrelationId.Value}] Step 1");
await Task.Run(() =>
{
Console.WriteLine($"[{CorrelationContext.CorrelationId.Value}] Inside Task.Run");
});
await Task.Delay(50);
Console.WriteLine($"[{CorrelationContext.CorrelationId.Value}] Step 2");
}
finally
{
CorrelationContext.CorrelationId.Value = null;
}
}출력:
[REQ-A] Start on thread 1
[REQ-B] Start on thread 1
[REQ-A] Step 1
[REQ-B] Step 1
[REQ-A] Inside Task.Run
[REQ-B] Inside Task.Run
[REQ-A] Step 2
[REQ-B] Step 2
각 요청이 자신의 CorrelationId를 유지하는 것을 확인할 수 있다.
7. 주의사항
반드시 정리하기
try
{
RequestContext.CorrelationId.Value = "value";
// 작업 수행
}
finally
{
// 누수 방지
RequestContext.CorrelationId.Value = null;
}정리하지 않으면 다음 요청에 값이 남아있을 수 있다.
큰 객체 넣지 않기
// 나쁜 예 - 큰 객체
AsyncLocal<List<LargeObject>> _data = new();
// 좋은 예 - 작은 메타데이터
AsyncLocal<string?> _correlationId = new();AsyncLocal은 메타데이터용이다. 캐시처럼 큰 객체를 넣으면 GC와 성능에 악영향.
불변 값 사용 권장
// 나쁜 예 - mutable 객체
public class MutableContext { public int Counter; }
AsyncLocal<MutableContext> _context = new();
// 좋은 예 - immutable 값
AsyncLocal<string?> _correlationId = new();8. HttpContext.Items vs AsyncLocal
| 특성 | HttpContext.Items | AsyncLocal |
|---|---|---|
| 의존성 | ASP.NET Core 필요 | 프레임워크 독립적 |
| 범위 | HTTP 요청 한정 | 모든 비동기 흐름 |
| 접근성 | IHttpContextAccessor 필요 | 정적 접근 가능 |
| 용도 | 웹 요청 내 데이터 공유 | 범용 컨텍스트 전파 |
선택 기준:
- ASP.NET Core 웹 앱에서 HTTP 요청 한정 →
HttpContext.Items - 백그라운드 작업, 콘솔 앱, 라이브러리 →
AsyncLocal
마무리
AsyncLocal<T>은 비동기 호출 흐름에서 컨텍스트를 유지하는 강력한 도구다.
정리하면:
- 비동기 흐름 단위로 값 유지 (스레드 아님)
- await, Task.Run 이후에도 값 전파
- finally에서 반드시 정리 (누수 방지)
- 작은 메타데이터를 저장 (CorrelationId, UserId 등)
로깅, 트레이싱, 요청별 컨텍스트 관리에 쓸 만하다. 다만 남용하면 "숨겨진 의존성"이 되니까, 필요한 최소한의 데이터를 저장하자.
Loading comments...