TL;DR
- 진심 1000% 담아서 써보는 글
- 그 어떤 과제보다 세상 고민 많이했다. 과제 제출 전까지도 고민하며 작업했다.
- 처음, 날 것의 소스코드를 보자마자 코치님의 순수 악의가 느껴졌다.
- 한 파일에 정갈?하게있는 코드는 나의 멘탈을 흔들기에 충분헀다.
- GET /posts 방식이 단순 목록 조회가 아닌 경우에 따라 세 가지로 나뉘는 코드를 보고 멘탈 살짝 금가기 시작했다.
- 근데 이 경우별 조회 케이스 이외에 또 쿼리스트링에 따라 필터링 해줘야하는 항목들도 존재...
- 남발되어 있는 useEffect와 searchParams의 효과가 굉장했다.
- 일단 각 레이어와 슬라이스 세그먼트 각 파트에 대한 이해가 필요했고 기본적은 구조 학습에 대한 러닝커브가 매우 높았다
- 글을 읽다 보면 어느 순간 투정이 되어버리는 것을 느낄 수 있다.

큰 효과!
FSD 2.0 적용하기
- 지난 2주 동안 함수형 프로그래밍의 개념을 학습하고 실제 적용을 해보며 액션과 계산, 데이터에 대해 이해를 어느정 도 할 수 있었다.
- 이어 이러한 개념들을 적용하기에 적합한 FSD를 학습해 보게 되었다.
- 그럼 바로 시작하겠다.
들어가기 전에...
- FSD 패턴은 아직까지 정형화된 패턴은 아니며 꾸준히 디벨롭되고 있다.
- FSD의 기본 골조를 따라가되 자기만의 기준을 가지고 규칙을 정립하는 것이 좋다.
- 기존 프로젝트에 적용을 하고 싶다면 한번에 갈아엎기 보다는 점진적으로 마이그레이션하는 것이 좋다.
- 지금부터 보게될 내용들도 처음부터 FSD 패턴을 적용하는 것이아닌 기존의 하나의 파일에 몰려있던 코드들을 쪼개보며 적용하는 것을 알 수 있다.
그래서 FSD? 뭔데 그게?
- FSD는 feature-sliced-design 의 약자로, 직역하면 역할군을 나눈 디자인 패턴을 의미한다.
- 이는 과연 무슨 의미일까?
- 과거 (그리 멀지 않지만) 디자인 패턴이라 불릴만한 것들이 몇 가지가 있었다. 아토믹 패턴이라던가 Container and presentation 패턴이라던가
- 개인적으로 위에서 언급한 패턴들을 깊게 사용해 본 것은 아니지만 점점 커져가는 서비스에 적용하기에는 한계점이 느껴졌다.
- 가장 큰 한계는 관리의 측면이었다.
- 서비스가 커지고 다루는 도메인(데이터)가 커질 때 마다 관리해야할 포인트가 하나 이상 늘어났다.
- UI 재사용성을 기준으로 관심사를 분리하다보니 도메인에 대한 고려가 부족했고 이를 나누는 기준도 모호했다고 생각한다.
- 그렇게 시간이 흘러보면 하나의 수정이 필요할 때마다 하나의 도메인임에도 불구하고 흩어져있는 코드들을 따라 타고타고 들어가야 했다.
- 물론 이는 분명 개인의 숙련도도 영향이 있다.
- 허나 FSD는 이러한 고민과 문제에 대한 해답을 어느정도 만들었다.
- 아래의 사진과 같이 각 역할에 따라 레이어를 나누고 해당 레이어에서 도메인(슬라이스)별로 나뉘게 된다. 이후 각 도메인에 맞는 여러 세부 폴더를 가진다.

