cyh-lab.com
← 블로그 목록으로

cyh-lab.com 개발기 · 2026-08-10

Next.js에서 서버 없이 게임 상태를 관리하는 방법

useState만으로는 상태가 흩어져서 관리가 어려워졌다. 게임별 useReducer로 모으고, 타이머는 useInterval 커스텀 훅으로 처리한 방식을 정리했다.

작성자: cyh-lab.com 운영자

charades.cyh-lab.com은 서버가 없다. 외부 API 호출도 없다. 게임 진행 중 상태 전부를 클라이언트에서만 관리한다. 처음에는 단순하게 생각했는데, 막상 타이머·팀 점수·현재 라운드·라이어 게임 역할 배정까지 맞물리다 보니 상태 관리를 어떻게 할지 고민이 좀 됐다. 정리한 방식을 기록해둔다.

useState만으로는 한계가 생긴다

게임 하나에 상태가 꽤 많다. 몸으로 말해요를 예로 들면 현재 제시어, 팀별 점수, 남은 시간, 현재 라운드, 게임 진행 단계(설정→게임중→결과) 정도가 있다.

처음엔 각 컴포넌트에 useState를 분산해서 썼는데, 컴포넌트 간에 상태를 전달하는 props가 길어지면서 관리가 어려워졌다. 특히 타이머가 끝났을 때 상위 컴포넌트까지 이벤트를 올려보내는 흐름이 복잡해졌다.

useReducer로 게임 상태를 한 곳에 모았다

게임별로 reducer를 만들어서 상태를 한 곳에서 관리하는 방식으로 바꿨다.

// 몸으로 말해요 상태 타입
type CharadesState = {
  phase: 'setup' | 'playing' | 'result'
  currentWord: string
  timeLeft: number
  scores: Record<string, number>  // 팀명: 점수
  currentTeam: string
  round: number
}

type CharadesAction =
  | { type: 'START_GAME'; word: string; team: string }
  | { type: 'TICK' }
  | { type: 'CORRECT'; team: string }
  | { type: 'SKIP' }
  | { type: 'TIME_UP' }
  | { type: 'RESET' }

function charadesReducer(state: CharadesState, action: CharadesAction): CharadesState {
  switch (action.type) {
    case 'START_GAME':
      return { ...state, phase: 'playing', currentWord: action.word, currentTeam: action.team }
    case 'TICK':
      return { ...state, timeLeft: state.timeLeft - 1 }
    case 'CORRECT':
      return {
        ...state,
        scores: { ...state.scores, [action.team]: (state.scores[action.team] ?? 0) + 1 },
      }
    case 'TIME_UP':
      return { ...state, phase: 'result' }
    case 'RESET':
      return initialState
    default:
      return state
  }
}

이렇게 하면 상태 변경 로직이 reducer 안에 모여서 추적하기 쉬워진다. 컴포넌트는 dispatch만 호출하면 된다.

타이머는 useEffect로 처리

타이머는 useInterval 커스텀 훅을 만들어서 썼다.

function useInterval(callback: () => void, delay: number | null) {
  const savedCallback = useRef(callback)

  useEffect(() => {
    savedCallback.current = callback
  }, [callback])

  useEffect(() => {
    if (delay === null) return
    const id = setInterval(() => savedCallback.current(), delay)
    return () => clearInterval(id)
  }, [delay])
}

게임 중일 때만 타이머를 돌리고, phase가 바뀌면 멈추는 방식으로 연결했다.

useInterval(
  () => dispatch({ type: 'TICK' }),
  state.phase === 'playing' && state.timeLeft > 0 ? 1000 : null
)

useEffect(() => {
  if (state.phase === 'playing' && state.timeLeft === 0) {
    dispatch({ type: 'TIME_UP' })
  }
}, [state.timeLeft, state.phase])

라이어 게임은 역할 배정이 핵심

라이어 게임은 상태보다 초기 설정이 중요했다. 인원 수와 라이어 수를 받아서 랜덤으로 역할을 배정하는 로직을 썼다.

function assignRoles(players: string[], liarCount: number) {
  const shuffled = [...players].sort(() => Math.random() - 0.5)
  return shuffled.map((player, index) => ({
    name: player,
    isLiar: index < liarCount,
  }))
}

역할 배정 결과는 화면에 한 명씩 확인하는 방식으로 구현했다. 옆 사람이 볼 수 없게 확인 버튼 누르고 본인이 봐야 하는 구조다.

서버 없이 충분하다

멀티플레이 실시간 게임이 아니라 한 화면을 같이 보면서 하는 파티게임이라 서버가 필요 없었다. 오히려 서버 없이 정적 배포로 끝나니까 인프라 관리 부담이 없다. Vercel 무료 플랜으로 운영 가능한 이유이기도 하다.