개발일지/TIL(Today I Learned)

2025-01-14 <트러블 슈팅> - 게임 종료 및 세션 삭제 구현하기

프린스 알리 2025. 1. 14.

내일배움캠프 Node.js 트랙 54일차

<트러블 슈팅> - 게임 종료 및 세션 삭제 구현하기

게임이 정상적으로 종료되지 않던 문제

 

유니티 에디터를 통해서 실행을 종료할 때, 클라이언트 연결이 정상적으로 종료되지 않던 문제가 있었다. 위 에러 메시지는 종료가 제대로 이루어지지 않아서 소켓 오류가 났다는 것인데... 사실 이런 에러가 나는 건 어쩔 수 없는 부분이긴 했다.(오류가 나면 에러 메시지를 뱉게끔 코드를 짰기 때문에)


다만, 종료 이후에 이루어져야 할 로직들(세션에서 유저 삭제, 유저 세션에서 삭제 등등…)이 에러로 인해 실행되지 않는 게 문제였다. 에러 메시지는 그대로 두되, 그 외의 작동이 정상적으로 수행될 순 없는 걸까. 고민을 좀 해보다가 종료 방식을 바꿔보기로 결정했다.

TCP 연결 종료 방식 변경

그래서 Disconnect 핸들러 함수를 만들어서 서버단에서 클라이언트의 연결을 종료하는 방식으로 개선하였다.

 

<클라이언트>

public void SendDisconnectPacket () {
        if (!Connected) return;

        try
        {
                Disconnect disconnect = new Disconnect();
                SendPacket(disconnect, (uint)Packets.HandlerIds.Disconnect);
                Debug.Log("Disconnect 패킷 전송 완료");

                System.Threading.Thread.Sleep(100); // 100ms 대기
                _connected = false;
        }
        catch (Exception ex)
        {
                Debug.LogError($"Disconnect 패킷 전송 실패: {ex.Message}");
        }
}

 

<서버>

export const disconnectHandler = async ({ socket, userId }) => {
  console.log('클라이언트가 연결을 종료했습니다.');

  const user = getUserById(userId);

  if (!user) {
    throw new CustomError(
      ErrorCodes.USER_NOT_FOUND,
      '유저를 찾을 수 없습니다.',
    );
  }

  // 플레이어의 마지막 위치 저장
  await updateLastLocation(user.x, user.y, user.id);
  // 플레이어의 마지막 게임 세션 ID 저장
  await updateLastGameId(user.gameId, user.id);

  // 세션에서 유저 삭제
  removeUser(socket);
  // 게임 세션에서 유저 삭제, 세션의 유저 배열이 빈 배열이면 세션도 삭제
  removeUserInSession(user.id, user.gameId);

  console.log('유저 세션', userSessions);
  console.log('게임 세션', gameSessions);

  socket.destroy(); // 정상적으로 소켓 종료
};

그랬더니 임시적으로 문제가 해결된 모습이다.

 

유저의 강제 종료 대처하기

그러나 이것만으론 안심하기엔 일렀다. 왜냐하면 유저가 강제 종료를 하는 경우를 고려해야 했기 때문이다. 강제 종료를 하면 앞선 소켓 오류가 여전히 발생했고, 그 때문에 여전히 유저의 데이터가 게임 세션에 계속 남아있는 버그가 있었다.

그래서 좀 더 코드를 보완하고자 했다.

 


이 버그의 가장 큰 문제는 강제 종료 시에도 tcp 연결은 유지되어 있다는 점이었다. 이 점을 역이용해서 연결 종료 패킷을 보내기로 했다. 팀원과의 회의 중, 클라이언트가 종료 될 때 OnApplicationQuit이라는 함수가 자동으로 실행된다는 힌트를 얻게 되었고, 이 곳에 서버로 패킷을 보내는 함수를 호출하였다.

 

 

그리고 하나 더 고려해볼 문제가 있었는데, 강제 종료 시에도 tcp 연결이 남아 있다곤 하지만 tcp 통신조차 제대로 되지 않는 경우가 있을 수도 있다는 점이었다. 이때는 클라이언트로부터 패킷을 받을 것이라 확신할 수 없기에, 인터벌 매니저에서 주기적으로 연결 상태를 체크하기로 했다.

 

아래 코드는 서버 쪽에서 구현해둔 checkPong 함수인데, 10초 이상 퐁이 오지 않으면 클라이언트의 연결을 강제로 종료하고 있다.

