히치키치

리액트에서 하도 state 거려서 봤더니 정말 state state state 본문

Cognisle

리액트에서 하도 state 거려서 봤더니 정말 state state state

히치키치 2024. 7. 12. 00:42

이 글은 cognisle version 2 카드 뒤집기 게임 개발 중 리액트 state 지옥을 개선한 여정이 담긴 글이다.

 

시연 모습: 기억력 테스트 (4x4 카드 뒤집기 게임)

 

시작로딩 짝 맞추기 성공 짝 맞추기 실패
시작로딩 짝맞추기성공 짝맞추기실패
게임 종료 후 결과 전환 게임 종료 안내 게임 결과 발표
시작로딩 짝맞추기성공 짝맞추기실패

 

version 1의 시연 모습으로 사실 30명 사용자가 게임 플레이 하면서 이거 왜이래요..? 문의가 들어왔거나 생각지도 못한 side-effect가 발생한 적은 없다. 그러나? 발생한 케이스가 없었던 거지.. 발생할 가능성이 없는 것은 아니잖냐.... 개발하면서 조금만 useState 설계나 useEffect 의존성을 바꾸면 전혀 생각지도 않은 작동이 발생해서 당황해서 다시 원래대로 돌려놓기!식의 돌려막기 하기도 했었다.

 

기존 로직의 커스텀 훅: 기억력 테스트 (4x4 카드 뒤집기 게임)

 

게임과 카드 보드 관련된 커스텀 훅에서만 state만 9개이고 + 해당 state 의존성이 있는 함수랑 useEffect만 5개이다... 사실 적정한 state 갯수는 정해진게 없다만.. 문제는 ""개발하면서 조금만 useState 설계나 useEffect 의존성을 바꾸면 전혀 생각지도 않은 작동이 발생하면 당황해서 다시 원래대로 돌려놓기""에서 이미 개선의 필요성은 말을 다했다고 생각한다.

 

플로우 차트 그리기: 기억력 테스트 (4x4 카드 뒤집기 게임)

 

사실 너무나 다들 아는 자명한 카드 뒤집기 게임이라 처음 개발부터 플로우를 그려서 작업하지 않았다.. 그러나 처음 시작할 때보다 고려야할 유저의 게임 플레이 상태와 카드 뒤집기/매칭/실패 등 진행 단계 복잡도와 그에 맞춰 모달이나 화면 UI 등 제어할게 점점 많아지면서 확실히 플로우를 다잡고 그에 맞춰 다시 state와 그에 대한 이펙트를 설계해서 개발해야겠다고 생각했다.

게임 플레이의 단계 쪼개기 - 안내 모달 관련

 

카드 뒤집기 게임에서 게임 진행 상태에 대해서 분기점과 병합점, 모달 안내 제공되는 부분을 고려해 5가지 단계로 나눴다.

1. START : 게임 시작
2. CHOOSING : 유저가 카드 고름
3. MATCHED : 유저가 고른 카드가 서로 짝이 맞음
4. CLEAR : 유저가 주어진 카드의 모든 짝을 맞춤
5. RESULT : 유저의 게임 플레이 결과 안내

 

게임 플레이의 단계 쪼개기 - 카드 Flip 관련

 

 

 

카드 이미지는 위와 같다. 앞면에는 카드 아이템 이미지가 뒷면에는 해당 아이템을 숨기고 모든 카드에 동일한 이미지가 담겼다. 클릭과 매칭 상태에 따라 앞면이 보여하는지 뒷면이 보여야하는지 결정된다. 이렇게 앞면 → 뒷면, 뒷면 → 앞면, 이미 매칭된 경우 앞면 고정으로 카드 한 개 각각에 대해 전환 및 고정 처리가 필요하다. 이 조작이 플로우에서 어디에 존재하는지 표시했다.

유저의 카드 클릭에 대해 해당 카드에 필요한 전환 및 고정 처리를 3가지로 구분했다.

빨간 상자 - 앞면 → 뒷면 | FaceDown
보라색 상자 - 뒷면 → 앞면 | FaceUp
초록색 상자 - 앞면 고정 | Matched

 

게임 플레이 로직 - useState와 useEffect 재설계

각종 모달과 플레이 정보에 필요한 값과 state를 나열했다.

/**
 *
 * @전체카드리스트: cards
 * @게임진행상태: status
 * @소요시간: time
 * @현재획득한아이템갯수: obtain
 * @가장최근에매칭된카드번호: currentMatched
 * @클릭횟수: clicked
 * @게임통해얻는아이템목록: items
 *
 *
 */

 

위를 기반으로 커스텀 훅을 작성했다.

 

2가지로 필요한 함수 최소화

1. updateStatus: 각 카드 앞뒷면 제어
2. handleClick: 선택한 카드 Matching 여부와 사용자 클릭에 따른 게임 단계 파악

 

2가지로 useEffect 관련 로직 구성

