지난 1편에서는 Zustand를 이용해 간단한 카운터 애플리케이션을 만들며 상태 관리의 첫걸음을 떼었습니다.
create 함수로 스토어를 만들고, 컴포넌트에서 훅을 호출해 상태를 사용하는 것이 얼마나 간결한지 경험하셨을 겁니다.
이번 2편에서는 그 표면 아래에 숨어있는 Zustand의 진짜 힘을 탐험해 보겠습니다.
더욱 안전하고 명확하게 스토어를 설계하는 방법, 불필요한 렌더링을 막아 애플리케이션 성능을 극대화하는 비결, 그리고 리액트 컴포넌트 바깥에서도 상태를 제어하는 고급 기술까지, Zustand를 단순한 '상태 저장소'가 아닌, 정교한 '상태 관리 시스템'으로 활용하기 위한 핵심 개념들을 하나하나 파헤쳐 보겠습니다.
Zustand 스토어의 모든 것은 create 함수로부터 시작됩니다.
타입스크립트와 함께 사용하면, 이 작은 함수는 우리에게 엄청난 안정성과 개발 편의성을 제공합니다.
먼저 스토어에 들어갈 상태(state)와 행동(actions)들의 타입을 각각 정의하는 것이 좋습니다.
store/userStore.ts
// 1. 상태(State)와 행동(Actions)에 대한 타입 정의interface UserState { name: string; email: string; isLoggedIn: boolean;}interface UserActions { setName: (name: string) => void; login: (email: string) => void; logout: () => void;}// 2. 두 타입을 합쳐 스토어의 전체 타입을 정의type UserStore = UserState & UserActions;
이렇게 상태와 액션을 분리하여 타입을 정의하면, 스토어의 구조를 한눈에 파악하기 쉬워집니다.
이제 이 타입을 create 함수에 제네릭(<>)으로 전달하여 스토어를 생성합니다.
store/userStore.ts
import { create } from 'zustand'// ... 타입 정의에 이어서 작성 ...// 3. create 함수에 타입을 적용하여 스토어 생성const useUserStore = create<UserStore>((set) => ({ // 초기 상태 (State) name: 'Guest', email: '', isLoggedIn: false, // 상태를 변경하는 함수들 (Actions) setName: (newName) => set({ name: newName }), login: (userEmail) => set({ isLoggedIn: true, email: userEmail, name: '사용자' // 로그인 시 기본 이름 설정 }), logout: () => set({ isLoggedIn: false, email: '', name: 'Guest' }),}));export default useUserStore;
create<UserStore>(...) 와 같이 타입을 명시해주면, 스토어 내부에서 정의하는 객체가 UserStore 타입을 반드시 만족해야 한다고 타입스크립트에게 알려주는 것과 같습니다.
만약 name 속성을 빼먹거나 login 함수의 매개변수 타입을 틀리게 작성하면, 즉시 에러를 발견할 수 있어 실수를 방지해 줍니다.
만약 다른 컴포넌트에서 setName 함수만 호출해서 name을 변경해도, UserProfile 컴포넌트는 리렌더링됩니다. 이건 우리가 원하는 동작입니다.
하지만, 만약 이 컴포넌트가 name만 사용하고 email이나 isLoggedIn은 사용하지 않는다면 어떨까요?
email이 변경될 때도 이 컴포넌트가 불필요하게 리렌더링되는 것은 낭비입니다.
selector는 스토어에서 내가 필요한 상태만 콕 집어 선택하는 기능입니다.
useUserStore 훅의 인자로 selector 함수를 전달하면 됩니다.
components/UserNameDisplay.tsx
import useUserStore from '../store/userStore';function UserNameDisplay() { // state 객체에서 name 속성만 선택합니다. const name = useUserStore((state) => state.name); console.log('UserNameDisplay 컴포넌트가 리렌더링 되었습니다!'); return <p>사용자 이름: {name}</p>;}
이제 UserNameDisplay 컴포넌트는 useUserStore의 name 상태가 변경될 때만 리렌더링됩니다.
email이나 isLoggedIn 상태가 아무리 바뀌어도 이 컴포넌트는 전혀 영향을 받지 않습니다.
Zustand는 내부적으로 이전 name 값과 새로운 name 값을 Object.is (거의 === 와 동일)로 비교하여 변경 여부를 판단합니다.
만약 이름(name)과 로그인 액션(login) 두 가지가 필요하다면 어떻게 할까요?
다음과 같이 코드를 작성하면 문제가 발생합니다.
// 이렇게 사용하면 안 됩니다!const { name, login } = useUserStore((state) => ({ name: state.name, login: state.login }));
이 코드는 상태가 변경되지 않아도
매번 리렌더링
됩니다.
왜냐하면 리액트 컴포넌트가 렌더링될 때마다 (state) => ({...}) 부분이 실행되면서, 내용은 같지만 메모리 주소는 다른
새로운 객체
가 계속해서 만들어지기 때문입니다.
Zustand는 이전 객체와 새 객체를 ===로 비교하고, 다르다고 판단하여 항상 리렌더링을 발생시킵니다.
이 문제를 해결하기 위해 shallow 함수를 사용합니다.
components/LoginManager.tsx
import useUserStore from '../store/userStore';import { shallow } from 'zustand/shallow';function LoginManager() { // shallow 함수를 두 번째 인자로 전달합니다. const { name, login, logout } = useUserStore( (state) => ({ name: state.name, login: state.login, logout: state.logout, }), shallow // 이 부분이 핵심인데, shallow를 사용하면 객체 내부의 값들을 1단계 깊이에서 비교합니다. ); console.log('LoginManager 컴포넌트가 리렌더링 되었습니다!'); // ...}
shallow는 객체 내부의 값들을 1단계 깊이에서 비교(shallow equality check)합니다.
객체 자체가 아니라 객체 안의 name, login, logout 값들이 실제로 변경되었을 때만 리렌더링을 발생시킵니다.
덕분에 불필요한 렌더링을 완벽하게 막을 수 있습니다.
subscribe는 스토어의 상태가 변경될 때마다 특정 함수를 실행하도록 등록하는 기능입니다.
리액트의 useEffect와 비슷하지만, 컴포넌트 생명주기와는 관계없이 스토어의 변화 자체를 감지합니다.
// logger.tsimport useUserStore from './store/userStore';console.log('초기 이름:', useUserStore.getState().name);// 상태 변경을 구독합니다.const unsubscribe = useUserStore.subscribe( (state, prevState) => { // state: 변경 후 상태, prevState: 변경 전 상태 if (state.name !== prevState.name) { console.log(`이름이 변경되었습니다: ${prevState.name} -> ${state.name}`); } });// 나중에 구독을 취소하고 싶을 때 이 함수를 호출합니다.// unsubscribe();
subscribe는 구독을 취소할 수 있는 unsubscribe 함수를 반환합니다. useEffect의 클린업 함수처럼, 필요 없어졌을 때 메모리 누수를 방지하기 위해 반드시 호출해 주는 것이 좋습니다.
destroy는 스토어와 관련된 모든 구독(subscribe)을 초기화하고 메모리에서 제거하는 함수입니다.
일반적인 싱글 페이지 애플리케이션에서는 거의 사용할 일이 없지만, 여러 개의 독립적인 앱이 동작하는 마이크로 프론트엔드 환경 등에서 특정 모듈이 제거될 때 관련 스토어를 깨끗하게 정리하는 용도로 사용할 수 있습니다.
useUserStore.destroy() 와 같이 호출합니다.
리액트에서는 상태를 업데이트할 때 '불변성'을 지키는 것이 매우 중요합니다.
즉, 기존 상태 객체나 배열을 직접 수정하는 대신, 항상 새로운 복사본을 만들어 변경해야 합니다.
Zustand의 set 함수는 이 불변성 유지를 매우 쉽게 만들어 줍니다.
// 나쁜 예: 상태를 직접 수정 (Don't do this!)// set((state) => {// state.isLoggedIn = true; // 원본 state 객체를 직접 변경// return state;// });// 좋은 예: 새로운 객체를 반환set((state) => ({ isLoggedIn: true }));
set((state) => ({...})) 구문은 현재 상태(state)를 받아서, 변경이 필요한 부분만 포함된
새로운 객체
를 반환합니다.
Zustand는 이 새로운 객체를 기존 상태와 병합하여 전체 상태를 업데이트합니다.
이 과정은 자동으로 불변성을 지켜주기 때문에, 우리는 복잡한 스프레드 연산자(...)나 Immer와 같은 라이브러리 없이도 안전하게 상태를 관리할 수 있습니다.
이번 시간에는 타입스크립트를 활용한 안전한 스토어 설계부터 시작하여, selector와 shallow를 이용한 정교한 성능 최적화 방법, 그리고 get, subscribe와 같은 고급 스토어 API 사용법까지 깊이 있게 알아보았습니다.
이 개념들은 Zustand를 단순한 상태 저장소에서 벗어나, 복잡한 애플리케이션의 상태를 효율적이고 안정적으로 관리하는 강력한 도구로 만들어주는 핵심 열쇠입니다.
특히 selector의 원리를 이해하고 활용하는 것은 Zustand를 사용하는 모든 개발자에게 가장 중요한 기술이라고 할 수 있습니다.
다음 3편에서는 오늘 배운 개념들을 총동원하여, 실전 예제인 'To-Do 리스트 앱'을 함께 만들어 보겠습니다.