← 문제로

15. 매치메이킹: 한 플레이어가 동시에 두 매치에 배정되는 상황 — C#

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

해설 — 매치메이킹: 한 플레이어가 동시에 두 매치에 배정되는 상황 — C#

난이도: 중상

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

요약

핵심 결함은 여러 모드 매처가 병렬로 도는데, "후보가 Searching 인지 검사" 와 "InMatch 로 확정" 사이에 교차 모드 락이 없다는 점이다. 같은 플레이어가 "랭크 3v3" 와 "일반 3v3" 두 큐에 있을 때, 두 매처 스레드가 거의 동시에 (A)에서 그를 Searching 으로 보고 각자 매치에 넣은 뒤 (B)에서 둘 다 InMatch 로 확정 → 이중 배정. 게다가 큐 컬렉션(List)을 락 없이 동시 순회·Remove 하므로 자료구조 손상과, 순회 중 같은 리스트를 수정하는 InvalidOperationException("Collection was modified") 도 발생한다. 정답 한 줄: 플레이어 배정은 플레이어 단위로 원자적으로 "선점(claim)"하고(CAS/락), 큐 조작은 전역(또는 샤드) 락 아래에서 한다.


문제점

(A)+(B) 교차 모드 이중 배정 — TOCTOU (동시성) ★간판

  • 증상: 한 플레이어가 두 매치(서로 다른 모드)에 동시에 들어간다. 한쪽 매치는 인원이 비거나, 플레이어가 두 게임에 입장 시도하며 세션이 꼬인다.
  • 재현 조건: 플레이어 X 가 mode1, mode2 큐에 동시 등록. 매처1(mode1)·매처2(mode2)가 병렬로 TryFormMatch 실행. 둘 다 (A)에서 X 를 Searching 으로 읽고 후보에 포함 → 각자 (B) 진입 전까지 아무도 InMatch 로 못 바꿈 → 둘 다 X 를 매치에 넣고 InMatch 확정.
  • 근본 원인: 플레이어 "선점"이 원자적이지 않다. 검사(State==Searching)와 확정 (State=InMatch)이 분리돼 있고, 서로 다른 매처 스레드 간 동기화가 없다. 플레이어를 잡는 단위가 모드 큐가 아니라 플레이어여야 하는데 그 단위의 원자 연산이 없다.

(A) Where(...).Take(...) 와 (B) Remove 의 컬렉션 동시 수정 (동시성/메모리)

  • 증상: InvalidOperationException("Collection was modified; enumeration may not execute") 또는 요소 유실/중복. List 는 스레드 세이프하지 않다.
  • 재현 조건: 한 매처가 q.Where(...) 지연 평가로 큐를 순회하는 동안 다른 매처가 같은(또는 공유된) 큐에서 Remove. 특히 (B)의 _queues[m].Remove(p)다른 모드의 큐를 건드리므로 그 모드 매처와 정면 충돌.
  • 근본 원인: 큐 자료구조에 대한 임계 구역 부재.

(B) 등록 모드 목록과 실제 큐의 정합성 — 부분 제거 (정확성)

  • 증상: p.QueuedModes 가 최신이 아니거나(취소로 한 모드만 빠졌는데 목록 미갱신), 도중 예외가 나면 일부 큐에서만 제거돼 플레이어가 유령처럼 한 큐에 남는다.
  • 근본 원인: 상태 전이와 "모든 큐 제거"가 하나의 트랜잭션(원자 단위)이 아니다.

매치 취소/재매칭과의 경합 (동시성)

  • 매치 취소로 InMatch → Searching 으로 되돌리는 경로와 (A)(B) 가 락을 공유하지 않으면, 되돌리는 순간 매처가 그를 다시 잡아 또 이중 배정/유실이 생긴다.

수정안

핵심: ① 매칭 전역(또는 MMR 버킷 샤드) 락 아래에서 후보 수집~배정~큐 제거를 한 임계 구역으로, ② 플레이어 선점을 원자적 claim(상태 CAS)으로, ③ 큐 스냅샷을 떠서 순회 중 수정 회피.

private readonly object _gate = new object();   // 매칭 도메인 락(또는 버킷별 샤드 락)

public Match TryFormMatch(int mode)
{
    lock (_gate)
    {
        var q = _queues[mode];

        // 스냅샷으로 후보 선정(지연 평가 + 동시 수정 회피)
        var candidates = q.Where(p => p.State == MatchState.Searching)
                          .Take(_teamSize).ToList();
        if (candidates.Count < _teamSize) return null;

        // 같은 락 안이므로, 여기서 본 Searching 은 확정될 때까지 불변
        var match = new Match { Mode = mode };
        foreach (var p in candidates)
        {
            p.State = MatchState.InMatch;               // 선점 확정
            foreach (int m in p.QueuedModes.ToList())   // 사본으로 순회(원본 Remove 안전)
                _queues[m].Remove(p);
            match.PlayerIds.Add(p.Id);
        }
        return match;
    }
}

