넘블 미션 회고 : 상태 관리 라이브러리 없이 상태를 관리하시오.

쏘쏘임·2022년 2월 11일
5
post-thumbnail

React의 상태 관리에 대해 고민하기 위해 Numble에서 주최한 '상태관리 라이브러리를 사용하지 않고 다른 색깔 찾기 게임 제작' 프로젝트에 참여했습니다. 마주한 고민과 어려움, 학습한 내용을 공유합니다.
👉 결과물 페이지 바로가기

❓ 무엇을 어떻게 왜 클론하는가

  • 기간: 22-02-04 ~ 22-02-13
  • 주제: 상태관리 라이브러리를 사용하지 않고 다른 색깔 찾기 게임 제작
  • 설명: 넘블의 예제 페이지를 클론 한다.
  • 요구 사항
  1. React Framework를 사용할 것
  2. Function Component를 활용할 것
  3. Javascript보다는 Typescript를 활용할 것
  4. 서버에 배포할 것 (Vercel과 같은 서비스를 이용해보세요)
  5. Context, Redux, Mobx, Recoil 등 상태관리 도구를 사용하지 않을 것
  • 넘블의 예제 사이트 화면 (feat.상태관리에 집중하라는 넘블의 메세지)

🌲 상태 관리 어떻게 했나

🌱 가상의 프로젝트 설정

상태 관리에 대해 고민할수록 답이 없다고 느껴졌습니다. 각각의 패턴마다 장단점이 있었기 때문입니다. 결국 가상의 프로젝트를 생각한 후, 이 프로젝트가 진행될 방향을 가정해 보며 방향성을 잡아보았습니다.

  1. 게임은 제한된 시간 내에 정답을 맞히면 다음 stage로 넘어간다. 이때 다른 색깔 찾기뿐만이 아닌 스피드 퀴즈 게임 등 다른 게임이 올 수도 있다.
  2. 격자 보드는 정답을 선택하게 하는 게임이다. 색깔 찾기가 아닌 같은 그림 찾기 게임이 올 수도 있고 색이 아니라 다른 그림(이미지) 찾기가 올 수도 있다.
  3. 다양한 페이지에서 게임을 삽입하는 경우가 많아 재사용하기 쉬워야 한다.

🌱 상태 관리 전략

  1. 커스텀 훅을 이용한 제어의 역전
  2. 상태 관리를 커스텀할 수 있도록 useReducer를 매개변수로 받기
  3. 상태와 관련된 요소의 가까운 곳에서 상태 관리(game과 board 역할별로 상태 분리)

다양한 패턴을 참고한 끝에 State Reducer 패턴 형식을 취하면서 컨테이너 컴퍼넌트(상태가 있는 컴퍼넌트)와 프레젠테이셔널 컴퍼넌트(상태가 없이 props를 받아 그리는 컴퍼넌트)를 구분하는 구조로 설계하였습니다.

🌱 상태 구조

🌱 코드 소개

App 컴퍼넌트 : 커스텀 훅이 뱉어주는 요소들을 필요한 자식 요소들에게 전달

useGame 커스텀 훅에서 게임을 진행하는데 필요한 상태와 이벤트 핸들러를 받아 Header, Board등 필요한 값들을 컴퍼넌트에 전달해주면 끝!

  const {
    gameState: { leftTime, isGaming, stage, score },
    handleClickAnswer,
  } = useGame();

  return (
    <>
      <Header stage={stage} leftTime={leftTime} score={score} />
      <Board
        stage={stage}
        isGaming={isGaming}
        handleClickAnswer={handleClickAnswer}
      />
    </>
  );

이렇게 커스텀 훅과 같은 곳에 복잡한 부수효과 처리를 다 가둬둔 것은 under the hood라고 말하는 것을 많이 보았습니다. 저 안의 로직이 뭔지 알 필요 없이 컴퍼넌트를 사용하는 사람은 불러와 쓰기만 하면 되어 커스텀 자유도를 잃지만 간단하게 사용할 수 있는 제어의 역전이 일어납니다.

