입장 처리를 기다리는 동안, 이미 나간 유저가 아직 접속 중으로 보였다
랜챗에 신고/차단 기능(슬라이스 6)을 붙이면서 onJoin을 비동기로 바꿔야 했다. 매칭 큐에 넣기 전에 DB에서 차단 목록을 적재해야, 신고당한 유저가 큐에 새어 들어가는 걸 막을 수 있었다(fail-closed). 차단 목록을 먼저 확정하고 나서야 매칭 큐에 넣는 순서를 지키지 않으면, 이미 신고·차단된 상대가 다시 매칭될 수 있었다. 그런데 이 변경 하나가 “누가 지금 접속 중인가”를 판단하는 로직 전체를 흔들어놨다.
버그는 이런 식으로 드러났다. 매칭 대기 중이던 유저가 창을 닫았는데, 서버는 한동안 그 유저를 여전히 접속 중으로 취급했다. 접속 중인 유저 수를 세거나 매칭 큐 상태를 판단하는 로직이 실제와 어긋나 있었던 것이다. 원인을 쫓아가 보니 Colyseus 0.15의 룸 생명주기 내부 순서 자체가 문제였다.
Colyseus의 _onJoin 순서
Colyseus 0.15의 Room._onJoin은 대략 이런 순서로 동작한다.
await this.onJoin(client, options, auth);
if (client.state === ClientState.LEAVING) {
throw new Error('early_leave');
}
// ...정상 입장 처리
우리가 작성한 onJoin이 먼저 끝까지 실행되고, 그 다음에야 클라이언트가 이미 나갔는지를 확인한다. 그리고 이 확인에서 걸려 early_leave가 throw되면, catch 블록에 가서야 _onLeave가 호출된다. 즉 정리(cleanup) 시점은 우리 onJoin이 끝나는 시점보다 항상 뒤에 온다.
await 사이에 생긴 유령 클라이언트
우리 onJoin은 이제 차단 목록을 불러오느라 DB를 기다린다.
async onJoin(client, options) {
const blocked = await fetchBlockedUserIds(client.userId); // 여기서 대기
this.enqueue(client, blocked);
}
이 await가 걸려 있는 동안 유저가 연결을 끊으면 어떻게 될까. Colyseus 내부 흐름상 onJoin이 resolve되기 전까지는 위 순서의 다음 단계(LEAVING 체크, _onLeave 호출)로 넘어가지 않는다. 그런데 우리가 작성한 onLeave는 아직 실행되지 않았고, Colyseus가 내부적으로 들고 있는 clientsById 맵에는 그 클라이언트가 여전히 남아 있었다. 겉보기엔 완전히 정상적으로 접속돼 있는 클라이언트와 구분이 안 된다.
그 결과 “지금 접속 중인 유저 목록”을 만들 때 clientsById에 있는지만 봤던 코드는 이미 나간 유저를 접속 중으로 잘못 셌다.
// 틀린 판단: clientsById에 있다고 접속 중인 건 아니다
const isConnected = this.clientsById.has(id);
이 판단을 기준으로 매칭이나 인원 수 표시 같은 로직을 짜면, 비동기 onJoin이 도는 짧은 구간마다 이미 나간 유저가 계속 끼어드는 상태가 반복된다.
가드가 하나로는 부족했던 이유
그래서 client.state === ClientState.LEAVING을 함께 보게 고쳤다. 그런데 반대 방향 경로도 있었다. room.disconnect()나 dispose 경로에서는 _onLeave가 먼저 실행된다. 즉 이번엔 우리 onLeave가 먼저 돌고 나서, 다른 시점의 onJoin이 뒤늦게 이어질 수 있는 상황이 생겼다. 이 경우엔 신원(identity) 가드가 필요했다 — 맵에 저장된 클라이언트가 지금 참조하고 있는 클라이언트와 같은 객체인지 확인하는 것이다. 같은 id로 재접속한 새 클라이언트가 맵을 다시 채워 넣었을 수도 있으니, id만 비교해서는 안 되고 객체 동일성까지 봐야 했다.
결국 두 가드가 모두 있어야 안전했다. 한쪽은 “아직 정리되지 않은 유령”을 걸러내고, 다른 한쪽은 “이미 자리를 내준 옛 클라이언트”를 걸러낸다.
function clientGone(id, client) {
return this.clientsById.get(id) !== client
|| client.state === ClientState.LEAVING;
}
TypeScript가 좁히기를 잘못한 이유
처음엔 이 두 조건을 같은 분기 안에 풀어서 썼다. 그런데 client.state === ClientState.LEAVING 같은 리터럴 비교를 한 함수 안에서 두 번 반복하면, TypeScript가 첫 번째 체크 이후 client.state 타입에서 LEAVING을 배제된 것으로 좁혀버렸다. 그래서 두 번째 비교를 “절대 겹칠 수 없는 비교”로 판단해 에러를 냈다.
TS 입장에서는 합리적인 추론이다. 한 함수 스코프 안에서 같은 변수의 상태가 도중에 바뀔 근거가 없다고 보는 게 일반적인 제어 흐름이기 때문이다. 하지만 이 코드는 두 비교 사이에 await가 끼어 있고, 그 사이 다른 이벤트 핸들러가 client.state를 바꿀 수 있다는 걸 TS는 알 도리가 없다. clientGone(id, client)처럼 별도 함수 호출로 감싸자, 함수 경계를 넘어가면서 이 좁히기가 풀려 문제가 사라졌다. 결과적으로 이 함수 분리는 가독성뿐 아니라 타입 체커를 속이지 않기 위한 장치이기도 했던 셈이다.
확인해서 안심할 수 있었던 부분
덧붙여 확인해 둔 게 하나 있다. Colyseus의 _onMessage 리스너는 onJoin이 resolve된 이후에야 부착된다. 그래서 차단 목록을 적재하는 동안 클라이언트가 보낸 메시지가 먼저 도착해 처리되는 경우는 없었다 — 그 방향은 걱정하지 않아도 됐다. 만약 반대였다면 onJoin이 아직 끝나지 않은 상태에서 채팅이나 시그널링 메시지가 먼저 처리되는 별개의 순서 문제가 하나 더 생겼을 것이다.
교훈
프레임워크의 생명주기 훅을 비동기로 만드는 순간, “이 핸들러가 도는 동안 대상이 사라질 수 있다”는 새로운 상태가 생긴다. 프레임워크가 정리(cleanup)를 정확히 언제 실행하는지 실제 소스로 확인하지 않으면, 우리가 들고 있는 자료구조는 이미 유효하지 않은데도 겉보기엔 유효해 보인다. 생존 여부를 판정할 땐 우리가 만든 맵이 아니라, 프레임워크가 노출하는 상태(client.state)까지 함께 봐야 한다. 그리고 그 상태가 await 도중 바뀔 수 있다는 사실은, 타입 체커에게도 별도로 알려줘야 할 때가 있다.