← 문제로

21. 재접속 시 기존 세션 재바인딩과 옛 연결의 늦은 콜백/패킷

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

해설 — 재접속 시 기존 세션 재바인딩과 옛 연결의 늦은 콜백/패킷

난이도: 상

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

요약

재바인딩은 하지만, 이벤트가 어느 연결에서 왔는지를 전혀 확인하지 않는다. (A) 재접속 시 같은 Session 객체에 Connection = newConn 으로 새 연결을 묶는다(상태 보존 의도는 옳다). 하지만 (B)(C) OnDisconnectconn현재 바인딩된 연결인지 확인하지 않고 무조건 Alive=false + Remove 한다 → 옛 연결의 종료 콜백이 재접속 "이후에" 도착하면 방금 재접속한 살아있는 세션을 죽이고 제거한다(정상 접속 중인 플레이어가 강제 종료). (D) OnPacketconn 검사 없이 세션 상태를 바꿔 옛 소켓의 늦은 패킷이 새 연결의 세션에 적용된다(스테일 입력). 게다가 connect/disconnect/packet 이 서로 다른 IO 스레드라 보호도 없다. 정답 한 줄: 연결마다 세대(generation/epoch) 토큰을 부여하고, 재바인딩 시 세대를 올린 뒤, 종료/패킷 콜백은 "내가 현재 바인딩된 연결(세대)인가"를 확인하고서만 처리하라.

변별: problem2(좀비/유령 세션)·problem4(비동기 종료 순서 UAF)·problem8(재접속 토큰 검증) 과 달리, 본 문제는 재바인딩 후 옛 연결의 늦은 이벤트가 새 연결 세션을 오염/종료 시키는 "stale callback on reattached session" 이 핵심이다.


문제점

(B)(C) OnDisconnect 가 연결 신원 미확인 — 살아있는 세션 종료 (세션 수명/정합) ★간판

  • 분류 태그: stale disconnect / missing identity check.
  • 증상: 옛 연결의 종료 콜백이 재접속 후 도착 → _byAccount 에서 현재(새) 세션을 제거하고 Alive=false. 정상 접속 중인 플레이어가 사라진다.
  • 재현 조건: 끊김 직후 빠른 재접속, 옛 소켓의 OnDisconnect 가 지연 도착.
  • 근본 원인: "누가 끊겼는가(conn)"와 "현재 바인딩(s.Connection)"을 대조하지 않음.

(D) OnPacket 이 연결 신원 미확인 — 스테일 입력 적용 (정합) ★간판

  • 분류 태그: stale packet applied / wrong-connection authority.
  • 증상: 옛 연결의 늦은 패킷이 새 연결 세션의 HP/상태를 바꾼다. 옛 소켓이 마치 현재 플레이어인 양 행동.
  • 근본 원인: 패킷도 "현재 바인딩된 연결에서 온 것"만 유효해야 한다.

(A) + 동시성 — 재바인딩/종료/패킷 경합 (동시성)

  • 분류 태그: data race / no per-account serialization.
  • 증상: connect/disconnect/packet 이 다른 IO 스레드에서 같은 세션·맵을 동시 접근. 재바인딩과 옛 종료가 겹치면 순서에 따라 결과가 달라진다.
  • 근본 원인: 계정 단위 직렬화(락/싱글 스레드 처리) 부재.

수정안 (정확한 코드)

연결에 세대 토큰을 부여한다. 재바인딩 시 세대를 올리고, 콜백은 자신의 세대가 현재 세대와 같을 때만 동작한다. 계정 단위 락으로 직렬화.

public class Session
{
    public long   AccountId;
    public object Connection;
    public long   Generation;   // 현재 바인딩 세대
    public int    Hp = 100;
    public bool   Alive = true;
}

public class SessionManager
{
    private readonly Dictionary<long, Session> _byAccount = new();
    private readonly object _gate = new();   // 데모용(실서비스는 계정 단위 락/액터)

    // 반환: 이 연결에 발급된 세대(이후 콜백이 들고 다닌다)
    public long OnConnect(long accountId, object newConn)
    {
        lock (_gate)
        {
            if (!_byAccount.TryGetValue(accountId, out var s))
            {
                s = new Session { AccountId = accountId };
                _byAccount[accountId] = s;
            }
            s.Connection = newConn;
            s.Alive = true;
            return ++s.Generation;   // 세대 증가 → 옛 연결의 콜백은 무효화됨
        }
    }

    public void OnDisconnect(long accountId, object conn, long gen)
    {
        lock (_gate)
        {
            if (_byAccount.TryGetValue(accountId, out var s)
                && s.Generation == gen)   // 내가 현재 바인딩일 때만 정리
            {
                s.Alive = false;
                _byAccount.Remove(accountId);
            }
            // 세대 불일치 = 옛 연결 → 아무것도 하지 않음(no-op)
        }
    }

    public void OnPacket(long accountId, object conn, long gen, int damage)
    {
        lock (_gate)
        {
            if (_byAccount.TryGetValue(accountId, out var s)
                && s.Generation == gen)   // 현재 바인딩 연결의 패킷만 적용
            {
                s.Hp -= damage;
            }
        }
    }
}

핵심: 세대 토큰으로 "옛 연결 vs 새 연결"을 구분 → 옛 연결의 종료/패킷은 자동 no-op. 계정 단위 락이 재바인딩·종료·패킷을 직렬화한다. (conn 객체 동일성 비교로도 되지만, 옛 연결 객체가 재사용/풀링되면 위험하므로 단조 증가 세대가 견고.)

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

  • 계정을 한 스레드/액터에 고정: 그 계정의 모든 이벤트를 순차 처리하면 락이 사라지고 순서가 자명해진다(트레이드오프: 라우팅·로드밸런싱).
  • 연결 상태 머신 명시화: Connected/Reattaching/Closed 상태와 세대를 함께 두고, 늦은 이벤트는 상태/세대로 거른다.
  • 옛 연결 정리는 멱등·무해하게: 옛 소켓 자원(버퍼/핸들)만 닫고, 세션 객체의 수명은 세대 검사 뒤에만 건드린다.
  • 재접속 유예(grace) 타이머: 끊김 후 N초 세션 유지 → 재접속 시 재바인딩, 시간 초과 시에만 정리. 옛/새 이벤트가 명확히 분리된다.

면접 포인트

  1. "stale callback" 패턴 — 비동기 이벤트는 늦게 도착한다. 콜백이 들고 온 세대/토큰과 현재 상태를 대조해 옛 이벤트를 무력화하는 게 핵심.
  2. 객체 동일성(conn==s.Connection)보다 단조 증가 세대가 견고한 이유(연결 객체 풀링/재사용).
  3. 재접속·종료·패킷은 계정 단위로 직렬화(락/액터). 빠른 재접속 레이스를 항상 가정하라.