본문 바로가기
프로젝트

24/11/03 - [실습] Node.js 플러스(2 - 11): 레이턴시(Latency)(feat. 라운드 트립 레이턴시), 인터벌 매니저(Interval Manager)

by Jini_Lamp 2024. 11. 3.

레이턴시(Latency)데이터가 한 지점에서 다른 지점으로 이동하는 데 걸리는 시간을 의미한다. 즉, 쉽게 말해 데이터 지연 시간이다.

레이턴시 과정

 

이처럼 레이턴시는 한 지점에서 다른 지점으로 데이터가 도로 이동하는 시간을 의미한다.

 

한편, 데이터가 왕복하는 시간은 라운드 트립 레이턴시(Round Trip Latency), 또는 라운드 트립 타임(RTT, Round Trip Time)이라고 부른다.

해당 기능은 데이터 패킷이 한 지점에서 다른 지점으로 전송된 후, 다시 원래 지점으로 돌아오는 데 걸리는 시간을 의미한다. 즉, 데이터가 목적지에 도달하고 응답이 다시 출발지로 되돌아오는 데 걸리는 시간이 바로 라운드 트립 레이턴시이다.

 

이러한 과정은 핑(Ping) 명령어를 통해 측정할 수 있는데, 핑 명령어는 네트워크 상에서 특정 서버나 장치가 연결 가능한지 확인하고, 그 왕복 시간(RTT)을 측정하는 도구이다.

 

 

하지만 네트워크 환경에 따라 레이턴시와 RTT 간의 차이가 발생할 수 있다.

이를 개선하기 위해 다양한 기술이 존재하는데, 이는 다음과 같다.

  • 레이턴시 마스킹(Latency Masking)
    네트워크 지연을 사용자가 느끼지 못하도록 숨기는 기술
    이를 통해 사용자 경험을 개선하고, 네트워크 지연에 따른 불편을 최소화할 수 있다.

  • 추측항법(Dead Reckoning)
    예측 및 보정 기법 중 하나로, 이미 지나간 시간(여기서는 레이턴시) 만큼을 예측하여 데이터를 미리 전달
    예시는 다음과 같다.
    • 100ms인 상황에서 속도가 1이고, 1초에 한번 패킷을 전달하는 경우
    • 1초 뒤에 보낸 패킷은 1.1초에 도착
    • 즉, '거리 = 속력 X 시간' 공식에 따라 '1.1 X 1' 만큼의 값을 전달

그러니 만약 사용자가 1초 동안 x축으로 1만큼 움직인다면, 패킷은 1.1초에 도착하므로 서버는 미리 '1.1 X 1'을 계산하여 클라이언트에게 보내준다.

이 과정에서  '거리 = 속력 X 시간' 공식을 사용하기 때문에, 이동 방향에 따라 속력 값을 다르게 계산해 예측된 위치 값을 전달해야 한다.

 

 

 

레이턴시와 추측항법의 관계

레이턴시와 추측항법은 상호 보완적 관계다. 레이턴시로 인해 데이터 전송이 지연될 때, 추측 항법은 지연 시간 동안의 위치 변화를 예측하여 실시간 경험을 유지하게 된다.

즉, 레이턴시가 발생해도 클라이언트와 서버가 미리 예측된 위치 데이터를 주고 받으며, 레이턴시로 인한 시각적 차이를 줄일 수 있다.

 

따라서 레이턴시가 높을 수록 추측항법의 중요성이 커지며, 이를 통해 네트워크 지연으로 발생할 수 있는 불편을 최소화할 수 있다. 추측항법은 실시간 애플리케이션에서 네트워크 지연을 보완하는 핵심 기술이며, 레이턴시와 결합하여 원활하고 지연이 적은 사용자 경험을 제공하는 데 중요한 역할을 한다.

 

 

 

프로젝트에 적용되는 내용

지금까지 진행한 프로젝트를 볼 때, 서버 내에 게임 세션이 여러 개 생성되고, 해당 세션에는 각각의 유저들(최대 4명)이 게임에 참여했다.

 

각 세션을 기준으로, 게임에 참여한 유저들의 라운드 트립 레이턴시를 측정해 서버에 기록한다.

