21. 재접속 시 기존 세션 재바인딩과 옛 연결의 늦은 콜백/패킷
난이도 상해설 — 재접속 시 기존 세션 재바인딩과 옛 연결의 늦은 콜백/패킷
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
재바인딩은 하지만, 이벤트가 어느 연결에서 왔는지를 전혀 확인하지 않는다.
(A) 재접속 시 같은 Session 객체에 Connection = newConn 으로 새 연결을 묶는다(상태 보존
의도는 옳다). 하지만 (B)(C) OnDisconnect 는 conn 이 현재 바인딩된 연결인지 확인하지
않고 무조건 Alive=false + Remove 한다 → 옛 연결의 종료 콜백이 재접속 "이후에" 도착하면
방금 재접속한 살아있는 세션을 죽이고 제거한다(정상 접속 중인 플레이어가 강제 종료).
(D) OnPacket 도 conn 검사 없이 세션 상태를 바꿔 옛 소켓의 늦은 패킷이 새 연결의
세션에 적용된다(스테일 입력). 게다가 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초 세션 유지 → 재접속 시 재바인딩, 시간 초과 시에만 정리. 옛/새 이벤트가 명확히 분리된다.
면접 포인트
- "stale callback" 패턴 — 비동기 이벤트는 늦게 도착한다. 콜백이 들고 온 세대/토큰과 현재 상태를 대조해 옛 이벤트를 무력화하는 게 핵심.
- 객체 동일성(
conn==s.Connection)보다 단조 증가 세대가 견고한 이유(연결 객체 풀링/재사용). - 재접속·종료·패킷은 계정 단위로 직렬화(락/액터). 빠른 재접속 레이스를 항상 가정하라.
해설 — 재접속 시 기존 세션 재바인딩과 옛 연결의 늦은 콜백/패킷 (C++)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
논리적 문제(연결 신원 미확인)는 C# 트윈과 같고, C++ 에서는 소유권/수명(UAF) 이 치명적이다.
(A) 재접속 시 같은 Session* 에 새 연결을 묶고 그 포인터를 새 연결 쪽이 들고 다닌다. 하지만
(B)(C) OnDisconnect 는 conn 신원을 확인하지 않고 delete it->second 로 세션을 통째로
파괴한다 → 옛 연결의 종료 콜백이 재접속 후 도착하면, 새 연결이 보유한 Session* 가
댕글링이 되어 이후 OnPacket 의 it->second->hp 또는 새 연결의 사용이 use-after-free.
(D) 옛 연결의 늦은 패킷도 세션을 변경. 동기화도 없다(다중 IO 스레드 → 데이터 레이스).
정답 한 줄: 세션을 shared_ptr 로 소유하고 연결마다 세대 토큰을 부여해, 종료/패킷은
현재 세대일 때만 동작시키며, 외부 보유는 weak_ptr 로 수명을 안전하게.
변별: problem4(비동기 종료 순서 UAF)가 "한 연결의 닫기/콜백 순서"라면, 본 문제는 재바인딩된 세션을 옛 연결이 파괴하는 reattach 고유의 UAF/오염이 핵심.
문제점
(B)(C) 신원 미확인 delete — use-after-free / 살아있는 세션 파괴 (메모리/수명) ★간판
- 분류 태그: use-after-free / wrong-owner deletion / missing identity check.
- 증상: 옛 연결의 종료가 재접속 후 도착 → 새 연결이 보유한
Session*를delete. 이후 그 포인터 사용은 UAF. 빠른 재접속 반복 시 double-free 도 가능. - 근본 원인: "누가 끊겼는가"와 "현재 바인딩"을 대조하지 않고, 생짜 포인터로 단독 소유.
(D) 신원 미확인 패킷 적용 — 스테일 입력 (정합)
- 분류 태그: stale packet applied.
- 증상: 옛 소켓의 늦은 패킷이 새 연결 세션의 hp 를 변경.
(A)+동시성 — 데이터 레이스 (동시성/UB)
- 분류 태그: data race on map/session (UB).
- 증상: connect/disconnect/packet 이 다른 스레드에서 같은 맵·세션 동시 접근.
- 근본 원인: 계정 단위 직렬화 부재.
수정안 (정확한 코드)
shared_ptr 소유 + 세대 토큰 + 계정 단위 락. 외부(연결)는 weak_ptr/세대로 안전하게 참조.
#include <unordered_map>
#include <memory>
#include <mutex>
struct Session2
{
long accountId;
long generation = 0; // 현재 바인딩 세대
int hp = 100;
bool alive = true;
};
class SessionManager2
{
std::unordered_map<long, std::shared_ptr<Session2>> byAccount_;
std::mutex mtx_; // 데모용(실서비스는 계정 단위 락/액터)
public:
// 반환: 세션과 이 연결의 세대(콜백이 들고 다닌다)
std::pair<std::shared_ptr<Session2>, long>
OnConnect(long accountId)
{
std::lock_guard<std::mutex> lk(mtx_);
auto& s = byAccount_[accountId];
if (!s) s = std::make_shared<Session2>(Session2{ accountId });
s->alive = true;
long gen = ++s->generation; // 옛 연결의 세대는 더 이상 현재가 아님
return { s, gen };
}
void OnDisconnect(long accountId, long gen)
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = byAccount_.find(accountId);
if (it != byAccount_.end() && it->second->generation == gen)
{
it->second->alive = false;
byAccount_.erase(it); // shared_ptr 참조계수로 안전 회수(보유자 있으면 유지)
}
// 세대 불일치 = 옛 연결 → no-op
}
void OnPacket(long accountId, long gen, int damage)
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = byAccount_.find(accountId);
if (it != byAccount_.end() && it->second->generation == gen)
it->second->hp -= damage;
}
};
핵심: ① shared_ptr 로 소유 → 누군가 보유 중이면 erase 해도 객체가 살아있어 UAF
없음(수명은 참조계수가 관리). ② 세대 토큰 → 옛 연결의 종료/패킷은 세대 불일치로 no-op.
외부 보유는 weak_ptr 로 들고 lock() 으로 안전하게 승격.
더 나은 설계 (+ 트레이드오프)
- 계정을 한 스레드/액터에 고정: 이벤트 순차 처리로 락·레이스 제거(트레이드오프: 라우팅).
- 연결 상태 머신 + 세대: Connected/Reattaching/Closed 와 세대로 늦은 이벤트를 일관되게 거른다.
- 재접속 유예 타이머: 끊김 후 N초 유지, 재접속 시 재바인딩, 만료 시에만 회수.
- 소유권 단일화: 세션 객체의 소유자를 매니저로 단일화하고 연결은
weak_ptr/핸들만 보유.
면접 포인트
- C++ 비동기 수명 관리: 콜백이 늦게 온다 → 객체 수명을
shared_ptr/weak_ptr로 잡고 "누가 소유하는가"를 단일화. 생짜 포인터 단독 소유 + 비동기 delete 는 UAF 의 지름길. - 세대 토큰으로 옛 연결을 무력화하는 패턴(객체 동일성 비교의 함정: 포인터/객체 재사용).
- 표준 컨테이너·세션은 외부 동기화 필요. 계정 단위 직렬화로 reattach 레이스 해소.