JS/TIL(Today I Learned)

2024-12-13

프린스 알리 2024. 12. 13.

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

웹소켓으로 게임 서버 제작하기 - 이게 트러블 슈팅…?

TIL이라는 이름이 무색하게도 오늘 내게 남은 것은 좀처럼 답이 보이지 않는 버그와 싸운 기억 뿐이다. 아니, 어쩌면 배웠다고 해야할까. 이 에러와 사흘 가까이 분투하느라 Promise에 대한 개념도 바로잡게 되었고, 웹소켓과 아주 조금이나마 친해졌으니 말이다. 오늘은 다소 사담 같은 TIL을 작성해볼까 한다.

발단: 서버에서 로직을 수행하자

문제는 뭐 당연하겠지만 웹소켓에 대한 어정쩡한 이해도 때문에 발생했다. 대충 emit은 전송하는 거고 on은 듣고 있는 거 아님? 이 정도로만 생각하고 있다가 복잡한 코드 앞에서 급격히 머리가 아파지기 시작했던 것이다.

태초에 (원흉이 되었던) 이런 코드가 있었다.

class Score {
  score = 0;
  HIGH_SCORE_KEY = 'highScore';
  // stageChange = true;
  lastStageId = null; // 마지막으로 알림을 보낸 스테이지 ID

  constructor(ctx, scaleRatio) {
    this.ctx = ctx;
    this.canvas = ctx.canvas;
    this.scaleRatio = scaleRatio;
  }

