이번 시간은 지난 번에 진행하던 것에 이어서 해당 클라이언트와 서버 코드를 바탕으로,
원활한 접속 연결과 위치 동기화를 하기 위해 작업했다.
사실 지난 시간에 분석을 한 것은, 클라이언트와 서버 각각의 구현이나 주요 메서드 동작원리에 대해 알아본 점이 대부분이다.
이번에 정리해 볼 내용은 이 둘이 연동하여 어떤 흐름으로 진행되는 지에 대한 시나리오다.
서버를 실행하면, init
파일 디렉토리 내 index.js 스크립트를 통해 게임 에셋, 프로토버퍼를 로딩하고, DB 커넥션에 대해 간단한 쿼리를 보내 테스트를 한다.
//====================================================================================================================
//====================================================================================================================
// init/index.js
// 서버에 대한 초기화 작업: 에셋 로딩, 패킷 로딩, 커넥션 테스트
//====================================================================================================================
//====================================================================================================================
import pools from '../db/database.js';
import { testAllConnections } from '../utils/db/testConnection.js';
import { loadGameAssets } from './assets.js';
import { loadProtos } from './loadProtos.js';
const initServer = async () => {
try {
await loadGameAssets();
await loadProtos();
await testAllConnections(pools);
// 다음 작업
} catch (e) {
console.error(e);
process.exit(1); // 오류 발생 시 프로세스 종료
}
};
export default initServer;
위 검증을 거쳐 게임 시작 처리를 하면,
데이터 수신을 시작하면서, 클라이언트에서 서버로 초기 패킷을 전송한다.
// 초기 패킷 전송
void SendInitialPacket() {
InitialPayload initialPayload = new InitialPayload
{
deviceId = GameManager.instance.deviceId,
playerId = GameManager.instance.playerId,
latency = GameManager.instance.latency,
};
// handlerId는 0으로 가정
SendPacket(initialPayload, (uint)Packets.HandlerIds.Init);
}
초기 패킷은 위와 같이 deviceId, playerId, latency를 담아 전송한다.
initialHandler가 불러지게 되는데,
//====================================================================================================================
// handlers/user/initial.handler.js.
// 유저 초기화 핸들러 이벤트
//====================================================================================================================
//====================================================================================================================
import { addUser } from '../../session/user.session.js';
import { HANDLER_IDS, RESPONSE_SUCCESS_CODE } from '../../constants/handlerIds.js';
import { createResponse } from '../../utils/response/createResponse.js';
import { handleError } from '../../utils/error/errorHandler.js';
import { createUser, findUserByDeviceID, updateUserLogin } from '../../db/user/user.db.js';
const initialHandler = async ({ socket, userId, payload }) => {
try {
const { deviceId } = payload;
// device Id로 유저 찾기
let user = await findUserByDeviceID(deviceId);
// deviceId 기준으로, 찾은 유저로 로그인하거나, 최초 접속 시 유저 생성
if (!user) {
user = await createUser(deviceId);
} else {
await updateUserLogin(user.id);
}
// 세션에 유저 추가
addUser(user.id, socket);
// 유저 정보 응답 생성
const initialResponse = createResponse(
HANDLER_IDS.INITIAL,
RESPONSE_SUCCESS_CODE,
{ userId: user.id },
deviceId,
);
// 소켓을 통해 클라이언트에게 응답 메시지 전송
socket.write(initialResponse);
} catch (error) {
handleError(socket, error);
}
};
export default initialHandler;
쿼리문을 통해 db에 deviceId로 새로운 유저가 생성된 모습이다.
그리고 위 코드스니펫에
socket.write(initialResponse);
를 보면 알 수 있듯이, 서버는 클라이언트로부터 받은 초기 패킷에 대한 처리로 그 응답 패킷을 전송한다.
void ProcessReceivedData(byte[] data, int length) {
incompleteData.AddRange(data.AsSpan(0, length).ToArray());
while (incompleteData.Count >= 5)
{
// 패킷 길이와 타입 읽기
byte[] lengthBytes = incompleteData.GetRange(0, 4).ToArray();
int packetLength = BitConverter.ToInt32(ToBigEndian(lengthBytes), 0);
Packets.PacketType packetType = (Packets.PacketType)incompleteData[4];
if (incompleteData.Count < packetLength)
{
// 데이터가 충분하지 않으면 반환
return;
}
// 패킷 데이터 추출
byte[] packetData = incompleteData.GetRange(5, packetLength - 5).ToArray();
incompleteData.RemoveRange(0, packetLength);
// Debug.Log($"Received packet: Length = {packetLength}, Type = {packetType}");
switch (packetType)
{
case Packets.PacketType.Normal:
HandleNormalPacket(packetData);
break;
case Packets.PacketType.Location:
HandleLocationPacket(packetData);
break;
}
}
}
초기 응답 패킷에 대해서는, packetType이 Normal
이란 enum으로 설정되었기에,
void HandleNormalPacket(byte[] packetData) {
// 패킷 데이터 처리
var response = Packets.Deserialize<Response>(packetData);
Debug.Log($"HandlerId: {response.handlerId}, responseCode: {response.responseCode}, timestamp: {response.timestamp}");
// 서버로부터 온 응답코드가 잘못됨
if (response.responseCode != 0 && !uiNotice.activeSelf) {
AudioManager.instance.PlaySfx(AudioManager.Sfx.LevelUp);
// 2: server error 알림
StartCoroutine(NoticeRoutine(2));
return;
}
// 응답 데이터가 유효하게 존재하면
if (response.data != null && response.data.Length > 0) {
if (response.handlerId == 0) {
GameManager.instance.GameStart();
}
ProcessResponseData(response.data);
}
}
노말 패킷에 대해 응답코드에 대한 검증 이후 유효성을 판단하면, GameManager
를 통해 GameStart()
메서드를 호출하여, client 내에서 전반적인 게임 흐름을 시작한다.
// GameManager.cs
public void GameStart() {
player.deviceId = deviceId;
player.gameObject.SetActive(true);
hud.SetActive(true);
GameStartUI.SetActive(false);
isLive = true;
AudioManager.instance.PlayBgm(true);
AudioManager.instance.PlaySfx(AudioManager.Sfx.Select);
}
// Player.cs
void OnEnable() {
if (deviceId.Length > 5) {
myText.text = deviceId[..5];
} else {
myText.text = deviceId;
}
myText.GetComponent<MeshRenderer>().sortingOrder = 6;
anim.runtimeAnimatorController = animCon[GameManager.instance.playerId];
}
// Update is called once per frame
void Update()
{
if (!GameManager.instance.isLive) {
return;
}
inputVec.x = Input.GetAxisRaw("Horizontal");
inputVec.y = Input.GetAxisRaw("Vertical");
// 위치 이동 패킷 전송 -> 서버로
NetworkManager.instance.SendLocationUpdatePacket(rigid.position.x, rigid.position.y);
}
위의 Player.cs
스크립트를 보면 알 수 있듯이, GameStart
메서드를 통해 isLive
플래그 변수가 켜지면서, 사용자 입력을 통한 이동과 위치 동기화 패킷을 NetworkManager.instance.SendLocationUpdatePacket
로 싱글턴의 네트워크 매니저 메서드 호출로 보내게 된다.
이 부분 이후는 이제 새로 구현하기 위해 코드 스니펫 첨부는 생략함.
사실 지금은 스켈레톤 코드 기반의 작업 흐름이고, 이 흐름에서 추가되야 하는 부분이 존재한다.
현재 client side에서 server 로 보내는 패킷은
뿐이지만,
접속 이후 게임 시작시작 시
등이 추가되어야 한다.
다음은 이제 작업 중에 발견한 에러 핸들링 과정이다.
물론 패킷 디코딩 과정이 실패하는 이유에는 다양한 원인이 존재할 것이다.
내가 시도한 방법은 다음과 같다.
아마도 서버 개발을 할 때 가장 자주 마주칠 오류의 이유라고 생각되고, 필자의 경우에 해당한 경우이다. 클라이언트와 서버가 동일한 패킷 구조를 이용해야 하지만, 초기에 다른 종류의 속성과 순서로 패킷이 정의되어 있었기에 이에 대해 일치시키는 과정이 필요하다.
[ProtoContract]
public class CommonPacket
{
[ProtoMember(1)]
public uint handlerId { get; set; }
[ProtoMember(2)]
public string userId { get; set; }
[ProtoMember(3)]
public string version { get; set; }
[ProtoMember(4)]
public byte[] payload { get; set; }
}
syntax = "proto3";
package common;
message Packet {
uint32 handlerId = 1;
string userId = 2;
string version = 3;
bytes payload = 4;
}