35. 멀티 디바이스 세션 목록의 교체·제거 경합 (C#)
난이도 중해설 — 멀티 디바이스 세션 목록의 교체·제거 경합 (C#)
난이도: 중상
요약
락이 전혀 없어 Dictionary 동시 접근 자체가 위험하고, 더 본질적으로 (C)의 로그아웃 제거가
"플랫폼 슬롯"을 무조건 지운다. 같은 플랫폼에서 빠르게 재로그인이 일어나면, 옛 연결의 늦은
Disconnect가 새 세션을 지워버린다(정당한 세션 유실). 또한 로그인 교체(A→B)가 원자적이지 않아
경합 시 세션이 유실되거나 킥 통지가 어긋난다.
문제점
- [세션 수명/오제거] 늦은 Disconnect가 새 세션을 삭제. 같은 플랫폼으로 재로그인하면
OnLogin이 옛 세션을 킥하고 슬롯을 새 세션으로 교체한다. 그런데 옛 연결의OnDisconnect(oldSession)가 그 뒤에 도착하면, (C)는s.Platform키만 보고 슬롯을 지운다 — 지금 그 슬롯에 든 것이 새 세션인데도. 결과: 방금 로그인한 정상 세션이 목록에서 사라져 후속 조회·브로드캐스트에서 누락된다(유령 로그아웃). - [동시성/경합] 로그인 교체 비원자. (A)의 킥과 (B)의 등록 사이, 그리고 계정 엔트리 생성
(
TryGetValue→생성→대입) 사이가 원자적이지 않다. 두 스레드가 동시에 같은 계정 엔트리를 만들면 하나가 덮어써 다른 세션이 통째로 유실된다. - [자료구조] Dictionary 동시 접근.
_byAccount,perPlatform에 대한 무보호 동시 읽기/쓰기는 손상·무한 루프·예외를 유발한다.
근본 원인
① 계정 상태 변경(로그인 교체/로그아웃 제거)이 하나의 임계 구역으로 직렬화되지 않았고, ② 제거를 "플랫폼 키"로만 하기 때문에 슬롯의 신원(어느 연결인가) 을 확인하지 않는다. 재사용되는 키(platform) 위에서 신원 검증 없이 삭제하는 것은 전형적인 ABA류 오제거다.
수정안
계정별 락으로 로그인/로그아웃을 직렬화하고, 제거 시 슬롯에 든 세션이 바로 그 세션인지(ConnId) 확인한 뒤에만 지운다.
private readonly object _gate = new();
public void OnLogin(Session s)
{
lock (_gate)
{
if (!_byAccount.TryGetValue(s.AccountId, out var perPlatform))
_byAccount[s.AccountId] = perPlatform = new();
if (perPlatform.TryGetValue(s.Platform, out var old) && old.ConnId != s.ConnId)
old.Kick();
perPlatform[s.Platform] = s;
}
}
public void OnDisconnect(Session s)
{
lock (_gate)
{
if (_byAccount.TryGetValue(s.AccountId, out var perPlatform)
&& perPlatform.TryGetValue(s.Platform, out var cur)
&& cur.ConnId == s.ConnId) // 지금 그 슬롯이 '이' 세션일 때만 제거
{
perPlatform.Remove(s.Platform);
if (perPlatform.Count == 0) _byAccount.Remove(s.AccountId);
}
}
}
핵심은 신원 확인부 삭제(compare-and-remove): cur.ConnId == s.ConnId가 아니면 이미 다른(새) 세션이
그 슬롯을 차지한 것이므로 건드리지 않는다.
더 나은 설계
- 계정별 락은 전역 락보다 경합이 적다(
ConcurrentDictionary<long, AccountEntry>+ 엔트리별 락). - Kick은 락 밖에서 수행하고(소켓 I/O를 락 안에서 하지 않는다), 락 안에서는 교체 대상만 정한다.
- 세션에 단조 증가
Generation을 두면 ConnId 비교 대신 세대 비교로 더 저렴하게 신원 검증할 수 있다.
면접 포인트
- "키만 보고 지우기"가 왜 위험한가 — 재사용되는 키 위에서 신원 검증 없는 삭제는 새 점유자를 지운다(ABA).
- 로그인/로그아웃/재접속의 순서 역전(늦은 콜백) 시나리오를 어떻게 방어하나(compare-and-swap/remove).
- 락 안에서 소켓 I/O(Kick)를 하면 안 되는 이유와 분리 방법.
해설 — 멀티 디바이스 세션 목록의 교체·제거 경합과 무효화 (C++)
난이도: 중상
요약
동기화가 전혀 없어 중첩 unordered_map을 여러 스레드가 동시에 수정한다(데이터 레이스·rehash 중
반복자/노드 무효화 → 크래시). 논리적으로는 (C)의 제거가 슬롯의 신원(connId) 을 확인하지 않아,
같은 플랫폼 재로그인 뒤 옛 연결의 늦은 OnDisconnect가 새 세션을 지운다(정당한 세션 유실).
문제점
- [세션 수명/오제거] 늦은 Disconnect가 새 세션 삭제. 같은 플랫폼 재로그인이 (A)(B)로 슬롯을 새
세션으로 교체한 뒤, 옛 연결의
OnDisconnect(old)가 도착하면 (C)는platform키만 보고 슬롯을 지운다 — 실제로는 새 세션이 들어있는데도. 새 세션이 목록에서 사라져 조회·브로드캐스트에서 누락된다. - [동시성/자료구조 손상] 무보호 map 동시 접근.
byAccount_[accountId]는 없는 키면 삽입이라 rehash를 유발할 수 있고, 동시 rehash는 다른 스레드가 보유한 반복자/&perPlatform참조를 무효화한다. (A)에서 얻은it도 다른 스레드의 삽입/삭제로 무효화될 수 있다. - [동시성/교체 비원자] 로그인 경합. 같은 계정으로 동시에 로그인하면 계정 엔트리 생성과 슬롯 대입이 겹쳐 한쪽 세션이 통째로 유실된다.
근본 원인
① 계정 상태 변경이 임계 구역으로 직렬화되지 않았고, ② 제거를 "재사용되는 키(platform)"로만 하며 슬롯 점유자의 신원(connId)을 검증하지 않는다(ABA류 오제거). C++에서는 여기에 컨테이너 무효화가 겹쳐 크래시로 번진다.
수정안
매니저 전체(또는 계정별)를 뮤텍스로 보호하고, compare-and-erase로 슬롯이 바로 그 세션일 때만 지운다.
Kick(소켓 I/O)은 락 밖에서 한다.
#include <mutex>
class MultiSessionManager {
public:
void OnLogin(std::shared_ptr<Session> s) {
std::shared_ptr<Session> toKick;
{
std::lock_guard<std::mutex> g(mx_);
auto& perPlatform = byAccount_[s->accountId];
auto it = perPlatform.find(s->platform);
if (it != perPlatform.end() && it->second->connId != s->connId)
toKick = it->second; // 대상만 집어두고
perPlatform[s->platform] = std::move(s);
}
if (toKick) toKick->Kick(); // I/O 는 락 밖에서
}
void OnDisconnect(const std::shared_ptr<Session>& s) {
std::lock_guard<std::mutex> g(mx_);
auto ait = byAccount_.find(s->accountId);
if (ait == byAccount_.end()) return;
auto& perPlatform = ait->second;
auto it = perPlatform.find(s->platform);
if (it != perPlatform.end() && it->second->connId == s->connId) { // 신원 일치할 때만
perPlatform.erase(it);
if (perPlatform.empty()) byAccount_.erase(ait);
}
}
private:
std::mutex mx_;
std::unordered_map<std::int64_t,
std::unordered_map<std::string, std::shared_ptr<Session>>> byAccount_;
};
shared_ptr이므로 슬롯에서 지워져도 아직 참조가 있으면 객체는 살아있다(UAF는 아님). 문제는 순수하게
논리적 오제거와 컨테이너 동시 수정이다.
더 나은 설계
- 계정별 샤드 락(계정 해시로 N개 뮤텍스)으로 경합을 낮춘다.
- 세션에 단조
generation을 두면 connId 비교를 세대 비교로 대체해 더 저렴하게 신원 검증한다. - Kick 통지는 큐로 넘겨 락·I/O를 완전히 분리한다.
면접 포인트
unordered_map무효화 규칙(rehash: 참조·포인터 유효, 반복자 무효)과 그것이 동시성 설계에 주는 제약.- compare-and-erase가 왜 필요한가 — 재사용 키 위에서 신원 검증 없는 삭제는 새 점유자를 지운다(ABA).
- 락 안에서 소켓 I/O(Kick)를 피하는 이유(락 보유 시간·재진입·데드락).