FSD 아키텍처에 컴파운드 패턴 적용해보기

Seoyong Lee·2024년 12월 26일
10

FSD 아키텍처

FSD의 특징

FSD(Feature-Sliced Design)는 최근 주목받는 프론트엔드 아키텍처로 폴더를 '역할(role)'에 더해 '기능(feature)' 단위로 분할하는 것이 주요 특징입니다. 그렇다면 역할과 기능은 어떤 차이가 있을까요?

역할(role): api, utils, components, hooks ...

위와 같이 역할은 코드 관점에서 동작에 따라 나누어진 계층이라고 볼 수 있습니다. CRA 등의 보일러플레이트가 api, hooks, components 등으로 구성된 것도 바로 이러한 역할을 중심으로 폴더구조를 구성했기 때문입니다.

일반적인 역할 중심의 폴더 구조
├── public
├── src
│   ├── api
│   │   └── order     ex) getOrderList.ts
│   ├── assets
│   ├── components
│   │   └── order     ex) OrderList.tsx
│   ├── config
│   ├── hooks
│   │   └── order     ex) useOrderList.ts
│   ├── styles
│   ├── utils
│   ├── App.js
│   └── index.js
├── package.json
└── README.md

이러한 방식은 계층별로 구분하여 시스템의 복잡성을 줄일 수 있었지만, 점차 프로젝트의 규모가 커지기 시작하면서 components 폴더 내부가 비대해지고, 특정 기능이(ex. order) 여러 폴더로 파편화되는 문제(/model/order, /api/order, /components/order)가 발생하게 됩니다.

기능(feature): profile, order, login ...

기능은 실제 서비스 관점에서 비즈니스 도메인을 중심으로 구분됩니다. 역할에 더해서 이러한 기능을 중심으로 프로젝트를 구성하도록 제안된 아키텍처가 바로 FSD입니다.

기능 중심의 FSD 폴더 구조
├── public
├── src
│   ├── app
│   ├── pages
│   │   └── order
│   ├── widgets
│   │   └── order-history
│   │       └── ui       ex) OrderHistory.tsx (주문내역 컴포넌트)
│   ├── features
│   │   └── order-list
│   │       ├── model    ex) useOrderList.ts (API 결과 처리 커스텀 훅)
│   │       └── ui       ex) OrderListTable.tsx (주문내역 테이블 컴포넌트)
│   ├── entities
│   │   └── purchase
│   │       ├── api      ex) getOrderList.ts (API 요청 코드)
│   │       └── model  	 ex) order-list.ts (응답 결과 스키마)
│   └── shared
│           └── ui       ex) Table.tsx (공통 컴포넌트)
├── package.json
└── README.md

위 구조를 보면 주문과 관련된 기능이 계층에 따라 구분된 상태로 각 층을 관통하는 것을 확인할 수 있습니다. 역할 중심의 구조에선 order라는 폴더를 위계 없이 여러 곳에서 만들었던 것과 달리 FSD는 각 계층의 명확한 정의를 따르고 있으며 위에서 아래로, 단방향으로만 import 하도록 제한하는 특징을 가집니다.

  • 레이어(Layer): 역할에 따른 수직적 관심사 분리
  • 슬라이스(Slice): 기능(비즈니스 도메인)에 따른 관심사 분리
  • 세그먼트(Segment): 기술적 관심사 분리

그렇다면 기능을 중심으로 구분하는 FSD는 어떤 이점이 있을까요?

FSD의 장점

FSD는 프로젝트의 규모가 커지더라도 관심사에 따른 분류를 유지하도록 도와줍니다. 이를 통해 결과적으로 하나의 기능을 담당하는 코드가 여러 폴더로 파편화되지 않도록 막아줍니다. 또한 명확하게 정의된 규칙에 따른 분류는 코드의 일관성을 높이고, 유지보수 및 확장성에도 기여합니다.

