React 상태 관리 - Zustand

Take!·2025년 8월 2일

JavaScript

목록 보기
8/12

시작하며

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>

구독자 관리에 Set을 사용하는 이유

  • 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>

React와의 통합

  • React에서 이 패턴을 활용하기 위해서는 커스텀 훅을 만들어야 한다.
<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>

Selector란?

  • Selector는 전체 상태에서 필요한 부분만 선택(추출)하는 함수이다.
<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>
}

고급 기능 구현

1. 조건부 구독

<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>

2. 미들웨어 지원

<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>

3. Selector Factory 패턴

<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>

Zustand

<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>

정리

  • 구독/알림 패턴은 상태 관리의 핵심

    • Redux: useSelector로 선택적 구독
    • MobX: Observer 패턴으로 자동 추적
    • Valtio: Proxy 기반 반응형 상태
    • Jotai: Atomic 단위의 구독 관리
  • Set 사용의 이점

    • 함수 중복 사용 방지
    • 효율적인 추가/삭제 --> O(1)
    • 참조 기반 관리
  • Selector의 중요성

    • 필요한 데이터만 구독
    • 참조 안정성 유지
    • 계산된 값 제공
  • React와 통합

    • useSyncExternalStore 활용
    • 커스텀 훅으로 편의성 제공
    • 자동 구독 해제로 메모리 누수 방지
  • 성능 최적화

    • 선택적 구독
    • 얕은 비교
    • 배치 업데이트

마무리하며...

구독/알림 패턴은 현재 많은 상태 관리 라이브러리에서 기본이 되는 아이디어로 작용한다. 이 패턴을 사용하여 Vanilla JS로 여러 application을 개발하면서 동작 원리를 체화한다면, 앞으로 트렌드에 따라 어떤 라이브러리가 나와도 기술을 단순히 익히는 것 이상으로 잘 사용할 수 있지 않을까 라는 생각이 들었다.

profile
확장성 있는 설계와 유지보수가 용이한 클린 코드 지향하는 개발자입니다.

0개의 댓글