`features` 레이어에서 여러 `entities` 상태를 조합하는 법: 파생 상태 설계와 Zustand selector 리렌더 최적화
React와 Zustand, TanStack Query를 실제 프로젝트에서 써본 경험이 있는 개발자라면 이 글에서 바로 적용할 수 있는 내용을 찾을 수 있을 겁니다.
프론트엔드 코드베이스가 커지면 어느 순간 이런 상황을 맞닥뜨리게 됩니다. 장바구니 화면에서 상품 목록, 사용자 정보, 쿠폰 상태를 한꺼번에 보여줘야 하는데, 이 세 가지가 각각 다른 레이어에, 다른 방식으로 관리되고 있는 거죠. 결국 totalPrice를 계산하는 코드가 컴포넌트 세 군데에 흩어졌고, 어느 한 쪽이 업데이트되면 다른 쪽이 뒤처지는 일이 반복됩니다. 저도 처음엔 그냥 컴포넌트 안에서 useUserStore() 전체를 구독하고, useQuery로 가져온 데이터를 useState에 다시 담아서 관리했는데, 어느 순간 상태가 두 군데서 충돌하고 리렌더가 폭발하는 걸 경험했습니다.
이 글의 전략은 단 두 가지 원칙으로 압축됩니다. 서버 상태와 클라이언트 상태를 명확히 분리하고, selector로 구독 범위를 좁히는 두 가지 원칙만 제대로 잡으면 불필요한 리렌더와 동기화 버그를 동시에 잡을 수 있습니다. Feature-Sliced Design(FSD) 아키텍처에서 features 레이어가 왜 여러 entities의 상태를 조합하기 적합한 위치인지, 그리고 TanStack Query의 select와 Zustand의 selector + useShallow를 어떻게 조합해서 파생 상태를 깔끔하게 설계할 수 있는지를 실전 코드와 함께 풀어냅니다.
핵심 개념
파생 상태와 SSOT
쉽게 말하면, 파생 상태(Derived State) 는 하나 이상의 원본 상태(source of truth)에서 계산·조합해 만들어지는 값입니다. 원본이 바뀌면 자동으로 갱신되며, 자체적으로 저장하지 않고 항상 계산을 통해 얻어집니다.
장바구니에 담긴 상품들의 총 금액은 저장하는 게 아니라 "상품 목록 × 수량"으로 계산해서 얻는 값입니다. 이 값을 별도로 저장하면 오히려 동기화 버그가 생깁니다. 원본 상태만 신뢰하고, 나머지는 파생시키는 게 핵심입니다.
이 원칙을 SSOT(Single Source of Truth) 라고 부릅니다. 동일한 데이터를 한 곳에서만 관리하고 나머지는 그 값을 파생시키는 설계 원칙인데, 여러 군데서 같은 값을 관리하는 순간 두 값이 달라지는 버그가 반드시 생깁니다. 한 번쯤은 겪어봤을 겁니다.
FSD에서 파생 상태는 어디에 놓아야 할까
FSD는 app → pages → widgets → features → entities → shared 순서의 단방향 의존 구조를 갖습니다. 각 레이어는 자신보다 아래 레이어만 참조할 수 있습니다.
| 레이어 | 역할 | 파생 상태 위치 여부 |
|---|---|---|
entities |
User, Product, Order 같은 도메인 개체 단위 | 단일 엔티티 내부의 파생만 가능 |
features |
여러 엔티티를 사용하는 실제 사용자 인터랙션 | 복수 엔티티 조합 파생 상태의 적합한 위치 |
widgets |
독립적인 UI 블록 | features의 파생 상태 소비 |
entities 레이어의 슬라이스들은 서로를 알면 안 됩니다. cart 엔티티가 user 엔티티를 직접 참조하는 건 FSD 위반입니다. 그래서 "사용자가 선택한 장바구니 항목의 총금액"처럼 여러 엔티티를 엮은 파생 상태는 features 레이어의 커스텀 훅 안에서 만드는 게 자연스럽습니다.
서버 상태 vs 클라이언트 상태: 섞으면 생기는 일
솔직히 이 둘을 구분하지 않고 모든 걸 Zustand에 넣던 시절이 있었습니다. 그 결과는 참담했는데요. 서버에서 새 데이터가 내려오면 Zustand 스토어와 충돌하고, 캐시 무효화 타이밍이 맞지 않아 화면에 오래된 데이터가 남아 있는 일이 생겼습니다.
한 줄로 요약하면: "서버에서 오는 데이터는 절대 Zustand에 복제하지 않는다." 이 원칙 하나가 수많은 동기화 버그를 예방합니다.
| 상태 종류 | 특징 | 담당 도구 |
|---|---|---|
| 서버 상태 | 비동기 원격 데이터, 캐싱·재검증 필요 | TanStack Query |
| 클라이언트 상태 | UI 상태, 사용자 선택, 임시 데이터 | Zustand |
Zustand selector가 리렌더를 줄이는 원리
Zustand는 기본적으로 Object.is로 이전 값과 새 값을 비교합니다. selector가 반환하는 값이 달라졌을 때만 컴포넌트를 리렌더합니다.
// 안티패턴: 전체 스토어 구독 → 어떤 상태 하나만 바뀌어도 리렌더
const state = useUserStore();
// 권장: 필요한 값만 선택적 구독 → 해당 값이 변경될 때만 리렌더
const username = useUserStore((state) => state.user.name);문제는 객체나 배열을 반환할 때입니다. { name, role }처럼 객체를 반환하면 실제 값이 같아도 매번 새 참조가 생기기 때문에 Zustand는 항상 "변경됐다"고 판단합니다. 이때 useShallow가 필요합니다. 실제로 이 패턴을 처음 쓸 때 useShallow를 빠뜨려서 콘솔 경고가 가득 찼던 기억이 있는데, 한 번 경험하면 절대 까먹지 않게 됩니다.
import { useShallow } from 'zustand/react/shallow';
// useShallow 없이 → 매 렌더마다 새 객체 참조 → 무한 리렌더 위험
const { name, role } = useUserStore(
(s) => ({ name: s.user.name, role: s.user.role })
); // ❌
// useShallow 사용 → 얕은 비교로 실제 값이 같으면 리렌더 건너뜀
const { name, role } = useUserStore(
useShallow((s) => ({ name: s.user.name, role: s.user.role }))
); // ✅쉽게 말하면: useShallow 는 Zustand 4.x에서 공식 도입된 얕은 비교 훅입니다. 객체·배열을 selector 반환값으로 쓸 때 불필요한 리렌더를 막아주며, 이전 버전의
shallow비교 함수보다 번들 사이즈도 작습니다.
실전 적용
결제 요약 화면: 서버·클라이언트 상태 조합
결제 요약 화면을 만든다고 가정합니다. 사용자가 선택한 상품 ID 목록(클라이언트 상태)과 쿠폰 코드(클라이언트 상태)는 Zustand에, 실제 상품 데이터(서버 상태)는 TanStack Query에 있습니다. features/checkout 레이어의 커스텀 훅에서 이 둘을 조합합니다.
// features/checkout/model/useCheckoutSummary.ts
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useShallow } from 'zustand/react/shallow';
import { useCartStore } from '@/entities/cart';
import { fetchProductsByIds } from '@/entities/product';
export function useCheckoutSummary() {
// 클라이언트 상태: useShallow로 다중 값 안전하게 구독
const { selectedIds, couponCode } = useCartStore(
useShallow((s) => ({
selectedIds: s.selectedIds,
couponCode: s.couponCode,
}))
);
// selectedIds가 배열이므로 queryKey도 배열 안에 배열이 됩니다.
// TanStack Query는 키를 JSON 직렬화로 비교하므로 배열을 키에 넣어도 안전합니다.
const { data: selectedProducts } = useQuery({
queryKey: ['products', selectedIds],
queryFn: () => fetchProductsByIds(selectedIds),
// 캐시 원본은 유지하고 변환값만 메모이제이션합니다.
// select 함수 참조가 바뀌면 메모이제이션이 깨지므로 외부에 정의하는 편이 좋습니다.
select: (products) => products.filter((p) => selectedIds.includes(p.id)),
enabled: selectedIds.length > 0,
});
// select가 이미 필터링한 결과를 받아서 총액만 계산합니다
const totalPrice = useMemo(() => {
if (!selectedProducts) return 0;
return selectedProducts.reduce((sum, p) => sum + p.price * p.quantity, 0);
}, [selectedProducts]);
return { selectedProducts, totalPrice, couponCode };
}| 코드 포인트 | 설명 |
|---|---|
useShallow로 클라이언트 상태 구독 |
selectedIds, couponCode 중 하나만 바뀌어도 리렌더되지만, 둘 다 안 바뀌면 건너뜀 |
select 옵션 |
TanStack Query 캐시의 원본 데이터는 유지, 변환값만 메모이제이션. select 함수 참조가 바뀌면 메모이제이션이 무력화되므로 인라인 함수보다 외부 정의가 안전합니다 |
useMemo로 최종 파생 |
selectedProducts가 변경될 때만 총액 재계산 |
enabled: selectedIds.length > 0 |
빈 배열로 불필요한 네트워크 요청 방지 |
참조 안정화: 외부 selector 정의 패턴
컴포넌트 안에 selector를 인라인으로 쓰면 렌더마다 새 함수가 생깁니다. 특히 TanStack Query의 select 옵션에 인라인 함수를 넘기면 매 렌더마다 새 함수 참조가 생겨 메모이제이션이 완전히 무력화됩니다. selector는 컴포넌트 밖에 정의하는 편이 훨씬 낫습니다.
// entities/product/model/productSelectors.ts
// 모듈 레벨에 정의 → 항상 같은 함수 참조 보장
export const selectAvailableProducts = (state: ProductState) =>
state.products.filter((p) => p.stock > 0);
// 파라미터가 필요한 경우: 커링 패턴
export const selectProductById = (id: string) => (state: ProductState) =>
state.products.find((p) => p.id === id);// 컴포넌트에서 사용
import { selectAvailableProducts, selectProductById } from '@/entities/product';
function ProductList() {
const availableProducts = useProductStore(selectAvailableProducts);
return <>{/* ... */}</>;
}
function ProductDetail({ id }: { id: string }) {
// 값(함수)을 메모이제이션할 때는 useMemo가 시맨틱하게 더 적합합니다.
// useCallback은 이벤트 핸들러처럼 함수 선언 자체를 메모이제이션할 때 관용적으로 씁니다.
const selectById = useMemo(() => selectProductById(id), [id]);
const product = useProductStore(selectById);
return <>{/* ... */}</>;
}심화: proxy-memoize로 다중 스토어 파생 상태 최적화
여기서부터는 심화 내용입니다. 위의 두 패턴만으로도 대부분의 케이스를 충분히 커버할 수 있습니다.
대시보드처럼 여러 엔티티 스토어를 한꺼번에 참조해 무거운 계산을 해야 할 때는 proxy-memoize가 유용합니다. 직접 접근한 속성만 의존성으로 추적하기 때문에 reselect처럼 입력 selector를 일일이 나열하지 않아도 됩니다.
// features/dashboard/model/useDashboardStats.ts
import { memoize } from 'proxy-memoize';
import { useOrderStore } from '@/entities/order';
import { useProductStore } from '@/entities/product';
import { useMemo } from 'react';
// 최소 타입 구조 참고
// type OrderState = { orders: Array<{ id: string; amount: number }> }
// type ProductState = { products: Array<{ id: string; salesCount: number }> }
type CombinedState = {
orders: OrderState['orders'];
products: ProductState['products'];
};
// 모듈 레벨에 정의 → proxy-memoize가 Proxy로 접근 경로를 추적
const selectDashboardStats = memoize((state: CombinedState) => ({
totalRevenue: state.orders.reduce((sum, o) => sum + o.amount, 0),
topProducts: state.products
.filter((p) => p.salesCount > 100)
.sort((a, b) => b.salesCount - a.salesCount)
.slice(0, 5),
}));
export function useDashboardStats() {
const orders = useOrderStore((s) => s.orders);
const products = useProductStore((s) => s.products);
// proxy-memoize는 입력 참조가 같으면 캐시된 결과를 반환합니다.
// 바깥의 useMemo는 proxy-memoize 캐시를 신뢰하지 못할 경우를 대비한 방어적 래퍼입니다.
return useMemo(
() => selectDashboardStats({ orders, products }),
[orders, products]
);
}한 줄로 요약하면: proxy-memoize 는 JavaScript Proxy를 이용해 함수 내부에서 실제로 접근한 속성만 자동으로 의존성으로 등록합니다.
reselect는 "어떤 값에 의존하는지"를 직접 선언해야 하지만,proxy-memoize는 이를 런타임에 자동으로 추적합니다.
실무에서 가장 흔한 실수
-
서버 데이터를 Zustand에 복제하기: TanStack Query로 가져온 데이터를
useEffect로 Zustand 스토어에 다시 저장하는 패턴입니다. 캐시 무효화 타이밍이 어긋나면 화면에 오래된 데이터가 남고, 관리해야 할 상태가 두 배로 늘어납니다.useEffect로 서버 응답을setState하는 코드가 있다면 그게 1순위 정리 후보입니다. -
selector를 컴포넌트 안에 인라인으로 정의하기:
useQuery({ select: (data) => data.filter(...) })처럼 렌더마다 새 함수를 만들면 TanStack Query의 메모이제이션이 작동하지 않습니다. 컴포넌트 외부에 정의하거나useMemo로 감싸는 편이 훨씬 낫습니다. -
전체 스토어를 구독한 채 파생 상태 계산하기:
const state = useCartStore()로 전체를 구독하면 스토어 안의 어떤 값이 바뀌어도 해당 컴포넌트가 리렌더됩니다. 필요한 필드만 selector로 좁혀서 구독하는 것이 성능 면에서 훨씬 낫습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 단일 진실 공급원(SSOT) | 원본 상태만 관리하면 파생값은 자동 갱신. 두 군데서 같은 값을 관리하다 생기는 동기화 버그가 원천 차단됩니다 |
| 리렌더 최적화 | selector로 필요한 상태만 구독하면 무관한 상태 변경에 컴포넌트가 반응하지 않습니다 |
| 관심사 분리 | 계산 로직을 selector나 훅으로 빼면 컴포넌트는 렌더링에만 집중할 수 있습니다 |
| 테스트 용이성 | 순수 함수인 selector는 스토어나 컴포넌트 없이 단위 테스트 작성이 쉽습니다 |
| FSD 아키텍처 적합성 | 파생 상태 로직이 features 레이어의 커스텀 훅에 모여 있어 코드 위치가 예측 가능합니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 초기 설계 비용 | 어디가 서버 상태고 어디가 클라이언트 상태인지 먼저 결정해야 합니다 | 도메인 경계를 FSD 레이어 기준으로 명확히 나누는 편이 좋습니다 |
| useShallow 누락 시 무한 리렌더 | 객체·배열 반환 selector에 useShallow를 빠뜨리면 무한 리렌더가 발생합니다 |
객체·배열을 반환하는 모든 selector에 useShallow 적용을 권장합니다 |
| proxy-memoize 중첩 객체 변경 감지 주의 | 깊은 중첩 구조에서 Proxy가 변경을 감지하지 못할 수 있습니다 | 중첩이 깊은 경우 reselect나 useMemo와 혼합 사용이 가능합니다 |
| 다중 스토어 조합 복잡도 | 여러 도메인 스토어를 합칠 때 의존 관계가 복잡해집니다 | features 레이어 커스텀 훅 안에 조합 로직을 캡슐화하는 방식이 효과적입니다 |
마치며
features 레이어를 파생 상태의 조합 지점으로 삼고, 서버 상태는 TanStack Query select로, 클라이언트 상태는 Zustand selector + useShallow로 각자의 방식대로 다루면 불필요한 리렌더와 동기화 버그를 함께 해결할 수 있습니다. 이 패턴을 적용하고 나면 컴포넌트가 놀랍도록 단순해집니다. 상태를 어디서 가져올지 고민하는 시간이 줄고, 버그의 원인을 한 곳에서 추적할 수 있게 됩니다.
지금 바로 시작해볼 수 있는 3단계:
-
현재 Zustand 스토어에서 서버 데이터를 담고 있는 슬라이스를 찾아 TanStack Query로 옮겨보시면 좋습니다.
useEffect로 서버 응답을setState하는 패턴이 있다면 그게 1순위 후보입니다. -
useUserStore()처럼 스토어 전체를 구독하는 코드를 찾아,useUserStore((s) => s.user.name)처럼 필요한 필드만 선택하도록 바꿔보시면 좋습니다. 객체·배열을 반환한다면useShallow를 함께 적용하는 것을 권장합니다. -
여러 엔티티 상태를 컴포넌트 안에서 직접 조합하고 있다면, 해당 로직을
features/[도메인]/model/use[기능명].ts커스텀 훅으로 분리해보시면 좋습니다. 이 훅 안에서 TanStack Query와 Zustand 값을 조합하고useMemo로 파생 상태를 계산하는 구조가 됩니다.
참고 자료
- Working with Zustand | tkdodo.eu
- React Query Selectors, Supercharged | tkdodo.eu
- Zustand 공식 문서 — Reference
- shallow & useShallow | DeepWiki
- Selectors & Re-rendering | DeepWiki
- Federated State Done Right: Zustand, TanStack Query, and the Patterns That Actually Work | nextsteps.dev
- Feature-Sliced Design 공식 문서 — Layers
- Does TanStack Query replace Redux/MobX? | TanStack 공식
- proxy-memoize 공식 문서
- Zustand Architecture Patterns at Scale | Brainhub