FSD 살펴보기

기능 분할 설계를 실제 프로젝트에 적용한다면 어떻게 활용하는 것이 좋을까요? 레이어 간의 애매한 구분을 명확하게 해 줄 기준은 어떻게 세울 수 있을까요? 이 글에서는 FSD를 실제 프로젝트에서 활용하기 위한 방법을 고민해 봅니다.

2024-07-11에 씀

웹 기술이 발전함에 따라, 웹 서비스가 제공하는 기능도 다양하고 복잡해졌습니다. 이에 따라 웹 프론트엔드 프로젝트의 복잡도도 높아지고 있습니다.

사용자에게 더 좋은 서비스를 제공하기 위해 서비스 요구사항은 계속 추가되거나 변경됩니다. 이러한 요구사항을 잘 반영하기 위해서는 유연하고 안정적인 코드를 작성하는 것이 중요합니다. 그러나 서비스가 성장하면서 프로젝트가 커질수록 코드의 복잡도는 높아지게 됩니다. 코드의 복잡도가 높아지면 코드의 의존 관계를 파악하기 어려워지고, 따라서 아래와 같은 문제가 발생하기 시작합니다.

이런 문제가 발생하면, 기능의 추가나 변경에 소극적인 태도를 취하게 됩니다. 이를 해결하기 위해, 의존성을 효과적으로 분리하여 코드의 복잡도를 낮출 수 있는 방법이 필요합니다.

의존성에 대한 문제는 왜 발생할까요? 아래 폴더 구조를 살펴보겠습니다.

위 구조에서는 코드를 분류하는 기준을 코드의 ‘종류’ 혹은 ‘역할’로 두었습니다. 이러한 구조에서 발생하는 가장 큰 문제점은 코드의 응집도가 떨어진다는 것입니다. 소규모 프로젝트라면 당장은 괜찮겠지만, 프로젝트가 성장할수록 문제는 커집니다.

물론 좋은 IDE를 사용하면 굳이 폴더 구조를 살피지 않아도 쉽게 파일을 탐색할 수 있지만, 프로젝트를 처음 접하는 사람에게는 어디서부터 프로젝트를 탐색해야 할지가 여전히 어렵게 느껴집니다.

기능 분할 설계 (Feature-Sliced Design, FSD)

기능 분할 설계(이하 FSD)는 코드를 정리하기 위한 규칙이자 컨벤션입니다. 공식 문서에서는 이해하기 쉽고, 자주 변경되는 비즈니스 요구사항에 대응하기 쉬운 프로젝트를 만들기 위한 방법론이라고 설명하고 있습니다.

공식 문서에서 설명하는 FSD의 장점은 아래와 같습니다.

구성

FSD 아키텍처의 구조는 아래와 같이 구성됩니다.

먼저, 프로젝트를 최대 7개의 레이어로 나눕니다. 각 레이어 안에는 슬라이스라는 모듈이 포함되어 있습니다. 슬라이스는 다시 세그먼트라는 단위로 구성되어 있습니다.

주목할 부분은, 각 레이어에 속하는 모듈을 나누는 기준이 비즈니스 도메인이라는 점입니다. user, post, comment 등 비즈니스 도메인으로 먼저 슬라이스를 구분한 다음, 각 도메인과 관련된 코드를 각 슬라이스에 포함시킵니다. 그리고 슬라이스 안에서 코드의 종류에 따라 세그먼트가 분리됩니다. 이렇게 함으로써 동일한 도메인에 해당되는 코드를 한 곳에 모아 코드의 응집도를 높일 수 있습니다.

FSD에는 의존성 참조와 관련된 두 가지 규칙이 있습니다.

이러한 규칙을 통해 모듈 간의 결합도를 낮추고, 의존성을 효과적으로 관리할 수 있습니다. 만약 features 레이어에 속한 코드를 수정했다면, entities나 shared 레이어의 코드에는 사이드 이펙트가 발생하지 않을 것이라고 예상할 수 있습니다.

Segments

레이어를 살펴보기 전에, 세그먼트의 구조를 먼저 살펴보겠습니다.

Layers

레이어 설명에 들어가기 전에…

