AppOrbit
← blog

게임의 4가지 상태를 Zustand로 관리하기 — 3편: 상태 관리와 아키텍처

Zustand, 상태관리, React, 아키텍처, 성능최적화, 리렌더링

1. 들어가며: 게임은 상태 관리가 전부다

프론트엔드에서 상태 관리 라이브러리를 뭘 쓸까 고민하는 건 흔한 일입니다. 하지만 게임 개발에서는 이 선택이 평소보다 훨씬 중요합니다. 일반적인 CRUD 앱과 달리, 게임은 상태 전환이 빈번하고, 각 상태에 따라 UI가 완전히 달라지며, 애니메이션이 상태 변경에 밀접하게 연결되기 때문입니다.

차트예측게임에는 4가지 게임 페이즈가 있습니다:

[게임 페이즈 흐름]

IDLE (대기)
  → 시작 화면, 게임 시작 버튼
  → startGame() 호출 시 PLAYING으로 전환

PLAYING (플레이 중)
  → 차트 90개 표시, 예측 버튼 활성화
  → makePrediction() 호출 시 REVEALING으로 전환

REVEALING (결과 공개)
  → 나머지 10개 캔들 + 트렌드라인 표시
  → 맞으면: nextRound() → PLAYING
  → 하트 0이면: GAMEOVER로 전환

GAMEOVER (게임 오버)
  → 최종 점수, 공유 버튼
  → resetGame() → IDLE

이 4개의 페이즈를 어떻게 깔끔하게 관리하고, 불필요한 리렌더링은 어떻게 막았는지 이야기하겠습니다.

2. 왜 Zustand인가

후보군 비교

상태 관리 라이브러리를 선택할 때 세 가지를 비교했습니다.

Redux Toolkit: 가장 안정적이지만, 이 프로젝트에는 과합니다. Slice, Action, Reducer, Selector 등의 보일러플레이트가 게임 하나 만드는 데 비해 너무 무겁습니다.

React Context + useReducer: 별도 라이브러리 없이 가능하지만, Context의 고질적인 문제 — 값이 바뀌면 구독하는 모든 컴포넌트가 리렌더링되는 문제를 해결하려면 Context를 잘게 쪼개야 합니다. 게임처럼 상태 변경이 빈번한 앱에서는 성능 이슈가 체감됩니다.

Zustand: 하나의 파일에 상태와 액션을 함께 정의할 수 있고, 선택적 구독이 기본적으로 가능합니다. 번들 사이즈도 약 1KB 수준으로 가볍습니다. 게다가 React 외부에서도 상태에 접근할 수 있어서, 나중에 유틸리티 함수에서 게임 상태를 읽어야 할 때도 유연하게 대응할 수 있습니다.

결국 Zustand를 선택한 핵심 이유는 **"단순함"**이었습니다. 학습 곡선이 거의 없고, 보일러플레이트가 최소이며, 성능 최적화 도구가 내장되어 있습니다.

3. 스토어 설계: 하나의 스토어, 선택적 구독

단일 스토어 전략

게임 상태를 여러 스토어로 나눌 수도 있었지만, 하나의 스토어로 통합했습니다. 이유는 게임의 상태들이 서로 강하게 결합되어 있기 때문입니다.

예를 들어, makePrediction 액션 하나가 실행되면:

  • phase가 revealing으로 바뀌고
  • prediction에 선택값이 저장되고
  • isCorrect가 계산되고
  • 맞혔으면 scorecombo가 업데이트되고
  • 틀렸으면 hearts가 감소하고
  • trendLineData가 계산되고
  • visibleCandles이 100으로 바뀝니다

이렇게 하나의 액션이 7개 이상의 상태를 동시에 변경하는 상황에서, 스토어를 분리하면 오히려 복잡도만 증가합니다.

선택적 구독 래퍼

Zustand에서 가장 중요한 최적화 포인트는 **"필요한 상태만 구독하기"**입니다. 기본적으로 Zustand 스토어를 구독하면 어떤 상태든 바뀔 때 리렌더링이 발생합니다. 이를 방지하기 위해 useShallow를 활용한 래퍼 훅을 만들었습니다.

[선택적 구독 패턴]

