FSD Widgets에서 여러 Entity 조합하기 — 복합 쿼리와 Cross-Slice 의존성 설계 (Feature-Sliced Design)
FSD(Feature-Sliced Design)를 처음 도입했을 때 가장 먼저 부딪히는 벽이 있습니다. 레이어 구조를 잡고, entities에 User도 만들고 Order도 만들었는데, "이 둘을 동시에 보여주는 대시보드 위젯은 어디 두지?"라는 질문 앞에서 멈추게 되는 순간이요. 저도 그랬습니다. entities 레이어 안에서 직접 조합하면 되지 않나 싶어서 해봤다가, cross-slice 위반 경고를 마주하고 나서야 "아, 이건 다른 레이어 이야기구나"를 깨달았거든요.
이 글은 FSD를 이미 도입했거나 도입을 검토 중인 개발자를 위한 글입니다. 레이어, 슬라이스, 세그먼트 같은 기본 개념에는 어느 정도 익숙하다는 전제로 진행하고, 처음 접하시는 분은 Feature-Sliced Design 공식 문서부터 보시면 맥락이 잡힐 겁니다.
이 글을 다 읽고 나면 기존 코드베이스에서 cross-slice 위반을 직접 식별하고, 그 로직을 widgets 레이어로 어떻게 옮기는지 코드 수준에서 바로 따라해볼 수 있습니다. TanStack Query의 useQueries로 복합 쿼리를 구성하는 방법, @x 표기법으로 entity 간 타입 참조를 명시적으로 관리하는 방법, 그리고 공유 컴포넌트를 어느 레이어에 둬야 할지 기준을 세우는 방법까지 User-Order-Product 도메인 예시 하나로 함께 풀어봅니다.
핵심 개념
FSD 레이어 계층과 단방향 의존성
FSD는 코드를 6개의 레이어로 나누고, 위에서 아래 방향으로만 import를 허용합니다.
app → pages → widgets → features → entities → shared이 규칙 덕분에 의존성 그래프가 DAG(Directed Acyclic Graph, 순환 없는 방향 그래프) 형태를 유지합니다. 어떤 슬라이스를 수정해도 영향이 상위 레이어에만 전파되고, 하위 레이어는 건드리지 않아도 됩니다.
문제는 "같은 레이어 안의 슬라이스끼리는 서로 참조할 수 없다"는 규칙입니다. 현실 도메인에서 Order가 Product 타입을 알아야 한다거나, User와 Order를 한 화면에 묶어야 할 때 이 규칙이 발목을 잡습니다.
Cross-slice 문제: 동일 레이어의 슬라이스 A가 슬라이스 B를 참조해야 하는 상황. 일반적인 방식으로는 FSD 규칙을 위반하게 된다.
Widgets 레이어의 역할
예전에는 widgets를 "컴포넌트를 조합만 하는 레이어"로 좁게 이해하는 경우가 많았습니다. 최근 FSD 가이드에서는 이 관점이 달라졌습니다. widgets는 이제 자체 스토어, API 호출, 비즈니스 로직을 직접 소유하는 독립 단위로 설계하도록 권장됩니다.
widgets/
user-order-dashboard/
api/ ← 복합 쿼리 훅
model/ ← 위젯 전용 스토어/타입
ui/ ← 컴포넌트
index.ts ← public APIshared → entities → features 레이어 모두를 참조할 수 있는 가장 상위의 재사용 단위이기 때문에, 여러 entity를 엮는 로직의 자연스러운 집합 장소가 됩니다.
@x 표기법 — Entity 간 타입 참조를 명시적으로
entity 레이어 내에서 타입 수준의 참조가 필요할 때 FSD 가이드에서 권장하는 방법이 @x 표기법입니다. "A가 B와 교차 참조된다"는 사실을 파일 경로 자체에 드러내는 방식입니다.
entities/product/@x/order.ts이 파일은 product 슬라이스가 order 슬라이스에게만 열어주는 전용 public API 역할을 합니다. 일반 index.ts와 분리되어 있으므로, 파일 트리만 봐도 어떤 슬라이스가 어떤 슬라이스와 연결되어 있는지 한눈에 파악됩니다. Steiger(FSD 전용 아키텍처 린터, 뒤에서 다시 설명)도 이 패턴을 이해해서, index.ts를 통한 cross-import는 경고하고 @x 경로를 통한 참조는 허용합니다.
@x표기법은 entities 레이어에만 사용하는 것이 원칙입니다. features나 widgets에서의 cross-import는 일반적으로 설계 오류의 신호입니다.
그렇다면 실제 코드에서는 어떻게 보일까요? User-Order-Product라는 한 서비스를 예시로, 점점 복잡해지는 시나리오를 차례로 풀어보겠습니다.
실전 적용
예시 1: Widgets에서 User와 Order를 동시에 조회하는 복합 쿼리
가장 흔한 시나리오입니다. 대시보드 위젯 하나가 사용자 정보와 해당 사용자의 주문 목록을 동시에 보여줘야 합니다.
폴더 구조
src/
entities/
user/
api/
userQueries.ts
index.ts
order/
api/
orderQueries.ts
index.ts
widgets/
user-order-dashboard/
api/
useUserOrderDashboard.ts
ui/
UserOrderDashboard.tsx
index.ts ← 위젯의 public APIentities 레이어: 개별 쿼리 팩토리만 소유
// entities/user/api/userQueries.ts
import { queryOptions } from '@tanstack/react-query';
import { fetchUser } from './userApi';
export const userQueries = {
detail: (userId: string) =>
queryOptions({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
}),
};// entities/order/api/orderQueries.ts
import { queryOptions } from '@tanstack/react-query';
import { fetchOrdersByUser } from './orderApi';
export const orderQueries = {
byUser: (userId: string) =>
queryOptions({
queryKey: ['orders', 'byUser', userId],
queryFn: () => fetchOrdersByUser(userId),
}),
};widgets 레이어: 조합 로직은 여기서
useQuery를 두 번 호출하는 대신 useQueries를 사용하는 이유는 두 요청을 병렬로 실행하면서 로딩·에러 상태를 배열로 한꺼번에 관리할 수 있기 때문입니다. 특히 한쪽 쿼리만 실패했을 때도 나머지 데이터를 부분 렌더링할 수 있어 UX 면에서 유리합니다.
// widgets/user-order-dashboard/api/useUserOrderDashboard.ts
import { useQueries } from '@tanstack/react-query';
import { userQueries } from '@/entities/user';
import { orderQueries } from '@/entities/order';
export function useUserOrderDashboard(userId: string) {
const [userResult, ordersResult] = useQueries({
queries: [
userQueries.detail(userId),
orderQueries.byUser(userId),
],
});
return {
user: userResult.data,
orders: ordersResult.data ?? [],
isLoading: userResult.isLoading || ordersResult.isLoading,
// 두 쿼리 중 하나라도 실패하면 isError가 true
// 부분 실패 처리가 필요하다면 userResult.isError / ordersResult.isError를 개별로 확인할 수 있습니다
isError: userResult.isError || ordersResult.isError,
};
}// widgets/user-order-dashboard/ui/UserOrderDashboard.tsx
import { useUserOrderDashboard } from '../api/useUserOrderDashboard';
import { Skeleton } from '@/shared/ui'; // 도메인 무관 범용 로딩 UI
import { UserProfile } from '@/entities/user/ui'; // user 슬라이스 전용 UI
import { OrderList } from '@/entities/order/ui'; // order 슬라이스 전용 UI
interface Props {
userId: string;
}
export function UserOrderDashboard({ userId }: Props) {
const { user, orders, isLoading } = useUserOrderDashboard(userId);
if (isLoading) return <Skeleton />;
return (
<section>
<UserProfile user={user} />
<OrderList orders={orders} />
</section>
);
}// widgets/user-order-dashboard/index.ts
// 위젯 외부에서는 이 경로를 통해서만 접근합니다
export { UserOrderDashboard } from './ui/UserOrderDashboard';| 역할 | 위치 | 이유 |
|---|---|---|
개별 쿼리 팩토리 (queryOptions) |
entities/<slice>/api/ |
단일 도메인 책임 |
복합 쿼리 훅 (useQueries) |
widgets/<name>/api/ |
여러 entity 조합은 상위 레이어 담당 |
범용 로딩 UI (Skeleton) |
shared/ui/ |
도메인과 무관한 범용 컴포넌트 |
도메인 UI (UserProfile, OrderList) |
entities/<slice>/ui/ |
단일 entity에 종속된 UI |
| 복합 렌더링 컴포넌트 | widgets/<name>/ui/ |
여러 entity 데이터를 조합해 그리는 단위 |
예시 2: @x 표기법으로 Order가 Product 타입을 참조하기
같은 서비스를 조금 더 키워볼게요. 이번엔 Order에 주문한 상품 목록이 필요해졌습니다. Order 모델이 ProductSummary 타입을 필드로 가져야 하는 상황인데, 일반적인 방식으로는 같은 레이어 내 cross-import가 되어 규칙을 위반합니다. @x 표기법이 이 문제를 해결합니다.
폴더 구조
src/entities/
product/
@x/
order.ts ← order 슬라이스 전용 public API
model/
types.ts
index.ts
order/
model/
types.ts ← product/@x/order에서만 가져옴
index.tsproduct의 전용 public API 정의
// entities/product/model/types.ts
export interface Product {
id: string;
name: string;
price: number;
stock: number;
}
// order 슬라이스에 노출할 최소한의 타입만 별도로 정의
export interface ProductSummary {
id: string;
name: string;
price: number;
}// entities/product/@x/order.ts
// order 슬라이스에게만 허용하는 전용 export
export type { ProductSummary } from '../model/types';order에서 참조
// entities/order/model/types.ts
import type { ProductSummary } from '@/entities/product/@x/order';
export interface Order {
id: string;
userId: string;
products: ProductSummary[]; // product/@x/order를 통해 가져온 타입
totalAmount: number;
createdAt: string;
}그러면 예시 1의 OrderList 컴포넌트는 이 Order 타입을 props로 받아 products 배열을 렌더링합니다. 위젯 레벨에서 User 정보와 Order+Product 구조가 자연스럽게 연결되는 흐름입니다.
이 패턴의 핵심은 product/index.ts가 아니라 product/@x/order.ts에서만 가져온다는 점입니다. index.ts를 통한 일반 cross-import는 Steiger가 경고를 발생시키고, @x 경로를 통한 참조만 허용합니다.
예시 3: Cross-slice 공유 컴포넌트의 위치 결정
솔직히 처음엔 매번 헷갈렸습니다. UserOrderCard처럼 두 도메인이 섞인 컴포넌트가 나오면 entities에 둬야 할지, widgets에 둬야 할지, shared에 둬야 할지 기준이 흐릿했거든요. 아래 표가 그 기준을 정리해줍니다.
| 상황 | 배치 위치 | 예시 |
|---|---|---|
| 특정 entity에 종속된 UI | entities/<slice>/ui/ |
UserProfile, ProductBadge |
| 여러 entity를 조합하는 UI | widgets/<name>/ui/ |
UserOrderDashboard |
| 도메인과 무관한 범용 UI | shared/ui/ |
Button, Modal, Skeleton |
| 특정 위젯에서만 쓰는 서브 컴포넌트 | widgets/<name>/ui/ 내부 |
OrderListItem (dashboard 전용) |
"공유 범위가 애매하다면 더 높은 레이어에서 시작하는 것을 권장합니다." widgets에 두었다가 다른 곳에서도 필요해지면 그때 shared로 내리는 점진적 방식이 안전합니다.
저희 팀에서 이 기준을 도입하고 나서, PR 리뷰에서 "이 컴포넌트가 왜 entities에 있어요?"라는 질문이 눈에 띄게 줄었습니다. 배치 기준이 명문화되면 리뷰어와 작성자가 같은 언어로 대화할 수 있기 때문입니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 명확한 의존성 방향 | 단방향 import 규칙이 스파게티 구조를 원천 차단 |
| 높은 응집도 | 위젯이 UI, 로직, API를 하나의 폴더에 보유해 탐색 비용 감소 |
| 점진적 채택 | pages-first 방식으로 시작해 필요한 만큼만 레이어 추가 가능 |
| 자동화된 검증 | Steiger로 규칙 위반을 CI에서 조기 발견 |
| 테스트 경계 명확 | 슬라이스 격리 덕분에 단위 테스트 범위 설정이 직관적 |
단점 및 주의사항
처음 FSD를 도입할 때 자주 겪는 함정들이 있습니다.
@x 남용 위험이 첫 번째입니다. cross-import를 편의상 남발하면 의존성이 다시 복잡해집니다. @x는 entities 레이어에만, 그것도 불가피할 때만 쓰는 것이 원칙입니다. PR 리뷰 체크리스트에 "@x 신규 추가 시 사유 명시"를 넣어두면 자연스럽게 관리됩니다.
shared 비대화도 흔한 문제입니다. 재사용성이 불확실한 코드를 shared에 몰아넣으면 쓰레기통 레이어로 전락합니다. 처음엔 widget 내부에 두고, 재사용이 확인될 때만 shared로 내리는 방식이 훨씬 안전합니다.
복합 쿼리의 위치 모호성도 있습니다. 이 쿼리가 widget에 속해야 할지, feature에 속해야 할지 판단이 어려운 경우가 생기는데, 재사용 여부와 의미적 독립성(단독으로 의미가 있는가)을 기준으로 삼으면 대부분 해결됩니다.
학습 곡선은 솔직히 무시할 수 없습니다. 레이어·슬라이스·세그먼트 3단계 구조와 import 규칙을 팀 전체가 내재화해야 일관성이 유지됩니다. Steiger + ESLint 규칙으로 자동 강제하고, 팀 내 문서화를 병행하는 것이 현실적입니다.
소규모 프로젝트 오버엔지니어링도 있습니다. pages + shared만으로 충분한 프로젝트에 전체 레이어를 강제하면 복잡도만 올라갑니다. 팀 규모와 도메인 복잡도에 맞게 점진적으로 도입하는 것이 좋습니다.
Steiger: FSD 전용 아키텍처 린터. 레이어 간 import 위반, public API 미준수,
@x표기법 오용 등을 CLI와 IDE에서 자동 감지합니다.npx steiger ./src로 바로 사용해볼 수 있습니다.
실무에서 가장 흔한 실수
-
entities에서 직접 조합 쿼리를 작성하는 것 —
entities/user/api/에서orderQueries를 import하면 cross-slice 위반입니다. 조합은 반드시 상위 레이어(widgets 또는 pages)에서 이루어져야 합니다. -
@x없이 같은 entity 레이어끼리 직접 참조하는 것 —import { Product } from '@/entities/product'를entities/order/model/types.ts에 쓰는 것은 규칙 위반입니다.@x경로를 통해서만 참조할 수 있습니다. -
shared/ui/에 도메인 종속 컴포넌트를 넣는 것 —UserOrderCard처럼 도메인 의미가 있는 컴포넌트가 shared에 들어가면, shared가 entities를 참조하게 되어 레이어 방향이 역전됩니다.
마치며
이 구조를 팀에 도입하고 나서 PR 리뷰에서 실질적인 변화가 생겼습니다. "왜 이 로직이 entities에 있어요?", "이 컴포넌트 어디서 가져온 거예요?"라는 위치 논쟁이 거의 사라졌거든요. 의존성 방향이 명확해지면 토론의 기준이 생기고, 그 기준이 코드베이스 전체에 일관성을 만들어줍니다.
지금 바로 시작해볼 수 있는 3단계:
-
Steiger를 프로젝트에 추가해볼 수 있습니다 —
pnpm add -D steiger로 설치한 뒤npx steiger ./src를 실행해보시면, 현재 코드베이스에서 FSD 규칙 위반이 몇 개인지 바로 확인할 수 있습니다. -
기존의 cross-entity 로직이 어디에 있는지 점검해보시면 좋습니다 — entities나 features 안에 여러 도메인을 엮는 쿼리나 컴포넌트가 있다면, 해당 코드를 widgets 레이어로 이동하는 것부터 시작할 수 있습니다.
-
entity 간 타입 참조가 있는 곳에
@x폴더를 만들어볼 수 있습니다 —entities/<slice>/@x/<other-slice>.ts파일을 만들어 노출할 타입만 re-export하고, 참조하는 쪽은 이 경로만 사용하도록 정리해보시면 의존 관계가 한눈에 들어오기 시작합니다.
참고 자료
- Feature-Sliced Design 공식 문서 - Layers
- Feature-Sliced Design 공식 문서 - Cross-imports 가이드
- Feature-Sliced Design 공식 문서 - Public API
- FSD with React Query 공식 가이드
- FSD v2.0 → v2.1 마이그레이션 가이드
- Steiger - FSD Architecture Linter | GitHub
- Feature-Sliced Design 2.1 — Changing How We Organize Frontend | Medium
- Mastering FSD: Lessons from Real Projects | DEV Community
- FSD with TanStack Query - Slicing Data | DEV Community