[TIL] 20240110 TIL : 서버 로직과 에러

Jaeyoung Ko·2일 전
0

이번 시간은 지난 번에 진행하던 것에 이어서 해당 클라이언트와 서버 코드를 바탕으로,

원활한 접속 연결과 위치 동기화를 하기 위해 작업했다.


클라이언트-서버 통신 흐름


사실 지난 시간에 분석을 한 것은, 클라이언트와 서버 각각의 구현이나 주요 메서드 동작원리에 대해 알아본 점이 대부분이다.

이번에 정리해 볼 내용은 이 둘이 연동하여 어떤 흐름으로 진행되는 지에 대한 시나리오다.

시나리오

  1. node 서버를 실행한다.

서버를 실행하면, 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;

  1. Unity Client 가 실행되고, 사용자에게 입력(deviceId, IP address, port number)을 받는다.

  1. 2번에서의 입력으로 접속을 시도한다.
    접속 시도 시, client side에서 일어나는 흐름이다.
  • 입력값 port number가 정수 범위 내에 유효한지 검증
  • deviceId가 공란인지, 입력되었는지 확인 -> 공란일 시에는 고유 guid로 생성

위 검증을 거쳐 게임 시작 처리를 하면,
데이터 수신을 시작하면서, 클라이언트에서 서버로 초기 패킷을 전송한다.


    // 초기 패킷 전송
    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를 담아 전송한다.

  1. 초기 패킷을 받은 서버는 해당 패킷에 대해 적절한 처리를 한다. 해당 패킷의 핸들러 ID를 토대로

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);

를 보면 알 수 있듯이, 서버는 클라이언트로부터 받은 초기 패킷에 대한 처리로 그 응답 패킷을 전송한다.

  1. 이후 클라이언트에서는, 서버로부터 받은 패킷에 대한 처리를 하는데,

    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로 싱글턴의 네트워크 매니저 메서드 호출로 보내게 된다.

이 부분 이후는 이제 새로 구현하기 위해 코드 스니펫 첨부는 생략함.

  1. 그러면 클라이언트에서 서버로 보낸 패킷에 대해, 이번에는 위치 동기화 핸들러 id 임을 확인하고,
    서버는 dead reckoning 을 통한 처리 이후 응답 패킷을 보내야 한다.



주의

사실 지금은 스켈레톤 코드 기반의 작업 흐름이고, 이 흐름에서 추가되야 하는 부분이 존재한다.
현재 client side에서 server 로 보내는 패킷은

  • 접속 시 최초 초기 패킷 전송
  • 프레임/레이턴시 단위로 위치 동기화 패킷 전송

뿐이지만,

접속 이후 게임 시작시작 시

  • 게임 핸들러를 통해 유저가 최초 세션을 생성 시의 게임 세션 관리
  • 이미 생성된 세션으로의 다른 유저의 join 으로 세션 참여하기

등이 추가되어야 한다.



다음은 이제 작업 중에 발견한 에러 핸들링 과정이다.

Packet Decoding Error

물론 패킷 디코딩 과정이 실패하는 이유에는 다양한 원인이 존재할 것이다.

내가 시도한 방법은 다음과 같다.


Endian 통일

  • 직렬화/역직렬화 과정에서 byte stream에 대해서 엔디안 차이가 존재할 수 있다. 다만, 이 경우, 클라이언트에서는 별도의 Big Endian으로 컨버팅해주는 메서드를 구현하여 사용했고, 서버 단에서도 컨버팅을 제대로 하고 있기 때문에 해당 문제는 아니였다.

프로토콜 정의 문제

아마도 서버 개발을 할 때 가장 자주 마주칠 오류의 이유라고 생각되고, 필자의 경우에 해당한 경우이다. 클라이언트와 서버가 동일한 패킷 구조를 이용해야 하지만, 초기에 다른 종류의 속성과 순서로 패킷이 정의되어 있었기에 이에 대해 일치시키는 과정이 필요하다.

Client의 common Packet 구조 as C#


[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; }
}

Server의 common Packet 구조 as JS

syntax = "proto3";

package common;

message Packet {
  uint32 handlerId = 1;
  string userId = 2;
  string version = 3;
  bytes payload = 4;
}
profile
안녕하세요, 고재영입니다. 언제나 즐겁게 살려고 노력합니다.

0개의 댓글