카테고리 없음

FSD 아키텍처 정독하기 4편

harubanbo 2026. 5. 9. 23:02

FSD 적용 중 주의점

UI의 큰 재사용 블록 widget

만약 모든 페이지에 공통적으로 쓰이는 헤더 UI가 있다고 가정해보자, 이 헤더 UI를 모든 페이지에 구현하는 것은 비효율적이므로 재사용 로직으로 분리하고 싶을 것이다. 이미 shared라는 코드 재사용에 용이한 레이어를 알고있지만 shared 레이어에 큰 UI 블록을 넣는 것은 주의할 점이 있다.

UI 블록이 크더라도 단순 API 호출 등 내부 구현 로직이 굉장히 간단하면 문제가 없으나 서로 다른 도메인의 데이터를 가져와서 UI 구성요소를 구현해야하는 복잡한 경우에는 보통 features나 entities 레이어에 관련 요소들이 위치할 것이다. 레이어 참조 제약 조건에 따라서 shared 레이어는 상위 레이어인 features나 entities의 요소를 참조할 수 없다.

그렇다면 이런 경우에는 헤더 UI를 shared 레이어에 구성하는 게 아니라 widgets 레이어에 구현하고 widgets에서 features, entities 레이어의 요소를 가져와서 구현하는 것이 올바른 방향이다.

// shared/ui/Header.tsx 로고 + 네비게이션 링크만 있음, 상위 레이어(Features, Entities) 사용 안 함
export function Header() {
  return (
    <nav>
      <Logo />
      <Link to="/">Home</Link>
      <Link to="/login">Sign in</Link>
    </nav>
  );
}
// widgets/header/ui/Header.tsx 올바른 방법 - Widgets 레이어 사용
import { LoginButton } from 'features/auth';      // 하위 레이어
import { CartPreview } from 'features/cart';      // 하위 레이어
import { UserAvatar } from 'entities/user';       // 하위 레이어
import { Button } from 'shared/ui';               // 하위 레이어

export function Header() {
  return (
    <nav>
      <Logo />
      <CartPreview />
      <UserAvatar />
      <LoginButton />
    </nav>
  );
}

features와 entities에 들어가야 하는 것은 API 호출, 상태관리, 도메인 지식 등 비즈니스 로직이 포함된 요소가 들어가야 한다.

// features/auth/ui/LoginButton.tsx
// shared에 넣으면 안 됨 - 로그인 로직을 알고 있음
import { useAuth } from '../model/authStore';
import { login } from '../api/login';

export function LoginButton() {
  const { isLoggedIn, logout } = useAuth();

  if (isLoggedIn) {
    return <button onClick={logout}>로그아웃</button>;
  }
  return <button onClick={login}>로그인</button>;
}

// entities/user/ui/UserAvatar.tsx
// shared에 넣으면 안 됨 - User라는 도메인 개념을 알고 있음
import { useCurrentUser } from '../model/userStore';
import { Avatar } from 'shared/ui';  // Shared의 순수 UI 사용

export function UserAvatar() {
  const user = useCurrentUser();  // 현재 유저 정보 가져오기

  return (
    <Link to={`/profile/${user.username}`}>
      <Avatar src={user.image} alt={user.username} />
    </Link>
  );
}

// features/cart/ui/CartPreview.tsx
// shared에 넣으면 안 됨 - 장바구니 비즈니스 로직 포함
import { useCart } from '../model/cartStore';

export function CartPreview() {
  const { items, totalPrice } = useCart();

  return (
    <div>
      <span>🛒 {items.length}개</span>
      <span>{totalPrice}원</span>
    </div>
  );
}



인덱스 파일 사용시 주의사항 - circular import(순환참조)

circular import는 두 개 이상의 파일이 서로를 참조하는 구조를 말한다. 이런 구조는 번들러가 처리하기 어렵고 디버깅 하기도 힘든 런타임 오류를 만들 수 있다. 순환 참조는 인덱스 파일이 없더라도 발생할 수 있지만 인덱스 파일을 쓰면 이런 실수가 발생하기 쉬워진다. 예를 들어서 다음과 같다.

📂 pages/home/
  📄 index.js # Public API 정의
  📂 ui/
    📄 HomePage.jsx
  📂 api/
    📄 loadUserStatistics.js

