1. 엔티티 간 관계와 상호작용
원칙적으로는 엔티티 슬라이스끼리는 서로를 모르는 상태가 이상적이다. 하지만 실제 애플리케이션에서는 한 엔티티가 다른 엔티티를 포함하거나 여러 엔티티가 서로 상호작용하는 일이 자주 발생한다. 이런 경우 두 엔티티 간의 구체적인 상호작용 로직은 상위 레이어(features, pages 등등)로 올려서 처리하는 것이 좋다.
만약 한 엔티티의 데이터 안에 다른 엔티티가 포함되어야 한다면 @x cross-import 표기법을 사용해서 교차 public API를 통해 연결되었음을 명시해야 한다.
import type { Song } from "entities/song/@x/artist";
export interface Artist {
name: string;
songs: Array<Song>; // Artist 엔티티 안에 Song 엔티티를 포함
}
쇼핑몰 도메인과 관련해서 엔티티의 구조가 다음과 같다면
📂 entities/
📂 user/
📁 ui/ # UserAvatar, UserCard
📁 model/ # User 타입, userStore
📁 api/ # fetchUser
📂 product/
📁 ui/ # ProductCard, ProductImage
📁 model/ # Product 타입
📁 api/ # fetchProduct
📂 cart/
📁 ui/ # CartItem
📁 model/ # Cart 타입, cartStore
📁 api/ # fetchCart장바구니에 상품 추가하기 기능을 만들어야 한다면 이것은 product와 cart라는 두 엔티티가 필요해진다.
// ❌ 잘못된 방법 - entities/cart에서 product를 직접 import
// entities/cart/ui/AddToCartButton.tsx
import { Product } from 'entities/product'; // ❌ 같은 레이어 참조 금지 규칙 위반
function AddToCartButton({ product }: { product: Product }) {
const addToCart = useCartStore(state => state.add);
return (
<button onClick={() => addToCart(product)}>
장바구니 담기
</button>
);
}
위와 같은 이슈를 해결하는 첫 번째 방법은 상위 레이어에서 두 엔티티의 상호작용을 조합하는 방법이다. 첫 번째 해결 방법: 상위 레이어(Features)로 올리기 - 두 엔티티 간의 구체적인 상호작용 로직은 상위 레이어(Feature 또는 Page)로 올려서 처리를 권장
📂 features/
📂 add-to-cart/ # 👈 상호작용을 Feature로 분리
📂 ui/
📄 AddToCartButton.tsx
📄 index.ts
✅ 올바른 방법 - features에서 두 entity를 조합
// features/add-to-cart/ui/AddToCartButton.tsx
import { Product } from 'entities/product'; // ✅ 하위 레이어
import { useCartStore } from 'entities/cart'; // ✅ 하위 레이어
interface AddToCartButtonProps {
product: Product;
}
export function AddToCartButton({ product }: AddToCartButtonProps) {
const addToCart = useCartStore(state => state.add);
const handleClick = () => {
addToCart({
productId: product.id,
name: product.name,
price: product.price,
quantity: 1,
});
};
return (
<button onClick={handleClick}>
장바구니 담기
</button>
);
}전체 구조로 보기
📂 entities/
📂 user/
# User 자체에 관한 것만
📂 product/
# Product 자체에 관한 것만 (ProductCard, fetchProduct)
📂 cart/
# Cart 자체에 관한 것만 (cartStore, CartItem)
📂 features/
📂 add-to-cart/
# Product → Cart 상호작용 ✅
📂 checkout/
# Cart + User → 결제 상호작용 ✅
📂 product-review/
# User + Product → 리뷰 작성 상호작용 ✅페이지에서 조합하면 다음과 같다.
// pages/product-detail/ui/ProductDetailPage.tsx
import { ProductCard } from 'entities/product';
import { AddToCartButton } from 'features/add-to-cart';
export function ProductDetailPage() {
const product = useLoaderData();
return (
<div>
<ProductCard product={product} />
<AddToCartButton product={product} /> {/* Feature 사용 */}
</div>
);
}
하지만 상황에 따라서 엔티티 데이터 안에 다른 엔티티가 포함되어야 하는 등 어쩔 수 없이 같은 레이어의 다른 슬라이스를 참조해야할 때 예외적으로 @x cross-import를 통해 해결한다. (추후 나오는 public API의 일환으로 볼 수 있다)
1단계: 예를 들어서 artist 엔티티가 song 엔티티 목록을 가지고 있는 경우라면
// entities/artist/model/artist.ts
interface Artist {
name: string;
songs: Array<Song>; // 👈 Song 타입이 필요함!
}
이 경우 `entities/artist`에서 `entities/song`의 타입을 참조해야 하는데 FSD 규칙상 같은 레이어의 슬라이스끼리는 참조가 금지 @x 표기법 사용 방법 1단계: Song에서 교차 참조용 Public API 생성
📂 entities/
📂 song/
📂 @x/ # 👈 교차 참조 전용 폴더
📄 artist.ts # artist를 위한 export
📂 model/
📄 types.ts
📄 index.ts # 일반 Public APIsong 엔티티에서는 artist가 사용할 것만 export하는데 이때 `@x` 폴더 자체가 cross-import용 public API 역할을 하므로 별도로 song 엔티티의 index.ts에서 export를 해줄 필요는 없다.
// entities/song/@x/artist.ts
export type { Song } from '../model/types';
2단계: artist 엔티티에서 @x 경로로 song 엔티티를 import
// entities/artist/model/artist.ts
import type { Song } from 'entities/song/@x/artist'; // 👈 @x 경로 사용
export interface Artist {
name: string;
songs: Array<Song>;
}
왜 이렇게 하는가? - 의존성이 명확하게 보임
📂 entities/song/@x/
📄 artist.ts # "song이 artist에게 뭔가를 제공하고 있구나"
📄 playlist.ts # "song이 playlist에게도 뭔가를 제공하고 있구나"이렇게 cross-import를 하는 이유는 함께 리팩토링할 코드를 알 수 있다는 장점이 있다. 연결된 엔티티들은 함께 리팩토링이 필요하구나! 하는 연결관계를 놓치지 않게 명시하므로 @x 폴더만 보면 이 엔티티가 누구와 연결되어있는지 바로 파악이 가능하다.
// ❌ 일반 import - 금지됨
import { Song } from 'entities/song';
// ✅ @x import - 허용 (교차 참조 명시)
import type { Song } from 'entities/song/@x/artist';
2. 엔티티에서 얘기하는 도메인이란? (별도로 찾아본 내용 정리)
FSD에서 엔티티는 프로젝트가 다루는 핵심 비즈니스 도메인 객체를 표현하는 레이어로 DDD의 도메인과 FSD에서 다루는 도메인은 관련은 있으나 같지는 않다. DDD는 비즈니스 문제 영역 전체를 깊이 있게 모델링했다면 FSD의 도메인은 프론트에서 다루는(보여지는) 핵심 데이터 개념 위주로 이루어진다고 보인다. 결국 비즈니스 규칙을 정밀하게 모델링하는 것이 아닌 파일 구조를 비즈니스 의미 단위로 분류하기 위한 목적이다.
- 이 프로젝트에서 어떤 것들을 다루나
- 주로 UI 표현과 API 데이터 타입에 집중
- 도메인 간 얽혀있는 복잡한 비즈니스 규칙은 백엔드에 위임
- 프론트에서 다루는 핵심인 것들에 대한 UI 표현과 데이터 타입을 의미
FSD Entity의 실제 역할 - "이 앱에서 다루는 핵심 '것'들의 UI 표현과 데이터 타입"
📂 entities/
📂 user/ # "유저"라는 개념
📁 ui/ # UserAvatar, UserCard (어떻게 보여줄까)
📁 model/ # User 타입 (어떤 데이터인가)
📁 api/ # fetchUser (어떻게 가져올까)
📂 product/ # "상품"이라는 개념
📂 order/ # "주문"이라는 개념
📂 comment/ # "댓글"이라는 개념
핵심 차이는 DDD는 "주문에 상품을 추가할 때 재고 확인, 할인 적용, 최소 금액 체크..." 등 복잡한 비즈니스 규칙을 도메인 모델 내부에서 처리하고 FSD는 "주문 데이터를 받아서 화면에 보여주자" 등 세부적인 비즈니스 규칙은 백엔드가 처리하고 프론트는 결과를 표시
정리하면 FSD 도메인은 DDD에서 영감을 받아오지만 프론트엔드에 맞게 단순화된 개념으로 볼 수 있을 것이다. DDD는 비즈니스를 코드로 모델링하고 FSD에서 말하는 엔티티 도메인은 프로젝트에서 다루는 핵심 개념들을 폴더로 구분하는 느낌이다.