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 하도록 제한하는 특징을 가집니다.
그렇다면 기능을 중심으로 구분하는 FSD는 어떤 이점이 있을까요?
FSD는 프로젝트의 규모가 커지더라도 관심사에 따른 분류를 유지하도록 도와줍니다. 이를 통해 결과적으로 하나의 기능을 담당하는 코드가 여러 폴더로 파편화되지 않도록 막아줍니다. 또한 명확하게 정의된 규칙에 따른 분류는 코드의 일관성을 높이고, 유지보수 및 확장성에도 기여합니다.
FSD는 다음과 같이 public API를 이용해 특정 슬라이스에서 내보낼 부분을 명확히 정의합니다.
// widgets/main/header/index.ts
export { HeaderMain } from './ui/HeaderMain';
이러한 방식을 통해 무분별한 import를 방지하고 각 모듈 단위로 독립적으로 교체가 가능하게 합니다.
그러나 이러한 FSD도 단점은 있었습니다.
FSD를 처음 문서로 접했을 때는 장점들이 너무나 명확해서 적용하지 않을 이유가 없어 보였지만 실제 프로젝트에 적용해 보니 다음과 같은 문제에 마주하게 되었습니다.
특히 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 컴포넌트를 구성한 경우입니다.
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>
)
}
요구사항은 만족하였지만, 다음과 같은 단점들이 보입니다.
그렇다면 각각의 컴포넌트를 기능에 따라 분할하기만 하면 이러한 문제가 개선될까요?
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>
);
}
이를 개선하기 위해 같은 컴포넌트를 컴파운드 패턴을 적용하여 바꿔보겠습니다.
// 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의 계층에 컴파운드 패턴을 실제로 적용해 보겠습니다.
react.dev 사이트를 예시로 구성해 보겠습니다.
먼저 App을 제외한 가장 상위 레이어인 Pages를 식별합니다.
가장 메인 화면인 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을 통해 구체적인 컴파운드 패턴을 구현합니다.
// 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는 다음과 같이 클릭 시 실제 입력 가능한 검색 모달을 열어야 합니다.
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로 분리해 가는 것을 추천합니다.
// 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를 통해 전달받습니다.
더 깔끔하게 정리하고 싶다면 모달을 여는 버튼도 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 + 컴파운드 패턴의 장점과 단점에 대해 다시 정리하면서 마무리 하겠습니다.
테오의 프론트엔드 - 프론트엔드 개발자 관점으로 바라보는 관심사의 분리와 좋은 폴더 구조 (feat. FSD)
Feature-Sliced Design
patterns-dev - Compound 패턴
Kent C. Dodds - React Hooks: Compound Components