이때 핑의 값이 평균이나 가장 높은 것을 사용하는 방법이 있는데, 평균을 사용할 경우 최소/최대 값이 차이가 큰 유저간의 차이가 커지기 때문에 해당 프로젝트에서는 핑의 값이 가장 높은 값을 사용할 것이다. 이 경우, 모두에게 느리게 보이기 때문에 안정적인 멀티 플레이 동기화 환경을 제공할 수 있다.

 

또한 이를 측정하는 방법도 간단한데, 아래 이미지처럼 특정 시간마다 핑을 계산할 것이다.(보통 10초에 한번씩 측정한다고 한다.)

이때 점점 속도가 빨라지면 베스트 시나리오고, 네트워크 환경이 좋지 않아 핑이 튀는 유저가 있을 경우, 최악의 환경이 될 수 있다.(이는 핑을 측정하는 시간을 변경하는 식으로 대응할 수 있다고 한다.)

베스트 시나리오와 최악의 시나리오

 

자, 여기까지 왔다면 이젠 코드를 수정해볼 차례이다.

 

 

 

라운드 트립 레이턴시 측정

지금부터 할 일은 게임에 접속 중인 유저들의 라운드 트립 레이턴시(= 핑)를 구하는 일이다.

그러기 위해 해당 기능은 유저 클래스에 있는 게 가장 적절할 것이다.

 

하지만 그 전에 먼저, common.proto에 핑 패킷을 만들 것이다. 이는 서버에서 ping을 보내는 시간을 timestamp에 담아 보내고, 클라이언트는 이를 받아 그대로 다시 서버에게 반환한다. 이로 인해 '받은 시간 - 보낸 시간'이 성립되어 레이턴시 값을 구할 수 있게 된다.

// 핑 패킷
message Ping {
  int64 timestamp = 1;      // Ping 타임스탬프
}

 

패킷을 만들었으면 packetNames.js에 패킷을 등록한다.

export const packetNames = {
    // 문자열 안에 들어가는 이름은 ‘[package].[packet name]’ 의 형태일 것
    common: {
        Packet: 'common.Packet',
        Ping: 'common.Ping',
    },
    // 중략
};

 

이후 utils/notification/game.notification.js 파일을 생성하고 createPingPacket 함수를 통해 핑 패킷을 만드러주는 코드를 작성할 것이다.

더보기
import { config } from '../../config/config.js';
import { PACKET_TYPE } from '../../constants/header.js';
import { getProtoMessages } from '../../init/loadProtos.js';

// 해당 함수는 범용적으로 다른 notificatio들에게도 쓰일 예정이다.
const makeNotification = (message, type) => {
    // 패킷 길이 정보를 포함한 버퍼 생성
    const packekLength = Buffer.alloc(config.packet.totalLength);
    packekLength.writeUint32BE(
        message.length + config.packet.totalLength + config.packet.typeLength,
        0,
    );

    // 패킷 타입 정보를 포함한 버퍼 생성
    const packatType = Buffer.alloc(config.packet.typeLength);
    packatType.writeUInt8(type, 0);

    return Buffer.concat([packekLength, packatType, message]);
};

// 핑 패킷을 만든다.
export const createPingPacket = (timestamp) => {
    const protoMessage = getProtoMessages();
    const ping = protoMessage.common.Ping;

    const payload = { timestamp };
    const message = ping.create(payload);
    const pingPacket = ping.encode(message).finish();

    return makeNotification(pingPacket, PACKET_TYPE.PING);
};

 

여기까지 완성했다면 User 클래스로 가서 ping 관련 메서드를 추가한다.

이때, 라운드 트립 레이턴시를 측정하기 위해서는 '보내는 것'과 '받는 것'이 필요하다. 따라서 클래스 내에 다음과 같은 코드를 추가한다.

  • user.class.js
class User {
    // 중략
    // 보내고
    ping() {
        const now = Date.now();
        console.log(`${this.id}: ping`);
        this.socket.write(createPingPacket(now));
    }

    // 받는다
    handlePong(data) {
        const now = Date.now();
        this.latency = (now - data.timestamp) / 2;
        // console.log(`Received pong from user ${this.id} at ${now} with latency ${this.latency}ms`);
    }
}

 

지금까지 유저 클래스에 핑 관련 메서드를 추가하는 작업을 진행했다.

 

 

 

인터벌 매니저(Interval Manager)

이번에 할 일은 인터벌 매니저(Interval Manager)을 추가하는 일이다.

