- 개발을 해보며 사용해본 상태 관리 라이브러리가 어느덧 꽤 늘었다.
- 이제껏 써본 상태 관리 라이브러리는 Redux Toolkit/Recoil/Zustand 의 3가지 였는데, 여기에 많이 들어본 Jotai 까지 총 4가지의 라이브러리를 사용해보고 어떤 차이점이 보이는지 한 눈으로 확인해보기 위해 간단한 투두 화면을 만들었다.
공통 조건
위에서 언급한 대로 간단한 Todo List 화면을 만든다고 가정하고, 이 때 필요한 상태들을 위 4개의 라이브러리로 구현한다. 아래의 항목들을 구현했다(Recoil의 공식 문서 튜토리얼을 참고했다).
interface TodoItem {
id: string;
title: string;
description: string;
isDone: boolean;
date: Date;
}
items: TodoItem[]
addItem: (item: TodoItem) => void;
removeItem: (id: string) => void;
toggleItem: (id: string) => void;
todoIdx: number; // 투두 아이템의 고유 아이디를 부여하기 위함
increaseIdx: () => void;
각 라이브러리 별로 Todo로직을 시험할 수 있게끔 간단히 할 일을 추가/삭제하고 수행 여부를 표시할 수 있도록 컴포넌트를 만들었다.
Redux Toolkit
Redux 로직을 보다 간편하게 사용하기 위해 만들어진 패키지. Redux의 문제(ex. 복잡하고 많은 보일러 플레이트 등)를 해결할 수 있는 만큼 Redux 공식 문서에서도 사용을 권장하고 있다.
Redux 의 특징이라면 Flux 패턴에 기반한 상태 관리를 지원한다는 것이다.
Flux 패턴: MVC 패턴의 문제점이었던, 양방향 데이터 바인딩에 의한 복잡한 상태 관리를 해결하기 위해 페이스북이 제시한 상태 관리 패턴이다. 데이터의 흐름을 단방향으로 변화시킴으로써 흐름 파악을 더욱 쉽게 하고자 하는 의도로 만들어졌다. 자세한 사항은 나중에 별도의 게시글로 정리해보고자 한다.
Redux Tookit을 사용해 상태를 추가하기 위해서는 아래와 같은 과정을 거친다.
- Store를 만든다.
- Store란, 간단히 '어플리케이션 전체의 상태를 저장하는 객체'를 의미한다고.
- 이 Store를 바꾸기 위해서는 Action을 Dispatch해야 한다.
- Slice를 생성한다.
- Redux Toolkit에서 Slice는 상태와 상태를 변경하는 함수(Reducer), 각 Slice들을 구별할 수 있는 이름을 포함한다.
- Redux Toolkit에서는 위의 요소들을 createSlice 라는 메서드로 정의한다.
- 이렇게 만든 Store를 Provider에 등록하고, Provider가 어플리케이션을 감싸게 한다.
먼저, createSlice를 통해 Slice를 생성하는 코드는 아래와 같다.
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { TodoItem } from "../../types";
export interface TodoState {
todoItems: TodoItem[],
todoIdx: number,
}
const initialState: TodoState = {
todoItems: [],
todoIdx: 0,
};
export const todoSlice = createSlice({
name: 'todo',
initialState,
reducers: {
addTodoItem: (state, action: PayloadAction<TodoItem>) => {
state.todoItems.push(action.payload);
},
removeTodoItem: (state, action: PayloadAction<string>) => {
const itemIdx = state.todoItems.findIndex(el => el.id === action.payload);
state.todoItems = [...state.todoItems.slice(0, itemIdx), ...state.todoItems.slice(itemIdx + 1)];
},
toggleItem: (state, action: PayloadAction<string>) => {
const itemIdx = state.todoItems.findIndex(el => el.id === action.payload);
const item = state.todoItems[itemIdx];
state.todoItems = [...state.todoItems.slice(0, itemIdx), { ...item, isDone: !item.isDone }, ...state.todoItems.slice(itemIdx + 1)];
},
increaseIdx: (state) => {
state.todoIdx += 1;
}
}
});
export const { addTodoItem, removeTodoItem, toggleItem, increaseIdx } = todoSlice.actions;
export default todoSlice.reducer;
- createSlice 내에 name, initialState, reducers의 세 가지를 포함하는 객체를 전달하여 Slice를 만든다.
- name은 slice를 구분하기 위한 이름이다.
- initialState는 상태의 초기값을 의미한다.
- Reducer는 상태를 변경하는 함수를 의미한다.
- 앞서 Store 내의 상태를 변경하기 위해서 Actions을 Dispatch할 필요가 있다고 언급했는데, 위의 코드를 살펴보면 Action을 따로 생성하는 부분이 존재하지 않는다. 이는 Redux Tookit이 내부적으로 알아서 Reducer에 등록된 함수들을 가져다 createAction이라는 메서드를 사용해 각 함수에 대응하는 Action을 생성하기 때문이다. 이렇게 생성된 Action들은 slice.actions에 포함된다.
Redux를 사용한다면 Action의 타입에 따라 수행할 함수 내용(Reducer)을 직접 지정하는 과정이 필요하지만, Redux Toolkit은 이 과정을 알아서 처리함으로써 편의성을 제공한다.
Slice가 만들어졌다면 Store를 만들고 앞서 만든 slice의 reducer를 연결한다. Store를 만드는 데에는 configureStore 메서드를 사용한다.
import { configureStore } from "@reduxjs/toolkit";
import todoReducer from './todoReducer';
export const store = configureStore({
reducer: {
todo: todoReducer
}
});
export type TodoState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Store까지 완성되었다면 어플리케이션의 최상단에서 Provider 컴포넌트를 통해 Store를 연결한다. 이렇게 함으로써 어플리케이션 어느 곳에서나 상태에 접근하고 상태를 변경하는 것이 가능해진다.
import { Provider } from 'react-redux';
import { store } from './store/redux/todoStore.ts';
/* 중략 */
// Provider를 프로젝트 최상단에 추가한 모습
// ※ RecoilRoot는 다른 라이브러리(Recoil) 것
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<RecoilRoot>
<RouterProvider router={router} />
</RecoilRoot>
</Provider>
</React.StrictMode>,
);
Provider로 Store를 제공하는 것까지 끝났다면, 남은 것은 만든 상태를 가져와서 사용하는 것이다. 상태를 가져올 때는 useSelector, 상태를 변경하는 함수를 사용할 때는 useDispatch 훅을 사용한다.
아래 코드에는 useSelector와 useDispatch 대신 useAppSelector, useAppDispatch 라는 훅을 사용하고 있다. 이는 별개의 훅이 아니라, 타입스크립트에서 위 두 훅을 사용하기 위해 타입을 설정한 커스텀 훅일 뿐이다.
훅을 사용해 할 일 목록 기능을 구현한 코드가 아래와 같다.
import { TodoState } from "../../store/redux/todoStore";
import { useAppDispatch, useAppSelector } from "../../hooks/useRedux";
import { useCallback } from "react";
import { TodoItem } from "../../types";
import { addTodoItem, removeTodoItem, toggleItem, increaseIdx } from "../../store/redux/todoReducer";
export const Page = () => {
const todoIdx = useAppSelector((state: TodoState) => state.todo.todoIdx);
const todoItems = useAppSelector((state: TodoState) => state.todo.todoItems);
const dispatch = useAppDispatch();
const addTodo = useCallback((item: Omit<TodoItem, 'id'>) => {
dispatch(addTodoItem({ id: `i${todoIdx}`, ...item }));
dispatch(increaseIdx());
}, [dispatch, todoIdx]);
const removeTodo = useCallback((id: string) => {
dispatch(removeTodoItem(id));
}, [dispatch]);
const toggle = useCallback((id: string) => {
dispatch(toggleItem(id));
}, [dispatch]);
// 생략
};
- useAppSelector로 상태를 가져오고, useAppDispatch로 상태 변경 함수를 가져와 사용하는 것을 알 수 있다.
Recoil
페이스북에서 개발/유지보수 하고 있는 상태 관리 라이브러리. Recoil의 특징은 원자(Atomic) 단위로 상태를 관리할 수 있다라는 것이다.
'원자 단위로 상태를 관리한다'는 것의 의미는 상태를 가장 작은 단위로 나누어 관리하고, 이를 조합하여 큰 하나의 상태로 만들어 나간다는 의미이다. 그에 맞게 위의 Redux 코드와 비교하면 상당히 이질적인 형식의 코드를 보인다.
Recoil을 사용하기 위해서는 RecoilRoot 컴포넌트를 추가할 필요가 있다. 이 컴포넌트는 Recoil의 훅을 사용할 모든 컴포넌트의 상위에 존재해야 하는데, 그렇기 때문에 일반적으로는 프로젝트 최상단에 넣는다.
공식 문서에 따르면 RecoilRoot 컴포넌트는 복수 존재할 수 있다. 다만 이 경우, 각 RecoilRoot 하위의 컴포넌트들은 서로 독립된 상태를 갖는다고.
import { RecoilRoot } from 'recoil';
/* 중략 */
// RecoilRoot를 프로젝트 최상단에 추가한 모습
// ※ Provider는 다른 라이브러리(Redux Toolkit) 것
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<RecoilRoot>
<RouterProvider router={router} />
</RecoilRoot>
</Provider>
</React.StrictMode>,
);
RecoilRoot를 추가했다면 이제 상태를 새롭게 만들어 어플리케이션 내부에서 사용할 수 있다. Recoil에서 제공하는 '원자 단위'의 상태를 만들기 위해서는 atom이라는 메서드를 사용한다.
import { atom } from "recoil";
import { TodoItem } from "../../types";
export const todoItemsState = atom<TodoItem[]>({
key: 'todoItemsState',
default: [],
});
export const todoIdxState = atom<number>({
key: 'todoIdxState',
default: 1,
})
- atom 메서드를 사용해 todoItemsState라는 상태를 정의했다. 이 때 atom은 다음(AtomOptions) 값을 받는다.
- key: Recoil 내부적으로 상태들을 구분할 때 쓰이는 고유의 key 값이다. 일반적으로는 상태의 이름을 넣어주는 듯.
- default: 해당 상태의 초기값을 나타낸다. 여기서는 빈 배열로 설정해주었다.
이렇게 만든 상태값을 사용하기 위해서는 몇 가지 방법이 있다.
- useRecoilState 훅을 사용해 useState와 비슷한 형태로 상태와 해당 상태를 수정할 수 있는 setter 함수를 가져온다.
- useRecoilValue 훅을 사용해 상태 값만을 가져온다. 이 훅은 상태를 사용할 것이지만 변경은 하지 않을 때 사용할 수 있다.
- useSetRecoilState 훅을 사용해 해당 상태를 수정할 수 있는 setter 함수를 가져온다. 위의 useRecoilValue와는 다르게 값은 사용하지 않고 변경만 할 예정일 때 사용할 수 있다.
위의 훅들을 사용해 투두 리스트 구현에 필요한 기능을 만들면 대강 아래와 같아진다.
import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil";
import { useCallback } from "react";
import { todoIdxState, todoItemsState } from "../../store/recoil/todo";
export const Page = () => {
const todoItems = useRecoilValue(todoItemsState);
const [todoIdx, setTodoIdx] = useRecoilState(todoIdxState);
const setTodoItems = useSetRecoilState(todoItemsState);
const addTodoItem = useCallback((item: Omit<TodoItem, 'id'>) => {
setTodoItems((list) => [...list, { id: `i${todoIdx}`, ...item }]);
setTodoIdx((prev) => prev + 1);
}, [setTodoIdx, setTodoItems, todoIdx]);
const removeTodoItem = (id: string) => {
setTodoItems((list) => {
const itemIdx = list.findIndex(el => el.id === id);
return [...list.slice(0, itemIdx), ...list.slice(itemIdx + 1)];
});
};
const toggleItem = useCallback((id: string) => {
setTodoItems((list) => {
const itemIdx = list.findIndex(el => el.id === id);
const item = list[itemIdx];
return [...list.slice(0, itemIdx), { ...item, isDone: !item.isDone }, ...list.slice(itemIdx + 1)];
});
}, [setTodoItems]);
// 후략
};
- 할 일 목록 배열은 useRecoilValue와 useSetRecoilState로 상태와 setter 함수를 각각 따로 불러와봤다(구현상 useRecoilState 만 써도 충분하지만, 사용할 수 있는 모든 훅을 써보고 싶어 구태여 이렇게 작성해보았다).
- 아이디 값을 관리하기 위한 todoIdx은 useRecoilState로 상태와 setter 함수를 함께 불러왔다.
- 모양과 사용 예를 보면 알 수 있지만, useState와 흡사한 모습을 하고 있음을 알 수 있다.
- 위 세 가지 훅 모두 패러미터로 불러올 상태(앞서 atom 메서드로 만든 상태들)를 받는다.
Jotai
Recoil과 비슷하게, 상태를 원자적으로 접근하는 상태 관리 라이브러리. 사족으로 이름인 'Jotai(조타이)'는 일본어로 '상태'를 나타내는 状態를 영어로 나타낸 것이다. 차별점이 있다면 가벼운 사이즈(공식 문서에 의하면 2KB)를 갖고 있다는 점. Core API에 상태 관리의 핵심 기능만을 포함하고, 상황에 따라 사용할 수 있는 확장 기능들을 별도의 패키지로 분리하여 번들 사이즈를 낮출 수 있었던 것 같다.
처음 사용해본 라이브러리지만 그 원리 자체는 Recoil과 흡사하기도 하고 사용방법이 그렇게 어렵지 않아 금방 따라할 수 있었다. Recoil과의 차이점이라면 사용을 위해 별도의 컴포넌트를 최상단에 추가할 필요가 없다는 것이다. 패키지 설치 후 별도의 세팅 없이 바로 상태를 추가할 수 있으며, 이렇게 추가된 상태는 전역적으로 접근이 가능하다.
Jotai에서 상태를 새롭게 추가하기 위해서는 Recoil과 비슷하게 atom 메서드를 사용한다. 메서드 이름은 같지만 상태 선언 방법엔 약간 차이가 있다.
import { atom } from "jotai";
import { TodoItem } from "../../types";
export const todoItemsState = atom<TodoItem[]>([]);
export const todoIdxState = atom<number>(0);
- atom 메서드를 사용해 추가하려는 상태를 정의한다.
- 이 때 atom 메서드는 패러미터로 상태의 초기값을 받는다.
추가로, 이렇게 만든 상태를 사용해 '파생된 상태(Derived Atom)'를 만들 수 있다. 파생 상태는 이미 만들어진 상태를 사용하여 읽기 전용/쓰기 전용/읽기 및 쓰기가 모두 가능한 상태들을 만들 수 있다. 일례로, 위에서 만든 todoItemsState를 사용해 할 일 목록의 총 개수를 나타내는 읽기 전용 상태를 만들면 아래와 같다.
const totalItemsState = atom<number>((get) => get(todoItemsState).length);
만든 상태를 사용하는 방법 또한 Recoil과 흡사하다. 앞전 Recoil에서 언급한 3개의 훅처럼, Jotai 또한 상태를 사용하기 위한 다양한 훅이 있다.
- useAtom 훅을 사용해 useState처럼 상태 및 상태를 변경할 수 있는 setter 함수를 가져온다.
- useAtomValue 훅을 사용해 상태 만을 가져온다.
- useSetAtom 훅을 사용해 상태를 변경할 수 있는 setter 함수를 가져온다.
훅을 이렇게 용도 별로 분리하는 이유를, Jotai는 불필요한 리렌더링을 막기 위함 이라고 소개한다. 예를 들어 상태는 사용하지 않는데 해당 상태를 변경하는 setter 함수만 필요한 상황에서, 아래와 같이 setter를 가져와 사용하는 것은 상태 변경 시 리렌더링을 발생시킬 수 있기 때문이다.
const [, setSomething] = useState();
위의 훅들로 투두 리스트 구현에 필요한 기능을 만들면 아래와 같다.
import { useAtom } from "jotai";
import { todoIdxState, todoItemsState } from "../../store/jotai/todo";
import { TodoItem } from "../../types";
import { useCallback } from "react";
export const Page = () => {
const [todoIdx, setTodoIdx] = useAtom(todoIdxState);
const [todoItems, setTodoItems] = useAtom(todoItemsState);
const addTodoItem = useCallback((item: Omit<TodoItem, 'id'>) => {
setTodoItems((list) => [...list, { id: `i${todoIdx}`, ...item }]);
setTodoIdx((prev) => prev + 1);
}, [setTodoIdx, setTodoItems, todoIdx]);
const removeTodoItem = (id: string) => {
setTodoItems((list) => {
const itemIdx = list.findIndex(el => el.id === id);
return [...list.slice(0, itemIdx), ...list.slice(itemIdx + 1)];
});
};
const toggleItem = (id: string) => {
setTodoItems((list) => {
const itemIdx = list.findIndex(el => el.id === id);
const item = list[itemIdx];
return [...list.slice(0, itemIdx), { ...item, isDone: !item.isDone }, ...list.slice(itemIdx + 1)];
});
};
// 생략
};
+) Jotai에서 Store 만들기
원자 단위의 상태를 만드는 것 말고도, Jotai에서는 Store를 만드는 것이 가능하다. Store를 사용하면 복수의 상태를 한데 모아 관리하는 것이 가능하며, 이렇게 만든 Store는 React의 Context API와 유사하게 Provider 컴포넌트를 통해 하위 컴포넌트에 전달될 수 있다.
Store와 Provider를 사용함으로써 얻을 수 있는 이점은 각 하위 트리마다 서로 독립적인 상태를 관리할 수 있다는 점이다. (구태여 Recoil과 비교하자면, Recoil이 RecoilRoot 컴포넌트를 복수 둠으로써 독립적으로 상태를 관리하는 것과 비슷하지 않을까 싶다.)
Zustand
앞서 살펴본 Recoil과 Jotai와는 다르게, Zustand는 Store를 중심으로 상태를 관리한다. 이 Store는 관리하고자 하는 상태와 그 상태를 변경할 수 있는 함수에 대한 정보를 갖는 객체로, create 메서드를 통해 Store를 생성할 수 있다.
create 메서드는 React 에서 Store를 사용할 수 있도록 React Hook을 반환하는 메서드다. React 이외의 환경 혹은 React에 종속적이지 않은 상태를 추가하고 싶다면 createStore 메서드를 사용할 수 있다고.
create 메서드를 사용해 할 일 목록 기능에 대한 Store를 만드는 코드는 아래와 같다.
import { create } from "zustand";
import { TodoItem, TodoState } from "../../types";
const useZustandStore = create<TodoState>((set) => ({
todoItems: [],
todoIdx: 0,
addItem: (newItem: TodoItem) => set((state) => ({ todoItems: [ ...state.todoItems, newItem ] })),
removeItem: (id: string) => set((state) => {
const itemIdx = state.todoItems.findIndex(el => el.id === id);
return ({ todoItems: [ ...state.todoItems.slice(0, itemIdx), ...state.todoItems.slice(itemIdx + 1) ] });
}),
toggleItem: (id: string) => set((state) => {
const itemIdx = state.todoItems.findIndex(el => el.id === id);
const item = state.todoItems[itemIdx];
return ({ todoItems: [ ...state.todoItems.slice(0, itemIdx), { ...item, isDone: !item.isDone }, ...state.todoItems.slice(itemIdx + 1) ] });
}),
increaseIdx: () => set((state) => ({
todoIdx: state.todoIdx + 1
}))
}));
export default useZustandStore;
- create를 통해 할 일 관리 Store를 만든다. 이 때, create는 상태 혹은 상태 변경 함수 등을 반환하는 React 훅을 반환한다.
- 위 코드에서도 useZustandStore라는 이름의 훅을 만들고 있음에서 알 수 있다.
- 추가하고자 하는 상태는 todoItems, todoIdx와 같이 초기값을 지정한다.
- create를 사용할 때 set 함수를 받아, 내부에서 상태를 변경하는 함수를 정의할 때 사용한다.
- set 이외에도 현재 상태를 가져올 수 있는 get 함수, store를 사용할 수 있다.
이렇게 훅을 만들었다면, 생성한 상태를 사용할 때는 이 훅을 그냥 import 해서 사용하기만 하면 된다. 별도로 어플리케이션 최상단에 컴포넌트를 추가하거나 하는 작업을 필요로 하지 않는다.
위에서 만든 훅을 사용해 할 일 목록의 기능을 구현한 코드가 아래와 같다.
import { useCallback } from "react";
import useZustandStore from "../../store/zustand/todo";
import { TodoItem } from "../../types";
export const Page = () => {
const todoIdx = useZustandStore((state) => state.todoIdx);
const todoItems = useZustandStore((state) => state.todoItems);
const addItem = useZustandStore((state) => state.addItem);
const removeItem = useZustandStore((state) => state.removeItem);
const toggleItem = useZustandStore((state) => state.toggleItem);
const increaseIdx = useZustandStore((state) => state.increaseIdx);
const addTodoItem = useCallback((item: Omit<TodoItem, 'id'>) => {
addItem({ id: `i${todoIdx}`, ...item });
increaseIdx();
}, [addItem, increaseIdx, todoIdx]);
// 생략
};
- 대부분의 로직을 Store 내부에 정의했기 때문에, 앞에서 만들었던 useZustandStore 훅을 사용해 필요한 상태와 사용하려는 메서드를 그대로 가져와서 사용하기만 했다(예외적으로 할 일 추가의 경우, 컴포넌트 구조 상 별도의 함수를 만드는 작업을 거쳤다).
대강의 감상
- Redux는 제일 Flux 패턴에 충실한 형식을 따르는 반면, Recoil이나 Jotai은 상대적으로 React에서 사용하는 형식(useState를 통한 상태 관리)을 따른다는 느낌이 강했다. Zustand는 Store를 중심으로 상태를 관리한다는 특성 상 Redux와 비슷하나 Redux Toolkit에 비해 훨씬 작성해야 하는 코드가 적어 난이도가 낮게 느껴졌다. React를 배우고 처음 외부 상태 관리 라이브러리를 공부한다면 이 세 라이브러리(Recoil, Zustand, Jotai)가 Redux에 비해 상대적으로 친숙하고 편하게 다가오지 않을까 싶다.
- 물론 Redux는 오랜 시간동안 많은 사람들에 의해 사용된 만큼, 많은 프로젝트들이 Redux를 사용하고 있을 것을 감안하면 Redux에 대해 어느정도의 지식을 쌓아두는 것도 중요해보인다.
- Redux Toolkit은 Redux를 보다 편리하게 사용하도록 만들어졌고, Redux 공식에서도 사용을 추천하지만 이렇게 비교하니 역시 타 라이브러리에 비해 작성해야 하는 코드가 많다는 생각이 들 수 밖에 없었다. Flux 패턴과 그 이론에 대해 잘 알고 있지 않다면 많은 개념이 낯설고 헷갈리게 다가올 느낌. 나도 한동안 쓰지 않다가 오랜만에 사용해보려니 많이 헷갈려서 좀 헤맸다.
- Jotai는 처음 써보는데 Recoil과 사용 방법이 꽤 비슷하다는 인상을 받았다. Recoil이 아쉬운 유지보수라는 문제점을 안고 있는 이상, 비슷한 원리를 사용하는 Jotai가 대체재로 사용하기 편하겠다는 느낌이 들었다.
- 이번 글에선 각 상태 라이브러리들 간의 대강의 차이점을 알아보기 위해, 간단한 기능을 구현하며 비교하는 시간을 가졌다.
- 각 라이브러리의 상세한 특징이나 구조에 대해서 살펴보지 못한게 아쉬웠는데, 모던 리액트 Deep Dive에서 이를 다루는 것 같아 이를 계기로 구현 부분 까지 살펴보고자 한다.
'공부 > FE' 카테고리의 다른 글
프록시 설정을 알아보자 (0) | 2024.11.23 |
---|---|
웹소켓 연결을 해보자 (커스텀 웹소켓 훅 만들어보기) (0) | 2024.11.16 |
Hydration이란? (0) | 2024.11.07 |
실시간으로 서버에서 데이터를 가져와보기 (feat. Polling/Websocket/SSE) (0) | 2024.11.03 |
PostMessage 알아보기 (3) | 2024.10.20 |