+handleClickAnswer는 클릭한 요소가 정답인지 아닌지에 따라 gameState의 상태를 업데이트 시켜주는 핸들러입니다. 따라서 클릭으로 답을 고르는 유형의 게임이라면 Board가 아닌 다른 컴퍼넌트를 사용할 수 있습니다. 예를 들어 제한 시간 안에 정답을 클릭하는 스피드 퀴즈 게임이 올 수도 있습니다.

useGame 커스텀 훅 : reducer를 이용한 상태관리 및 부수효과 처리

import { gameReducer, initialGameState } from 'containers/Game/gameReducer';

// Game 컨테이너 컴퍼넌트에서 기본적으로 사용할 gameReducer를 기본값으로 넣어준다.
export function useGame({ reducer = gameReducer } = {}) {
  // useReducer Hook을 이용해 상태 관리한다. 불러온 initialGameState를 통해 기본 상태 값을 초기화해준다.
  const [gameState, dispatch] = useReducer(reducer, initialGameState);

  // ... 디스패치할 함수들을 생성해준다. 매개변수로 값을 받아 payload에 넣어줄 수 있다.
  const initializeGame = () => dispatch({ type: 'INITIALIZE_GAME' });
  
  // 부수효과들을 훅을 이용해 제어한다.
  useLayoutEffect(() => {
  }, [gameState.leftTime]);

  useEffect(() => {
  }, [gameState.isGaming]);

  // 반환할 핸들러를 작성해준다.
  const handleClickAnswer = (e) => {};

  return {
    gameState,
    handleClickAnswer,
  };
}

컨테이너 컴퍼넌트 폴더 내부에 작성해 놓은 리듀서와 초기값을 기본값으로 지정해줍니다. 각종 부수효과들을 처리한 후 state와 handler 같이 랜더링에 필요한 요소들을 반환합니다.

gameReducer를 기본값으로 설정했지만, 상태를 다르게 처리하고 싶으면 커스텀한 reducer를 만들어 인자에 넣을 수 있습니다. useGame(newGameReducer)

+각각의 reducer와 액션 타입등은 타입을 지정하였습니다.

🧾 프로젝트 소개

🖍 디렉토리 구조

  1. 관심사를 분리하기 위해 containers 폴더에 상태를 관리하는 컨테이너 컴퍼넌트를, components 프레젠테이셔널 컴퍼넌트를 넣어 구분
  2. 각각의 컴퍼넌트 이름을 파스칼 형식으로 표기한 폴더가 있고 내부에 css, tsx, types, (reducer, reducer types : 기본값으로 사용될 리듀서들) 파일을 묶어 컴퍼넌트 단위로 관심사 분리
  3. hooks 폴더 내부에 각각의 컨테이너 컴퍼넌트가 호출할 커스텀 훅 모아넣기
  4. styles, utils, constants 등의 폴더에서 프로젝트 전반에서 필요한 글로벌 스타일, 유틸 함수, 상수 등 넣기

