18. 매칭 수락(레디 체크) 대기 중 취소/타임아웃 경합
난이도 중해설 — 매칭 수락(레디 체크) 대기 중 취소/타임아웃 경합
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
공유 상태(_matches, MatchProposal.Accepted)를 락 없이 여러 스레드(Accept/Decline/
타임아웃 타이머)가 만진다. OnAccept의 "추가 → 전원 검사 → 시작"(A)(B)과 Cancel(C)이
경합하면 ① 타임아웃·취소 직후 도착한 Accept 가 매치를 성사시키는 유령 매치, ② 마지막
수락과 타임아웃이 겹쳐 _startGame과 Cancel이 둘 다 실행, ③ Accepted에 대한 동시
Add/순회로 컬렉션 손상이 난다. 또 성사/취소 후 타이머를 해제하지 않아 나중에
OnTimeout이 늦게 깨어 같은/재사용 matchId 에 부작용을 준다. 정답 한 줄: 매치별 상태
전이를 하나의 락으로 직렬화하고, "성사/취소" 를 단일 최종 상태로 만들어 한 번만 일어나게
하며, 종료 시 타이머를 반드시 해제한다.
문제점
(공통) 공유 상태 비동기화 — 동시성 (데이터 경쟁) ★간판
- 증상:
_matches(Dictionary)와m.Accepted(HashSet)에 Accept/Decline/타임아웃이 동시 접근.Cancel이foreach(m.Accepted)순회 중 다른 스레드가Add→InvalidOperationException또는 손상. 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 차단.
더 나은 설계 (+트레이드오프)
- 명시적 상태기계: Proposed → (Accepting) → Started | Cancelled. 전이는 CAS/락으로 한 번만. 잘못된 전이는 거부. 트레이드오프: 코드량↑, 그러나 경합 추론이 쉬워짐.
- 타임아웃을 별도 타이머 대신 점검 루프/휠 타이머로: 매치마다 Timer 객체를 만들지 않고 단일 타이밍 휠에서 만료 매치를 일괄 처리 → 객체 수·해제 누락 위험↓. 트레이드오프: 타이밍 해상도.
- 단일 스레드 액터/이벤트 루프: 한 매치의 모든 이벤트를 같은 액터가 순차 처리하면 락 자체가 불필요. 트레이드오프: 액터 프레임워크 도입.
- 멱등 토큰: Accept 요청에 매치별 nonce 를 줘 재전송/중복을 안전 흡수.
면접 포인트 (예상 질문)
- "마지막 수락" 과 "타임아웃" 이 동시에 일어날 때
_startGame과Cancel이 모두 실행될 수 있는 인터리빙을 설명하라. 어떻게 막나? - 종료 후에도 살아있는 타이머가 왜 위험한가(특히 matchId 재사용 시)? 해제 시점은?
- 부작용 콜백(
_startGame)을 락 안에서 부르면 안 되는 이유는?
해설 — 매칭 수락(레디 체크) 대기 중 취소/타임아웃 경합 (C++)
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
공유 상태(matches_, MatchProposal)를 락 없이 Accept/Decline/타임아웃 스레드가
동시에 만진다. OnAccept의 "추가 → 전원검사 → 시작 → erase"(A)(B)와 Cancel(C)이
경합하면 ① 취소/타임아웃 직후 Accept 가 성사시키는 유령 매치, ② 마지막 수락과 타임아웃이
겹쳐 startGame_·Cancel이 둘 다 실행, ③ matches_.erase(it) 로 다른 스레드가 들고
있던 MatchProposal& 참조가 댕글링(UB), ④ accepted 동시 변경/순회로 손상이 난다.
정답 한 줄: 매치별 상태 전이를 락으로 직렬화하고, 성사/취소를 단일 최종 상태로 한 번만
일으키며, 참조 대신 안전한 수명 관리(shared_ptr/스냅샷)로 erase 후 접근을 막는다.
문제점
(UB) erase 후 댕글링 참조 — 수명/동시성 ★간판
- 증상: 스레드 A 의
OnAccept가MatchProposal& m = it->second;로 참조를 잡은 뒤, 스레드 B 의Cancel이matches_.erase→ A 의m은 파괴된 원소를 가리킴. 이후startGame_(m)은 use-after-free. - 근본 원인: 컨테이너 원소 참조를 들고 있는 동안 다른 경로가 erase. 동기화·수명관리 부재.
(공통) 공유 상태 데이터 경쟁 — 동시성 ★간판
- 증상:
matches_(map)와m.accepted(set)에 동시 삽입/삭제/순회.Cancel의for(p : m.accepted)도중 다른 스레드insert→ 반복자 무효화·손상. map 자체도 동시erase/find가 데이터 경쟁. - 근본 원인: 임계구역 없음.
(A)(B)(C) 성사 vs 취소 경합 — TOCTOU/상태전이 (정합) ★간판
- 증상: 타임아웃
Cancel의 erase 직전 마지막 Accept 가 전원검사를 통과해startGame_→ 이미 취소 처리 중인 매치 시작. 또는 둘 다 실행되어 같은 인원이 게임 시작- 큐 복귀.
cancelled플래그는 설정만 하고 확인하는 곳이 없다.
- 큐 복귀.
- 근본 원인: "성사"·"취소"가 상호배타 최종 상태로 모델링되지 않음, 검사–전이 비원자.
(멱등) 비후보 Accept — 정합
- 증상:
players검증 없이accepted.insert(playerId)→ 후보가 아닌 ID 가 끼면size()비교가 오성사를 유발할 수 있다.
수정안
핵심: shared_ptr<MatchProposal> 로 수명 분리, 매치별 mutex 로 전이 직렬화, 최종 플래그.
struct MatchProposal {
std::string matchId;
std::vector<std::int64_t> players;
std::unordered_set<std::int64_t> accepted;
bool finished = false; // 성사/취소 공통 최종 플래그
std::mutex mtx;
};
void OnAccept(const std::string& matchId, std::int64_t playerId) {
std::shared_ptr<MatchProposal> m;
{ std::lock_guard<std::mutex> g(mapMtx_);
auto it = matches_.find(matchId);
if (it == matches_.end()) return;
m = it->second; } // shared_ptr 복사 → 수명 보장
bool start = false;
{ std::lock_guard<std::mutex> g(m->mtx);
if (m->finished) return; // 이미 끝남 → 무시
if (std::find(m->players.begin(), m->players.end(), playerId) == m->players.end())
return; // 후보만 인정
m->accepted.insert(playerId);
if (m->accepted.size() == m->players.size()) { m->finished = true; start = true; } }
if (start) {
{ std::lock_guard<std::mutex> g(mapMtx_); matches_.erase(matchId); }
startGame_(*m); // 락 밖, shared_ptr가 수명 보장
}
}
void Cancel(const std::string& matchId) {
std::shared_ptr<MatchProposal> m;
{ std::lock_guard<std::mutex> g(mapMtx_);
auto it = matches_.find(matchId);
if (it == matches_.end()) return;
m = it->second; }
std::vector<std::int64_t> toRequeue;
{ std::lock_guard<std::mutex> g(m->mtx);
if (m->finished) return; // 이중 취소/성사 후 취소 방지
m->finished = true;
toRequeue.assign(m->accepted.begin(), m->accepted.end()); }
{ std::lock_guard<std::mutex> g(mapMtx_); matches_.erase(matchId); }
for (auto p : toRequeue) requeue_(p);
}
// matches_ 는 unordered_map<string, shared_ptr<MatchProposal>> 로 변경.
// 타이머는 finished 진입 시 취소하거나, 만료 콜백에서 finished면 no-op.
포인트
shared_ptr복사로 erase 후에도 객체가 살아 있어 댕글링 제거.finished를 락 안에서 검사+설정 → 성사/취소가 정확히 한 번.- 부작용은 락 밖에서,
accepted스냅샷 기반으로 수행.
더 나은 설계 (+트레이드오프)
- 명시적 상태기계(Proposed→Started|Cancelled), CAS/락 1회 전이. 추론 용이, 코드↑.
- 타이밍 휠로 매치별 타이머 객체 대신 일괄 만료 처리 → 해제 누락·객체수↓. 해상도↓.
- 단일 스레드 액터: 한 매치 이벤트를 한 액터가 순차 처리 → 락 불필요. 프레임워크 도입.
- 멱등 토큰으로 Accept 재전송 안전 흡수.
면접 포인트 (예상 질문)
matches_.erase후MatchProposal&가 왜 UB 인가?shared_ptr가 어떻게 해결하나?- "마지막 수락"과 "타임아웃"이 동시일 때 둘 다 실행되는 인터리빙과 차단법(finished)?
- 부작용 콜백을 매치 락 안에서 호출하면 안 되는 이유(재진입/데드락)는?