1. 어떤 문제를 해결하려 했는가
Next.js + Zustand + TanStack Query 조합으로 규모 있는 프로덕트를 만들다 보면, 패턴 이론이 아니라 다음과 같은 구체적인 문제들에 부딪힙니다.
- 백엔드 API가 늦어지면 프론트엔드 개발도 함께 멈춘다
- Storybook에서 "재고 있음"과 "품절" 시나리오를 각각 검증하고 싶은데, 전역 모킹으로는 스토리별 분기가 어렵다
- 서버 상태(TanStack Query)와 클라이언트 상태(Zustand)의 책임 경계가 흐려진다
- 테스트를 위해 Provider를 겹겹이 감싸다 보면 배보다 배꼽이 커진다
- FSD를 도입하려니
pages레이어가 Next.js 예약 디렉토리와 의미가 겹친다
이 글은 이 문제들을 하나의 구조로 절충한 기록입니다. 결론부터 한 문장으로 요약하면 이렇습니다.
Repository를 데이터 교체 지점으로, View를 상태 표현 지점으로 분리하면, 프로덕션 조립은 단순하게 유지하면서 Storybook에서는 실제 ViewModel 흐름을 시나리오별로 검증할 수 있다.
특히 컴포넌트 스토리, 페이지 통합 스토리, HTTP 인프라 테스트를 서로 다른 seam으로 분리해, 각 테스트가 검증해야 할 범위를 명확히 하는 데 초점을 맞췄습니다.
이 조합이 처음이어도 아래 다섯 개만 잡고 가면, 나머지 용어는 문맥에서 자연스럽게 따라옵니다.
이 글에서 자주 쓰는 용어
FSD(Feature-Sliced Design): 기능과 도메인의 책임을 계층·슬라이스 단위로 구조화하는 프론트엔드 아키텍처 방법론 Repository: 데이터 원천(API 등)을 추상화해, 사용하는 쪽이 구현 세부를 모르게 하는 객체 ViewModel: View에 필요한 상태와 행동을 조립해 내려주는 훅 DI(의존성 주입): 필요한 구현체를 내부에서 직접 만들지 않고 외부에서 넣어주는 방식 seam: 테스트에서 구현을 교체할 수 있는 경계
용어를 하나 정확히 해두겠습니다. 이 글에서 말하는 MVVM은 전통적인 MVVM(플랫폼 독립적 ViewModel, 양방향 바인딩)의 완전한 구현이 아닙니다. "View와 상태·행동 조립 로직을 분리한다"는 원칙만 차용해 React custom hook으로 구현한 실전형 변형입니다. 실제로 뒤에 나올 뷰모델은 토스트·바텀시트 같은 UI 서비스를 직접 호출하므로 엄밀한 플랫폼 독립성은 없습니다. 패턴의 순수성보다 분리가 팀에 주는 실익에 초점을 맞춥니다.
2. 선택한 구조와 의존성 방향
FSD pages와 Next.js의 관계, 정확하게
먼저 흔한 오해 하나를 정리합니다. Next.js는 App Router와 Pages Router의 점진적 공존을 공식 지원합니다. 동일 URL 경로가 두 라우터에 중복될 때만 충돌하며, 두 디렉토리 자체가 공존하지 못하는 것이 아닙니다.
문제는 공존 불가가 아니라 의미의 겹침입니다. FSD의 pages 레이어를 그대로 쓰면 프레임워크 예약 디렉토리와 이름이 겹쳐서, 프로젝트 배치에 따라 라우터 디렉토리로 해석되거나(루트에 app/pages가 있으면 src/ 하위 동명 디렉토리가 무시될 수 있음) 팀원에게 라우팅 폴더로 오인될 여지가 생깁니다.
그래서 이 글에서는 라우팅 진입점 app/을 프로젝트 루트에 얇게 유지하고, FSD의 화면 조립 레이어를 views로 리네이밍했습니다. FSD 공식 가이드의 권장안은 _pages이며, views는 언더스코어 프리픽스보다 팀 내 가독성이 낫다고 판단한 커스텀 선택입니다. _pages를 따라도 무방합니다.
디렉토리 구조
파일명은 운영체제 간 대소문자 인식 차이로 빌드가 깨지는 사고를 방지하기 위해 전면 kebab-case로 통일했고, FSD의 api 세그먼트는 역할이 더 분명한 repositories로 이름을 바꿨습니다.
📂 project-root
├── 📂 app # Next.js 라우팅 진입점 (얇은 껍데기)
│ └── 📂 cart
│ └── page.tsx # views/cart 를 re-export 하는 서버 컴포넌트
│
└── 📂 src
├── 📂 shared
│ └── 📂 lib
│ └── 📂 http
│ ├── api-client.ts # Axios 인스턴스 (인터셉터, 헤더)
│ └── refresh-access-token.ts # 토큰 갱신 (도메인 없는 HTTP 인프라)
│
├── 📂 entities
│ └── 📂 product
│ └── 📂 model
│ └── types.ts # ProductEntity 데이터 명세
│
├── 📂 features
│ └── 📂 cart
│ ├── 📂 repositories
│ │ ├── inventory-repository.ts # env 기반 real/mock 선택
│ │ ├── inventory-repository.interface.ts # 레포지토리 인터페이스
│ │ ├── inventory-repository.mock.ts
│ │ ├── inventory-repository.real.ts
│ │ └── inventory-repository.context.tsx # 기본값 내장 Context
│ ├── 📂 lib
│ │ └── 📂 constants
│ │ └── cart.ts # CART_MAX_QUANTITY = 10
│ ├── 📂 model
│ │ ├── errors.ts # 도메인 에러 (OutOfStockError)
│ │ └── store.ts # Zustand (동기 규칙 전담)
│ ├── 📂 ui
│ │ └── 📂 cart-view
│ │ ├── cart-view.container.tsx # 뷰모델 호출 + 바인딩
│ │ ├── cart-view.stories.tsx
│ │ ├── cart-view.view-model.ts
│ │ └── cart-view.tsx # props만 받는 순수 View
│ ├── index.ts # Public API
│ └── testing.ts # 테스트 전용 Public API
│
└── 📂 views # FSD pages의 리네이밍
└── 📂 cart
├── cart-page.stories.tsx
├── cart-page.ui.tsx
└── index.ts// app/cart/page.tsx — 라우팅 껍데기는 이게 전부입니다.
import { CartPage } from "@/views/cart";
export default CartPage;의존성 방향은 한 줄로 요약됩니다: views → features → entities → shared, 그리고 각 슬라이스의 외부 접근은 index.ts, index.server.ts, testing.ts 같은 목적별 Public API entry를 통해서만 허용합니다. 이 규칙은 글 말미의 린트 설정으로 기계적으로 강제합니다.
엄격한 FSD 분류와의 차이
미리 답해둘 반론이 하나 있습니다. 엄격한 FSD에서 features는 "사용자가 수행하는 상호작용" 단위이고, cart는 상호작용보다 도메인 명사에 가깝습니다. 정석대로 나누면 장바구니 상태는 entities/cart, 담기 동작은 features/add-to-cart, 목록 조립 UI는 widgets/cart-list로 분해됩니다.
이 예제에서 cart를 비교적 굵은 feature slice로 구성한 것은, Repository DI와 ViewModel 흐름을 한 슬라이스 안에서 한눈에 보여주기 위한 의도적 단순화입니다. 실제 프로젝트에서는 재사용 경계가 실제로 발생하는 시점에 위 분류로 세분화하는 것을 권장합니다. 같은 이유로 토큰 갱신은 비즈니스 도메인이 없는 HTTP 인프라로 보고 별도 repository 없이 shared/lib/http에 두었습니다.
entities는 화면 사정이 섞이지 않은 핵심 데이터 명세입니다.
// src/entities/product/model/types.ts
export interface ProductEntity {
id: string;
name: string;
price: number;
imageUrl: string;
}3. Repository: 데이터 교체 지점
결정
데이터 획득을 IInventoryRepository 인터페이스로 정의하고, real/mock 구현체를 환경변수로 스위칭하며, 여기에 기본값을 내장한 React Context를 얹어 테스트에서만 구현체를 오버라이드합니다.
이유
뷰모델이 Axios 설정이나 API 주소에 직접 의존하면 결합도가 높아지고, 백엔드 지연이 곧 프론트엔드 지연이 됩니다. 인터페이스와 mock 구현체가 있으면 NEXT_PUBLIC_USE_MOCK=true 하나로 백엔드 구현을 기다리지 않고 UI와 주요 상호작용 개발을 진행할 수 있습니다. (실제 연동과 통합 검증까지 끝나는 것은 아닙니다.)
이 repository는 장바구니 자체의 영속화가 아니라 장바구니 기능이 필요로 하는 재고 데이터 소스를 추상화하므로 InventoryRepository라고 명명했습니다. API 함수마다 인터페이스를 만드는 것은 과잉이지만, 이 예제처럼 mock/real 교체, DTO 해석(서버 스펙의 세부 사항 은닉), 전송 실패와 재고 부족의 도메인 에러 변환(StockCheckFailedError / OutOfStockError)이 실제로 필요한 경우에는 독립된 경계로 둘 실익이 있습니다. 덕분에 뷰모델에서 error.message === "OUT_OF_STOCK" 같은 문자열 비교나 Axios 타입 의존이 사라집니다. 재고가 여러 기능에서 재사용되는 시점에는 entities/inventory 같은 별도 슬라이스로 옮기면 됩니다.
// src/features/cart/model/errors.ts
// 뷰모델이 instanceof로 분기할 수 있는 도메인 에러.
// 전송 계층(HTTP)의 사정은 이 타입들 뒤로 숨습니다.
export class OutOfStockError extends Error {
readonly code = "OUT_OF_STOCK" as const;
constructor() {
super("상품 재고가 부족합니다.");
this.name = "OutOfStockError";
}
}
// 재고 조회 요청 과정에서 발생한 전송 실패(네트워크 단절·타임아웃·HTTP 오류)를
// 정규화한 타입. 원인은 cause로 보존합니다.
export class StockCheckFailedError extends Error {
readonly code = "STOCK_CHECK_FAILED" as const;
constructor(options?: ErrorOptions) {
super("재고를 확인하지 못했습니다.", options);
this.name = "StockCheckFailedError";
}
}// src/features/cart/repositories/inventory-repository.interface.ts
export interface IInventoryRepository {
// 검증 전용 메서드이므로 반환값이 없습니다.
// 재고 없음 → OutOfStockError, 재고 조회 요청 실패 → StockCheckFailedError.
assertAvailable: (productId: string) => Promise<void>;
}// src/features/cart/repositories/inventory-repository.real.ts
import { apiClient } from "@/shared/lib/http";
import { OutOfStockError, StockCheckFailedError } from "../model/errors";
import { IInventoryRepository } from "./inventory-repository.interface";
// 서버 응답 DTO — 이 형태는 repository 바깥으로 새어나가지 않습니다.
interface StockResponseDto {
stock: number;
}
export const realInventoryRepository: IInventoryRepository = {
assertAvailable: async (productId) => {
let response: { data: StockResponseDto };
// catch 범위를 HTTP 호출로 제한해 프로그래밍 오류까지 감싸지 않습니다.
try {
response = await apiClient.get<StockResponseDto>(
`/products/${productId}/stock`,
);
} catch (error) {
// 전송 실패(네트워크 단절·타임아웃·HTTP 오류)의 정규화
throw new StockCheckFailedError({ cause: error });
}
// DTO 해석: 재고 부족은 전송 실패가 아닌 기능 도메인 실패입니다.
if (response.data.stock <= 0) {
throw new OutOfStockError();
}
},
};기본값 내장 Context: 기본 구현체를 이용한 선택적 DI
환경변수 스위칭은 빌드 단위의 정적 스위치라서, Storybook에서 스토리별로 다른 응답 시나리오(재고 있음 vs 품절)를 연출할 수 없습니다. 그래서 Context를 하나 얹되, 기본값으로 실제 구현체를 넣어둡니다.
// src/features/cart/repositories/inventory-repository.ts
import { mockInventoryRepository } from "./inventory-repository.mock";
import { realInventoryRepository } from "./inventory-repository.real";
// process가 없는 브라우저 번들(Vite Storybook 등) 대비 가드
const useMock =
typeof process !== "undefined" &&
process.env.NEXT_PUBLIC_USE_MOCK === "true";
export const inventoryRepository = useMock
? mockInventoryRepository
: realInventoryRepository;typeof process 가드는 장식이 아닙니다. Storybook의 Vite builder는 브라우저 코드에 Node의 process.env를 기본 제공하지 않는데, 아래 Context가 기본값을 만들기 위해 이 모듈을 즉시 import하므로 스토리에서 Provider로 덮어쓰기 전에 모듈 평가가 먼저 일어납니다. 가드가 없으면 이 시점에 process is not defined가 발생할 수 있습니다. 가드가 있으면 Vite Storybook에서는 기본적으로 real이 선택되고, 스토리별 mock은 어차피 Provider로 주입하므로 이 글의 테스트 전략과도 맞습니다. Storybook에서 env 스위칭 자체를 써야 한다면 .storybook/main.ts의 env 또는 viteFinal로 변수를 명시적으로 노출해야 합니다.
// src/features/cart/repositories/inventory-repository.context.tsx
"use client";
import { createContext, useContext } from "react";
import { IInventoryRepository } from "./inventory-repository.interface";
import { inventoryRepository } from "./inventory-repository"; // barrel이 아닌 구체 경로
const InventoryRepositoryContext = createContext<IInventoryRepository>(inventoryRepository);
export const InventoryRepositoryProvider = InventoryRepositoryContext.Provider;
export const useInventoryRepository = () => useContext(InventoryRepositoryContext);스위칭 모듈을 barrel(index.ts)이 아닌 inventory-repository.ts라는 명시적 파일로 분리한 데는 이유가 있습니다. Context가 같은 폴더의 ./index를 import하는 구조에서는, 나중에 누군가 그 index에 Context를 재export하는 순간 바로 순환 참조가 생깁니다. 글 말미에 noImportCycles 린트를 켤 것이므로 예제부터 원칙을 지킵니다: 내부 모듈끼리는 구체 경로로 연결하고, barrel은 외부 소비자 전용으로 남겨둡니다.
React Context는 상위에서 Provider를 찾지 못하면 createContext()의 기본값을 반환합니다. 따라서 프로덕션 코드에는 Provider를 한 줄도 깔지 않아도 되고, feature가 늘어나도 앱 트리에 Provider가 쌓이지 않습니다. Provider는 스토리북과 테스트에서 오버라이드가 필요할 때만 등장합니다. 기본값이 null이 아닌 실제 구현체이므로 훅마다 null 체크 방어 코드도 필요 없습니다.
트레이드오프
- 테스트에서 Provider 누락이 조용히 지나갑니다. 기본 구현체 fallback의 반대 급부로, 테스트에서 Provider를 빠뜨려도 즉시 실패하지 않고 실제 repository를 호출합니다. 네트워크 요청이 소리 없이 나가면 테스트가 느려지거나 환경에 따라 결과가 달라질 수 있습니다. 테스트 환경에는 예상하지 못한 HTTP 요청을 실패 처리하는 가드(MSW의
onUnhandledRequest: "error", 또는 테스트 빌드에서apiClient가 throw하도록 구성)를 함께 켜두어야 Provider 누락이 즉시 드러납니다. - 기본 구현체를 Context fallback으로 쓰면 해당 구현이 클라이언트 모듈 그래프에 포함됩니다. 구현체가 무겁거나 server-only 의존성을 갖는다면 명시적 Provider 조립이나 별도 entry 분리를 검토해야 합니다. 지금처럼 얇은 Axios repository라면 문제가 되지 않습니다.
- 기본값은 모듈 로드 시점에 한 번 평가됩니다. env 스위칭이 정적으로 결정되는 지금 구조에서는 문제가 없지만, 런타임에 mock/real을 토글하는 요구가 생기면 그때는 앱 루트에 Provider를 실제로 배치해야 합니다.
- 과잉 설계 방지 조항: 모든 API에 Repository 인터페이스를 만들자는 규칙이 아닙니다. mock/real 이중화가 필요하거나, DTO 해석·에러 변환이 실재하거나, 스토리북 시나리오 주입 대상이 되는 데이터 소스에만 적용합니다. 단순 조회 한 건이면 평범한 api 함수로 충분합니다.
App Router에서의 Public API 주의점
이 구조는 barrel Public API + 클라이언트 Context + Server Component에서의 re-export를 모두 사용하므로 한 가지 지뢰를 언급해 둡니다. FSD의 Next.js 가이드에 따르면, 한 slice의 barrel에서 client용 모듈과 server-only 모듈을 함께 export하면 client module graph에 server-only 사이드 이펙트가 전파될 수 있습니다. server-only 코드(서버 전용 repository, loader 등)가 생기는 시점에는 barrel을 분리하세요.
features/cart/
├── index.ts # client-safe exports
├── index.server.ts # server-only exports
└── testing.ts # 테스트 전용 exports같은 원리로 테스트 전용 entry point도 분리합니다. resetCartStore나 mock 주입용 Provider를 기본 index.ts에 노출하면 프로덕션 소비자에게도 테스트 API가 보입니다. 목적별 Public API를 이미 나누고 있으므로 자연스럽게 하나 더 추가하면 됩니다.
// src/features/cart/testing.ts — 테스트/스토리북 전용 Public API
export { InventoryRepositoryProvider } from "./repositories/inventory-repository.context";
export { resetCartStore } from "./model/store";
export { OutOfStockError, StockCheckFailedError } from "./model/errors";
export type { IInventoryRepository } from "./repositories/inventory-repository.interface";4. ViewModel: 서버 상태와 클라이언트 상태의 조합 지점
결정
Zustand는 동기식 클라이언트 규칙만, TanStack Query는 비동기 서버 통신만 담당하게 역할을 제한하고, 두 흐름의 결합은 뷰모델 훅 한 곳에서 일방향으로 처리합니다.
이유
Zustand 스토어 안에서 비동기 API를 호출하면 스토어의 역할이 모호해지고, 반대로 상태 변화를 useEffect로 감시해 UI를 갱신하면 추가 렌더링과 레이스 컨디션의 여지가 생깁니다. 스토어 액션은 평범한 동기 함수이므로, get()으로 규칙을 먼저 검증한 뒤 set()을 수행하고 결과를 리턴값으로 돌려주면 됩니다. 상태 변화를 감시하는 대신, 호출 지점에서 결과를 받아 그 자리에서 UI 피드백을 분기합니다.
// src/features/cart/lib/constants/cart.ts
export const CART_MAX_QUANTITY = 10;// src/features/cart/model/store.ts
import { create } from "zustand";
import { ProductEntity } from "@/entities/product";
import { CART_MAX_QUANTITY } from "../lib/constants/cart";
export interface CartItemModel extends ProductEntity {
quantity: number; // 상품 Entity에 장바구니 도메인 상태(수량)를 결합
}
type AddItemResult =
| { success: true }
| { success: false; reason: "MAX_LIMIT_EXCEEDED" };
interface CartState {
items: CartItemModel[];
addItemSync: (product: ProductEntity) => AddItemResult;
}
const addOrIncreaseItem = (
items: CartItemModel[],
product: ProductEntity,
): CartItemModel[] => {
const exists = items.some((item) => item.id === product.id);
return exists
? items.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item,
)
: [...items, { ...product, quantity: 1 }];
};
export const useCartStore = create<CartState>((set, get) => ({
items: [],
addItemSync: (product) => {
// get()으로 규칙을 먼저 검증하고, 통과한 경우에만 set()을 수행합니다.
const existingItem = get().items.find((item) => item.id === product.id);
if (existingItem && existingItem.quantity >= CART_MAX_QUANTITY) {
return { success: false, reason: "MAX_LIMIT_EXCEEDED" };
}
set((state) => ({ items: addOrIncreaseItem(state.items, product) }));
return { success: true };
},
}));
// 테스트/스토리북용 리셋 유틸 (testing.ts를 통해 노출)
export const resetCartStore = () =>
useCartStore.setState(useCartStore.getInitialState(), true);뷰모델은 repository를 Context에서 획득하고, 서버 확인 → 동기 규칙 검증 → UI 피드백의 일방향 흐름을 조립합니다.
// src/features/cart/ui/cart-view/cart-view.view-model.ts
import { useMutation } from "@tanstack/react-query";
import { ProductEntity } from "@/entities/product";
import { useToast } from "@/shared/lib/toast";
import { useBottomSheet } from "@/shared/lib/bottom-sheet";
import { CART_MAX_QUANTITY } from "../../lib/constants/cart";
import { useInventoryRepository } from "../../repositories/inventory-repository.context";
import { OutOfStockError, StockCheckFailedError } from "../../model/errors";
import { useCartStore } from "../../model/store";
export const useCartViewModel = () => {
// 정적 import 대신 Context에서 획득: 뷰모델은 인터페이스만 압니다.
const inventoryRepository = useInventoryRepository();
// 셀렉터 구독: 필요한 조각 외의 상태 변경에는 반응하지 않습니다.
const items = useCartStore((state) => state.items);
const addItemSync = useCartStore((state) => state.addItemSync);
const { openToast } = useToast();
const { openBottomSheet } = useBottomSheet();
const { mutate: addToCart, isPending } = useMutation({
// 1단계: 서버 재고 확인 — 실패는 repository가 도메인 에러로 던집니다
mutationFn: async (product: ProductEntity) => {
await inventoryRepository.assertAvailable(product.id);
return product;
},
// 2단계: 통과 시 Zustand 동기 업데이트
onSuccess: (product) => {
const result = addItemSync(product);
// 3단계: 동기 리턴값으로 즉시 UI 피드백 분기 (useEffect 감시 없음)
if (!result.success && result.reason === "MAX_LIMIT_EXCEEDED") {
openToast({
type: "error",
message: `최대 ${CART_MAX_QUANTITY}개까지만 담을 수 있습니다.`,
});
return;
}
openToast({ type: "success", message: "장바구니에 담겼습니다." });
},
onError: (error: Error) => {
// 문자열이나 Axios 타입이 아닌, 기능에서 정의한 도메인 에러로 분기합니다.
if (error instanceof OutOfStockError) {
openBottomSheet({
title: "품절 안내",
content: "현재 상품의 서버 재고가 부족합니다.",
});
return;
}
if (error instanceof StockCheckFailedError) {
openToast({
type: "error",
message: "재고를 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.",
});
return;
}
// 예상하지 못한 오류는 재고 조회 실패로 위장하지 않습니다.
// 프로젝트 정책에 따라 Sentry 등으로 보고합니다.
console.error(error);
openToast({ type: "error", message: "예상하지 못한 오류가 발생했습니다." });
},
});
// 메모이제이션은 View에 전달되는 최종 경계인 Container에서만 합니다.
return {
cartItems: items,
isSubmitting: isPending,
onAddItemClick: addToCart,
};
};두 가지를 정직하게 밝혀둡니다.
첫째, 재고 사전 확인은 품절 상품을 담는 헛수고를 미리 줄여주는 UX 장치일 뿐입니다. 확인한 순간과 실제 주문하는 순간 사이에 재고가 바뀔 수 있으므로(이런 틈을 check-then-act 문제라고 부릅니다), 최종 검증은 반드시 주문 API 서버에서 다시 이루어져야 합니다.
둘째, repository를 Context에서 받는 방식은 뒤에서 기각할 "훅 주입 DI"와 겉보기에 비슷하지만 결정적으로 다릅니다. 주입되는 것이 훅이 아니라 평범한 객체이고, useInventoryRepository() 호출 자체는 항상 같은 자리에서 실행되므로 훅 규칙과 무관합니다. 부수 효과로 뷰모델 단위 테스트도 열립니다. renderHook에 InventoryRepositoryProvider wrapper만 감싸면 vitest에서 "품절 응답 시 바텀시트가 호출되는가"를 스토리북 없이 검증할 수 있습니다.
대안 검토: 뷰모델 훅을 props로 주입하는 방식은 왜 기각했나
useViewModel?: typeof useCartViewModel 형태로 훅 자체를 주입하는 방식도 시도했지만 기각했습니다.
- 훅 호출 구조가 런타임 값에 의해 결정됩니다. 주입된 구현체마다 내부에서 부르는 훅 구성이 다르면 교체 시 React의 훅 호출 순서 규칙이 깨질 수 있고, "교체하지 말 것"이라는 팀 내 약속은 린터로 강제할 수 없습니다.
- 정적 분석과 추론 가능성이 나빠집니다. 컴포넌트가 어떤 상태 의존성을 갖는지 소스만 봐서는 알 수 없게 되고, 코드 탐색과 exhaustive-deps 검사가 반쯤 무력화됩니다.
참고로 "함수 prop이라 참조 안정성이 불리하다"는 이유는 기각 근거로 쓰지 않았습니다. 일반 콜백 prop에도 동일하게 발생하는, 훅 주입만의 고유 단점이 아니기 때문입니다.
5. View/Container 분리와 Storybook 3단 모킹 전략
결정
View는 훅을 전혀 모르는 순수 프리젠테이션 컴포넌트로, 뷰모델 훅 호출은 얇은 Container로 분리합니다.
// src/features/cart/ui/cart-view/cart-view.tsx
import { memo } from "react";
import { CartItemModel } from "../../model/store";
interface CartViewProps {
cartItems: CartItemModel[];
isSubmitting: boolean;
onAddItemClick: () => void;
}
// 훅 호출이 하나도 없는 순수 프리젠테이션 컴포넌트
export const CartView = memo(
({ cartItems, isSubmitting, onAddItemClick }: CartViewProps) => {
return (
<div className="cart-view-container">
<h2>장바구니 목록 ({cartItems.length}개)</h2>
<button onClick={onAddItemClick} disabled={isSubmitting}>
{isSubmitting ? "서버 재고 판별 중..." : "장바구니 담기"}
</button>
</div>
);
},
);// src/features/cart/ui/cart-view/cart-view.container.tsx
"use client";
import { useCallback } from "react";
import { ProductEntity } from "@/entities/product";
import { useCartViewModel } from "./cart-view.view-model";
import { CartView } from "./cart-view";
interface CartViewContainerProps {
product: ProductEntity;
}
export const CartViewContainer = ({ product }: CartViewContainerProps) => {
const vm = useCartViewModel();
// View에 전달되는 콜백 참조를 Container에서 안정화합니다.
const handleAddItemClick = useCallback(() => {
vm.onAddItemClick(product);
}, [vm.onAddItemClick, product]);
return (
<CartView
cartItems={vm.cartItems}
isSubmitting={vm.isSubmitting}
onAddItemClick={handleAddItemClick}
/>
);
};이유와 효과
- View가 순수해지므로
React.memo를 적용할 수 있습니다. memo는 "안 그린다"는 보장이 아니라 성능 최적화 힌트지만, 부모(Container)가 리렌더되더라도 props 참조가 같다면 대부분의 불필요한 View 렌더링을 건너뛸 수 있습니다. - 최적화 지점이 Container라는 단일 경계에 모입니다. 뷰모델과 Container 양쪽에서 중복으로 메모이제이션하는 대신, View에 전달되는 최종 경계 한 곳에서만 안정화합니다. 단, 모든 곳에
useCallback을 바르자는 뜻은 아닙니다. 메모된 하위 컴포넌트에 실제로 전달되는 콜백처럼 최적화 효과가 실재하는 지점에만 적용합니다. - 컴포넌트 스토리에서 외부 의존성 모킹이 사라집니다. args로 props를 넣으면 끝이고(이벤트 확인용 callback spy
fn()만 사용), Controls 패널에서 상태를 실시간 토글할 수 있습니다.
// src/features/cart/ui/cart-view/cart-view.stories.tsx (외부 의존성 모킹 제로)
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { fn } from "storybook/test";
import { CartView } from "./cart-view";
const meta: Meta<typeof CartView> = {
component: CartView,
title: "Features/Cart/CartView",
args: {
cartItems: [],
isSubmitting: false,
onAddItemClick: fn(),
},
};
export default meta;
type Story = StoryObj<typeof CartView>;
export const Empty: Story = {};
export const Submitting: Story = {
args: { isSubmitting: true },
};트레이드오프
기능당 파일이 하나 늘어납니다. 버튼 하나짜리 기능에서는 과잉으로 느껴질 수 있으므로, 팀 규칙은 다음과 같이 운영합니다: 스토리북 시각 테스트의 대상이 되는 UI만 Container/View로 분리하고, 스토리 계획이 없는 일회성 내부 컴포넌트는 생략합니다. 나중에 스토리가 필요해지는 순간이 분리 리팩토링 시점입니다.
3단 모킹 전략
이 구조의 테스트 전략은 세 층으로 정리됩니다.
- 컴포넌트 스토리: 순수 View에 args만 전달. 네트워크·Repository·전역 상태 모킹 없음(이벤트 확인용 callback spy
fn()만 사용). - 페이지 통합 스토리: Container 이하 진짜 뷰모델을 구동해 실제 로직 흐름을 검증.
QueryClientProviderdecorator +InventoryRepositoryProvider로 스토리별 시나리오 주입. - HTTP 경계 검증: Axios 인터셉터의 토큰 갱신, 요청·응답 직렬화, 에러 매핑처럼 실제 Repository 구현과 HTTP 경계까지 포함해 검증할 때는 MSW 사용.
Repository Context와 MSW는 경쟁 관계가 아니라 검증 범위가 다른 도구입니다. Context는 ViewModel 이상의 로직을 빠르고 결정적으로 검증하고, MSW는 실제 Repository와 HTTP 경계까지 포함해 검증합니다. 이 글의 기본 통합 스토리에서는 Context를 seam으로 사용하지만, 실제 Repository의 요청·응답 매핑까지 검증해야 하는 시나리오라면 페이지 통합 스토리에서도 MSW를 쓸 수 있습니다. Context 방식의 장점은 페이지 안에 뷰모델이 몇 겹으로 중첩되어 있어도 각 feature의 Provider를 겹쳐 감싸는 것으로 전부 제어된다는 점입니다.
통합 스토리의 공통 셋업
QueryClient는 만드는 위치가 중요합니다. decorator 함수 안에서 매번 new QueryClient()를 하면 리렌더 때마다 새 클라이언트가 생겨 캐시·mutation 상태가 의도치 않게 초기화될 수 있고, 반대로 모듈 바깥에 하나만 두면 스토리 간 캐시가 공유됩니다. StoryQueryProvider가 마운트된 동안 하나의 QueryClient를 유지하고(useState 초기화 함수), 스토리가 교체되면 key로 새 Provider 인스턴스를 마운트해 격리합니다. mutation을 테스트하므로 mutations.retry도 함께 명시해 끕니다.
// .storybook/preview.tsx
// 이 글의 스토리북 예시는 Storybook 9 + Vite 기반 @storybook/nextjs-vite 기준입니다.
// Webpack 커스텀이 필요한 프로젝트라면 import를 @storybook/nextjs로 교체하세요.
import { useState, type PropsWithChildren } from "react";
import type { Preview } from "@storybook/nextjs-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ToastProvider } from "@/shared/lib/toast";
import { BottomSheetProvider } from "@/shared/lib/bottom-sheet";
const StoryQueryProvider = ({ children }: PropsWithChildren) => {
// 스토리 렌더 생명주기 동안 하나의 QueryClient를 유지합니다.
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
const preview: Preview = {
decorators: [
// 스토리가 교체되면 key로 Provider를 새로 마운트해 캐시를 격리합니다.
// 뷰모델이 실제로 호출하는 토스트·바텀시트의 공통 UI Provider도 함께 등록합니다.
(Story, context) => (
<StoryQueryProvider key={context.id}>
<ToastProvider>
<BottomSheetProvider>
<Story />
</BottomSheetProvider>
</ToastProvider>
</StoryQueryProvider>
),
],
};
export default preview;또 하나의 공통 전제는 스토어 격리입니다. Zustand 스토어는 컴포넌트 언마운트와 무관하게 모듈 메모리에 살아있으므로, 앞 스토리가 변경한 상태가 다음 스토리로 흘러 들어갑니다. 다만 리셋을 전역 preview에 넣으면 feature가 20개로 늘었을 때 preview가 모든 store를 import하고 초기화해야 하는 구조가 됩니다. 그래서 해당 스토리 파일의 meta-level beforeEach에 국소화합니다(아래 스토리 코드 참고). 프로젝트의 모든 스토리가 특정 store에 의존하는 경우에만 전역 초기화가 맞습니다.
통합 스토리 작성 시 두 가지 함정
- 즉시 resolve되는 mock으로 로딩 상태를 검증하면 flaky합니다.
userEvent.click이 끝난 시점에 Promise가 이미 resolve되어 pending 문구가 사라져 있을 수 있습니다. pending 검증에는 resolve 시점을 테스트가 직접 쥐는 deferred Promise — 완료 시점을 테스트 코드가 직접 정할 수 있게 미뤄둔 Promise — 를 쓰고, deferred 역시 각 스토리 실행 전(beforeEach)에 새로 생성해 resolver가 이전 실행과 공유되지 않도록 합니다. 아래 예제는 스토리가 순차 실행된다는 전제의 간결한 형태이며, 동일 모듈의 스토리를 병렬 실행하는 환경이라면 deferred 상태도 decorator나 스토리별 팩토리 내부로 완전히 격리해야 합니다. - 토스트·바텀시트가 portal로 canvas 밖에 렌더링되면
storybook/test의screen으로 조회합니다.canvas는 스토리 내부,screen은 문서 전체라는 구분이 코드에서 그대로 드러납니다.
// src/views/cart/cart-page.stories.tsx
// Storybook 9 + @storybook/nextjs-vite 기준. Webpack 기반이면 @storybook/nextjs.
import type { Decorator, Meta, StoryObj } from "@storybook/nextjs-vite";
import { expect, screen } from "storybook/test";
// 테스트 전용 요소는 테스트 전용 Public API(testing.ts)로 가져옵니다.
import {
InventoryRepositoryProvider,
OutOfStockError,
resetCartStore,
type IInventoryRepository,
} from "@/features/cart/testing";
import { CartPageUI } from "./cart-page.ui";
// mock은 렌더마다 새 참조가 생기지 않도록 컴포넌트 밖 상수로 선언합니다.
const outOfStockRepository: IInventoryRepository = {
assertAvailable: async () => {
throw new OutOfStockError();
},
};
// pending 검증용 deferred mock 팩토리
const createDeferredStockRepository = () => {
let resolve!: () => void;
const repository: IInventoryRepository = {
assertAvailable: () =>
new Promise((nextResolve) => {
resolve = nextResolve;
}),
};
return {
repository,
resolveStock: () => resolve(),
};
};
type DeferredStockRepository = ReturnType<typeof createDeferredStockRepository>;
let successDeferred: DeferredStockRepository;
const withRepository =
(repository: IInventoryRepository): Decorator =>
(Story) => (
<InventoryRepositoryProvider value={repository}>
<Story />
</InventoryRepositoryProvider>
);
const meta = {
component: CartPageUI,
title: "Views/CartPage",
parameters: {
nextjs: { appDirectory: true },
},
// 스토어와 deferred(resolver 공유 방지)를 각 스토리 실행 전에 초기화합니다.
async beforeEach() {
resetCartStore();
successDeferred = createDeferredStockRepository();
},
} satisfies Meta<typeof CartPageUI>;
export default meta;
type Story = StoryObj<typeof meta>;
// 시나리오 1: pending → 담기 성공 (resolve 시점을 테스트가 제어)
export const AddToCartSuccessFlow: Story = {
// beforeEach에서 만든 deferred를 렌더 시점에 읽습니다.
decorators: [
(Story) => (
<InventoryRepositoryProvider value={successDeferred.repository}>
<Story />
</InventoryRepositoryProvider>
),
],
play: async ({ canvas, userEvent }) => {
await userEvent.click(canvas.getByRole("button", { name: "장바구니 담기" }));
// resolve 전이므로 pending이 확정적으로 관찰됩니다 (findByText로 상태 반영 대기)
await expect(
await canvas.findByText("서버 재고 판별 중..."),
).toBeInTheDocument();
// 테스트가 원하는 시점에 검증 통과를 흘려보냅니다
successDeferred.resolveStock();
await expect(
await screen.findByText("장바구니에 담겼습니다."),
).toBeInTheDocument();
},
};
// 시나리오 2: 품절 → 바텀시트 노출
export const OutOfStockFlow: Story = {
decorators: [withRepository(outOfStockRepository)],
play: async ({ canvas, userEvent }) => {
await userEvent.click(canvas.getByRole("button", { name: "장바구니 담기" }));
await expect(await screen.findByText("품절 안내")).toBeInTheDocument();
},
};env 스위칭만으로는 불가능했던 스토리별 응답 분기가 decorator 한 겹으로 해결됩니다. 서두에 적은 핵심 문장이 코드로 완성되는 지점입니다.
6. 이 구조를 쓰지 말아야 하는 경우
아키텍처는 도구이지 종교가 아니므로, 적용하지 말아야 할 조건도 명확히 해둡니다.
- 소규모 프로젝트, 짧은 수명의 프로덕트: 레이어와 파일 규약의 유지 비용이 이득을 앞섭니다. 이 구조는 여러 명이 수개월 이상 협업하는 코드베이스에서 값을 합니다.
- 단순 CRUD 화면 위주의 서비스: mock/real 이중화나 시나리오 테스트가 필요 없는 화면에까지 Repository 인터페이스와 Container 분리를 강제하면 파일 수만 늘어납니다. 위에서 정한 예외 조항(스토리 대상만 분리, 이중화가 실익 있는 데이터 소스만 인터페이스화)을 우선하세요.
- 팀이 규약을 합의하지 못한 상태: 이 구조의 이득 대부분은 "모든 슬라이스가 같은 모양"이라는 예측 가능성에서 나옵니다. 절반만 적용된 컨벤션은 없느니만 못합니다. 도입한다면 린트 강제(아래 설정)와 함께 시작하세요.
규약의 기계적 강제는 Biome으로 합니다. (설정은 @biomejs/biome 2.2 이상 기준입니다. noImportCycles는 2.0에서 nursery로 도입된 뒤 suspicious 그룹으로 승격되었고, 프로젝트 전체 모듈 그래프를 스캔하므로 domains.project 활성화가 필요합니다. useFilenamingConvention은 코드 식별자용 useNamingConvention과 별개인 파일명 전용 룰입니다.)
{
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"linter": {
"enabled": true,
"domains": {
"project": "all"
},
"rules": {
"recommended": true,
"style": {
"useFilenamingConvention": {
"level": "error",
"options": {
"strictCase": true,
"requireAscii": true,
"filenameCases": ["kebab-case"]
}
},
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": [
"@/features/*/**",
"@/entities/*/**",
"@/views/*/**",
"!@/features/*/testing",
"!@/features/*/index.server"
],
"message": "FSD 위반: 슬라이스 내부 파일에 직접 접근하지 마세요. Public API(index.ts / index.server.ts / testing.ts) 경로만 사용해야 합니다."
}
]
}
}
},
"suspicious": {
"noImportCycles": "error"
}
}
}
}! negation 글롭은 Biome 2.2부터 공식 지원되며, testing.ts·index.server.ts 같은 목적별 Public API entry를 차단 대상에서 제외합니다. views까지 포함해 예제에 등장하는 모든 슬라이스 레이어를 검사 대상으로 넣었으므로, "각 슬라이스의 외부 접근은 Public API로만"이라는 본문 규칙과 설정이 일치합니다. (widgets 레이어를 쓰기 시작하면 같은 패턴으로 추가하면 됩니다.)
더 정밀한 레이어 방향성 강제(features → entities 허용, 역방향 금지)가 필요하다면 ESLint 진영의 @feature-sliced/eslint-config나 eslint-plugin-boundaries 병행이 현실적인 선택지입니다.
7. 결론: 좋은 모듈화는 AI 에이전트의 작업 범위도 좁힌다
마지막으로, 이 구조가 사람만을 위한 것이 아니라는 점을 짧게 덧붙입니다. Claude Code 같은 코딩 에이전트에게 기능 수정을 맡길 때, 설계가 무너진 코드베이스에서는 수천 줄의 얽힌 컨텍스트를 해석하다 문맥을 놓치고 결함 있는 코드를 낼 확률이 올라갑니다. 반면 이 구조에서는 View·ViewModel·Story가 한 폴더에 응집하고 데이터 규격과 통신이 각자의 자리에 격리되어 있어, 관련 폴더 몇 개만 컨텍스트로 지정하면 작업 범위가 좁고 규칙이 예측 가능해집니다. 환각이 사라지는 것은 아니지만, 결과물의 품질 편차가 줄어드는 것을 현업에서 반복적으로 확인하고 있습니다.
IInventoryRepository 인터페이스를 주고 "mock과 real 구현체를 작성해 줘"라고 지시할 수 있는 구조와, "알아서 어딘가에 API 호출을 넣어 줘"라고 말할 수밖에 없는 구조의 차이는, 사람 온보딩에서든 에이전트 활용에서든 같은 방향으로 작동합니다.
정리하면 이 글의 주장은 처음의 한 문장으로 돌아옵니다. Repository를 데이터 교체 지점으로, View를 상태 표현 지점으로 분리하면, 프로덕션 조립은 단순하게 유지하면서 Storybook에서는 실제 ViewModel 흐름을 시나리오별로 검증할 수 있습니다. 나머지 규약(kebab-case, Public API 린트, views 리네이밍)은 이 두 분리를 팀 스케일에서 유지하기 위한 가드레일입니다.
긴 글 읽어주셔서 감사합니다. 더 좋은 구조에 대한 고견이나 피드백은 댓글로 편하게 나누어 주세요.
이 글에서 다루지 않은 두 주제 — "화면 특화 Zustand 상태의 페이지 이탈 시 초기화 패턴(StrictMode 이중 실행 주의점 포함)"과 "Biome 가드레일 심화 구성" — 는 분량 관계로 별도 글에서 다룰 예정입니다.