본문 바로가기

C#

C# Lazy<T>를 활용한 지연 초기화 패턴

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>는 간단하지만 강력한 패턴입니다. 예외 캐싱, 스레드 안전 모드, 비동기 조합을 이해하면 안정성과 성능을 동시에 달성할 수 있습니다.