본문 바로가기
react

Recoil은 쉬운 라이브러리?(Recoil vs Redux)

by HomieKim 2022. 9. 5.

 상태관리(State Management)는 리액트에서 굉장히 중요한 개념입니다.

리액트는 데이터의 흐름이 단방향이기 때문에 프로젝트의 규모가 조금만 커져도 Props를 전달하기 위해선 하위 컴포넌트로 내려줘야하고, 불필요한 Props drilling이 발생하기 때문에 전역적으로 상태를 관리할 수 있는 라이브러리를 사용합니다.

그 중 가장 대표적인 Redux를 공부하고 최근에는 redux-toolkit을 이용해서 프로젝트를 진행하고 있습니다.

Client State 와 Server State?

 프로젝트를 진행하며 프론트엔드 내에서 상태관리가 필요한 데이터 들이 대부분 api요청을 통해 받아오는 데이터 이기 때문에 api 캐싱과 로딩, 업데이트 등 서버와 관련된 상태 관리 기능을 제공하는 라이브러리(react-query, SWR 등)를 사용하기로 결정했습니다. 

 다만 이렇게 server-state를 분리하다 보니  client-side 에서 전역적으로 관리할만한 데이터는 볼륨이 크지 않았고 많은 보일러 플레이트 코드가 필요한 redux가 과연 필요한가 오버엔지니어링은 아닌가 의문이 들었습니다. 이를 해결하기 위해 찾아 보던 도중 문법이 더 간소하고 state의 update가 간단한 recoil이 조금 더 적합한 라이브러리가 아닐까 생각하여 공부하게 되었습니다.


✔ Redux vs Recoil

  recoil이 주목 받는건 단순히 redux 보다 간단해서? 코드양이 적어서? 라고 단정짓기는 어려울 것 같다. 왜 recoil을 써야하는 지 알기 위해선 두 라이브러리의 차이를 알고 있는게 좋다. 가장 큰 차이 점으로는 두 라이브러리가 갖는 아키텍처 구조가 다르다는 점이다

- flux 구조

redux는 기본적으로 flux 구조를 가집니다.

flux 구조의 요소에 대해서는 Redux에 관해 정리한 글에서 볼 수 있습니다.

2022.03.27 - [react] - [React 상태관리]리덕스 이해하기 + todoList 만들기

가장 중요한 특징은 사진에서 볼 수 있다 시피 단방향 데이터 바인딩 이라는 점 입니다. reducer를 정의해 놓고 컴포넌트 단에서 dispatch를 통해 store에 저장되어 있는 상태를 변경 시킬 수 있습니다.

recoil과의 차이점이라고 한다면, store라는 중앙 저장소를 두고 전역에서 공유한다는 점입니다. 

- Atomic 구조

recoil이 가지고 있는 기본 컨셉이 Atoms입니다.

사진과 같이 redux 처럼 하나의 store를 공유하는 것이 아닌 각각의 Atoms를 컴포넌트에서 구독하는 형태입니다.

recoil의 핵심 개념은 Atoms 과 Selector 두 가지 입니다. 공식문서에서는 recoil 이렇게 소개하고 있습니다.

Recoil을 사용하면 atoms (공유 상태)에서 selectors (순수 함수)를 거쳐 React 컴포넌트로 내려가는 data-flow graph를 만들 수 있다. Atoms는 컴포넌트가 구독할 수 있는 상태의 단위다. Selectors는 atoms 상태값을 동기 또는 비동기 방식을 통해 변환한다.

공식문서가 한글로 되어있어 편했지만, 영문을 번역해 놓은거라 그런지 문장만 가지고 이해하기가 어렵습니다.

직접 간단한 todo-list를 만들어보며 코드를 통해 이해하면 빠르게 습득할 수 있습니다. 


 

코드 설명을 위해 만든 todo-list

✔ Atoms 이해하기

App.tsx

import React from 'react';
import AllDoneButton from './components/done-button';
import FilteredTodoList from './components/filter-list';
import TodoFilter from './components/todo-filter';
import TodoInput from './components/todo-input';
import TodoList from './components/todo-list';

function App() {
  return (
    <main>
      <TodoInput />
      <TodoList />
      <AllDoneButton />
      <hr />
      <TodoFilter />
      <FilteredTodoList/>
    </main>
  );
}

export default App;

먼저 기본적인 todo리스트를 만들 atoms를 정의 해야 합니다. 

