Redux vs context api 비교 및 Recoil과의 첫만남!

2023. 7. 5. 22:21프론트엔드/ReactJS

내가 배운 전역 상태 라이브러리들 Redux vs Context API

Redux, React-Redux

  • 상태의 중앙 관리를 위한 상태관리도구 이다.
  • ‘전역 상태’를 생성하고 관리하기 위한 라이브러리라고 볼 수 있다.
  • 이때 상태를 액션(순수함수)이라는 이벤트를 사용한다.
  • flux패턴을 통해 단방향 흐름으로 예측가능한 방식으로 업데이트 하기 위함이다.
  • 미들웨어를 추가해서 전역상태를 위한 통신을 추가할 수 있다.
  • 전역상태를 관리하는 라이브러리 답게 Redux를 사용할 경우 State의 변경이 일어나면 State를 사용하고 있는 컴포넌트에서만 리렌더링이 발생한다.

하지만

  • 라이브러리를 설치해야 한다.
  • 초기 세팅이 번거롭다. (+ 보일러 plate가 필요하다. )

저는 Context API를 리액트 빌트인 상태 관리 라이브러리라고 알고 있었습니다. 현실은?

Context API는 근본적으로 props drilling을 해결하기 위한 솔루션이다.

컨텍스트 안에 포함된 모든 레벨에서 명시적으로 prop를 전달하지 않고, 어디서든 상태 값에 접근 할 수 있는 방법을 제공한다.

개념적으로는 종속성 주입의 한 형태이다. 자식 컴포넌트에 특정한 상태값이 필요하다는 것은 알고 있지만 값 자체를 생성하거나 설정하려 하지 않는다.

대신 상위 요소가 런타임에 해당 값을 전달한다고 가정한다.

상태 관리 도구로서 Context API를 사용한다면 다음과 같은 장점이 있다.

  • Built-in
  • 러닝커브가 낮다.

하지만 Context API를 사용할 경우 Provider를 선언한 부모 컴포넌트에서부터 useContext를 사용한

모든 자식 컴포넌트가 리렌더링이 발생한다. ⇒ 따라서 복잡한 전역 상태를 관리할 때 사용하는것은 옳지 않다.

Context API는 종속성 주입의 한 형태일 뿐 아무것도 관리 하지 않는다?

  • Context API 는 단지 종속성 주입의 한 형태일뿐 아무것도 관리하지 않는다. 상태관리는 일반적으로 useStateuseReducer 를 통해 일어난다.
  • state를 선언해서 Provider를 사용하는 모든 컴포넌트에 State를 공용으로 사용한다는 뜻이다.

Context가 ‘상태관리’가 아닌이유?

상태관리는 시간이 지남에 따라 상태가 변경되는 방식을 의미한다.

아래와 같은 경우를 상태관리라 한다.

  • 초기 값을 저장한다.
  • 현재 값을 읽을 수 있다.
  • 값 업데이트가 가능하다.

React useStateuseReducer가 상태관리의 좋은 예이다.

Context 와 useReducer

  • useReducer 의 경우 새로운 상태 값을 생성 할 때 해당 Context 내부에 포함된 컴포넌트들이 상태값의 일부에만 관심이 있더라도 강제로 re-render 되기 때문에 성능 문제가 발생 할 수 있다. React-Redux 를 사용하면 저장소 상태의 특정 부분만 사용하고 해당 값이 변경 될 때만 re-render 할 수 있다.
  • Context API + useReducer 는 낮은 규모와 빈도의 업데이트와 같은 정적인 상태의 전달에는 괜찮지만, Flux 와 유사한 상태 전파의 대체물로는 부족하다.

해결하려는 문제에 가장 적합한 도구를 선택해라

  • 단순 prop-drilling을 피하는 것이 목적이라면 Context 를 사용해라
  • 적당히 복잡한 컴포넌트가 있거나 외부 라이브러리를 사용하고 싶지 않다면 Context + useReducer 를 사용해라
  • 특정 구성 요소만 re-render 시키거나, 사이드 이펙트를 줄이기 위해 더 강력한 기능이 필요하다면 Redux 를 사용해라

