29. ConcurrentDictionary.GetOrAdd의 팩토리 중복 호출

난이도 최상 해설 보기 →

결함을 모두 찾고 원인·수정안·더 나은 설계를 제시하라. 마커 (A)(B) 는 주목 위치 힌트다.

결함 코드 · C#
// ============================================================================
// [언어 내부동작 문제] C# - ConcurrentDictionary.GetOrAdd의 팩토리 중복 호출
// ----------------------------------------------------------------------------
// 주제: ConcurrentDictionary 는 스레드 안전한데, 왜 GetOrAdd 에 넘긴 값 생성 델리게이트
//       (factory)가 "한 번보다 더 많이" 호출될 수 있는가?
//   - 게임서버에서 흔한 패턴: 플레이어 ID -> 세션/캐시 객체를 여러 스레드(IOCP 워커,
//     매치메이킹 스레드 등)가 동시에 GetOrAdd 로 최초 생성하며 접근한다.
//
// 과제:
//   1) 두 스레드가 동시에 같은 키로 GetOrAdd 를 호출할 때 아래 코드의 출력이
//      어떻게 나올 수 있는지 예측하라(팩토리가 몇 번 호출될 수 있는지, 최종적으로
//      딕셔너리에 남는 값과 두 스레드가 "받는" 값이 같은지 다른지).
//   2) ConcurrentDictionary.GetOrAdd(key, Func<TKey,TValue> factory) 가 내부적으로
//      "락을 잡고 factory 호출까지 통째로 보호"하지 않는 이유(락 경합을 줄이기 위한
//      설계)를 설명하라.
//   3) factory 가 부수효과(리소스 할당, 카운터 증가, 로그 출력 등)를 갖고 있을 때
//      왜 위험한지, 안전하게 쓰려면 어떻게 바꿔야 하는지 설명하라.
//   (먼저 직접 예측·설명을 적은 뒤 answer.md 와 대조할 것)
// ============================================================================

using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

public class SessionCache
{
    private readonly ConcurrentDictionary<int, PlayerSession> _sessions = new();
    private int _factoryCallCount = 0;

    public class PlayerSession
    {
        public int PlayerId { get; }
        public Guid InstanceTag { get; } = Guid.NewGuid();

        public PlayerSession(int playerId)
        {
            PlayerId = playerId;
        }
    }

    public PlayerSession GetOrCreate(int playerId)
    {
        return _sessions.GetOrAdd(playerId, id =>
        {
            // (A) 이 팩토리 델리게이트는 "최종적으로 딕셔너리에 들어가는 값"을 만들 때만
            //     호출되는 게 아닐 수 있다.
            Interlocked.Increment(ref _factoryCallCount);
            return new PlayerSession(id);
        });
    }

    public int FactoryCallCount => _factoryCallCount;
}

public static class Program
{
    public static void Main()
    {
        var cache = new SessionCache();
        const int playerId = 100;
        const int threadCount = 8;

        var barrier = new Barrier(threadCount);
        var results = new SessionCache.PlayerSession[threadCount];

        var tasks = new Task[threadCount];
        for (int i = 0; i < threadCount; i++)
        {
            int idx = i;
            tasks[idx] = Task.Run(() =>
            {
                barrier.SignalAndWait(); // 최대한 동시에 GetOrCreate 호출을 시도
                results[idx] = cache.GetOrCreate(playerId);
            });
        }
        Task.WaitAll(tasks);

        // (B) 모든 스레드가 "같은" PlayerSession 인스턴스를 받았는지 확인
        bool allSame = true;
        for (int i = 1; i < threadCount; i++)
        {
            if (!ReferenceEquals(results[0], results[i]))
                allSame = false;
        }

        Console.WriteLine($"factory 호출 횟수 >= 1: {cache.FactoryCallCount >= 1}");
        Console.WriteLine($"모든 스레드가 동일 인스턴스를 참조: {allSame}");
        Console.WriteLine($"딕셔너리 최종 원소 개수: 1 (키가 하나이므로)");
    }
}
내 리뷰 · C#
내 답안 · 자동 저장

작성 후 위 해설 보기에서 모범 해설과 대조하세요.