예측 가능성

  • /ui라는 세그먼트에 속한 파일은 어떤 슬라이스에 속하더라도 그것이 UI를 담당하는 컴포넌트 파일일 것이라는 '예측 가능성'을 높여줍니다.
  • 마찬가지로 각 레이어에 따라 같은 이름의 세그먼트라도 담당하는 역할이 달라질 것으로 예측이 가능합니다.
    • ex) /entities/api: 기본 데이터 페칭 함수가 정의되어 있을 것 같습니다.
    • ex) /feature/api: Tanstack Query 등을 이용한 API 호출 함수가 있을 것 같습니다.

public API를 이용한 모듈화

  • FSD는 다음과 같이 public API를 이용해 특정 슬라이스에서 내보낼 부분을 명확히 정의합니다.

    // widgets/main/header/index.ts
    
    export { HeaderMain } from './ui/HeaderMain';
  • 이러한 방식을 통해 무분별한 import를 방지하고 각 모듈 단위로 독립적으로 교체가 가능하게 합니다.

컨벤션과 확장성

  • 특정 기능의 코드가 비대해지면 이를 적당한 크기로 나누기 편한 기준점을 제시해 줍니다.
  • 나아가 팀 컨벤션으로 작용하여 코드의 유지보수를 돕고 확장성을 높이는 역할을 합니다.

그러나 이러한 FSD도 단점은 있었습니다.

FSD 적용의 어려움

FSD를 처음 문서로 접했을 때는 장점들이 너무나 명확해서 적용하지 않을 이유가 없어 보였지만 실제 프로젝트에 적용해 보니 다음과 같은 문제에 마주하게 되었습니다.

  • 기존 프로젝트에 FSD를 적용하려면 전체적인 구조를 역할이 아닌 기능 중심으로 다시 만들어야 합니다.
    • 이는 프로젝트 전체 구조에 대한 이해도를 높일 기회라고 생각하고 감수해야 하는 것 같습니다.
  • 실제로 어떤 코드가 /feature 나 /widget, /entities로 들어가야 하는지에 대해 구분이 어렵습니다.
    • 이 부분은 공식 사이트의 예시를 봐도 강력하게 정해진 룰은 없는 것으로 보아 서비스의 특성에 따라 각자에게 맞는 방식을 스스로 찾아야 하는 것으로 보입니다.
  • 이렇게 구분이 어렵다 보니 Page -> Widgets -> Features 순으로 위는 점차 비대해지고 Feature에 어떤 것을 두어야 할지 애매하다 보니 일관성을 유지하기 어렵습니다.
    • 처음에는 Post, Delete 등 동사로 정의될 수 있는 비즈니스 액션만 feature로 두려다 보니 get 요청과 같은 단순 조회 및 UI관련 코드는 대부분 widget에만 모이게 되었습니다.
    • 이를 줄이기 위해 다시 Atomic Design Pattern과 같이 UI를 기반으로 쪼개서 내려보내자니 ui가 아닌 다른 세그먼트도 한 단위로 묶어줄 수 있는 무언가가 필요했습니다.

특히 widget과 feature를 명확하게 구분하는 부분은 가장 어려운 점이었습니다. 이러한 문제를 해결하기 위해서 컴파운드 패턴(Compound Pattern)을 적용해 모듈을 적당한 크기로 분할하고 조합해보기로 하였습니다.

컴파운드 패턴

특징과 장점

컴파운드 패턴은 컴포넌트 동작의 세부 구현이나 상태를 내부적으로 감추고 이를 사용하는 쪽에서는 드러나지 않도록 제한하는 패턴입니다. 이러한 방식을 통해 외부에서 import 되는 컴포넌트를 제한하고 추상화 수준에 따른 명확한 구분을 가능하게 합니다.

<select>
	<option value="value1">key1</option>
	<option value="value2">key2</option>
	<option value="value3">key3</option>
</select>

위 예시에서 select 태그는 key1을 선택했다는 사실을 어떻게 알 수 있을까요? select와 option 태그는 명시적인 표현 없이 암묵적으로 상태를 공유합니다.

