← 문제로

18. 매칭 수락(레디 체크) 대기 중 취소/타임아웃 경합

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

해설 — 매칭 수락(레디 체크) 대기 중 취소/타임아웃 경합

난이도: 중

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

요약

공유 상태(_matches, MatchProposal.Accepted)를 락 없이 여러 스레드(Accept/Decline/ 타임아웃 타이머)가 만진다. OnAccept의 "추가 → 전원 검사 → 시작"(A)(B)과 Cancel(C)이 경합하면 ① 타임아웃·취소 직후 도착한 Accept 가 매치를 성사시키는 유령 매치, ② 마지막 수락과 타임아웃이 겹쳐 _startGameCancel둘 다 실행, ③ Accepted에 대한 동시 Add/순회로 컬렉션 손상이 난다. 또 성사/취소 후 타이머를 해제하지 않아 나중에 OnTimeout이 늦게 깨어 같은/재사용 matchId 에 부작용을 준다. 정답 한 줄: 매치별 상태 전이를 하나의 락으로 직렬화하고, "성사/취소" 를 단일 최종 상태로 만들어 한 번만 일어나게 하며, 종료 시 타이머를 반드시 해제한다.


문제점

(공통) 공유 상태 비동기화 — 동시성 (데이터 경쟁) ★간판

  • 증상: _matches(Dictionary)와 m.Accepted(HashSet)에 Accept/Decline/타임아웃이 동시 접근. Cancelforeach(m.Accepted) 순회 중 다른 스레드가 AddInvalidOperationException 또는 손상. Dictionary 동시 Remove/TryGetValue도 UB.
  • 근본 원인: 임계구역이 전혀 없다.

(A)(B)(C) 성사 vs 취소 경합 — TOCTOU / 상태전이 (정합) ★간판

  • 증상 1 (유령 매치): 타임아웃이 Cancel_matches.Remove 직전, 마지막 Accept 가 TryGetValue로 m 을 잡고 있다가 _startGame(m) 실행 → 이미 취소된(또는 취소될) 매치가 시작. 반대로 Accept 가 Remove 한 뒤 타임아웃이 옛 참조로 Cancel → 시작된 매치 인원이 큐로 복귀(이중 상태).
  • 증상 2 (둘 다 실행): 마지막 Accept 의 전원검사 통과와 타임아웃의 Cancel이 거의 동시 → _startGame_requeue가 같은 인원에 모두 적용.
  • 근본 원인: "성사" 와 "취소" 가 상호배타적 최종 상태로 모델링되지 않았고, 검사와 전이가 원자적이지 않다. Cancelled 플래그도 설정만 하고 아무도 확인하지 않는다.

(타이머) 종료 시 타이머 미해제 — 자원/지연 부작용 (수명)

  • 증상: 매치가 성사되거나 Decline 으로 취소돼 _matches에서 빠진 뒤에도 TimeoutTimer는 살아 있다. 나중에 OnTimeout이 깨어 (운 나쁘면 재사용된 matchId 의) 매치를 취소. Timer 객체 누수.
  • 근본 원인: 최종 상태 진입 시 Timer.Dispose()/해제를 하지 않음.

(멱등) Accept 중복/재전송 — 정합

  • 증상: 같은 플레이어의 Accept 가 두 번 와도 HashSet 이라 카운트는 안 늘지만, 후보가 아닌 임의 playerId 가 Accept 하면 Accepted.Count가 부풀어 잘못 성사될 수 있다(후보 검증 부재).

수정안

핵심: 매치별 락으로 상태 전이를 직렬화, 최종 상태(Started/Cancelled)를 한 번만, 타이머 해제.

public class MatchProposal
{
    public string MatchId;
    public List<long> Players;
    public HashSet<long> Accepted = new();
    public bool Finished = false;          // 성사/취소 공통 최종 플래그
    public Timer TimeoutTimer;
    public readonly object Gate = new();
}

public void OnAccept(string matchId, long playerId)
{
    MatchProposal m;
    lock (_lock) { if (!_matches.TryGetValue(matchId, out m)) return; }

    lock (m.Gate)
    {
        if (m.Finished) return;                              // 이미 성사/취소됨 → 무시
        if (!m.Players.Contains(playerId)) return;           // 후보만 인정
        m.Accepted.Add(playerId);
        if (m.Accepted.Count != m.Players.Count) return;

        m.Finished = true;                                   // 최종 상태 확정(한 번만)
        m.TimeoutTimer?.Dispose();                           // 타이머 해제
    }
    lock (_lock) { _matches.Remove(matchId); }
    _startGame(m);                                           // 락 밖에서 부작용 수행
}

private void Cancel(string matchId)
{
    MatchProposal m;
    lock (_lock) { if (!_matches.TryGetValue(matchId, out m)) return; }

    List<long> toRequeue;
    lock (m.Gate)
    {
        if (m.Finished) return;                              // 이미 끝남 → 무시(이중 방지)
        m.Finished = true;
        m.TimeoutTimer?.Dispose();
        toRequeue = new List<long>(m.Accepted);              // 스냅샷
    }
    lock (_lock) { _matches.Remove(matchId); }
    foreach (var p in toRequeue) _requeue(p);
}
// OnDecline/OnTimeout 은 Cancel(matchId) 호출로 통일

포인트

  • Finished를 락 안에서 검사+설정해 성사/취소가 정확히 한 쪽만 한 번 일어난다.
  • 종료 시 TimeoutTimer.Dispose()로 늦은 타임아웃 콜백 차단(+누수 방지).
  • 부작용(_startGame/_requeue)은 매치 락 밖, 스냅샷 기반으로 수행해 락 보유 시간 단축 및 콜백 재진입 데드락 회피.
  • 후보(Players.Contains) 검증으로 비후보 Accept 차단.

더 나은 설계 (+트레이드오프)

  1. 명시적 상태기계: Proposed → (Accepting) → Started | Cancelled. 전이는 CAS/락으로 한 번만. 잘못된 전이는 거부. 트레이드오프: 코드량↑, 그러나 경합 추론이 쉬워짐.
  2. 타임아웃을 별도 타이머 대신 점검 루프/휠 타이머로: 매치마다 Timer 객체를 만들지 않고 단일 타이밍 휠에서 만료 매치를 일괄 처리 → 객체 수·해제 누락 위험↓. 트레이드오프: 타이밍 해상도.
  3. 단일 스레드 액터/이벤트 루프: 한 매치의 모든 이벤트를 같은 액터가 순차 처리하면 락 자체가 불필요. 트레이드오프: 액터 프레임워크 도입.
  4. 멱등 토큰: Accept 요청에 매치별 nonce 를 줘 재전송/중복을 안전 흡수.

면접 포인트 (예상 질문)

  1. "마지막 수락" 과 "타임아웃" 이 동시에 일어날 때 _startGameCancel이 모두 실행될 수 있는 인터리빙을 설명하라. 어떻게 막나?
  2. 종료 후에도 살아있는 타이머가 왜 위험한가(특히 matchId 재사용 시)? 해제 시점은?
  3. 부작용 콜백(_startGame)을 락 안에서 부르면 안 되는 이유는?