Wanted-PreOnboarding-Team-8 / pre-onboarding-9th-2-8

원티드 프리온보딩 2차 과제
https://pre-onboarding-9th-2-8.netlify.app
0 stars 8 forks source link

상태관리 라이브러리에 대하여 #7

Open jiheon788 opened 1 year ago

jiheon788 commented 1 year ago

2023.03.07 PM 19:00회의에서 상태관리 관련하여 3가지 선택지가 있었습니다. 그 중 2가지는 아래와 같은 이유로 기각되었습니다.

  1. Redux: props drilling 문제가 발생할 정도로 프로젝트 규모가 크지않아서 기각
  2. React Query: 서버와의 통신이 없기에 굳이 사용할 이유가 없다 판단

그렇게 상태관리 라이브러리를 사용하지 않기로 하고, 몇 분은 ContextAPI를 사용한다 하셨던걸로 기억합니다.. 저는 localStorage를 사용해보려 했습니다. 하지만 진행하다 보니 상태 관리 라이브러리가 필요하다 판단되어 이슈를 남깁니다. 이유는 아래와 같습니다.

Context API의 렌더링 이슈

아마 Context API 예제를 찾아보면 대부분이 테마, 로그인 정보 같은 정도로 사용하고 있는 것을 보셨을겁니다. 이는 Context API가 예전부터 렌더링 이슈가 있어왔습니다. Provider 하위에서 context를 구독하는 모든 컴포넌트는 Provider의 value가 바뀔 때마다 다시 렌더링 됩니다. 즉 직접 context를 사용하지 않는 부분까지 리렌더링 됩니다. 때문에 업데이트가 자주 필요한 부분에 권장되지 않습니다.

관련 링크

localStorage

저의 경우는 ContextAPI의 렌더링 이슈 때문에 로컬스토리지를 사용해볼까 했습니다. 하지만 이는 state가 아니기에 적합하지 않습니다. (좀 늦게 깨달았습니다.)

결론

위와 같은 이유로 저는 리덕스를 사용해 장바구니를 구현하려 합니다. 회의에서 라이브러리를 사용하지 않기로 했는데 혼자 라이브러리를 쓰는 것은 옳지 못하다 생각되어 이슈로 남기겠습니다.

greyHairChooseLife commented 1 year ago

이 이슈가 올라왔을 때 저는 리덕스는 커녕 context API에 관해서도 아는 것이 없었습니다. 언젠가 관련 된 공부를 하고 공유 해 주신 의견을 이해라도 해 보리라 생각했는데, 오늘 비로소 의견을 덧붙여 봅니다.

제가 이해한 이 이슈의 핵심은

장바구니 상태값을 관리 하기에 context API는 부적합하다. 왜냐하면,

  1. 상품 리스트 페이지와 장바구니 페이지 양쪽에서 이른바 '장바구니 상태값'에 접근 할 수 있어야 한다. 즉, 양쪽에서 장바구니 컨텍스트를 구독하고 있어야 한다.
  2. 장바구니 컨텍스트에 담긴 상태값은 얼마든지 자주 바뀔텐데 그때마다 (과제에서 요구 된)모든 컴포넌트가 계속 리렌더링 된다.

링크에서 지적하는 context API의 리렌더링 이슈 2가지 (요약)

링크1. Context API는 왜 쓰고, 그렇다면 Redux는 필요없을까? :point_right: context를 구독하는 컴포넌트의 부모 및 자식 컴포넌트까지 전부 리렌더링 된다.

링크2. 언제 context API를 사용하고 언제 zustand나 redux를 사용할까요? :point_right: useSelector를 이용 할 수 없어서 (경우에 따라)비효율적이다.

참고로 링크 1은 일부 틀린 설명을 하고 있습니다. 특정 context를 구독하는 컴포넌트가 다시 렌더링 되다고 하여도 부모 컴포넌트가 리렌더링 되야 하는 것은 아닙니다.

한편 개인적으로 이것들은 context API의 렌더링 이슈라기보단 특성에 가깝다고 생각합니다. context API의 애초 설계에 포함되지 않는 스펙을 (예상)기대하다 보니 널리 오해가 퍼진 것 같습니다.

