[주문앱 7탄] reducer로 리팩토링하기

비얌·2023년 5월 23일
1
post-thumbnail

🧹 개요

이번 포스팅과 다음 포스팅에서는 지금까지 만든 그릭요거트 앱을 리팩토링하는 과정을 다루려고 한다.

두가지의 리액트 Hook을 사용하여 기존의 코드를 개선할 것인데, 그 Hook은 useReducer와 useContext이다. 이 둘을 사용하여 리팩토링한 결과, 아래의 장점들을 체감할 수 있었다.

  • reducer:
    • 상태 간의 관계를 파악하기 쉽다.
    • 로직을 컴포넌트 밖으로 분리하여 다른 곳에서도 쓸 수 있다.
  • context:
    • prop drilling 문제가 해결된다.
    • reducer와 함께 하나의 파일에서 관리할 수 있다.

이번 포스팅이서는 분량상 reducer로 리팩토링한 내용만 다루기로 했다!



✨ 결과 미리보기

결과는 이전과 같은데, 아래처럼 리팩토링 후에도 전과 똑같이 동작한다.



🛫 reducer를 사용하여 리팩토링하기

📃 기존 코드

기존의 코드는 아래와 같다.

// 📃 App.jsx
function App() {
  // ...생략
  const [cartItems, setCartItems] = useState([]);
  const [totalPrice, setTotalPrice] = useState(0);
  
  // 📌 장바구니 안에서 + 버튼을 누르면 수량을 1 증가시키는 함수
  const onAdd = id => {
    const updatedArr = cartItems.map((cur) => {
      if (cur.id === id) {
        cur.amount++;
      }
      return cur;
    });
	setCartItems(updatedArr);
    
    const newTotalPrice = updatedArr.reduce((acc, cur) => {
      return acc + (cur.amount * cur.price);
	}, 0);

  // 📌 장바구니 안에서 - 버튼을 누르면 수량을 1 감소시키는 함수
  const onRemove = id => {
    const updatedArr = cartItems.map((cur) => {
      if (cur.amount > 0 && cur.id === id) {
        cur.amount--;
      } else if (cur.amount === 0 && cur.id === id) { 
        cur.amount;
      }
      return cur;
    });

    const newTotalPrice = updatedArr.reduce((acc, cur) => {
      return acc + (cur.amount * cur.price);
    }, 0);
    setTotalPrice(newTotalPrice);

    const removedArr = updatedArr.filter((cur) => { 
      return cur.amount > 0;
    })
    setCartItems(removedArr);
  }
  
  // 📌 `+ 담기` 버튼을 누르면 장바구니에 선택한 아이템을 담아주는 함수
  const onSave = selectedItemData => {
    const newItemData = cartState.items.concat(selectedItemData);

    const mergedItemData = newItemData.reduce((acc, cur) => {
      const found = acc.find((item) => item.id === cur.id);
      if (found) {
        found.amount += Number(cur.amount);
      } else {
        acc.push({...cur});
      }
      return acc;
    }, []);

    setCartItems(mergedItemData);

    newItemData.forEach(item =>{
      setTotalPrice(totalPrice + item.amount * item.price);
    })
  };
}
export default App;

이 기존 코드에 대한 설명은 아래와 같다.

  • useState로 cartItems와 totalPrice 상태를 선언한다.

    const [cartItems, setCartItems] = useState([]);
    const [totalPrice, setTotalPrice] = useState(0);
  • setter 함수를 이용해서 장바구니 안에 있는 아이템 데이터를 업데이트한다.

    // 예시
    setCartItems(updatedArr);
    setTotalPrice(newTotalPrice);
  • 장바구니에서 + 버튼을 누르면 onAdd 함수가, - 버튼을 누르면 onRemove 함수가 실행된다. 이때 + 혹은 - 버튼이 눌린 항목의 id가 함수에 인자로 전달된다.

    // 📃 CartItem.jsx(하위 컴포넌트)
    const CartItem = ({ id }) => {
      return (
        // 생략
        <>
          <button onClick={() => cartCtx.onRemove(id)}>-</button>
          <button onClick={() => cartCtx.onAdd(id)}>+</button>
        </>
      );
    };
    
    export default CartItem;

📃 reducer로 리팩토링한 코드

reducer를 사용해서 cartItems와 totalPrice 상태를 한군데에서 관리하고 액션에 따라 다르게 기능할 수 있게 해보자.

아래는 리팩토링을 완료한 코드인데, 이 코드가 어떤 순서로 작성되었는지 알아보자.

// 📃 App.jsx
import React, { useReducer } from 'react';

const initialCartState = {
  items: [],
  totalPrice: 0
}

const cartReducer = (state, action) => { 
  switch (action.type) {
    case 'saved': {
      const newItemData = state.items.concat(action.selectedItemData);
      const mergedItemData = newItemData.reduce((acc, cur) => {
        const found = acc.find((item) => item.id === cur.id);
        if (found) {
          found.amount += Number(cur.amount);
        } else {
          acc.push({...cur});
        }
        return acc;
      }, []);
      
      const newTotalPrice = mergedItemData.reduce((acc, cur) => {
        return acc + (cur.amount * cur.price);
      }, 0);
      
      return {
        items: mergedItemData,
        totalPrice: newTotalPrice
      }
    }

    case 'added': {
      const updatedArr = state.items.map((cur) => {
        if (cur.id === action.id) {
          return {
            ...cur,
            amount: cur.amount + 1
          }
          // cur.amount++;
        }
        return cur;
      });

      const newTotalPrice = updatedArr.reduce((acc, cur) => acc + (cur.amount * cur.price), 0);
      
      return {
        items: updatedArr,
        totalPrice: newTotalPrice
      }
    }

    case 'removed': {
      const updatedArr = state.items.map((cur) => {
        if (cur.amount > 0 && cur.id === action.id) {
          return {
            ...cur,
            amount: cur.amount - 1
          }
        } else if (cur.amount === 0 && cur.id === action.id) { 
          cur.amount
        }
        return cur;
      });
  
      const removedArr = updatedArr.filter((cur) => { 
        return cur.amount > 0
      })

      const newTotalPrice = updatedArr.reduce((acc, cur) => {
        return acc + (cur.amount * cur.price);
      }, 0);
      
      return {
        items: removedArr,
        totalPrice: newTotalPrice
      }
    }
    default:
      return initialCartState;
  }
}

const handleSaveItem = selectedItemData => {
  dispatch({
    type: 'saved',
    id: selectedItemData.id,
    selectedItemData: selectedItemData,
  })
}

const handleAddItem = id => {
  dispatch({
    type: 'added',
    id: id,
  })
}

const handleRemoveItem = id => {  
  dispatch({
    type: 'removed',
    id: id
  })
}


function App() {
// ...생략
const [cartState, dispatch] = useReducer(
    cartReducer,
    initialCartState
  );
  // ...생략
  return (
    <>
      <Cart
        cartItems={cartItems} 
        totalPrice={totalPrice} 
        onAdd={onAdd} 
        onRemove={onRemove}  
      />
      <Header cartItems={cartItems} />
      <Toppings 
        onSaveItem={onSaveItem}
      />
    </>
  )
}

export default App;

1. 이벤트 핸들러를 작성해준다.

reducer는 setState처럼 함수로 무엇을 할 것인지가 아니라 방금 한 일이 무엇인지를 리액트에게 알려준다. 어떤 일인지는 dispatch 안에 있는 객체(action)의 type 속성에서 알 수 있다(강제된 것은 아니지만 type이 관습적으로 쓰인다)

const handleSaveItem = selectedItemData => {
  dispatch({
    type: 'saved',
    id: selectedItemData.id,
    selectedItemData: selectedItemData,
  })
}

const handleAddItem = id => {
  dispatch({
    type: 'added',
    id: id,
  })
}

const handleRemoveItem = id => {  
  dispatch({
    type: 'removed',
    id: id
  })
}

2. reducer 함수를 작성한다.

reducer는 현재 상태(state)와 dispatch의 객체(action)를 받아와 새로운 상태를 반환해주는 함수이다.

const cartReducer = (state, action) => { 
  switch (action.type) {
    case 'saved': {
      // ...생략
    }

    case 'added': {
      // ...생략
    }

    case 'removed': {
      // ...생략
    }

    default:
      return initialCartState;
  }
}

3. useReducer를 선언하여 컴포넌트에 reducer를 연결하고, 기존의 state를 없앤다

1) useReducer를 상단에 임포트한다.

