React의 상태 관리에 대해 공부하면서 JS의 구독/알림 패턴까지 들어오게 되었다. 이 글에서는 필자가 이해되지 않았던 부분들을 중심으로(철저히) 세세하게 구독/알림 패턴부터 React의 상태관리, 더 나아가 많이들 사용하고 있는 Zustand에 대해서도 알아보도록 하겠다
(당연히 다음 세션은 Jotai...)
구독/알림 패턴은 Observer Pattern의 한 형태로, 특정 상태를 관찰하고 있는 여러 구독자(subscriber)들에게 상태 변화가 발생했을 때, 자동으로 알림을 보내는 방식이다.
이 패턴의 핵심은 다음과 같다.
<script>
class Store {
constructor(initialState) {
this.state = initialState;
this.subscribers = new Set();
}
// 구독자 등록 메서드
subscribe(callback) {
this.subscribers.add(callback);
// 구독을 취소할 수 있는 함수를 반환 (클로저 활용)
return () => {
this.subscribers.delete(callback);
};
};
// 상태 변경 및 알림
setState(newState) {
this.state = { ...this.state, ...newState }
this.notify(); // 알림
};
// 모든 구독자에게 알리는 메서드
notify() {
this.subscribers.forEach(callback => callback(this.state));
};
// 상태값 반환
getState() {
return this.state;
};
}
</script>
Array 대신 Set을 사용하는 이유는 간단하다. 중복방지, 연산 속도, 참조 기반
중복 방지 : 같은 callback이 여러번 등록되는 것을 방지
연산 속도 : Set()의 delete 연산이(O(1)) Array의 splice보다(O(n)) 더 빠름.
참조 기반 : 함수의 참조로 관리하므로 정확한 해제 가능.
<script>
// ❌ Array 사용 시의 문제점
this.subscribers.push(callback) // 중복 가능
const index = this.subscribers.indexOf(callback)
this.subscribers.splice(index, 1) // O(n) 연산
// ✅ Set 사용의 장점
this.subscribers.add(callback) // 중복 자동 방지, O(1)
this.subscribers.delete(callback) // O(1) 연산
</script>
<script>
const store = new Store({ count: 0, name: wtlee });
// 구독자1 UI 업데이트
const unsubscribe1 = store.scribe((state) => {
console.log('UI 업데이트:', state)
document.getElementById('count').textContent = state.count;
});
// 구독자2 로깅
const unsubscribe2 = store.subscribe((state) => {
console.log(`로그 : Count의 변화 ${state.count}`);
});
// 상태 변경
store.subscribe({ count: 1 });
store.subscribe({ count: 2 });
// 구독 해제
unsubscribe1();
store.setState({ count: 3 }); // 구독자2 로깅 -> 로그 : Count의 변화 3
</script>
<script>
import { useSyncExternalStore } from "react" // useSyncExternalStore훅은 외부 상태와 연동하기 위한 훅
const useStoreValue = (store, selector = (state) => state) => {
return useSyncExternalStore(
store.subscribe.bind(store),
() => selector(store.getState()),
() => selector(store.getState())
)
}
// React Component 에서 실제 사용
function Counter() {
const count = useStoreValue(store, state => state.count)
return (
<div>
<p>Count: {count}</p>
<button
onClick={() => store.setState({ count: count + 1 })}
>
증가
</button>
</div>
)
}
</script>
<script>
const state = {
user: { name: 'wtlee', age: 35 },
todos: [{ id: 1, text: 'Learn Zustand' }],
counter: { count: 5 },
ui: { loading: false }
}
const selectCount = (state) => state.counter.count; // 5
const selectUserName = (state) => state.user.name; // 'wtlee'
const selectTodoCount = (state) => state.todos.length // 1
// 복합 selector
const selectFullInfo = (state) => `${state.user.name}: ${state.counter.count}`
</script>
<script>
function UserInfo() {
const userName = useStoreValue(store, state => state.user.name);
return <h1>Hello, {userName}</h1> // user.name 변경시에만 리렌더링
}
function Counter() {
const count = useStoreValue(store, state => state.counter.count);
return <p>Count: {count}</p> // counter.count 변경시에만 리렌더링
}
</script>
// ❌ 매번 새로운 함수 생성 - 성능 저하
function Counter() {
const count = useStoreValue(store, (state) => state.count); // 새로운 함수
return <div>{count}</div>
}
// ✅ 컴포넌트 외부에 selector 정의
const selectCount = (state) => state.count
function Counter() {
const count = useStoreValue(store, selectCount); // 동일한 참조 유지
return <div>{count}</div>
}
// ✅ 또는 useCallback 사용
function Counter() {
const selector = useCallback((state) => state.count, [])
const count = useStoreValue(store, selector)
return <div>{count}</div>
}
<script>
class AdvancedStore extends Store {
subscribe(callback, selector = (state) => state) {
let previousValue = selector(this.state)
const wrappedCallback = (state) => {
const currentValue = selector(state)
// 값이 실제로 변경될 때만 콜백 실행
if (currentValue !== previousValue) {
previousValue = currentValue
callback(currentValue, state)
}
}
this.subscribers.add(wrappedCallback)
return () => this.subscribers.delete(wrappedCallback)
}
}
// 특정 필드만 구독
store.subscribe(
(count) => console.log('Count changed:', count),
(state) => state.count // count가 변경될 때만 호출
)
</script>
<script>
class MiddlewareStore extends Store {
constructor(initialState) {
super(initialState)
this.middlewares = []
}
use(middleware) {
this.middlewares.push(middleware)
}
setState(newState) {
let finalState = { ...this.state, ...newState }
// 미들웨어 체인 실행
for (const middleware of this.middlewares) {
finalState = middleware(this.state, finalState) || finalState
}
this.state = finalState
this.notify()
}
}
// 로깅 미들웨어
const loggingMiddleware = (prevState, nextState) => {
console.log('State changed:', { prevState, nextState })
return nextState
}
store.use(loggingMiddleware)
</script>
<script>
// ID 기반으로 특정 아이템을 선택하는 factory
const createSelectTodoById = (id) => (state) =>
state.todos.find(todo => todo.id === id)
function TodoItem({ id }) {
const selector = useMemo(() => createSelectTodoById(id), [id])
const todo = useStoreValue(store, selector)
return <div>{todo?.text}</div>
}
</script>
<script>
// 스토어 설정
const store = new Store({
user: { name: 'wtlee', email: 'wtlee@example.com' },
counter: { count: 0 },
todos: []
})
// Selector 정의 -> useCallback으로 해도 무관.
const selectCount = (state) => state.counter.count
const selectUserName = (state) => state.user.name
const selectTodoCount = (state) => state.todos.length
// 컴포넌트들
function UserInfo() {
const userName = useStoreValue(store, selectUserName)
return <h1>Hello, {userName}!</h1>
}
function Counter() {
const count = useStoreValue(store, selectCount)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => store.setState({
counter: { count: count + 1 }
})}>
증가
</button>
</div>
)
}
function App() {
return (
<div>
<UserInfo /> {/* user.name 변경 시에만 리렌더링 */}
<Counter /> {/* counter.count 변경 시에만 리렌더링 */}
</div>
)
}
</script>
<script>
import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}))
// 내부적으로 구독/알림 패턴 사용
function Counter() {
const { count, increment } = useStore()
return <button onClick={increment}>{count}</button>
}
</script>
구독/알림 패턴은 상태 관리의 핵심
Set 사용의 이점
Selector의 중요성
React와 통합
성능 최적화
구독/알림 패턴은 현재 많은 상태 관리 라이브러리에서 기본이 되는 아이디어로 작용한다. 이 패턴을 사용하여 Vanilla JS로 여러 application을 개발하면서 동작 원리를 체화한다면, 앞으로 트렌드에 따라 어떤 라이브러리가 나와도 기술을 단순히 익히는 것 이상으로 잘 사용할 수 있지 않을까 라는 생각이 들었다.