(:receipt: 최하단에 이 링크들이 말 하는 바를 이해하기 위한 상세 설명을 썼습니다. 저에게는 많은 공부가 됐는데, 아마도 읽어보시면 좋을 것 같습니다.)

이번 이슈에 대한 제 의견은

"주어진 과제 요구사항을 구현 하는 정도에서 리렌더링에 주목 해 보자면 context API도 충분하다."

입니다.

근거는 아래와 같습니다.

  1. context API의 특성에 따라 리렌더링이 우려 되는 부분은 장바구니를 상태값의 getter와 setter를 구분하여 providing함으로써 해결 할 수 있다. 혹은 그 이상으로 provide할 장바구니 관련 상태가 있다면 이를 개별적으로 providing 할 수도 있다.
  2. 특정 context를 구독하고 있는 컴포넌트의 자식 컴포넌트(그러나 해당 context를 직접 구독하고 있지는 않은)가 함께 리렌더링 되는 것은 매우 자연스럽다. 당연히 내장 API든 다른 어떤 라이브러리든 모두 똑같다.
  3. useSelector를 위시한 redux의 상태 관리 기능이 필요할 만큼 장바구니 상태값이 복잡다단하지 않다.

실제로 저희의 최종 제출 코드를 살펴보면, 상품 리스트 페이지와 예약 리스트 페이지 모두 cartSlice를 구독하고 있으며, 이 컴포넌트들의 자식 컴포넌트들 모두 장바구니 상태값의 변화에 따라 리렌더링 됩니다.

한편 이 이슈와는 별개로 저희 프로젝트에 리덕스의 사용이 의미가 없었느냐 하면 그렇지는 않다고 생각합니다. 리덕스를 통해 얻을 수 있는 장점에는 리렌더링 관련 효율성 외에도 많이 있습니다.

그중에 저희 프로젝트에서 잘 드러난 요소, 프로젝트 진행 당시에 제가 사용법만 간신히 후다닥 익힌 저에게도 와 닿았던 점은 전역 상태들을 컴포넌트 밖에서 관리 할 수 있게 해 준다는 점입니다. 저에게는 백엔드의 MVC(Model - View -Controller) 아키텍쳐를 떠오르게 했습니다.

이렇게 (전역적으로 사용 할)상태들의 저장과 관리가 일부 컴포넌트에 종속되지 않는다는 않는다는 점은 context API와 비교되는 점이기도 합니다.

관련 링크에서 지적하는 context API의 리렌더링 이슈 2가지 (자세히 뜯어 보기)

첫째. Context API는 왜 쓰고, 그렇다면 Redux는 필요없을까?

여기에서는 컴포넌트가 컨텍스트를 직접 구독하고 있지 않아도, 이것의 부모 또는 자식 컴포넌트가 컨텍스트를 구독하고 있으면 기본적으로 리렌더링 된다고 설명합니다. 그러면서 React.memo를 활용하여 부모 또는 자식 컴포넌트를 memoization하는 대응 방법을 소개, 그런 대응 방식의 장단점을 덧붙이고 있습니다.

링크에서 인용 : "위와 같이 ChildComponentOne은 Context를 사용하지도 않았고 그저 Context를 사용하는 컴포넌트를 렌더링 했을 뿐인데 리렌더링이 발생합니다."

그러나 이것은 (절반)틀린 설명입니다. 컨텍스트를 구독하는 컴포넌트의 자식 컴포넌트는 함께 리렌더링 되지만, 부모 컴포넌트는 관련 없습니다.

그렇다면 링크의 저자가 보여준 예시에서는 왜 컨텍스트를 구독하는 컴포넌트의 부모 컴포넌트까지 리렌더링 되었을까요?

해당 예시는 아래와 같습니다. (useEffect같은 쓸데 없는 것은 지웠습니다.)

const ChildComponentOne = () => {
  console.log("Child Component 1 is Rendered");

  return <ChildComponentTwo />;
};