Kent C. Dodds은 다음과 같이 컴파운드 패턴을 설명합니다.

중요한 측면은 "암묵적 상태(implicit state)"의 개념입니다. <select> 요소는 선택된 옵션에 대한 상태를 암묵적으로 저장하고 이를 하위 항목과 공유하므로 해당 상태에 따라 자체적으로 렌더링하는 방법을 알 수 있습니다. 그러나 상태 공유는 HTML 코드에 상태에 액세스할 수 있는 항목이 없기 때문에 암묵적입니다(그리고 그럴 필요도 없습니다).

컴파운드 패턴은 위와 같이 ui와 이와 관련된 상태를 하나로 묶는 방법을 제공하고 있기에 FSD의 피처와 위젯에서 각 슬라이스를 정의하고 내보내는 기준으로 사용하기에 적합하다고 생각했습니다.

다음은 Header 컴포넌트를 구현한 예시로 이를 통해 컴파운드 패턴을 구체적으로 살펴보겠습니다.

요구사항

  • Header 컴포넌트는 헤더의 내용인 Content와 Banner, SearchIcon으로 구분됩니다.
  • Banner는 특정 조건을 만족하면 보여집니다.
  • SearchIcon은 사용되는 곳에 따라 숨길 수 있어야 합니다.

먼저 일반적인 방식으로 단일 Header 컴포넌트를 구성한 경우입니다.

export const Header = ({
  showSearchIcon,
}: {
  showSearchIcon: boolean;
}): JSX.Element => {
  const { showBanner, onClickSearch, onClickBack } = useHeader();

  return (
    <header>
      {/* Banner */}
      <Transition.Root show={showBanner}>
        <Transition.Child>
          <div>
            <h1>
              <Image height={32} src="/assets/logo.png" width={32} />
            </h1>
            <div></div>
          </div>
          <Button>
            <div>
              <span>download app</span>
            </div>
          </Button>
        </Transition.Child>
      </Transition.Root>
      {/* Content */}
      <div>
        <button onClick={onClickBack} type="button">
          <Image height={16} src="/assets/logo.svg" width={76} />
        </button>
      </div>
      {/* SearchIcon */}
      {showSearchIcon && (
        <button onClick={onClickSearch} type="button">
          <Image
            alt="search"
            height={24}
            src="/assets/icons/ic_search.png"
            width={24}
          />
        </button>
      )}
    </header>
  );
};
function Profile() {
	return (
    	<Layout>  
            <Header showSearchIcon={false} />
      		...
        </Layout>
	)
}

요구사항은 만족하였지만, 다음과 같은 단점들이 보입니다.

  • 사용하는 곳(Profile)에서는 Header 컴포넌트의 내부 구조를 확인할 방법이 없습니다.
  • showSearchIcon이라는 prop을 직접 넘겨주어 SearchIcon 노출 여부를 결정하기 때문에 Header 컴포넌트를 사용하는 모든 곳에서 해당 정보를 반복적으로 전달해야 하는 단점을 가지고 있습니다.
  • 코드가 길어지면서 Header의 구조를 직관적으로 파악하기 어렵습니다.
  • 저수준의 동작이 뒤섞여 재사용이 어렵습니다.

그렇다면 각각의 컴포넌트를 기능에 따라 분할하기만 하면 이러한 문제가 개선될까요?

import { HeaderBanner } from '@/features/search/ui/HeaderBanner';
import { HeaderContent } from '@/features/search/ui/HeaderContent';
import { HeaderSearchIcon } from '@/features/search/ui/HeaderSearchIcon';

function Profile() {
  const { showBanner, showSearchIcon } = useHeader();

  return (
    <Layout>
      <HeaderBanner showBanner={showBanner} />
      <HeaderContent />
      <HeaderSearchIcon showSearchIcon={showSearchIcon} />
      ...
    </Layout>
  );
}
  • 이제 사용하는 곳에서 구조는 볼 수 있지만 각각의 기능을 직접 import 해야 합니다.
  • 분할을 하다 보니 헤더의 동작을 관장하는 useHeader 커스텀 훅이 밖으로 노출 돼버렸습니다.
  • 각 컴포넌트를 묶어줄 방법이 필요해 보입니다.

