항해 플러스 코육대를 해봤다

Sharlotte ·2023년 10월 3일
0


https://hanghaeplus-coyukdae.oopy.io/

이번 추석 때 간단히 토이 프로젝트를 하고 싶었는데 어느 오픈채팅방 공지에 해커톤같지만 해커톤같지 않은 이벤트를 홍보하고 있었다.

주제가 꽤나 간단했는데 상품은 그렇지 않은 모니터라서 갑자기 끌렸다.

생각하기

그냥 행맨만 만드는건 그냥 Array#include 짬처리밖에 되지 않는다, 너무 간단해서 하루만에 끝날게 뻔했다. 사람이 좀 간절해져야 한다고 생각해서 유니코드디스코드 봇, 웹페이지를 모두 뭉치는 혼종을 떠올렸다. 그렇게 해서 만들어진게 아래 다이어그램이다.

서버단에서 행맨 게임 코어가 제공하는 인터페이스를 웹과 디코봇에서 따로 자신들의 클라이언트와 결합하게끔 호환 코드를 구체화하도록 만들었고, 이렇게 구체화된 코드는 프론트에선 express를 통한 data fetching과 socket.io, 디스코드 봇에선 라이브러리와 discord API 간의 내부적 통신으로 클라이언트에 도달한다.
그리고 왜 그랬는지 몰라도 플렛폼에서 만족 못했던 난 유니코드로 행맨을 하자는 기상천외한 미친 생각을 내버렸다.

즉, 이번 프로젝트는 무려 크로스 플렛폼 유니코드 행맨 멀티플레이게임인 것이다.

구현하기

행맨 코어 만들기

사실 행맨은 아주 간단한 게임이다.
주어진 글자들 중 하나를 제출하면 글자를 없애고, 정답 글자들에 있으면 같은 글자들을 모두 공개하는 대신 만약 없다면 기회가 하나 차감되어서 0에 도달하면 지는 구조다.
이 코어에서 한번 비틀기 위해 "주어진 글자들"의 범주를 "알파뱃"에서 "유니코드"로 확장시켰다.

처음엔 유니코드에 대해 전혀 몰라서 이런저런 시행착오를 겪었는데, 그 중 가장 끔직한게 유니코드가 여러개인 문자가 존재한단 것이였다. 문자를 유니코드 배열로 간주하고, 문자열 배열을 유니코드 이차원 배열로 간주해야 하므로 문자 동치와 같은 대부분의 영역에서 나쁜 DX를 경험했다.
그러나 MDN에 따르면, 굳이 unicode가 아니더라도 단일 숫자로 UTF-16에 호환되는 문자들을 인코딩/디코딩할 수 있는 함수가 있었다. 이름이 charPointAt인 함수인데, 비슷한 charCodeAt가 먼저 보여서 놓쳤던 것이다. 인코딩은 fromCodePoint다.

결국 유니코드에서 "UTF-16로 인코딩되는 유니코드"로 바뀌었지만 그래도 많으니 만족한다.

추가로, 주어진 단어들의 범주가 기하급수적으로 늘어났는데 어떻게 선별하냐는 의문도 자연스래 생겼었다. 그냥 중복이 안될때까지 계속 랜덤으로 돌렸다. 비효율의 극치이라서 조금 아쉬운 엑션이긴 하다.

이렇듯이 행맨 코어는 행맨이란 게임의 기본적인 로직과 유니코드와의 결합을 이룬 상태인데, 여러 플렛폼에서 똑같이 호환되기 위해선 행맨 게임 객체가 스스로 id를 만들어 갖고 있어야 한다는 결론이 났다. uuid는 또 어떻게 뽑나 하고 귀찮니즘이 쇄도했지만 crypto 내장 라이브러리의 randomUUID 함수가 이미 있어서 생각보다 정말 쉽게 해결했다.

유저 시스템은 따로 만들지 않았다. 서버와 클라이언트가 있으니 어쩌다가 결국 필요해지지 않을까 싶었지만 결국 필수적인 상황은 오지 않았다. 부가기능을 위해 필요할 것 같다.

디스코드 봇

일반적으로 어떠한 프로그램을 작성할 때, 첫번째로 만들기 편한게 CLI고 두번째로 편한게 이러한 챗봇이라고 생각한다. 공통점은 클라이언트에 대한 관심이 비교적 크게 줄어든다는 점이다. 때문에 디스코드 봇으로 먼저 개발을 주도했다.

디스코드 봇의 개발은 생각보다 순조로웠다. 애초에 처음부터 설계를 해두고 들어가니 어디서 책임을 잘라내야 하는지가 명확하게 보이는 느낌이였다. 명령어 구조까지 직접 짜기엔 부담스러워서 Discordx 프레임워크를 사용했다. 데코레이터로 명령어를 구사할 수 있도록 마련해주는 도구다.