그래서 문제점이 뭔데?

현재 외주 프로젝트에서 context를 통해 전역 상태 관리를 하고 있었는데 알고보니 의존성 주입을 위한 도구였고, 새로 로그인 상태를 관리하는 라이브러리를 추가해야겠다고 인식했다.

하지만.. redux를 쓰기엔 너무 로직이 하찮고! 적다! 그래서!

Recoil 이란?

compatibility and simplicity

https://recoiljs.org/docs/introduction/motivation

편리함과 간단함을 모토로 내세우는 Recoil은 상태괸리 라이브러리이다. 이 라이브러리가 왜 나왔는지

저의 비참한 영어 번역 실력?으로 해석해보겠습니다.

(한국어 버전이있습니다! 빨리 알아서 다행 )

Built in Context의 문제점

  • 호환성과 단순성 때문에 리액트 내부의 상태 관리 기능을 사용하는것이 가장 좋습니다. 하지만 React에는 다음과 같은 문제점이 있습니다.
    • 컴포넌트의 상태를 Common ancestor로 올려야 공유할 수 있습니다.
    • Context는 Single Value만 저장 할 수 있습니다. ⇒ 각각 Consumer의 무한한 값을 저장할 수 없다.
  • 이 두가지 특성이 트리의 꼭대기에서 Provide 해야하기 때문에 Code Spliting에 어려움을 겪는다.

그러면 이 문제를 Recoil에서는 어떻게 해결하고 있을까요?

우리는 API와 의미 및 동작을 가능한 React답게 유지하면서 이것을 개선하고자 한다.

Recoil은 직교(orthogonal)하지만, 본질적인 방향 그래프를 정의하고 React 트리에 붙인다.

상태 변화는 이 그래프의 뿌리(atoms)로부터 순수함수(selectors)를 거쳐 컴포넌트로 흐르며, 다음과 같은 접근 방식을 따른다.

 최대한 React 스럽게 즉 FLUX 패턴으로 이어지는 단방향 패턴을 사용하는듯 합니다.

다 배우고 나니 그런건 아니였고, useState처럼 사용할 수 있다는 장점이였다.

 

  • 우리는 공유상태(shared state)도 React의 내부상태(local state)처럼 간단한 get/set 인터페이스로 사용할 수 있도록 boilerplate-free API를 제공한다.
    • (필요한 경우 reducers 등으로 캡슐화할 수도 있다)
  • 우리는 동시성 모드(Concurrent Mode)를 비롯한 다른 새로운 React의 기능들과의 호환 가능성도 갖는다.
  • ⚠️ chat gpt의 답변 : React의 동시성 모드(Concurrent Mode)는 React 18부터 도입된 새로운 기능입니다. 동시성 모드는 애플리케이션의 성능을 향상시키고, 사용자 경험을 개선하기 위해 React의 렌더링 동작을 최적화하는 방식입니다. ⇒ 즉 react와 호환이 잘 된다!
  • 상태 정의는 점진적이고(incremental) 분산되어 있기 때문에, 코드 분할이 가능하다.
  • 상태를 사용하는 컴포넌트를 수정하지 않고도 상태를 파생된 데이터로 대체할 수 있다.
  • 파생된 데이터를 사용하는 컴포넌트를 수정하지 않고도 파생된 데이터는 동기식과 비동기식 간에 이동할 수 있다.
  • 우리는 네비게이션을 일급객체로 취급할 수 있고 심지어 링크에서 상태 전환을 인코딩할 수도 있다.
  • 전체 애플리케이션 상태를 하위 호환되는 방식으로 유지하기가 쉬우므로, 유지된 상태는 애플리케이션 변경에도 살아남을 수 있다.

정리하면, 리액트 스럽게 전역 상태관리를 하면서 상태관리에 관한 API를 제공하고 호환성도 뛰어나답니다. 그럼 핵심 개념을 통해서 더 자세하게 알아보겠습니다.

