Lazy<T>는 값이 실제로 필요할 때까지 객체 생성을 미루는 지연 초기화 도우미입니다. 생성 비용이 큰 리소스, 드물게 쓰이는 서비스, 스레드 안전한 단일 로드 등에 유용합니다.
1. 기본 사용법
Value를 접근할 때 최초 한 번만 생성됩니다. IsValueCreated로 생성 여부를 확인할 수 있습니다.
using System;
using System.Threading;
class HeavyResource
{
public HeavyResource()
{
Console.WriteLine("Constructed");
Thread.Sleep(1000); // 무거운 초기화 시뮬레이션
}
public string Work() => "done";
}
Lazy<HeavyResource> _heavy = new Lazy<HeavyResource>(() => new HeavyResource());
Console.WriteLine(_heavy.IsValueCreated); // False
Console.WriteLine(_heavy.Value.Work()); // 여기서 생성됨
Console.WriteLine(_heavy.IsValueCreated); // True
2. 스레드 안전 모드 이해하기
기본 생성자는 스레드 안전합니다. 필요에 따라 모드를 명시하세요.
using System;
using System.Threading;
int Expensive() { Thread.Sleep(200); return Environment.TickCount; }
int PossiblyFail()
{
if (DateTime.UtcNow.Ticks % 2 == 0) throw new InvalidOperationException("boom");
return 42;
}
var safe = new Lazy<int>(() => Expensive(), LazyThreadSafetyMode.ExecutionAndPublication); // 단일 생성 보장
var retryable = new Lazy<int>(() => PossiblyFail(), LazyThreadSafetyMode.PublicationOnly); // 실패 시 재시도 허용
var notSafe = new Lazy<int>(() => 1, LazyThreadSafetyMode.None); // 단일 스레드 전제
3. 예외 캐싱과 재시도
기본적으로 ValueFactory가 던진 예외는 캐싱되어 이후 접근 시에도 동일 예외가 재던져집니다. 실패 후 재시도하려면 PublicationOnly를 사용합니다.
// 예외 캐싱(기본)
var lazy1 = new Lazy<int>(() => throw new InvalidOperationException("fail"));
try { var v = lazy1.Value; } catch { /* 이후에도 동일 예외 */ }
// 실패 시 다른 스레드가 다시 시도 가능
var lazy2 = new Lazy<int>(() => PossiblyFail(), LazyThreadSafetyMode.PublicationOnly);
4. 싱글턴에 적용
스레드 안전하고 간결한 싱글턴을 만들 수 있습니다.
using System;
public sealed class AppSettings
{
private AppSettings() { }
private static readonly Lazy<AppSettings> _instance =
new Lazy<AppSettings>(() => Load(), LazyThreadSafetyMode.ExecutionAndPublication);
public static AppSettings Instance => _instance.Value;
private static AppSettings Load()
{
// 파일/환경변수/원격 구성 로드
return new AppSettings();
}
}
5. DI/서비스와 함께 지연 주입
기본 DI 컨테이너에서 Lazy<T>를 직접 주입하지 못하는 경우가 있습니다. 이때 IServiceProvider로 감싸서 필요 시 해결합니다.
using System;
using Microsoft.Extensions.DependencyInjection;
public class ReportsController
{
private readonly Lazy<ExpensiveService> _service;
public ReportsController(IServiceProvider sp)
{
_service = new Lazy<ExpensiveService>(() => sp.GetRequiredService<ExpensiveService>());
}
public string Get()
{
// 실제로 필요할 때만 생성됩니다.
return _service.Value.Render();
}
}
public class ExpensiveService { public string Render() => "ok"; }
6. 비동기 지연 초기화: Lazy<Task<T>>
Lazy는 async를 직접 지원하지 않으므로 Task를 감싸서 사용합니다.
using System;
using System.Threading.Tasks;
private static readonly Lazy<Task<string>> _lazyData =
new Lazy<Task<string>>(() => LoadAsync(), LazyThreadSafetyMode.ExecutionAndPublication);
private static async Task<string> LoadAsync()
{
await Task.Delay(500);
return "DATA";
}
public static async Task<string> GetDataAsync()
{
return await _lazyData.Value; // ConfigureAwait 필요 시 추가
}
7. 단일 로드 캐시(캐시 스탬피드 방지)
ConcurrentDictionary와 결합하면 키별 단일 초기화를 보장합니다. 실패 시 항목을 제거해 재시도할 수 있게 합니다.
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
private readonly ConcurrentDictionary<string, Lazy<Task<string>>> _cache = new();
public async Task<string> GetUserAsync(string id)
{
var lazy = _cache.GetOrAdd(id, key =>
new Lazy<Task<string>>(() => LoadUserAsync(key), LazyThreadSafetyMode.ExecutionAndPublication));
try
{
return await lazy.Value;
}
catch
{
_cache.TryRemove(id, out _); // 실패 시 다음 호출이 다시 시도하도록 제거
throw;
}
}
private static async Task<string> LoadUserAsync(string id)
{
await Task.Delay(200);
return $"User:{id}";
}
8. LazyInitializer와의 비교
필드 하나만 간단히 지연 초기화할 때는 LazyInitializer가 더 가볍습니다. 추가 할당 없이 기존 필드를 채웁니다.
using System;
using System.Threading;
private object _sync = new object();
private Expensive? _expensive;
public Expensive Expensive =>
LazyInitializer.EnsureInitialized(ref _expensive, ref _sync, () => new Expensive())!;
public class Expensive { }
9. 흔한 함정과 팁
- 재귀 접근 금지: ValueFactory 내부에서 자신의 Value를 다시 읽으면 InvalidOperationException이 발생합니다.
- 리셋 불가: Lazy<T>는 한 번 초기화되면 값을 바꿀 수 없습니다. 재초기화하려면 Lazy 인스턴스를 교체하세요.
- PublicationOnly 남용 주의: 여러 스레드가 동시에 생성할 수 있어 리소스 낭비가 생길 수 있습니다.
- 비동기 차단 금지: Lazy<Task<T>>에서 .Result/.Wait()는 데드락 위험이 있습니다. 항상 await 하세요.
- 값이 거의 항상 필요한 경우 Lazy 오버헤드가 손해일 수 있습니다. 실제 접근 빈도와 비용을 고려해 선택합니다.
10. 언제 사용하면 좋은가
- 생성 비용이 크고 사용 빈도가 낮은 구성/리소스
- 병렬 환경에서 단 한 번의 안전한 초기화를 보장해야 할 때
- 캐시 스탬피드 방지와 단일 로드를 구현할 때
Lazy<T>는 간단하지만 강력한 패턴입니다. 예외 캐싱, 스레드 안전 모드, 비동기 조합을 이해하면 안정성과 성능을 동시에 달성할 수 있습니다.
'C#' 카테고리의 다른 글
| C# CallerArgumentExpression으로 검증 메시지 자동화 (0) | 2026.07.24 |
|---|---|
| C# nameof 연산자로 리팩터링 안전성 높이기 (0) | 2026.07.24 |
| C# ReaderWriterLockSlim으로 읽기/쓰기 성능 최적화 (0) | 2026.07.23 |
| C# SemaphoreSlim으로 비동기 동시성 제한 구현하기 (0) | 2026.07.22 |
| C# 숫자 서식 지정(Custom Numeric Format) 사용하기 (1) | 2026.07.21 |