checkPong = async () => {
    let now = Date.now();
    // console.log('연결 췤!: ', now - this.lastPong);

    // 10초 이상 퐁이 오지 않으면 연결 종료
    if (now - this.lastPong > 10000) {
        console.log('클라이언트가 연결을 종료했습니다.');

        // 플레이어의 마지막 위치 저장
        await updateLastLocation(this.x, this.y, this.id);
        // 플레이어의 마지막 게임 세션 ID 저장
        await updateLastGameId(this.gameId, this.id);

        // 세션에서 유저 삭제
        removeUser(this.socket);
        // 게임 세션에서 유저 삭제, 세션의 유저 배열이 빈 배열이면 세션도 삭제
        removeUserInSession(this.id, this.gameId);

        console.log('유저 세션', userSessions);
        console.log('게임 세션', gameSessions);

        this.socket.destroy(); // 정상적으로 소켓 종료
    }
};

결과

지금까지는 강제종료를 하면 소켓 오류가 나고 종료 작업이 제대로 되지 않았지만, 서버에서 종료하는 방식으로 변경하고 위의 안전장치를 완성하니 오류는 나더라도 나머지 작업(세션에서 유저 삭제 등등)이 가능해졌다! 따라서 강제 종료된 플레이어의 스프라이트가 삭제되지 않고 세션에 남아있던 버그 또한 말끔히 고쳐졌다.

유저 및 게임 세션 삭제

끝으로 유저 및 세션 삭제가 정상적으로 이루어지는지 콘솔 로그를 통해 확인해 보았다.

클라이언트가 연결되었습니다: 127.0.0.1 53731
[2025-01-10 17:03:33] Executing query: SELECT * FROM user WHERE id = ? , ["50e7003c-b112-4f26-a091-ea7d8172f91c"]
[2025-01-10 17:03:33] Executing query: INSERT INTO user (id) VALUES (?) , ["50e7003c-b112-4f26-a091-ea7d8172f91c"]
게임 세션 생성!
접속 중인 유저들 [
  User {
    checkPong: [AsyncFunction: checkPong],
    id: '50e7003c-b112-4f26-a091-ea7d8172f91c',
    socket: Socket {
      #생략
    },
    x: 0,
    y: 0,
    latency: 0,
    lastUpdateTime: 1736496213692,
    playerId: 0,
    lastPong: 1736496213692,
    gameId: 'fd648930-2ed4-409b-82b6-02390ab34917'
  }
]
클라이언트가 연결되었습니다: 127.0.0.1 53734
[2025-01-10 17:03:34] Executing query: SELECT * FROM user WHERE id = ? , ["561f7c85-f22c-47b7-80a6-41dd764643a9"]
[2025-01-10 17:03:34] Executing query: INSERT INTO user (id) VALUES (?) , ["561f7c85-f22c-47b7-80a6-41dd764643a9"]
접속 중인 유저들 [
  User {
    checkPong: [AsyncFunction: checkPong],
    id: '50e7003c-b112-4f26-a091-ea7d8172f91c',
    socket: Socket {
      #생략
    },
    x: 0,
    y: 0,
    latency: 0,
    lastUpdateTime: 1736496214698,
    playerId: 0,
    lastPong: 1736496213692,
    gameId: 'fd648930-2ed4-409b-82b6-02390ab34917'
  },
  User {
    checkPong: [AsyncFunction: checkPong],
    id: '561f7c85-f22c-47b7-80a6-41dd764643a9',
    socket: Socket {
      #생략
    },
    x: 0,
    y: 0,
    latency: 0,
    lastUpdateTime: 1736496214724,
    playerId: 1,
    lastPong: 1736496214724,
    gameId: 'fd648930-2ed4-409b-82b6-02390ab34917'
  }
  # 게임ID가 같다는 건 같은 세션에 속해있다는 것
]
클라이언트가 연결을 종료했습니다.
유저 세션 [
  User {
    checkPong: [AsyncFunction: checkPong],
    id: '561f7c85-f22c-47b7-80a6-41dd764643a9',
    socket: Socket {
      #생략
    },
    x: -2.6280000000000023,
    y: 3.7874999999999996,
    latency: 54,
    lastUpdateTime: 1736496217210,
    playerId: 1,
    lastPong: 1736496214724,
    gameId: 'fd648930-2ed4-409b-82b6-02390ab34917'
  }
  # 한 명만 남은 모습
]
게임 세션 [
  Game {
    users: [ [User] ],
    intervalManager: IntervalManager { intervals: [Map] },
    state: 'waiting',
    id: 'fd648930-2ed4-409b-82b6-02390ab34917'
  }
  # 한 명만 남은 모습
]
Connection closed normally.
클라이언트가 연결을 종료했습니다.
유저 세션 []
게임 세션 []
# 그리고 아무도 없었다.
Connection closed normally.

댓글