const ChildComponentTwo = () => {
  const [number, setNumber] = useContext(MyContext);

  console.log("Child Component 2 is Rendered");

  return <>
    <p>{number}</p>
    <button onClick={() => setNumber(prev => prev + 1)}>+</button>
  </>
};

export default function App() {
  const [number, setNumber] = useState(0);

  return (
    <MyContext.Provider value={[number, setNumber]}>
      <ChildComponentOne />
    </MyContext.Provider>
  );
}

링크의 필자가 지적하는 것은 <Child..Two>가 리렌더링 되면 이의 부모 컴포넌트이면서, 컨텍스트를 구독하지 않는 <Child..One>까지 리렌더링 된다는 것입니다.

<Child..One>까지 리렌더링 된다는 것은 사실입니다. 하지만 그것은 컨텍스트 때문이 아니고 <App> 컴포넌트 자체가 리렌더링 되기 때문입니다. <MyContext.Provider... >로 감싸지지 않은 다른 컴포넌트를 추가하면 그것은 리렌더링 되지 않을까요? 당연히 리렌더링 됩니다.

다만 컨텍스트를 구독하는 컴포넌트의 자식 컴포넌트는 컨텍스트를 사용하지 않더라도 리렌더링 됩니다. 그러나 이것은 매우 자연스럽습니다. 컨텍스트가 아니라도 부모 컴포넌트의 상태값이 바뀌면 자식은 리렌더링 됩니다.

둘째. 언제 context API를 사용하고 언제 zustand나 redux를 사용할까요?

여기에서 지적하는 바는 결론적으로 context API에는 selector가 없다는 것입니다. 맞는 말입니다.

그렇다면 useSelector는 어떤 이점을 줄까요?

제출 된 저희의 프로젝트를 돌아보면, 총 3개의 slice를 정의했습니다. product, cart, modal입니다. 그리고 useSelector를 활용해 컴포넌트 단위 별로 적절한 slice만을 선택하여 사용했습니다. 그래서 다른 slice가 업데이트 되든 말든, 각 컴포넌트는 자신이 이용하는 slice에만 반응하여 리렌더링 됩니다.

여기까지도 아주 좋습니다만, 이것이 useSelector가 할 수 있는 전부는 아닙니다. 좀 더 본질에 가까운 질문은 이렇습니다.

Slice는 독립적으로 관리 할 수 있는 상태값의 최소 단위인가?

답은 '아니요' 입니다. useSelector를 이용해서 slice를 더 작게 쪼개서 사용 할 수 있습니다. 즉, 컴포넌트는 slice의 일부분만 구독하고 반응(리렌더링) 할 수 있습니다.

예를 들어 아래와 같은 slice가 있을 때,

// slice, 
state = {
  cartList: [one, two, three, ...],
  cartAdmin: {
    name: 'Sangyeon'
  } 
}

아래와 같이 쪼개서 사용할 수 있습니다.

// <CartListTable> 컴포넌트
const cartList = useSelector(state => state.cartSlice.cartList);   // cartSlice라는 이름으로 슬라이스에 접근 
// <ControlCartAdmin> 컴포넌트
const cartAdmin = useSelector(state => state.cartSlice.cartAdmin);   // cartSlice라는 이름으로 슬라이스에 접근 

이때 만약 어딘가에서 cartSlicecartList 속성을 업데이트 한다면 똑같은 cartSlice를 사용하고 있는 <ControlCartAdmin>컴포넌트도 같이 업데이트 될까요? 아닙니다.

이렇듯 useSelector API는 우리가 slice 조차 필요한 만큼 쪼개서 더 효율적으로 사용할 수 있게 해줍니다.

링크에서 다음과 같이 말합니다.

첨언하자면 왜 컨텍스트로 zustand나 Recoil, redux를 대체할 수 없을까요? 기본적으로 컨텍스트는 Reactivity를 제공하지 않습니다. 즉 셀렉터 등으로 상태 변화에 대한 컴포넌트 구독 메커니즘을 수현할 수가 없습니다.
그냥 해당 컨텍스트의 상태가 바뀌면, 해당 컨텍스트에 의존하는 모든 컴포넌트는 상테가 변경되게 됩니다. 엑셀을 웹에서 구현한다 생각하면 이 문제가 극단적이 되는데여, 리덕스, zustand는 셀렉터 구독이 가능하기 때문에 상태가 아예 새로 만들어져도 변경된 셀만 렌더링이 가능합니다. 리코일은 해당 아톰에 의존적인 컴포넌트만 변경됩니다. context API는 memoiziation 연산밖에는 리렌더링을 방어할 방도가 없습니다.

