한 걸음 더 — selectors · `Object.keys` · `persist()` 자세히 보기

useCollectionStore의 코드를 다시 펼쳐 놓고, 세 부분을 깊게 들여다봅니다.
export const useCollectionStore = create<...>()( persist( // ← ③ 왜 감쌌나? (set) => ({ favorites: {}, // ... }), { name: "cat-collection", ... }, ), ); export const selectFavoriteCount = (state: CollectionState) => // ← ① selector Object.keys(state.favorites).length; // ← ② Object.keys export const selectFavoriteIds = (state: CollectionState) => Object.keys(state.favorites);
① Selector — 정확히 뭐고, 왜 쓰는가
Selector 는 단순히 "store 상태에서 필요한 값을 꺼내는(또는 계산하는) 함수" 입니다. 모양은 한결같이 이렇습니다.
(state) => 어떤_값
state는 store 의 현재 상태 전체이고, 반환값이 우리가 꺼낸 또는 계산한 값입니다.
const selectFavoriteCount = (state) => Object.keys(state.favorites).length; // └─ 계산해서 ────────────────────┘ // └─ 숫자 하나 돌려줌 const selectItems = (state) => state.items; // └─ 그냥 꺼내기만 —┘
쓸 때는 그 selector 함수를 useCollectionStore 에 넘깁니다.
const count = useCollectionStore(selectFavoriteCount);
즉, "useCollectionStore 야, selectFavoriteCount 가 돌려주는 값만 구독할게."
왜 함수로 따로 빼는가 — 네 가지 이유
(가) 부분 구독 — 필요한 부분만 듣는다
Zustand 의 가장 큰 장점입니다. 컴포넌트가 selector 가 돌려준 그 값이 바뀔 때만 다시 렌더링됩니다.
// CartBadge 는 총 개수만 보면 된다 → 합계 selector 만 구독 const totalCount = useCollectionStore(selectFavoriteCount); // → 어떤 항목의 메모가 바뀌어도, 총 개수가 안 변하면 CartBadge 는 다시 안 그려진다.
selector 가 좁을수록 렌더링이 줄어 큰 화면에서도 부드럽습니다.
(나) 파생값 계산을 한 곳에 모은다
favorites 배열 안에서 "개수" 나 "총액" 같은 값을 매번 컴포넌트에서 계산하면 같은 코드가 여기저기 흩어집니다. selector 에 모아 두면 한 줄로 끝납니다.
// 흩어진 모습 — 곳곳에서 같은 계산을 반복 const headerCount = Object.keys(store.favorites).length; const sidebarCount = Object.keys(store.favorites).length; const badgeCount = Object.keys(store.favorites).length; // 모인 모습 const count = useCollectionStore(selectFavoriteCount); // 한 줄
selectFavoriteCount 의 정의가 바뀌면(예: "별점 매긴 즐겨찾기만 세기") 한 곳만 고치면 모든 컴포넌트가 일관되게 따라옵니다.
(다) 재사용
여러 컴포넌트가 같은 selector 를 공유합니다.
// Header 에서 const count = useCollectionStore(selectFavoriteCount); // FavoritesView 에서 const count = useCollectionStore(selectFavoriteCount);
한 번 정의해 두면 어디서든 가져다 씁니다.
(다) 안정적 참조 — 모듈 스코프에 두는 이유
selector 를 컴포넌트 안에 인라인으로 두면 매 렌더링마다 새 함수 가 만들어집니다.
// 좋지 않음 — 매 렌더 새 함수 function Header() { const count = useCollectionStore((s) => Object.keys(s.favorites).length); // ... } // 권장 — 모듈 스코프에 한 번만 정의 export const selectFavoriteCount = (s) => Object.keys(s.favorites).length; function Header() { const count = useCollectionStore(selectFavoriteCount); // ... }
Zustand 가 "같은 selector 인지" 비교할 때 모듈 스코프 함수는 참조가 같아 비교가 빠릅니다. 큰 화면에서 이 작은 차이가 쌓이면 의미가 있어요.
인라인 selector 는 언제 OK?
단순 속성 접근((s) => s.items)은 인라인으로 둬도 됩니다. 그건 일종의 관용 — 모두가 알아보는 모양이라.
파생값을 계산하거나, 여러 컴포넌트에서 쓴다면 모듈 스코프의 이름 있는 selector 로 빼는 게 정답입니다.
② Object.keys(state.favorites) — 이 문법은 무슨 뜻인가
Object.keys 자체
자바스크립트 내장 함수입니다. 객체를 받아서 그 객체의 키들을 문자열 배열로 돌려줍니다.
const person = { name: "민준", age: 30, role: "개발자" }; Object.keys(person); // → ["name", "age", "role"]
인자는 객체 하나. 반환은 그 객체의 모든 키를 모은 배열.
state.favorites 의 모양
타입을 풀어 보면:
favorites: Record<string, Favorite> // └─ 키는 string, 값은 Favorite 인 객체 ─┘
Record<string, Favorite>는 그냥 객체 입니다. 예를 들어:
favorites = { "1": { id: "1", rating: 5, note: "최애" }, "3": { id: "3", rating: 4, note: "" }, "6": { id: "6", rating: 0, note: "귀여움" }, }
여기서 키는 "1", "3", "6" — 고양이 id 들입니다.
둘을 합치면
Object.keys(state.favorites) // → ["1", "3", "6"]
즉 저장된 즐겨찾기의 id 들 을 배열로 받습니다.
.length 를 붙이면:
Object.keys(state.favorites).length // → 3
저장된 즐겨찾기의 개수 가 됩니다. 그래서 selectFavoriteCount 가 Object.keys(...).length 인 거예요.
왜 객체에 객체를 통째로 넣는가
"Object.keys() 함수에 왜 객체를 넣는 거죠?"
Object.keys 는 함수입니다. 함수는 인자가 필요해요. "어떤 객체의 키를 알려달라" 고 물어보려면 그 객체 자체를 인자로 줘야 합니다.
Object.keys(favorites) // ← favorites 라는 객체를 통째로 건넨다 // ↑↑↑↑↑↑↑↑↑↑ 인자
"키들만 골라서 넣는다" 가 아니라, "이 객체를 줄 테니, 너(Object.keys)가 키들을 뽑아 다오" 라는 뜻이에요. 함수 사용의 일반 문법이에요 — Math.max(1, 5, 3) 에서 1·5·3 을 통째로 주고 Math.max 가 최댓값을 골라 주는 것과 같습니다.
Object.keys 의 친구들
Object.keys 만 있는 게 아닙니다. 비슷한 셋이 함께 다닙니다.
const favorites = { "1": { id: "1", rating: 5, note: "최애" }, "3": { id: "3", rating: 4, note: "" }, }; Object.keys(favorites); // → ["1", "3"] ← 키만 Object.values(favorites); // → [ { id:"1", ...}, { id:"3", ...} ] ← 값만 Object.entries(favorites); // → [ ["1", { id:"1", ...}], ["3", { id:"3", ...}] ] ← [키, 값] 쌍
쓰임:
// 즐겨찾기 별점의 평균을 구하고 싶다면 → values 가 자연스러움 const avg = Object.values(state.favorites).reduce((sum, f) => sum + f.rating, 0) / Object.values(state.favorites).length; // id 와 별점을 묶어서 출력하고 싶다면 → entries Object.entries(state.favorites).forEach(([id, fav]) => { console.log(`${id}: ★${fav.rating}`); });
이 세 가지는 객체를 배열처럼 다루고 싶을 때 가장 자주 꺼내 쓰는 도구입니다.
③ persist() 미들웨어 — 왜 감쌌나
이 한 줄 차이가 "새로고침하면 모두 사라진다" 와 "새로고침해도 그대로 있다" 를 가릅니다.
감싸지 않은 모습 — 메모리에만 산다
// 가상의 "persist 없는" 버전 export const useCollectionStore = create<...>( (set) => ({ favorites: {}, toggleFavorite: (id) => set(...), // ... }), );
이렇게 두면 favorites 가 메모리에만 존재합니다. 페이지를 새로고침하면 메모리가 비워지면서 — 사용자가 저장한 모든 즐겨찾기가 사라집니다.
persist() 로 감싼 모습 — localStorage 와 동기화
export const useCollectionStore = create<...>()( persist( (set) => ({ favorites: {}, // ... }), { name: "cat-collection", storage: createJSONStorage(() => localStorage), }, ), );
persist() 는 우리가 짠 store 함수를 한 번 감싸는 함수입니다. 이런 식으로 다른 함수를 감싸서 동작을 추가하는 함수를 흔히 미들웨어(middleware) 라고 부릅니다.
persist 가 자동으로 해 주는 두 가지:
- store 가 만들어질 때 (페이지 로드 시) →
localStorage의"cat-collection"키에서 데이터가 있으면 읽어 와favorites에 채워 넣음. - 상태가 바뀔 때마다 → 새 상태를
JSON.stringify해서localStorage의 같은 키에 자동 저장.
우리가 setItem/getItem 을 직접 부르지 않아도 됩니다. 그게 미들웨어의 보상이에요.
옵션 두 줄의 의미
{ name: "cat-collection", storage: createJSONStorage(() => localStorage), }
name: "cat-collection"—localStorage에서 어느 키에 저장할지. 이 키를 바꾸면 마치 "리셋" 처럼 동작합니다(이전 데이터는 다른 키에 남아 있게 됨).storage: createJSONStorage(() => localStorage)— 어떤 저장소에 둘지.localStorage대신sessionStorage를 주면 탭을 닫을 때 사라지는 저장소가 됩니다.createJSONStorage는 객체를 자동으로JSON.stringify/JSON.parse해 주는 어댑터예요.
persist 와 useHydrated 가 짝인 이유
persist 의 단점이 하나 있습니다. 서버에는 localStorage 가 없어서, SSR 때는 항상 초기 상태(favorites: {}) 로 그려진다는 것. 클라이언트에서 hydration 후에야 persist 가 localStorage 에서 진짜 값을 복원합니다.
그 짧은 시간 동안 화면이 "빈 카트 → 채워진 카트" 로 점프하면 — 별점 배지가 (0) → (3) 으로 튀거나, hydration mismatch 경고가 뜹니다.
// useHydrated 없이 function Header() { const count = useCollectionStore(selectFavoriteCount); return <div>{count}</div>; // 서버: 0 // 클라이언트 첫 렌더: 0 (아직 persist 복원 전이므로) // ... persist 복원 후: 3 // 화면이 0 → 3 으로 튐! } // useHydrated 와 함께 function Header() { const hydrated = useHydrated(); const count = useCollectionStore(selectFavoriteCount); return <div>{hydrated ? count : 0}</div>; // 서버: 0 // 클라이언트 첫 렌더: 0 (hydrated=false 라 강제로 0 표시) // useEffect 실행 → hydrated=true → 3 // 이때는 hydration 이 이미 끝났으므로 자연스러운 갱신 }
persist + useHydrated 가 현업의 표준 짝 인 이유입니다.
정리
- Selector = "state 에서 값을 꺼내거나 계산해 돌려주는 함수". 부분 구독 / 파생값 / 재사용 / 안정적 참조를 위해 모듈 스코프에 둔다.
Object.keys(obj)= 객체의 키들을 배열로 반환하는 자바스크립트 내장 함수. 객체를 통째로 인자로 넘긴다. 친구로Object.values,Object.entries가 있다.persist()= Zustand 미들웨어. store 함수를 한 번 감싸 읽기/저장 을localStorage와 자동 동기화. 새로고침에 데이터가 살아남는다. SSR 환경에선useHydrated와 짝으로.

댓글
댓글을 작성하려면 이 필요합니다.