FSD 구조
- 이는 확실히 DX적으로 유리한 패턴이다.
- 각 레이어에 명시된 역할에 따라 예측가능한 설계가 1차적으로 가능하게 되고 하위 도메인(슬라이스)들은 해당 역할에 맞는 관심사를 가진다.
- FSD는 아키텍쳐 설게에 대한 가이드와 규칙을 제공한다. 앞서 언급했듯이 FSD는 지금도 발전 중이며 강제성을 띄는 부분은 없다.
- 그나마 가장 큰 강제성은 하위 레이어는 상위 레이어를 참조할 수 없다는 것이다. 같은 레이어 끼리의 참조도 안된다.
- 개인적으로 같은 레이어 끼리의 참조 불가능 이라는 규칙을 지키는 것이 가장 어려웠다.
- 자세한 내용은 FSD overview 에서 확인할 수 있다.
- 필자는 과제에 대한 발제를 듣고 팀원들과 얘기를 나누고, 실제 FSD를 적용하면서 나름 자신만의 구조를 만들 수 있었다. 간략하게 나의 기준 은 아래와 같다.
- 레이어
- app과 shared는 slice(도메인)을 가지지 않는다.
- app
- 최상위 레이어에서 전 레이어에서 공통적으로 사용하기 위해 감싸는 것들
- e.g.) Router, Provider, Global style
- pages
- 실제 router에 따라 나뉘는 페이지단 컴포넌트
- e.g.) login-page. my-page, order-page
- widgets
- pages를 구성하는 하나의 완전한 기능을 하는 컴포넌트들
- 최대한 도메인에 관여한 비즈니스 로직을 제외
- e.g.) 상품 필터링,
- features
- 비즈니스 로직을 다루는 실제 구현 컴포넌트
- entities
- 도메인에 대한 엔티티를 가장 근접한 단계에서 다루는 레이어
- e.g.) user 엔티티, product 엔티티
- shared
- 어느 레이어에서든 접근 가능한 엔티티들을 다루지 않는 레이어
- e.g.) ui kit, toggle과 같은 공통적인 유틸 또는 hook
- 슬라이스
- 이는 각 비즈니스 도메인별로 나뉘어져 있으며 자유롭게 만들 수 있다.
- 동일한 레이어의 다른 슬라이스를 참조할 수 없다. (높은 응집도, 낮은 결합도)
- 이 부분에 있어서 필자는 완벽하게 지키지 못한 것 같다.
- 이러한 부분을 집중적으로 연습한다면 나만의 규칙을 정할 수 있을 것 같다.
- 세그먼트
- app이나 shared에서는 슬라이스를 다루지 않기 떄문에 자유로운 세그먼트 구성이 가능하다.
- 구분이 필요할 때는 케밥 케이스를 통해 파일 이름으로 구분
- e.g.) 00-types.ts, product-config.ts
- ui
- UI와 관련된 모든 것: UI 컴포넌트, 날짜 포맷터, 스타일 등.
- api
- api 함수, tanstack-query와 같은 서버 사이드 상태 관리 라이브러리를 쓴다면 여기에
- model
- 스키마, 스토어 타입
- lib
- 슬라이스에 필요한 함수들
- config
- 상수, 설정 파일
- 레이어
- 이어서는 새롭게 시작하는 프로젝트가 아닌 기존의 서비스를 단계별로 리팩토링하는 내용을 다뤄보겠다.
basic 단계
- 과제를 구현하면서 일단은 가장 나누기 쉽다고 판단되는 shared 레이어에 한 파일에 몰려있던 ui kit을 분리하는 작업을 시작했다 (이때까지는 나름 순조로웠을 지도?...)
- 그리고 app으로 분리할 수 있는 부분을 파악하고 나누었다.
entities
- entities에 post / comment / tag / user 엔티티에 대한 슬라이스를 만들고 일단은 api와 model로 주로 활용했고 이후 ui에는 어떤게 들어가는가? 를 고민 하게됐다.
- 아래는 entities의 post를 예로 들어 작성한 코드이다.
// @entities/post/api/post-api.ts
import { NewPost, Post, PostsResponse } from "@/entities/post/model/types";
import { PostSearchParamsKey } from "@/features/post/model/types";
import { createRequestOptions, fetchApi } from "@/shared/lib/api";
import { POST_API_PATHS } from "../config/post-api-paths";
//query
const getPosts = async (
queries: PostSearchParamsKey
): Promise<PostsResponse> => {
const { limit, skip, sortBy, sortOrder } = queries;
const postsResponse = await fetchApi<PostsResponse>(POST_API_PATHS.base, {
searchParams: { limit, skip, sortBy: sortBy, order: sortOrder },
});
return postsResponse;
};
// ....... 중략
// ======================================================
//mutate
const addPost = async (newPost: NewPost) => {
const response = await fetchApi<Post>(
POST_API_PATHS.add,
createRequestOptions("POST", newPost)
);
return response;
};
// .......중략
// ======================================================
// total
export const postApi = Object.freeze({
getPosts,
addPost,
updatePost,
deletePost,
searchPosts,
getPostsByTag,
});
- 코드에서도 확인할 수 있 듯 순수 post entity만을 다루는 api이다.
- 이렇게 분리를 해놓으면 역할이 명확하기도 하고 api가 필요한 feature에서 자유롭게 가져다가 커스텀하거나 그대로 가져다 쓸 수 있다
- feature도 아닌것이 entity에 대한 데이만 props로 받아서 비즈니스 로직없이 순수하게 View 용도로만 사용이 가능할까? 이게 widget이었군…
- Tags로 한번 만들어 보긴했으나 뭔지 모를 찝찝함이 남아 있다.
// entities/tag/ui/Tags.tsx
import { HTMLAttributes } from "react";
import { Tag } from "../model/types";
type TagsProps = {
tags: Tag[] | undefined;
component?: React.ElementType;
} & HTMLAttributes<keyof React.JSX.IntrinsicElements>;
const Tags = ({
tags,
component: Component = "button",
...props
}: TagsProps) => {
if (!tags) return <div>태그가 존재하지 않습니다.</div>;
return tags.map((tag) => (
<Component key={tag.url} value={tag.slug} {...props}>
{tag.slug}
</Component>
));
};
export default Tags;
widget
- widget도 일단은 page에 정의 되어있던 것에서 큰 덩어리를 먼저 나누었다.
- 나누고 리팩토링하는 과정은 생략했다.
- 의도는 이러이러 했고 결론적으로는 이런 기능을 해야 한다. 라고만 이해해도 충분하다 생각한다.
// @/widgets/post/ui/TablePosts.tsx
import { useQueryPosts } from "@/features/post/api/use-get-post";
import {
PostTableHeader,
PostTablePagination,
PostTableRow,
} from "@/features/post/ui/table";
import { useQueryParams } from "@/shared/model";
import { Loader, Table } from "@/shared/ui";
import { useMemo } from "react";
const TablePosts = () => {
const { queries } = useQueryParams();
const { data, isLoading, isError } = useQueryPosts(queries);
const hasPosts = useMemo(
() => data?.posts?.length && data.posts.length > 0,
[data?.posts]
);
const tableBody = hasPosts ? (
data?.posts.map((post) => <PostTableRow key={post.id} post={post} />)
) : (
<Table.Row>
<Table.Cell colSpan={5}>게시물이 없습니다.</Table.Cell>
</Table.Row>
);
if (isError) {
return <div>에러가 발생했습니다.</div>;
}
return (
<>
{isLoading ? (
<Loader />
) : (
<Table.Container>
<PostTableHeader />
<Table.Body>{tableBody}</Table.Body>
</Table.Container>
)}
<PostTablePagination total={data?.total || 0} />
</>
);
};
export default TablePosts;
- widget은 하나의 완전한 기능을 하는 컴포넌트로 원하는 pages에 import해서 그대로 사용하면 된다.
features
- 그리고 각 widget에서 필요한 feature를 생각하고 작업을 시작하면서 대혼란이 오기 시작함
- 슬라이스에 도메인과 기능 이 두 부분을 고려하다 보니 생각이 굉장히 많아졌다.
- e.g.) filter 기능을 하는 tag sort order
- 단순 ui뿐아니라 별로도 관리해야할 segement가 있을 것으로 판단했다.
- but 나머지 tag / sort / order는 어떻게 해야하나 고민을 하게 되었다.
- 1번 filter > ui segment에서 각 tag sort order
- 2번 filter-tag > ui , filter-sort > ui , filter-order > ui
- 그리고 comment와 post는 크게 생각해서 도매인 기준으로 slice를 나누고 ui 단에서 modal / table / …. 이렇게 나누게 되었다.
- 그렇게 되니 사이즈가 꽤나 커져서 {domain}-{feature} 의 형태로 다시 슬라이스를 나눠야하나 고민하게 되었다.
- 확실히 어느정도의 범위로 쪼개야하는지 감이 안잡히니 feature 레이어의 각 slice들이 ui를 서로 공유하게 됨, 여기서 관리 포인트가 하나 더 늘어났다고 생각했다.
- e.g.) filter 기능을 하는 tag sort order
- 이렇게 feature에 대한 작업을 하다보니 widget도 무언가 feature와 비슷한 형태가 되는거 같았다
- 그러나 하나의 widget은 완전한 기능을 해야하는 차이점이 있었고 이를 위해 여러 feature들이 모여 만들어진다.
- e.g.) filter는 특정 entity에 엮여있다기 보다는 하나의 필터링이라는 기능을 담당하기에 slice를 filter로 두고 작업을 했다.
// @features/filter/ui/FilterOrder.tsx
import { useQueryParams } from "@/shared/model";
import { Select } from "@/shared/ui/Select";
const FilterOrder = () => {
const {
handleUpdateQuery,
queries: { sortOrder },
} = useQueryParams();
return (
<Select.Container
value={sortOrder}
onValueChange={(value: string) => handleUpdateQuery("sortOrder", value)}
>
<Select.Trigger className="w-[180px]">
<Select.Value placeholder="정렬 순서" />
</Select.Trigger>
<Select.Content>
<Select.Item value="asc">오름차순</Select.Item>
<Select.Item value="desc">내림차순</Select.Item>
</Select.Content>
</Select.Container>
);
};
export default FilterOrder;
others... 기타 고민하는 과정들
- 이후 상태관리를 위해 처음에는 context api 로 진행했다.
- 각 entity에 대한 use{entity}를 만들고 context api에 주입해서 전역에서 쓸 수 있도록 작업을 진행했다
- 처음부터 상태관리 라이브러리를 쓰는게 낫지 않았을까 생각했다.
- 작성해야할 보일러 플레이트 코드가 많다고 생각했고 꽤나 번거롭다.
- 이후 zustand 도입을 진행했다.
- 각 entity에 해당하는 store를 만들어서 관리했다.
- useSearchParams도 만들어서 관리 중인데 단순 read를 위한 경우 바로 컴포넌트에서 넣고자했다.
- list의 item 역할을 하는 ui는 엔티티 데이터를 props로 받아서 view의 역할을 담담하게 했다.
- 모달에 대한 관리도 꽤나 힘들었다.
- 처음에는 useToggle의 형태로 만들어서 관리하다 보니 close를 props로 넘겨줘야하는 일이 생겨서 외부에 의존하는 형태가 별로라 생각했다
- 또한 중첩 이렇게 작업하게 되면 중첩 모달의 관리가 힘들었다
- 그래서 이후 Map을 state로 두는 global modal을 만들어 진행했다.
- 닫기가 필요한 컴포넌트에서 중첩 모달이 아닌경우 closeAll / 중첩 모달인 경우 close(${type값}) 형태로 관리를 했다.
import FormAddPost from "@/features/post/ui/forms/FormAddPost";
import { Button, Dialog } from "@/shared/ui";
import { Plus } from "lucide-react";
import { useModalAddPost } from "../../model/useModalAddPost";
const ModalAddPost = () => {
//========================================
//before
const { isOpen, toggle } = useToggle();
//after
const { isOpen, toggle } = useModalAddPost();
//========================================
return (
<Dialog.Container open={isOpen} onOpenChange={toggle}>
<Dialog.Trigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" />
게시물 추가
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>새 게시물 추가</Dialog.Title>
</Dialog.Header>
//======================================== // before
<FormAddPost close={close} />
// after
<FormAddPost />
//========================================
</Dialog.Content>
</Dialog.Container>
);
};
export default ModalAddPost;
// features/post/model/useModalAddPost.ts
import { getModalKey, useModalStore } from "@/shared/model/useModalStore";
import { useShallow } from "zustand/shallow";
export const useModalAddPost = () => {
const modalStore = useModalStore(
useShallow((state) => ({
activeModals: state.activeModals,
toggle: state.toggle,
close: state.close,
}))
);
return {
isOpen: modalStore.activeModals.has(getModalKey({ type: "addPost" })),
toggle: (isOpen: boolean) => modalStore.toggle({ type: "addPost" }, isOpen),
close: () => modalStore.close({ type: "addPost" }),
};
};
-
FSD에서도 점진적 적용에 대한 짤막한 가이드라인을 제공해서 한번 시도
점진적 도입
FSD로 마이그레이션하고자 하는 기존 코드베이스가 있다면, 다음과 같은 전략을 제안합니다. 이는 우리의 마이그레이션 경험에서 유용했던 방법입니다.
- App과 Shared 레이어를 모듈별로 천천히 구성하여 기반을 만드는 것으로 시작하세요.
- 기존의 모든 UI를 Widgets와 Pages에 큰 틀에서 분배하세요. FSD 규칙을 위반하는 의존성이 있더라도 괜찮습니다.
- 점진적으로 임포트 위반을 해결하고, Entities와 가능하다면 Features도 추출하기 시작하세요.
리팩토링 중이거나 프로젝트의 특정 부분만 리팩토링할 때는 새로운 대규모 엔티티를 추가하지 않는 것이 좋습니다.
advanced 단계
- 이때 부터 조금씩 지쳐갔다.. ㅋㅋ