이를 개선하기 위해 같은 컴포넌트를 컴파운드 패턴을 적용하여 바꿔보겠습니다.

// widgets/profile/ui/ProfileMain.tsx
import { Header } from '@/features/header';

function Profile() {
	return (
    	<Layout>  
            <Header>
                <Header.Banner/>
                <Header.Content/>
            </Header>
      		...
        </Layout>
	)
}

위와 같이 컴파운드 패턴이 적용되면 사용하는 곳에서도 Header 컴포넌트의 구성을 확인할 수 있고 prop 등을 받지 않아 훨씬 깔끔합니다. profile이라는 widget에서 사용되는 Header 컴포넌트는 Header 자신만 import 하고 Content와 같은 개별 컴포넌트는 직접 노출되지 않는 것을 확인할 수 있습니다. 또한 Banner가 열리는 조건과 세부 구현 사항은 사용하는 곳에서는 보이지 않도록 암묵적(implicit)으로 feature 레벨에 숨겨져 있습니다. 이를 통해 저수준의 구현에 시선을 뺐지 않으면서도 전체적인 구조를 명확하게 볼 수 있다는 장점을 가지게 되었습니다. 이러한 구성은 구체적인 코드의 동작을 정의한 feature 레벨 코드의 조합을 통해 widget을 구성하는 FSD의 정의와도 비슷한 부분이 있습니다.

만약 다른 곳에서 사용할 때는 Banner 대신 Search 컴포넌트를 추가하고 싶다면 별도의 Header 수정 없이 다음과 같이 재조합만으로 목적에 맞게 사용이 가능합니다.

// widgets/contents/ui/ContentsMain.tsx
import { Header } from '@/features/header';

function Main() {
	return (
    	<Layout>  
            <Header>
                <Header.Content/>
                <Header.Search/>
            </Header>
      		...
        </Layout>
	)
}

Header 컴포넌트의 세부 구현사항은 다음과 같이 feature 레이어에 구현하였습니다.

// features/header/ui/Header.tsx
interface HeaderContextValue {
  showBanner: boolean;
}

export const HeaderContext = createContext<HeaderContextValue>(
  {} as HeaderContextValue,
);

export const Header = ({ children }: { children: ReactNode }): JSX.Element => {
  const { showBanner } = useHeaderBanner();

  return (
    <HeaderContext.Provider value={{ showBanner, onClickBack }}>
      <header>
        {children}
      </header>
    </HeaderContext.Provider>
  );
};

Header.Banner = HeaderBanner;
Header.Content = HeaderContent;
Header.Search = HeaderSearch;

각 세부 기능에 따라 컴포넌트를 다시 분할하였고 Context API를 통해 각 컴포넌트가 내부적으로 상태를 공유하는 것을 확인할 수 있습니다. 이를 통해서 전역상태 등을 사용하지 않으면서도 내부적으로 통제된 단방향 상태 흐름을 유지하는 것을 확인할 수 있습니다. 또한 상태가 사용되고 변경되는 영역을 컴파운드 컴포넌트 안으로 한정하여 다른 모듈과 결합되지 않도록 분리하고 예상하지 못한 버그를 방지하며 손쉽게 교체가 가능합니다.

컴파운드 패턴은 이렇게 사용하는 곳에서는 전체 구조를 보여주면서도 통제된 방식으로 컴포넌트를 불러오도록 제한하고, 저수준의 구현은 하위레벨로 두도록 하여 좀 더 명확한 레벨 구분을 가능하게 합니다.

이러한 장점을 살려 FSD의 계층에 컴파운드 패턴을 실제로 적용해 보겠습니다.

FSD에 컴파운드 패턴 적용하기

실전 예제

