TIL 44일차 Redux 상태관리 실습 오답노트 🍱

들어가며

오늘은 빈칸 채우기로 진행해서 코딩 자체를 많이 하진 않았지만 개념과 구조를 익히는데 많은 시간을 투자했다. 과정 자체 내용이 적어서 별로 어렵지 않나? 싶었는데 왠걸.. 복잡해서 페이지 한두장으로 설명하기 힘들어서 내용이 적었던게 아닐까? 동영상 강의가 있긴한데 문장 하나하나 뜯어가면서 익혀야 될 정도로 압축 또 압축이었다. 압축내용은 매우 좋았지만 코드 구조를 이해하는데는 조금 힘들었다.
뭔가 불만만 늘어놓는 것 같은데 사실 코드스테이츠 굉장히 좋아하고 많이 좋아하니까 그만큼 더 관심이 가는.. 그런거다. 사랑해요 코드스테이츠!

기술에 대한 감상을 써보면 음...... 구조를 보고 느낀 감상은 복잡한 구조에는 이유가 있다. 확장성을 고려해서 구현된 코드를 보니 여기저기 신경쓸 부분이 많았다. 물론 규모가 커지면 그 정도의 수고를 들여서 구성할 정도의 가치가 있다. 그리고 구축해두면 실제로 구동되는 부분은 심플해서 알면 알수록 좋은 친구같다. 근데 친해질 때까지 힘들었다. 그리고 맨땅에서다시 만들 자신이 없다. 공부하자. 😇

글을 어떻게 쓸까 고민했다. Redux 개념을 중심으로 쓸까? 아니면 코드를 중심으로 쓸까? 조금 고민하다가 코드를 중심으로 쓰려고 한다. 코드를 쓰다보면 핵심 개념까지 건들 수 밖에 없으니까. 쭈욱 내려가보자. 요즘 오답노트를 많이쓴다. 실습을 하면 많이 햇갈렸던 코드를 정리하는 방식이 재법 마음에 든다.

package.json

맨땅에서 만들기 대비한 의존성 확인. redux-thunk 모듈은 redux 환경에서 비동기 구현을 도와준다.

{
  "dependencies": {
    "@babel/preset-react": "^7.12.10",
    "@codestates-cc/submission-npm": "^1.1.2",
    "@reduxjs/toolkit": "^1.1.0",
    "axios": "^0.21.0",
    "react": "^17.0.1",
    "react-dom": "^17.0.1",
    "react-redux": "^7.1.0-rc.1",
    "react-router-dom": "^5.2.0",
    "react-scripts": "4.0.1",
    "redux": "^4.0.5",
    "redux-thunk": "^2.3.0"
  }
}    

index.js

여기부터 시작이다. Redux 환경 컴포넌트는 시작부터 다르다. Provider 컴포넌트를 react-redux 모듈에서 받아서 <App /> 컴포넌트를 감싼다. 앞으로 store가 변경됐을 때 랜더링을 다시 진행하도록 구성하는 시작점으로 보여진다.

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import store from './store/store';
import { Provider } from 'react-redux';
import * as serviceWorker from './serviceWorker';

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

Provider 컴포넌트는 store 객체를 상속받는다. 그 다음 바로 이어지는 것이 store다. 사실 store가 먼저인 것 같지만 React의 흐름 기준은 App.js 그 다음 store

store/store.js _ createStore 😜

이 부분은 하나도 모르겠다. store라는 변수에 createStore 메소드 실행 결과가 들어간다는 것과 첫번째 매개변수로 rootReducer가 들어간다는 것만 알겠다. 그리고 thunk 확장 모듈이 포함된다는 것도 알겠다.
핵심은 import 받은 rootReducer라는 이름으로 dispatch가 호출되고 값이 변경되면 store에 전달해서 랜더링을 다시 한다.
와.. 복잡하다. 몇 번 보니까 조금 알겠는거지 처음 봤을땐.. ㅋㅋㅋㅋ

import { compose, createStore, applyMiddleware } from "redux";
import rootReducer from '../reducers/index';
import thunk from "redux-thunk";

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ?
  window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({})
  : compose;
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(thunk)));

export default store;

pages/ItemListContainer.js

react-redux 모듈로부터 useSelector, useDispatch 메소드를 받아서 변수에 실행한다.

import React from 'react';
import { addToCart, notify } from '../actions/index';
import { useSelector, useDispatch } from 'react-redux';
import Item from '../components/Item';
...

