본문 바로가기
카테고리 없음

“싱글톤의 안전한 지연 생성: Lazy<T>는 락 없이 ‘처음 한 번만’ 만들어준다”

by 공부봇 2025. 10. 10.
반응형

아주 좋은 질문이에요! 싱글톤에서 Lazy<T>를 쓰는 건 “정말 필요해질 때까지 객체 생성을 미루고, 그 순간을 안전하게(스레드-세이프) 한 번만” 하려는 목적입니다. 꼭 써야만 하는 건 아니지만, 락/이중 체크 같은 보일러플레이트를 줄이고 예외/경합까지 깔끔히 처리해 주는 장점이 있어요. 아래 교재식으로 정리해 드릴게요. (가정: C# 12 / .NET 8)


1) 왜 생겼는가 — “생성 시점을 늦추고, 동시에 단 한 번만”

  • 게으른 초기화(lazy init) 필요: 무거운 싱글톤을 처음 필요할 때 만들고 싶음.
  • 스레드 안전성: 여러 스레드가 동시에 접근해도 객체가 하나만 만들어져야 함.
  • 보일러플레이트 감소: lock + double-checked locking은 구현이 번거롭고 실수 여지가 있음 → 표준 구성요소로 캡슐화한 것이 Lazy<T>.

2) 무엇인가 — Lazy<T>의 동작과 옵션

  • 기본 개념: new Lazy<T>(() => new T())로 지연 생성 래퍼를 만들고, 최초 Value 접근 시 팩토리를 실행해 인스턴스를 생성.
  • 스레드 안전 모드 (LazyThreadSafetyMode):
    • ExecutionAndPublication(기본): 단 한 스레드만 팩토리 실행, 예외 캐시됨(계속 같은 예외를 던짐).
    • PublicationOnly: 여러 스레드가 시도 가능하지만 승자만 게시, 예외 캐시 안 함(다음 접근에서 재시도).
    • None: 스레드 안전 보장 없음(단일 스레드 환경에서만).
  • 예외 처리: 팩토리에서 예외가 나면 위 규칙대로 예외/성공 결과를 캐시해 반복 실행을 막거나(ExecutionAndPublication) 재시도(PublicationOnly)합니다.

3) 언제/어떻게 쓰는가 — 싱글톤 구현 패턴과 MWE

3-1. 가장 간단한 Lazy<T> 싱글톤

public sealed class MyService
{
    private MyService() { /* 무거운 초기화 */ }

    private static readonly Lazy<MyService> _instance =
        new Lazy<MyService>(() => new MyService(), LazyThreadSafetyMode.ExecutionAndPublication);

    public static MyService Instance => _instance.Value; // 최초 접근 시 생성
}
  • 장점: 락/이중체크 없이 깔끔, 스레드-세이프, 필요할 때까지 생성 지연.

3-2. 가벼운 타입이라면? — 정적 초기화(대안)

public sealed class MyLightService
{
    private MyLightService() { }

    // CLR의 타입 초기화는 첫 사용 시 1회, 스레드-세이프하게 수행됨
    public static readonly MyLightService Instance = new MyLightService();
}
  • 특징: 사실상 “타입이 처음 참조될 때” 초기화되므로 게으른 초기화 + 스레드-세이프를 이미 충족.
    무거운 작업이 아니라면 이게 제일 단순하고 빠릅니다.

3-3. Double-Checked Locking (권장 X: 보일러플레이트 多)

public sealed class MyService2
{
    private static MyService2? _instance;
    private static readonly object _sync = new();

    private MyService2() {}

    public static MyService2 Instance
    {
        get
        {
            if (_instance is null)
            {
                lock (_sync)
                {
                    _instance ??= new MyService2();
                }
            }
            return _instance;
        }
    }
}
  • 동작은 하지만, 락/가시성 관련 실수를 유발하기 쉬워 요즘은 Lazy<T>나 정적 초기화를 더 권장합니다.

3-4. DI 컨테이너 사용 시 (ASP.NET Core 등)

  • 보통은 DI 컨테이너의 수명 관리(예: AddSingleton<T>())가 더 낫습니다.
    프레임워크가 생성 시점·스레드 안전·처분(Dispose) 까지 책임지므로 직접 싱글톤 패턴을 구현할 이유가 줄어듭니다.

4) 자주 하는 실수와 베스트 프랙티스

흔한 실수

  1. .Value를 잊고 _instance 자체를 남발
    • Lazy<T>는 래퍼예요. 실제 인스턴스는 _instance.Value.
  2. 예외 캐싱 오해
    • 기본 모드에선 첫 실패가 캐시되어 이후에도 같은 예외가 납니다. 재시도 원하면 PublicationOnly를 검토.
  3. 불필요한 Lazy<T> 남용
    • 타입 초기화만으로 충분한 경우(public static readonly T Instance = new T();)엔 Lazy<T>는 오버킬 + 약간의 오버헤드.
  4. 싱글톤 + 상태 보관 남용
    • 싱글톤이 mutable 상태를 많이 가지면 테스트/동시성 이슈가 커집니다. 가능하면 무상태(stateless) 또는 불변 설계를.
  5. 팩토리에서 외부 캡처
    • new Lazy<T>(() => Create(dep))에서 dep이 스코프 밖 객체라면 예상치 못한 수명 연장/순환 참조가 생길 수 있어요. 의존성은 명시적으로 주입하거나 정적 팩토리에서 안전하게 참조하세요.
  6. async 초기화 혼동
    • Lazy<T>는 동기 팩토리만. 비동기 초기화가 필요하면 Lazy<Task<T>> 패턴(또는 AsyncLazy)을 쓰고, 소비자는 await Singleton.Value가 아니라 await SingletonTask.Value처럼 처리하세요.

베스트 프랙티스

  • 무거운 초기화 + 다중 스레드 접근 → Lazy<T> 권장.
  • 가벼운 초기화/단순 접근 → 정적 필드 초기화로 충분.
  • 재시도 필요 여부로 ExecutionAndPublication vs PublicationOnly 선택.
  • DI 선호: 앱 전반에선 싱글톤을 DI 컨테이너로 구성하고, “진짜 전역”이 필요할 때만 패턴 사용.
  • 불변 설계: 싱글톤이 노출하는 상태는 가급적 불변/스레드-세이프한 구조 사용.

미니 종합 예제 (비동기 초기화가 필요한 서비스)

public sealed class HeavyClient
{
    private HeavyClient() { /* 연결/버퍼 등 */ }

    // Lazy<Task<HeavyClient>>로 비동기 초기화 지연
    private static readonly Lazy<Task<HeavyClient>> _instanceTask =
        new(() => CreateAsync(), LazyThreadSafetyMode.ExecutionAndPublication);

    public static Task<HeavyClient> InstanceAsync => _instanceTask.Value;

    private static async Task<HeavyClient> CreateAsync()
    {
        var c = new HeavyClient();
        await Task.Delay(100); // 예: 핸드셰이크
        return c;
    }
}

// 사용
var client = await HeavyClient.InstanceAsync;

질문 요지에 대한 한 줄 결론

  • 왜 Lazy를 쓰나요?
    → “처음 쓸 때 딱 한 번만, 스레드-세이프하게 만들고 싶어서.”
  • 안 써도 되나요?
    → “네. 정적 초기화DI 컨테이너로 충분한 경우가 많습니다. 다만 무거운 초기화·경합·예외 처리까지 깔끔히 다루려면 Lazy<T>가 가장 단순하고 안전합니다.”

 

 

반응형