일반 구독 (비효율적):
  const state = useStore()
  → 스토어의 어떤 값이든 바뀌면 리렌더링

선택적 구독 (최적화):
  const { hearts, score } = useStore(['hearts', 'score'])
  → hearts나 score가 바뀔 때만 리렌더링
  → combo, phase 등 다른 값이 바뀌어도 무시

이 래퍼 훅은 키 배열을 받아서, 해당 키들의 값만 추출한 객체를 반환합니다. useShallow가 이전 값과 얕은 비교를 해서, 실제로 값이 바뀐 경우에만 리렌더링을 트리거합니다.

4. 컴포넌트 아키텍처: View/Logic 완전 분리

3-Tier 구조

이 프로젝트는 페이지 → 컨텐츠 → 섹션의 3계층 구조를 따릅니다.

[컴포넌트 계층]

GamePage (페이지)
  └── 레이아웃만 담당, 모달 토글 같은 최소 로직

GameArea (컨텐츠)
  └── 게임 로직 훅과 섹션을 연결하는 조율자

HeaderSection / ChartSection / ControlSection (섹션)
  └── 순수 View, 받은 props만으로 렌더링

여기서 핵심 원칙은 **"섹션은 절대 useState나 useEffect를 직접 사용하지 않는다"**는 것입니다. 모든 상태와 사이드 이펙트는 훅에서 처리하고, 섹션은 그 결과물을 받아서 그리기만 합니다.

왜 이렇게까지 분리하는가

처음에는 "게임 하나 만드는데 이 정도 분리가 필요한가?" 싶었습니다. 하지만 개발하다 보니 명확한 이점을 체감했습니다.

디버깅이 쉬워집니다. UI가 이상하게 보이면 섹션 컴포넌트를, 로직이 잘못되면 훅을 보면 됩니다. 두 가지가 섞여있으면 "이 버그가 렌더링 문제인지 상태 문제인지"부터 파악해야 해서 시간이 두 배로 듭니다.

애니메이션 작업이 편해집니다. framer-motion 애니메이션을 추가할 때, 순수 View 컴포넌트에 선언적으로 motion props만 달아주면 됩니다. 로직이 섞여있으면 애니메이션 타이밍과 상태 변경 타이밍이 충돌하는 문제가 생기기 쉽습니다.

5. 성능 최적화: 섹션별 독립 구독

문제 상황

게임에는 3개의 주요 섹션이 있습니다:

  • HeaderSection: 점수, 콤보, 하트, 라운드 표시
  • ChartSection: 캔들스틱 차트 렌더링
  • ControlSection: 예측 버튼, 결과 배너

만약 GameArea 컨텐츠에서 모든 상태를 가져와서 각 섹션에 props로 내려주면, 어떤 상태가 바뀌든 세 섹션 모두 리렌더링됩니다. 점수가 올라갈 때 차트가 다시 그려질 필요는 없고, 예측 버튼을 누를 때 헤더가 다시 그려질 필요도 없습니다.

[비효율적인 구조]

GameArea (모든 상태 구독)
  ├── HeaderSection (props) → GameArea 리렌더 시 같이 리렌더
  ├── ChartSection (props) → GameArea 리렌더 시 같이 리렌더
  └── ControlSection (props) → GameArea 리렌더 시 같이 리렌더

해결: 훅을 섹션별로 분리

각 섹션이 필요한 상태만 직접 스토어에서 구독하도록 훅을 분리했습니다.

[최적화된 구조]

GameArea
  └── 페이지 레벨 로직만 (초기화, 광고 프리로드)

HeaderSection
  └── useGameStore(['hearts', 'score', 'combo', 'round'])
      → 이 4개 값이 바뀔 때만 리렌더

ChartSection
  └── useGameStore(['currentScenario', 'visibleCandles', 'trendLineData'])
      → 시나리오나 캔들 수가 바뀔 때만 리렌더

ControlSection
  └── useGameStore(['phase', 'prediction', 'isCorrect', 'hearts', 'canRevive'])
      → 게임 페이즈나 결과가 바뀔 때만 리렌더

이렇게 하면:

  • 점수가 올라가도 차트는 리렌더되지 않습니다
  • 예측 버튼을 눌러도 헤더는 즉시 리렌더되지 않습니다 (결과 판정 후 점수/하트가 바뀔 때만)
  • 각 섹션이 자신에게 관련된 상태 변경에만 반응합니다