그 전에 잠시 인터벌 매니저는 무엇인지 알고 넘어가자.

 

인터벌 매니저(Interval Manager)란, 주기적으로 특정 작업이나 이벤트를 실행하도록 관리하는 기능을 의미한다.

보통 비동기 프로그래미이나 타이머 기반 작업에서 사용되며, 특정 인터벌(시간 간격)마다 작업이 반복되도록 설정한다. 

 

현재 프로젝트에서는 서버에서 다수의 게임 세션이 생성되고, 세션 안에는 유저 4명이 각각 존재한다.

이때, 유저들은 일정 주기마다 핑(ping)을 보내게 되는데, 이때, 이 주기를 유저들이 관리해도 되지만, 이를 다른 곳에서 관리하는 것도 나쁘지 않은 방법이다.

이때 생겨난 것이 매니저라는 개념이며, 그 개념 안에 또다시 인터벌 매니저라는 것이 생겼다.

 

그럼 바로 코드를 작성해 보자.

먼저, 앞으로 서버에 적용할 수 있는 각종 매니저들의 부모 클래스를 만들어 보자.(지금은 이 클래스를 이용해 인터벌 매니저를 만들 것이다.)(그 외에도 몬스터 생성에 관한 매니저나, 보스 몬스터에 대한 매니저 등을 추가할 수 있다.)

  • classes/managers/base.manager.js
더보기
class BaseManager {
    constructor() {
        // 부모 매니저 그 자체로 생성되지 못하도록...
        if (new.target === BaseManager) {
            throw new TypeError('Cannot construct BaseManager instances directly');
        }
    }

    // 자식 클래스들이 상속 받아 사용할 메서드들 미리 선언
    addPlayer(playerId, ...args) {
        throw new Error('Must implement addPlayer method');
    }

    removePlayer(playerId) {
        throw new Error('Must implement removePlayer method');
    }

    clearAll() {
        throw new Error('Must implement clearAll method');
    }
}

export default BaseManager;

 

이제, BaseManager 클래스를 상속받을 IntervalManager 클래스를 만들어 보자.

  • classes/managers/interval.manager.js
더보기
import BaseManager from './base.manager.js';

class IntervalManager extends BaseManager {
    constructor() {
        super();
        this.intervals = new Map(); // 유저는 하나의 인터벌 매니저만 갖을 수 있기 때문에...
    }

    // 유저마다 인터벌 매니저 생성
    addPlayer(playerId, callback, interval, type = 'user') {
        if (!this.intervals.has(playerId)) {
            this.intervals.set(playerId, new Map());
        }
        this.intervals.get(playerId).set(type, setInterval(callback, interval));
    }

    // addGame(gameId, callback, interval) {
    //     this.addPlayer(gameId, callback, interval, 'game');
    // }

    // 유저의 위치 이동을
    // 1. 인터벌을 통해 주기적으로 서버 -> 클라로 업데이트 하는 방법
    // 2. 유저가 요청을 받았을 때 업데이트
    // 여기서는 1을 기준으로 작성되었다.
    addUpdatePosition(playerId, callback, interval) {
        this.addPlayer(playerId, callback, interval, 'updatePosition');
    }

    // 유저 인터벌 삭제
    removePlayer(playerId) {
        if (this.intervals.has(playerId)) {
            const userIntervals = this.intervals.get(playerId);
            userIntervals.forEach((intervalId) => clearInterval(intervalId));
            this.intervals.delete(playerId);
        }
    }

    // 유저 or 게임이 가진 인터벌만 지우고 싶은 경우
    removeInterval(playerId, type) {
        if (this.intervals.has(playerId)) {
            const userIntervals = this.intervals.get(playerId);
            if (userIntervals.has(type)) {
                clearInterval(userIntervals.get(type));
                userIntervals.delete(type);
            }
        }
    }

    clearAll() {
        this.intervals.forEach((userIntervals) => {
            userIntervals.forEach((intervalId) => clearInterval(intervalId));
        });
        this.intervals.clear();
    }
}

export default IntervalManager;

 

그런 다음 이제 게임 클래스에 인터벌 매니저를 추가한다.

왜냐하면 게임 세션 하나하나마다 인터벌 매니저를 가져야 하기 때문이다. 또한 유저가 추가될 때마다 인터벌 매니저에 해당 유저에 대한 정보도 같이 추가되어야 하며, 추가되었기에 삭제도 되어야 한다.

  • game.class.js
