개발로그

카드 게임 제작 ( 6, 완 ) - 게임이 돌아가기는 한다

ddony8128 2026. 2. 16. 11:20

카드 게임 제작 기록 처음부터 보기 : https://helloworld.ai.kr/14

 

카드 게임 제작 ( 1 ) - 회고

게임 링크 : cardgame.perfect.ai.kr 개발 기간 : 2025.10.28 ~ 2025.11.27총 소요 시간 : 110시간Lovable에게 감탄 ( UI 작업 )https://blog.helloworld.ai.kr/15JWT 토큰은 json web token token이다 ( 로그인 구현 )https://blog.helloworl

blog.helloworld.ai.kr

 

게임 링크 : cardgame.perfect.ai.kr

 

Magician Duel

 

cardgame.perfect.ai.kr

 

이 시점의 소요 시간 : 110시간

WebSocket 연결부 구현
게임 상태 스키마 및 WebSocket 이벤트 명세 설계
게임 엔진 구조 설계 및 구현
웹페이지 배포 및 도메인 연결

게임이 돌아는 가도록 만들었다.


React 보충수업

React를 쓰다 보면 특정 값이나 함수가 불필요하게 재평가되거나, 렌더링 때마다 새로 생성되는 문제가 성능에 영향을 줄 수 있다. 이를 방지하고 최적화를 돕는 훅이 useMemo, useCallback, useRef 이다.

  • useMemo(함수, 의존성 배열)
    함수의 반환값을 메모이제이션한다. 의존성이 바뀌지 않으면 다시 평가되지 않는다.
  • useCallback(함수, 의존성 배열)
    함수 자체를 메모이제이션한다. 의존성이 바뀌지 않으면 동일한 객체로 유지된다.
  • ref = useRef(초기값)
    ref.current가 바뀌어도 컴포넌트가 다시 렌더링되지 않는다. 값을 직접 읽고 수정할 수 있다. DOM 접근에도 많이 쓴다.

웹소켓 객체는 렌더링될 때마다 새로 만들어지면 안 된다. 그래서 useMemo 혹은 useRef 안에 담아서 활용하게 된다.

useRef(new WebSocket())
혹은
useMemo(() => new WebSocket(), [])


웹소켓의 기본

서버와 클라이언트가 실시간으로 양방향 소통할 수 있는 프로토콜이다. 주소가 http가 아닌 ws 혹은 wss로 시작한다.
HTTP REST API는 갱신 정보가 있으면 클라이언트가 폴링을 해야 하지만, WS는 서버가 먼저 메시지를 보낼 수 있다.
메시지는 보통 Json 문자열이며, 이벤트 type과 payload로 나누어 전달한다.

예시)

message = {
  type: "game_ready",
  payload: { userId, roomId }
}

브라우저에는 웹소켓이 기본 내장되어 있다. addEventListener로 콜백을 등록하고 send로 메시지를 전송한다.

const socket = new WebSocket("wss://server.com/api/socket");

socket.addEventListener("open", () => {
  console.log("websocket connected");
});

socket.addEventListener("message", (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === "hello") {
    socket.send(JSON.stringify({ type: "hello_back" }));
  }
});

Node.js에서는 ws 라이브러리를 써야 한다. on으로 콜백을 등록하고 send로 메시지를 전송한다.

import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 3000 });

wss.on("connection", (client) => {
  client.on("message", (msg) => {
    const data = JSON.parse(msg);
  });
  client.send(JSON.stringify({ type: "hello" }));
});

React에서는 useEffect 안에서 웹소켓 연결을 하고 useEffect의 반환 값으로 웹소켓 종료를 써야 한다. 중복 연결 / 메모리 누수를 막기 위한 것이다.

useEffect(() => {
  const socket = new WebSocket("ws://localhost:3000");
  socket.onmessage = (event) => { /* ... */ };

  return () => {
    socket.close(); // 언마운트 시 반드시 연결 종료
  };
}, []);

