반응형
아주 좋은 질문이에요! 싱글톤에서 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) 자주 하는 실수와 베스트 프랙티스
흔한 실수
- .Value를 잊고 _instance 자체를 남발
- Lazy<T>는 래퍼예요. 실제 인스턴스는 _instance.Value.
- 예외 캐싱 오해
- 기본 모드에선 첫 실패가 캐시되어 이후에도 같은 예외가 납니다. 재시도 원하면 PublicationOnly를 검토.
- 불필요한 Lazy<T> 남용
- 타입 초기화만으로 충분한 경우(public static readonly T Instance = new T();)엔 Lazy<T>는 오버킬 + 약간의 오버헤드.
- 싱글톤 + 상태 보관 남용
- 싱글톤이 mutable 상태를 많이 가지면 테스트/동시성 이슈가 커집니다. 가능하면 무상태(stateless) 또는 불변 설계를.
- 팩토리에서 외부 캡처
- new Lazy<T>(() => Create(dep))에서 dep이 스코프 밖 객체라면 예상치 못한 수명 연장/순환 참조가 생길 수 있어요. 의존성은 명시적으로 주입하거나 정적 팩토리에서 안전하게 참조하세요.
- 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>가 가장 단순하고 안전합니다.”
반응형