29. ConcurrentDictionary.GetOrAdd의 팩토리 중복 호출
난이도 최상내 리뷰 · C#
해설 · C#
해설 — ConcurrentDictionary.GetOrAdd의 팩토리 중복 호출
난이도: 최상
답변 프레임워크: 요약 → 핵심 개념 → 단계별 내부 동작 → 흔한 오해·함정 → 면접 포인트
요약
ConcurrentDictionary 는 "딕셔너리 자체의 내부 상태(버킷/노드 연결)"만 스레드 안전하게
지켜줄 뿐, GetOrAdd 에 넘긴 factory 델리게이트 호출까지 원자적으로 만들어주지는 않는다.
여러 스레드가 같은 키로 동시에 GetOrAdd 를 호출하면, 그 키가 아직 없다고 각자 판단한 스레드
여러 개가 factory 를 각각 실행할 수 있다. 다만 딕셔너리에 실제로 "들어가는" 값은 그중
단 하나로 결정되며(경쟁에서 이긴 값만 저장), 다른 스레드들이 만든 값은 버려진다.
그 결과 모든 호출자가 GetOrCreate 에서 최종적으로는 같은 인스턴스를 반환받지만, factory
는 스레드 수만큼(최대) 여러 번 실행되었을 수 있다.
출력 예측
실행할 때마다 타이밍에 따라 FactoryCallCount 의 정확한 값(1~8 사이 어느 값)은 달라질 수
있지만, 아래 두 속성은 항상 성립한다:
factory 호출 횟수 >= 1: True
모든 스레드가 동일 인스턴스를 참조: True
딕셔너리 최종 원소 개수: 1 (키가 하나이므로)
FactoryCallCount는 1(운 좋게 경쟁이 거의 없었던 경우) 이상, 최대 8(스레드 수)까지 나올 수 있다 — 비결정적이다. 정확한 숫자를 맞히는 게 핵심이 아니라 "1보다 클 수 있다"는 점, 그리고 "몇 번 불렸든 최종적으로 딕셔너리에 남는 값·모든 호출자가 받는 값은 하나로 수렴한다"는 점이 핵심이다.
핵심 개념
- 딕셔너리의 원자성 ≠ 델리게이트의 원자성:
GetOrAdd가 보장하는 것은 "이 키에 대해 최종적으로 딕셔너리에 저장되는 값은 하나이고, 모든 호출자가 그 하나를 돌려받는다"는 결과의 일관성이지, "factory 가 정확히 한 번만 실행된다"는 부수효과의 유일성이 아니다. - 락-프리(lock-free) 낙관적 설계:
ConcurrentDictionary는 전체 딕셔너리를 잠그고 factory 를 실행하는 대신, 락 없이(또는 버킷 단위의 짧은 락으로) factory 를 먼저 실행한 뒤 그 결과를 삽입 시도하고, 이미 다른 스레드가 먼저 넣었으면 자신이 만든 값을 버리고 기존 값을 반환하는 낙관적 동시성 전략을 쓴다. 이는 "긴 락 구간 안에서 임의의(느릴 수도 있는) 사용자 코드를 실행"하는 것을 피해 락 경합과 데드락 위험을 줄이기 위한 설계 선택이다. - "승자 하나, 패자는 폐기": 여러 스레드가 동시에 factory 를 실행해도, 딕셔너리 내부의
삽입 시도는 (CAS 기반 등으로) 원자적으로 조정되어 정확히 하나의 값만 채택된다. 이
채택된 값이 이후 모든
GetOrAdd/TryGetValue호출에서 일관되게 반환된다.
단계별 내부 동작
- 8개 스레드가
Barrier로 동기화되어 거의 동시에GetOrCreate(100)→GetOrAdd(100, factory)를 호출한다. - 각 스레드는 내부적으로 해당 버킷을 확인해 키가 아직 없다고 판단하면(다른 스레드가
아직 못 넣은 상태), 자신의
factory를 실행해 새PlayerSession인스턴스를 만든다. 이 순간 여러 스레드가 동시에 "아직 없다"고 판단하면 여러 개의 서로 다른PlayerSession인스턴스(서로 다른InstanceTag)가 각자 만들어질 수 있다. - 각 스레드는 자신이 만든 값을 딕셔너리에 삽입 시도한다. 내부 동기화 메커니즘(버킷 락 또는 CAS)이 오직 한 스레드의 삽입만 성공시키고, 나머지는 "이미 다른 값이 들어가 있음"을 감지한다.
- 삽입에 실패한(늦은) 스레드들은 자신이 만든
PlayerSession인스턴스를 버리고, 딕셔너리에 이미 들어간(승자의) 값을 대신 반환한다. - 결과적으로
GetOrCreate를 호출한 8개 스레드 모두 동일한 하나의PlayerSession인스턴스를 돌려받지만(allSame == true),Interlocked.Increment(ref _factoryCallCount)는 "패자"들의 factory 실행에서도 이미 호출되어버렸기 때문에 호출 횟수가 1보다 클 수 있다.
흔한 오해·함정
- "ConcurrentDictionary 니까 GetOrAdd 안의 코드도 원자적으로 한 번만 실행된다" ✗ — 이것이 이 문제의 핵심 함정이다. .NET 공식 문서도 "factory 는 여러 번 호출될 수 있다"고 명시한다. 팩토리는 부수효과가 없는(idempotent) 순수 함수여야 안전하다.
- "팩토리가 리소스(소켓, 파일 핸들, DB 커넥션)를 여는 코드라면?" — 매우 위험하다. 경쟁에서 진 스레드가 만든 값도 factory 실행 도중 리소스를 이미 열어버렸을 수 있고, 그 값은 버려지므로 그 리소스는 아무도 정리하지 않는 채로 누수된다. 게임서버에서 세션 생성 factory 안에 소켓 초기화나 DB 커넥션 오픈을 넣으면 동시 접속 폭주 시 커넥션이 새는 원인이 된다.
- 해결책 1 —
Lazy<T>로 감싸기:ConcurrentDictionary<TKey, Lazy<TValue>>를 쓰고GetOrAdd(key, id => new Lazy<TValue>(() => CreateExpensive(id)))형태로 하면,Lazy<T>자체의 생성(가벼움)만 여러 번 될 뿐, 실제 비싼 생성 로직은.Value접근 시 스레드 안전하게 단 한 번만 실행되도록 만들 수 있다(단,LazyThreadSafetyMode설정에 유의). - 해결책 2 —
GetOrAdd(key, valueFactory, factoryArgument)오버로드나 사후 정리: 비싼 자원을 factory 안에서 직접 만들지 말고, "패자가 됐을 때 정리(dispose)하는 코드"를 추가로 작성하거나, 애초에 가벼운 placeholder 를 넣고 실제 초기화는 나중에 단일 스레드에서 하는 패턴으로 설계를 바꾼다. - "카운터/로그 부수효과는 사소하니 괜찮다" ✗ — 통계·로그 목적이라도 "생성 횟수"를 세는 카운터가 실제 생성 성공 횟수보다 부풀려져 모니터링 지표가 왜곡될 수 있다.
면접 포인트 (예상 질문)
ConcurrentDictionary.GetOrAdd가 스레드 안전함에도 factory 델리게이트가 여러 번 호출될 수 있는 이유는? 이것이 설계 결함이 아니라 의도된 트레이드오프인 이유는?- factory 안에서 리소스를 할당하는 코드를 작성하면 왜 위험한가?
Lazy<T>로 감싸면 무엇이 달라지는가? - 게임서버에서 플레이어 세션을 지연 생성(lazy creation)할 때, 동시 접속 상황에서 안전하게 "정확히 한 번만" 비싼 초기화를 수행하도록 만드는 방법을 설계해보라.