개인적으로는 공식 문서 상의 FSD의 레이어 기준이 명확히 딱 떨어지지는 않는다고 느꼈습니다. 그래서 공식 문서에서 설명하는 각 레이어의 역할에 더해서 제가 FSD 기반의 프로젝트를 진행한다면 어떤 기준으로 레이어를 나눌 수 있을지에 대해 얘기해 보려고 합니다.

아래와 같은 블로그 메인 페이지를 만드는 상황을 가정해 보겠습니다.

포스팅 리스트에는 아래와 같은 요구사항이 있습니다.

FSD의 7가지 레이어 중, processes를 제외한 6가지 레이어를 하위 레이어부터 하나씩 살펴보겠습니다.

(참고: processes는 deprecated 되었으므로 제외합니다)

Shared

모든 레이어에서 공통으로 사용하기 위한 코드를 모아 두기 위한 레이어입니다. 도메인이나 비즈니스 로직과 관련 없는 코드가 이 레이어에 포함될 수 있습니다.

Entities

서비스를 구성하는 가장 작은 도메인 단위들이 슬라이스로 존재하는 레이어입니다. 각 슬라이스는 도메인 엔티티 모델과 관련된 코드를 가지고 있으며, 주된 관심사는 이 엔티티를 표현하는 것입니다.

주의할 점은, 이 레이어는 엔티티의 값을 변경하는 등의 액션에는 관심이 없습니다. 이러한 액션은 후술할 레이어인 Features 레이어에서 관리합니다.

entities/post

블로그 포스팅에 대한 엔티티를 entities/post 슬라이스로 정의해 보겠습니다.

먼저, 포스팅에 대한 타입을 아래와 같이 model 세그먼트에 추가했습니다.

1// entities/post/model/PostSummary.ts
2export type PostSummary = {
3 id: string;
4 title: string;
5 description: string;
6 updatedAt: Date;
7}

그리고, 이 포스팅 모델을 표현하기 위한 Post 컴포넌트를 ui 세그먼트에 추가했습니다. 스타일링을 위한 CSS 파일도 같은 경로에 추가합니다.

1// entities/post/ui/PostCard.tsx
2import './PostCard.module.css';
3
4type PostCardProps = {
5 post: PostSummary;
6};
7
8export const PostCard = ({ post }: PostCardProps) => (
9 <article>
10 <h2>{post.title}</h2>
11 <p>{post.description}</p>
12 <time dateTime={post.updatedAt.toDateString()}>수정일 {post.updatedAt.toLocaleDateString()}</time>
13 </article>
14);

스토리북에서 위의 UI 컴포넌트를 렌더링해 보면, 아래와 같이 렌더링됩니다.

 

✋ 잠깐! 나중에 포스트 리스트 페이지에서 아래와 같은 요구사항을 추가할 수 있어야 합니다.

‘삭제하기’와 ‘북마크 등록’은 액션입니다. 엔티티는 엔티티 모델을 표현하는 것에만 관심이 있지, 이러한 액션에는 관심이 없기 때문에 액션을 구현할 책임이 없습니다.

엔티티가 직접 기능을 구현하는 대신에, 사용하는 쪽에서 원하는 UI를 주입할 수 있도록 슬롯을 만들어 줄 수 있습니다.

1// entities/post/ui/PostCard.tsx
2type PostCardProps = {
3 post: PostSummary;
4 bottomSlot?: ReactNode;
5 actionSlot?: ReactNode;
6};
7
8export const PostCard = ({ post, bottomSlot, actionSlot }: PostCardProps) => (
9 <article>
10 <h2>{post.title}</h2>
11 <p>{post.description}</p>
12 <time dateTime={post.updatedAt.toDateString()}>수정일 {post.updatedAt.toLocaleDateString()}</time>
13 {bottomSlot}
14 {actionSlot && <div className={'action-slot-container'}>{actionSlot}</div>}
15 </article>
16);

슬롯에 값을 설정한 스토리를 추가해 주었습니다.

1// PostCard.stories.ts
2export const PostCardWithAction: Story = {
3 args: { post: mockPost, actionSlot: '북마크', bottomSlot: '삭제하기' },
4};

이 스토리는 아래와 같이 보입니다.

PostSummary 타입과 PostCard 컴포넌트는 다른 모듈에서도 참조해서 사용할 수 있어야 합니다. 따라서 entities/post/index.ts 파일을 만들고, Public API를 정의해 주었습니다.