Atoms

  • Atoms는 state의 단위이며 업데이트와 구독이 가능합니다.
  • Atoms가 업데이트되면 구독된 컴포넌트는 새로운 값을 반영하여 다시 렌더링 됩니다.
  • Atoms는 런타임에서 생성될 수도 있습니다.
  • Atoms는 React의 로컬 컴포넌트의 state 대신 사용할 수 있습니다. 대신 동일한 Atom을 여러 컴포넌트에서 구독하면 모든 컴포넌트는 state를 공유합니다.

Atoms는 atom함수를 사용해 생성합니다.

const fontSizeState = atom({
  key: 'fontSizeState',
  default: 14,
});
  • Atoms는 고유한 키가 필요합니다. 키는 디버깅, 지속성 및 모든 atoms의 map을 볼 수 있는 특정 고급 API에 사용됩니다.
  • 두 개의 atom이 같은 키를 갖는 것은 오류이기 때문에 키값은 전역적으로 고유하도록 해야합니다.
  • React 컴포넌트의 상태처럼 기본값도 가진다.

컴포넌트에서 Atom을 구독하기 (읽고 쓰기)

컴포넌트에서 atom을 읽고 쓰려면 useRecoilState라는 훅을 사용합니다.

React의 useState와 비슷하지만, 상태가 컴포넌트 간에 공유될 수 있다는 차이가 있습니다.

function FontButton() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);
  return (
    <button onClick={() => setFontSize((size) => size + 1)} style={{fontSize}}>
      Click to Enlarge
    </button>
  );
}

버튼을 클릭하면 버튼의 글꼴 크기가 1만큼 증가하며, fontSizeState atom을 사용하는 다른 컴포넌트의 글꼴 크기도 같이 변화합니다.

Selector

  • Selector는 atoms나 다른 selector를 입력으로 받아들이는 순수함수 입니다.
  • 상위의 atoms 또는 selectors가 업데이트되면 하위의 selector 함수도 다시 실행됩니다.
  • 컴포넌트들은 selectors를 atoms처럼 구독할 수 있으며 selectors가 변경되면 컴포넌트들도 다시 렌더링 됩니다.

seletor는 상태를 기반으로 하는 파생 데이터를 계산하는 데 사용됩니다.

말이 좀 어색한데 state를 이용해서 새로운 데이터를 만들어내는데 이용된다고 보시면 됩니다.

  • 최소한의 상태 집합만 atoms에 저장하고 다른 모든 파생되는 데이터는 selecetors에 명시한 함수를 통해 효율적으로 계산함으로써 쓸모없는 상태의 보존을 방지합니다.
  • Selectors는 어떤 컴포넌트가 자신을 필요로 하는지, 또 자신은 어떤 상태에 의존하는지를 추적하기 때문에 이러한 함수적인 접근방식을 매우 효율적으로 만듭니다.
  • 컴포넌트의 관점에서 보면 selectors와 atoms는 동일한 인터페이스를 가지므로 서로 대체할 수 있습니다.
  • Selectors는 selector함수를 사용해 정의합니다.
const fontSizeLabelState = selector({
  key: 'fontSizeLabelState',
  get: ({get}) => {
    const fontSize = get(fontSizeState);
    const unit = 'px';

    return `${fontSize}${unit}`;
  },
});

get 속성은 계산될 함수입니다. 전달되는 get 인자를 통해 atoms와 다른 selectors에 접근할 수 있습니다.

다른 atomsselectors에 접근하면 자동으로 종속 관계가 생성되므로, 참조했던 다른 atomsselectors가 업데이트 되면 이 함수도 다시 실행됩니다.

fontSizeLabelState 예시에서 selector는 fontSizeState라는 하나의 atom에 의존성을 갖습니다.

→ fontSizeState에 따라 변화하게 된다는 뜻.

개념적으로 fontSizeLabelState selector는 fontSizeState를 입력으로 사용하고 형식화된 글꼴 크기 레이블을 출력으로 반환하는 순수 함수처럼 동작합니다.

