My Boundary As Much As I Experienced

리액트로 지뢰찾기 구현 과정 1. 전체 파일 구조와 전역 상태 구성 본문

Projects/etc.

리액트로 지뢰찾기 구현 과정 1. 전체 파일 구조와 전역 상태 구성

Bumang 2024. 3. 30. 23:37

 

Easy, Intermediate, Expert 그리고 Custom 설정 등에 따라 타일맵 크기가 바뀐다.

 

과제 요구 사항에서 지켜야 할 부분들 중 아래 부분에 대한 구현 과정이다.

 

◆ 난이도 변경이 가능해야 합니다.

    ◆ Beginner (8X8) 지뢰 10개

    ◆ Intermediate (16X16) 지뢰 40개

    ◆ Expert (32X16) 지뢰 100개

    ◆ Custom (가로, 세로, 지뢰 수 조정 가능)

◆ 설정 가능한 가로, 세로는 최대 100 x 100이며, 지뢰수는 격자칸 수의 1/3 이하로 설정 가능합니다.

◆ 전역상태관리로는 redux-toolkit을 사용합니다.

 

일단 요구 조건 대로라면,

레벨에 따라 타일 맵 X, Y 크기와 지뢰의 갯수가 바뀌어야 하고,

커스텀에선 아예 사용자 입력값을 받아 타일맵 크기를 동적으로 변경할 수 있어야 했다.

이를 위해 전체 파일구조를 어떻게 짰는지부터 설명하겠다.

 

 

전체 파일구조

전체 파일 구조를 도식화하면 아래와 같다.

Layout은 게임창 전체 컨테이너를 flex 중앙 정렬을 해놓은 구조이다.

Option레벨 선택 메뉴, 음소거 등의 설정 기능들을 모아놓은 구조이다.

Game은 실제 게임 영역이며, Status(남은 지뢰 갯수, 리셋 이모티콘, 타이머)와 TileMap(타일맵)으로 구성되어 있다.

 

그리고 Option에서 Level을 설정하면, Game 영역에서 그 Level에 따른 타일맵 크기와 지뢰 갯수로 변화한다.

이를 위해 Layout 단에서 level state를 만들어서 아래로 뿌릴 수 있지만 Props drilling이 심히 우려되고,

Level이 바뀔 때마다 그냥 props를 내려주기만 하는 컴포넌트도 재렌더가 계속 일어날 것이다..

이런 상황을 막기 위하여, Level은 전역상태로 만들어주기로 했다.

 

또한 비슷하게도, stale(기본상태), playing, gameOver, success 등의 게임 상태PlayingStateOptionGame이 소통해야됐다.

이렇게 최상위 루트에 가까운 곳부터 쓰이는 정보는 의미적으로 전역상태라고 불리기에 손색이 없기도 하다.

 

레벨 전역 상태 설정

처음엔 전역 상태를 객체로 만들어서 헤비하게 가지고 있는게 안 좋을거라 생각했다.

그래서 LevelSlice를 "Beginner" | "Intermediate" | "Expert" | "Custom" 등 레벨 이름만 따서 리터럴 타입으로 설정하였다.

type LevelType = "Beginner" | "Intermediate" | "Expert" | "Custom";

// redux 초기값
const initialState: InitialState = {
  value: "Beginner"
};

 

그리고 각 레벨의 세부 정보를 담은 객체에 이 전역상태 이름을 key로 제공하여,

그에 맞는 X길이, Y길이, 지뢰 갯수를 활용하려고 했다.