Node 서버에서 웹소켓을 다룰 때에는 연결된 클라이언트들을 잘 관리하는 것이 핵심이다. 각 방에 여러 클라이언트 웹소켓을 집어넣고, 메시지를 어떤 클라이언트에게 보낼지 판단해주는 RoomManager 같은 클래스를 정의해서 쓰면 된다.


WebSocket 사용 시 StrictMode의 트롤링

React 개발 모드에서 StrictMode를 켜면 잠재적 부작용을 감지하기 위해 컴포넌트의 마운트/언마운트 사이클을 두 번 실행한다. 즉 컴포넌트의 마운트 - 언마운트 - 마운트가 발생하면서 useEffect도 두 번 발생한다.

웹소켓 연결 초기화를 useEffect에 넣어두니, 매우 짧은 시간에 웹소켓 연결-해제-재연결이 발생하고 이해할 수 없는 버그가 터졌다.

고민을 좀 해보다가, 그냥 웹소켓 테스트할 때는 StrictMode를 꺼버리기로 했다.


게임 플레이 구조의 3계층 구조

1. 게임 엔진 (서버)

의존성 없이 독립적으로 돌아가는 순수한 시뮬레이터다.

  • src/core/ 폴더 안에 독립적으로 구현
  • WebSocket 송신 함수, DB 기록 함수 등은 콜백으로 주입
  • GameState를 규칙에 맞게 변환시키는 상태 머신

엔진의 핵심 상태 값은 WAITING_FOR_USER_INPUT, WAITING_FOR_ENGINE_RESOLUTION, WAITING_FOR_USER_ACTION, GAME_OVER 이다.

GameState에는 각 유저의 덱, 버린 덱, 손패, 재앙 덱, 게임판 상태, 설치된 ritual 정보 등이 기록된다.

2. 웹소켓 처리 (클라이언트 ↔ 서버)

메시지의 이벤트 타입에 따라 라우팅한다. 크게 세 가지 역할.

(1) 클라이언트 행동을 엔진에 전달 (클라이언트 → 서버)
카드 사용, 이동, ritual 능력 사용, 턴 종료, 멀리건 결정
→ answer_muligan, user_action, player_input

(2) 엔진 상태 변화를 브로드캐스트 (서버 → 클라이언트)
드로우, 데미지, 버림, 설치, 파괴 등 결과
→ game_init, ask_muligan, state_patch

(3) 민감 정보 숨기기
서버는 전체 GameState를 가지고 있지만, 유저에게 전달하기 전 상대의 덱/손패 등은 가려야 한다.
즉 FoggedGameState로 변환된 후 클라이언트에게 제공된다.

참고로 웹소켓의 메시지 전송은 초당 백 회 ~ 천 회까지도 거뜬하다고 하니, REST API보다 더 많이 쪼개서 처리하는 것도 괜찮다.

WebSocket 프로토콜

  • 각 클라이언트는 UI 마운트 후 ready 전송
  • 양쪽 ready를 받으면 서버가 게임 초기화
    • game_init + 초기 FoggedGameState 전달
  • ask_muligan / answer_muligan으로 멀리건 진행
  • 상태 전환은 state_patch(서버→클라)
  • 행동은 user_action(클라→서버)
  • 효과 처리 중 선택이 필요하면
    • request_input으로 요청
    • player_input으로 응답

3. UI (클라이언트)

클라이언트의 행동을 웹소켓 메시지로 바꿔서 전송하고, 매번 갱신되는 FoggedGameState를 페이지에 띄운다. 웹소켓은 useRef로 관리한다.

UI 상세 사양

표시:

  • 내 손패 / 상대 손패 “개수”
  • 메인 덱 / 재앙 덱 남은 카드 수
  • 버린 덱
  • 설치된 ritual 정보
  • 각 마법사의 위치와 hp
  • 현재 마나와 최대 마나