1// entities/post/index.ts
2export { type PostSummary } from './model/PostSummary';
3export { PostCard } from './ui/PostCard';

User

나중에 사용자의 로그인 여부와 관리자 여부에 따라 다른 UI를 보여주기 위해서, 사용자의 로그인 데이터를 관리해야 합니다. 사용자 로그인 데이터의 경우 여러 모듈에서 사용될 수 있으니 전역 스토어로 관리하는 것이 적합합니다. 이러한 전역 스토어는 entities/user 슬라이스 안에 정의합니다.

1// entities/user/store/store.ts
2import { create } from 'zustand';
3import { User } from 'entities/user/model/user';
4
5type UserState = {
6 user: User | null;
7};
8
9export const useUserStore = create<UserState>(() => ({
10 user: null,
11}));

마찬가지로 useUserStore 를 내보내는 Public API를 entities/user/index.ts 파일에 정의합니다.

Features

feature 레이어는 ‘비즈니스 로직’을 가지고 있는 레이어입니다. 여기서 말하는 비즈니스 로직이란 엔티티의 값을 변경하거나, 추가하거나, 삭제하는 등 값의 변경이 발생하는 로직을 다루는 모듈이 속하게 되는 레이어입니다.

예를 들어, 포스트를 북마크에 추가하기, 포스트를 삭제하기 등이 features 레이어에 속할 수 있는 비즈니스 로직이 됩니다.

포스트를 북마크에 추가하는 기능을 features 레이어에 추가해 보겠습니다.

먼저, 북마크 추가와 삭제 API를 호출할 함수를 추가했습니다. 실제 서버는 없기 때문에, 일단 로그만 출력하게 해뒀습니다. 나중에 서버가 구현되면 함수 구현을 변경하면 됩니다.

1// features/addToBookmark/api/addToBookmark.tsx
2export const addToBookmark = (postId: string): Promise<void> => {
3 return new Promise((resolve) => {
4 console.log(`${postId} 포스트가 북마크 추가됨`);
5 resolve();
6 });
7};
8
9// features/addToBookmark/api/removeFromBookmark.tsx
10export const removeFromBookmark = (postId: string): Promise<void> => {
11 return new Promise((resolve) => {
12 console.log(`${postId} 포스트가 북마크 삭제됨`);
13 resolve();
14 });
15};

포스트가 북마크에 추가되어 있는지 여부에 따라 북마크에 추가할지, 삭제할지 결정하는 헬퍼 함수도 추가했습니다.

1// features/addToBookmark/lib/toggleBookmark.ts
2export const toggleBookmark = async (postId: string, isInBookmark: boolean) => {
3 await (isInBookmark ? removeFromBookmark : addToBookmark)(postId);
4};

북마크를 토글할 수 있는 아이콘 버튼 컴포넌트도 추가했습니다.

1// features/addToBookmark/ui/AddToBookmarkButton.tsx
2export const AddToBookmarkButton = ({ id, isInBookmark: initialBookmarkState }: AddToBookmarkButtonProps) => {
3 const [isInBookmark, setIsInBookmark] = useState(initialBookmarkState);
4
5 const handleClick = () => {
6 toggleBookmark(id, isInBookmark);
7 setIsInBookmark((prev) => !prev);
8 };
9
10 return (
11 <button onClick={handleClick}>
12 <img src={isInBookmark ? '/filled-bookmark.svg' : '/outlined-bookmark.svg'} />
13 </button>
14 );
15};

사용하는 쪽에서는 AddToBookmarkButton 만 가져다 사용하면 됩니다. 슬라이스 밖에서는 이 버튼이 내부적으로 무슨 일을 하고 있는지는 알 필요가 없습니다.

Widgets

widgets 레이어는 features와 entities를 조합해 하나의 UI 섹션을 만드는 역할을 합니다. 이 레이어에서 entities에서 열어준 슬롯에 features에서 내보낸 비즈니스 로직을 주입해주게 됩니다.

widgets 레이어부터는 UI가 대부분이기 때문에, 세그먼트 경계가 필요하지 않은 경우가 많습니다. 그래서 따로 ui 세그먼트를 만들지 않고, 바로 postList 폴더 하위에 컴포넌트를 생성했습니다.