react.dev 사이트를 예시로 구성해 보겠습니다.

1. Page 식별

먼저 App을 제외한 가장 상위 레이어인 Pages를 식별합니다.

  • home
  • learn
  • reference
  • community
  • blog

2. Home page 구현

가장 메인 화면인 Home page부터 구현해 보겠습니다.

export default function Home(): JSX.Element {
  return (
    <Layout>
      <Header>
        <Header.Logo />
        <Header.SearchBar />
        <Header.NavMenu />
        <Header.Buttons />
      </Header>
      <Content />
      <Footer />
    </Layout>
  );
}

위와 같이 메인 페이지는 크게 UI를 기준으로 Header, Content, Footer로 구성하였습니다. 내부 구조를 밖에서 볼 필요성이 낮거나 구조를 변경해서 다른 곳에서 재사용하지 않는 Footer와 같은 컴포넌트는 굳이 컴파운드 패턴으로 구성되지 않은 것을 확인할 수 있습니다. 이제 Header widget을 통해 구체적인 컴파운드 패턴을 구현합니다.

3. Header widget 구현

// widgets/header/ui/Header.tsx

interface HeaderContextValue {
  currentPage: string;
}

export const HeaderContext = createContext<HeaderContextValue>(
  {} as HeaderContextValue,
);

export const Header = ({ children }: { children: ReactNode }): JSX.Element => {
  const { currentPage } = useHeader();

  return (
    <HeaderContext.Provider value={{ currentPage }}>
      <header>{children}</header>
    </HeaderContext.Provider>
  );
};

Header.Logo = HeaderLogo;
Header.SearchBar = HeaderSearchBar;
Header.NavMenu = HeaderNavMenu;
Header.Buttons = HeaderButtons;

현재 페이지에 해당하는 메뉴 버튼 표시를 위해 ContextAPI를 이용해 currentPage 상태를 추가하였습니다. currentPage 상태는 Header 밖에서는 불필요하며 내부적으로만 사용됩니다. 사실 Header 컴포넌트 밖에서는 이런 상태가 존재했다가 소리 소문 없이 사라져도 외부에는 아무런 변화를 주지 않아야합니다. 이제 이 중에서 SearchBar 컴포넌트를 자세히 살펴보겠습니다. SearchBar는 다음과 같이 클릭 시 실제 입력 가능한 검색 모달을 열어야 합니다.

4. Header.SearchBar 구현

UI에 따라 구성한 SearchBar 컴포넌트입니다. 아직 input 영역은 버튼으로만 기능하며 실제 입력은 Modal 내부에서 가능합니다.

// widgets/header/ui/HeaderSearchBar.tsx

import { SearchModal } from '@/features/search-modal';

export const HeaderSearchBar = (): JSX.Element => {
  const [isModalOpen, setIsModalOpen] = useState(false);

  const onClick = () => {
    setIsModalOpen(true);
  };

  const onClose = () => {
    setIsModalOpen(false);
  };

  return (
    <div>
      <div>
        <img src="assets/search-icon.png" />
      </div>
      <button onClick={onClick}>
        <span>검색</span>
        <input disabled />
      </button>
      <SearchModal isOpen={isModalOpen} onClose={onClose}>
        <SearchModal.Input />
        <SearchModal.Results />
        <SearchModal.Bottom />
      </SearchModal>
    </div>
  );
};

구성을 진행하다 보니 검색 모달은 직접 키보드로 입력한 키워드를 기반으로 API 요청 등을 진행해야 하기 때문에 코드량이 많아질 것으로 예상됩니다. 자연스럽게 이를 SearchModal feature로 분리합니다. FSD 문서에서 정의한 feature의 특성은 다음과 같습니다.

This layer is for the main interactions in your app, things that your users care to do. These interactions often involve business entities, because that's what the app is about.
이 레이어(feature)는 앱의 주요 상호 작용, 즉 사용자가 관심을 갖는 작업을 위한 것입니다. 이러한 상호 작용에는 비즈니스 주체(entities)가 포함되는 경우가 많습니다. 왜냐하면 이것이 바로 앱의 목적이기 때문입니다.

