실시간 멀티플레이 게임 서버에서 연결 및 세션 관리는 단순히 클라이언트의 접속 여부를 확인하는 작업이 아니다. 네트워크 연결이 끊어진 플레이어를 어떻게 처리할 것인지, 재접속한 플레이어의 상태를 어떻게 복구할 것인지, 서버 종료 시 진행 중인 게임을 어떻게 안전하게 정리할 것인지까지 포함하는 서버 설계의 핵심 영역이다.
특히 WebSocket 기반 게임 서버에서는 네트워크 연결이 끊어지는 상황이 언제든지 발생할 수 있다. 클라이언트 강제 종료, 모바일 네트워크 전환, 일시적인 패킷 손실, 서버 장애 등 다양한 원인이 존재한다.
이때 연결 종료와 게임 퇴장을 동일하게 취급하면 일시적인 네트워크 문제만으로 플레이어가 게임에서 제거될 수 있다. 반대로 연결이 끊긴 플레이어를 무기한 유지하면 사용하지 않는 세션이 누적되어 서버 자원을 낭비하게 된다.
따라서 연결 관리 시스템은 다음과 같은 문제를 해결해야 한다.
- 연결 종료와 게임 퇴장을 구분한다.
- 재접속 시 기존 플레이어의 상태를 복구한다.
- 세션의 유효 기간과 타임아웃을 관리한다.
- 동일 계정의 중복 접속을 제어한다.
- 서버 종료 시 연결과 게임 상태를 안전하게 정리한다.
연결 종료와 게임 퇴장의 차이
연결 종료(Connection Disconnect)
연결 종료는 클라이언트와 서버 사이의 네트워크 통신이 끊어진 상태를 의미한다.
예를 들어 플레이어가 게임을 진행하던 중 Wi-Fi에서 모바일 데이터로 전환하거나, 네트워크 장애가 발생하거나, 게임 프로세스가 강제 종료될 수 있다.
이러한 상황에서는 서버가 클라이언트와 통신할 수 없지만, 플레이어가 게임을 완전히 떠나려 했는지는 알 수 없다.
따라서 연결 종료가 발생했다고 해서 반드시 게임에서 플레이어를 제거해서는 안 된다.
게임 퇴장(Game Leave)
게임 퇴장은 플레이어가 현재 게임 세션에서 나가겠다는 의사를 표시하거나, 서버가 정책에 따라 해당 플레이어를 게임에서 제거하는 행위다.
예를 들어 다음과 같은 상황이 있다.
- 플레이어가 로비에서 나가기 버튼을 누른다.
- 플레이어가 게임 종료를 요청한다.
- 비정상적인 행위로 인해 서버가 플레이어를 강제 퇴장시킨다.
- 재접속 유예 시간이 만료되어 서버가 플레이어를 제거한다.
게임 퇴장은 단순한 네트워크 연결 종료보다 더 큰 의미를 가진다. 플레이어를 방에서 제거하고, 인원수를 갱신하고, 게임 규칙에 따라 전투나 매치 상태를 변경해야 할 수 있다.
두 상태를 분리해야 하는 이유
예를 들어 4인 협동 공포 게임에서 플레이어 한 명이 인터넷 연결 문제로 접속을 잃었다고 가정하자.
연결 종료와 게임 퇴장을 동일하게 처리하면 해당 플레이어는 즉시 방에서 제거된다. 다시 접속하더라도 기존 방으로 돌아갈 수 없고, 다른 플레이어의 진행 상태에도 불필요한 영향을 줄 수 있다.
반대로 연결 종료 상태를 별도로 관리하면 일정 시간 동안 플레이어의 자리를 유지할 수 있다.
이때 서버는 플레이어의 캐릭터를 일시적으로 비활성화하거나 AI로 대체하고, 재접속이 성공하면 기존 상태를 복구할 수 있다.
핵심은 네트워크 연결 상태와 게임 참여 상태를 별도의 상태로 관리하는 것이다.
| 구분 | 연결 종료 | 게임 퇴장 |
|---|---|---|
| 의미 | 네트워크 통신 중단 | 게임 참여 종료 |
| 발생 원인 | 네트워크 장애, 프로세스 종료 | 사용자 요청, 서버 정책 |
| 방에서 제거 | 즉시 제거하지 않을 수 있음 | 일반적으로 제거 |
| 재접속 | 기존 세션 복구 가능 | 새로운 참여 절차가 필요할 수 있음 |
| 게임 상태 | 유예 기간 동안 유지 가능 | 게임 규칙에 따라 정리 |
연결 및 세션 상태 머신
연결 관리 로직이 복잡해지는 것을 방지하려면 플레이어의 상태를 명시적으로 정의하는 것이 좋다.
예를 들어 다음과 같은 상태 머신을 구성할 수 있다.
CONNECTED
|
| 연결 종료
v
DISCONNECTED
|
| 재접속 성공
v
CONNECTED
DISCONNECTED
|
| 유예 시간 만료
v
LEFT
CONNECTED
|
| 명시적인 퇴장 요청
v
LEFT
각 상태의 의미는 다음과 같다.
CONNECTED: 클라이언트와 서버가 정상적으로 통신하는 상태DISCONNECTED: 연결은 끊어졌지만 기존 게임 참여 상태를 유지하는 상태LEFT: 게임 참여가 종료되어 세션 정리가 완료된 상태
실제 구현에서는 연결 상태와 게임 참여 상태를 더 세분화할 수도 있다. 예를 들어 인증 완료, 매칭 대기, 게임 진행, 재접속 대기, 퇴장 처리 중 등의 상태를 별도로 관리할 수 있다.
중요한 점은 상태 전이 조건을 명확하게 정의하는 것이다. 여러 이벤트가 동시에 발생하더라도 이미 퇴장 처리된 플레이어가 다시 복구되거나, 동일한 퇴장 처리가 여러 번 실행되지 않도록 해야 한다.
재접속 및 상태 복구(Reconnection and State Recovery)
재접속이 필요한 이유
실시간 게임에서는 네트워크 연결이 일시적으로 끊어질 수 있다. 서버가 이를 항상 영구적인 이탈로 판단하면 사용자 경험이 나빠진다.
재접속 시스템은 기존 플레이어를 식별하고, 해당 플레이어가 속했던 게임 상태를 복원하는 기능이다.
일반적인 처리 과정은 다음과 같다.
클라이언트 연결 종료
|
v
서버가 연결 종료 감지
|
v
플레이어를 DISCONNECTED로 변경
|
v
재접속 유예 시간 동안 세션 유지
|
v
클라이언트 재접속 요청
|
v
인증 및 세션 검증
|
v
기존 플레이어 상태 복구
|
v
최신 게임 상태 전송
재접속이 성공하려면 기존 플레이어를 식별할 수 있어야 한다. 단순히 새로운 WebSocket 연결이 생성되었다는 사실만으로 기존 플레이어라고 판단해서는 안 된다.
재접속 토큰
클라이언트가 재접속할 때 사용할 수 있도록 별도의 재접속 토큰을 발급할 수 있다.
interface ReconnectSession {
playerId: string;
roomId: string;
tokenHash: string;
expiresAt: number;
}
playerId: 기존 플레이어 식별자roomId: 기존 플레이어가 속한 방tokenHash: 재접속 토큰 검증에 사용할 해시값expiresAt: 재접속 허용 만료 시각
클라이언트는 재접속 요청과 함께 토큰을 전달한다. 서버는 토큰의 유효성, 만료 여부, 방의 존재 여부, 플레이어 상태 등을 검증한 뒤 기존 세션을 복구한다.
실제 서비스에서는 토큰 원문을 로그에 남기거나 평문으로 영구 저장하지 않는 것이 좋다. 토큰은 충분한 무작위성을 확보하고, 만료 시간과 재사용 정책을 명확하게 설정해야 한다.
재접속 상태 복구
네트워크 연결을 복구했다고 해서 게임 상태까지 자동으로 복구되는 것은 아니다.
예를 들어 플레이어가 연결을 잃은 동안 다른 플레이어들이 이동하고, 적이 공격하고, 아이템이 사용되었을 수 있다.
따라서 서버는 재접속한 클라이언트에 최신 상태를 전달해야 한다.
interface PlayerSnapshot {
playerId: string;
position: {
x: number;
y: number;
z: number;
};
health: number;
inventory: string[];
lastProcessedInputSequence: number;
}
이 스냅샷은 개념적인 예시다. 실제로는 게임 규칙에 따라 필요한 상태만 포함해야 한다.
서버는 인증된 플레이어의 최신 상태를 읽고, 클라이언트에 전달한다. 클라이언트는 기존 화면을 서버 상태에 맞춰 갱신한다.
lastProcessedInputSequence처럼 마지막으로 처리한 입력 번호를 관리하면 재접속 과정에서 이미 처리한 입력을 중복 적용하는 문제도 줄일 수 있다.
상태 복구에서 주의할 점
클라이언트가 보관한 데이터를 무조건 신뢰해서는 안 된다.
체력, 재화, 인벤토리, 아이템 소유권, 공격 결과 등 중요한 상태는 서버가 권한을 가지고 있어야 한다. 클라이언트는 복구 요청을 보낼 수 있지만, 복구할 실제 상태는 서버가 결정해야 한다.
또한 재접속은 기존 세션을 복구하는 작업이지, 게임 상태를 이전 시점으로 되돌리는 작업이 아니다. 서버가 이미 확정한 게임 진행 결과는 일반적으로 유지되어야 한다.
세션 유지와 타임아웃(Session Management and Timeout)
세션이란 무엇인가?
세션은 특정 사용자의 인증 상태나 게임 참여 상태를 일정 시간 동안 유지하기 위한 논리적 단위다.
WebSocket 연결과 세션은 동일한 개념이 아니다.
- 연결(Connection): 실제 네트워크 통신 경로
- 세션(Session): 사용자의 인증 및 게임 참여를 나타내는 논리적 상태
하나의 세션이 연결 종료 후에도 일정 시간 유지될 수 있다. 이 구조가 있어야 네트워크가 일시적으로 끊어졌을 때 기존 게임 참여 상태를 복구할 수 있다.
타임아웃의 종류
세션 관리에서는 목적에 따라 서로 다른 타임아웃을 사용한다.
연결 타임아웃(Connection Timeout)
연결을 설정하는 과정이 일정 시간 안에 완료되지 않을 때 연결 시도를 중단한다.
유휴 타임아웃(Idle Timeout)
일정 시간 동안 유효한 활동이 없으면 연결이나 세션을 종료하는 정책이다. 다만 실시간 게임에서는 입력이 없는 플레이어도 정상적인 사용자일 수 있으므로, 단순히 게임 입력이 없다는 이유만으로 종료해서는 안 된다.
하트비트 타임아웃(Heartbeat Timeout)
클라이언트와 서버가 주기적으로 Ping/Pong 또는 별도의 하트비트 메시지를 교환하고, 일정 시간 동안 응답이 없으면 연결이 끊어진 것으로 판단한다.
재접속 유예 시간(Reconnection Grace Period)
연결이 끊어진 뒤 기존 게임 세션을 유지하는 시간이다. 이 시간이 지나면 서버는 플레이어를 퇴장 처리할 수 있다.
인증 세션 만료(Session Expiration)
인증 정보의 유효 기간이 만료되는 시점이다. 게임 연결이 살아 있더라도 인증 정책에 따라 재인증이 필요할 수 있다.
하트비트 구현 예시
Node.js와 WebSocket을 사용하는 경우 하트비트를 통해 연결 상태를 확인할 수 있다.
import WebSocket from "ws";
const HEARTBEAT_INTERVAL_MS = 10_000;
const HEARTBEAT_TIMEOUT_MS = 30_000;
interface ClientConnection {
ws: WebSocket;
lastPongAt: number;
}
function startHeartbeat(
clients: Set<ClientConnection>
): NodeJS.Timeout {
return setInterval(() => {
const now = Date.now();
for (const client of clients) {
if (
now - client.lastPongAt >
HEARTBEAT_TIMEOUT_MS
) {
client.ws.terminate();
continue;
}
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.ping();
}
}
}, HEARTBEAT_INTERVAL_MS);
}
ws 라이브러리에서는 Pong 이벤트를 받아 마지막 응답 시각을 갱신할 수 있다.
ws.on("pong", () => {
client.lastPongAt = Date.now();
});
위 코드는 하트비트의 핵심 원리를 보여주는 예시다. 실제 서비스에서는 연결 객체의 등록과 제거, 타이머 정리, 이벤트 중복 실행, 종료 중인 서버의 상태 등을 함께 처리해야 한다.
또한 terminate()는 네트워크 연결을 강제로 종료하는 동작이다. 이것만으로 게임 세션을 퇴장 처리했다고 간주해서는 안 된다. 연결 종료 이벤트를 통해 별도의 세션 상태 전이 로직이 실행되어야 한다.
타임아웃을 하나로 통합하면 안 되는 이유
모든 상황에 동일한 타임아웃을 적용하면 불필요한 종료나 자원 낭비가 발생한다.
예를 들어 하트비트 응답이 없다는 이유로 게임 세션을 즉시 삭제하면, 일시적인 네트워크 장애가 발생했을 때 재접속 기회를 잃게 된다.
반대로 재접속 유예 시간을 지나치게 길게 설정하면 실제로 이탈한 플레이어의 자리가 오랫동안 점유된다.
따라서 연결 감지 타임아웃과 게임 세션 유지 시간을 분리하고, 게임 장르와 서버 정책에 맞춰 각각 설정해야 한다.
중복 세션 방지(Duplicate Session Prevention)
중복 세션이 발생하는 원인
중복 세션은 동일한 사용자가 여러 연결을 통해 동시에 게임 서버에 접속하거나, 재접속 과정에서 기존 연결과 새 연결이 동시에 살아 있는 상태를 의미한다.
대표적인 원인은 다음과 같다.
- 동일 계정으로 여러 클라이언트에서 로그인한다.
- 네트워크가 끊어진 뒤 재접속을 시도했지만 기존 연결이 아직 정리되지 않았다.
- 클라이언트가 재접속 요청을 여러 번 전송한다.
- 서버가 연결 종료 이벤트를 처리하기 전에 새로운 연결이 등록된다.
중복 세션을 방치하면 동일 계정이 여러 번 게임 명령을 전송하거나, 한 플레이어의 상태가 서로 다른 연결에서 갱신되는 문제가 발생할 수 있다.
단일 세션 정책
동일 계정에 하나의 활성 세션만 허용하려면 계정 식별자를 기준으로 현재 세션을 관리할 수 있다.
const activeSessions = new Map<
string,
WebSocket
>();
function registerSession(
playerId: string,
ws: WebSocket
): void {
const existing = activeSessions.get(playerId);
if (existing && existing !== ws) {
existing.close(4001, "Replaced by new session");
}
activeSessions.set(playerId, ws);
}
위 예시는 단일 Node.js 프로세스 안에서 중복 연결을 처리하는 기본적인 구조다.
하지만 이것만으로 완전한 중복 세션 방지가 보장되지는 않는다.
기존 연결이 종료되는 과정에서 close 이벤트가 늦게 도착할 수 있다. 이때 기존 연결의 종료 핸들러가 activeSessions.delete(playerId)를 무조건 실행하면 새 연결까지 등록된 상태에서 세션 정보가 삭제될 수 있다.
따라서 제거할 때는 현재 등록된 연결이 실제로 자신인지 확인해야 한다.
function unregisterSession(
playerId: string,
ws: WebSocket
): void {
if (activeSessions.get(playerId) === ws) {
activeSessions.delete(playerId);
}
}
이처럼 세션을 등록한 연결과 제거를 요청한 연결이 동일한지 검증하면 오래된 연결이 새 연결의 상태를 훼손하는 문제를 방지할 수 있다.
분산 서버에서의 중복 세션 방지
서버가 여러 인스턴스로 운영된다면 Map만으로는 충분하지 않다.
예를 들어 서버 A와 서버 B가 각각 같은 사용자의 연결을 관리하면 두 서버 모두 해당 사용자를 활성 상태로 판단할 수 있다.
이때 Redis와 같은 공유 저장소를 이용해 세션 소유권을 관리할 수 있다.
개념적으로 다음과 같은 정보를 저장한다.
session:{playerId}
ownerServerId
connectionId
generation
expiresAt
여기서 generation은 새로운 세션 소유권이 부여될 때 증가하는 세대 번호다. 이전 연결에서 늦게 도착한 작업이 새로운 세션의 상태를 변경하지 못하도록 검증하는 데 활용할 수 있다.
단, Redis에 단순히 값을 기록하는 것만으로 원자성이 자동 보장되지는 않는다. 여러 서버가 동시에 세션을 등록할 수 있으므로 조건부 갱신, 원자적 스크립트 또는 적절한 잠금 전략을 사용해야 한다.
또한 연결이 끊긴 서버의 세션 정보가 영구히 남지 않도록 만료 시간을 설정하고, 하트비트나 임대(Lease) 갱신을 통해 소유권을 관리할 수 있다.
중복 세션 정책은 서비스에 따라 달라진다
모든 게임이 반드시 기존 연결을 종료해야 하는 것은 아니다.
- 경쟁 게임: 계정당 단일 활성 세션을 강제할 수 있다.
- 협동 게임: 계정당 단일 세션을 유지하되 재접속을 허용할 수 있다.
- 여러 기기 지원 서비스: 인증 세션은 여러 개 허용하되 특정 게임 매치에는 하나만 참여하도록 제한할 수 있다.
따라서 인증 세션, 게임 연결, 매치 참여를 구분해 중복 허용 범위를 설계해야 한다.
Graceful Shutdown
Graceful Shutdown이란?
Graceful Shutdown은 서버를 종료할 때 진행 중인 작업과 연결을 가능한 한 안전하게 정리하는 절차다.
서버 프로세스를 즉시 종료하면 다음과 같은 문제가 발생할 수 있다.
- 진행 중인 요청이 처리되지 못한다.
- 게임 상태가 저장되기 전에 프로세스가 종료된다.
- WebSocket 연결이 예고 없이 끊어진다.
- 세션 소유권이나 매치 상태가 잘못 유지된다.
- 클라이언트가 서버 장애와 정상적인 서버 종료를 구분하기 어렵다.
특히 컨테이너 환경에서 배포하거나 서버 인스턴스를 교체할 때 Graceful Shutdown은 중요하다.
일반적인 종료 절차
SIGTERM / SIGINT 수신
|
v
서버를 종료 상태로 전환
|
v
신규 연결 및 매칭 요청 차단
|
v
기존 연결에 서버 종료 안내
|
v
진행 중인 작업 정리 및 상태 저장
|
v
WebSocket 연결 종료
|
v
DB / Redis / 기타 자원 정리
|
v
프로세스 종료
종료 순서에서 중요한 것은 신규 작업을 먼저 차단하고, 이미 진행 중인 작업을 정리한 뒤 자원을 해제하는 것이다.
게임 서버라면 종료 시점에 모든 게임을 무조건 저장하고 끝내는 것이 항상 최선은 아니다. 게임의 일관성 요구사항에 따라 매치를 중단하거나, 다른 서버로 이전하거나, 진행 상태를 저장하고 재접속을 허용하는 등의 정책을 선택해야 한다.
Node.js 서버 종료 예시
import http from "node:http";
import { WebSocketServer } from "ws";
const server = http.createServer();
const wss = new WebSocketServer({ server });
let shuttingDown = false;
server.listen(3000);
async function shutdown(): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
// 1. 신규 연결 및 매칭 요청을 차단한다.
// 애플리케이션의 readiness 상태를 false로 변경한다.
// 2. 진행 중인 게임 작업을 정리한다.
// 실제 구현에서는 게임별 정책에 따라 상태를 저장한다.
// 3. 클라이언트에 종료를 알린다.
for (const ws of wss.clients) {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({
type: "SERVER_SHUTDOWN",
message: "Server is shutting down"
}));
}
}
// 4. WebSocket 서버의 연결을 종료한다.
wss.close();
for (const ws of wss.clients) {
ws.close(1001, "Server shutting down");
}
// 5. HTTP 서버의 신규 연결 수락을 중단한다.
server.close();
// 6. DB, Redis 등 애플리케이션 자원을 정리한다.
// await database.close();
// await redis.quit();
}
process.on("SIGTERM", () => {
void shutdown();
});
process.on("SIGINT", () => {
void shutdown();
});
이 코드는 종료 절차를 설명하기 위한 기본 예시다. 운영 환경에 적용하려면 추가적인 처리가 필요하다.
wss.close()와server.close()의 완료 콜백 또는 Promise를 기다린다.- 종료 안내 메시지가 전송되기 전에 연결을 닫지 않도록 한다.
- 종료 제한 시간을 설정하고, 제한 시간 내에 정리되지 않은 연결은 강제로 종료한다.
- 중복된 종료 신호가 발생해도 정리 작업이 한 번만 실행되도록 한다.
- 데이터베이스와 Redis의 연결을 실제로 해제하고, 실패 시 정책을 적용한다.
- 컨테이너나 프로세스 관리자가 지정한 종료 유예 시간 안에 작업을 마친다.
또한 close()는 일반적으로 정상적인 WebSocket 종료 절차를 시작하지만, 상대방의 응답이 없으면 연결이 계속 남을 수 있다. 따라서 일정 시간 후 terminate()로 강제 종료하는 보조 절차가 필요할 수 있다.
로드 밸런서와의 연계
여러 서버 인스턴스를 운영한다면 Graceful Shutdown은 로드 밸런서와도 연계해야 한다.
서버를 종료하기 전에 신규 트래픽을 받지 않도록 readiness 상태를 변경하고, 로드 밸런서가 해당 서버로 신규 요청을 보내지 않도록 해야 한다.
다만 로드 밸런서에서 서버를 제외했다고 해서 기존 WebSocket 연결이 자동으로 종료되거나 다른 서버로 이동하는 것은 아니다. 기존 연결의 처리 방식은 별도로 설계해야 한다.
필요하다면 클라이언트에 재접속 안내를 전송하고, 재접속 시 새로운 서버에서 기존 게임 상태를 복구하도록 구성할 수 있다.
연결 및 세션 관리에서 고려해야 할 보안 문제
세션 관리에는 사용자 인증뿐 아니라 연결 소유권과 상태 변경 권한도 포함된다.
재접속 토큰 탈취
재접속 토큰이 유출되면 공격자가 다른 사용자의 세션을 탈취할 수 있다. 토큰의 충분한 무작위성, 안전한 전송, 만료, 재사용 제한을 고려해야 한다.
세션 고정 및 세션 탈취
인증 전후에 세션 식별자를 적절히 갱신하고, 세션 소유권을 서버에서 검증해야 한다.
중복 요청 및 재생 공격
재접속 요청이나 게임 명령이 반복되어도 같은 작업이 중복 실행되지 않도록 요청 식별자, 순서 번호, 멱등성 정책 등을 적용할 수 있다.
권한 검증
클라이언트가 전달한 playerId나 roomId만으로 복구 대상 세션을 결정해서는 안 된다. 인증된 사용자와 세션의 소유권이 일치하는지 서버에서 확인해야 한다.
서비스 거부 공격
짧은 시간에 대량의 연결이나 재접속 요청이 발생할 수 있으므로 연결 수 제한, 요청 빈도 제한, 인증 전 연결 제한 등의 방어가 필요하다.
실무 설계 체크리스트
| 항목 | 확인할 사항 |
|---|---|
| 연결 종료 | 연결 종료와 게임 퇴장을 구분했는가? |
| 상태 머신 | 상태 전이와 중복 실행 방지 규칙이 있는가? |
| 재접속 | 기존 플레이어를 안전하게 식별할 수 있는가? |
| 상태 복구 | 서버 권한 상태를 기준으로 복구하는가? |
| 타임아웃 | 연결 감지와 재접속 유예 시간을 분리했는가? |
| 하트비트 | 응답이 없는 연결을 탐지할 수 있는가? |
| 중복 세션 | 동일 계정의 연결 정책이 명확한가? |
| 분산 환경 | 여러 서버에서 세션 소유권을 일관되게 관리하는가? |
| Graceful Shutdown | 신규 작업 차단부터 자원 해제까지 순서가 정의되어 있는가? |
| 보안 | 재접속 토큰, 권한 검증, 요청 재생 방어가 적용되어 있는가? |
| 관측 가능성 | 연결 수, 재접속 성공률, 세션 만료 수, 종료 시간 등을 기록하는가? |