본문 바로가기

개발/개발 후일담

웹에서 즐기는 보드게임 만들기

반응형

웹에서 즐기는 보드게임 만들기 - 마블

 

서론

개발 소식, 커뮤니티를 보다 보면 재미있는 기술 스택들이 많이 보이는데 이를 적용할 기회가 적었습니다. 프로덕션 레벨에서는 맘대로 기술 스택을 바꾸거나 적용하기가 어려웠고 그렇다고 이를 시험해 볼 수 있는 장기적인 사이드 프로젝트도 없었습니다. 무언가 내 맘대로 할 수 있는 테스트베드가 필요했습니다.

 

마블(Marble)은 웹 기반으로 진행되는 도시건설 보드게임(랜드마크 건설!)입니다.

 

마블 인게임

 

프로젝트 첫 번째 목표는 '게임이 굴러가게 만들자'가 최우선이고 두 번째는 쿠버네티스, MSA, 이벤트 기반 아키텍처 등 진짜 관심 있는 개발론을 적용시켜 보는 게 목표입니다.

⚙️ 기반 기술스택 정하기

React + NestJS + tailwindcss

 

프론트엔드는 React, 백엔드는 NestJS를 사용해서 만들었습니다.

또한 프론트엔드 스타일링 도구로 tailwindcss(이하 tailwind)를 선택했습니다. tailwind는 기본적인 스타일 값(margin, padding 등)과 색상 테마를 지원합니다. 저는 전문적인 디자이너가 아니고 이 프로젝트 역시 디자인에 초점을 두지 않았기 때문에 tailwindcss를 사용해서 조금의 부담을 덜 수 있었습니다.

 

마블은 프론트엔드보다 백엔드에서 집중하고 싶은 프로젝트입니다. (React 계열에서는) NextJS 선택지도 있었는데, 러닝커브와 프로젝트 관리 없이 쉽게 개발하기 위해 React를 선택했습니다.

백엔드는 사실 다룰 수 있는 프레임워크가 Spring Boot, NestJS 말고 없습니다😅 NestJS의 경우 Nestia 라이브러리를 사용해서 프론트엔드를 위한 SDK를 만들 수 있는데 이를 사용해 보기 위해 선택했습니다. (Nestia는 바로 다음 이야기에서 다룹니다.)

🎈 ORM은 Prisma 그리고 API는 Nestia

데이터베이스와 백엔드 서버를 연결시켜 주기 위한 ORM을 골라야 합니다. 프로젝트를 만들었던 당시에 Sequelize, TypeORM, Prisma로 크게 세 가지가 대표적인 ORM이었습니다. (요즘에는 DrizzleORM, MikroORM이라는 선택지도 있습니다.)

Sequelize는 TypeScript에서 사용하기에 개발 경험이 좋지 않았습니다. 이전에 사용했던 경험으로는 테이블 간 관계 설정을 TypeScript의 이점을 살리면서 쓰기에는 쉽지 않았습니다. TypeORM도 좋은 ORM 라이브러리이지만 TypeORM에서는 Entity 정의를 코드 파일로 정의합니다. 이와 다르게 Prisma에서는 `.schema` 파일을 통해 Entity 구조를 정의할 수 있습니다.

 

작성한 Schema 파일을 코드에서 사용할 수 있는 라이브러리 형태로 변환합니다.

 

`*.schema` 파일에 Entity를 정의하면 Prisma는 이를 `@prisma/client` 라이브러리로 감싸서 제공합니다. 백엔드 코드에서는 `@prisma/client` 파일을 불러와 정의한 Entity들을 사용할 수 있습니다.

 

프론트엔드와 백엔드 통신을 위해 Nestia 라이브러리를 사용했습니다.

보통은 HTTP API를 사용할 때 `fetch`, `axios`와 같은 HTTP 클라이언트 라이브러리를 사용합니다. Nestia 역시 내부적으로는 HTTP 클라이언트 라이브러릴 호출합니다.

Nestia에서 제공하는 데코레이터를 통해 NestJS에서 API를 정의하면 이를 기반으로 SDK를 만들어줍니다. 프론트엔드에서는 이 SDK를 가져와 쓰기만 하면 됩니다. API 호출 주소나 스펙 등을 관리하지 않고 백엔드 기반으로 생성되는 SDK으로 API를 호출할 수 있습니다.

🔧 TurboRepo으로 모노레포 적용하고 Renovate으로 의존성 관리하기

Prisma Schema, Nestia SDK 그리고 여기에 더해지는 프론트엔드와 백엔드 ESLint 설정 파일까지, 공통적으로 쓰이는 코드들이 여러 있습니다. 이를 하나로 묶어 관리하기 위해 모노레포를 적용했습니다.

모노레포 툴은 TurboRepo를 사용합니다. TurboRepo는 작 과정을 캐싱합니다. 즉, 이미 캐싱된 내용은 작업하지 않고 넘아갑니다.

 

프론트엔드, 백엔드와 같은 서비스 코드는 `apps` 폴더에, Nestia API, Prisma Schema, ESLint, tsconfig 등과 같은 공통된 모듈은 `packages` 폴더에 넣습니다.

 

Renovate는 의존성을 관리해 주는 툴입니다. 기본적으로 GitHub에서 비슷한 기능을 제공하는데, Renovate에서는 보다 상세한 설정을 제공합니다. 또한 GitHub Issue를 통해 Dashboard를 제공합니다.

의존성 업데이트가 필요한 PR을 자동으로 생성해 주는데 무료 플랜에서는 Open PR의 개수가 10개로 제한되어 있습니다. 올라와 있는 PR을 모두 처리해 주면 새로운 의존성 업데이트 PR이 올라옵니다.

 

GitHub Issue - Renovate Dashboard

🕹️ 게임 상태 관리하기