지침..
- tanstack-query 도입 시작
- https://feature-sliced.design/kr/docs/guides/tech/with-react-query / tanstack-query에 대한 가이드도 있긴해서 참고했다
- query-key-factory 패턴으로 진행했다.
- 이는 쿼리키에 대한 관리를 쉽게 가능하다.
- 여기에서는 순수하게 queryOption을 통해 key를 관리 하지만 원래는 @lukemorales/query-key-factory 라이브러리를 써서 관리했다. 형태는 비슷하다.
- FSD 도입 이전에 실무에서 tanstack-query의 아키텍처는 아래와 같이 구성했다.

기존 코드
- FSD를 적용한 tanstack-query 아키텍쳐

FSD를 적용한 tanstack-query 아키텍쳐
리팩토링 이후 FSD 2.0 폴더 구조

FSD 2.0 폴더 구조
결론
- FSD 계속해서 언급했는 FSD는 강제성을 띈다기 보다는 가이드라고 나는 생각한다.
- 역시 중요한 것은 본인 만의 기준이며 이를 팀 또는 조직에서 구성원들과 최종적인 컨벤션을 마련하는 것이다.
- 개인적은 욕심은 FSD 3.0이라 스스로 칭할 수 있을만큼 가다듬어 가고자 한다.
PS. 포스트 순서가 뒤죽박죽일 수 있습니다.!!
이런 경험 나도 하고 싶다면?
- 실무를 뛰면서
- 도입하고 싶었지만 도입하지 못한,
- 경험하고 싶었지만 경험하지 못한,
- 이런 포인트들 다들 가지고 계실텐데요
- 저는 이런 경험들을 하고 싶었기에 이번 항해플러스에 참여하게 되었습니다.
- 현재 항해플러스 FE 4기도 모집 중이라 하니 참여하여서 원하는 부분들 채워가셨으면 좋겠습니다.
- 아래의 코드를 입력하면 20만원 할인!
- 추천 코드 : VX7gBb