사용자가 검색어를 입력하고, 이를 기반으로 API를 통해 검색 결과를 가져오는 SearchModal은 feature에 두기에 매우 적합해 보입니다.

위 사례처럼 widget 레벨에서 코드의 복잡도가 갑자기 높아지거나, 사용자와 구체적인 상호작용을 하는 코드가 섞여 있다면 이를 feature로 분리해 가는 것을 추천합니다.

5. SearchModal feature 구현

// features/search-modal/ui/SearchModal.tsx

interface SearchModalContextValue {
  isOpen: boolean;
  onClose: () => void;
  searchResults: SearchResult[];
  setKeyword: (keyword: string) => void;
}

export const SearchModalContext = createContext<SearchModalContextValue>(
  {} as SearchModalContextValue,
);

export const SearchModal = ({
  children,
  isOpen,
  onClose,
}: {
  children: ReactNode;
  isOpen: boolean;
  onClose: () => Void;
}): JSX.Element => {
  const { keyword, setKeyword } = useInput();
  const { searchResults } = useSearchResults(keyword);

  return (
    <SearchModalContext.Provider
      value={{ isOpen, onClose, searchResults, keyword, setKeyword }}>
      {children}
    </SearchModalContext.Provider>
  );
};

SearchModal.Input = SearchModalInput;
SearchModal.Results = SearchModalResults;
SearchModal.Bottom = SearchModalBottomSeciton;

SearchModal은 다시 모달에서만 사용되는 컴포넌트와 상태로 묶입니다. 검색어(keyword)나 검색 결과(searchResults) 등의 상태는 SearchModal 내부에서만 사용되도록 다시 ContextAPI로 공유합니다. 외부에서 버튼을 통해 변경되는 isOpen과 모달 닫기에 대한 함수 onClose는 불가피하게 외부에서 props를 통해 전달받습니다.

  • 이 부분에서 prop 대신 zotai나 recoil 등의 마이크로 상태 관리 라이브러리 등을 사용할 수는 있지만 주의해야 할 점은 여전히 상위에서 하위 레벨로만 import 하는 룰을 지켜야 한다는 점입니다. 이 경우 atom 등의 상태는 되도록 하위 레벨에 두는 것이 이 룰을 지키기에 수월합니다.

더 깔끔하게 정리하고 싶다면 모달을 여는 버튼도 feature 모듈 내부로 포함시킬 수 있습니다. 이러한 방법을 이용하면 HeaderSearchBar에 노출되는 상태는 모두 SearchBar로 흡수됩니다.

// widgets/header/ui/HeaderSearchBar.tsx

import { SearchBar } from '@/features/search-bar';

export const HeaderSearchBar = (): JSX.Element => {
  return (
    <SearchBar>
      <SearchBar.Button />
      <SearchBar.Modal>
        <SearchBar.ModalInput />
        <SearchBar.ModalResults />
        <SearchBar.ModalBottom />
      </SearchBar.Modal>
    </SearchBar>
  );
};
// features/search-bar/ui/SearchBar.tsx

interface SearchBarContextValue {
  isOpen: boolean;
  onClose: () => void;
  searchResults: SearchResult[];
  setKeyword: (keyword: string) => void;
}

export const SearchBarContext = createContext<SearchBarContextValue>(
  {} as SearchBarContextValue,
);

export const SearchBar = ({
  children,
}: {
  children: ReactNode;
}): JSX.Element => {
  const { isOpen, onClose } = useModal();
  const { keyword, setKeyword } = useInput();
  const { searchResults } = useSearchResults(keyword);

  return (
    <SearchBarContext.Provider
      value={{ isOpen, onClose, searchResults, keyword, setKeyword }}>
      {children}
    </SearchBarContext.Provider>
  );
};