function ItemListContainer() {
  const state = useSelector(state => state.itemReducer);
  const { items, cartItems } = state;
  const dispatch = useDispatch();

  const handleClick = item => {
    if (!cartItems.map(el => el.itemId).includes(item.id)) {
      //TODO: dispatch 함수를 호출하여 아이템 추가에 대한 액션을 전달하세요.
      dispatch(addToCart(item.id));
      dispatch(notify(`장바구니에 ${item.name}이(가) 추가되었습니다.`));
    } else {
      dispatch(notify('이미 추가된 상품입니다.'));
    }
  };

Selector 🍱

useSelector 메소드의 매개변수는 콜백 함수이고 그 함수의 첫번째 매개변수 state를 주목하자. 매개 변수에 store 객체가, 객체가! 들어있고 itemReducer 키를 불러온다.

그 다음 state 객체에서 구조분해 할당으로 items와 cartItems 변수에 키를 각각 할당한다.
const { items, cartItems } = state

와~~~ 뭐지? 굳이 콜백으로 불러야되나? 구조상 다 이유가 있겠지? 근데 여러 컴포넌트들을 겪으면서 꼬아놓는 구조에 어느정도 면역이 생긴 것 같다. React 처음 봤을 때의 멀미까지 나지는 않더라.

먼저 useSelector()는 컴포넌트와 state를 연결하는 역할을 합니다. 컴포넌트에서 useSelector 메소드를 통해 store의 state에 접근할 수 있는 것이죠.
useSelector의 전달인자로는 콜백 함수를 받으며 콜백 함수의 전달인자로는 state 값이 들어갑니다. 자세한 사용법은 공식 문서의 useSelector examples 를 참고하세요.

import { useSelector, useDispatch } from 'react-redux';
...

  const state = useSelector(state => state.itemReducer);
  const { items, cartItems } = state;

Dispatch 🛎

useDispatch 메소드는 ActionReducer에 전달한다. 라고 되어있다... 는데 Action은 어디있고 Reducer는 어떻게 부르는거람??? 여기서 해맸다. Action은 따로 파일을 보면서 설명한다. 아래 문단으로 가자.

Reducer로 전달하는 부분은 Provider 컴포넌트가 감싸고 있으니 그 안에 유일한 store 객체의 rootReducer로 전달된다고 생각할 수 있다.

import React from 'react';
import { addToCart, notify } from '../actions/index';
import { useSelector, useDispatch } from 'react-redux';
import Item from '../components/Item';
...

  const dispatch = useDispatch();

  const handleClick = item => {
    if (!cartItems.map(el => el.itemId).includes(item.id)) {
      //TODO: dispatch 함수를 호출하여 아이템 추가에 대한 액션을 전달하세요.
      dispatch(addToCart(item.id));
      dispatch(notify(`장바구니에 ${item.name}이(가) 추가되었습니다.`));
    } else {
      dispatch(notify('이미 추가된 상품입니다.'));
    }
  };

actions/index.js_Action 📄

addToCart 메소드는 Action 객체를 생성하는 함수였다. Action 객체를 생성할 때 매개변수에 따라서 객체가 달라질 수 있어서 객체로 할당했다. Reducer로 전달될 때는 함수 실행 결과가 전달되기 때문에 객체로 들어간다. 따라서 객체만 있어도 된다.
처음엔 이름이 액션이니까 함수가 아닐까? 라는 막연한 생각이 있었다. 근데 그냥 객체더라. 마치 영수증처럼. type이라는 키에 어떤 가게인지 나오고 payload 키에 전달할 값이 적혀있다. payload가 계속 변하기 때문에 객체 생성 함수가 전달인자를 받아서 조건에 맞는 객체를 생성한다.

상단에 ADD_TO_CART 문자열 export한 이유는 구조를 보여주기 위함이 아닌가 싶다. 나중에 Reducer에서 이 액션 생성 파일을 참고해서 문자열을 받아온다. 변수 대신 문자열을 넣어도 잘 동작한다. 기능적인 의미는 없고 구조를 보여주기 위함이 아닐까?

// action types
export const ADD_TO_CART = 'ADD_TO_CART';
...

// actions creator functions
export const addToCart = itemId => {
  return {
    type: ADD_TO_CART,
    payload: {
      quantity: 1,
      itemId,
    },
  };
};

reducer/index.js

대빵 리듀서다. store가 생성될 때 참조했던 rootReducer가 다른 리듀서들을 묶어주는 역할을 하고있다. 실제 동작할 때는 dispatch가 호출되면 rootReducer안에 있는 모든 리듀서를 호출하게 된다.
예를들어 itemReducer 조건문 밖에 console.log를 걸어두면 notificationReducer 를 호출할 때도 console.log를 출력한다. dispatch가 호출되면 안에 있는 리듀서들을 전부 호출하는 모습을 확인할 수 있다.

import { combineReducers } from 'redux';
import itemReducer from './itemReducer';
import notificationReducer from './notificationReducer';

const rootReducer = combineReducers({
  itemReducer,
  notificationReducer
});

export default rootReducer;

reducer/ItemReducer.js

itemReducer의 매개변수를 주목하자.

  • 첫 매개변수는 기본 값으로 하드코딩된 데이터를 가지고 있다. 그 이후부터는 state 객체를 이어받으면서 상태를 유지한다. 이 부분에서 리듀서가 store를 관리하는 모습을 확인할 수 있다.
  • 두번째 매개변수는 Action 객체가 전달된다. 그래서 switch 문에서 action.type 객체 키값으로 조건을 확인할 수 있고 payload또한 참고할 수 있다.
import { REMOVE_FROM_CART, ADD_TO_CART, SET_QUANTITY } from '../actions/index';
import { initialState } from './initialState';

const itemReducer = (state = initialState, action) => {
  switch (action.type) {
    case ADD_TO_CART:
      //TODO
      return Object.assign({}, state, {
        cartItems: [...state.cartItems, action.payload],
      });
      break;

Reducer의 Immutability(불변성) ☀️

Reducer는 순수 함수여야 한다. splice같은 원본 값을 수정하는 함수는 원본을 복사하고 사용하는게 좋다. 나는 이 부분에서 Splice 한 결과를 그대로 반영하려고 해서 한시간동안 삽질을 했다.

Reducer 함수를 작성할 때 주의해야 할 점이 있습니다. 바로 Redux의 state 업데이트는 immutable한 방식으로 변경해야 한다는 것인데요. Redux의 장점 중 하나인 변경된 state를 로그로 남기기 위해서 꼭 필요한 작업입니다.

왜 immutable 한 방식으로 바꾸어야 하는지 잘 모르겠다면 React life cycle 키워드로 검색해보는 것을 권장합니다. (React에서 state를 변경하기 위해서는 this.state에 바로 할당하는 것이 아닌 this.setState를 통해 state를 변경해주어야 했죠?) 이 룰에 대해 자세하게 다룬 블로그 글도 시간이 된다면 한번 읽어보는 것을 추천합니다. (React를 잘 사용하기 위해서도 꼭 알아야 하는 내용들이 있어요.)

그렇다면 immutable한 방식으로 state를 변경하기 위해서는 어떻게 코드를 작성해야 할까요? 위의 itemReducer 예제 코드에서 Object.assign을 통해 새로운 객체를 만들어 리턴하는 것을 통해 힌트를 얻을 수 있습니다.

예제에도 친절하게 Object.assign을 사용하라고 하는데 그걸 무시했다. 실패한 코드와 성공한 코드를 보자. 주석처리한 코드가 실패한 코드다. splice한 결과를 배열에 반영하고 주소값 그대로 반환했다. useState처럼 동일한 참조자료형을 반환했으니 주소값 변경을 인지하지 않아서 페이지 랜더링 처리를 하지 않는다.

깊은 복사까지 확인해서 객체 안의 배열도 새롭게 할당해야 한다. 안그러면 변경되지 않은 배열로 인지하고 해당 배열을 참고하는 페이지를 다시 랜더링하지 않는다. 😈

새로운 객체를 할당은 Object.assign을 활용한다. 심심해서 이렇게 할당한게 아니구나. 주소값이 새로운 객체를 할당해야 정상적으로 다시 랜더링을 수행하면서 동작한다.

    case SET_QUANTITY:
      // let idx = state.cartItems.findIndex(el => el.itemId === action.payload.itemId);
      // //TODO
      // state.cartItems[idx] = action.payload;
      // return state;

      let idx = state.cartItems.findIndex(el => el.itemId === action.payload.itemId);
      //TODO
      const cart2 = state.cartItems.slice();
      cart2[idx].quantity = action.payload.quantity;
      return Object.assign({}, state, {
        cartItems: [...cart2],
      });

      break;

좋은 웹페이지 즐겨찾기