React의 상태 관리에 대해 고민하기 위해 Numble에서 주최한 '상태관리 라이브러리를 사용하지 않고 다른 색깔 찾기 게임 제작' 프로젝트에 참여했습니다. 마주한 고민과 어려움, 학습한 내용을 공유합니다.
👉 결과물 페이지 바로가기
상태 관리에 대해 고민할수록 답이 없다고 느껴졌습니다. 각각의 패턴마다 장단점이 있었기 때문입니다. 결국 가상의 프로젝트를 생각한 후, 이 프로젝트가 진행될 방향을 가정해 보며 방향성을 잡아보았습니다.
다양한 패턴을 참고한 끝에 State Reducer 패턴 형식을 취하면서 컨테이너 컴퍼넌트(상태가 있는 컴퍼넌트)와 프레젠테이셔널 컴퍼넌트(상태가 없이 props를 받아 그리는 컴퍼넌트)를 구분하는 구조로 설계하였습니다.
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가 아닌 다른 컴퍼넌트를 사용할 수 있습니다. 예를 들어 제한 시간 안에 정답을 클릭하는 스피드 퀴즈 게임이 올 수도 있습니다.
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와 액션 타입등은 타입을 지정하였습니다.
containers
폴더에 상태를 관리하는 컨테이너 컴퍼넌트를, components
프레젠테이셔널 컴퍼넌트를 넣어 구분컴퍼넌트 단위
로 관심사 분리hooks
폴더 내부에 각각의 컨테이너 컴퍼넌트가 호출할 커스텀 훅 모아넣기고정된 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 속성을 이용해 한 줄에 몇개의 요소들이 있을지 설정하였습니다.
👍 장점
👎 단점
Board에서 받아올 이벤트 핸들러를 활용하기 위해, 또 더 효율적으로 이벤트 처리를 하기 위해 이벤트 위임을 하고자 하였습니다.
현재 순서가 뒤바뀌는 게임을 기획하지 않는 상태이기 때문에 2번째 방법을 선택하였습니다.
useMemo가 무거운 알고리즘이라 자칫 남용하면 성능 최적화에 더 안 좋은 영향을 끼칠 수 있다는 아티클을 봐왔습니다. Board 컴퍼넌트는 하위 요소가 많아질 수 있기 때문에 useMemo를 써도 좋을 것 같았습니다. 다만, Board 컴퍼넌트 자체를 메모하는 것이 옳은가 고민되었습니다. 임포트 하여 배치하는 상황에 맞춰 사용할 수 있도록 App 에서 Board 컴퍼넌트를 만들 때 메모해 주었습니다.
무엇보다 상태 관리에 대한 다양한 패턴과 각각의 패턴이 가질 수 있는 장단점을 고민해 볼 수 있어서 좋았습니다. 이 외에도 target 과 currentTarget의 차이, 새로운 크로스 브라우징 이슈, 타입 설정 등 생각지도 못한 다양한 문제를 만났고 배울 수 있었습니다.
어디까지가 오버엔지니어링인지, 내가 근거를 가지고 코드를 작성하고 있는지 알기 힘들었던 점이 가장 힘들었습니다. 그렇지만 함께 넘블 미션에 참여한 동료들과 같이 코드 리뷰를 하며 의견을 주고받을 수 있어 그 자체로 매우 즐겁고 유익한 프로젝트였습니다. (선택받은 10% 피드백도 매우 원하는 중)
다음 미션엔 더 많은 동료들과 참여하고 싶습니다. 배워야 할 것이 너무 많아 어떤 주제여도 상관 없으니 얼른 풀어주세요! 감사합니다~~