Redux, Zustand 돌아보기 =) + 구조분해할당에 대한 오해..Frontend/개발 관련 지식2025. 9. 28. 16:33
Table of Contents
돌아보기..
- 개발자로 일을 하면서 Redux와 zustand를 가장 많이 사용한 것 같은데 정작 정리한적이 한번도 없어서 한번 돌아보자...................................
- 정리하면서 헷갈렸던 부분들이 많이 명확해졌다..
- 내가 구조분해 할당에 대해 약간 오해하던 부분도 좀 명확하게 풀렸고 참조형 데이터를 구조분해할당 없이 구독할 때, 무조건 리렌더링 한다고 이상한 오해를 하고 있었는데 그것도 풀려버림!
구조분해할당 대한 오해..
const { count, items } = useStore(state => state);
- 위 코드에서 나는 단순히 const { count, items } 라는 코드를 적는 순간 새로운 참조가 만들어지는 줄 알았다..
- 하지만 정확히 보면 그게 아니긴 하다!
여기서 중요한 점은 두 가지 단계가 있다는 거다!
1.selector가 반환하는 객체 생성
(state) => state
- count, items가 바뀌지 않아도, 불변성의 원리때문에 다른 값이 바뀌어도 전체 state의 참조가 변경된다!
- 즉, selector가 새로운 참조를 반환 생김 → useStore 입장에서는 이전과 다른 값
- 이 때문에 구조분해 할당과 상관없이 리렌더링 가능성 발생
2.구조분해 할당
const { count, items } = …
- 여기서 구조분해 할당은 단순히 객체 안의 속성을 변수로 꺼내는 행위일뿐이다
- 새로운 객체를 생성하지 않음!
- 이미 selector에서 새 객체가 만들어졌기 때문에, 구조분해 할당은 그저 접근만 하는 것
헷갈리는 케이스 ! ! (그렇다면 이건 왜?)
import { create } from "zustand";
interface State {
count: number;
name: string;
items: string[];
test: { a: string; b: number };
increment: () => void;
addItem: (item: string) => void;
}
export const useTestStore = create<State>((set) => ({
count: 0,
name: "Zustand Store",
items: [],
test: { a: "example", b: 42 },
increment: () => set((state) => ({ count: state.count + 1 })),
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));
- 위와 같은 스토어가 있고 어떤 컴포넌트에서 test를 구조분해 할당해서 a를 쓰고 있다고 생각해보자!!
import { useTestStore } from "../../../zustand/store";
export const Name = () => {
const { a } = useTestStore((state) => state.test);
console.log("Name component rendered");
return (
<div>
<h1>Name</h1>
<p>{a}</p>
</div>
);
};
- 여기서 나는 당연히? a가 바뀌지 않아도 test라는 객체를 selector가 리턴하니까 매번 리렌더링 된다고 오해를 했었다..
- 하지만 정작 중요한거는 test라는 객체의 참조가 변경되지 않았다는 점이다..!
- 상태를 업데이트할 때, 불변성의 원리로 state 전체의 참조가 새로 생성되지만, test가 변하지 않으면 참조도 그대로..!
- "참조가 변경되지 않았다!" 가 정말 핵심 문장이다!
- 하지만 정작 중요한거는 test라는 객체의 참조가 변경되지 않았다는 점이다..!
- count를 늘렸을 때, increment 함수를 보면 count의 참조만 바뀌기 때문이다..!
- 구조 분해 할당이 반드시 리렌더링을 발생시킨다!라는 오해는 이제 풀어보도록하자
Redux ?
- Redux에서는 애플리케이션 전체 상태를 한 곳(Store)에서 관리 (단일 스토어)
- 단방향 데이터 흐름 (Flux 아키텍처 기반)
- Action → Reducer → Store → View
- 상태 변경은 반드시 순수 함수인 Reducer를 통해서만 가능
- 상태는 불변성 유지 (새로운 참조 객체를 반환해야 함)
- 비동기 처리 불가 → redux-thunk 같은 middleware 필요
- DevTools 제공 → 상태 추적 및 디버깅 강력
++ Flux ??
Flux는 Facebook(Meta)에서 만든 단방향 데이터 흐름 아키텍처 패턴이다!!
1. Action (액션)
- 사용자 이벤트나 API 응답 등을 나타내는 객체
{ type: 'ADD_ITEM', payload: data } 형태
2. Dispatcher (디스패처)
- 모든 액션을 받아서 Store로 전달하는 중앙 허브
- 단 하나만 존재
3. Store (스토어)
- 애플리케이션의 상태와 비즈니스 로직 관리
- Dispatcher에 등록되어 액션을 받음
4. View (뷰)
- React 컴포넌트
- Store의 상태를 구독하고 UI 렌더링
Redux 작동 흐름
Store 생성
- createStore(or RTK configureStore)로 스토어를 만든다.
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
const initialState = { count: 0, items: [] as string[] };
function counterReducer(state = initialState, action: any) {
switch (action.type) {
case "increment":
return { ...state, count: state.count + 1 }; // 새로운 객체 반환
case "addItem":
return { ...state, items: [...state.items, action.payload] }; // 새로운 배열 반환
default:
return state;
}
}
export const store = createStore(counterReducer, applyMiddleware(thunk));
Action Creator (액션 생성 함수)
// 액션 생성 함수
export const increment = () => ({ type: "increment" });
export const addItem = (item: string) => ({ type: "addItem", payload: item });
Action Dispatch
- 컴포넌트에서 dispatch({ type: "increment" }) 호출
- 액션 객체가 Store로 전달됨
import { useDispatch, useSelector } from "react-redux";
import { increment, addItem } from "./store";
function Counter() {
const dispatch = useDispatch();
const count = useSelector((state: any) => state.count);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(addItem("Apple"))}>Add Item</button>
</div>
);
}
Reducer 실행
- Store는 Reducer에 현재 상태 + 액션 전달
- Reducer는 새로운 상태 객체를 반환 (불변성 유지 필수)
Store 업데이트
- Reducer 반환값이 Store에 저장
- 내부적으로 이전 상태와 새로운 상태를 교체
구독 컴포넌트 리렌더링
- useSelector 훅이 Store 변경을 구독
- useSelector의 반환값이 이전과 다르면 컴포넌트 리렌더링
- Primitive 값 → 값 비교
- 참조형 값 → 참조 비교 (===)
컴포넌트 리렌더링
Primitive 값
export const CounterPrimitive = () => {
const count = useSelector((state: any) => state.count);
const dispatch = useDispatch();
console.log("CounterPrimitive render");
return (
<div>
<p>{count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
</div>
);
};
- count 값이 바뀌면 리렌더링
- 단순 값 비교로 최적화 쉬움
참조형 값
export const CounterArray = () => {
const items = useSelector((state: any) => state.items);
const dispatch = useDispatch();
console.log("CounterArray render");
return (
<div>
<ul>{items.map((item) => <li key={item}>{item}</li>)}</ul>
<button onClick={() => dispatch({ type: "addItem", payload: "apple" })}>
Add Item
</button>
</div>
);
};
- 참조형 값의 참조가 실제로 변경이 일어나면 리렌더링이 일어남!
- { items: ...newItems }
Thunk를 쓰는 이유
- redux-thunk는 Flux의 단방향 데이터 흐름을 유지하면서 비동기 로직을 처리하기 위한 미들웨어
- Redux Reducer는 순수 함수 → 비동기 로직(API 호출, setTimeout 등) 금지
- Thunk를 쓰면 Action Creator가 함수를 반환할 수 있음 !!
- dispatch할 action의 리턴값이 객체이여야 하는데, action의 리턴값이 비동기 함수라면 "Actions must be plain objects" 에러 발생!!
- redux-thunk를 쓰면 액션 생성 함수에서 객체 대신 함수를 반환할 수 있다.
- Redux는 이 함수를 실행하고, dispatch와 getState를 인자로 주입해준다.
- 반환된 함수 안에서 dispatch 실행 가능 → 비동기 처리 후 상태 업데이트 가능
export const fetchItems = () => {
return async (dispatch: any) => {
const res = await fetch("/api/items");
const data = await res.json();
dispatch({ type: "addItem", payload: data });
};
};
// 컴포넌트에서
dispatch(fethItems)
- 물론 위와 같은 액션의 형태를 를 그냥 함수 내부에 구현해버리면 되지만 그렇게한다면 로직이 UI에 섞여버린다
- 당연히 같은 기능을 하는 코드를 사용할 때 반복을 해줘야 하므로 재사용성이 떨어지고 응집도도 매우 낮아진다..!
- 이런 관점에서 등장하지 않았을까 ? 추상화 도구로써 등장한 것 같은 생각이 든다!
구조분해 할당 주의하기!
// count는 primitive, items는 array
const { count, items } = useSelector((state: any) => state);
- state 객체를 통째로 반환하면 항상 새 객체
- 참조가 달라져서 매번 구독하는 값이 변경되었다고 판단될 수 있다!
- 잘못쓰면 항상 리렌더링이 발생할 수 있음!
- 이를 해결하기 위해서는 구조분해 할당을 풀거나, memoized selector를 이용해서 해결할 수 있음!
- 변경이 없다면 이전 참조 그대로 사용 ^^
selector 나누기 (useShallow or memoized selector)
// useShallow
const count = useSelector((state: any) => state.count);
const items = useSelector((state: any) => state.items, shallowEqual);
// or memoized selector
export const selectFilteredItems = createSelector(
[(state: any) => state.items],
(items) => items.filter((item: string) => item !== "")
);
const count = useSelector((state: any) => state.count);
const items = useSelector(selectFilteredItems);
구조분해 할당하고 memoized selector 쓰기
- memoization의 결과가 변하지 않으면 새로운 객체를 만들지 않고 이전 결과를 그대로 반환!!!!!!!!!!!!!!!!
const selectCount = (state: any) => state.count;
const selectItems = (state: any) => state.items;
export const selectCounter = createSelector(
[selectCount, selectItems],
(count, items) => ({ count, items })
);
const { count, items } = useSelector(selectCounter); // shallowEqual 불필요
// 새로운 객체를 만들지 않고 이전 결과를 그대로 반환!!!!!!!!!!!!!!!!
Immer (RTK 기본 포함)
- Redux Toolkit은 내부적으로 Immer 사용
- state를 직접 수정하는 문법처럼 보이지만, 실제로는 불변성 유지된 새로운 객체 반환
- 실제 변경이 일어난 부분의 참조를 바꿔줌
- 스프레드 연산자를 편하게 쓰게해줄 수 있는 기능입니다!!!!!!!!!!
const counterSlice = createSlice({
name: "counter",
initialState: { count: 0, items: [] },
reducers: {
increment: (state, action) => { state.count += action.payload ?? 1; },
addItem: (state, action) => { state.items.push(action.payload); }
}
});
- state.count += 1 처럼 작성 가능 → Immer가 자동으로 새로운 참조 반환
내가 생각하는 redux 장점..
1. 강력한 디버깅 & 시간 여행 (Time-travel)
- Redux DevTools로 가능한 것들:
- 모든 액션 히스토리 확인
- 특정 시점으로 되돌리기
- 액션 재생(replay)
- 상태 변화 추적
2. 예측 가능성 & 엄격한 규칙
// 어디서든 같은 입력 = 같은 출력
reducer(state, action) // 항상 예측 가능
- 순수 함수(reducer)로만 상태 변경
- 불변성 강제
- 액션 → 리듀서 흐름이 명확
4. 대규모 팀 협업
- 명확한 컨벤션 (actions, reducers, types)
- 코드 리뷰가 쉬움
- 역할 분담 명확 (액션 담당자, 리듀서 담당자)
5. SSR & 코드 스플리팅 (이게 가장 꿀인듯 ?)
SSR
// client.js
import { createStore } from 'redux';
import { hydrateRoot } from 'react-dom/client';
// 서버에서 주입한 상태 가져오기
const preloadedState = window.__PRELOADED_STATE__;
delete window.__PRELOADED_STATE__;
// 같은 초기 상태로 store 생성
const store = createStore(rootReducer, preloadedState);
hydrateRoot(
document.getElementById('root'),
<Provider store={store}>
<App />
</Provider>
);
코드 스플리팅
// store/index.ts
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import userReducer from './userSlice';
// 초기에는 필수 reducer만
const staticReducers = {
user: userReducer,
};
export const store = configureStore({
reducer: staticReducers,
});
// 비동기 reducer 저장소
const asyncReducers = {};
// Reducer 동적 주입 함수
export function injectReducer(key, asyncReducer) {
asyncReducers[key] = asyncReducer;
// 기존 + 새로운 reducer 합치기
store.replaceReducer(
combineReducers({
...staticReducers,
...asyncReducers,
})
);
}
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// pages/AdminPage.tsx
import { lazy, Suspense, useEffect } from 'react';
import { injectReducer } from '../store';
function AdminPageWrapper() {
useEffect(() => {
// Admin 페이지 진입 시 reducer 로드
import('../store/adminSlice').then((module) => {
injectReducer('admin', module.default);
});
}, []);
return (
<Suspense fallback={<Loading />}>
<AdminPage />
</Suspense>
);
}
// AdminPage.tsx
function AdminPage() {
// 이제 admin state 사용 가능
const adminData = useSelector((state) => state.admin);
return <div>{adminData}</div>;
}
6. 테스트 용이성
// 순수 함수라 테스트 간단
expect(reducer(state, action)).toEqual(expectedState);
7. 강력한 커뮤니티 & 생태
- Redux Toolkit으로 보일러플레이트 감소
- 무수히 많은 플러그인
- 방대한 자료와 레퍼런스
Zustand
- 스토어 구조 자유로움
- 하나의 전역 store로 관리하거나, 도메인별로 여러 store로 분리 가능
- Redux처럼 단일 store 강제 X
- 단방향 데이터 흐름: set → store → subscribe → component
- Reducer 필요 없음: 상태 업데이트를 set 함수로 직접 수행
- 불변성 유지: 새로운 객체 반환 필요, 필요 시 immer 미들웨어 사용 가능
- 비동기 처리 가능: middleware 없이 async/await 직접 사용 가능
- 간단한 API: create 하나로 store 생성
- DevTools 지원: 미들웨어 연결 시 Redux DevTools 사용 가능
Store 생성
import { create } from "zustand";
interface State {
count: number;
items: string[];
increment: () => void;
addItem: (item: string) => void;
}
export const useStore = create<State>((set) => ({
count: 0,
items: [],
increment: () => set((state) => ({ count: state.count + 1 })),
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));
- Redux와 달리 Reducer 필요 없음
- 필요 시 여러 store를 나눠서 관리 가능
Action
- Redux처럼 별도의 Action Creator 불필요
- store 안의 setter를 이용한 함수가 곧 액션 역할을 할 수 있음!
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
const addItem = useStore((state) => state.addItem);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={() => addItem("Apple")}>Add Item</button>
</div>
);
}
- Redux: dispatch(action)
- Zustand: store 함수 직접 호출 → 코드가 더 직관적
상태 업데이트 & 불변성
- 기본적으로 새로운 객체 반환 필요
set((state) => ({ items: [...state.items, "banana"] }));
- Immer 미들웨어 사용 시 가변 문법 가능
import { immer } from "zustand/middleware/immer";
const useStore = create(
immer((set) => ({
items: [] as string[],
addItem: (item) => set((state) => { state.items.push(item); }),
}))
);
컴포넌트 리렌더링
- Selector 기반 구독
- 선택한 state만 바뀌면 해당 컴포넌트만 리렌더링
const count = useStore((state) => state.count); // count만 구독
const items = useStore((state) => state.items); // items만 구독
- Primitive 값 → 값 비교
- 값 자체가 바뀌면 리렌더링!
- 참조형 값 → 참조 비교
- 참조가 바뀌었을 때만!!!!!!!!!!! 리렌더링
- 기본적으로 얕은 비교 없음 → 필요 시 shallow 사용
const { count, items } = useStore(
(state) => state,
shallow
);
- 혹은, equalityFn을 사용해서 속성을 직접 비교해서 가져오기!
const { count, items } = useStore(
(state) => state,
(a, b) => a.count === b.count && a.items === b.items
);
구조분해할당 주의하기!
const { count, items } = useStore((state) => state);
- 구조분해 할당 자체가 문제라기보다는, selector가 매번 새로운 참조로 바뀐 전체 상태를 반환하는 것이 문제이다!
- 결과적으로 shallow 비교나 equality function 없이 그냥 쓰면, Zustand는 이전과 다른 객체라고 판단 → 리렌더링 발생
해결하기: shallow 사용
import { shallow } from "zustand/shallow";
const { count, items } = useStore(
(state) => state,
shallow
);
- shallow는 객체의 1단계 속성만 비교
- 이전 { count, items }와 새 객체 비교 → count나 items 둘 다 바뀌지 않았다면 리렌더링 없음
해결하기: custom equality function 사용
const { count, items } = useStore(
(state) => state,
(a, b) => a.count === b.count && a.items === b.items
);
- 직접 비교 로직 정의 가능
- 필요한 값만 체크 → 불필요 리렌더링 완벽 방지
비동기 처리
- Redux처럼 middleware 필요 없음
- 그냥 async 함수 작성 가능
const useStore = create((set) => ({
items: [] as string[],
fetchItems: async () => {
const res = await fetch("/api/items");
const data = await res.json();
set({ items: data });
},
}));
주의 할점!!
- 어떤 상황에서든 selector가 항상 새로운 객체를 반환하도록 만들어버리면 무한 렌더링이 발생하므로, 이 경우는 반드시 equalityFn이나 shallow로 memoization을 하자!!!!!!!!!
const { count, items } = useStore((state) => ({
count: state.count,
items: state.items,
}));'Frontend > 개발 관련 지식' 카테고리의 다른 글
| React 이벤트 처리 방식 (0) | 2025.10.14 |
|---|---|
| Suspense ↔ (lazy loading, dynamic import, HTML Streaming) (0) | 2025.09.25 |
| 서버 컴포넌트(RSC) .. ? SSR .. ? (0) | 2025.09.24 |
| Routing (react / next) (0) | 2025.09.22 |
| CSR에서 SSG, SSR, ISR 그리고 Next ?? (0) | 2025.09.22 |
@덕구공 :: Duck9s'
주니어 개발자에욤
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!