index(public API)에서 HomePage를 가져온다.

// pages/home/index.js (Public API)
export { HomePage } from "./ui/HomePage"; // HomePage 가져옴
export { loadUserStatistics } from "./api/loadUserStatistics";

HomePage 안에서 index의 loadUserStatistics를 임포트

// pages/home/ui/HomePage.jsx
import { loadUserStatistics } from "../";  // index.js에서 가져옴

export function HomePage() {
  // loadUserStatistics 사용...
}

순환 구조 발생

index.js (public API) ─────────┐
    │                          │
    │ export { HomePage }      │
    ▼                          │
HomePage.jsx                   │
    │                          │
    │ import { loadUserStatistics } from "../"
    │                          │
    └──────────────────────────┘
         다시 index.js로!

 

그래서 이를 해결하려면 같은 슬라이스 내부에서 모듈을 import할 때는 상대경로를 사용해서 정확히 어느 파일을 참조하는지 명확히 작성해야 한다. 결국 public API(index)는 정말 외부 슬라이스를 위한 용도로만 참조해야 한다. index에서 export한 모듈이 다시 index를 참조하지 않도록 주의한다.

// ❌ 잘못된 방법 - index.js(public API)를 통해 가져옴
import { loadUserStatistics } from "../";

// ✅ 올바른 방법 - 파일을 직접 가져옴 (순환 없음)
import { loadUserStatistics } from "../api/loadUserStatistics";
// pages/home/ui/HomePage.jsx

// 같은 슬라이스 내부 → 직접 경로
import { loadUserStatistics } from "../api/loadUserStatistics";

// 다른 슬라이스 public API (절대경로나 alias를 사용)
import { Button } from "@/shared/ui";
import { UserCard } from "@/entities/user";

 

FSD 적용 중 최적화 방법

large bundle & tree shaking 문제

일부 번들러는 index 파일에서 여러 모듈을 한 번에 export할 경우 실제로 사용하지 않는 코드를 제대로 제거하지 못할 수 있다. 대부분의 public API에서는 모듈간 연관성이 높아서 큰 문제는 없으나 shared/ui, shared/lib처럼 서로 관련성이 낮은 모듈 묶음에서는 문제가 커진다. 예를 들어서 아래와 같은 구조가 있다면

- 📂 shared/ui/
    - 📂 button
    - 📂 text-field
    - 📂 carousel
    - 📂 accordion

이 상황에서 외부에서 단순히 button 하나만 쓰고 싶은데 shared/ui 전체를 통째로 하는 큰 index(public API)가 있다면

  • carousel, accordian 등 무거운 의존성까지 함께 번들에 포함될 수 있다.

또한 각 코드 안에 무거운 라이브러리까지 포함되어 있다면 최종 번들 크기에 큰 영향을 준다. 이를 해결하려면 다음과 같이 각 컴포넌트, 라이브러리 별로 별도의 작은 index(pulbic API)를 만든다.

- 📂 shared/ui/
    - 📂 button
        - index.ts
    - 📂 text-field
        - index.ts

그리고 사용하는 쪽에서는 shared/ui의 public이 아니라 shared/ui/button, shared/ui/text-field와 같이 컴포넌트 단위에 직접 import해서 public API(index)를 불러온다

import { Button } from "@/shared/ui/button";
import { TextField } from "@/shared/ui/text-field";


하지만 public API의 우회 방지의 한계점이 있다. 슬라이스에 index(public API)를 만들어도 개발자가 직접 내부 경로를 입력해서 import하거나 IDE 차원에서 auto import를 통해 내부 파일 경로를 자동으로 선택해버리면 public API 규칙이 깨질 수 있으므로 주의해야 한다.

대규모 프로젝트에서의 bundler 성능 문제

index(public API)가 너무 많아지면 불필요한 모듈이 import되므로 개발서버 실행 속도나 HMR 성능이 저하될 수 있다.

