전역 상태 관리의 필요성과 라이브러리들
이번 React+ts 스터디를 시작해보면서 첫 공부 주제로 전역 상태관리에 대해 알아보겠습니다.
1. 전역 상태 관리가 왜 필요할까?
Todo 앱을 예시로 필요한 컴포넌트를 나눠 보겠습니다.
App
├── TodoInput ← 할 일 추가 (todos를 "바꾸는" 쪽)
├── TodoList ← 목록 표시, 토글, 삭제 (todos를 "읽고 바꾸는" 쪽)
└── TodoCount ← 남은 개수 표시 (todos를 "읽는" 쪽)
세 컴포넌트가 모두 todos라는 같은 데이터를 보게 됩니다.
function App() {
const [todos, setTodos] = useState<Todo[]>([])
return (
<>
<TodoInput setTodos={setTodos} />
<TodoList todos={todos} setTodos={setTodos} />
<TodoCount todos={todos} />
</>
)
}
React만 쓰면 보통 이렇게 상태를 공통 부모로 올리고 props로 내려줍니다. 컴포넌트가 세 개일 때는 괜찮지만, 앱이 커지면 문제가 생깁니다.
- Props drilling:
todos를 쓰지도 않는 중간 컴포넌트들이 아래로 넘겨주려고 props를 받아야 합니다. - 불필요한 리렌더링: 상태가 최상단에 있으니
todos가 바뀔 때마다 그 아래 트리 전체가 다시 렌더링됩니다. - 상태 변경 로직이 흩어짐:
setTodos(...)를 호출하는 곳이 여기저기 생겨서 “상태가 어디서, 어떻게 바뀌는지” 추적하기 어렵습니다.
전역 상태 관리는 상태를 컴포넌트 트리 바깥(또는 최상단의 한 곳)에 두고, 필요한 컴포넌트가 직접 꺼내 쓰게 하는 방식입니다. 다만 “직접 꺼내 쓰는” 방식이 도구마다 다르고, 그 차이가 리렌더링 성능 차이로 이어집니다.
2. 세 가지 방식 한눈에 보기
useContext + useReducer
- React에 내장되어 있어서 추가 설치가 필요 없습니다.
useReducer로 상태와 변경 로직(reducer)을 정의하고,Context로 트리 아래에 전달합니다.- Context는 원래 값을 전달하는 도구이지 상태 관리 도구가 아닙니다. 그래서 Provider의 value가 바뀌면 그 Context를 쓰는 컴포넌트가 전부 리렌더링됩니다. 일부 값만 골라서 구독할 수 없습니다.
Redux Toolkit
- Redux 공식 도구 모음입니다. 옛날 Redux의 보일러플레이트(액션 타입 상수, 액션 생성 함수, switch문 등)를 크게 줄여 줍니다.
- 앱 전체 상태를 하나의 store에 두고, 상태는 action을 dispatch해야만 바뀝니다. 그래서 흐름이 예측 가능합니다.
createSlice하나로 reducer와 action creator를 함께 만들 수 있습니다. 내부에서 Immer를 써서state.push(...)처럼 직접 수정하는 것처럼 코드를 써도 불변성이 지켜집니다.useSelector로 필요한 값만 골라서 구독합니다.
Zustand
- 아주 가벼운 상태 관리 라이브러리입니다.
create하나로 상태와 액션을 한 번에 정의하고, 그 결과가 바로 훅이 됩니다.- Provider가 필요 없습니다. store가 React 트리 바깥에 있습니다.
- Redux처럼 selector로 필요한 값만 구독합니다.
비교 표
| Context + useReducer | Redux Toolkit | Zustand | |
|---|---|---|---|
| 설치 | 불필요 (React 내장) | @reduxjs/toolkit, react-redux | zustand |
| Provider | 필요 | 필요 | 불필요 |
| 상태 변경 | dispatch({ type, ... }) | dispatch(actionCreator(payload)) | 액션 함수 직접 호출 |
| 불변성 처리 | 직접 (...spread) | Immer 내장 (직접 수정 가능) | 직접 (...spread) |
| 부분 구독 (selector) | ❌ | ✅ useSelector | ✅ useStore(selector) |
3. Todo 구현, 어떻게 다를까?
세 방식 모두 같은 컴포넌트 구조(TodoInput, TodoList, TodoCount)와 같은 Todo 타입을 씁니다.
// src/shared/types.ts
export interface Todo {
id: number
text: string
done: boolean
}
3-1. 상태와 로직 정의하기
- Context + useReducer: reducer 함수와 Context, Provider를 직접 만듭니다.
// src/context/TodoContext.tsx
type Action =
| { type: 'add'; text: string }
| { type: 'toggle'; id: number }
| { type: 'remove'; id: number }
function todoReducer(state: Todo[], action: Action): Todo[] {
switch (action.type) {
case 'add':
return [...state, { id: Date.now(), text: action.text, done: false }]
case 'toggle':
return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t))
case 'remove':
return state.filter((t) => t.id !== action.id)
}
}
const TodoContext = createContext<TodoContextValue | null>(null)
export function TodoProvider({ children }: { children: ReactNode }) {
const [todos, dispatch] = useReducer(todoReducer, [])
return <TodoContext.Provider value={{ todos, dispatch }}>{children}</TodoContext.Provider>
}
액션 타입을 직접 정의하고, 불변성을 지키기 위해 ...spread와 map/filter로 항상 새 배열을 만들어야 합니다.
- Redux Toolkit:
createSlice로 reducer와 action creator를 한 번에 만듭니다.
// src/redux/todoSlice.ts
const todoSlice = createSlice({
name: 'todos',
initialState: [] as Todo[],
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
state.push({ id: Date.now(), text: action.payload, done: false })
},
toggleTodo: (state, action: PayloadAction<number>) => {
const todo = state.find((t) => t.id === action.payload)
if (todo) todo.done = !todo.done
},
removeTodo: (state, action: PayloadAction<number>) => {
return state.filter((t) => t.id !== action.payload)
},
},
})
export const { addTodo, toggleTodo, removeTodo } = todoSlice.actions
// src/redux/store.ts
export const store = configureStore({
reducer: { todos: todoReducer },
})
살펴볼 점:
PayloadAction<string>은 “이 액션의payload는 string”이라는 타입입니다. 덕분에addTodo(123)처럼 잘못 호출하면 타입 에러가 납니다.state.push(...),todo.done = !todo.done처럼 직접 수정하는 것처럼 써도 Immer가 새 상태를 만들어 줍니다.removeTodo처럼 새 값을return하는 것도 가능합니다. 단, 한 reducer 안에서 수정과 return을 동시에 하면 안 됩니다.
- Zustand:
create하나에 상태와 액션을 모두 넣습니다.
// src/zustand/useTodoStore.ts
export const useTodoStore = create<TodoState>((set) => ({
todos: [],
addTodo: (text) =>
set((state) => ({ todos: [...state.todos, { id: Date.now(), text, done: false }] })),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
})),
removeTodo: (id) => set((state) => ({ todos: state.todos.filter((t) => t.id !== id) })),
}))
살펴볼 점:
- action type도, reducer의 switch문도, Provider도 없습니다
- 상태를 바꾸는 함수 자체가 store 안에 들어 있는 구조입니다.
- 훅을 만들때와 동일하게 use~로 시작해야하는 네이밍 규칙이 있습니다.
3-2. 앱에 연결하기
// Context: Provider로 감싸야 함
<TodoProvider>
<TodoInput /><TodoList /><TodoCount />
</TodoProvider>
// Redux: Provider에 store를 넘겨야 함
<Provider store={store}>
<TodoInput /><TodoList /><TodoCount />
</Provider>
// Zustand: 그냥 쓰면 됨
<>
<TodoInput /><TodoList /><TodoCount />
</>
3-3. 컴포넌트에서 꺼내 쓰기: 여기서 차이가 생깁니다
TodoInput (상태를 바꾸기만 하는 컴포넌트)
// Context: dispatch만 필요하지만 Context 전체를 구독하게 됨
const { dispatch } = useTodoContext()
// Redux: dispatch만 가져옴 → todos가 바뀌어도 리렌더링 안 됨
const dispatch = useAppDispatch()
// Zustand: addTodo 함수만 골라서 구독
const addTodo = useTodoStore((state) => state.addTodo)
Context에서는 구조 분해로 dispatch만 꺼내도 useContext가 value 객체 전체를 구독합니다. 그래서 todos가 바뀌면 TodoInput도 같이 리렌더링됩니다.
TodoCount (파생된 값만 필요한 컴포넌트)
// Context: todos 전체를 받아서 계산
const { todos } = useTodoContext()
const left = todos.filter((t) => !t.done).length
// Redux: selector가 "숫자"를 반환
const left = useAppSelector((state) => state.todos.filter((t) => !t.done).length)
// Zustand: 마찬가지로 "숫자"를 반환
const left = useTodoStore((state) => state.todos.filter((t) => !t.done).length)
Redux와 Zustand는 selector가 반환한 값이 이전과 같으면(===) 리렌더링을 건너뜁니다. 여기서는 숫자를 반환하니까, todos가 바뀌어도 남은 개수가 그대로면 TodoCount는 리렌더링되지 않습니다.
4. 직접 확인해 보기: 리렌더링 실험
말로만 들으면 와닿지 않으니, 리렌더링 횟수를 화면에 띄워 봤습니다.
// src/shared/RenderBadge.tsx
export function RenderBadge() {
const count = useRef(0)
count.current += 1
return <span className="badge">render {count.current}</span>
}
useRef값은 리렌더링돼도 유지되고, 값을 바꿔도 리렌더링을 일으키지 않습니다.- props도 없고
memo도 안 했기 때문에 부모가 렌더링될 때마다 같이 렌더링됩니다. 그래서 배지 숫자는 곧 부모 컴포넌트의 렌더링 횟수입니다. - 이 배지를 세 구현의
TodoInput,TodoList,TodoCount에 각각 하나씩 넣었습니다.
참고:
StrictMode를 켜면 개발 모드에서 렌더링이 두 번씩 실행돼서 숫자가 2씩 올라갑니다. 이 실험에서는StrictMode를 쓰지 않았습니다.
실험 결과
각 방식에 할 일을 2~3개 넣은 뒤 같은 동작을 해 봤습니다. 표의 숫자는 각 컴포넌트 배지가 몇 번 늘었는지입니다.
① 할 일 추가
| Context | Redux | Zustand | |
|---|---|---|---|
| TodoInput | +1 | +1 | +1 |
| TodoList | +1 | +1 | +1 |
| TodoCount | +1 | +1 | +1 |
셋 다 똑같습니다. TodoInput은 제출할 때 setText('')로 자기 로컬 state를 비우니까 어느 방식이든 리렌더링됩니다. TodoList와 TodoCount는 목록과 개수가 실제로 바뀌었으니 리렌더링되는 게 맞습니다. 추가 동작만 보면 차이를 알 수 없습니다.
② 체크박스 토글
| Context | Redux | Zustand | |
|---|---|---|---|
| TodoInput | +1 | 0 | 0 |
| TodoList | +1 | +1 | +1 |
| TodoCount | +1 | +1 | +1 |
여기서 처음 차이가 납니다. TodoInput은 todos와 아무 관련이 없는데 Context에서만 리렌더링됩니다.
③ input에 타이핑
| Context | Redux | Zustand | |
|---|---|---|---|
| TodoInput | +1 | +1 | +1 |
| TodoList | 0 | 0 | 0 |
| TodoCount | 0 | 0 | 0 |
타이핑은 TodoInput의 로컬 state만 바꾸니까 전역 상태와 관계없이 모두 똑같습니다.
정리하면
- Context: “이 Context를 쓰는 컴포넌트 전부”가 리렌더링 단위입니다.
- Redux / Zustand: “selector 결과가 바뀐 컴포넌트”만 리렌더링됩니다.
Context에서도 state Context와 dispatch Context를 나누거나 memo를 적절히 써서 줄일 수는 있습니다. 하지만 그건 개발자가 직접 신경 써야 하는 부분이고, Redux와 Zustand는 selector로 이걸 기본으로 해결해 줍니다.
댓글
질문과 경험을 댓글로 나눠주세요. 댓글은 GitHub 계정으로 작성할 수 있습니다.