전환 효과:

  • 카드 드로우, 카드 버림, 카드 폐기
  • 덱 리셋
  • 피해/치유
  • ritual 설치/파괴
  • 게임 로그

상호작용 효과:

  • 멀리건
  • 이동
  • 카드 내기
  • ritual 카드 사용하기

프론트에서는 내 턴인지, 마나가 충분한지만 판단한다.
그 외 불가능 행동은 서버가 판단 후 invalid_action 메시지를 보내고, 이를 텍스트 창으로 표시한다.


Zustand가 돌아왔다

실시간 GameState는 DB에 저장되지 않고 서버 메모리 위에서만 살아있는 구조다.
클라이언트도 실시간 게임 상태를 저장할 저장소가 필요하고, 그 역할을 다시 Zustand가 맡게 되었다.

즉 FoggedGameState는 클라이언트에서 Zustand 스토어로 관리된다.

서버가 state_patch 이벤트를 보내면 → Zustand 스토어 업데이트
→ 커스텀 훅으로 컴포넌트 리렌더링 → UI는 항상 최신 FoggedGameState 렌더링

카드 정보는 크게 두 종류가 있다.

  • 내 덱의 카드 정보
    → 게임 시작 전에 REST API로 미리 로딩 가능
  • 상대 덱의 카드 정보
    → 게임 시작 시에는 알 수 없음
    → 상대가 카드를 내거나, 버리거나, 폐기할 때 드러남
    → 서버는 state_patch 이벤트를 전송하면서 부분적으로 카드 정보를 전달

이 카드 정보들을 모두 종합해 Zustand에서 캐싱하도록 하였다.


게임 엔진 = 효과 스택 + 옵저버 + 규칙 수정 레이어

이 프로젝트 전체에서 최고의 깨달음으로 선정할 수 있는 부분이다.

게임 엔진을 어떤 식으로 구현할지가 난제였는데 GPT와 토론하면서 최고의 구조를 찾아버렸다.
바로 스택, 옵저버, 규칙 수정 레이어를 함께 활용하는 방식이다.

이거면 카드 뭉치들이 어떤 연쇄 작용을 일으켜도 모두 커버되겠다 싶었다.

1. 옵저버

게임 내에 활성화되어 있는 카드들이 소유한, 특정 트리거가 발생했을 때 자동으로 발동되어야 하는 효과들은 옵저버 패턴으로 각 트리거에 구독시켜둔다.

  • onTurnStart → 턴 시작 시 구독 효과들이 스택으로 push
  • onDraw → 카드 뽑을 때 구독 효과들이 스택으로 push

2. 효과 스택

처리되어야 하는 효과들은 모두 스택으로 들어가 LIFO 방식으로 처리된다.
카드 효과뿐 아니라 페이즈 진행, 플레이어 행동에 따른 효과도 마찬가지다.

각 단위 효과가 처리될 때마다 checkGameOver가 호출된다. 승부가 결정되었다면 남은 스택은 모두 무시하고 즉시 게임이 종료된다.

드로우 페이즈 진행 예시

  • (카드 뽑기, 턴 시작) 효과를 스택에 push
  • pop → 카드 뽑기
    • onDraw 옵저버 push
    • 뽑힌 카드에 onDrawn 효과가 있다면 스택에 push
  • pop 계속
  • pop → 턴 시작
    • onTurnStart 옵저버 push
  • pop 계속

전광석화 처리 예시 (이동/공격 3회 반복)

  • (이동, 공격, 이동, 공격, 이동, 공격) push
  • pop → 이동
    • request_input으로 방향 선택
    • onMove 옵저버 push
    • 이동 칸에 상대 ritual 있으면 파괴
    • onDestroy 효과 push
  • pop → 공격
    • request_input으로 방향 선택
    • onDamage 옵저버 push
    • 대상의 onDamaged 효과 push
  • 위 과정을 3번 반복

3. 규칙 수정 레이어