Selectors는 useRecoilValue()를 사용해 읽을 수 있습니다.

useRecoilValue()는 하나의 atom이나 selector를 인자로 받아 대응하는 값을 반환합니다. fontSizeLabelState selector는 writable하지 않기 때문에 useRecoilState()를 이용하지 않습니다.

(writable한 selectors에 대한 더 많은 정보는 selector API reference에 자세히 기술되어 있다.)

function FontButton() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);
  const fontSizeLabel = useRecoilValue(fontSizeLabelState);

  return (
    <>
      <div>Current font size: ${fontSizeLabel}</div>

      <button onClick={setFontSize(fontSize + 1)} style={{fontSize}}>
        Click to Enlarge
      </button>
    </>
  );
}

실습하면서 사용해보기

말로만 들어서는 절 대 한번에 알아 먹지 못하겠습니다. 직접 Todo App을 만들어보면서 실습해보겠습니다.

vite+ react.js기반의 프로젝트를 하나 생성하겠습니다.

yarn create vite

recoil을 설치 하겠습니다.

npm i recoil

또는

yarn add recoil

Atoms를 먼저 작성해보겠습니다.

📦atom
 ┗ 📜todoListState.js
import { atom } from "recoil";

export const todoListState = atom({
  ket: "todoListState",
  default: [],
});

atom에 고유한 key를 주었고, 기본값으로 빈 배열을 주었습니다. 이 atom을 읽기 위해 우리는 useRecoilValue()훅을 사용할 수 있습니다.

export function TodoList() {
  const todoList = useRecoilValue(todoListState);

  return (
    <>
      {/* <TodoListStats /> */}
      {/* <TodoListFilters /> */}
      <TodoItemCreator />

      {todoList.map((todoItem) => (
        <TodoItem key={todoItem.id} item={todoItem} />
      ))}
    </>
  );
}

const todoList = useRecoilValue(todoListState); 에서 todoList를 가져오게 됩니다.

이 todoList를 setter하기 위해서 내용을 업데이트하는 setter 함수에 접근해야 합니다.

setter 함수를 얻기 위해 useSetRecoilState() 훅을 사용할 수 있다.

...
import { useSetRecoilState } from "recoil";

export function TodoItemCreator() {
  const [inputValue, setInputValue] = useState("");
  const setTodoList = useSetRecoilState(todoListState);

  const addItem = () => {
    setTodoList((oldTodoList) => [
      ...oldTodoList,
      {
        id: getId(),
        text: inputValue,
        isComplete: false,
      },
    ]);
    setInputValue("");
  };

  const onChange = ({ target: { value } }) => {
    setInputValue(value);
  };

  return (
    <div>
      <input type="text" value={inputValue} onChange={onChange} />
      <button onClick={addItem}>Add</button>
    </div>
  );
}
  • const setTodoList = useSetRecoilState(todoListState); 를 통해서 setter 함수를 가져옵니다. updater 형식을 사용한다는 점에 유의해야 합니다.

TodoItem 컴포넌트는 todo 리스트의 값을 표시하는 동시에 텍스트를 변경하고 항목을 삭제할 수 있습니다.

우리는 todoListState를 읽고 항목 텍스트를 업데이트하고, 완료된 것으로 표시하고, 삭제하는 데 사용하는 setter 함수를 얻기 위해 useRecoilState()를 사용한다.

function TodoItem({ item }) {
  const [todoList, setTodoList] = useRecoilState(todoListState);
  const index = todoList.findIndex((listItem) => listItem === item);

  const editItemText = ({ target: { value } }) => {
        ...
  };

  const toggleItemCompletion = () => {
        ...
  };

  const deleteItem = () => {
        ...
  };

  return (
    <div>
      <input type="text" value={item.text} onChange={editItemText} />
      <input
        type="checkbox"
        checked={item.isComplete}
        onChange={toggleItemCompletion}
      />
      <button onClick={deleteItem}>X</button>
    </div>
  );
}
  • 지금 까지 알아본 결과로써 매우 특이한 점이 있는데 그냥 react 훅인 useState처럼 사용할 수 있다는것입니다.
  • const [todoList, setTodoList] = useRecoilState(todoListState);매우 간단하게 우리에게 익숙한 방식으로 사용하면 될것같아요.

