본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 10. · 18 Views
getDefaultStore 심화 학습 완벽 가이드
Zustand의 getDefaultStore를 깊이 있게 파헤칩니다. 내부 구현 원리부터 고급 활용 패턴, 성능 최적화까지 실무에 바로 적용할 수 있는 노하우를 담았습니다.
목차
1. getDefaultStore 상세 분석
김개발 씨는 Zustand를 사용하여 프로젝트를 진행하던 중, 공식 문서에서 getDefaultStore라는 API를 발견했습니다. "이게 정확히 뭐지?" 궁금증이 생긴 김개발 씨는 선배인 박시니어 씨에게 물어보기로 했습니다.
getDefaultStore는 Zustand 스토어의 내부 인스턴스에 직접 접근할 수 있게 해주는 API입니다. 일반적으로 React 컴포넌트 내에서 useStore 훅을 사용하지만, React 컴포넌트 외부에서 스토어에 접근해야 할 때 이 API가 필요합니다.
마치 도서관에서 사서에게 책을 요청하는 대신, 직접 서고에 들어가는 것과 같습니다.
다음 코드를 살펴봅시다.
import { create } from 'zustand'
// 기본 스토어 생성
const useUserStore = create<UserState>((set) => ({
user: null,
setUser: (user) => set({ user }),
}))
// getDefaultStore로 스토어 인스턴스 접출
const store = useUserStore.getState
// 컴포넌트 외부에서 상태 읽기
const currentUser = store().user
// 컴포넌트 외부에서 상태 변경
store().setUser({ name: 'Kim', role: 'developer' })
박시니어 씨가 김개발 씨의 코드를 보며 설명을 시작했습니다. "좋은 질문이에요.
getDefaultStore를 이해하려면 먼저 Zustand의 구조를 알아야 합니다." Zustand의 이중 구조 Zustand 스토어는 사실 두 가지 얼굴을 가지고 있습니다. 하나는 React 컴포넌트에서 사용하는 훅(Hook) 형태이고, 다른 하나는 순수한 스토어 객체입니다.
쉽게 비유하자면, 마치 은행 계좌와 같습니다. 일반 고객은 창구 직원(훅)을 통해 계좌에 접근하지만, 특별한 권한을 가진 관리자는 직접 금고(스토어 인스턴스)에 접근할 수 있습니다.
getDefaultStore는 바로 이 금고 열쇠입니다. 왜 필요한가 React 컴포넌트 안에서는 useStore 훅만 사용하면 됩니다.
그런데 실무에서는 컴포넌트 밖에서 스토어에 접근해야 하는 상황이 자주 발생합니다. 김개발 씨도 최근에 이런 문제를 겪었습니다.
API 호출을 담당하는 서비스 레이어에서 사용자 인증 토큰을 가져와야 했는데, 그곳은 React 컴포넌트가 아니었습니다. 훅을 사용할 수 없는 상황이었죠.
또한 WebSocket 연결 관리 클래스에서 현재 사용자 정보를 확인해야 했지만, 역시 React 컴포넌트 외부였습니다. getDefaultStore의 등장 바로 이런 문제를 해결하기 위해 getDefaultStore가 존재합니다.
이 API를 사용하면 어디서든 스토어에 접근할 수 있습니다. 컴포넌트 라이프사이클과 무관하게 동작하며, React의 리렌더링 메커니즘과도 독립적입니다.
무엇보다 타입 안전성을 유지하면서 스토어의 모든 기능을 사용할 수 있다는 큰 장점이 있습니다. 내부 동작 원리 위의 코드를 자세히 살펴보겠습니다.
create 함수로 스토어를 생성하면, 내부적으로 두 가지가 만들어집니다. 첫째는 React 훅으로 사용할 수 있는 useUserStore 함수이고, 둘째는 순수 자바스크립트 객체인 스토어 인스턴스입니다.
getState() 메서드를 호출하면 현재 스토어의 상태 스냅샷을 얻을 수 있습니다. 이것은 단순히 값을 읽는 것이므로 리렌더링을 발생시키지 않습니다.
상태를 변경하는 함수(setUser 등)도 동일하게 접근할 수 있습니다. 실무 활용 사례 실제 프로젝트에서는 어떻게 활용할까요?
예를 들어 axios 인터셉터를 설정한다고 가정해봅시다. 모든 API 요청에 인증 토큰을 자동으로 추가해야 합니다.
인터셉터는 컴포넌트 외부의 설정 파일에 작성되므로, getDefaultStore를 사용하여 스토어에서 토큰을 가져올 수 있습니다. 많은 기업에서 이런 패턴을 적극적으로 사용하고 있습니다.
특히 대규모 애플리케이션에서 비즈니스 로직을 React 컴포넌트에서 분리할 때 매우 유용합니다. 주의사항 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 getState()를 컴포넌트 렌더링 로직에서 직접 사용하는 것입니다. 이렇게 하면 상태가 변경되어도 컴포넌트가 리렌더링되지 않는 문제가 발생할 수 있습니다.
따라서 컴포넌트 내부에서는 반드시 useStore 훅을 사용해야 합니다. 정리 박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다.
"아, 그래서 API 서비스 레이어에서 이걸 쓰면 되는 거군요!" getDefaultStore를 제대로 이해하면 React 컴포넌트와 순수 자바스크립트 로직을 깔끔하게 분리할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 컴포넌트 내부에서는 절대 getState() 사용하지 말고 useStore 훅 사용
- API 서비스, WebSocket, 타이머 등 컴포넌트 외부 로직에서 활용
- 타입 안전성을 위해 항상 제네릭 타입 명시
2. 소스 코드 깊이 읽기
김개발 씨는 getDefaultStore가 어떻게 구현되어 있는지 궁금해졌습니다. "내부 코드를 직접 보면 더 깊이 이해할 수 있지 않을까?" 박시니어 씨가 웃으며 말했습니다.
"좋아요, 함께 소스 코드를 열어봅시다."
Zustand의 create 함수는 내부적으로 createStore와 useStore를 조합하여 동작합니다. 소스 코드를 읽어보면 스토어 인스턴스가 클로저로 관리되며, getState, setState, subscribe 같은 핵심 메서드들이 제공됩니다.
이 구조를 이해하면 Zustand의 동작 원리를 완벽히 파악할 수 있습니다.
다음 코드를 살펴봅시다.
// Zustand 내부 구조 (단순화 버전)
type StoreApi<T> = {
getState: () => T
setState: (partial: Partial<T>) => void
subscribe: (listener: (state: T) => void) => () => void
}
function createStore<T>(initializer: (set, get) => T): StoreApi<T> {
let state: T
const listeners = new Set<(state: T) => void>()
const setState = (partial: Partial<T>) => {
state = { ...state, ...partial }
listeners.forEach(listener => listener(state))
}
const getState = () => state
const subscribe = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
state = initializer(setState, getState)
return { getState, setState, subscribe }
}
박시니어 씨가 Zustand의 GitHub 저장소를 열었습니다. "자, 여기 create 함수의 실제 구현이 있어요.
코드가 생각보다 단순하죠?" 클로저의 마법 Zustand의 핵심은 클로저입니다. 마치 비밀 금고처럼, state 변수는 외부에서 직접 접근할 수 없지만, 반환된 메서드들을 통해서만 조작할 수 있습니다.
김개발 씨가 코드를 가리키며 물었습니다. "이 listeners라는 Set은 뭔가요?" 박시니어 씨가 설명했습니다.
"좋은 질문이에요. 이것은 옵저버 패턴의 구현입니다." 옵저버 패턴의 구현 옵저버 패턴은 신문 구독과 비슷합니다.
여러 구독자(리스너)가 있고, 신문(상태)이 갱신되면 모든 구독자에게 자동으로 배달됩니다. listeners는 Set 자료구조로 관리됩니다.
중복을 자동으로 제거하고, 추가와 삭제가 빠르기 때문입니다. setState가 호출되면 모든 리스너를 순회하면서 새로운 상태를 전달합니다.
setState의 불변성 처리 코드의 setState 부분을 주목해봅시다. 스프레드 연산자를 사용하여 새로운 객체를 생성합니다.
이것은 React의 불변성 원칙을 따르기 위함입니다. 원본 객체를 변경하지 않고 새 객체를 만들어야, React가 변경을 감지하고 리렌더링할 수 있습니다.
getState의 단순함 getState는 놀라울 정도로 단순합니다. 그냥 현재 state를 반환할 뿐입니다.
하지만 이 단순함이 강력한 이유는, 언제 어디서든 호출할 수 있다는 점입니다. 컴포넌트 라이프사이클과 무관하게 동작합니다.
subscribe의 정교함 subscribe 메서드는 리스너를 등록하고, 구독 취소 함수를 반환합니다. 이것은 메모리 누수를 방지하는 핵심 패턴입니다.
실무에서는 컴포넌트가 언마운트될 때 구독을 취소해야 합니다. useEffect의 cleanup 함수에서 반환된 함수를 호출하면, listeners에서 해당 리스너가 제거됩니다.
실제 Zustand는 더 복잡합니다 물론 실제 Zustand 소스 코드는 이보다 훨씬 복잡합니다. 미들웨어 지원, 타입 추론, 성능 최적화 등 많은 기능이 추가로 구현되어 있습니다.
하지만 핵심 원리는 동일합니다. 클로저로 상태를 캡슐화하고, 옵저버 패턴으로 변경을 전파하며, 불변성을 유지하는 것입니다.
미들웨어 체인 Zustand의 강력한 기능 중 하나는 미들웨어입니다. persist, devtools 같은 미들웨어들은 모두 이 기본 스토어 API를 감싸서 기능을 추가합니다.
마치 양파 껍질처럼, 각 미들웨어가 스토어를 감싸면서 기능을 덧붙입니다. 최종적으로 반환되는 것은 여러 겹의 래퍼로 감싸진 스토어이지만, 핵심은 여전히 동일한 createStore입니다.
타입 추론의 비밀 TypeScript를 사용할 때 Zustand가 타입을 자동으로 추론해주는 것도 이 구조 덕분입니다. 제네릭 타입이 create → createStore → StoreApi로 전달되면서, 모든 메서드의 타입이 자동으로 결정됩니다.
정리 김개발 씨가 감탄했습니다. "와, 겉으로 보기엔 복잡해 보이는데 핵심은 정말 단순하네요!" 박시니어 씨가 고개를 끄덕였습니다.
"그래서 Zustand가 인기 있는 거예요. 단순하지만 강력하죠." 소스 코드를 직접 읽어보면 라이브러리에 대한 이해도가 크게 높아집니다.
여러분도 자주 사용하는 라이브러리의 소스 코드를 한번 열어보세요.
실전 팁
💡 - GitHub에서 실제 Zustand 소스 코드 읽어보기 (vanilla.ts 파일부터 시작)
- 클로저와 옵저버 패턴은 상태 관리의 핵심 개념
- 타입 정의 파일(.d.ts)을 보면 API 구조를 한눈에 파악 가능
3. 내부 구현 원리
박시니어 씨가 화이트보드를 꺼냈습니다. "이제 getDefaultStore가 React와 어떻게 연결되는지 그림으로 그려볼까요?" 김개발 씨는 노트북을 열고 메모할 준비를 했습니다.
getDefaultStore는 React의 useSyncExternalStore 훅과 긴밀하게 연동됩니다. Zustand는 React 18의 동시성 기능을 지원하기 위해 내부적으로 이 훅을 사용하며, 스토어의 변경사항을 React 컴포넌트에 안전하게 전파합니다.
이 과정에서 tearing 현상을 방지하고, 일관된 상태를 보장합니다.
다음 코드를 살펴봅시다.
import { useSyncExternalStore } from 'react'
// React와의 연결 (내부 구현 단순화)
function useStore<T, U>(
api: StoreApi<T>,
selector: (state: T) => U = (state) => state as any
): U {
// React 18의 useSyncExternalStore 사용
return useSyncExternalStore(
api.subscribe, // 구독 함수
() => selector(api.getState()), // 현재 상태 스냅샷
() => selector(api.getState()) // 서버 사이드용 스냅샷
)
}
// 실제 사용
const userName = useStore(userStore, (state) => state.user.name)
박시니어 씨가 화이트보드에 그림을 그리기 시작했습니다. 한쪽에는 Zustand Store, 다른 쪽에는 React Component를 그렸습니다.
외부 스토어의 문제 React 입장에서 Zustand 스토어는 외부 스토어입니다. 마치 React 세계 밖에 있는 별도의 우주와 같습니다.
이 둘을 안전하게 연결하는 것이 핵심 과제입니다. 예전에는 이것이 큰 문제였습니다.
React가 동시성 렌더링을 시작하면서, 렌더링 도중 외부 상태가 변경되면 tearing이라는 현상이 발생할 수 있었습니다. 화면의 위쪽과 아래쪽이 서로 다른 상태를 보여주는 것입니다.
useSyncExternalStore의 등장 React 18은 이 문제를 해결하기 위해 useSyncExternalStore라는 훅을 제공했습니다. 이 훅은 세 가지 인자를 받습니다.
첫 번째는 구독 함수입니다. React가 이 함수를 호출하여 스토어의 변경을 구독합니다.
두 번째는 상태 스냅샷을 가져오는 함수입니다. 렌더링 시점의 정확한 상태를 읽습니다.
세 번째는 서버 사이드 렌더링용 스냅샷입니다. Zustand의 영리한 통합 Zustand는 이 훅을 완벽하게 활용합니다.
api.subscribe를 첫 번째 인자로 전달합니다. 이것은 앞서 본 subscribe 메서드입니다.
React가 컴포넌트를 마운트할 때 자동으로 구독하고, 언마운트할 때 자동으로 구독을 취소합니다. 두 번째와 세 번째 인자로는 selector 함수를 래핑한 함수를 전달합니다.
selector는 전체 상태에서 필요한 부분만 선택하는 역할을 합니다. Selector의 최적화 Selector는 성능 최적화의 핵심입니다.
전체 상태 객체가 변경되어도, selector가 반환하는 값이 같다면 컴포넌트는 리렌더링되지 않습니다. 마치 필터처럼, 관심 있는 데이터만 추출합니다.
실무에서는 이것이 매우 중요합니다. 대규모 애플리케이션에서는 스토어에 수십 개의 속성이 있을 수 있는데, 각 컴포넌트는 자신이 필요한 것만 선택하여 불필요한 리렌더링을 방지합니다.
동시성 안전성 React 18의 동시성 기능은 렌더링을 중단하고 재시작할 수 있습니다. 이 과정에서 외부 상태가 변경되면 문제가 발생할 수 있습니다.
useSyncExternalStore는 이것을 감지하고 처리합니다. 렌더링 시작 시점과 완료 시점의 스냅샷을 비교하여, 중간에 변경이 있었다면 렌더링을 다시 시작합니다.
이렇게 해서 항상 일관된 상태를 보장합니다. getState와 subscribe의 조합 코드를 다시 보면, getState와 subscribe가 완벽한 조화를 이룹니다.
subscribe는 "언제" 변경이 일어났는지 알려주고, getState는 "무엇이" 변경되었는지 알려줍니다. React는 이 두 정보를 조합하여 정확한 타이밍에 정확한 값으로 리렌더링합니다.
실무에서의 의미 이 모든 복잡한 로직은 개발자가 신경 쓰지 않아도 됩니다. Zustand가 알아서 처리해주기 때문입니다.
여러분은 그냥 create로 스토어를 만들고, useStore로 사용하면 됩니다. 내부적으로 React 18의 최신 기능을 활용하여 안전하고 효율적으로 동작합니다.
정리 김개발 씨가 정리했습니다. "그러니까 getDefaultStore로 스토어 인스턴스를 만들고, useSyncExternalStore로 React와 연결하는 거군요!" 박시니어 씨가 칭찬했습니다.
"완벽해요!" 내부 구현을 이해하면 라이브러리를 더 자신 있게 사용할 수 있습니다. 문제가 생겼을 때도 원인을 빠르게 파악할 수 있습니다.
실전 팁
💡 - useSyncExternalStore는 React 18 이상에서만 사용 가능
- Selector 함수는 순수 함수여야 하며 매번 동일한 입력에 동일한 출력 반환
- 복잡한 selector는 별도 함수로 분리하여 재사용
4. 고급 사용 패턴
김개발 씨는 이제 기본은 이해했지만, 실무에서 더 고급스럽게 활용하는 방법이 궁금했습니다. "선배님, 실제 프로젝트에서는 어떻게 쓰세요?" 박시니어 씨가 자신의 프로젝트 코드를 열었습니다.
고급 패턴으로는 스토어 팩토리 패턴, 미들웨어 체이닝, 타입 안전한 selector, 동적 스토어 생성 등이 있습니다. 이러한 패턴들을 활용하면 대규모 애플리케이션에서도 깔끔하고 유지보수하기 쉬운 상태 관리를 구축할 수 있습니다.
다음 코드를 살펴봅시다.
// 스토어 팩토리 패턴
const createUserStore = (initialData?: Partial<UserState>) => {
return create<UserState>()((set, get) => ({
user: initialData?.user ?? null,
settings: initialData?.settings ?? {},
// 타입 안전한 액션
updateUser: (updates: Partial<User>) =>
set((state) => ({
user: state.user ? { ...state.user, ...updates } : null
})),
// 비동기 액션
fetchUser: async (id: string) => {
const user = await api.getUser(id)
set({ user })
},
// 셀렉터 메서드
getUserRole: () => get().user?.role ?? 'guest',
}))
}
// 여러 스토어 인스턴스 생성
const adminStore = createUserStore({ user: adminUser })
const guestStore = createUserStore()
박시니어 씨의 화면에는 수백 줄의 코드가 펼쳐져 있었습니다. "우리 회사에서는 멀티 테넌트 시스템을 운영하는데, 각 테넌트마다 별도의 스토어가 필요해요." 스토어 팩토리 패턴 스토어 팩토리는 스토어를 찍어내는 공장과 같습니다.
같은 구조를 가지지만 서로 독립적인 스토어들을 동적으로 생성할 수 있습니다. 김개발 씨가 코드를 보며 감탄했습니다.
"오, 이렇게 하면 사용자마다 다른 스토어를 줄 수 있겠네요!" 맞습니다. 멀티 탭 애플리케이션이나, 테스트 환경에서 격리된 스토어가 필요할 때 매우 유용합니다.
타입 안전성 강화 TypeScript를 사용할 때는 타입 안전성이 핵심입니다. 위 코드에서 updateUser 메서드는 Partial<User> 타입만 받습니다.
잘못된 속성을 전달하면 컴파일 타임에 에러가 발생합니다. 이것은 런타임 버그를 사전에 방지하는 강력한 안전장치입니다.
비동기 액션 패턴 상태 관리에서 비동기 처리는 피할 수 없습니다. fetchUser 같은 비동기 액션을 스토어 내부에 정의하면, 로직이 한곳에 모여 관리하기 쉽습니다.
API 호출, 로딩 상태 관리, 에러 처리를 모두 캡슐화할 수 있습니다. 실무에서는 여기에 로딩 상태와 에러 상태도 추가합니다.
isLoading, error 같은 속성을 함께 관리하면 UI에서 로딩 스피너나 에러 메시지를 쉽게 표시할 수 있습니다. 셀렉터 메서드 패턴 getUserRole 같은 메서드는 파생 상태를 계산합니다.
이것을 스토어 내부에 정의하면 여러 장점이 있습니다. 첫째, 로직이 중복되지 않습니다.
둘째, 테스트하기 쉽습니다. 셋째, 비즈니스 로직이 한곳에 모입니다.
컴포넌트는 이 메서드를 호출하기만 하면 됩니다. 내부 계산 로직이 복잡해져도 컴포넌트는 영향을 받지 않습니다.
미들웨어 조합 Zustand의 미들웨어는 조합할 수 있습니다. persist 미들웨어로 상태를 localStorage에 저장하고, devtools 미들웨어로 Redux DevTools에 연결하고, immer 미들웨어로 불변성 관리를 쉽게 할 수 있습니다.
이 세 가지를 동시에 사용하는 것도 가능합니다. 네임스페이스 패턴 대규모 애플리케이션에서는 스토어를 도메인별로 분리합니다.
userStore, cartStore, productStore처럼 각 도메인마다 별도 스토어를 만들고, 필요한 곳에서 조합하여 사용합니다. 이것은 모듈화와 관심사 분리 원칙을 따르는 것입니다.
동적 스토어 등록 코드 스플리팅과 함께 사용하면 더욱 강력합니다. 특정 페이지를 로드할 때 해당 페이지의 스토어도 동적으로 생성하고, 페이지를 벗어나면 스토어를 정리합니다.
이렇게 하면 메모리를 효율적으로 사용할 수 있습니다. 테스트 친화적 설계 팩토리 패턴은 테스트에서 빛을 발합니다.
각 테스트마다 새로운 스토어 인스턴스를 생성하므로, 테스트 간 상태 오염이 없습니다. 초기 데이터를 주입하여 특정 시나리오를 쉽게 재현할 수 있습니다.
정리 김개발 씨가 메모를 정리하며 말했습니다. "이런 패턴들을 알면 훨씬 세련된 코드를 작성할 수 있겠어요!" 박시니어 씨가 조언했습니다.
"처음부터 복잡하게 만들지 말고, 필요할 때 점진적으로 적용하세요." 고급 패턴은 도구일 뿐입니다. 프로젝트의 요구사항에 맞게 선택적으로 사용하는 것이 중요합니다.
실전 팁
💡 - 처음에는 단순하게 시작하고, 필요할 때 패턴 적용
- 각 도메인마다 별도 스토어 파일로 분리하여 관리
- TypeScript의 타입 추론을 최대한 활용
5. 성능 최적화 팁
프로젝트가 커지면서 김개발 씨는 성능 문제를 경험하기 시작했습니다. 일부 컴포넌트가 불필요하게 자주 리렌더링되는 것 같았습니다.
"선배님, 이거 어떻게 최적화하나요?"
Zustand 성능 최적화의 핵심은 정확한 selector 사용, 얕은 비교 활용, 상태 분할, 메모이제이션입니다. 특히 selector를 잘못 작성하면 모든 상태 변경마다 컴포넌트가 리렌더링될 수 있으므로, 올바른 패턴을 익히는 것이 중요합니다.
다음 코드를 살펴봅시다.
import { shallow } from 'zustand/shallow'
import { useCallback } from 'react'
// 나쁜 예: 매번 새 객체 생성
const BadComponent = () => {
const data = useUserStore((state) => ({
name: state.user.name,
email: state.user.email,
})) // 매번 리렌더링!
return <div>{data.name}</div>
}
// 좋은 예 1: 원시 값만 선택
const GoodComponent1 = () => {
const name = useUserStore((state) => state.user.name)
return <div>{name}</div>
}
// 좋은 예 2: shallow 비교 사용
const GoodComponent2 = () => {
const { name, email } = useUserStore(
(state) => ({ name: state.user.name, email: state.user.email }),
shallow
)
return <div>{name} - {email}</div>
}
// 좋은 예 3: 안정적인 selector 함수
const selectUserInfo = (state) => ({
name: state.user.name,
email: state.user.email,
})
const GoodComponent3 = () => {
const userInfo = useUserStore(selectUserInfo, shallow)
return <div>{userInfo.name}</div>
}
박시니어 씨가 김개발 씨의 코드를 살펴보더니 문제를 바로 찾아냈습니다. "여기 보세요.
selector가 매번 새 객체를 만들고 있어요." 참조 동등성의 함정 JavaScript에서 객체와 배열은 참조로 비교됩니다. 마치 두 사람이 같은 집에 사는지 확인하는 것이 아니라, 같은 주소를 가지고 있는지 확인하는 것과 같습니다.
selector가 매번 새 객체를 반환하면, 내용이 같아도 React는 다른 것으로 판단합니다. 결과적으로 상태가 실제로 변경되지 않았는데도 컴포넌트가 리렌더링됩니다.
김개발 씨의 코드가 바로 이 문제를 겪고 있었습니다. 스토어의 어떤 속성이 변경되든, 해당 컴포넌트가 항상 리렌더링되었습니다.
해결책 1: 원시 값 선택 가장 간단한 해결책은 원시 값만 선택하는 것입니다. 문자열, 숫자, 불린 같은 원시 타입은 값으로 비교됩니다.
따라서 실제로 값이 변경될 때만 컴포넌트가 리렌더링됩니다. 필요한 값이 하나뿐이라면 이 방법이 최선입니다.
해결책 2: shallow 비교 여러 값이 필요하다면 shallow 비교를 사용합니다. shallow 함수는 객체의 첫 번째 레벨만 비교합니다.
모든 속성의 값이 같으면 변경되지 않은 것으로 판단합니다. 이것은 성능과 편의성의 좋은 균형점입니다.
Zustand는 기본적으로 Object.is를 사용하는데, 이것은 참조 동등성을 확인합니다. shallow를 두 번째 인자로 전달하면 얕은 비교로 전환됩니다.
해결책 3: 안정적인 selector selector 함수를 컴포넌트 외부에 정의하면 더욱 좋습니다. 렌더링마다 selector 함수가 재생성되지 않으므로, Zustand가 내부적으로 최적화할 여지가 생깁니다.
또한 여러 컴포넌트에서 같은 selector를 재사용할 수 있습니다. 상태 분할 전략 큰 스토어를 여러 작은 스토어로 분할하는 것도 좋은 전략입니다.
userStore, cartStore, uiStore처럼 도메인별로 분리하면, 각 컴포넌트가 정말 필요한 스토어만 구독합니다. user 정보가 변경되어도 cart를 사용하는 컴포넌트는 영향받지 않습니다.
메모이제이션 활용 복잡한 계산이 필요한 파생 상태는 메모이제이션합니다. useMemo를 사용하여 계산 결과를 캐싱하거나, 스토어 내부에 캐시를 구현할 수 있습니다.
예를 들어 장바구니 총액 계산처럼 비용이 큰 연산은 메모이제이션이 필수입니다. 구독 최적화 subscribe 메서드를 직접 사용할 때는 주의가 필요합니다.
컴포넌트 외부에서 구독할 때는 반드시 cleanup 함수를 호출해야 합니다. 그렇지 않으면 메모리 누수가 발생합니다.
useEffect의 cleanup 함수를 활용하는 것이 안전합니다. DevTools로 프로파일링 React DevTools의 Profiler를 사용하여 실제로 리렌더링을 측정합니다.
추측하지 말고 측정하는 것이 중요합니다. 어떤 컴포넌트가 얼마나 자주 렌더링되는지, selector가 제대로 작동하는지 확인할 수 있습니다.
과도한 최적화 주의 성능 최적화는 필요할 때만 하는 것이 좋습니다. 모든 selector에 shallow를 붙이거나, 모든 값을 메모이제이션할 필요는 없습니다.
실제로 성능 문제가 발생했을 때, 프로파일링 결과를 보고 병목 지점만 최적화하는 것이 현명합니다. 정리 박시니어 씨의 설명을 듣고 김개발 씨가 코드를 수정했습니다.
불필요한 리렌더링이 사라지자 애플리케이션이 훨씬 부드러워졌습니다. "성능 최적화는 측정이 먼저네요!" Zustand는 기본적으로 충분히 빠릅니다.
올바른 selector 패턴만 따라도 대부분의 성능 문제는 해결됩니다.
실전 팁
💡 - 원시 값 선택 > shallow 비교 > 깊은 비교 순으로 고려
- React DevTools Profiler로 실제 리렌더링 측정
- selector 함수는 컴포넌트 외부에 정의하여 재사용
6. 실전 베스트 프랙티스
이제 이론은 충분히 배웠습니다. 김개발 씨가 물었습니다.
"선배님, 실제 프로젝트에서는 이 모든 걸 어떻게 조합해서 쓰나요?" 박시니어 씨가 실전 프로젝트 구조를 보여주었습니다.
실전에서는 폴더 구조, 네이밍 컨벤션, 타입 정의, 테스트 전략, 에러 처리, 로깅을 체계적으로 관리해야 합니다. 팀 프로젝트에서는 일관된 패턴을 유지하는 것이 무엇보다 중요하며, 코드 리뷰와 문서화를 통해 지식을 공유해야 합니다.
다음 코드를 살펴봅시다.
// stores/user/types.ts
export interface User {
id: string
name: string
email: string
role: 'admin' | 'user' | 'guest'
}
export interface UserState {
user: User | null
isLoading: boolean
error: Error | null
}
export interface UserActions {
fetchUser: (id: string) => Promise<void>
updateUser: (updates: Partial<User>) => void
logout: () => void
}
// stores/user/store.ts
export const useUserStore = create<UserState & UserActions>()(
devtools(
persist(
(set, get) => ({
user: null,
isLoading: false,
error: null,
fetchUser: async (id) => {
set({ isLoading: true, error: null })
try {
const user = await api.getUser(id)
set({ user, isLoading: false })
} catch (error) {
set({ error: error as Error, isLoading: false })
logger.error('Failed to fetch user', { id, error })
}
},
updateUser: (updates) =>
set((state) => ({
user: state.user ? { ...state.user, ...updates } : null
})),
logout: () => set({ user: null, error: null }),
}),
{ name: 'user-store' }
)
)
)
// stores/user/selectors.ts
export const selectUser = (state: UserState) => state.user
export const selectIsAdmin = (state: UserState) =>
state.user?.role === 'admin'
export const selectUserName = (state: UserState) =>
state.user?.name ?? 'Guest'
박시니어 씨의 프로젝트 폴더를 열어보니 깔끔하게 정리되어 있었습니다. "우리 팀은 이런 구조를 따르고 있어요." 폴더 구조의 중요성 좋은 폴더 구조는 프로젝트의 지도와 같습니다.
처음 보는 사람도 어디에 무엇이 있는지 쉽게 찾을 수 있어야 합니다. 각 스토어는 독립적인 폴더를 가집니다.
types.ts에는 타입 정의, store.ts에는 스토어 구현, selectors.ts에는 재사용 가능한 셀렉터, hooks.ts에는 커스텀 훅을 배치합니다. 이렇게 하면 파일 하나가 너무 길어지지 않습니다.
각 파일이 단일 책임을 가지므로, 수정할 때도 해당 파일만 열면 됩니다. 타입 우선 설계 TypeScript를 사용한다면 타입 정의부터 시작하는 것이 좋습니다.
State와 Actions를 인터페이스로 분리하면 책임이 명확해집니다. State는 데이터 구조를, Actions는 가능한 동작을 정의합니다.
이것은 API 설계와 비슷한 접근법입니다. 타입을 먼저 정의하면 구현 중에 실수를 줄일 수 있습니다.
컴파일러가 빠진 속성이나 잘못된 타입을 즉시 알려주기 때문입니다. 에러 처리 패턴 실무에서는 에러 처리가 필수입니다.
모든 비동기 액션에 try-catch를 추가하고, 에러를 상태로 저장합니다. UI에서는 이 에러 상태를 확인하여 사용자에게 적절한 메시지를 표시합니다.
로딩 상태도 함께 관리합니다. isLoading을 true로 설정했다가, 작업이 완료되면 false로 되돌립니다.
이것만으로도 로딩 스피너를 쉽게 구현할 수 있습니다. 로깅 전략 프로덕션 환경에서는 문제 추적을 위해 로깅이 중요합니다.
중요한 액션마다 로그를 남깁니다. 사용자 ID, 타임스탬프, 에러 메시지 같은 컨텍스트 정보를 함께 기록하면, 나중에 버그를 재현하고 수정하기 쉽습니다.
Sentry나 LogRocket 같은 서비스와 통합하면 더욱 강력합니다. 실시간으로 에러를 모니터링하고, 스택 트레이스를 확인할 수 있습니다.
미들웨어 활용 persist 미들웨어로 상태를 localStorage에 저장하면, 페이지를 새로고침해도 상태가 유지됩니다. devtools 미들웨어를 사용하면 Redux DevTools로 상태 변화를 시간 여행하듯 추적할 수 있습니다.
이것은 디버깅할 때 엄청나게 유용합니다. 두 미들웨어를 조합할 때는 순서가 중요합니다.
devtools가 가장 바깥에 있어야 persist의 동작도 DevTools에 표시됩니다. 셀렉터 재사용 selectors.ts에 정의한 셀렉터는 여러 컴포넌트에서 재사용합니다.
selectIsAdmin 같은 비즈니스 로직이 담긴 셀렉터는 특히 유용합니다. 권한 확인 로직을 한곳에서 관리하므로, 정책이 바뀌어도 한 곳만 수정하면 됩니다.
테스트 작성 스토어는 순수 자바스크립트 객체이므로 테스트하기 쉽습니다. 각 액션을 호출하고, 상태가 올바르게 변경되었는지 확인하는 단위 테스트를 작성합니다.
비동기 액션은 mock API를 사용하여 테스트합니다. React Testing Library와 함께 사용하면 통합 테스트도 작성할 수 있습니다.
실제 컴포넌트에서 스토어를 사용하는 시나리오를 테스트합니다. 문서화 팀 프로젝트에서는 문서화가 생명입니다.
각 스토어의 README.md에 용도, 주요 액션, 사용 예제를 작성합니다. JSDoc 주석으로 타입과 함수에 설명을 추가하면, IDE에서 자동완성과 함께 도움말이 표시됩니다.
코드 리뷰 새로운 스토어를 추가하거나 기존 스토어를 수정할 때는 반드시 코드 리뷰를 거칩니다. 타입 정의가 명확한지, 에러 처리가 빠짐없는지, 성능 이슈는 없는지 팀원들이 함께 확인합니다.
이 과정에서 지식이 공유되고 코드 품질이 향상됩니다. 점진적 마이그레이션 기존 프로젝트에 Zustand를 도입한다면 한 번에 모두 바꾸지 말고 점진적으로 마이그레이션합니다.
작은 스토어 하나부터 시작하여, 팀이 익숙해지면 점차 확대합니다. Redux나 Context API와도 공존할 수 있으므로, 서두를 필요가 없습니다.
정리 김개발 씨가 감탄했습니다. "체계적으로 관리하니까 정말 깔끔하네요!" 박시니어 씨가 조언했습니다.
"처음부터 완벽할 필요는 없어요. 프로젝트와 함께 구조도 진화시키세요." 베스트 프랙티스는 정답이 아니라 가이드입니다.
여러분 팀의 상황에 맞게 조정하여 사용하세요.
실전 팁
💡 - 폴더 구조는 팀 전체가 합의하고 일관되게 유지
- 타입 정의 파일을 먼저 작성하면 설계가 명확해짐
- 에러 처리와 로깅은 초기부터 계획하여 구현
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
마이크로서비스 배포 완벽 가이드
Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.
Application Load Balancer 완벽 가이드
AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.
고객 상담 AI 시스템 완벽 구축 가이드
AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.
에러 처리와 폴백 완벽 가이드
AWS API 호출 시 발생하는 에러를 처리하고 폴백 전략을 구현하는 방법을 다룹니다. ThrottlingException부터 서킷 브레이커 패턴까지, 실전에서 바로 활용할 수 있는 안정적인 에러 처리 기법을 배웁니다.
AWS Bedrock 인용과 출처 표시 완벽 가이드
AWS Bedrock의 Citation 기능을 활용하여 AI 응답의 신뢰도를 높이는 방법을 배웁니다. 출처 추출부터 UI 표시, 검증까지 실무에서 바로 사용할 수 있는 완전한 가이드입니다.