옵저버와 스택으로 커버가 안 되는 효과들이 있다.

  • “이 ritual이 5개 이상이면 승리”
  • “내 즉발 카드 비용 -1”
  • “손패 제한을 10으로 증가”

게임 규칙 자체를 변경하는 지속효과들이다.

별도의 RuleModifier 레이어에서 관리하며, 비용 계산 / 게임 오버 체크 / 손패 최대치 등을 계산할 때 반영한다.

규칙 수정 레이어가 필요한 카드들은 나중에 만들 것 같다.
현재 단계에서는 복잡도가 지나치게 높다.

추가. Resolve Stack 도입

효과 스택 + 옵저버 구조로 게임 엔진을 만들다가 난제를 만났다.

카드 효과들이 효과 스택에 올라가고 하나씩 pop 되어 처리되는데
그동안 카드 본체는 어디에 있어야 할까?

현재 처리하고 있는 카드들을 보관하는 자료구조가 필요하다는 것을 깨달았다.

예를 들어,

카드를 뽑은 후 1장을 골라 버리고, 스스로 폐기되는 효과를 가진 카드가 있다고 하자.
이 카드를 ‘사용’하면 효과 스택에

  • 그 카드를 폐기하기
  • 버리기
  • 카드 뽑기

효과가 각각 스택에 들어간다.

스택에서 하나씩 빼내어 처리하는 동안 사용되는 카드는 손패에 있어도, 버린 덱에 있어도, 덱 안에 있어도 논리적으로 방해가 된다.
하지만 ‘그 카드를 폐기하기’ 효과를 처리하기 위해 어딘가에는 있어야 한다.

그래서 Resolve Stack THROW_RESOLVE_STACK 효과를 도입했다.

  • Resolve Stack: 지금 처리 중인 카드를 잠시 보관
  • THROW_RESOLVE_STACK: Resolve Stack에서 카드 하나 pop해서 제거
    • 그 카드가 버린 덱으로 가든, 보드 위로 가든, 폐기되든 처리

이를 도입하고 나니

  • “다른 카드를 발동시킨다”
  • “마법진을 설치한다”

같은 종첩 카드 처리 효과도 자연스럽게 해결됐다.


완성!

여기까지 기나긴 여정을 통해 웹사이트 상에서 게임이 돌아가도록 했다.
부족한 부분들이 너무 많이 눈에 띄었다.

 

 

개선의 여지

게임 제목과 일러스트 추가
튜토리얼, 게임 애니메이션 등 UI/UX 편의 요소 추가
카드 수 추가 및 밸런스 조절
엔진 시스템 전반적인 개선
게임 로깅 시스템 추가

그렇지만 여기서 한 번 끊고 가줘야 또 힘내서 다른 프로젝트를 하든, 이 프로젝트를 개선하든 할 것 같았다.
카드 게임 개발 여정은 여기서 마무리된다.

덧붙여

하스스톤을 보면 크리티컬한 버그가 발생할 때마다 ‘대학 코딩 수준’이라고 까이는데, 그래도 참 잘 만들어진 게임이다.

요즘도 유튜브에서 하스스톤 영상을 가끔 보는데
“이 효과는 어떤 순서대로 스택에 들어가서 처리될지”
머릿속으로 시뮬레이션 돌려보는 직업병(?)이 생겼다.

카드게임 프로젝트가 개발자 커리어로서 가지는 의미

중규모 정도의 웹 개발을 한다면 카드게임 프로젝트 구조를 그대로 활용할 것 같다.

프론트엔드는

  • 서버 상태를 받아와서 관리하는 React Query
  • 자체 상태를 관리하는 Zustand

그 두 가지 상태에만 의존하는 UI로 쪼갠다.

서버에서는

  • 프론트와 소통하는 부분
  • 어느 것에도 의존하지 않는 순수 로직 부분

으로 나눈다.

내가 만든 것은 카드게임이지만 아키텍쳐와 개발 철학은 일반적인 웹 프로젝트에 그대로 적용할 수 있다.