더보기
import IntervalManager from '../managers/interval.manager.js';

const MAX_PLAYERS = 4;

class Game {
    constructor(id) {
        this.id = id;
        this.users = [];
        this.state = 'waiting'; // 게임의 대기값. 'waiting' 또는 'inProgress'를 갖는다.
        this.intervalManager = new IntervalManager();
    }

    addUser(user) {
        if (this.users.length >= MAX_PLAYERS) {
            throw new Error('Game session is full');
        }
        this.users.push(user);

        // user.ping.bind(user): User 클래스에 있는 ping()를 사용..
        // 지금은 1초마다 핑...
        this.intervalManager.addPlayer(user.id, user.ping.bind(user), 1000);
        if (this.users.length === MAX_PLAYERS) {
            setTimeout(() => {
                // 3초 후에 게임 시작
                this.startGame();
            }, 3000);
        }
    }

    getUser(userId) {
        return this.users.find((user) => user.id === userId);
    }

    removeUser(userId) {
        this.users = this.users.filter((user) => user.id !== userId);
        this.intervalManager.removePlayer(userId);

        if (this.users.length < MAX_PLAYERS) {
            this.state = 'waiting';
        }
    }

    startGame() {
        this.state = 'inProgress';
    }
}

export default Game;

 

여기까지 작성했다면 이제 실제로 데이터를 받았을 때, 이를 처리해야 할 곳이 필요하다.

이를 onDate()에서 처리할 예정이다.

  • onData.js
    코드를 설명하자면, protoBuff에 정의되어 있는 pinMessage 구조체를 가지고 와 디코딩 한 뒤, 메시지를 읽은 다음 해당 내용을 user.handlePong()으로 관리를 할 것이다.
더보기
 case PACKET_TYPE.PING:
    {
      const protoMessages = getProtoMessages();
      const Ping = protoMessages.common.Ping;
      const pingMessage = Ping.decode(packet);
      const user = getUserBySocket(socket);
      if (!user) {
        throw new CustomError(ErrorCodes.USER_NOT_FOUND, '유저를 찾을 수 없습니다.');
      }
      user.handlePong(pingMessage);
    }
    break;

 

코드를 보면,  getUserBySocket()를 사용하고 있는 걸 알 수 있는데, 이는 이전에 유전 세션에 저장되어 있는 유저의 정보를 조회할 때, 유저ID만 가지고 조회를 했지만, 현재는 ID가 없기 때문에(왜냐하면 Ping 패킷은 ID를 주고받지 않기 때문)  socket 그 자체로 유저 정보를 조회해야 한다.

 

따라서 해당 함수를 유저 세션 코드에 추가해서 사용한다.

  • user.session.js
export const getUserBySocket = (socket) => {
    return userSessions.find((user) => user.socket === socket);
};

 

 

여기까지 완성했다면 테스트를 진행해야 한다.

이때, 터미널에서 확인하기 위해 유저 클래스의 handlePong() 에서 다음과 같은 코드를 추가한다.

console.log(`Received pong from user ${this.id} at ${now} with latency ${this.latency}ms`);

 

클라이언트는 아래를 참고하여 변경한다.

더보기
import net from 'net';
import Long from 'long';
import { getProtoMessages, loadProtos } from './src/init/loadProtos.js';

const TOTAL_LENGTH = 4; // 전체 길이를 나타내는 4바이트
const PACKET_TYPE_LENGTH = 1; // 패킷타입을 나타내는 1바이트

let userId;
let sequence;
const deviceId = 'xxxx1x';

const createPacket = (handlerId, payload, clientVersion = '1.0.0', type, name) => {
  const protoMessages = getProtoMessages();
  const PayloadType = protoMessages[type][name];

  if (!PayloadType) {
    throw new Error('PayloadType을 찾을 수 없습니다.');
  }

  const payloadMessage = PayloadType.create(payload);
  const payloadBuffer = PayloadType.encode(payloadMessage).finish();

  return {
    handlerId,
    userId,
    clientVersion,
    sequence: 0,
    payload: payloadBuffer,
  };
};