최적화하는 방법은 아래와 같다.

  1. large bundle & tree shaking 문제에서 설명한 것처럼 shared/ui, shared/lib 등에 있는 큰 index를 없애고 컴포넌트/모듈 단위로 쪼갠 작은 index를 사용한다.
  2. 세그먼트 단위로 불필요한 index(public API)를 만들지 않는다.
    1. 예를 들어서 features/comments/index.ts가 이미 슬라이스의 public API 역할을 하고 있다면 features/comments/ui/index.ts같이 중첩된 index는 굳이 만들 필요가 없다.
  3. 큰 프로젝트는 기능 단위 chunk 또는 패키지로 나눈다.
    1. Google Docs처럼 Document Editor와 File Browser를 서로 다른 chunk 패키지로 분리한다.
    2. 모노레포에서는 각 패키지를 독립적인 FSD root로 구성할 수 있다.
      1. 일부 패키지는 shared와 entities 레이어만 포함
      2. 다른 패키지는 app, pages 레이어만 포함
      3. 필요한 경우 작은 shared를 각 패키지에 두고 다른 패키지의 큰 shared를 참조하는 방식으로 설계

무슨 소리냐면 프로젝트가 커지면 모든 코드가 하나의 FSD 구조에 들어있게 된다.

📂 src/
  📂 app/
  📂 pages/
    📂 document-editor/
    📂 file-browser/
    📂 settings/
    📂 user-profile/
    ... (수십 개의 페이지)
  📂 features/
    ... (수십 개의 기능)
  📂 entities/
    ... (수십 개의 엔티티)
  📂 shared/
    ... (수백 개의 공통 모듈)


이는 번들러가 모든 파일을 한꺼번에 처리해야 해서 느려진다. 그래서 기능 단위로 쪼개는 방법이 있다.

Google Docs 예시로 생각하면:

  • Document Editor (문서 편집기) - 독립된 앱처럼 동작
  • File Browser (파일 탐색기) - 독립된 앱처럼 동작
  • Settings (설정) - 독립된 앱처럼 동작

이들을 별도의 패키지로 분리하는 것이다.

📂 packages/
  📂 document-editor/        # 독립된 FSD 구조
    📂 src/
      📂 app/
      📂 pages/
      📂 widgets/
      📂 features/
      📂 entities/
      📂 shared/

  📂 file-browser/           # 독립된 FSD 구조
    📂 src/
      📂 app/
      📂 pages/
      📂 features/
      📂 entities/
      📂 shared/

  📂 shared-core/            # 공통으로 쓰는 것들
    📂 src/
      📂 shared/
      📂 entities/


패키지간의 관계는 아래와 같다

┌─────────────────┐     ┌─────────────────┐
│ document-editor │     │  file-browser   │
│                 │     │                 │
│  자체 shared/   │     │  자체 shared/   │
│  자체 features/ │     │  자체 features/ │
└────────┬────────┘     └────────┬────────┘
         │                       │
         │    참조               │    참조
         ▼                       ▼
    ┌─────────────────────────────────┐
    │          shared-core            │
    │                                 │
    │  공통 UI 컴포넌트               │
    │  공통 Entity (User 등)          │
    │  공통 API 클라이언트            │
    └─────────────────────────────────┘


실제 구조 예시는 아래와 같다

📂 packages/
  📂 shared-core/                 # 전체 공통
    📂 shared/
      📂 ui/                      # Button, Modal 등
      📂 api/                     # API 클라이언트
    📂 entities/
      📂 user/                    # User는 모든 곳에서 필요

  📂 document-editor/             # 문서 편집기 전용
    📂 shared/
      📂 ui/                      # 에디터 전용 UI (Toolbar 등)
    📂 entities/
      📂 document/                # Document 엔티티
    📂 features/
      📂 text-formatting/         # 텍스트 서식
      📂 insert-image/            # 이미지 삽입
    📂 pages/
      📂 editor/

  📂 file-browser/                # 파일 탐색기 전용
    📂 entities/
      📂 folder/
      📂 file/
    📂 features/
      📂 file-upload/
      📂 file-search/
    📂 pages/
      📂 browser/


즉, 각 패키지가 자체 FSD 구조를 가지고, 공통 부분만 `shared-core` 같은 패키지로 분리하는 방식이다. 결국 모노레포로 전환하라는 의미인데 다음 상황이면 프로젝트를 모노레포로의 전환을 고려한다.

  • 빌드/HMR이 눈에 띄게 느려질 때
  • 서로 독립적인 큰 기능 영역이 생길 때
  • 여러 팀이 각 영역을 담당할 때