그럼 한번 실행을…

나를 반기는 애러들

 

어라라..? 이게 무슨일이야 ! 공식문서 예제인데!!

알고보니 RecoilRoot로 최상단 컴포넌트를 감싸줘야 한다는것입니다.

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
import { RecoilRoot } from "recoil";

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <RecoilRoot>
      <App />
    </RecoilRoot>
  </React.StrictMode>
);

잘 감싸주었습니다.. 한번 실행 해보면!

간단한 투두리스트

그렇다면 Selector는 언제 사용하는가?

  • Selector는 파생된 상태(derived state)의 일부를 나타냅니다.
  • 파생된 상태를 어떤 방법으로든 주어진 상태를 수정하는 순수 함수에 전달된 상태의 결과물로 생각할 수 있습니다.

파생된 상태는 다른 데이터에 의존하는 동적인 데이터를 만들 수 있기 때문에 강력한 개념입니다.

우리의 todo 리스트 애플리케이션 맥락에서는 다음과 같은 것들이 파생된 상태로 간주될 수 있겠습니다.

  • 필터링 된 todo 리스트 : 전체 todo 리스트에서 일부 기준에 따라 특정 항목이 필터링 된 새 리스트(예: 이미 완료된 항목 필터링)를 생성되어 파생된다.
  • Todo 리스트 통계 : 전체 todo 리스트에서 목록의 총 항목 수, 완료된 항목 수, 완료된 항목의 백분율 같은 리스트의 유용한 속성들을 계산하여 파생된다.

Atom의 state 가지고 이리저리 사용할 수 있는 동작들을 selector!로 구현하면 되는거군요 선생님!

함수인데 Atom의 state를 가지고 파생된 state를 가져올 수 있는 기능들을 뜻하는 것 같습니다.

필터링 된 todoList 구하기

  1. atom에 필터 기준 상태 선언
  2. 필터 기준 상태에 따른 TodoList selector 구현

필터링 된 todolist를 구현하기 위해서 우리는 atom에 저장될 수 있는 필터 기준을 선택해야 합니다.우리가 사용하게 될 필터 옵션은 "Show All", "Show Completed"과 "Show Uncompleted"가 있습니다.

기본 값은 "Show All”

const todoListFilterState = atom({
  key: 'todoListFilterState',
  default: 'Show All',
});

todoListFilterStatetodoListState를 사용해서 우리는 필터링 된 리스트를 새로 제작하는 filteredTodoListState selector를 구성할 수 있습니다.

export const filteredTodoListState = selector({
  key: "filteredTodoListState",
  get: ({ get }) => {
    const filter = get(todoListFilterState);
    const list = get(todoListState);

    switch (filter) {
      case "Show Completed":
        return list.filter((item) => item.isComplete);
      case "Show Uncompleted":
        return list.filter((item) => !item.isComplete);
      default:
        return list;
    }
  },
});
  • 여기서 filteredTodoListState는 내부적으로 2개의 의존성을 get을 통해서 가져옵니다.
  • 따라서 todoListFilterStatetodoListState을 추적하고 있습니다.
  • 그래서 둘 중 하나라도 변하면 filteredTodoListState는 재 실행됩니다.

컴포넌트 관점에서 보면 selectoratom을 읽을 때 사용하는 같은 훅을 사용해서 읽을 수 있습니다.

그러나 특정한 훅은 쓰기 가능 상태 (즉, useRecoilState())에서만 작동하는 점을 유의해야 합니다.

  • 모든 atom은 쓰기 가능 상태지만 selector는 일부만 쓰기 가능한 상태(getset 속성을 둘 다 가지고 있는 selector)로 간주됩니다.