링크의 필자가 엑셀을 예로 들었는데, useSelector가 빛날 수 있는 아주 적절한 예시인 듯 합니다. 예를들어 엑셀의 모든 셀(cell)을 가진 slice를 만들고, 각 cell을 구현한 컴포넌트에서 useSelector를 이용, slice에서 필요한 만큼만 잘라서 사용 할 수 있겠습니다.

이에 비해 context API는 여러 상태값을 독립적으로 사용하기 위해서는 개별적으로 providing하는 해야합니다. 1만 개의 셀 상태값을 독립적으로 관리하고 싶다면 1만개의 <context.provider>가 필요합니다. 또는 memoization(비용 발생)을 해야만 합니다.

tnghgks commented 1 year ago

좋은 글 잘 읽었습니다!

말씀하신대로 context API는 값이 변경된다면 Provider안에 있는 모든 컴포넌트들(값을 사용하지 않는 컴포넌트 포함)이 리렌더링되는 단점이있지만

  1. React.Memo를 통해서 값이 바뀌지 않았을 경우 리렌더링을 막을 수 있다.
  2. 다만, 모든 컴포넌트들을 memoization 한다면 비용이 발생한다.
  3. 그래서 Redux의 useSelector을 사용하면 slice 단위 뿐만 아니라 참조하고 있는 세부 상태에 따라 각각 렌더링 관리가 가능하다.
  4. context API 에서는 그렇게 하려면 독립적인 provider를 만들어야해서 독립적으로 관리할 값이 늘어나면 늘어날 수록 불리하다.

로 정리 할 수 있겠네요. 정성스럽게 적어주신 글 덕분에 구체적으로 알 수 있어서 너무 감사합니다.👍

greyHairChooseLife commented 1 year ago

@tnghgks

호수님, 요약 해 주신 내용 중 다른 것들은 맞지만 맨 처음

"말씀하신대로 context API는 값이 변경된다면 Provider안에 있는 모든 컴포넌트들(값을 사용하지 않는 컴포넌트 포함)이 리렌더링되는 단점이있지만"

이 부분에는 오류가 있습니다. Provider 안에 있는 모든 컴포넌트가 리렌더링 되는 것이 아닙니다.

딱 2가지 경우에 리렌더링 되는데,

  1. Provider가 제공하는 value가 변경 될 때, 이 context를 구독하고 있는 컴포넌트들과 그 자식 요소들
  2. Provider의 {children}으로 새로운 값(새롭게 렌더링 되어)이 제공 될 때

입니다.


그리고 이번에 이렇게 공부하고 테스트 해 보면서, 이런 특성은 context API를 (다양한 위치에서 다양한 방식으로 변경 되는)전역 상태값을 저장하는 용도에는 적합하지 않은 또는 불충분한 이유 정도로 표현해야 한다고 생각했어요.

미묘한 차이지만 호미로 막을 일을 가래로 막지 않을 수 있게 해 줍니다.

호미는 작고 약한 단점이 있기 때문에 광산에서 석탄을 캐는데 쓰지 못한다고는 하지 않죠. 즉, context API를 (다양한 위치에서 다양한 방식으로 변경 되는)전역 상태값을 저장하는 용도로 쓰고자 바라보기 전에는 단점이 아닙니다.

"context API는 aa라는 단점이 있어, 그래서 AA라는 용도로는 못쓰겠다!" :x:

"AA용도로 context API를 쓸 생각을 한 내가 미숙했구나!" :o:

'redux는 context API와 비교 대상이 아니다.' 라고 강조하는 글들이 나름 많이 나오는데요, 같은 의미라고 볼 수 있겠습니다.