본문 바로가기

C#

C# Lazy 지연 초기화 사용법: 스레드 안전 모드와 싱글톤

한 줄 요약: 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를 씁니다.

참고 자료와 관련 글