ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [TIL] 스파르타) Chapter 5 주특기 플러스 개인 과제 진행 (TCP 게임 서버, D-6)
    TIL 2024. 7. 2. 12:59


     > 과제 진행 간 완료한 사항 및 문제점과 해결 과정을 정리해보았다.

     

    학습 키워드: Node.js, net, TCP, socket, Buffer, Protobuf

     

    1. 완료한 과제 진행 사항

    1) 필수 요구사항 구현 완료:

     

    필수 요구사항 기능 구현 by donkim1212 · Pull Request #4 · donkim1212/sparta-ch5-tcp-game-server

    작업 내용 #2 에 명시된 모든 필수 기능 구현 완료 관련 사항 개인 블로그 TIL 링크, 참고

    github.com

     - 유저의 위치 정보 수신 및 업데이트

     - User와 Game 클래스, 접속중인 유저 목록을 담은 userSessions와 활성화된 게임 인스턴스 목록을 담은 gameSessions 객체, 그리고 각각의 객체를 관리하는 함수(CRUD)를 모아둔 UserSessionsManager와 GameSessionsManager 코드 작성

     - 게임 시작 시 게임 인스턴스 1개 생성, 유저 접속 시 유저 정보 저장 및 게임 인스턴스에 등록

     

    2. 과제 진행 간 문제점

    1) 캐릭터가 움직일 시 RangeError가 출력되던 문제 (해결):

    GitHub PR: https://github.com/donkim1212/sparta-ch5-tcp-game-server/pull/3

    Figure 1. 가만히 있을 때 (수정 전)
    Figure 2. 움직였을 때 (수정 전)

     게임 시작 직후 가만히 있을 때는 에러가 없다가 캐릭터를 움직이기 시작하면 RangeError가 출력되는 문제가 있었다. 헤더에 적힌 버퍼의 크기와 실제 버퍼의 크기가 달라서 생기는 문제였는데, 클라이언트에서 넘어오는 패킷의 payload에 사용되는 proto message type을 다른 것으로 혼동해서 잘 못 매핑해 둔 것이 원인이었다.

     

    // server-side, game.proto
    
    syntax = 'proto3';
    
    package game;
    
    message LocationUpdatePayload { // 실제로 넘어오는 message type
        float x = 1;
        float y = 2;
    }
    
    message LocationUpdate { // 매핑해둔 message type
        repeated UserLocation users = 1;
    
        message UserLocation {
            string id = 1;
            uint32 playerId = 2;
            float x = 3;
            float y = 4;
        }
    }

     

    // client-side, NetworkManager.cs
    
    ...
    
    async void SendPacket<T>(T payload, uint handlerId) {
        ...
    
        // 헤더 생성
        byte[] header = CreatePacketHeader(data.Length, Packets.PacketType.Normal);
    
        ...
    }
    ...
    
    public void SendLocationUpdatePacket(float x, float y) {
        // 위치 정보 업데이트는 LocationUpdatePayload를 사용한다
        LocationUpdatePayload locationUpdatePayload = new LocationUpdatePayload {
            x = x,
            y = y,
        };
    
        SendPacket(locationUpdatePayload, (uint)Packets.HandlerIds.LocationUpdate);
    }
    
    ...

     처음에 프로젝트 기본 셋업 코드를 작성하면서 위치 정보 갱신이 LocationUpdate를 통해 이루어진다고 생각하고 우선 작성해뒀었는데, 다시 한 번 천천히 살펴보니 위치 정보를 서버에 보내는 패킷에는 LocationUpdatePayload가 Normal 패킷 타입(이 부분도 어제는 문제점이라고 생각했으나 내가 구조를 잘 못 이해하고 있었다)으로 보내지고 있었다. 아래와 같이 수정하여 해결했다.

     

    // server-side, src/handlers/index.js
    
    ...
    
    const handlers = {
      [handlerIds.INITIAL]: {
        handler: initialHandler,
        protoType: protoTypeNames.common.InitialPacket,
      },
      [handlerIds.LOCATION_UPDATE]: {
        handler: locationUpdateHandler,
        // protoType: protoTypeNames.game.LocationUpdate,
        protoType: protoTypeNames.game.LocationUpdatePayload,
      },
    };
    
    ...

    Figure 3. 가만히 있을 때 (수정 후)
    Figure 4. 움직였을 때 (수정 후)

     

     

     

    2) End 이벤트가 발생하지 않는 문제 (해결):

    Figure 5. 연결 해제 메세지 (수정 후)

     클라이언트 종료 시 End 이벤트가 발생하지 않는 문제가 있었는데, 결론부터 말하자면 Unity Editor에서 게임을 실행하고 종료하는 경우만 해당되는 것이었다. 빌드된 클라이언트를 실행해보니 클라이언트 종료 시 서버에서 End 이벤트가 정상적으로 호출되는 것을 확인했다. 아래는 이 결론까지 도달한 과정이다.

     

    socket.setTimeout(10000);
    socket.on("timeout", onTimeout(socket));
    
    // -----
    
    const onTimeout = (socket) => () => {
      // disconnect socket and remove user(?) on timeout
    };

     처음에는 원인을 모르고 다른 방법으로 해결하려고 고민했었는데, 예를 들어 socket.setTimeout() 함수로 임의의 타임아웃 시간을 설정하여 해당 시간동안 아무런 패킷이 넘어오지 않으면 timeout 이벤트가 발생하는 방식을 사용하려고 했다. 그런데 문득 배포되어있는 예제 서버에서는 어떻게 구현했나 싶어서 접속해보니 클라이언트 종료 시 유저 캐릭터가 즉각 사라지는 것을 확인했다. 이를 미루어 예제 서버는 End 이벤트로 처리하고 있다고 짐작했다.

     

     대체 무슨 차이가 있나 고민하던 중, 혹시나 싶어서 로컬 서버에 테스트했던 것처럼 Unity 에디터에서 실행하여 예제 서버에 접속했다가 종료해보았는데, End 이벤트가 발생하지 않기 때문에 유저 캐릭터가 사라지지 않고 남아있게 되는 것을 확인했다. 이 문제를 해결하기 위해 Timeout 기능을 적용해보는 것도 좋을 듯 하다.

     


    3) LocationUpdate의 Response payload가 비어있던 문제 (해결):

    const locationUpdateHandler = async ({ socket, userId, payload }) => {
      const { x, y } = payload;
      const user = userSessionsManager.getUserByUserId(userId);
      /* do some calculation for x, y validation here */
      user.updatePosition(x, y);
    
      const LocationUpdate = getProtoMessages().game.LocationUpdate;
      const game = gameSessionsManager.getGameSession(0);
      
      const err = LocationUpdate.verify(game.getAllUserLocations());
      if (err) {
        console.error(err);
      }
    
      const responsePayload = LocationUpdate.encode(game.getAllUserLocations()).finish();
      console.log(`res payload: ${responsePayload}`); // responsePayload is empty
      const header = writeHeader(responsePayload.length, headerConstants.packetTypes.LOCATION);
      return Buffer.concat([header, responsePayload]);
    };
    
    export default locationUpdateHandler;

    Figure 6. buffer가 생성되지 않음 (수정 전)

     처음에는 encode 하려는 대상이 game.getAllUser() 였는데, getAllUsers의 유저 정보에 담긴 property가 LocationUpdate의 UserLocation에 명시된 필드보다 많아서 안된다고 생각하고 getAllUserLocations() 라는 함수를 새로 만들었다.

     

    // game.model.js
    
      ...
    
      getAllUsers() {
        return this.users;
      }
    
      getAllUserLocations() {
        const userLocations = [];
        for (const user of this.users) {
          userLocations.push({
            id: user.id,
            playerId: user.playerId,
            x: user.x,
            y: user.y,
          });
        }
        return userLocations;
      }
      
      ...

     그런데 해당 method를 사용해도 결과가 똑같아서 뭐가 문제인가 한참 고민하다가 proto파일에 정의된 repeated UserLocation 의 이름이 users 라는 것을 깨닫고 locationUpdateHandler를 아래와 같이 변경하여 해결했다.

     

    const locationUpdateHandler = async ({ socket, userId, payload }) => {
      const { x, y } = payload;
      const user = userSessionsManager.getUserByUserId(userId);
      /* do some calculation for x, y validation here */
      user.updatePosition(x, y);
    
      const LocationUpdate = getProtoMessages().game.LocationUpdate;
      const game = gameSessionsManager.getGameSession(0);
      
      const err = LocationUpdate.verify({ users: game.getAllUsers() }); // 객체로 변경하고 users 키에 할당
      if (err) {
        console.error(err);
      }
    
      const responsePayload = LocationUpdate.encode({ users: game.getAllUsers() }).finish(); // 객체로 변경하고 users 키에 할당
      const header = writeHeader(responsePayload.length, headerConstants.packetTypes.LOCATION);
      return Buffer.concat([header, responsePayload]);
    };

     또한, responsePayload를 decode 하면서 테스트 해보니 encode가 getAllUsers에서 필요한 필드만 뽑아내서 encode를 한다는 점을 알게 되었다. 하여 앞서 작성한 game.model.js의 getAllUserLocations()는 사용하지 않게 되었다.

     

     


    4) 게임 캐릭터가 복제되는 문제 (해결):

    Figure 7. 게임 캐릭터가 복제됨 (수정 전)

    // client-side, NetworkManager.cs
    
    ...
    
    void HandleLocationPacket(byte[] data) {
        try {
            LocationUpdate response;
    
            if (data.Length > 0) {
                // 패킷 데이터 처리
                response = Packets.Deserialize<LocationUpdate>(data);
            } else {
                // data가 비어있을 경우 빈 배열을 전달
                response = new LocationUpdate { users = new List<LocationUpdate.UserLocation>() };
            }
    
            Spawner.instance.Spawn(response);
        } catch (Exception e) {
            Debug.LogError($"Error HandleLocationPacket: {e.Message}");
        }
    }
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    public class Spawner : MonoBehaviour
    {
        public static Spawner instance;
        private HashSet<string> currentUsers = new HashSet<string>();
        
        void Awake() {
            instance = this;
        }
    
        public void Spawn(LocationUpdate data) {
            if (!GameManager.instance.isLive) {
                return;
            }
            
            HashSet<string> newUsers = new HashSet<string>();
    
            foreach(LocationUpdate.UserLocation user in data.users) {
                newUsers.Add(user.id);
    
                GameObject player = GameManager.instance.pool.Get(user);
                PlayerPrefab playerScript = player.GetComponent<PlayerPrefab>();
                playerScript.UpdatePosition(user.x, user.y);
            }
    
            foreach (string userId in currentUsers) {
                if (!newUsers.Contains(userId)) {
                    GameManager.instance.pool.Remove(userId);
                }
            }
            
            currentUsers = newUsers;
        }
    }

     

     서버에서 송신한 LocationUpdate 패킷을 클라이언트가 수신하면 Spawner 인스턴스의 Spawn을 이용하여 response에 담긴 위치 정보를 기반으로 캐릭터를 생성한다. 그런데 Figure 7과 같이 유저의 캐릭터가 둘로 불어나는 문제가 생겼다. 원인을 파악하던 중 클라이언트의 Spawner 코드에서 따로 유저 캐릭터 중복 제거 작업을 하지 않는 것 같아서 과제 요구사항을 다시 읽어보면서 이유를 알게 되었다.

     

    Figure 8. 필수 요구사항
    Figure 9. 도전 요구사항

     필수 요구사항에는 자세히 언급되어있지 않지만 도전 요구사항에 간접적으로 언급되어 있는 부분인데, 필수 요구사항의 구현에서는 패킷을 송신한 클라이언트의 유저 캐릭터 정보를 제외한 나머지 유저의 위치 정보를 수신해야 한다는 뜻이다. 도전 요구사항은 클라이언트 수정을 요구하므로 서버쪽 코드만 수정하면 해결할 수 있다는 말이 된다. 현재 서버쪽 코드에서는 game.getAllUsers() 함수로 게임 내 모든 유저의 정보를 LocationUpdate에 직렬화하는데, 여기서 패킷을 송신한 클라이언트의 유저 위치 정보를 제외하고 보내면 된다고 해석할 수 있다. 아래와 같이 수정하여 해결했다.

     

    // server-side, location-update.handler.js
    
    ...
    
    const filteredUsers = game.getAllUsers().filter((user) => user.id !== userId);
    const responsePayload = LocationUpdate.encode({ users: filteredUsers }).finish();
    
    ...

     

    Figure 10. 두 개의 클라이언트로 접속 (수정 후)

     

    필수 요구사항 완료!

     

    --


    REFERENCES:

     

     

    Chapter 5 게임서버 주특기 플러스 개인과제 | Notion

    Intro: “지금까지는 튜토리얼”

    teamsparta.notion.site

     > 과제 spec

     

    GitHub - donkim1212/sparta-ch5-tcp-game-server: [내일배움캠프] 스파르타) Chapter 5 게임서버 주특기 플러스

    [내일배움캠프] 스파르타) Chapter 5 게임서버 주특기 플러스 개인과제. Contribute to donkim1212/sparta-ch5-tcp-game-server development by creating an account on GitHub.

    github.com

     > 과제 repo

    728x90
Designed by Tistory.