← 문제로

14. 던전 인스턴스 정원 초과 (동시 입장 경합) — C#

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

해설 — 던전 인스턴스 정원 초과 (동시 입장 경합) — C#

난이도: 중

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

Interlocked/Volatile 를 썼다고 스레드 세이프한 게 아니다. 핵심 결함은 "정원 검사 → 중복 검사 → 입장 반영"이 하나의 원자 단위가 아니라 분리된 연산들의 나열이라는 점이다. 정원이 5인데 여러 스레드가 동시에 (A) _count >= 5 를 모두 거짓으로 보면 정원을 초과해 입장한다 (check-then-act 레이스). 또한 List<long> 은 스레드 세이프하지 않아 동시 Add/Contains 가 내부 배열을 손상시키거나 InvalidOperationException/요소 유실을 유발한다. 정답 한 줄: 검사-반영 전체를 하나의 락으로 직렬화하고, 카운트는 컬렉션 크기로 일원화한다.


문제점

(A)+(C) 검사-반영 비원자 — 정원 초과 레이스 (동시성) ★간판

  • 증상: 정원 5짜리 인스턴스에 6명 이상 입장.
  • 재현 조건: _count == 4 일 때 두 스레드가 동시에 (A) 4 >= 5 거짓 → 둘 다 통과 → 둘 다 (C) Interlocked.Increment_count == 6.
  • 근본 원인: Volatile.Read 검사와 Interlocked.Increment 증가가 분리된 두 연산. Interlocked 는 단일 연산만 원자적이지 "검사 후 조건부 증가"를 묶지 못한다. 필요한 것은 CAS(Interlocked.CompareExchange) 또는 락.

(B)+(C) List<long> 동시 접근 — 자료구조 손상 (동시성/메모리) ★언어 변별

  • 증상: 드물게 크래시/IndexOutOfRange/InvalidOperationException/요소 유실·중복.
  • 재현 조건: 한 스레드가 (B) Contains 로 순회하는 동안 다른 스레드가 (C) Add → 내부 배열 재할당/버전 변경과 겹침. 두 스레드 동시 Add 도 내부 인덱스 경합.
  • 근본 원인: List<T> 는 다중 쓰기에 안전하지 않다. _count 만 atomic 으로 보호해도 컨테이너 본체는 무방비.

(A)+(B) 중복 입장 레이스 (동시성)

  • 증상: 같은 플레이어가 두 번 멤버가 됨.
  • 재현 조건: 더블클릭으로 같은 playerId 두 요청이 동시에 (B) Contains==false 통과 → 둘 다 Add.
  • 근본 원인: 중복 검사와 삽입이 같은 임계 구역에 없음.

동기화 도메인 분리 — 개념 오류

  • _count(Interlocked) 와 _members(비보호 List) 가 서로 다른 동기화 도메인이라 둘이 절대 일관될 수 없다. 한 자원의 여러 필드는 같은 락으로 묶어야 한다.

수정안

핵심: ① 단일 lock 으로 검사~중복검사~반영을 하나의 임계 구역으로, ② 카운트는 _members.Count 로 일원화(별도 필드 제거 → 불일치 차단), ③ 멤버십 조회가 잦으면 HashSet<long> 사용.

public class DungeonInstance
{
    private readonly int           _capacity;
    private readonly object        _gate = new object();
    private readonly HashSet<long> _members = new HashSet<long>();

    public DungeonInstance(int capacity) { _capacity = capacity; }

    public bool TryEnter(long playerId)
    {
        lock (_gate)
        {
            if (_members.Count >= _capacity) return false;   // 정원
            return _members.Add(playerId);                   // 중복(원자): 신규면 true
        }
    }

    public int Count { get { lock (_gate) return _members.Count; } }
}

HashSet.Add 가 (중복 검사 + 삽입)을 한 번에 처리하고 신규 여부를 반환해 중복 입장 레이스까지 닫는다. 카운트를 Count 로 일원화해 두 도메인 불일치를 제거한 것이 포인트.

동시성 컬렉션을 쓴다면 ConcurrentDictionary<long, byte> + 원자 카운터로도 가능하지만, 정원이라는 상한 제약은 결국 임계 구역이 가장 단순·정확하다.


더 나은 설계

1) 입장은 "자리 예약" 모델

  • 매칭 단계에서 서버가 권위적으로 좌석을 사전 배정하고, 접속은 예약 토큰만 검증. 입장 폭주 자체가 사라진다. 트레이드오프: 예약 만료/노쇼 처리 필요.

2) 단일 소유 스레드(액터)

  • 인스턴스의 모든 입·퇴장을 소유 스레드가 직렬 처리 → 락 제거. 던전 서버 정석 패턴.

3) 분산 정원

  • 인스턴스를 여러 노드가 참조하면 정원은 분산 카운터/락 또는 단일 소유 노드 라우팅으로 보호. 단순 Interlocked 로는 불가.

4) 거부 사유 / 관측성

  • 정원 초과·중복 입장 거부를 S_EnterRejected(reason) + 메트릭으로 노출.

면접 포인트

  • 핵심: Interlocked ≠ 임계 구역. check-then-act 는 CAS 또는 락. 한 자원의 여러 필드는 같은 동기화 도메인.
  • 예상 질문:
    1. "Interlocked.Increment 를 썼는데 왜 정원 초과?" → 검사와 증가가 분리된 두 연산. 조건부 증가가 아님. CAS/락 필요.
    2. "List 를 동시에 Add 하면?" → 내부 배열 손상/예외/유실. lock 또는 동시성 컬렉션.
    3. "count 와 members 를 따로 두면?" → 영구 불일치. Count 로 일원화.