필터링 된 todo 리스트를 표시하는 것은 TodoList 컴포넌트에서 한 줄만 변경하면 될것같네요!

export function TodoList() {
  // const todoList = useRecoilValue(todoListState);
  const todoList = useRecoilValue(filteredTodoListState);

  return (
    <>
      {/* <TodoListStats /> */}
      {/* <TodoListFilters /> */}
      <TodoItemCreator />

      {todoList.map((todoItem) => (
        <TodoItem key={todoItem.id} item={todoItem} />
      ))}
    </>
  );
}

기본값인“show all”을 변경하려면 TodoListFilters를 구현해야 합니다.

function TodoListFilters() {
  const [filter, setFilter] = useRecoilState(todoListFilterState);

  const updateFilter = ({target: {value}}) => {
    setFilter(value);
  };

  return (
    <>
      Filter:
      <select value={filter} onChange={updateFilter}>
        <option value="Show All">All</option>
        <option value="Show Completed">Completed</option>
        <option value="Show Uncompleted">Uncompleted</option>
      </select>
    </>
  );
}

몇 줄의 코드로 우리는 필터링 기능을 구현할 수 있습니다.

우리는 TodoListStats 컴포넌트를 구현하기 위해 동일한 개념을 사용할 것이다.

우리는 다음 통계를 표시하려 합니다.

- todo 항목들의 총개수
- 완료된 todo 항목들의 총개수
- 완료되지 않은 todo 항목들의 총개수
- 완료된 항목의 백분율

각 통계에 대해 selector를 만들 수 있지만, 필요한 데이터를 포함하는 객체를 반환하는 selector 하나를 만드는 것이 더 쉬운 방법일 것입니다.

우리는 이 selector를 todoListStatsState라고 선언합니다.

export const todoListStatsState = selector({
  key: "todoListStatsState",
  get: ({ get }) => {
    const todoList = get(todoListState);
    const totalNum = todoList.length;
    const totalCompletedNum = todoList.filter((item) => item.isComplete).length;
    const totalUncompletedNum = totalNum - totalCompletedNum;
    const percentCompleted = totalNum === 0 ? 0 : totalCompletedNum / totalNum;

    return {
      totalNum,
      totalCompletedNum,
      totalUncompletedNum,
      percentCompleted,
    };
  },
});

마찬가지로 selector를 이용해서 통계를 객체로 내보냈습니다. 이것을 useRecoilValue() 로 불러와서 읽어들여 컴포넌트를 구현하면 되겠습니다.

function TodoListStats() {
  const {
    totalNum,
    totalCompletedNum,
    totalUncompletedNum,
    percentCompleted,
  } = useRecoilValue(todoListStatsState);

  const formattedPercentCompleted = Math.round(percentCompleted * 100);

  return (
    <ul>
      <li>Total items: {totalNum}</li>
      <li>Items completed: {totalCompletedNum}</li>
      <li>Items not completed: {totalUncompletedNum}</li>
      <li>Percent completed: {formattedPercentCompleted}</li>
    </ul>
  );
}

마무리

이번 포스팅에서 한 것들

오늘은 Recoil에 대해서 작성하고 실습했는데요. 공식 문서를 통해서 배우는것을 별로 안해봤는데 이번기회를 통해서 많이 부딪혀보고 직접 실습하면서 알게된것 같습니다.

다음 포스팅에는 Recoil을 왜 진짜 사람들이 많이 사용하는지 왜 그렇게 공고에 많이 올라오게 되는지 DEEP DIVE를 해보겠습니다. 감사합니다!!

 

출처

https://olaf-go.medium.com/context-api-vs-redux-e8a53df99b8

https://yilpe93.github.io/react/react-redux-vs-context-api/

https://itchallenger.tistory.com/370

https://recoiljs.org/ko/docs/introduction/core-concepts

https://ko.vitejs.dev/guide/

https://itprogramming119.tistory.com/entry/This-component-must-be-used-inside-a-RecoilRoot-component-해결-방법