SearchBar.Button = SearchBarButton;
SearchBar.Modal = SearchBarModal;
SearchBar.Input = SearchBarModalInput;
SearchBar.Results = SearchBarModalResults;
SearchBar.ModalBottom = SearchBarModalResults;

SearchModal feature의 model에선 keyword를 기반으로 실제 API 요청을 진행하는 커스텀 훅을 구성합니다. 이러한 hook이 제공하는 데이터는 내부적으로 공유될 가능성이 높기에 Context가 정의된 곳에서 주로 사용됩니다.

// features/search-modal/model/useSearchModal.tsx

export const useSearchResults = (
  keyword: string,
): {
  searchResults: SearchResult[];
} => {
  const { data: searchResults } = useQuery(
    searchRepository.results({ keyword }),
  );

  return { searchResults };
};

TanstackQuery의 쿼리 설정은 entities에서 구성합니다.

// entities/search/api/searchRepository.ts

import { getSearchResults } from '../get-search-results';

export const searchRepository = {
  results: ({ keyword }: SearchQuery) =>
    queryOptions({
      queryKey: ['search', 'results', keyword],
      queryFn: () => getSearchResults({ headers }),
      staleTime: 0,
    }),
};

이제 SearchModal이라는 독립적인 모듈이 완성되었습니다. 이제 이 모듈은 index.ts에서 정의된 항목만 밖으로 열려있습니다. 따라서 잘못된 import로 인해 의도하지 않은 종속이 발생하지 않도록 방지합니다.

export { SearchModal } from './ui/SearchModal';

또한 다음과 같은 하나의 진입로를 통해서 ui와 hook 등의 기능 세트에 접근할 수 있습니다.

import { SearchModal } from '@/features/search-modal';

전체적인 구조를 다시 정리하면 다음과 같습니다.

react.dev
├── public
├── src
│   ├── app
│   ├── pages
│   │   └── home
│   ├── widgets
│   │   └── header
│   │       └── ui       ex) Header.tsx (헤더 컴포넌트)
│   ├── features
│   │   └── search-modal
│   │       ├── model    ex) useSearchModal.ts (검색 결과 API 요청 커스텀 훅)
│   │       └── ui       ex) SearchModal.tsx (헤더 검색 모달 컴포넌트)
│   ├── entities
│   │   └── search
│   │       ├── api      ex) searchRepository.ts (API 요청 코드)
│   │       └── model  	 ex) search-results.ts (응답 결과 스키마)
│   └── shared
│           └── ui       ex) Input.tsx (공통 컴포넌트)
├── package.json
└── README.md

지금까지 실제 예시를 통해 FSD에 컴파운드 패턴을 적용해 보았습니다.
마지막으로 FSD + 컴파운드 패턴의 장점과 단점에 대해 다시 정리하면서 마무리 하겠습니다.

FSD + 컴파운드 패턴의 장점

  • 기존 FSD의 계층별 분리를 더욱 강화합니다.
  • 하위 레벨에서 import 되는 컴포넌트의 수를 줄이고 진입점을 통제합니다.
  • 불필요하게 넓은 범위에 걸쳐있는 상태를 없애고 필요한 상태만 모듈 단위로 가집니다.
  • 컴포넌트의 재조립을 용이하게 하여 변경과 재사용에 더욱 유연합니다.

FSD + 컴파운드 패턴의 단점

  • 지나치게 통제된 방식으로 컴포넌트를 만들면 사용이 어렵고 유연함이 떨어집니다.
  • props를 명시적으로 전달하는 것이 더 적합한 경우도 있습니다. (ex 외부에서 받는 정보 등)
  • 잘 정리되지 않으면 오히려 컴포넌트 내부의 복잡함을 더욱 가중시킬 수 있습니다.

참고

테오의 프론트엔드 - 프론트엔드 개발자 관점으로 바라보는 관심사의 분리와 좋은 폴더 구조 (feat. FSD)
Feature-Sliced Design
patterns-dev - Compound 패턴
Kent C. Dodds - React Hooks: Compound Components

0개의 댓글