import { useReducer } from 'react';

2) useReducer Hook을 사용하여 컴포넌트에 reducer를 연결한다.

const [cartState, dispatch] = useReducer(
    cartReducer,
    initialCartState
  );

3) useState로 생성한 cartItems와 totalPrice 상태, 그리고 setter 함수들을 없애준다.

// const [cartItems, setCartItems] = useState([]);
// const [totalPrice, setTotalPrice] = useState(0);

여기까지 하면 reducer를 사용한 작업이 끝난다!!



✨ 결과

리팩토링만 했으므로 화면에서 보이는 내용은 바뀌지 않았다. 그래서 성공!



💥 시행착오

💥 onSave()의 액션을 추가해야만 했던 이유

사실 처음에는 onAdd(), onRemove()에 해당하는 액션만 넣으려고 했다.

왜냐하면 onAdd()와 onRemove()는 같은 컴포넌트에서 불러와지는 공통점이 있는데 반해 onSave는 다른 컴포넌트에서 불러와지고, 또 코드의 내용도 상당히 달랐기 때문이다.

그래서 넣지 않으려고 했으나 reducer를 썼기 때문에 삭제한 cartItems와 totalPrice 상태를 onSave()에서 쓰고 있었다🤦‍♀️

그래서 onSave()에 해당하는 액션도 추가할 수밖에 없었다. 이부분에서 많은 시행착오를 겪었다. 강의에서는 onSave()와 onAdd()를 모두 같은 액션으로 판단하고 있었는데 나는 그렇게 할 수 없었기 때문이다.

