서버가 존재하는 온라인 멀티플레이어 게임은 모두 상태 업데이트에 큰 노력을 쏟을 것이다.
플레이어 A와 B가 같은 곳에 있다면, 둘은 서로 같은 상태로 동기화되어야 한다.
A는 움직였지만 B의 화면에서 A가 움직이지 않았다면 대형 사고다.
지금의 과제에서 가장 어려운 부분 또한 그런 내용이다.
명확한 상태 동기화를 위해 고민해야 할 부분이 많다.
어떤 주기로 동기화할 것인지, 어떻게 동기화할 것인지와 같은 내용이 되겠다.
1. Interval Manager 활용
서버 내에서 벌어지는 주기적인 활동을 ``Interval Manager``를 통해 관리할 수 있다.
하지만 강의에서 이를 최대한 활용하지 않는다는 느낌이 들었다.
이걸 어떻게든 활용해 보려고 했다.
const updateLocationHandler = ({ socket, userId, payload }) => {
try {
...
// 여기서 지속적인 위치 동기화 시도가 이루어진다
user.updatePosition(x, y);
const packet = gameSession.getAllLocation(userId);
socket.write(packet);
}
``updateLocationHandler``에서 지속적인 위치 동기화가 이루어진다.
의도한 대로 잘 동작하며, 문제라고 할 부분은 없다.
하지만 아쉬운 부분은 있는데, "업데이트 주기가 너무 빠르다"는 것이다.
물론 동기화가 짧은 주기로 이루어지면 좋지만, 만약 매우 많은 클라이언트가 접속해 있다면?
그걸 문제없이 같은 주기로 계속 갱신해 줄 수 있다는 보장이 없다.
따라서 이건 일정한 주기를 두고, 그 주기에 따라 갱신하게 해야 불필요한 부하를 줄일 수 있을 것이다.
이를 해결하기 위해 인터벌 매니저를 활용할 수 있다.
추가적으로 작성한 ``Interval Manager``는 다음과 같다.
// src/classes/managers/interval.manager.js
import BaseManager from './base.manager.js';
class IntervalManager extends BaseManager {
constructor() {
super();
...
}
// 플레이어별 인터벌 추가
addPlayerInterval(playerId, callback, interval, type = 'updatePosition') {
}
// 플레이어별 인터벌 제거
removePlayerIntervals(playerId) {
}
// 글로벌 인터벌 추가
addGlobalInterval(id, callback, interval) {
}
// 글로벌 인터벌 제거
removeGlobalInterval(id) {
}
// 모든 인터벌 제거
clearAllIntervals() {
}
export default IntervalManager;
게임 세션에서 글로벌하게 필요한 인터벌과 각 플레이어에게만 필요한 인터벌이 있을 것이다.
모든 걸 당장 사용하지 않더라도, 확장성을 위해 구분해 두는 것이 좋을 것이라 생각했다.
이는 게임 세션에서 다음과 같이 사용된다.
addUser(user) {
this.users.push(user);
// 플레이어별 인터벌 추가
const interval = 1000 / 60; // 60FPS
const callback = () => {
const newCoords = user.calculatePosition(this.getMaxLatency());
user.updatePosition(newCoords.x, newCoords.y);
};
this.intervalManager.addPlayerInterval(user.id, callback, interval);
}
// 인터벌을 통한 위치 업데이트
startLocationBroadcast() {
const interval = 1000 / 30; // 초당 30회
const callback = () => {
const packet = this.getAllLocation();
this.users.forEach((user) => {
user.socket.write(packet);
});
};
this.intervalManager.addGlobalInterval('broadcastLocation', callback, interval);
}
유저의 위치를 업데이트하기 위한 부분은 플레이어 인터벌로 관리하게 했고,
세션에서 모든 클라이언트에게 브로드캐스트 해주는 부분은 세션 전체에 관한 것이므로 글로벌 인터벌로 관리한다.
2. 지연시간 관리
모든 사람이 0ms의 시간으로 서버와 통신할 수 없다.
좁은 한국 땅에서도 2 자릿수 핑은 너무나도 당연하다.
FPS를 하다 보면, 내가 먼저 잘 쐈는데도 죽거나,
나는 적을 보지도 못했는데도 눈먼 탄에 죽어본 경험이 있을 것이다.
사실 적은 내 앞에 빠르게 나타나 슥삭 하고 간 것인데도 난 보지 못한 것이다.
이 모든 것들이 지연시간 차이로 인해 나타난다.
온라인 게임들은 이러한 지연시간 보정을 위해 엄청난 노력을 기울인다.
그러한 것들을 해소하기 위한 "넷코드"라는 것이 존재한다.
십수 년 전 배틀필드를 해 봤거나, 격투게임을 즐겨봤다면 뭔지 바로 알 것이다.
https://gall.dcinside.com/mgallery/board/view/?id=tk8&no=636608
이 정도로 고도의 관리를 지금 하지는 않지만, 가장 많이 쓰이는 지연시간 보정 방식을 사용해 보자.
그건 바로 "가장 높은 지연시간을 가지는 플레이어를 기준으로 갱신"하는 것이다.
``10ms``, ``20ms``, ``50ms``의 지연시간을 가지는 세명의 플레이어가 있으면,
가장 높은 지연시간인 ``50ms``를 기준으로 상태를 업데이트하겠다는 뜻이다.
같은 방에 핑이 높은 사람이 있다면 모두가 좋아하지 않는 이유이기도 하다.
나는 ``10ms``라는 어드밴티지를 갖고 있지만 누군가로 인해 느리게 갱신을 받아야 한다는 것이다.
그래서 난 ``Ping``을 활용하기로 했다.
정말 심플하게 핑만 활용해 서버사이드에 각 클라이언트 별 지연시간을 갖고 있게 하는 것이다.
먼저 핑 패킷에 대한 프로토버프 정의를 작성한다.
// Ping 메시지
message Ping {
int64 timestamp = 1; // Ping 타임스탬프
}
시간 정보만 교환하면 된다.
수신 측에서 현재 시간과 비교해 지연시간을 계산하게 된다.
그리고 핸들러 집합 객체에 해당 패킷을 처리하기 위한 부분을 추가한다.
[HANDLER_IDS.PING]: {
handler: pingHandler,
protoType: 'common.Ping',
}
이제 핸들러를 작성한다.
const pingHandler = async ({ socket, userId, payload }) => {
try {
const { timestamp } = payload;
// 현재 시간
const currentTime = Date.now();
// 레이턴시 계산
const rtt = currentTime - timestamp;
const latency = Math.round(rtt / 2);
// 유저 정보 업데이트
const user = getUserById(userId);
if (user) {
user.setLatency(latency);
console.log(`Updated latency for user ${user.id}: ${latency} ms`);
}
const pongResponse = createResponse(HANDLER_IDS.PING, RESPONSE_SUCCESS_CODE, {});
socket.write(pongResponse);
} catch (e) {
handlerError(socket, e);
}
};
지연시간을 계산 후, 유저의 지연시간을 업데이트한다.
업데이트가 완료됐다면 클라이언트에 ``Pong``을 보내준다.
서버에선 해당 핑을 잘 받았다는 응답만 해주는 것이기 때문에 데이터는 빈 데이터를 넘겨줬다.
이렇게 우리는 서버에서 모든 클라이언트의 레이턴시를 관리하게 됐다.
이제 서버에선 어디서나 다음의 함수를 통해 가장 큰 레이턴시가 몇인지 가져올 수 있다.
getMaxLatency() {
let maxLatency = 0;
this.users.forEach((user) => {
maxLatency = Math.max(maxLatency, user.getLatency());
});
return maxLatency;
}
단순히 사용자들을 순회하며 가장 큰 레이턴시를 찾아온다.
지금 생각하니 유저 ID와 레이턴시만 저장하는 ``Map``이 있었어도 됐을 것 같다.
// 주기적으로 레이턴시 보정
startLatencyCompensation() {
const interval = 100; // 100ms마다
const callback = () => {
const maxLatency = this.getMaxLatency();
this.users.forEach((user) => {
const adjustedPosition = user.calculatePosition(maxLatency);
user.updatePosition(adjustedPosition.x, adjustedPosition.y);
});
};
this.intervalManager.addGlobalInterval('latencyCompensation', callback, interval);
}
``getMaxLatency``를 사용해 레이턴시 보정을 수행할 수 있게 되었다.
3. 그래서 잘 되는가?
클라이언트도 위 변경 사항에 맞게 수정이 이루어졌다.
이제 제대로 잘 이루어지는지 확인해 보자.
왼쪽의 ``xxxd``를 조작한 것이다.
보이는 것으론 문제가 없어 보인다.
사실 이는 데드 레코닝을 수행하는 부분의 수정도 들어가 있기에 정상적으로 표시되고 있다.
원래는 가만히 있어도 진동하듯이 다른 플레이어의 캐릭터가 움직였고,
이동 시엔 마치 분신술은 사용한 듯 복수개로 보였다.
이렇게까진 아니지만 그 잔상이 매우 심하게 남았다.
이 문제로 정말 몇 날 며칠을 고민했지만 뾰족한 수가 없었다.
시도해 본 방법들도 다 실패였다.
먼저 기존 방식은 축 별로 독립적으로 이동 방향과 거리를 적용했다.
이는 이동 시 이동속도가 비일관적일 수 있다는 문제가 있었다.
특히 대각선 이동 시 부정확한 결과를 보여줄 수 있었다.
그리고 목표 위치를 초과하여 움직이는 것을 방지하기 위한 클램핑 로직이 없었다.
따라서 이동 시 클라이언트와 서버의 좌표가 달라 좌표를 맞추는 과정에서 큰 떨림이 있을 수 있었다.
이를 수정한 로직은 다음과 같다.
if (this.x === this.lastX && this.y === this.lastY) {
return { x: this.x, y: this.y };
}
먼저 이동이 없다면 그냥 현재 좌표를 바로 리턴해 준다.
// this.speed로 얼마나 가야하는지
const timeDiff = (Date.now() - this.lastUpdateTime + latency) / 1000; // 초 단위로 변환
const distance = this.speed * timeDiff;
// 각 축별 이동거리
const totalDistanceX = this.x - this.lastX;
const totalDistanceY = this.y - this.lastY;
총 이동거리를 구하고, 각 축별로 얼마나 이동해야 하는지도 구한다.
// arctan을 이용한 이동 방향 구하기
const angle = Math.atan2(totalDistanceY, totalDistanceX);
// 해당 각도로 얼마나 움직여야 하는지 계산
const movedX = this.lastX + Math.cos(angle) * distance;
const movedY = this.lastY + Math.sin(angle) * distance;
``atan2``로 목표 지점으로의 방향을 구하고 이동량을 최종적으로 구한다.
// 목표 위치를 초과하지 않도록 제한
const newX = (totalDistanceX > 0)
? Math.min(movedX, this.x)
: Math.max(movedX, this.x);
const newY = (totalDistanceY > 0)
? Math.min(movedY, this.y)
: Math.max(movedY, this.y);
return {
x: newX,
y: newY,
};
마지막으로 ``Math.min``을 통해 최종 이동 좌표를 벗어나는 이동을 하지 않도록 막는다.
그럼 좌표 계산은 완료됐으므로 새 좌표를 리턴해주며 끝난다.
4. 보간
서버에서 좌표를 짧은 주기로 쏴주는 것은 좋으나, 클라이언트에서 봤을 땐 그리 부드러워 보이지 않을 수 있다.
이러한 보간은 클라이언트 사이드에서 수행해야 한다.
난 혹시나 싶어 서버 쪽에 보간을 넣어봤지만 역시 아무런 변화도 없었다...
이번에 사용할 보간 함수는 ``easeInOutQuad``이다.
이전에 다른 프로젝트에서 써봤기 때문에 가장 먼저 떠올랐던 것이다.
https://easings.net/ko#easeInOutQuad
아래쪽 타입스크립트 코드를 C#으로 옮기자.
// Easing function
private float EaseInOutQuad(float x)
{
return x < 0.5f ? 2f * x * x : 1f - Mathf.Pow(-2f * x + 2f, 2f) / 2f;
}
그럼 이걸 어디에 적용하느냐?
로컬 플레이어는 즉각적으로 잘 움직이니, 다른 플레이어 표시를 위한 ``PlayerPrefab.cs``에 적용해야 한다.
먼저 보간을 위한 변수들을 추가한다.
private float interpolationStartTime;
private const float InterpolationDuration = 0.1f;
그리고 서버에서 좌표 수신 시마다 위치와 시간을 갱신해 준다.
// 서버로부터 위치 업데이트를 수신할 때 호출될 메서드
public void UpdatePosition(float x, float y)
{
lastPosition = transform.position;
currentPosition = new Vector3(x, y);
interpolationStartTime = Time.time;
}
마지막으로 ``Update``에서 보간을 수행해 준다.
void Update()
{
...
float elapsed = Time.time - interpolationStartTime;
float t = Mathf.Clamp01(elapsed / InterpolationDuration);
float easedT = EaseInOutQuad(t);
transform.position = Vector3.Lerp(lastPosition, currentPosition, easedT);
UpdateAnimation();
}
여기서 ``EaseInOutQuad``를 통해 계산한 값이 일반적으로 사용되는 시간 대신에 들어간 것이다.
결과는 다음과 같다.
보간이 있고 없고 부드러움의 차이가 확실히 느껴진다.
5. 시퀀스
레이턴시 관리를 위해 핑을 활용하기로 했다.
이미 서버는 각종 패킷에 대하여 시퀀스로 유효성을 확인하고 있고,
핑은 단순 레이턴시 체크용이니 시퀀스로 관리되지 않는 별도의 무언가로 하려고 했다.
난 핑을 5초마다 보내는 것으로 했고, 이외의 통신은 초당 30회 이루어진다.
그럼 150의 시퀀스가 있은 후, 핑을 보내면 151이 되어야 하는데...
모종의 시퀀스 불일치가 일어났고, 시퀀스는 150에서 증가하지 못하고 계속 에러만 뱉을 뿐이었다.
// 공통 패킷 구조
message Packet {
...
}
// Ping 메시지
message Ping {
int64 timestamp = 1; // Ping 타임스탬프
}
난 핑은 별도의 패킷으로 정의했고, 시퀀스에 영향을 받지 않게 했는데도 시퀀스 문제가 발생했다.
관련한 부분을 정말 하루 종일 쳐다봤는데도 해결할 수가 없었다.
그렇게 해결법은 보이지 않고, 마감 시간은 다가오니
문제를 빨리 해결할 수 있는 단 하나의 방법을 쓰기로 했다.
핑도 시퀀스를 갖게 하는 것이다.
문제로 짐작되는 부분이 없는 건 아니었으나 일단 작동을 확인하고 싶었다.
핑 핸들러도 다른 핸들러와 같은 형식을 취해 시퀀스 넘버를 갖게 했다.
const pingHandler = async ({ socket, userId, payload }) => {
try {
const { timestamp } = payload;
// 현재 시간
const currentTime = Date.now();
// 레이턴시 계산
// 유저 정보 업데이트
// 다른 핸들러처럼 createResponse로 응답 생성
const pongResponse = createResponse(HANDLER_IDS.PING, RESPONSE_SUCCESS_CODE, { timestamp: currentTime });
socket.write(pongResponse);
} catch (e) {
handlerError(socket, e);
}
};
이는 자연스럽게 ``common``이라는 공통 패킷 구조를 사용하게 됨과 같다.
그러므로 시퀀스 넘버 문제도 자연스럽게 해결할 수 있게 된 것이다.
이런 식으로 핑을 통해 레이턴시를 정상적으로 업데이트하며,
시퀀스 문제도 발생하지 않게 됐다.
타워 디펜스 프로젝트 때 서버 쪽 처리가 미흡했던 것이 꽤 마음에 걸렸는데,
이번 프로젝트를 통해 좋은 공부를 한 것 같다.
삼각함수를 이렇게 써먹어본 것도 꽤 오랜만이다.
'Camp > T.I.L.' 카테고리의 다른 글
[TIL #33] 마법의 엘리베이터 (0) | 2024.11.20 |
---|---|
[TIL #32] 자바스크립트와 싱글톤 (0) | 2024.11.18 |
[TIL #30] 모의 면접을 준비하며 (0) | 2024.10.30 |
[TIL #29] 점 찍기 (0) | 2024.10.25 |
[TIL #28] Investments in 2016 (0) | 2024.10.23 |