export const todoAtom = atom<Array<todoType>>({
  key: "todolist",
  default: [],
});

 

 확실히 스토어 생성하고 rootReducer 만들고 initialState 정의한다음.. 액션 type 정의하고.. 리덕스 보다 훨신 간단합니다. 간단하게 설명하면 이제 todoAtom이라는 이름의 Atom을 생성한 것이고 이 Atom은 default값으로 지금 빈 배열을 들고 있습니다. 이 상태를 컴포넌트 내부에서 간단하게 값을 가져오고 업데이트 할 수 있습니다.

 

todo-input.tsx

import React, { ChangeEvent, useState } from "react";
import { todoAtoms } from "../../recoil/atoms";
import { useRecoilState } from "recoil";
const TodoInput = () => {
  const [todoList, setTodoList] = useRecoilState(todoAtoms);
  const [todoInput, setTodoInput] = useState<string>("");

  const onChangeTodoInput = (e: ChangeEvent<HTMLInputElement>) => {
    setTodoInput(e.target.value);
  };

  const onClickHnadler = () => {
    const newId = todoList.length !== 0 ? todoList.length + 1 : 0;
    setTodoList((prev) => [
      ...prev,
      {
        id: newId,
        contents: todoInput,
        isDone: false,
      },
    ]);
    setTodoInput('');
  };
  
  return (
    <section>
      <input value={todoInput} onChange={onChangeTodoInput} />
      <button onClick={onClickHnadler}>등록</button>
    </section>
  );
};

export default TodoInput;

리액트에서 제공하는 hooks와 기본적인 인터페이스와 동일하게 useRecoilState('내가 정의한 Atom') 이렇게 사용할 수 있고 value와 setter를 각각 따로(useRecoilValue, useSetRecoilState) 가져 올 수 도 있습니다.

 // todo-item.tsx 삭제 버튼 누를 때 로직
 const setTodoList = useSetRecoilState(todoAtoms);

  const onDeleteHandler = () => {
    setTodoList((prev) => prev.filter(v => v.id !== item.id));
  };
  
  // todo-list.tsx 현재 todoAtoms의 값만 가져옴
  const todoList = useRecoilValue(todoAtoms);
  return <ul>{
      todoList.map((v) => (
        <TodoItem key={v.id} item={v} />
      ))
    }</ul>;
};

리액트에서 기본적으로 제공하고있는 useState hook과 사용법이 거의 동일하기 때문에 정말 배우기 쉬웠습니다.

 여기 까지 공부를 했을 때 들었던 생각은 정말 간단하고 쉽다! 그런데 좋은건가? 라는 의문이 들었습니다.

상태 값을 가져오고 수정하는게 쉽지만 지금 코드상에서는 todo를 추가, 삭제, isDone을 toggle 하는 로직이 전부 컴포넌트 안에 들어있습니다.

 프로젝트가 조금만 복잡해져도 로직이 길어지는데 컴포넌트안에 전부 쓰는 건 굉장히 비효율적이고 에러가 났을 때 관리하기 복잡합니다. (나중에 공부하면서 알았는데 이런 복잡한 로직을 selector를 이용해 해결합니다. 이는 글 후반부에서 다루겠습니다.) 게다가, immer 도 기본적으로 내장하고 있는 redux-toolkit을 쓰는게 개발하는 측면에서 훨신 직관적인 것 같다고 생각이 들었습니다. 물론 immer는 원한다면 붙이면 되는 것이고, toolkit에서 Slice를 생성하는 것 처럼 로직들을 한 파일에서 관리할 수 있는 방법을 찾아 보았습니다.

 

todo-hooks.ts

import { todoAtom } from "./../recoil/atoms";
import { SetterOrUpdater, useRecoilState } from "recoil";
import  { useCallback } from "react";
import { todoType } from "../typings/todo";

interface ReturnType {
  todoList: Array<todoType>;
  setTodoList: SetterOrUpdater<Array<todoType>>;
  AddTodo: (todo: todoType) => void;
  DeleteTodo: (id:number) => void;
  ToggleDone:(id:number) => void;
}

const useTodo = (): ReturnType => {
  const [todoList, setTodoList] = useRecoilState(todoAtom);

  const AddTodo = useCallback((todo: todoType) => {
    setTodoList((prev) => [...prev, todo]);
  },[setTodoList]);

  const DeleteTodo = useCallback((id:number) => {
    setTodoList((prev) => prev.filter(v => v.id !== id));
  },[setTodoList]);
  
  const ToggleDone = useCallback((id:number) =>{
    setTodoList((prev) => prev.map(v => v.id === id ? {...v, isDone: !v.isDone} : v))
  },[setTodoList]);

  return {
    todoList,
    setTodoList,
    AddTodo,
    DeleteTodo,
    ToggleDone,
  };
};

