← 블로그 목록 2025.02.25

Socket.io로 실시간 멀티플레이 구현하기

Escape from Dogkov의 멀티플레이는 Socket.io 기반 WebSocket 통신으로 구현되어 있습니다. HTTP 폴링이 아닌 WebSocket을 사용하기 때문에 지연 없이 실시간으로 플레이어 간 상호작용이 가능합니다. 이 글에서는 멀티플레이 아키텍처의 핵심인 서버 권위 모델과 상태 동기화 방식을 설명합니다.

서버 권위 모델 (Server Authority)

멀티플레이 게임에서 가장 중요한 설계 결정 중 하나가 "누가 게임 상태를 결정하는가"입니다. 크게 두 가지 모델이 있습니다:

Dogkov는 서버 권위 모델을 채택했습니다. 클라이언트는 키 입력(WASD, 마우스 각도)만 서버로 전송하고, 서버가 30fps로 모든 플레이어의 이동을 계산합니다. 클라이언트가 "나는 지금 (500, 300)에 있다"라고 말할 수 없으므로, 위치 조작 같은 핵 사용이 불가능합니다.

입력과 상태 분리

통신 구조는 명확하게 두 방향으로 분리됩니다:

클라이언트 → 서버: 입력 전송

// 클라이언트: 매 프레임 입력 상태 전송
socket.emit("input", {
    w: keys.w,
    a: keys.a,
    s: keys.s,
    d: keys.d,
    angle: mouseAngle,
    mouseRight: isAiming
});

서버 → 클라이언트: 상태 브로드캐스트

// 서버: 30fps로 모든 클라이언트에 상태 전송
setInterval(() => {
    for (let rid in rooms) {
        const room = rooms[rid];
        if (!room.sessionActive) continue;

        // 모든 플레이어 이동 계산
        for (let id in room.players) {
            const p = room.players[id];
            const inp = room.playerInputs[id];
            if (!inp || p.hp <= 0) continue;

            let dx = 0, dy = 0;
            if (inp.w) dy -= spd;
            if (inp.s) dy += spd;
            if (inp.a) dx -= spd;
            if (inp.d) dx += spd;

            // 충돌 처리 후 위치 업데이트
            // ...
        }

        // 결과를 모든 플레이어에게 전송
        io.to(room.id).emit("state", {
            players: room.players,
            enemies: room.enemies,
            matchTime: room.matchTime
        });
    }
}, 1000 / 30);

서버는 1초에 30번(약 33ms 간격) 게임 상태를 계산하고 브로드캐스트합니다. 클라이언트는 이 상태를 받아 화면을 그리면 됩니다.

룸 시스템

Dogkov는 Socket.io의 room 기능을 활용하여 독립적인 게임 방을 관리합니다. 각 방은 자체 맵, 플레이어 목록, 스캐브, 타이머를 가집니다.

function createRoom(name, password, hostId) {
    const id = 'room_' + (roomIdCounter++);
    rooms[id] = {
        id,
        name,
        password: password || '',
        hostId,
        players: {},
        playerInputs: {},
        walls: generateMap(),     // 방마다 다른 맵
        enemies: [],
        matchTime: 600,           // 10분
        sessionActive: false,
    };
    return rooms[id];
}

socket.join(room.id)으로 플레이어를 방에 추가하고, io.to(room.id).emit()으로 해당 방의 플레이어에게만 메시지를 보냅니다. 이렇게 하면 여러 게임이 동시에 돌아가도 서로 간섭하지 않습니다.

전투 이벤트 처리

총알 발사와 피격 판정은 약간의 클라이언트 책임이 있습니다. 총알의 궤적 계산과 충돌 판정은 클라이언트에서 수행하고, 피격 결과만 서버로 보냅니다:

// 클라이언트: 총알이 적에게 맞았을 때
socket.emit("player_hit", {
    targetId: enemy.id,    // scav_0 또는 player socket id
    damage: weapon.damage,
    headshot: isHeadshot
});

// 서버: 피격 처리 (서버가 최종 판정)
socket.on("player_hit", (data) => {
    const room = getRoomBySocketId(socket.id);
    const attacker = room.players[socket.id];
    if (!attacker) return;

    if (data.targetId.startsWith('scav_')) {
        const scav = room.enemies.find(e => e.id === data.targetId);
        if (!scav || scav.dead) return;
        scav.hp -= Math.min(100, data.damage);
        // 어그로 전파 로직...
    }
});

순수하게 서버에서 총알 궤적까지 계산하면 가장 이상적이지만, 30fps 서버 틱에서 모든 총알을 추적하면 성능 부담이 큽니다. 현재 구조는 "클라이언트가 탐지하고 서버가 검증"하는 하이브리드 방식입니다.

재접속 시스템

브라우저 게임의 최대 약점은 페이지 새로고침 시 연결이 끊긴다는 점입니다. 이를 해결하기 위해 토큰 기반 재접속 시스템을 구현했습니다:

  1. 게임 시작 시 클라이언트에 고유 토큰을 발급하고 sessionStorage에 저장
  2. 연결 끊김 시 서버는 플레이어 데이터를 180초간 보관
  3. 재접속 시 토큰으로 이전 세션을 찾아 플레이어 상태 복원
socket.on("disconnect", () => {
    if (token && room.sessionActive && room.players[socket.id]) {
        room.disconnectedPlayers[token] = {
            playerData: { ...room.players[socket.id] },
            timeout: setTimeout(() => {
                delete room.disconnectedPlayers[token];
            }, 180000)  // 3분 유예
        };
    }
});

덕분에 실수로 새로고침해도 3분 이내에 돌아오면 같은 게임에 같은 위치로 복귀할 수 있습니다.

대역폭 관리

30fps로 전체 게임 상태를 매번 보내면 대역폭 낭비가 큽니다. 현재는 전체 상태를 보내고 있지만, 향후 다음 최적화를 계획하고 있습니다:

마치며

Socket.io의 WebSocket 통신과 서버 권위 모델을 결합하면, 비교적 적은 코드로도 안정적인 멀티플레이를 구현할 수 있습니다. 핵심은 "클라이언트는 입력만, 서버는 로직만" 원칙을 지키는 것입니다. 다음 글에서는 Tarkov 스타일의 인벤토리 시스템 구현에 대해 이야기하겠습니다.

← 이전 글: Canvas FPS 엔진 다음 글: 인벤토리 시스템 →