🖍 컴퍼넌트 소개

  • Game은 특별한 요소는 없지만(필요시 추가 가능) 게임의 상태를 관리하는 가장 상단의 부모 요소(입니다.
  • Board는 그리드 형식으로 자식 요소들을 관리하는 게임판입니다. stage, isGaming props를 받아 이와 관련한 내부적인 상태를 관리합니다.
  • Header은 게임의 상태가 표시되는 프레젠테이셔널 컨퍼넌트 입니다.
  • Piece는 Board의 자식 요소들로, 현재 게임에서는 배경 색상만을 가집니다.

🖍 기타 컨벤션

  1. named-export로 내보내기 방식 통일
  2. 각각의 디렉토리는 index.ts 파일에서 재내보내기하여 임포트할 때는 디렉토리 경로까지 작성
  3. 파일들은 각각의 역할에 따라 통일된 네이밍 컨벤션

🐞 주요 이슈 및 해결 방법

💡 파이어폭스 크로스 브라우징 이슈

고정된 Board 사이즈 안에서 Piece 요소들을 가로 세로 균등한 비율로 삽입해야 한다.


클론 예제 사이트의 스타일을 그대로 적용하고자 부모 요소인 Board에 flex, flex-flow: row wrap 속성을 주고 하위 Piece 요소들을 랜더링했습니다. 이를 위해 width, height의 px 크기를 props로 전달하여 주었는데 크로스 브라우징 이슈(margin 해석에 대한 차이로 인해 파이어폭스에서 한 줄에 들어갈 Piece 요소가 넘침)가 있었습니다.

grid를 이용해 모던 브라우저 내에서 문제 없이 작동하도록 개선

Board에 display: grid;를 적용했습니다. grid-auto-rows: 1fr; 로 한 row에 모든 요소들이 같은 비율을 가질 수 있게 하였고, gap을 설정해 주었습니다. 또한 동적으로 grid-template-columns 속성을 이용해 한 줄에 몇개의 요소들이 있을지 설정하였습니다.

  • 👍 장점

    • 모던 브라우저 내 크로스 브라우징 이슈 없음
    • 계산식 단순화
    • props drilling 간소화
  • 👎 단점

    • 그리드 스타일을 지원하지 않는 ie 브라우저 포기

💡 이벤트 위임으로 정답 산출 이벤트 핸들러 처리하기

Board에서 받아올 이벤트 핸들러를 활용하기 위해, 또 더 효율적으로 이벤트 처리를 하기 위해 이벤트 위임을 하고자 하였습니다.

  1. dataset으로 인덱스 등록
  • 👍 장점
    • html 문서 파일에 표시되는 값이 인덱스이기에 정답이 직접적으로 노출되진 않음
  • 👎 단점
    • html에 dataset을 추가
  1. target vs currentTarget.children 비교
  • 👍 장점
    • html 문서 파일에 노출 안됨
    • 새로운 상태 혹은 dataset을 추가하지 않아도 됨
    • 이벤트 위임으로 효율적인 이벤트 처리
  • 👎 단점
    • 요소들의 순서가 뒤바뀌는 게임일 경우 사용할 수 없다.

현재 순서가 뒤바뀌는 게임을 기획하지 않는 상태이기 때문에 2번째 방법을 선택하였습니다.

💡 useMemo는 써야할까?

useMemo가 무거운 알고리즘이라 자칫 남용하면 성능 최적화에 더 안 좋은 영향을 끼칠 수 있다는 아티클을 봐왔습니다. Board 컴퍼넌트는 하위 요소가 많아질 수 있기 때문에 useMemo를 써도 좋을 것 같았습니다. 다만, Board 컴퍼넌트 자체를 메모하는 것이 옳은가 고민되었습니다. 임포트 하여 배치하는 상황에 맞춰 사용할 수 있도록 App 에서 Board 컴퍼넌트를 만들 때 메모해 주었습니다.

😊 마무리

무엇보다 상태 관리에 대한 다양한 패턴과 각각의 패턴이 가질 수 있는 장단점을 고민해 볼 수 있어서 좋았습니다. 이 외에도 target 과 currentTarget의 차이, 새로운 크로스 브라우징 이슈, 타입 설정 등 생각지도 못한 다양한 문제를 만났고 배울 수 있었습니다.

어디까지가 오버엔지니어링인지, 내가 근거를 가지고 코드를 작성하고 있는지 알기 힘들었던 점이 가장 힘들었습니다. 그렇지만 함께 넘블 미션에 참여한 동료들과 같이 코드 리뷰를 하며 의견을 주고받을 수 있어 그 자체로 매우 즐겁고 유익한 프로젝트였습니다. (선택받은 10% 피드백도 매우 원하는 중)

다음 미션엔 더 많은 동료들과 참여하고 싶습니다. 배워야 할 것이 너무 많아 어떤 주제여도 상관 없으니 얼른 풀어주세요! 감사합니다~~

참고 링크

-react 공홈 useReducer

profile
무럭무럭 자라는 주니어 프론트엔드 개발자입니다.

0개의 댓글