← 문제로

50. 조회 경로에서의 비원자 삽입(무락 Dictionary 동시 변형)

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 조회 경로에서의 비원자 삽입(무락 Dictionary 동시 변형)

난이도: 상

요약

GetOrCreate는 이름은 "조회"지만 실제로는 (A) 확인 후 (B)에서 딕셔너리를 **변형(삽입)**한다. Dictionary<TKey,TValue>는 스레드 안전하지 않아, 여러 스레드가 동시에 GetOrCreate를 호출하면 (1) 동시 쓰기로 내부 버킷/엔트리가 깨져 InvalidOperationException·IndexOutOfRangeException·무한 루프 등이 발생하고, (2) check-then-act가 비원자라 두 스레드가 모두 "없음"을 관측해 서로 다른 PlayerStat를 각각 생성한다. 한쪽이 덮어써 다른 쪽의 Kills 증가가 유실된다. "읽기 위주"라는 인상 때문에 무락으로 방치되지만, 조회 경로가 사실은 쓰기 경로다.

문제점

  • 분류태그: 조회 경로의 숨은 변형 + check-then-act 비원자 + 스레드 비안전 컨테이너 동시 접근
  • 증상: 부하 시 간헐적 컬렉션 예외/크래시, 킬 카운트 유실·중복 엔트리. 저부하(직렬)에선 재현 안 됨.
  • 재현조건: 같은/다른 키에 대해 여러 스레드가 동시에 GetOrCreate(또는 AddKill) 호출. 특히 첫 등록이 겹칠 때.
  • 근본원인: "읽기"로 가정한 경로가 자료구조를 변형한다. 그리고 그 변형(확인+삽입)이 원자적이지도, 락으로 보호되지도 않는다.

수정안

조회와 삽입을 원자화하고, 동시성 지원 자료구조를 쓰거나 락으로 보호한다.

// 방법 1) ConcurrentDictionary + 원자적 GetOrAdd
private readonly ConcurrentDictionary<int, PlayerStat> _stats = new();

public PlayerStat GetOrCreate(int playerId)
    => _stats.GetOrAdd(playerId, _ => new PlayerStat());
// 주의: 팩토리는 경합 시 여러 번 호출될 수 있다(초과 생성은 버려짐). 부작용 있는
//       생성이면 Lazy<PlayerStat> 를 값으로 넣어 "정확히 한 번 초기화"를 보장한다.

// 방법 2) 명시적 락 (읽기도 같은 락으로 보호해야 함)
private readonly object _gate = new object();
public PlayerStat GetOrCreate(int playerId)
{
    lock (_gate)
    {
        if (!_stats.TryGetValue(playerId, out var s))
        {
            s = new PlayerStat();
            _stats[playerId] = s;
        }
        return s;
    }
}

그리고 s.Kills++도 여러 스레드가 같은 PlayerStat를 만지면 비원자다 — Interlocked.Increment(ref s.Kills) 또는 락으로 보호.

더 나은 설계

  • 읽기/쓰기 경로 분리: 진짜 읽기 전용 조회와, 등록을 별도 API로. "조회가 자료구조를 바꾸지 않는다"를 불변식으로.
  • 읽기 폭주·쓰기 드묾이면 ConcurrentDictionary(스트라이프 락) 또는 COW/샤딩. GetOrAdd 팩토리 중복 호출 특성을 이해하고, 무거운/부작용 초기화는 Lazy<T>로.
  • 카운터는 Interlocked 또는 값 자체를 원자적으로.

면접 포인트

  • Dictionary(비안전) vs ConcurrentDictionary, GetOrAdd 팩토리가 왜 여러 번 호출될 수 있는가와 Lazy<T> 대응.
  • "조회처럼 보이는 변형"(get-or-create)이 만드는 동시성 버그.
  • check-then-act 비원자 + 카운터 증가 비원자의 이중 결함.