1. 처음 게임 시작 시 필요한 cards 초기화
게임이 시작될 때 한 번만 실행되며, 게임 카드를 초기화하고 셔플하는 역할
의존성 배열 : 빈배열 - 컴포넌트가 마운트될 때 한 번만 실행

2. 사용자가 현재 해당하는 게임 플로우 단계 내 변동
게임 플로우 단계에서 상태 변화가 일어날 때마다 실행
얻은 카드의 수(obtain)와 게임 상태(status)에 따라 다양한 UI 업데이트와 상태 변경 처리
의존성 배열 : 얻은 카드 갯수, 카드 매칭에 따른 게임 단계 - 이에 변동이 생길 때마다 실행됨

 

이를 통해 가독성을 향상시키고, 복잡성을 낮춰 버그 발생 가능성을 낮추고 예측 가능성을 높여 안정성을 확보하고자 했다.

 

첫번째 개선 결과 - 이점

2가지 이점

1. 코드 분리와 유지보수성
각 useEffect가 서로 다른 책임을 가지므로 코드의 분리가 명확해짐.

2. 의존성 관리
각 useEffect는 필요한 상태나 프로퍼티에 의존성을 명시적으로 관리함.
이로 인해 불필요한 실행을 줄이고, 코드의 효율성을 높일 수 있음.

 

 

한계점 : 여전히 복잡한 state와 Side Effect 관리

아래는 카드의 수(obtain)와 게임 상태(status)에 따라 실행되는 useEffect 내부 코드이다.

1. 가독성 문제
상태(status, obtain)가 변경될 때마다 여러 조건문을 사용하여 다양한 상태 처리해 코드 흐름을 이해하기 어려움
2. 여전한 상태 기반 제어 분리 필요성
여전히 상태 전이(matched → choosing, choosing → clear, clear → result)가 복잡함.
이로 인해 각 상태(matched, choosing, clear, result)에 따른 처리를 별도의 함수로 분리해 관리할 필요성을 느낌
3. 비동기 처리의 복잡성
setTimeout을 사용해 비동기적으로 상태를 변경하고 있음.
일부에서 중첩으로 setTimeout을 사용해 처리해 이해하기 어려움.

 

두번째 개선을 위한 공부

 

State란 무엇인가

1. 시간이 지남에 따라 변함
2. 부모로부터 props를 통해 전달됨
3. 컴포넌트 안의 다른 state나 props를 가지고 계산 가능함

React로 사고하기

 

State 구조화하기

원칙: 중복배제원칙(Don’t Repeat Yourself)
목표: 오류 없이 쉽게 상태 업데이트하기. State에서 불필요하고 중복된 데이터를 제거하면 모든 데이터 조각이 동기화 상태 유지에 유리함
1. 연관된 state 그룹화두 개 이상의 state 변수를 항상 동시에 업데이트한다면, 단일 state 변수로 병합 고려
2. 불필요한 state 피하기렌더링 중에 컴포넌트의 props나 기존 state 변수에서 계산 가능하다면, 컴포넌트의 state에 해당 정보를 넣지 않아야함
3. 깊게 중첩된 state 피하기깊게 계층화된 state는 업데이트하기 쉽지 않음. 가능하면 평탄한 state 구성하기

 

State 구조 선택하기

 

most critical benefit of useReducer — the ability to supply a function that controls state transitions. Going back to using useReducer, the only difference is you get an additional argument that is a function that can help us ensure that each state transition is safe and valid

 

useState Hell 치료하는 법

 

A Cure for React useState Hell?

useReducer is an often forgotten, but extremely powerful, hook in React. It can replace many cases you might be using useState, with a number of advantages.

www.builder.io

 

관리해야 할 상태값이 많아지는 경우, useReducer를 사용하면 컴포넌트 내부에서 복잡한 상태 관리 로직을 분리하여 깔끔하게 관리할 수 있습니다.

 

회사 프로젝트 후기 - (5) useReducer로 상태관리 최적화

 

회사 프로젝트 후기 - (5) useReducer로 상태관리 최적화

5월부터 참여하고 있는 프로젝트는 S 보험사에 납품되는 C/S Application의 커스터마이징 작업을 수행하는 프로젝트입니다. 그러나 어제 대상포진 발생으로 인해 긴급 휴가를 내야 했습니다. 오늘은

holystory-dev.com

 

두번째 개선 - useReducer 사용

다음은 useState 대신 useReducer를 사용해 개선하고자 판단하게 된 근거이다.

 

1. 복잡한 상태 관리
상태가 여러 개이고 상태 간의 관계나 로직이 복잡함

2. 다수의 연관된 상태 업데이트
여러 상태를 한 번에 업데이트해야 할 때 useState로 처리하며 어려움을 겪고 있음. useReducer는 여러 상태 업데이트를 하나의 액션으로 처리 가능해 개선의 목표였던 코드의 일관성 유지와 버그 감소에 유리함.

3. 로직이 상태 업데이트와 긴밀하게 연결된 경우
일부 상태 업데이트가 다른 상태나 컴포넌트의 모달과 같은 다른 부분에 영향을 미치고 있음. 액션을 통해 상태 변화를 명확히 추적하고, 예측 가능한 방식으로 상태 관리 가능해야함.

