← 문제로

33. 자동 파티 매칭의 정원 초과 (동시 배치 경합)

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

해설 — 자동 파티 매칭의 정원 초과 (동시 배치 경합)

난이도: 하

요약

TryAdd는 "정원 확인 → (잠깐의 처리) → 추가"를 하나의 원자 단위로 묶지 않는다. 여러 워커가 동시에 같은 파티에 들어오면 모두 (A)에서 "자리 있음"을 통과한 뒤 (B)에서 추가하여 정원을 넘긴다. 게다가 List<int>는 스레드 안전하지 않아 동시 Add가 내부 배열을 깨뜨릴 수 있다.

문제점

  • [경쟁 조건 / check-then-act(TOCTOU)]
    • 증상: 4인 파티에 5명 이상이 들어가거나, List 동시 수정으로 IndexOutOfRangeException·원소 유실·중복이 발생.
    • 재현 조건: 현재 인원이 3일 때 워커 2개가 거의 동시에 진입 → 둘 다 (A)에서 3 < 4 통과 → AssignSlot 처리 동안 서로의 추가를 보지 못함 → 둘 다 (B) 실행 → 인원 5.
    • 근본 원인: 검사 시점의 값(Count)과 행동 시점(Add) 사이에 다른 스레드가 끼어들 수 있다. 검사와 갱신이 원자적이지 않다. AssignSlot의 처리 시간이 창(window)을 넓혀 충돌 확률을 높인다.
  • [비스레드세이프 컬렉션]
    • List<T>는 동시 쓰기에 안전하지 않다. 정원 문제와 별개로 Add가 재할당/이동 중일 때 다른 스레드가 끼어들면 상태가 깨진다.

수정안

검사와 추가를 같은 임계 구역에 넣는다. 비용이 큰 AssignSlot은 자리를 "예약"한 뒤 락 밖에서 처리한다.

public sealed class Party
{
    public const int MaxMembers = 4;
    public int PartyId { get; }
    private readonly List<int> _members = new();
    private readonly object _gate = new();

    public Party(int id) => PartyId = id;

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

    public bool TryAdd(int playerId)
    {
        lock (_gate)
        {
            if (_members.Count >= MaxMembers) return false;
            if (_members.Contains(playerId)) return false; // 중복 배치 방지(멱등)
            _members.Add(playerId);                        // 검사+추가가 원자적
        }
        // 무거운 처리(슬롯 인덱스 계산, 입장 패킷)는 자리 확보 후 락 밖에서.
        AssignSlot(playerId);
        return true;
    }

    private void AssignSlot(int playerId) { /* ... */ }
}

AssignSlot이 실패할 수 있다면 실패 시 추가를 되돌려야(remove) 한다.

더 나은 설계

  • 원자적 카운터로 자리 예약: 인원을 int로 두고 Interlocked.Increment 후 한도 초과면 Interlocked.Decrement로 롤백하는 "예약 후 확정" 패턴은 락 없이 정원을 지킬 수 있다. 다만 멤버 목록 자체의 일관성은 여전히 동기화가 필요하므로, 보통은 짧은 lock이 가장 단순하고 정확하다.
  • 단일 소유 스레드(액터): 파티 변경을 한 스레드(또는 파티별 직렬 큐)로 일원화하면 락 없이도 경합이 사라진다. MMORPG에서 "파티/방은 한 스레드가 소유" 모델이 흔하다. 트레이드오프: 그 스레드가 병목이 될 수 있고, 교차-파티 작업(이전 등)은 메시지로 조율해야 한다.
  • 트레이드오프: 락은 단순·정확하지만 경합이 심하면 직렬화 비용. 액터 모델은 경합을 제거하지만 설계 복잡도와 지연이 늘 수 있다.

면접 포인트

  • "검사하고 나서 행동하기(check-then-act)"는 검사와 행동이 한 임계 구역 안에 있어야 의미가 있다 — 정원·재고·중복 가입의 단골 버그.
  • 무거운 작업을 락 안에서 하면 임계 구역이 길어진다. "자리 예약은 짧은 락, 실제 처리는 락 밖"으로 분리하는 패턴을 설명할 수 있는가.
  • List<T>/Dictionary<T>는 동시 쓰기에 안전하지 않다는 점, 그리고 멱등성(같은 플레이어 중복 배치 방지)을 함께 챙겨야 함을 짚는다.