export default useTodo;

 좋은 방법인진 모르겠으나 수많은 삽질 후 recoil에서 제공하는 hooks 들이 컴포넌트 바깥에 다른 파일에서 사용될 시 당연히 동작하지 않았고 결국 custom-hook을 사용해서 useRecoilState를 한번 감싸는? 형태로 사용해 보았습니다.

// custom hook 사용
// todo-item.tsx
const TodoItem = ({ item }: { item: todoType }) => {
  const {DeleteTodo, ToggleDone} = useTodo();

  const onDeleteHandler = () => {
    DeleteTodo(item.id);
  };
  const onChangeHandler = () => {
    ToggleDone(item.id);
  }
  return (
    <li>
      <input type="checkbox" checked={item.isDone} onChange={onChangeHandler} />
      <span>{item.contents}</span>
      <button onClick={onDeleteHandler}>삭제</button>
    </li>
  );
};

// todo-input.tsx 에서 tood 추가하는 이벤트 핸들러
  const {todoList, AddTodo} = useTodo();
  const onClickHnadler = () => {
    const newId = todoList.length !== 0 ? todoList.length + 1 : 0;
    AddTodo({
      id:newId,
      contents:todoInput,
      isDone:false,
    })
    setTodoInput("");
  };

✔ Selector 이해하기

비교적 간단한 atom과 달리 Selector가 개인적으로 조금 어려웠습니다.

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

파생된 상태라는 말이 저에겐 조금 추상적으로 다가와서 이해하기가 어려웠습니다.

파생된 상태? (derived state)

 selector를 이해하기 위해선 이 파생된 상태라는 개념을 이해해야 하는데, redux에 익숙하다 보니 redux에서 제공하는 useSelector와 유사한 것 으로 처음에 받아들여 헷갈렸던 것 같습니다. redux의 useSelector와는 별개의 개념으로 이해하셔 야합니다.

 파생된 상태란 우리가 정의한 atom를 가져와서 특정한 state 형태를 return 하고 싶을 때 사용하는 것으로 이해하였습니다. 예를 들어 특정한 select에 따라 데이터를 필터링 하고 싶다면 (전체 게시물 중 선택된 유저의 게시물만 보여준다 거나 하는 상황) 컴포넌트에서 filtering 하는 하는 로직을 작성해야 합니다. 그렇다고 전체 유저 리스트가 담겨있는 atom을 바꾸기도 힘들고 selectedId에 따라 다르게 보여줘야 하니 selectedId 값은 상태로 관리해야 겠죠, 이런 경우 atom에 있는 값을 가져와 selector로 변형 후 return이 가능 합니다. 의사 코드로 작성해 보면

const AllPostList = atom({
  key: "AllPostList",
  default: [],
});

const SelectedId = atom({
  key: "SelectedId",
  default: 0,
});

const FilteringPostSelector = selector({
  key: "FilteringPostSelector",
  get: ({ get }) => {
    const selectedId = get(SelectedId);
    const allList = get(AllPostList);
    return allList.filter((v) => v.id === selectedId);
  },
});

이런식으로 사용 가능 합니다. get 함수 내부에서 사용하고 있는 atom이 변경 될때마다 selector의 데이터도 변경됩니다. (구독의 개념)

 

Selector의 Set? 쓰기 가능한 상태!

여기까지 selector개념을 어느정도 이해했다고 생각했을 때 멘붕에 빠지게 한게 selector의 set 기능입니다. 정리하자면

- Selector : atom 가져와서 내가 원하는 로직을 적용 후 리턴

이렇게 설명할 수 있습니다. 그러면 여기서 set이 하는 기능은 무엇인지? 공식문서를 보면 selector의 get은 필수 요소 지만 set은 필수요소가 아닙니다.

recoil을 처음 공부하는 단계라 아직 set기능은 아직도 좀 알쏭달쏭합니다. 제가 이해한 것 대로 설명해 본다면

  • set이 설정되면 쓰기 가능한 상태를 반환(setState 같은 함수) 데이터가 단방향인 리덕스와 가장 큰 차이점 ,자기 자신인 selector를 수정하는게 아니라 특정 atom값을 set 할 수 있다. 이때 newValue가 T | DefaultValue 값을 같는데 제네릭  T에서 알수 있듯 get에서 반환하는 값과 타입이 일치해야함 