1// widgets/postList/PostList.tsx
2import { PostCard, PostSummary } from 'entities/post';
3import styles from './PostList.module.css';
4
5type PostListProps = {
6 posts: PostSummary[];
7};
8
9export const PostList = ({ posts }: PostListProps) => {
10 return (
11 <div className={styles.container}>
12 {posts.map((post) => (
13 <PostCard post={post} />
14 ))}
15 </div>
16 );
17};

PostList 컴포넌트는 현재 아래와 같이 보입니다.

아까 추가된 요구사항을 기억하시나요?

로그인 정보에 따라 PostCard의 슬롯을 채워주기 위해, 아까 만든 entities/user 의 스토어를 참조해서 로그인 정보를 가져왔습니다.

1// widgets/postList/PostList.tsx
2import { AddToBookmarkButton } from 'features/addToBookmark';
3import { PostCard, PostSummary } from 'entities/post';
4import { useUserStore } from 'entities/user';
5import styles from './PostList.module.css';
6
7type PostListProps = {
8 posts: PostSummary[];
9};
10
11export const PostList = ({ posts }: PostListProps) => {
12 const isLoggedIn = useUserStore((state) => state.user !== null);
13 const isAdmin = useUserStore((state) => Boolean(state.user?.isAdmin));
14
15 return (
16 <div className={styles.container}>
17 {posts.map((post) => (
18 <PostCard
19 post={post}
20 actionSlot={isLoggedIn && <AddToBookmarkButton id={post.id} isInBookmark={false} />}
21 bottomSlot={isAdmin && <button>삭제</button>}
22 />
23 ))}
24 </div>
25 );
26};

그러면 유저의 로그인 상태와 관리자 여부에 따라 아래와 같이 보이게 됩니다.

비로그인 유저

로그인 일반 유저

로그인 관리자 유저

Pages

pages 레이어는 웹의 한 페이지를 온전히 구성하는 역할을 합니다. pages가 해야 하는 일은 아래와 같습니다.

외부에서 가져오는 데이터는 API 서버일 수도 있고, URL 파라미터나 localStorage와 같은 브라우저 저장소일 수도 있습니다.

pages 레이어가 비즈니스 로직에 대해서 너무 많이 알지 않는 것이 좋습니다. 따라서 entities나 features에 대한 참조는 최소화하는 것이 좋습니다.

1// pages/main/MainPage.tsx
2import { useEffect, useState } from 'react';
3import { Header } from 'widgets/header';
4import { PostList } from 'widgets/postList';
5import { getPosts, PostSummary } from 'entities/post';
6import './MainPage.module.css';
7
8export const MainPage = () => {
9 const [postList, setPostList] = useState<PostSummary[]>([]);
10
11 useEffect(() => {
12 getPosts().then((result) => setPostList(result));
13 }, []);
14
15 return (
16 <>
17 <Header />
18 <section>
19 <h1>포스팅 리스트</h1>
20 <PostList posts={postList} />
21 </section>
22 </>
23 );
24};

아래와 같이 렌더링됩니다.

App

App 레이어는 애플리케이션 전체에 적용되어야 하는 설정을 적용하고, 라우팅을 담당합니다.

지금은 라우팅만 적용해 주겠습니다.

1// app/router.tsx
2import { createBrowserRouter } from 'react-router-dom';
3import { MainPage } from 'pages/main';
4
5export const router = createBrowserRouter([
6 {
7 path: '/',
8 element: <MainPage />,
9 },
10]);
11
12// app/main.tsx
13import React from 'react';
14import ReactDOM from 'react-dom/client';
15import { RouterProvider } from 'react-router-dom';
16import { router } from 'app/router.tsx';
17
18ReactDOM.createRoot(document.getElementById('root')!).render(
19 <React.StrictMode>
20 <RouterProvider router={router} />
21 </React.StrictMode>,
22);

개발 서버를 켠 다음 root에 접근하면, MainPage가 표시됩니다.

정리

정리하자면 각 레이어의 역할은 아래와 같습니다.

프로필 사진

조예진

이전 포스트
Testing Library - act는 언제 써야 할까?
다음 포스트
비슷한 동작을 하는 Jotai Atom을 관리하는 방법