모든 매처가 같은 _gate 를 공유하면, mode1 과 mode2 매처가 같은 플레이어를 동시에 잡는 일이 불가능해진다(직렬화). QueuedModes.ToList() 로 사본 순회해야 순회 중 원본 Remove 의 예외를 피한다.

전역 락이 처리량 병목이면, 플레이어 단위 원자 선점으로 락 범위를 줄인다:

// 전제: enum 은 Interlocked 오버로드가 없으므로 WaitingPlayer 에 int 백킹 필드
//   public int StateInt;   // 0=Searching, 1=InMatch (State 프로퍼티로 래핑)
// 를 두고, 그 int 를 CAS 한다.
// CAS 로 Searching -> InMatch 를 시도, 실패하면 다른 매처가 이미 잡은 것
bool TryClaim(WaitingPlayer p) =>
    Interlocked.CompareExchange(ref p.StateInt,
        (int)MatchState.InMatch, (int)MatchState.Searching) == (int)MatchState.Searching;
// 후보 전원 claim 에 성공해야 매치 확정, 일부 실패 시 성공분을 롤백(Searching 복구)

큐 제거는 여전히 큐 락이 필요하지만, "누가 그 플레이어를 갖는가"는 CAS 가 단번에 결정한다.


더 나은 설계

1) 매처 단일화 / 파티셔닝

  • 모드별로 매처를 따로 두는 대신, 플레이어를 MMR 버킷으로 샤딩하고 한 버킷은 단일 스레드가 소유해 모든 모드를 함께 본다. 한 플레이어가 항상 한 매처에만 속해 교차 배정이 구조적으로 불가능. 트레이드오프: 버킷 경계 플레이어 매칭 품질 약간 손해.

2) 2단계 커밋(제안 → 수락)

  • 매처는 "매치 제안"만 만들고(플레이어를 Reserved 상태로 잠금), 전원이 수락하면 확정, 하나라도 거절/타임아웃이면 전원 롤백(Searching 복구). 이중 배정 없이 취소도 깔끔.

3) 멀티 큐잉의 단일 소유

  • 플레이어가 여러 모드에 있어도, 매칭 시스템 내부에서는 "이 플레이어를 잡을 권리"를 단일 토큰/락으로 관리. 잡힌 즉시 다른 모든 모드에서 보이지 않게 된다(논리 삭제).

4) 큐 자료구조

  • List.Remove(p) 는 O(n) 이라 대형 큐에서 비싸다. 인덱스 가능한 우선순위 구조 + 지연 삭제(tombstone) 또는 LinkedList 노드 핸들 보관으로 O(1) 제거.

면접 포인트

  • 핵심: 매칭의 동시성은 "잡는 단위가 무엇인가" 가 관건 — 모드 큐가 아니라 플레이어를 원자적으로 선점해야 하고, 멀티 큐잉에서 교차 매처 경합을 어떻게 직렬화하느냐.
  • 예상 질문:
    1. "왜 한 플레이어가 두 매치에 들어가나?" → 검사-확정 비원자 + 매처 간 동기화 부재 (TOCTOU). 멀티 큐잉이 이를 노출.
    2. "전역 락이 병목이면?" → MMR 버킷 샤딩으로 한 플레이어 = 한 매처, 또는 플레이어 단위 CAS 선점 + 실패 롤백.
    3. "매치 취소와 재매칭이 겹치면?" → 상태 전이를 같은 락/CAS 도메인에서. 2단계 커밋으로 예약→확정/롤백을 원자화.

분류 메모: 이 문제는 매치메이킹(카탈로그 12.매치메이킹) 도메인 로직이지만 결함의 본질은 교차 스레드 TOCTOU + 공유 컨테이너 동시 변경이라 동시성 색채가 짙다. content_gamelogic 에 둔 것은 매칭이라는 게임 로직 맥락과 기존 content13(시즌 마감 경합)·content4(경매 입찰) 등 동시성 결함이 이미 이 카테고리에 다수 있는 선례에 따른 것이다. 기존 content4(단일 자원 최고가 경합)·session3(계정당 1세션 직렬화)과 달리 "여러 큐에서 한 엔티티를 선점하고 전 큐에서 일관 제거"라는 축이 신규다.