  update = async (deltaTime) => {
    this.score += deltaTime * 0.001;
    let currentStageId = Math.floor(this.score / 10) + 1000;

    if (
      Math.floor(this.score) % 10 === 0 && // 스코어가 10의 배수일 때
      this.lastStageId !== currentStageId && // 마지막으로 알림을 보낸 스테이지와 다를 때
      Math.floor(this.score) >= 10
      // && this.stageChange
    ) {
      // this.stageChange = false;
      this.lastStageId = currentStageId; // 현재 스테이지 ID로 업데이트
      await sendEvent(11, {
        currentStage: currentStageId - 1,
        targetStage: currentStageId,
      });
    }
  };

클라이언트 쪽에서 Score를 갱신할 때 쓰이는 메서드였다. 사실 로직적으로는 아무런 문제가 없다. 원하는 대로 작동했기에 겉으로만 본다면 썩 괜찮았다.

 

문제는 그게 겉뿐이라는 사실이다. 10으로 나누고 1000을 더해서 하드코딩이 된 숫자를 바탕으로 서버에 요청을 보낸다니. 솔직히 좀 그렇다. 이게 아주아주 작은 프로젝트라서 괜찮기야 하겠다만, 로직을 클라이언트에서 수행해서야 웹소켓을 배우는 의미가 많이 퇴색될 것이다. 양방향 통신….겨우 테이블 갱신해달라는 요청만 할 거라면 뭐하러 이걸 배우겠는가. 함수야 클라이언트에서도 만들 수 있고 요청이야 http로 보내면 되는걸.

전개: 서버에서 로직을 수행하자

그래서 결국엔 결심을 하게 되었다. 서버에서 로직을 수행하고 그 값을 클라이언트에서 받아오기로 결정하고야 만 것이다. 시작은 바람직했으나…과정이 상당히 고통스러웠다.

복선1: socket.on에 대한 몰이해

socket.on()이란 대체 뭘까.


이걸 쓰면 대충 '듣고 있는 중'이라고 이해하는 것까진 괜찮다. 하지만 코드를 짜야한다면 그정도 이해력만으로는 부족하다. socket.on()에 대해 반드시 알아둬야 하는 건 이 메서드는 이벤트 리스너를 등록하는 함수이며, 이곳에 등록된 콜백함수는 높은 확률로 비동기 처리가 필요하다는 사실이다.

 

물론 아래와 같은 경우에는 비동기처리가 필요하지 않다.

socket.on('response', (response) => {
    console.log(response)
})

하지만 서버의 response 안에 클라이언트가 써야 하는 데이터가 담겨 있다면? 내가 그 데이터를 반드시 쓰고 말겠다고 결심을 했다면? 그런데 socket.on에 비동기 처리가 필요하다는 사실을 하루가 지나서야 알게 된다면? 심지어 Promise에 대해 정확히 알지도 못한다면?

복선2: Promise가 함수인 줄 알았어요

아무래도 무식하게 코드를 짰다고 밖엔 할 말이 없다.
강의에서 실습한 걸 바탕으로 코드를 짰다 보니 Promise가 정확히 뭔지도 모르고 있었다.
그제서야 부랴부랴 Promise를 다시 공부했고,(지난 TIL 참고)
공부한 내용을 바탕으로 코드를 뜯어 고치기 시작했다.

const sendEvent = async (handlerId, payload) => {
  return new Promise((resolve, reject) => {
    // 서버에 이벤트 전송
    socket.emit('event', {
      userId,
      clientVersion: CLIENT_VERSION,
      handlerId,
      payload,
    });

    // 응답 수신 리스너 등록
    socket.on('response', (response) => {
      // 응답 데이터 확인

      try {
        if (!response) {
          reject(new Error('서버 응답이 비어 있습니다.'));
          return;
        }

        if (response.status === 'success') {
          resolve(response);
        } else {
          reject(new Error(response.message || 'Unknow Error'));
        }
      } catch (err) {
        console.error(err.message);
      }
    });
  });
};

완성된 sendEvent() 함수는 클라이언트에서 서버로 특정 이벤트 핸들러에 대해 패킷을 보낼 수 있게 구성되어 있다. 또, 보낸 패킷에 대한 응답(response)을 수신해서 응답의 status 값이 success라면 Promise 객체 속에 resolve 혹은 reject를 결과로 저장해둘 수 있다.

 

그렇게 얻어낸 응답을 활용하고 싶다면? 익히 알고 있듯이 async-await를 사용해서 결과값을 가져올 수 있다.

update = async (deltaTime) => {
// ... 
      const serverResponse = await sendEvent(4, {});
      const serverStageId = serverResponse.message;
      this.stageId = serverStageId;
// ... 
};

내가 바랐던 건 그리 대단한 것도 아니었다. 바로 이 한 줄의 데이터 조각이었다.

{ status: 'success', message: 1000 }

그러나 내가 맞닥뜨린 건?

위기: 비동기 처리가 낳은 끔찍한 결과…

진짜 이해가 가질 않았다. 내가 뭘 잘못했지? 로직이 잘못된 구석이 보이지 않는데 대체 왜 돌아가질 않는 걸까. 다시 서버 쪽 코드로 돌아가서 return을 잘못해주고 있는 건지 살펴봤다.

export const getStageId = async (userId) => {
  // 유저의 현재 스테이지 정보
  let currentStages = getStage(userId);
  if (!currentStages.length) {
    console.log('No stages found for user');
    return { status: 'fail', message: 'No stages found for user' };
  }

  // 오름차순 -> 가장 큰 스테이지 ID를 확인 <- 유저의 현재 스테이지
  currentStages.sort((a, b) => b.id - a.id); // 문제 후보(3) 빈 배열 가능성
  let currentStageId = currentStages[0].id; // 문제 후보(2) null이 담길 가능성

  return { status: 'success', message: currentStageId };
};

그럼에도 알 수가 없었다. 대체 왜 안 되는 걸까….
너무 짜증이 났던 나는 중단점을 마구 찍고 디버그 터미널을 돌리다가 아주 이상한 현상을 목격하게 된다.

 

없다.
무엇이?
메시지가 없다.

이럴 수가 있는 건가????
이해가 안 가서 디버깅 로그까지 찍어봤다.

console.log('Handler response:', response);

 

 

"뭐야? 진짜로 없잖아…"

절정: 반갈죽

내 코딩 인생이 겨우 몇 개월 남짓이라 그런가. 객체가 반갈죽을 당하는 건 처음 목격하는 일이었다.
내가 처음 겪는 일이라면 집단 지성을 이용할 수밖에.

근데

없었다.

결말: 용두사미?

마구 써재낀 소설이 그러하듯, 마구 써재낀 코드도 용두사미의 결말을 맞기 마련일까. 문제의 해결은 아주 싱겁고도 희한하게 이루어졌다.

 

물론 해결하는 과정만큼은 고난과 역경의 연속이었다. 그동안 나는 파싱도 의심해봤고, 혹시나 싶어서 하드코딩도 해봤지만 반갈죽당한 나의 객체는 돌아오지 않았다.

return JSON.stringify({
    status: 'success',
    message: 1000,
});

그런데 튜터님께 헬프를 보내고 디버그 콘솔을 찍어서 보여드리기 위해 반환값을 잠시 result 속에 담았을 때,

let result = { status: 'success', message: currentStageId };
return result;

갑자기 문제가 해결되었다. 집 나갔던 메시지가 돌아왔다.


네? 이게 끝?

정말로 이게 끝이다. 원인은 여전히 정확하겐 모르지만 튜터님은 다음과 같은 원인일 수 있다고 후보를 골라주셨다.

// 오름차순 -> 가장 큰 스테이지 ID를 확인 <- 유저의 현재 스테이지
currentStages.sort((a, b) => b.id - a.id); // 문제 후보(3) 빈 배열 가능성
let currentStageId = currentStages[0].id; // 문제 후보(2) null이 담길 가능성
// currentStageId = currentStageId.toString(); // 자료형 캐스팅 문제? 문제 후보(1)

어쩌면 자바스크립트의 과하게 유연한 형변환이 불러온 재앙이었던 걸까? 자바스크립트가 동적 타입 언어라는 건 익히 들어서 알고 있긴 했다. C로 코딩에 처음 입문했으니, 언젠가 이런 문제를 겪게 되리라 예상은 했다만… 그게 오늘이 될 줄이야.

 

자바스크립트가 원래 그런 언어인데 내가 어떻게 할 수도 없는 노릇이니, 다소 허탈하고 씁쓸한 트러블 슈팅이었다고 간추릴 수 있겠다.(이걸 과연 트러블 슈팅으로 쳐도 괜찮을지 모르겠지만) 앞으로 이상한 버그와 맞닥뜨리게 된다면 자바스크립트가 가진 특징에 유의하는 습관을 가지는 수밖엔 없을 것 같다.

'JS > TIL(Today I Learned)' 카테고리의 다른 글

2024-12-17  (0) 2024.12.17
2024-12-16 <OSI 7계층 - 네트워크 계층(IP, 서브넷 마스크, 라우터와 라우팅)>  (6) 2024.12.15
2024-12-12  (2) 2024.12.12
2024-12-11  (0) 2024.12.11
2024-12-10 <HTTP와 TCP, 그리고 웹소켓>  (2) 2024.12.10

댓글