게임 방과 게임 상태를 관리할 수단이 필요합니다. 방(Room)에서는 방 제목, 방장, 최대 인원, 게임 진행 상태를 관리해야 하고 게임(Game)에서는 플레이어 점수, 플레이어의 재화, 보드에서의 현재 위치 등을 관리해야 합니다. 게임이 끝나면 사라지는 소모성 데이터로 영구 저장할 필요가 없는 데이터입니다. 백엔드에서는 게임 상태를 관리하기 위해 Redis를 적용했습니다.

 

프론트엔드에서도 플레이어의 정보를 관리할 필요가 있습니다. 프론트엔드에서는 Zustand를 사용해서 플레이어의 게임 상태를 관리합니다.

 

상태 관리를 위해 백엔드에서는 Redis, 프론트엔드에서는 Zustand를 사용합니다.

 

방에서 모든 플레이어가 준비를 마치고 게임을 시작하면 WebSocket을 사용해 백엔드에서 초기 상태를 가져옵니다. 그리고 가져온 데이터를 Zustand로 만든 상태 저장소에 저장합니다.

 

다시 백엔드 Redis로 넘어와서, Redis에 저장한 데이터를 TypeScript 레벨에서 쉽게 관리할 방법이 필요합니다. 매번 데이터를 Redis에서 가져오고 이를 도메인 타입에 맞게 변환할 수는 없으니까요.

마블에서는 Redis와 동기화할 수 있는 추상 클래스를 만들고 이를 상속하는 방식으로 도메인을 만들어 관리합니다.

 

import { RedisClientType } from 'redis';

export abstract class SyncableToRedis {
  abstract toString(): string;

  abstract toJSON(): unknown;

  protected async syncRedis(redis: RedisClientType, table: string, key: string): Promise<void> {
    try {
      await redis.hSet(table, key, this.toString());
    } catch (error) {
      throw new Error(`Failed to sync ${table} to redis: ${error}`);
    }
  }
}

// 스펠링은 시적허용(?)으로 넘어가주세요...

 

이렇게 만든 클래스를 아래처럼 상속하여 사용할 수 있습니다. 도메인의 데이터를 최종적으로 Redis에 반영할 때에는 `syncRedis()` 메소드를 호출하면 됩니다.

 

export interface GameFields {
  readonly roomId: string;
  turn: number;
  playerOrder: Player[];
  currentOrderPlayerIndex: number;
  playerStatus: Record<UserId, GameStatus>;
  cityWhoHave: Record<CityId, UserId>;
}

export class Game extends SyncableToRedis {
  private constructor(
    public readonly roomId: string,
    public turn: number,
    public playerOrder: Player[],
    public currentOrderPlayerIndex: number,
    private playerStatus: Record<UserId, GameStatus>,
    public cityWhoHave: Record<CityId, UserId>
  ) {
    super();
  }

  public static create(roomId: string, players: Player[]): Game {
    /* ... 초기 데이터 생성 ... */
    return new Game(roomId, 1, currentPlayer, 0, defaultStatus, {});
  }

  public removePlayer(userId: string): void {
    this.playerOrder = this.playerOrder.filter((player) => player.userId !== userId);
    delete this.playerStatus[userId];
  }
  
  /* ... */
  
  public static fromJSON(json: string): Game {
    /* JSON에서 Game 클래스로 변환 */
  }

  public async syncRedis(redis: RedisClientType): Promise<void> {
    await super.syncRedis(redis, 'game', this.roomId);
  }
}

📢 이벤트 전파로 게임 진행 상황 관리하기

백엔드에서는 게임 진행 상황에 맞춰 이벤트를 발행하고 이 이벤트를 구독하는 방식으로 비즈니스 로직을 처리하고 있습니다.

예를 들어 '플레이어 퇴장'이라는 행위가 발생한다면 이를 이벤트로 발행하고 이 이벤트가 필요한 도메인에서 로직을 처리합니다. 플레이어가 퇴장했으니 방 목록에서 해당 플레이어를 없애야 하고 게임 진행 중에 중퇴를 했다면 이에 대한 페널티를 적용해야 합니다.

 

이벤트 전파 예시

 

CQRS 패턴을 적용하여 Query와 Command 로직을 분리한 상태인데, Command 로직에서는 이벤트를 발행하는 역할만 하고 있습니다. 실제 로직들은 각 도메인에서 해당 이벤트를 받아 처리합니다.

그 외...

게임에는 배경 음악이 필요합니다. 근데 저는 작곡을 할 줄 모릅니다. 

하지만 요즘 AI가 발전한 덕분에 로비 그리고 인게임에서 사용할 배경 음악을 AI로 만들어 적용했습니다. 또한 방 생성 시에 나오는 기본 방 제목들도 AI의 도움을 받아 샘플을 뽑아내 적용하였습니다.

 

기본적인 게임(주사위 굴리기, 도시 구매하기, 벌금 내기, 중간퇴장 처리하기 등등...)은 모두 구현했지만 아직 추가해보고 싶은 기능들이 많이 남아있습니다. 이벤트를 추적해서 플레이어의 게임 상황을 로그로 남길 수도 있고, 게임 경험치 배율이나 초기 자금, 도시 구매 가격 등을 백오피스 페이지에서 관리할 수도 있습니다.

앞으로 테스트베드로 삼아 여러 기술이나 기능들을 적용해 볼 생각입니다.

마무리

마블은 오픈소스 프로젝트로 GitHub에서 상세한 코드를 확인할 수 있습니다!

https://github.com/SkyLightQP/marble

 

또한 해당 프로젝트를 직접 플레이해 볼 수도 있습니다. (의미가 있을지는 모르겠지만 1인 플레이도 가능합니다.)

https://marble.daegyeo.me

 

반응형