← 문제로

35. 멀티 디바이스 세션 목록의 교체·제거 경합 (C#)

난이도 중
내 리뷰 · C#
해설 · 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)를 하면 안 되는 이유와 분리 방법.