45. Lazy<T> 와 LazyThreadSafetyMode 의 내부 동작
난이도 상해설 — Lazy 와 LazyThreadSafetyMode 의 내부 동작
난이도: 상
요약
Lazy<T> 는 "처음 접근할 때 한 번만 초기화"를 제공하지만, 어떤 보장을 주는지는 모드에 따라
완전히 다르다. 기본값(ExecutionAndPublication)은 팩토리 1회 실행을 보장하는 대신 락을
잡으므로 재귀·순환 초기화에서 예외 또는 교착을 만들고, 예외를 영구 캐시한다.
PublicationOnly 는 팩토리를 여러 번 실행할 수 있지만 예외를 캐시하지 않는다. None 은
동기화가 없어 동시 접근 자체가 지원되지 않는다.
핵심 개념
세 가지 모드
| 모드 | 동기화 | 팩토리 실행 횟수 | 예외 캐싱 | 재귀 시 |
|---|---|---|---|---|
ExecutionAndPublication (기본) |
인스턴스별 락(Monitor) | 정확히 1회 | 캐시함 | 같은 스레드 재진입 감지 → 예외 |
PublicationOnly |
Interlocked.CompareExchange 만 |
1회 이상(경쟁 시 여러 번) | 캐시 안 함 | 허용(중복 실행) |
None |
없음 | 단일 스레드 가정 | 캐시함 | 재진입 감지 → 예외 |
생성자 매핑도 자주 묻는다.
new Lazy<T>(),new Lazy<T>(Func<T>)→ExecutionAndPublicationnew Lazy<T>(bool isThreadSafe)→true면ExecutionAndPublication,false면Nonenew Lazy<T>(Func<T>, LazyThreadSafetyMode)→ 지정한 모드
내부 구조
Lazy<T> 는 대략 다음을 들고 있다.
_state: 아직 초기화 안 됨 / 초기화 중 / 완료 / 예외 발생 을 나타내는 필드(구현에 따라LazyHelper객체 참조로 표현). 완료되면 이 필드가null이 되어 이후 접근이 락 없이 필드 읽기 한 번으로 끝난다._factory: 초기화 후null로 지워 팩토리가 캡처한 객체가 GC 되게 한다._value: 결과.
ExecutionAndPublication 은 _state 가 가리키는 객체를 Monitor 로 잠그고, 그 안에서 팩토리를
실행한 뒤 _value 를 쓰고 _state 를 null 로 만든다. 초기화 이후의 Value 접근에는 락이
없다는 점이 중요하다. 즉 락 비용은 최초 1회에만 든다.
PublicationOnly 는 락 없이 팩토리를 실행하고, Interlocked.CompareExchange 로 _value 를
게시한다. 진 스레드가 만든 인스턴스는 버려진다(GC 대상). 그래서 팩토리에 부수효과가 있거나
IDisposable 자원을 만들면 누수가 된다.
단계별 내부 동작 — 시나리오별 예측
A: 기본 모드 (ExecutionAndPublication)
8개 스레드가 동시에 Value 에 접근해도 하나만 락을 잡고 팩토리를 실행하며, 나머지는 대기 후
같은 인스턴스를 받는다.
A: factoryCalls=1, allSame=True
B: PublicationOnly
락이 없으므로 여러 스레드가 동시에 팩토리에 진입한다. Config 생성자에 Thread.Sleep(50) 이
있어 경쟁 창이 넓으므로 대부분의 실행에서 8번 가까이 호출된다. 그러나 게시는
CompareExchange 로 하므로 모두 같은 인스턴스를 받는다.
B: factoryCalls=8(비결정적, 1~8), allSame=True
Config.Serial 은 1~8 중 하나이며 실행마다 달라진다. 나머지 7개 인스턴스는 그냥 버려진다.
"팩토리는 한 번만 실행된다"고 믿고 여기서 커넥션을 열거나 파일을 만들면 자원이 샌다.
C: None
동기화가 전혀 없다. Parallel.For 로 동시에 접근하는 것은 문서상 지원되지 않는 사용이다.
여기서 실제로 지배적인 결과는 "중복 호출"이 아니라 예외다.
Lazy<T> 의 내부 구현(ViaFactory)은 팩토리를 호출하기 직전에 _factory 를 null 로
지운다(초기화 후 클로저를 놓아주기 위한 처리). 그리고 진입 시 _factory == null 이면
재귀 초기화로 간주해 InvalidOperationException("ValueFactory attempted to access the Value
property of this instance")을 던진다. None 모드는 이 검사를 락 없이 하므로, 첫 스레드가
팩토리를 실행하는 동안(여기서는 Thread.Sleep(50) 때문에 창이 50ms나 된다) 뒤따라 들어온
스레드들은 거의 전부 이 예외를 맞는다.
C: InvalidOperationException, factoryCalls=1
Parallel.For 는 작업자 예외를 AggregateException 으로 감싸 던지므로, Touch(C) 를
try/catch 로 감싸지 않으면 거기서 프로그램이 끝나고 D·E 는 출력되지 않는다.
더 나쁜 것은 None 모드가 이 예외를 캐시한다는 점이다. 한 번 이렇게 오염되면 이후
단일 스레드에서 접근해도 영원히 같은 예외가 나온다. 즉 None 은 "운이 나쁘면 중복 생성"
정도가 아니라 동시 접근 한 번으로 인스턴스가 영구 사망할 수 있다.
정말로 단일 스레드에서만 쓰인다는 확신이 있을 때만 선택해야 한다.
D: 순환 초기화
D1.Value → 팩토리가 D2.Value 호출 → D2 의 팩토리가 다시 D1.Value 호출.
D1 은 아직 초기화 중이며, 같은 스레드가 D1.Value 에 재진입한다. Monitor 는 재진입
가능하므로 락 자체는 통과하지만, Lazy<T> 는 이 상황을 명시적으로 감지해
InvalidOperationException 을 던진다.
D: InvalidOperationException
메시지는 "ValueFactory attempted to access the Value property of this instance" 다.
여기서 문제가 묻는 "교착"은 다른 상황에서 발생한다. 스레드 T1 이 D1.Value 를, T2 가
D2.Value 를 동시에 시작하면
- T1 은
D1락 보유 →D2락 대기 - T2 는
D2락 보유 →D1락 대기
전형적인 ABBA 데드락이며, 이때는 재진입 감지가 작동하지 않는다(서로 다른 스레드이므로). 정리하면 같은 스레드 순환 → 예외로 감지, 서로 다른 스레드 순환 → 진짜 교착이다.
PublicationOnly 모드였다면 락이 없으므로 교착은 없다. 대신 팩토리가 무한 재귀에 빠져
StackOverflowException 으로 프로세스가 죽거나, 운 좋게 한쪽이 먼저 게시되면 통과한다.
이 결함은 정적 생성자(beforefieldinit)의 순환 초기화 교착과 같은 구조다. 게임서버에서
Lazy 로 만든 전역 설정들이 서로를 참조할 때 실제로 발생한다.
E: 예외 캐싱
기본 모드는 팩토리가 던진 예외를 캐시하고, 이후 모든 Value 접근에서 같은 예외
인스턴스를 다시 던진다. 팩토리는 두 번 다시 호출되지 않는다.
E0: InvalidOperationException attempts=1
E1: InvalidOperationException attempts=1
E2: InvalidOperationException attempts=1
E3: InvalidOperationException attempts=1
문제에 _eAttempts <= 2 면 던지도록 써 두었지만 3번째 호출은 영영 오지 않는다.
attempts 는 1에서 멈춘다.
PublicationOnly 였다면 예외를 캐시하지 않으므로 접근할 때마다 재시도한다.
E0: InvalidOperationException attempts=1
E1: InvalidOperationException attempts=2
E2: serial=N
E3: serial=N (E2 와 같은 값 — 한 번 게시되면 그 인스턴스가 고정된다)
N 은 1이 아니다. Config.FactoryCalls 는 Touch 안에서 리셋된 뒤 A·B·C 시나리오의 팩토리
호출만큼 올라가 있으므로, serial 은 그 누적값 다음 번호이며 실행마다 달라진다.
이 차이가 실무에서 가장 중요하다. 초기화가 실패할 수 있는 자원(DB 커넥션, 원격 설정 조회,
파일 로드)에 기본 모드 Lazy<T> 를 쓰면, 일시적 네트워크 오류 한 번으로 프로세스가 죽을
때까지 그 자원을 영영 못 쓴다. 재시작 외에는 복구 방법이 없다.
흔한 오해·함정
- "
Lazy<T>는 팩토리를 반드시 한 번만 부른다" —PublicationOnly와None에서는 아니다. - "
Lazy<T>는 스레드 안전하다" — 기본 생성자는 그렇지만new Lazy<T>(f, false)나LazyThreadSafetyMode.None은 아니다. Lazy<Task<T>>로 비동기 초기화 — 흔한 패턴이지만 주의가 필요하다. 팩토리 안에서.Result/.Wait()를 부르면 스레드풀 기아·교착 위험이 있고,Task가 실패하면 실패한Task가 캐시되어 재시도가 불가능하다. 재시도가 필요하면SemaphoreSlim+ 명시적 캐시나AsyncLazy구현을 쓴다.IsValueCreated로 분기 — 확인과 접근 사이에 다른 스레드가 초기화할 수 있어 check-then-act 다. 부수효과 있는 분기에 쓰면 안 된다.LazyInitializer.EnsureInitialized—Lazy<T>객체 할당조차 아끼고 싶을 때 쓰는 정적 헬퍼. 동작은PublicationOnly에 가깝다(경쟁 시 중복 생성 가능).- 팩토리가 캡처한 것은 초기화 후 해제된다 —
_factory = null로 지우므로 클로저가 잡고 있던 큰 객체가 GC 된다. 반대로 초기화 전까지는 계속 살아 있다.
면접 포인트
- 세 모드의 동기화 전략 차이와, 각각이 보장하는 것/보장하지 않는 것. 왜
PublicationOnly는 "게시만" 보장하는가. - 예외 캐싱이 기본 동작인 이유(초기화가 결정론적이라는 가정)와, 그 가정이 깨지는 실무 상황 (원격 의존성). 어떻게 우회하는가.
Lazy<T>순환 참조에서 단일 스레드는 예외, 다중 스레드는 교착이 되는 이유. 정적 생성자 순환 초기화 교착과 어떤 점이 같고 다른가.- 초기화 이후의
Value접근이 락 없이 필드 읽기로 끝나는 구조. 이중 검사 잠금(double-checked locking)을 직접 구현할 때volatile이 필요한 이유와Lazy<T>가 그것을 대신해 주는 방식.