Typescript + React + React Testing Library + Jest 환경 구성 및 몇가지 간단한 테스트 코드 예시
React의 전역 상태 관리 라이브러리들에 대해 알아보자 (Redux, Recoil, Zustand 그리고 Context API)
2024-09-28
Explanation
제가 홈 프로텍터 생활을 하다가, 이제 그만 취업을 해서 의젓한 사회인이 되어 보고자 이런저런 공부를 하고 있는데요. 그러다보니, 한달에 한번도 잘 안쓰던 블로그를 자주 쓰게 되었네요..
그리고 오늘은 오랜만에 React의 이야기!
그 중에서 많이 사용되는 전역 상태 라이브러리들에 대해서 그 동작이나 특징에 대해서 알아보려합니다!
글에 사용되는 모든 코드는 깃허브 저장소에서 확인하실 수 있습니다.
https://github.com/falsy/blog-post-example/tree/main/react-gloabl-state-managements
첫 번째로 가장 오래되고 여전히 가장 많은 사람들이 사용하는 Redux에 대해 알아봅니다.
일단, 간단한 구현 코드를 하나 만들어 볼까요?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// /src/reducers/todos.ts import { createSlice } from "@reduxjs/toolkit" interface ITodo { id: number text: string completed: boolean } const todosSlice = createSlice({ name: "todos", initialState: [] as ITodo[], reducers: { todoAdded(state, action) { state.push({ id: action.payload.id, text: action.payload.text, completed: false, }) }, todoToggled(state, action) { const todo = state.find((todo) => todo.id === action.payload) if (todo) { todo.completed = !todo.completed } }, }, }) export const { todoAdded, todoToggled } = todosSlice.actions export default todosSlice.reducer |
Redux 웹 사이트에 Redux Toolkit를 추천하면서 알려주는 예시 코드인데요. 오.. 예전 기억에 리듀서들 만들고 액션들 만들고 이것저것 할 게 많았던 거 같은데, Redux Toolkit를 사용하면 아주 간단하게 구성할 수 있네요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// /src/components/Todo.tsx import { useDispatch, useSelector } from "react-redux" import { todoAdded, todoToggled } from "../reducers/todos" export default function Todo() { const todos = useSelector((state: any) => state.todos) const dispatch = useDispatch() const addTodo = () => { dispatch(todoAdded({ id: todos.length + 1, text: "New Todo" })) } const toggleTodo = (id: number) => { dispatch(todoToggled(id)) } return ( <div> <button onClick={addTodo}>Add Todo</button> {todos.map((todo: any) => ( <div key={todo.id} onClick={() => toggleTodo(todo.id)}> {todo.text} {todo.completed.toString()} </div> ))} </div> ) } |
기습 Flux 아키텍처
React를 이야기하다 보면 자주 이야기되는 Flux 아키텍처입니다. 혹시나 그로 인해 React가 사용하는 아키텍처라고 생각할 수도 있는데, Flux는 React의 전역 상태 관리에 대한 체계적인 방법이 필요 했고 그렇게 개발한 아키텍처가 Flux 아키텍처입니다.
그리고 Flux의 아이디어를 기반으로 만들어진 라이브러리 중 하나가 Redux랍니다.
(오해가 있을 수 있으니, 오늘 이야기하는 Redux, Recoil, Zustand 모두 Flux 기반입니다.)
React에 대한 이야기는 다음 포스트에서..
Flux 아키텍처는 기본적으로 React와 같이 단방향 데이터 흐름을 가지며 크게, Action, Dispatcher, Store, View 로 역할을 나누고 각 역할을 아래와 같습니다.
– 액션(Action): 애플리케이션에서 발생하는 모든 이벤트를 의미합니다.
– 디스패처(Dispatcher): 액션을 스토어로 전달하는 역할을 합니다.
– 스토어(Store): 애플리케이션의 상태를 관리하고, 상태 변경이 발생하면 뷰에 알려줍니다.
– 뷰(View): React 컴포넌트로 구성되며, 스토어로부터 상태를 받아 UI를 업데이트합니다.
간단하게 요약하면 (이미 요약되어 있지만..) View는 우리가 아는 그 맨날 만드는 React 컴포넌트들을 이야기하고, Store는 전역 상태 값들을 관리하는 곳으로 상태값이 변경되면 그 변경을 View에 알려주고 View는 그럼 컴포넌트를 갱신하겠죠? Dispatcher는 이제 액션을 스토어에 전달하는 허브 역할을 하고 Action은 Type 값을 통해서 스토어에 상태값을 변경하는 함수를 이야기 한답니다.
다시 Redux로 돌아가서
Flux도 알아 봤으니, Redux는 Flux의 아이디어를 구체화한 라이브러리이고 특징에 대해 조금 더 알아보자면,
첫 번째로 단일 스토어 구성. Flux는 여러개의 Store를 사용할 수 있도록 하고 각 Store 간의 직접적인 통신은 없고 서로 독립적으로 동작하는 구성인데 반해, Redux는 전체 상태를 하나의 Store를 사용함으로써 상태의 일관성을 유지하고 디버깅에 용이하게 하였답니다.
두 번째로, Redux는 상태가 불변 객체로 관리되어 상태를 직접 변경하지 않고 Reducer를 통해서 새로운 상태 객체를 반환합니다.
이 부분은 조금 의외였어요. 당연히 Flux 아키텍처에서도 store의 상태를 기본적으로 불변 객체로 관리하는 것으로 설계했을 줄 알았는데, Flux는 store의 상태 값의 불변성 여부를 유연하게 열어 놓았더라고요.
좀 더 생각해보니까, React의 useState도 일반적으로 새로운 객체를 생성해서 사용하는거지 불변성을 강제하지는 않았네요?! 이게 약간 페이스북의 어떤 철학? 정책? 인가봐요.
세 번째로, Redux는 미들웨어 두어서 추가적으로 비동기 작업이나 로깅 등 추가 기능을 확장할 수 있게 구성하였습니다. 아무래도 Redux는 오랜시간 많은 사람들이 사용하였기에 이 미들웨어 풀도 넓어서 큰 장점일 수 있는 거 같아요.
위에 ‘Redux Toolkit’ 예시는 action이나 reducer의 type 값이 안보이는데, 예전에 Redux 사용할 때는 아래의 예시처럼 type을 사용했었답니다. 아마도 내부적으로 type 값을 자동으로 만들어서 사용하겠죠??
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { createStore } from 'redux' // Reducer function counter(state = 0, action) { switch (action.type) { case 'INCREMENT': return state + 1 case 'DECREMENT': return state - 1 default: return state } } // Dispatch + Action store.dispatch({ type: 'INCREMENT' }) |
위와 같이 Redux는 Reducer를 사용해서 상태 값의 불변성을 보장합니다.
마지막으로 Redux의 DevTools를 통한 디버깅의 장점도 살짝 알아보자면,
1 |
$ npm install redux-devtools-extension |
라이브러리를 설치해주고
1 2 3 4 5 6 7 8 9 10 |
// /webpack.config.js ... plugins: [ new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify( process.env.NODE_ENV || "development" ), }), ], ... |
개발 모드인지 확인하기 위한 Webpack 설정 파일에 DefinePlugin을 통해 환경 변수를 추가해주고
1 2 3 4 5 6 7 8 9 10 |
// /src/store.ts import { configureStore } from "@reduxjs/toolkit" import todosReducer from "./reducers/todos" export default configureStore({ reducer: { todos: todosReducer, }, devTools: process.env.NODE_ENV !== "production", }) |
store에 devTools 속성을 설정해주고
그리고 마지막으로 크롬 브라우저의 경우에는 아래 확장프로그램을 설치하면 개발 도구를 사용할 수 있답니다!
https://chromewebstore.google.com/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=ko
Recoil는 페이스북이 만든 상태 관리 라이브러리로 역시 Flux 아키텍처 기반으로 개발되었습니다.
우선 아까의 todo를 간단하게 Recoil로 구현해보면,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// /src/hooks/useTodo.ts import { atom, useRecoilState } from "recoil" interface ITodo { id: number text: string completed: boolean } const todoState = atom({ key: "todoState", default: [] as ITodo[], }) export const useTodoState = () => { return useRecoilState(todoState) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
// /src/components/Todo.tsx import { useTodoState } from "../hooks/useTodo" export default function Todo() { const [todos, setTodos] = useTodoState() const addTodo = () => { setTodos((currVal) => { return [ ...currVal, { id: todos.length + 1, text: "New Todo", completed: false }, ] }) } const toggleTodo = (id: number) => { setTodos((currVal) => { return currVal.map((todo) => { if (todo.id === id) { return { ...todo, completed: !todo.completed } } return todo }) }) } return ( <div> <button onClick={addTodo}>Add Todo</button> {todos.map((todo: any) => ( <div key={todo.id} onClick={() => toggleTodo(todo.id)}> {todo.text} {todo.completed.toString()} </div> ))} </div> ) } |
짜잔 어떤가요? 정확하게 같진 않지만, 간단하죠?
Redux와 같이 Flux 아키텍처를 기반으로 하지만 차이점이라면, Recoil은 Redux의 Reducer 부분이 없죠?! 조금 더 Flux에 충실하게 구현한게 아닌가 싶어요. 그리고 Reducer 부분이 없는 만큼 새로운 상태값에 대한 불변성을 사용자가 직접 구현해야 한답니다. (새로운 객체로 상태 변경)
조금 더 이해가 쉽도록 위 코드를 Flux의 구성 요소와 매핑해보면,
[todoState(atom) = Store]
Recoil의 atom은 상태의 저장소 역할을 하며 Flux의 Store의 역할을 합니다. 그리고 여기서 atom을 여러개 만들어 사용되니까 Redux와 다르게 여러개의 Store를 사용하는 것처럼 보이지만, 내부적으로는 하나의 Store를 사용하고 다만 atom이라는 논리적인 단위로 나뉘어서 사용한다고 합니다.
[addTodo, toggleTodo = Action]
예시 코드에서는 addTodo와 toggleTodo 함수가 애플리케이션의 상태를 변경하는 이벤트이므로 Flux의 Action에 해당합니다.
[setTodos = Dispatcher]
Flux에서는 ‘Dispatcher’가 Action을 Store로 전달하여 상태를 변경하는 역할을 하죠? 위 예시에서는 Recoil에서 useRecoilState로 제공되는 setTodos 함수가 이 Dispatcher의 역할에 해당합니다.
[Todo(Component) = View]
마지막으로 Todo 컴포넌트가 View에 해당합니다.
이처럼 페이스북에서 직접 개발하고, 쉽고, 간단한 구성이 Recoil의 장점인 거 같아요. 하지만 시간이 지나면서 점점 사용하는 비중이 줄고 있는데요. 일단 가장 큰 문제는 1년 넘게 업데이트가 없습니다.. 그동안 발견된 메모리 누수 이슈나 이제는 아주 일반적으로 사용되는 SSR에서의 지원도 부족한 상황에 업데이트까지 멈춰서 이제는 많은 개발자들이 떠나고 있습니다.
만약 지금 시작하는 프로젝트라면, Recoil을 사용하지는 않을 거 같아요.
Zustand는 현시점에서 가장 부상하고 있는? 상태 관리 라이브러리랍니다.
현재 npm의 주간 다운로드를 보면, ‘@reduxjs/toolkit’은 약 370만 다운로드를 기록하고 있으며 ‘zustand’는 약 430만 다운로드를 기록하고 있답니다.
그런데 ‘react-redux’가 주간 다운로드가 약 790만 다운로드인 것으로 보아, 그래도 여전히 가장 많이 사용되는 상태 관리 라이브러리는 Redux인 거 같아요.
Zustand는 Flux 아키텍처를 엄격하게 따르지 않으며, Recoil 보다도 적은 코드량으로 간단하게 구성할 수 있으며 다른 라이브러리들과 달리 React의 Context API를 사용하지 않고 클로저로 사용해서 별도의 Provider 감싸주지 않아도 된답니다.
여기서 잠시 코드를 보면,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
// /src/hooks/useTodo.ts import { create } from "zustand" interface ITodo { id: number text: string completed: boolean } type Todos = { list: ITodo[] todoAdded: () => void todoToggled: (id: number) => void } const useStore = create<Todos>()((set) => ({ list: [], todoAdded: () => set((Todos) => ({ list: [ ...Todos.list, { id: Todos.list.length + 1, text: "New Todo", completed: false }, ], })), todoToggled: (id: number) => set((Todos) => ({ list: Todos.list.map((todo) => todo.id === id ? { ...todo, completed: !todo.completed } : todo ), })), })) export default useStore |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// /src/components/Todo.tsx import useTodo from "../hooks/useTodo" export default function Todo() { const { list, todoAdded, todoToggled } = useTodo() const addTodo = () => { todoAdded() } const toggleTodo = (id: number) => { todoToggled(id) } return ( <div> <button onClick={addTodo}>Add Todo</button> {list.map((todo: any) => ( <div key={todo.id} onClick={() => toggleTodo(todo.id)}> {todo.text} {todo.completed.toString()} </div> ))} </div> ) } |
와우! 엄청 간단하죠?!
제가 개인 프로젝트로 전역 상태 관리가 필요하다면 전 고민 없이 Zustand를 사용할 거 같아요.
조금 더 Zustand의 특징을 살펴보자면,
앞서 이야기한대로 Zustand는 React의 Context API를 사용하지 않고 클로저를 통한 상태 저장과 구독 시스템을 사용하기 때문에 React의 컴포넌트 트리와 독립적으로 전역 상태를 관리할 수 있습니다.
이 말을 조금 쉬운 상황으로 설명하자면, Context API를 사용하는 Provider가 있고 해당 Context의 값이 변경되면 Provider 안의 컴포넌트들이 불필요하게 리렌더링 될 수 있는데, Zustand는 Context API를 사용하지 않기 때문에 해당 상태값이 연결된 컴포넌트에만 리렌더링이 발생한다는 의미입니다.
그렇다면, Context API를 사용하는 Redux나 Recoil은 Zustand에 비해 리렌더링이 많이 발생하는 비효율적인 라이브러리인가?! 라고 생각할 수 있는데, Redux나 Recoil 모두 해당 상태를 구독하고 있는 컴포넌트를 추적하고 실제로 선택된 값이 변경되는지 확인하여 리렌더링 하도록 최적화가 잘되어 있어서 그렇지 않습니다.
너무 Zustand의 장점만 말한 것 같아서, Redux에 비교했을 때 단점을 조금 정리해보면,
Zustand는 매우 간결하게 상태를 관리할 수 있지만, Redux는 actions, reducers, middlewares등 상태 변경에 대한 흐름이 명확히 구조화 되어 있어서 규모가 커졌을 때 상태 관리가 어려울 수 있습니다.
하지만 이 부분은 뭐.. 규모가 커졌을때는 흐름에 대한 상태를 구조화 하는 건 너무 당연한 거라, 오히려 서비스에 맞게 또는 조직에 맞게 자유롭게 구성할 수 있으니 막 단점이라 생각되지는 않는 거 같아요.
Redux에는 다양한 미들웨어들이 있다?!
저는 Redux를 사용할 때 미들웨어를 거의 안사용해봐서 요것도 잘 모르겠네요..
Redux에 비해 규모는 작지만, Zustand도 미들웨어를 지원한답니다.
Redux는 디버깅 및 상태 추적에 용이하다. 아무래도 Redux DevTools가 좀 Redux에게 굉장히 매력적인 부분이긴 한 거 같아요. 요건 인정.
Zustand도 DevTools가 있긴한데, 아직 완벽하지는 않은 거 같아요.
React는 v16.3부터 Context API를 사용할 수 있는데요, Context API를 사용하면 해당 Provider 안에서 간단하게 전역의 상태를 관리할 수 있습니다.
하지만 주의할 점은, 앞서 이야기 나왔던 것처럼 Context API는 상태값이 변경됨에 따라 Provider 아래의 모든 컴포넌트가 리렌더링 될 수 있기 때문에 React.Memo나 useMemo, useCallback를 사용해서 렌더링 최적화를 직접 구현해줘야 합니다.
[2024.10.10 내용 추가]
React v18 기준으로 Context API도 렌더링의 최적화가 진행되어 Provider 아래의 컴포넌트 중에 Context를 구독하는 컴포넌트만 리렌더링 됩니다.
그리고 당연히 별도의 미들웨어도 없고, 복잡한 상태의 변경 흐름을 관리하고 조합하는데 어려운 단점이 있습니다. 이어지는 이야기지만 디버깅이 어렵다는 문제도 있습니다.
하지만 전역 상태 라이브러리들은 대부분 애플리케이션 전체의 상태를 관리하는데 초점을 두고 있기 때문에 특정 기능이 국소적으로 사용되는 형태라면 Context API도 괜찮은 선택지가 될 수 있습니다.
예를 들자면,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
import { createContext, useContext, useState, ReactNode } from "react" interface ThemeContextType { theme: string toggleTheme: () => void } const ThemeContext = createContext<ThemeContextType | undefined>(undefined) interface ThemeProviderProps { children: ReactNode } const ThemeProvider = ({ children }: ThemeProviderProps) => { const [theme, setTheme] = useState<string>("light") const toggleTheme = () => setTheme((prev) => (prev === "light" ? "dark" : "light")) return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> {children} </ThemeContext.Provider> ) } const ThemedComponent = () => { const context = useContext(ThemeContext) if (!context) { throw new Error("ThemedComponent must be used within a ThemeProvider") } const { theme, toggleTheme } = context return ( <div style={{ background: theme === "light" ? "#fff" : "#333", color: theme === "light" ? "#000" : "#fff", }} > <p>Current Theme: {theme}</p> <button onClick={toggleTheme}>Toggle Theme</button> </div> ) } const App = () => ( <div> <h1>App without Theme</h1> <ThemeProvider> <ThemedComponent /> </ThemeProvider> </div> ) export default App |
이렇게 특정 범위에서만 상태값 값을 공유하는 구조에서 유용하게 사용할 수 있습니다.