한 줄 요약: C# Lazy 지연 초기화는 Lazy<T>로 비용이 큰 객체를 처음 Value를 읽을 때 한 번만 만드는 방법입니다. 기본 모드(ExecutionAndPublication)는 스레드에 안전하고, 단일 스레드라면 LazyThreadSafetyMode.None으로 잠금 비용을 줄일 수 있습니다.
C# Lazy 지연 초기화란 무엇인가요?
지연 초기화(lazy initialization)는 객체 생성을 실제로 필요한 시점까지 미루는 기법입니다. .NET에서는 System.Lazy<T>가 이를 표준 방식으로 제공하며, 값을 읽는 Value 속성과 생성 여부를 알려주는 IsValueCreated 속성으로 구성됩니다. 앱 시작 속도를 높이거나, 쓰이지 않을 수도 있는 무거운 객체(DB 클라이언트, 큰 캐시, 설정 파일)의 비용을 아끼고 싶을 때 사용합니다.
Lazy<T> 기본 사용법
팩토리 함수(Func<T>)를 생성자에 넘기면, 처음 Value에 접근할 때 딱 한 번 실행되고 결과가 저장됩니다. 이후 호출은 저장된 같은 인스턴스를 돌려줍니다.
using System;
public class ReportService
{
// 생성 시점에는 비용이 큰 작업을 하지 않고, 처음 Value를 읽을 때 한 번만 만든다.
private readonly Lazy<HeavyClient> _client =
new(() =>
{
Console.WriteLine("HeavyClient 생성");
return new HeavyClient();
});
public void Run()
{
Console.WriteLine(_client.IsValueCreated); // False
_client.Value.Send("첫 호출"); // HeavyClient 생성 출력
_client.Value.Send("두 번째 호출"); // 같은 인스턴스 재사용
Console.WriteLine(_client.IsValueCreated); // True
}
}
public class HeavyClient
{
public void Send(string message) => Console.WriteLine($"전송: {message}");
}
팩토리를 생략한 new Lazy<T>()는 T의 public 매개변수 없는 생성자를 호출합니다. 생성자에 인자가 필요하면 반드시 팩토리 람다를 넘겨야 합니다.
Lazy 싱글톤은 어떻게 만드나요?
static 필드에 Lazy<T>를 두고 Instance 속성에서 Value를 반환하면 됩니다. 직접 lock과 double-checked locking을 작성하지 않아도 스레드 안전한 싱글톤이 됩니다.
public sealed class AppSettings
{
private static readonly Lazy<AppSettings> _instance =
new(() => new AppSettings(), LazyThreadSafetyMode.ExecutionAndPublication);
public static AppSettings Instance => _instance.Value;
private AppSettings()
{
// 설정 파일 읽기 같은 무거운 초기화
}
}
LazyThreadSafetyMode 3가지 차이는 무엇인가요?
LazyThreadSafetyMode는 여러 스레드가 동시에 Value를 읽을 때의 동작을 정하는 열거형입니다. 생성자에 bool isThreadSafe를 넘기면 true는 ExecutionAndPublication, false는 None으로 동작합니다.
| 모드 | 동작 | 예외 캐시 | 추천 상황 |
|---|---|---|---|
| None | 스레드 안전성 없음 | 캐시함 | 단일 스레드, 최고 성능 |
| PublicationOnly | 여러 스레드가 동시에 생성 가능, 먼저 끝난 값 하나만 채택 | 캐시 안 함 | 생성이 가볍고 실패 시 재시도가 필요할 때 |
| ExecutionAndPublication (기본값) | 잠금으로 한 스레드만 생성 | 캐시함 | 생성이 한 번만 일어나야 하는 일반적인 경우 |
using System.Threading;
// 여러 스레드가 동시에 만들 수 있지만, 먼저 끝난 값 하나만 채택된다.
var lazy = new Lazy<int>(
() => LoadFromNetwork(),
LazyThreadSafetyMode.PublicationOnly);
// 단일 스레드에서만 쓴다면 잠금 비용이 없는 None
var local = new Lazy<string>(() => "값", LazyThreadSafetyMode.None);
Lazy 초기화에서 예외가 나면 어떻게 되나요?
기본 모드에서는 팩토리가 던진 예외가 캐시되어, 이후 Value를 읽을 때마다 같은 예외가 다시 던져집니다. 팩토리는 다시 실행되지 않습니다. 실패 후 재시도가 필요하면 PublicationOnly를 쓰거나 호출 쪽에서 새 Lazy를 만들어야 합니다.
var lazy = new Lazy<int>(() =>
{
Console.WriteLine("실행");
throw new InvalidOperationException("실패");
});
try { _ = lazy.Value; } catch (InvalidOperationException) { }
try { _ = lazy.Value; } catch (InvalidOperationException) { }
// "실행"은 한 번만 출력되고, 두 번째 호출은 캐시된 예외를 다시 던진다.
LazyInitializer.EnsureInitialized는 언제 쓰나요?
Lazy<T> 객체 자체를 만들고 싶지 않을 때(객체가 매우 많아 래퍼 할당이 부담될 때) System.Threading.LazyInitializer의 정적 메서드로 필드를 직접 지연 초기화할 수 있습니다. 다만 코드가 길어지므로 일반적인 경우에는 Lazy<T>가 더 읽기 쉽습니다.
using System.Threading;
public class Cache
{
private List<string>? _items;
private object? _lock;
private bool _initialized;
public List<string> Items =>
LazyInitializer.EnsureInitialized(
ref _items, ref _initialized, ref _lock, () => LoadItems());
private static List<string> LoadItems() => new() { "a", "b", "c" };
}
비동기 초기화는 Lazy<Task<T>>로 처리합니다
Lazy<T>의 팩토리는 동기 함수라서 await를 직접 쓸 수 없습니다. 대신 Lazy<Task<T>>로 감싸면 로드가 한 번만 시작되고, 여러 호출자가 같은 Task를 기다리게 할 수 있습니다.
public class DataLoader
{
private readonly Lazy<Task<string>> _data =
new(() => Task.Run(async () =>
{
await Task.Delay(500);
return "로드 완료";
}));
// 여러 곳에서 await 해도 로드는 한 번만 시작된다.
public Task<string> GetAsync() => _data.Value;
}
주의할 점
- 팩토리 안에서 같은
Lazy의Value를 다시 읽으면 순환 호출로InvalidOperationException이 발생합니다. - 값 형식이 가볍거나 항상 쓰인다면
Lazy는 오히려 불필요한 래퍼 객체와 호출 비용만 늘립니다. Lazy<T>.Value를 처음 읽는 요청이 느려지므로, 지연 시간이 중요한 경로에서는 시작 시점에 미리Value를 읽어 두는 방법도 고려하세요.- 스레드 안전성은 "생성"에 대한 것일 뿐이며, 생성된 객체 내부 상태의 동기화까지 보장하지 않습니다.
자주 묻는 질문(FAQ)
Q. Lazy<T>는 기본적으로 스레드 안전한가요?
A. 네. 팩토리를 넘기는 기본 생성자들은 ExecutionAndPublication 모드를 사용하므로 여러 스레드가 접근해도 팩토리는 한 번만 실행됩니다.
Q. IsValueCreated를 읽으면 값이 생성되나요?
A. 아니요. IsValueCreated는 생성 여부만 확인하며 초기화를 유발하지 않습니다.
Q. 생성 후 값을 바꾸거나 초기화를 되돌릴 수 있나요?
A. Value는 읽기 전용이고 한 번 만들어지면 되돌릴 수 없습니다. 다시 만들고 싶다면 새 Lazy<T> 인스턴스를 만들어 교체해야 합니다.
Q. 정적 생성자(static readonly 필드)와 무엇이 다른가요?
A. static readonly 필드는 형식이 처음 사용될 때 초기화되어 시점 제어가 어렵지만, Lazy<T>는 Value를 읽는 정확한 시점까지 미룰 수 있고 스레드 모드와 예외 처리를 선택할 수 있습니다.
정리
Lazy<T>는 처음Value를 읽을 때 한 번만 객체를 만듭니다.- 기본값
ExecutionAndPublication은 스레드 안전하며 예외도 캐시합니다. - 단일 스레드는
None, 재시도가 필요하면PublicationOnly를 고려합니다. - 비동기 로드는
Lazy<Task<T>>, 필드 단위 초기화는LazyInitializer를 씁니다.
참고 자료와 관련 글
'C#' 카테고리의 다른 글
| C# DI 서비스 수명: Singleton·Scoped·Transient 차이 (0) | 2026.10.16 |
|---|---|
| C# Options 패턴: IOptions·Snapshot·Monitor 차이 (0) | 2026.10.16 |
| C# 13 params 컬렉션: ReadOnlySpan으로 할당 줄이기 (0) | 2026.10.15 |
| C# ArrayPool 사용법: Rent와 Return으로 GC 부담 줄이기 (0) | 2026.10.14 |
| C# Lock 클래스 사용법: .NET 9 lock 성능과 안전성 높이기 (0) | 2026.10.14 |