또한 명령어에 맞춰 구현할 때 멀티플레이를 계속 의식하면서 디스코드 봇에서 핵심적인 로직을 담당하지 않도록 작성했다. 봇 자신 또는 디스코드와 연관된 작업 외의 게임적인 부분들은 모두 코어 인터페이스를 호출하여 해결했다.
물론 쌍방향 통신은 한쪽에서 보내기만 해서 될 일이 아니다. 행맨 코어에서도 디스코드 봇으로 데이터를 보내기 위해 디스코드 봇이 행맨 코어에 이벤트를 구독하도록 만들었다. 이제 웹페이지 유저가 게임을 플레이해도 중요한 이벤트들은 디스코드 봇에게도 알려져서 똑같이 디스코드 유저도 볼 수 있게 된다.

프론트

물론 이런 방법은 웹페이지에선 통하지 않으므로 거기선 거기대로의 통신 방법을 구축했다.
아래와 같이 아주 끔찍하고 결합적인 콜백 지옥의 함수 하나가 소켓 통신을 모두 담당하고 있다.
서버쪽에서 게임 이벤트를 클라이언트로 전달해주고, 클라이언트에서 오는 이벤트를 중간에 처리한 다음 게임에 보내주는 중계기의 역할을 소켓이 하고 있다.

io.on("connection", (socket) => {
  console.log("a user connected");
  socket.on("join", (gameId, callback) => {
    console.log("a user joined to", gameId);
    const game = GameManager.games[gameId];
    if (!game) return;

    const getGamePayload = (): WebsocketGamePayload => ({
      words: Array.from(game.words),
      correctWords: Array.from(game.correctWords),
      misCorrectWords: Array.from(game.misCorrectWords),
      life: game.life,
      currentAnswer: Array.from(game.correctAnswerWords)
        .map((answerCharPoint) =>  game.correctWords.has(answerCharPoint) ? displaySupportedUnicode(answerCharPoint) : "_")
        .join(""),
    });
    callback(getGamePayload());
    game.on("WORD_TRIED", (word, isSuccessed) => {
      socket.emit("WORD_TRIED", word, isSuccessed, getGamePayload());
    });
    game.once("GAME_ENDED", (isWin) => {
      socket.timeout(3000).emit("GAME_ENDED", isWin, getGamePayload());
    });
  });
  // for real-time game list updating
  GameManager.on("GAME_STARTED", (gameId) => {
    socket.emit("GAME_STARTED", gameId);
  });

  socket.on("START_GAME",
    (
      correctAnswer: string,
      wordAmount: number,
      callback: (id: string) => void
    ) => {
      const game = GameManager.startGame(correctAnswer, wordAmount);
      callback(game.id);
    }
  );
  socket.on("WORD_TRIED", (gameId, word) => {
    GameManager.games[gameId].try(word);
  });
  socket.on("disconnect", () => {
    console.log("user disconnected");
  });
});

서버와 클라이언트 모두 만들어야 하니 평소와 같았더라면 Next.js로 퉁치면 될 일이였다.
그러나 이번에는 그렇게 할 수 없다! Next.js는 웹소켓 통신, 더 나아가 Socket.io 통신과 호환되지 않는다. ...물론 page dir에서 커스텀 서버로 만들거나 next.js에서 RCC만 해대는 식으로 가능은 하지만 불쾌한 어거지로 느껴졌다.
그래서 마감일 하루 전에 vite로 전환하기로 결정했다. 그리고 10분만에 끝났다. 맙소사!

Vite는 next.js와 같은 page 라우팅 방식이 없어 아쉬웠는데 역시나 이미 있었다!. Vite plugin pages 플러그인으로 손쉽게 next.js pages dir과 같은 형태로 구축했다.

또한 쌍방향 통신을 위해 socket.io를 사용했고, 간편한 모달창 보여주기를 위해 SweetAlert2를 사용했다.
스타일 라이브러리나 UIKit까진 필요없어서 css module를 사용했다.

배포하기

그거 아는가? Vercel는 Next.js 뿐만이 아니라 여러 번들러들도 자유롭게 지원해준다.
이것 덕분에 Vite로 Vercel에 배포할 수 있게 되었다.
웹프론트는 이렇게 간단히 해결됐지만 Node.js 서버가 정말 문제였다. github education도 전에 따놓아서 heroku를 통해 배포할 생각이였지만 모노레포라서 heroku가 최상위 경로만 고집해 미칠 것 같았다. 결국 포기하고 Google Cloud Platform으로 이동했는데 많이 힘들 것 같다.

여러모로 억까도 많고 힘들고 이게 3일만의 결과물이 맞나 싶을 정도로 크지만 역으로 이정도로 짜릿한 적은 올해에 들어 오랜만인 것 같았다.

profile
샤르르르

0개의 댓글