이미 강의와 나의 코드가 많이 다른 상황이었고, 그래서 현재 상태로는 강의에서 한 것처럼 하기 힘들 것 같다는 결론을 내렸다.

하나의 액션으로 어떻게 합칠 수 있을지는 나중에 다시 와서 생각해보려고 한다.

결론적으로는 추가하는 것에 성공했다!


💥 switch case는 블록 스코프가 아니다

블록 스코프란, 코드 블록 내에서만 유효한 스코프로 중괄호를 기준으로 범위가 구분된다.

아래의 <1번>과 <2번>, <3번>은 모두 같은 의미이다. 하지만 <1번>에서는 오류가 뜨는데, 이유는 중괄호 {}가 없는 switch case는 블록 스코프가 아니기 때문이다.

처음에 <1번>처럼 작성하였고 오류가 나서 당황했는데, 스코프 이슈 때문이었다.

<2번>처럼 중괄호를 써주면 블록 스코프가 되어 문제가 해결된다.

// 📌 <1번> switch를 사용했을 때(1) -> 오류
function reducer(tasks, action) {
  switch (action.type) {
    // 블록 스코프가 아님
    case 'added': let a = 1;
    case 'changed': let a = 1;
    case 'deleted': let a = 1;
    default: ...
  }
}
  
// 📌 <2번> switch를 사용했을 때(2) -> 정상 작동
function reducer(tasks, action) {
  switch (action.type) {
    // 블록 스코프임
    case 'added': {
      let a = 1;
    }
    case 'changed': {
      let a = 1;
    }
    case 'deleted': {
      let a = 1;
    }
    default: {
      let a = 1;
    }
  }
}

// 📌 <3번> if를 사용했을 때 -> 정상 작동
function reducer(tasks, action) {
  if (action.type === 'added') {
    let a = 1;
  } else if (action.type === 'changed') {
    let a = 1;
  } else if (action.type === 'deleted') {
    let a = 1;
  } else {
    let a = 1;
  }
}

💥 상태 객체를 수정할 때 직접 변형하면 안된다

+- 버튼을 눌러서 onAdd와 onRemove 함수를 호출하면 갑자기 1이 아니라 2씩 증가하고 감소하는 오류가 있었다.

이 오류의 원인은 상태 객체를 변형했기 때문이다. 기술적으로는 변경 가능하지만, 공식문서에서는 모든 상태 객체를 바꿀 수 없는 read-only로 취급하라고 말하고 있다.

그래서 아래처럼 변경하였고, 오류가 해결되었다.

// 📌 상태를 변경하는 잘못된 예
case 'added': {
  const updatedArr = state.items.map((cur) => {
    if (cur.id === action.id) {
      cur.amount++;
    }
    return cur;
  });
}

// 📌 상태를 변경하는 옳은 예
case 'added': {
  const updatedArr = state.items.map((cur) => {
    if (cur.id === action.id) {
      return {
        ...cur,
        amount: cur.amount + 1
      }
    }
    return cur;
  });
}


🐹 회고

사실 context를 사용하여 리팩토링한 과정까지 이곳에서 다루려고 했는데, 너무 길어져서 포스팅을 나누게 되었다.

이전에 강의에서 처음 reducer와 context를 접했을 때, 이건 너무 어려워서 혼자서는 절대 쓰지 못할 거야.. 라고 생각했는데 이번에 해내서 기뻤다!🤩

다음 포스팅에서는 context로 리팩토링하는 과정을 살펴보자😆

profile
🐹강화하고 싶은 기억을 기록하고 공유하자🐹

0개의 댓글