게임처럼 초당 여러 번 상태가 바뀔 수 있는 앱에서, 이 차이는 특히 저사양 모바일 기기에서 체감됩니다.

6. 페이즈 기반 조건부 렌더링

4-Phase UI 분기

GameArea 컨텐츠에서는 phase 값에 따라 완전히 다른 UI를 보여줍니다.

[페이즈별 UI]

phase === 'idle':
  → 그라디언트 타이틀 + 시작 버튼
  → 심플한 시작 화면

phase === 'playing':
  → 헤더(점수/하트) + 차트(90개 캔들) + 예측 버튼
  → 게임 플레이 중 화면

phase === 'revealing':
  → 헤더 + 차트(100개 캔들 + 트렌드라인) + 결과 배너
  → 결과 확인 화면
  → 맞혔으면: 다음 라운드 버튼
  → 하트 0이면: 부활 또는 결과 확인 버튼

phase === 'gameover':
  → 최종 차트 + 점수 통계 + 공유/재시작 버튼
  → 게임 오버 화면

여기서 중요한 점은, 각 페이즈 전환 시 이전 페이즈의 컴포넌트가 완전히 언마운트된다는 것입니다. React의 조건부 렌더링을 활용해 페이즈가 바뀌면 이전 UI를 제거하고 새 UI를 마운트합니다. 이 방식이 visibility를 토글하는 것보다 메모리 효율적이고, 각 페이즈의 진입 애니메이션(framer-motion의 initialanimate)을 자연스럽게 트리거할 수 있습니다.

7. 스토어 초기화 패턴

페이지 이탈 시 정리

게임 페이지를 벗어날 때는 스토어를 초기 상태로 되돌려야 합니다. 그렇지 않으면 다음에 페이지를 다시 방문했을 때 이전 게임 상태가 남아있게 됩니다.

[초기화 패턴]

useGameLogic 훅:
  → 컴포넌트 마운트 시: 광고 프리로드
  → 컴포넌트 언마운트 시: initStore() 호출 → 모든 상태를 초기값으로 리셋

initStore가 리셋하는 값들:
  phase → 'idle'
  score → 0
  combo → 0
  maxCombo → 0
  hearts → 2
  round → 0
  currentScenario → null
  prediction → null
  isCorrect → null
  canRevive → true
  ... (모든 상태를 초기값으로)

이 패턴은 Zustand를 사용하는 프로젝트에서 꽤 범용적으로 쓸 수 있습니다. initStoreData라는 초기값 객체를 별도로 선언해두고, initStore 액션에서 이 객체를 setState에 넘기면 됩니다. 페이지 전용 스토어에서 특히 유용한 패턴입니다.

8. 마치며

이번 편에서 다룬 내용을 정리하면:

  • 단일 스토어 전략: 강하게 결합된 게임 상태는 나누지 않고 하나로 관리
  • 선택적 구독: useShallow 래퍼로 필요한 상태만 구독해 리렌더링 최소화
  • View/Logic 분리: 섹션은 순수 View, 모든 로직은 훅으로 분리
  • 섹션별 독립 구독: 각 UI 영역이 자신에게 관련된 상태만 구독
  • 페이즈 기반 렌더링: 4개의 게임 상태에 따라 완전히 다른 UI를 조건부 렌더링
  • 스토어 초기화: 페이지 이탈 시 깔끔한 상태 리셋

Zustand는 "상태 관리 라이브러리를 쓰고 있다는 걸 잊을 정도로 자연스러운" 도구입니다. 복잡한 게임 상태도 직관적으로 다룰 수 있었고, 선택적 구독 덕분에 성능 이슈 없이 부드러운 게임 경험을 만들 수 있었습니다.

다음 편에서는 차트 렌더링과 애니메이션을 다룹니다. lightweight-charts로 캔들스틱 차트를 그리는 과정, framer-motion으로 게임 피드백 애니메이션을 구현한 과정, 그리고 모바일 기기에서 60fps를 유지하기 위해 겪은 최적화 여정을 이야기하겠습니다.

👉 차트예측게임 바로가기