DefaultValue는 Reset과 set을 구분하기 위해 사용되는 듯 하다.

역시 헷갈릴 땐 코드를 써보는게 가장 빠른 방법이다. selector를 이해하기 위해 조금 억지로? 맞춘감이 있지만 isDone의 상태에 따라 todo-list를 필터링 하는 selector를 만들어 보고 set을 통해 모든 todo item을 isDone으로 만들 수 있도록 설정해 보았다.

import { atom, selector } from "recoil";
import { todoType } from "../typings/todo";

export const todoAtom = atom<Array<todoType>>({
  key: "todolist",
  default: [],
});

export const statusAtom = atom<string>({
  key: "status",
  default: "All",
});

export const filterSelector = selector({
  key: "fliterTodoList",
  get: ({ get }) => {
    const status = get(statusAtom);
    const todolist = get(todoAtom);
    if (status === "All") {
      return todolist;
    } else if (status === "Done") {
      return todolist.filter((v) => v.isDone === true);
    } else {
      return todolist.filter((v) => v.isDone === false);
    }
  },
  set:({get, set})=>{
    const doneList = get(todoAtom).map(v => v.isDone ? v : {...v, isDone :true});
    set(todoAtom, doneList);
  }
});

selector를 사용하는 방법은

// fliter button
const TodoFilter = () => {
  const [todoStatus ,setTodoStatus] = useRecoilState(statusAtom);

  const clickHandler = (e:SyntheticEvent) =>{
    setTodoStatus((e.target as HTMLDivElement).innerHTML);
  }

  return (
    <div className='filter' onClick={clickHandler}>
      <span className={ todoStatus === 'All' ?'select':''}>All</span>
      <span className={ todoStatus === 'Done' ?'select':''}>Done</span>
      <span className={ todoStatus === 'Doing' ?'select':''}>Doing</span>
    </div>
  )
}

// fliter-list
const FilteredTodoList = () => {
  const filteredList = useRecoilValue(filterSelector);
  return (
    <ul>
    {
      filteredList.map(v => <TodoItem key={v.id} item={v} />)
    }
    </ul>
  )
}

// 모든 todo를 isDone으로 set
const AllDoneButton = () => {
  const  setFilter = useSetRecoilState(filterSelector);
 
  return <button  onClick={()=>setFilter([])}>전체 todo Done</button>
}

export default AllDoneButton;

 사실 set하는 부분은 filterSelector의 리턴 타입을 맞춰주지 않으면 에러가 나서 억지로 []빈배열을 넣었고 기능을 잘 활용한 건지는 의문이긴 하다, 그래도 selector의 set 기능을 써보는 거에 의의를 두고.. 지금 진행 중인 프로젝트를 redux-toolkit에서 recoil로 전환 해보고 또 알게된게 있다면 글을 써 보아야 겠다.

 


✔ Recoil을 써본 후기?

  • 확실히 recoil이 인기가 많은 이유가 있는 것 같다. 그런데 다른 블로그나 영상들을 보면 간단하다, 쉽다 이런 말이 많던데 쉬운지는 잘 모르겠다.
  •  atom, selector 요정도만 활용할 것이라면 redux 보다 좋은 것 같다. 쉽다, 간단하다 이런 말 하는게 useState처럼 인터페이스가 제공되는게 가장 큰 이유 같다. 
  • selector를 이용한 비동기 처리나 effect 핸들링, atom를 배열 처럼? 파라미터를 줘서 개별적으로 다루는 atomFamily 등 사용을 못해봤지만, 숙련도가 올라간다면 개발할 때 정말 편할 것 같다
  • 근데 공부하면 공부할 수록 프로젝트 규모가 크다면 제대로 활용하기 위해 많은 고민이 필요할 것 같다. atom이라는 redux 보다 작은 단위로 state를 정의하고 selector로 이를 핸들링 하는 느낌인데 오히려 redux는 데이터 흐름따라 직관적이고 디버깅도 devtools가 잘되어있어서 프로젝트의 규모나 특성에 맞춰서 선택하는게 중요할 것 같다.

todo list 코드

https://github.com/HomieKim/React_PlayGround/tree/main/todolist-using-recoil

 

댓글