const sendPacket = (socket, packet) => {
  const protoMessages = getProtoMessages();
  const Packet = protoMessages.common.Packet;
  if (!Packet) {
    console.error('Packet 메시지를 찾을 수 없습니다.');
    return;
  }

  const buffer = Packet.encode(packet).finish();

  // 패킷 길이 정보를 포함한 버퍼 생성
  const packetLength = Buffer.alloc(TOTAL_LENGTH);
  packetLength.writeUInt32BE(buffer.length + TOTAL_LENGTH + PACKET_TYPE_LENGTH, 0); // 패킷 길이에 타입 바이트 포함

  // 패킷 타입 정보를 포함한 버퍼 생성
  const packetType = Buffer.alloc(PACKET_TYPE_LENGTH);
  packetType.writeUInt8(1, 0); // NORMAL TYPE

  // 길이 정보와 메시지를 함께 전송
  const packetWithLength = Buffer.concat([packetLength, packetType, buffer]);

  socket.write(packetWithLength);
};

const sendPong = (socket, timestamp) => {
  const protoMessages = getProtoMessages();
  const Ping = protoMessages.common.Ping;

  const pongMessage = Ping.create({ timestamp });
  const pongBuffer = Ping.encode(pongMessage).finish();
  // 패킷 길이 정보를 포함한 버퍼 생성
  const packetLength = Buffer.alloc(TOTAL_LENGTH);
  packetLength.writeUInt32BE(pongBuffer.length + TOTAL_LENGTH + PACKET_TYPE_LENGTH, 0);

  // 패킷 타입 정보를 포함한 버퍼 생성
  const packetType = Buffer.alloc(PACKET_TYPE_LENGTH);
  packetType.writeUInt8(0, 0);

  // 길이 정보와 메시지를 함께 전송
  const packetWithLength = Buffer.concat([packetLength, packetType, pongBuffer]);

  socket.write(packetWithLength);
};

// 서버에 연결할 호스트와 포트
const HOST = 'localhost';
const PORT = 5555;

const client = new net.Socket();

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

client.connect(PORT, HOST, async () => {
  console.log('Connected to server');
  await loadProtos();

  const successPacket = createPacket(0, { deviceId }, '1.0.0', 'initial', 'InitialPacket');

  await sendPacket(client, successPacket);
  await delay(500);

  const createGamePacket = createPacket(
    4,
    { timestamp: Date.now() },
    '1.0.0',
    'game',
    'CreateGamePayload',
  );

  await sendPacket(client, createGamePacket);
});

client.on('data', (data) => {
  // 1. 길이 정보 수신 (4바이트)
  const length = data.readUInt32BE(0);
  const totalHeaderLength = TOTAL_LENGTH + PACKET_TYPE_LENGTH;

  // 2. 패킷 타입 정보 수신 (1바이트)
  const packetType = data.readUInt8(4);
  const packet = data.slice(totalHeaderLength, totalHeaderLength + length); // 패킷 데이터
  const protoMessages = getProtoMessages();

  if (packetType === 1) {
    const Response = protoMessages.response.Response;

    try {
      const response = Response.decode(packet);
      const responseData = JSON.parse(Buffer.from(response.data).toString());
      if (response.handlerId === 0) {
        userId = responseData.userId;
      }
      console.log('응답 데이터:', responseData);
      sequence = response.sequence;
    } catch (e) {
      console.log(e);
    }
  } else if (packetType === 0) {
    try {
      const Ping = protoMessages.common.Ping;
      const pingMessage = Ping.decode(packet);
      const timestampLong = new Long(
        pingMessage.timestamp.low,
        pingMessage.timestamp.high,
        pingMessage.timestamp.unsigned,
      );
      // console.log('Received ping with timestamp:', timestampLong.toNumber());
      sendPong(client, timestampLong.toNumber());
    } catch (pongError) {
      console.error('Ping 처리 중 오류 발생:', pongError);
    }
  }
});

client.on('close', () => {
  console.log('Connection closed');
});

client.on('error', (err) => {
  console.error('Client error:', err);
});

 

코드가 진행되는 순서는 다음과 같다.

  1. 서버와 클라이언트가 연결된다.
  2. 클라이언트 쪽에서 createGamePacket이 만들어지고, 유저가 게임 세션에 바로 추가된다.
  3. 게임 세션에 추가된 유저는 바로 인터벌 매니저에 추가된다.
  4. 그리고 초당 한 번씩 레이터시를 측정한다.

 

결과를 확인하면 다음과 같다.

결과