4. 상태 전이가 명확한 경우
상태 전이에 대해 명확히 정의하고 관리할 수 있음. 이를 리듀서 함수의 액션을 통해 제어 가능함.

 

 

초기 상태 정의

 

 

 

1. currentMatched
현재 매칭된 카드의 번호 저장
2. obtained
플레이어가 획득한 카드들의 번호를 배열로 저장
3. clicked
사용자의 클릭 횟수 기록
4. userStatus
현재 게임 진행 상태
5. time
게임의 시작 시간과 종료 시간

 

 

 

리듀서 함수 정의

 

 

useReducer 훅에서 사용되는 리듀서 함수로, 액션 타입에 따라 상태를 변경하고 새로운 상태를 반환함. ...state를 사용하여 기존 상태를 복제하고 필요한 필드만 변경함. 이를 통해 불변성을 유지하며 상태를 업데이트함.

 

 

1. playStateActionKey.INCREASE_CLICKED
클릭 횟수 증가시키는 액션
2. playStateActionKey.OBTAIN_CARD
카드 획득하는 액션. payload에는 획득한 카드와 상태 포함됨
3. playStateActionKey.CHANGE_USER_STATUS
사용자의 게임 상태를 변경하는 액션. 게임 종료 시 상태를 업데이트하고 종료 시간 기록함

 

 

 

useReducer를 적용한 커스텀 훅

 

 

useReducer를 사용하여 playState 관리함. 

playState는 현재 게임의 다양한 상태를 포함하는 객체임.

dispatch 함수를 통해 상태 변경 가능함.

 

 

 

 

획득 아이템 번호 배열(obtained)과 현재 게임 진행 상태(userStatus)에 따라 실행되는 useEffect 내부 코드

 

 

1. 의존성 배열 및 트리거 조건
playState.obtained와 playState.userStatus가 변경될 때마다 호출함
2. userStatus의 값 상태에 따른 처리 로직
MATCHED: 현재 상태가 MATCHED인 경우, 3초 후에 CHOOSING 상태로 변경됨
CHOOSING: 모든 카드를 맞춘 경우 (= 현재 상태가 CHOOSING + 획득한 카드 수가 최대 카드 수와 동일), CLEAR 상태로 변경됨
CLEAR: 현재 상태가 CLEAR인 경우, 5초 후에 RESULT 상태로 변경됨
3. dispatch 함수
playReducer에 정의된 액션을 전달하고 상태를 변경함. 
상태 변경 시 useEffect가 다시 호출되어 일부 새로운 상태에 따른 처리가 진행됨
4. 비동기 처리
setTimeout 함수를 사용하여 특정 시간이 지난 후에 상태를 변경하는 것은 게임 플로우를 자연스럽게 진행하기 위함. 사용자가 일정 시간 동안 변경된 게임 상태에 맞는 UI를 확인하고 플레이 할 수 있도록 함

 

 

 

 

카드 클릭했을 때 실행되는 handleClick 함수

 

 

1. disabled.current 체크
카드에 해당하는 DOM 요소가 비활성화된 경우 함수 종료

2. 현재와 이전 카드 설정
curr 변수: 현재 클릭된 카드
prev 변수: 이전에 클릭된 카드

3. 클릭 횟수 증가
INCREASE_CLICKED 액션을 dispatch로 실행해 클릭 횟수 증가

4. 카드 앞/뒷면 처리
4-1) 이미 매칭된 카드 클릭한 경우
curr.status === GameCardStatusKey.MATCHED: 추가적인 처리 불필요해 함수 종료
4-2) 매칭되지 않은 카드 클릭
현재 클릭된 카드를 FACE_UP 상태로 업데이트
사용자가 클릭한 카드가 뒤집혔음을 UI에 반영 필요

5. 카드 짝 맞추기 상황 확인
5-1) 첫번째 카드 선택한 경우 
!prev: 이전에 클릭한 카드 없음
5-2) 두번째로 선택한 카드가 첫번째 카드 선택한 것과 동일한 경우
prevIndex.current === currIndex: 사용자가 같은 카드를 두 번 연속으로 클릭
두 경우 모두 현재 선택한 카드가 첫번째 선택한 카드값으로 유지되어야 함. 짝이 맞는지 따질 필요는 없으니 함수 종료

6. 카드 짝이 맞는 경우
두 카드를 MATCHED 상태 업데이트
OBTAIN_CARD 액션을 디스패치하여 사용자가 이 카드를 획득했음 처리

7. 카드 짝이 맞지 않는 경우
두 카드 모두 뒷면이 보이도록 UI 처리 필요 (이 동안 사용자가 카드 클릭 불가능해야함)
UI 처리가 완료된 경우 다시 사용자가 카드 클릭 가능하도록 하기

8. 첫번째로 선택한 카드 인덱스 초기화해 다음 카드 짝 맞추기 진행되게 함

 

 

 

Comments