// 레벨에 따른 세부 정보를 가진 객체, key값이 전역상태와 동일하다.
const levelStatus = {
  Beginner: {
    X: 10,
    Y: 10,
    mine: 10,
  },
  Intermediate: {
  ...
}

// 활용할 때 이런 식으로...
levelStatus[currentLevel];
levelStatus[currentLevel].X;
levelStatus[currentLevel].Y;
levelStatus[currentLevel].mine;

// 혹은 구조분해할당하여 사용한다.
const { X, Y, mine } = levelStatus[currentLevel];

 

그러나 이럴 경우 단점이 3개 있었다.

 

1.

레벨 이름 불러오는 전역상태각 레벨의 세부정보 객체를 언제나 같이 import해서 활용해야 한다.

그리고 사용 시에도 levels[currentLevel].MINE 같은 식으로 쓰게 됐는데 이름이 조금 긴 감이 없잖아 있었다..

 

2.

레벨 이름 불러오는 전역상태와 각 레벨의 세부정보 객체, 그리고 이 두 개의 타입까지

총 4개의 level뭐시기 파일이 발생했다. 비슷한 이름이 많아졌다.

(levels 객체, Level 타입, levelStatus 객체, LevelStatus 타입)

 

그러나 1, 2번의 경우보다 더 심각한 문제는 아래와 같다.

 

3.

Custom 레벨의 경우 X, Y, mine 값들이 동적으로 결정되어야 한다. 

그런데 이렇게 동적으로 바뀌어야하는 값들을 객체 리터럴 선언에 쓸 수 없다.

 

사실 솔직하게 말하자면 처음에 커스텀 값이 동적으로 결정되어야한다는걸 고려 못하고 레벨 전역상태를 설계했었다.

그리고나서 나중에 3번을 깨달은 뒤 '커스텀 로직은 따로 만들어야되나?'하고 고민하기도 했다.

 

그러나 고민 끝에 레벨 이름과 레벨 세부 정보를 각각 나눠서 관리하지 않고, 모두 통합해서 레벨 전역 상태로 활용하여 단순화했다.

그 결과, 전역 상태에 현재 레벨의 이름과 그에 따른 세부 정보들도 한 번에 볼 수 있게 됐고,

커스텀 값 설정 시에도 유저입력값이 반영될 수 있는 구조가 되었다.

 

결국 같이 써야되는 정보들은 모두 묶어 객체로 정리하는 것이 좋으며,

애초부터 모든 경우의 수를 통합할 수 있는 구조를 생각하는 것이 현명한 것 같다.

 

하여튼 그래서 추후 개선한 레벨 전역 상태는 아래와 같다.

// level전역 상태에 이름과 세부정보를 모두 가진 채로 전환되게 설정하였다.

export type LevelKeyType = "Beginner" | "Intermediate" | "Expert" | "Custom";

export interface LevelValueType {
  TITLE: LevelKeyType; // 이름과
  X: number; // X길이
  Y: number; // Y길이
  MINE: number; // 지뢰 갯수
};

 

그리고 레벨 전역 상태에 관련된 로직들(redux는 useSelector와 해당 slice, dispatch등 가져올게 많다..)과

레벨 상태를 바꾸는 핸들러들을 모두 묶어 useLevelSwitch라는 이름의 훅으로 정리하였다.

import { useDispatch, useSelector } from "react-redux";
import { change } from "@store/levelSlice";

import { type RootState } from "@store/index";
import { LevelValueType, type LevelKeyType } from "@/types/level";
import usePlayingSwitch from "./usePlayingSwitch";
import useModal from "./useModal";

const useLevelSwitch = () => {
  const { playingSwitchHandler } = usePlayingSwitch();
  const { modalChangeHandler } = useModal();
  const dispatchLevel = useDispatch();
  const currentLevel = useSelector((state: RootState) => {
    return state.levels.value;
  });

  // 레벨들의 이름
  const levelKeys: LevelKeyType[] = ["Beginner", "Intermediate", "Expert", "Custom"];

  // 
  const levelSwitchHandler = (level: LevelValueType) => {
    localStorage.setItem("level", JSON.stringify(level)); // 현재 레벨을 로컬스토리지에 저장해놓는다
    playingSwitchHandler("stale"); // 레벨을 바꿀 때마다 게임 초기화를 한다.
    if (level.TITLE === "Custom") { // 커스텀을 선택했으면
      modalChangeHandler("Custom");
    }

    dispatchLevel(change(level)); // 레벨 전환
  };

  // 현재 레벨, 레벨 전환 핸들러, 레벨 이름들 모음 등등..
  return { currentLevel, levelSwitchHandler, levelKeys };
};

export default useLevelSwitch;

 

 

플레이 상황 전역 상태 설정

플레이 상황 전역 상태는 아래와 같다. 위에서 얻은 교훈대로 'playing에 따른 부수적인 정보들도 같이 객체로 정리할까?'

라고 고민했는데, 플레이 상황에 따라 각각의 컴포넌트에서 조작해야될 것들이 너무 각기 달라서..

플레이 상황 전역 상태는 문자열 리터럴 타입으로만 해주기로 했다.

export type playingType = "stale" | "playing" | "gameOver" | "success";

 

 

그리고 플레이 전역 상태에 관한 코드들, 플레이 상태 바꾸는 핸들러 등을 모두 묶어

usePlayingSwitch라는 이름의 훅으로 정리하였다.

import { RootState } from "./../store/index";
import { useDispatch, useSelector } from "react-redux";
import { change } from "@/store/playingStateSlice";
import { playingType } from "@/types/playing";
import useSound from "@/hooks/useSound";

const usePlayingSwitch = () => {
  const dispatchPlayingState = useDispatch();
  const currentPlayingState = useSelector((state: RootState) => {
    return state.playing.value;
  });
  const { playSoundHandler } = useSound();

  const playingStates: playingType[] = ["stale", "playing", "gameOver", "success"];

  const playingSwitchHandler = (state: playingType) => {
    if (state === "success") {
      playSoundHandler("soundWin");
    }
    dispatchPlayingState(change(state));
  };

  // 현재 플레이 상태, 플레이 상태 바꾸기 핸들러, 모든 플레이 상태의 이름 배열 등등...
  return { currentPlayingState, playingSwitchHandler, playingStates };
};

export default usePlayingSwitch;

 

 

 

 

이 외에도 남은 열린 타일 갯수, 남은 마인 갯수, 모달 on/off 여부, 음소거 여부, 시간 등등 많은 전역상태들이 존재한다.

그러나 가장 많이 쓰이는 핵심적인 것은 레벨과 플레이 상태이니 이것만 설명하고 끝내도록 하겠다.