이번 과제는 FSD(Feature-Sliced Design)
를 직접 적용해보는 것이 핵심이었다.
평소에 폴더 구조나 아키텍처 설계에서 자주 어려움을 겪었기 때문에, 이번 기회를 통해 구조화에 대한 감을 잡고 싶었다.
하지만 과제를 진행하면서 판단 기준이 명확하게 세워지지 않았고, 오히려 도메인 중심으로 응집도 높은 폴더 구조를 가져가는 게 더 낫지 않나 하는 의문이 계속 들었다.
이런 의문을 가지고 멘토인 테오에게 질문을 했고, 테오는 다음과 같은 답변을 해주었다.
“FSD는 멘탈모델이다. 과제는 절취선을 만드는 기준을 세우는 과정이다. 어디를 잘라야 하는지 보는 눈을 기르고, 필요할 때 자르자.”
이 답변을 들으면서 흐릿하던 FSD의 개념이 조금은 선명해졌다.
단순히 폴더 구조를 칼같이 나누는 것이 중요한 것이 아니라, 왜 그렇게 나누는지를 먼저 깨닫고, 그 과정에서 기준을 세워 더 편하게 사용하는 것이 진짜 FSD의 목적이라는 생각이 들었다.
이러한 깨달음을 바탕으로, 이번 글에서는 내가 FSD를 이해하고 적용한 방식, 구조를 나누며 겪은 고민과 선택의 기록을 정리해보려 한다.
FSD는 단순한 폴더 구조 규칙이 아니다.
공식 문서에서도 “Feature-Sliced Design is an architectural methodology for designing frontends, focused on scalability and maintainability.”라고 말하듯, 프로젝트를 기능적으로 잘게 나누고, 비즈니스 변화에 유연하게 대응할 수 있도록 설계하는 전체적인 방법론에 가깝다.
FSD의 핵심은 크게 세 가지 축으로 정리된다.
레이어(Layer)
: 프로젝트를 수직적으로 나누는 기준. app
, processes
, pages
, widgets
, features
, entities
, shared
순으로 구조화되며, 상위 레이어는 하위 레이어만을 참조할 수 있다.
슬라이스(Slice)
: 각 도메인을 기준으로 나누는 수평적 분리 단위. 예를 들어 user
, post
, comment
같은 실제 비즈니스 단위로 나뉜다.
세그먼트(Segment)
: 슬라이스 내부에서 역할을 나누는 기준. model, ui, lib, api 등 기술적 관심사를 기준으로 디렉토리를 분리한다.
즉, 단순히 컴포넌트를 어디에 넣을지만 고민하는 것이 아니라, 비즈니스 기능 중심으로 구조화하고, 그 안에서 기술적인 관심사를 분리해나가는 것이 핵심이다.
이러한 접근은 규모가 커져도 구조의 일관성을 유지하는 데 큰 장점이 있다.
FSD에서 레이어는 각자의 명확한 책임을 가지며, 하위 레이어만을 참조할 수 있는 단방향 의존성이 원칙이다.
이 원칙은 프로젝트가 확장되어도 하위 기능이 상위 기능에 영향을 주지 않도록 만들며, 유지보수성을 확보하는 데 중요한 역할을 한다.
Layer | 설명 |
---|---|
app | 전역 설정 및 초기화 레이어. 라우팅, 글로벌 스타일, 상태 관리 등의 설정이 이곳에 위치한다. |
processes | 페이지 간 프로세스를 다룬다. 예: 로그인 → 메인 이동, 결제 플로우 등. |
pages | 라우팅에 연결된 페이지 단위 컴포넌트. URL과 1:1 대응된다. |
widgets | 여러 feature와 entity가 결합된 UI 블록. 예: 헤더, 댓글 블록, 검색 필터 등. |
features | 독립적인 비즈니스 기능 단위. 예: 정렬 기능, 댓글 작성 폼, 좋아요 버튼 등. |
entities | 핵심 비즈니스 도메인 로직. API, 타입, 상태, CRUD 등 entity 관련 내용을 포함한다. |
shared | 모든 곳에서 사용할 수 있는 공통 자원. 예: 버튼, 인풋, 유틸 함수, 타입 유틸리티 등. |
공식 문서를 그대로 따르는 것도 의미가 있지만, 과제를 진행하면서 더 중요하게 느껴졌던 건 "내가 납득할 수 있는 기준"을 만드는 것이었다. 구조를 억지로 끼워 맞추기보다, 역할에 맞게 자연스럽게 나누는 방향을 택했다.
이번 과제에서는 특히 Layer를 기준으로 구조를 설계했다. 다음은 실제로 내가 사용한 주요 디렉토리와 그 기준이다.
entities
: 타입 정의, API 요청, 단일 도메인의 상태 (예: post
, user
, comment
)features
: 여러 entity
가 조합된 비즈니스 로직, useQuery
, useMutation
을 포함한 비동기 로직, zustand
기반의 전역 상태 관리widgets
: 여러 컴포넌트를 묶은 UI 조각으로, 내부에서 feature
를 호출하거나 데이터를 표시하는 데 집중pages
: 라우팅 단위로, 상태 초기화 및 URL 쿼리 파라미터 처리와 같은 진입 시점의 로직을 담당모든 걸 정답처럼 나누진 않았지만, 최소한 각 레이어가 어떤 책임을 가져야 할지 스스로 납득할 수 있는 기준을 갖게 된 것이 가장 큰 수확이었다.
이번 과제는 타입 정의가 전혀 되어 있지 않은 상태에서 시작해야 했다.
처음에는 빠르게 작업하기 위해 any로 넘길까 고민도 했지만, 이후 로직 분리나 컴포넌트 재사용을 고려하면 결국 다시 돌아와야 할 작업이라는 걸 알고 있었다. 그래서 아예 초반에 타입을 먼저 정의하기로 결정했다.
이 과정에서 API 응답 구조를 기준으로 도메인을 나누고, post
, user
, comment
같은 엔티티 중심으로 타입을 설계해나갔다.
RESTful API
의 응답을 기준으로 하다 보니, 어떤 값이 필수인지, 어떤 구조가 중첩되어 있는지 자연스럽게 정리되었고, 나중에 로직을 분리할 때 기준이 되어주었다.
이후에 나오는 entities
디렉토리 구조도, 이 타입 정의를 기반으로 맞춰나갈 수 있었다.
타입 정의가 완료된 뒤에는, 실제 API 요청 로직을 분리하는 작업을 시작했다.
가장 먼저 고민한 건 'API 요청 함수는 어디에 위치시켜야 할까?'였다.
나는 다음 기준을 세우고, 이를 entities
레이어에 적용했다.
post
, user
, comment
등)에 속할 것이 기준에 따라 fetchPosts
, fetchUsers
, fetchComments
등 기본적인 요청 함수들을 각각 entities/post/api
, entities/user/api
, entities/comments/api
에 정리했다.
다만 이 과정에서도 애매한 경우가 있었다.
예를 들어 tag
는 post
와 강하게 연결된 값인데, fetchPostsByTag
는 결국 post
데이터를 다루는 API였다.
그래서 tag
를 별도의 엔티티로 분리하지 않고, 해당 요청 함수는 post
엔티티 내부에 포함시켰다.
또한 단일 API 호출이 아닌, 여러 요청을 조합해서 처리해야 하는 경우도 있었다.
대표적으로 게시글(post
)과 사용자(user
) 정보를 각각 받아와서 병합하는 로직이 있었는데,
이러한 도메인 간 조합 로직은 비즈니스 로직으로 판단하여 features/post
아래에 따로 분리해 관리했다.
즉, API 요청 함수는 최대한 단순하게 유지하고, 그 결과를 가공하거나 병합하는 역할은 feature 레이어에서 담당하도록 구조를 나눴다.
API 로직을 정리한 뒤에는 전역 상태 관리를 어디에 둘지에 대한 고민이 시작됐다.
searchQuery
, selectedTag
, pagination
, sortOrder
처럼 기능 단위에서 공유하는 상태들이 꽤 많았기 때문이다.
처음엔 store
파일을 entities
나 shared
에 둘까도 고민했지만,
해당 상태들은 특정 비즈니스 흐름에서만 사용되기 때문에 결국 features
내부에서 관리하는 것이 더 자연스럽다고 판단했다.
예를 들어, 게시글 리스트에 필요한 필터 조건은 features/post/model/usePostFilterStore.ts
에,
검색창 입력 상태는 features/post/model/useSearchStore.ts
에 각각 분리했다.
이 외에도 다이얼로그 열림 여부
처럼 UI에 가까운 전역 상태들도 존재했다.
이런 UI 상태도 마찬가지로, 단순히 "공통 상태니까 shared
에" 두는 방식보다는 관련 feature
내부에 함께 묶는 편이 응집도가 높다고 판단했다.
그래서 post
관련 다이얼로그 상태는 features/post/model/usePostDialogStore.ts
에서 관리하도록 했다.
zustand를 사용할 때 가장 주의했던 점은 단순히 "전역"이라는 이유로 shared에 몰아넣지 않는 것이었다.
그 상태가 실제로 어디에 쓰이는가, 어떤 도메인에 속해 있는가를 먼저 고민했고, 그 결과 상태도 결국 기능 단위로 응집시키는 방향으로 구조가 잡혔다.
초기에는 하나의 파일에 모든 로직과 UI를 통합해두고 있었지만, FSD의 구조적 기준에 맞춰 UI를 점진적으로 분리해나가기 시작했다.
먼저 각 기능을 기준으로 feature
단위로 나누고, 이후 재사용성이 있거나 역할이 명확한 UI 컴포넌트는 별도로 추출했다.
이 과정에서 자연스럽게 상태 전달 구조를 다시 설계해야 했다.
현재 전역 상태는 zustand를 통해 UI 관련 상태만 관리하고 있었고, API 응답 데이터는 따로 zustand로 보관하지 않았다.
그래서 테이블에서 렌더링할 데이터나 핸들러 같은 값은 컴포넌트 간에 props
로 직접 전달할 수밖에 없었다.
덕분에 데이터의 흐름은 명확했지만, 컴포넌트가 깊어질수록 넘겨야 할 값이 많아졌다.
특히 PostTable
→ PostTableContent
→ PostTableItem
처럼 2단계 이상 중첩된 구조에서는
단순히 보여주기 위한 데이터임에도 불구하고 상위 컴포넌트에서 계속 전달해줘야 하는 상황이 발생했고, 결국 props drilling
문제가 불거졌다.
UI를 분리하면서 가장 크게 느낀 건 단순히 컴포넌트를 나누는 것만으로는 충분하지 않다는 점이었다.
나눈 후 각 컴포넌트 간의 데이터 전달 구조까지 고려하지 않으면, 오히려 전체 구조가 더 복잡해질 수 있다는 걸 경험적으로 알게 됐다.
UI 분리 이후, 상태 전달 구조의 복잡함뿐만 아니라 데이터 페칭 및 캐싱 처리에서도 불편함을 느꼈다.
처음에는 fetch 함수로 API 요청을 직접 호출하고, 그 응답값을 useState
를 통해 수동으로 관리하고 있었다.
하지만 필터 변경, 검색, 태그 적용 등 다양한 상황에 따라 조건부로 데이터를 요청해야 하는 구조에서 상태 동기화가 점점 번거로워졌다.
그래서 React Query를 도입해 로직을 전면적으로 재정의하기로 했다.
React Query를 사용하면서 구조를 다음과 같이 나눴다:
fetch
): entities
디렉토리 내에 작성해, 도메인 단위의 요청만을 담당하도록 분리useQuery
/ useMutation
훅: features
내에 정의해, 조건에 따라 적절한 API 요청을 수행하고 로딩, 에러, 캐싱 등의 상태를 관리예를 들어 usePostsQuery
훅에서는 tag
나 searchQuery
가 있을 경우 조건에 따라 다른 API를 호출하고, 그 결과로 가져온 posts와 users 데이터를 조합하는 로직을 포함했다.
export const usePostsQuery = ({ limit = 10, skip = 0, tag, searchQuery }: UsePostsQueryProps) => {
return useQuery({
queryKey: ["posts", skip, limit, tag, searchQuery],
queryFn: async () => {
let postsData
if (searchQuery) {
postsData = await fetchPostsBySearch({ searchQuery })
} else if (tag && tag !== "all") {
postsData = await fetchPostsByTag({ tag })
} else {
postsData = await fetchPosts({ limit, skip })
}
const usersData = await fetchUsers()
return {
posts: combinePostsWithAuthors(postsData.posts, usersData.users),
total: postsData.total,
}
},
})
}
이 조합 로직은 API 요청이 아니라 프론트에서의 후처리이기 때문에 feature
단에 위치시켰고,
덕분에 쿼리 키만 바뀌면 자동으로 데이터를 다시 불러오고 캐싱까지 관리되는 구조를 만들 수 있었다.
이전까지는 요청 후 응답을 받아 수동으로 저장하고 처리하던 방식이었기 때문에, 페이지를 이동하거나 필터를 변경할 때마다 관련 데이터를 다시 동기화하는 코드가 반복적으로 필요했다.
하지만 React Query를 적용한 이후에는 쿼리 키만으로 이 흐름을 단순화할 수 있었고, 특히 searchQuery
와 selectedTag
의 조건 분기와 같은 복잡한 상황도 하나의 훅 내부에서 처리할 수 있어 유지보수가 훨씬 수월해졌다.
다음은 내 최종 폴더구조이다.
📦src ┣ 📂app ┃ ┗ 📂assets ┃ ┃ ┗ 📜react.svg ┣ 📂entities ┃ ┣ 📂comment ┃ ┃ ┣ 📂api ┃ ┃ ┃ ┣ 📜createComment.ts ┃ ┃ ┃ ┣ 📜deleteComment.ts ┃ ┃ ┃ ┣ 📜fetchCommentsByPostId.ts ┃ ┃ ┃ ┣ 📜index.ts ┃ ┃ ┃ ┣ 📜likeComment.ts ┃ ┃ ┃ ┗ 📜updateComment.ts ┃ ┃ ┗ 📂model ┃ ┃ ┃ ┣ 📜index.ts ┃ ┃ ┃ ┗ 📜types.ts ┃ ┣ 📂post ┃ ┃ ┣ 📂api ┃ ┃ ┃ ┣ 📜createPost.ts ┃ ┃ ┃ ┣ 📜deletePost.ts ┃ ┃ ┃ ┣ 📜fetchPosts.ts ┃ ┃ ┃ ┣ 📜fetchPostsBySearch.ts ┃ ┃ ┃ ┣ 📜fetchPostsByTag.ts ┃ ┃ ┃ ┣ 📜fetchTags.ts ┃ ┃ ┃ ┣ 📜index.ts ┃ ┃ ┃ ┗ 📜updatePost.ts ┃ ┃ ┣ 📂model ┃ ┃ ┃ ┣ 📜index.ts ┃ ┃ ┃ ┗ 📜types.ts ┃ ┃ ┗ 📂ui ┃ ┃ ┃ ┣ 📜LikeDislikeStats.tsx ┃ ┃ ┃ ┗ 📜index.ts ┃ ┗ 📂user ┃ ┃ ┣ 📂api ┃ ┃ ┃ ┣ 📜fetchUser.ts ┃ ┃ ┃ ┣ 📜fetchUsers.ts ┃ ┃ ┃ ┗ 📜index.ts ┃ ┃ ┣ 📂model ┃ ┃ ┃ ┣ 📜index.ts ┃ ┃ ┃ ┣ 📜types.ts ┃ ┃ ┃ ┗ 📜useUserModal.ts ┃ ┃ ┗ 📂ui ┃ ┃ ┃ ┣ 📜UserDetailDialog.tsx ┃ ┃ ┃ ┗ 📜index.ts ┣ 📂feature ┃ ┣ 📂comment ┃ ┃ ┣ 📂model ┃ ┃ ┃ ┣ 📜index.ts ┃ ┃ ┃ ┣ 📜useAddCommentMutation.ts ┃ ┃ ┃ ┣ 📜useCommentManagement.ts ┃ ┃ ┃ ┣ 📜useCommentStore.ts ┃ ┃ ┃ ┣ 📜useCommentsQuery.ts ┃ ┃ ┃ ┣ 📜useDeleteCommentMutation.ts ┃ ┃ ┃ ┣ 📜useLikeCommentMutation.ts ┃ ┃ ┃ ┗ 📜useUpdateCommentMutation.ts ┃ ┃ ┗ 📂ui ┃ ┃ ┃ ┣ 📜CommentAddDialog.tsx ┃ ┃ ┃ ┣ 📜CommentEditDialog.tsx ┃ ┃ ┃ ┣ 📜Comments.tsx ┃ ┃ ┃ ┗ 📜index.ts ┃ ┣ 📂filter ┃ ┃ ┗ 📂ui ┃ ┃ ┃ ┣ 📜FilterSearch.tsx ┃ ┃ ┃ ┣ 📜FilterSortBy.tsx ┃ ┃ ┃ ┣ 📜FilterSortOrder.tsx ┃ ┃ ┃ ┣ 📜FilterTag.tsx ┃ ┃ ┃ ┗ 📜index.ts ┃ ┣ 📂post ┃ ┃ ┣ 📂lib ┃ ┃ ┃ ┣ 📜combinePostsWithAuthors.ts ┃ ┃ ┃ ┗ 📜index.ts ┃ ┃ ┣ 📂model ┃ ┃ ┃ ┣ 📜index.ts ┃ ┃ ┃ ┣ 📜useAddPostMutation.ts ┃ ┃ ┃ ┣ 📜useDeletePostMutation.ts ┃ ┃ ┃ ┣ 📜useDialogStore.ts ┃ ┃ ┃ ┣ 📜useNewPostStore.ts ┃ ┃ ┃ ┣ 📜usePostDetail.ts ┃ ┃ ┃ ┣ 📜usePostFilterStore.ts ┃ ┃ ┃ ┣ 📜usePostQueryParams.ts ┃ ┃ ┃ ┣ 📜usePostsByTagQuery.ts ┃ ┃ ┃ ┣ 📜usePostsQuery.ts ┃ ┃ ┃ ┣ 📜useSearchStore.ts ┃ ┃ ┃ ┣ 📜useSelectedPostStore.ts ┃ ┃ ┃ ┗ 📜useUpdatePostMutation.ts ┃ ┃ ┗ 📂ui ┃ ┃ ┃ ┣ 📜Pagination.tsx ┃ ┃ ┃ ┣ 📜PostAddButton.tsx ┃ ┃ ┃ ┣ 📜PostAddDialog.tsx ┃ ┃ ┃ ┣ 📜PostDeleteButton.tsx ┃ ┃ ┃ ┣ 📜PostDetailButton.tsx ┃ ┃ ┃ ┣ 📜PostDetailDialog.tsx ┃ ┃ ┃ ┣ 📜PostEditButton.tsx ┃ ┃ ┃ ┣ 📜PostEditDialog.tsx ┃ ┃ ┃ ┣ 📜PostTable.tsx ┃ ┃ ┃ ┣ 📜PostTableContent.tsx ┃ ┃ ┃ ┣ 📜PostTableItem.tsx ┃ ┃ ┃ ┣ 📜PostTitleCell.tsx ┃ ┃ ┃ ┗ 📜index.ts ┃ ┣ 📂tag ┃ ┃ ┣ 📂model ┃ ┃ ┃ ┣ 📜index.ts ┃ ┃ ┃ ┗ 📜useTags.ts ┃ ┃ ┗ 📂ui ┃ ┃ ┃ ┣ 📜TagItem.tsx ┃ ┃ ┃ ┗ 📜index.ts ┃ ┗ 📂user ┃ ┃ ┗ 📂ui ┃ ┃ ┃ ┣ 📜UserCell.tsx ┃ ┃ ┃ ┗ 📜index.ts ┣ 📂pages ┃ ┗ 📜PostsManagerPage.tsx ┣ 📂shared ┃ ┣ 📂api ┃ ┃ ┣ 📜base.ts ┃ ┃ ┗ 📜index.ts ┃ ┗ 📂ui ┃ ┃ ┣ 📜Button.tsx ┃ ┃ ┣ 📜Card.tsx ┃ ┃ ┣ 📜Dialog.tsx ┃ ┃ ┣ 📜HighlightText.tsx ┃ ┃ ┣ 📜Input.tsx ┃ ┃ ┣ 📜Select.tsx ┃ ┃ ┣ 📜Table.tsx ┃ ┃ ┣ 📜Textarea.tsx ┃ ┃ ┗ 📜index.ts ┣ 📂widgets ┃ ┣ 📜Dialogs.tsx ┃ ┣ 📜Filter.tsx ┃ ┣ 📜Footer.tsx ┃ ┣ 📜Header.tsx ┃ ┣ 📜PostAdminBody.tsx ┃ ┣ 📜PostAdminHeader.tsx ┃ ┗ 📜index.ts ┣ 📜App.tsx ┣ 📜index.css ┣ 📜index.tsx ┣ 📜main.tsx ┗ 📜vite-env.d.ts
이번 과제를 통해 다시금 느낀 건, 구조를 잘게 나눈다고 해서 자동으로 코드가 명확해지진 않는다는 점이었다. 오히려 폴더를 나누는 순간부터 "이건 어디에 넣어야 할까?"라는 고민이 시작됐고, 때론 단순한 기능 하나에도 수차례 디렉토리를 옮기며 기준을 재검토해야 했다.
하지만 그 과정에서 알게 된 건, 중요한 것은 딱 떨어지는 정답이 아니라 한 번 기준을 정했다면 그 기준을 일관되게 지켜나가는 태도라는 사실이었다.
처음엔 무조건 entity
와 feature
, widget
을 명확히 구분해야 한다고 생각했지만, 막상 프로젝트를 진행하다 보면 그런 경계는 생각보다 흐릿하게 다가왔다. 그리고 실제로 코드를 짜는 입장에서 그 경계를 결정하는 건 단순한 문서의 규칙이 아니라 도메인, 관심사, 협업 흐름을 종합적으로 고려한 판단의 결과였다.
테오는 본인의 기술 블로그에서 이렇게 말한다.
FSD에서 가장 어렵다 여겨지지만, entity, features, widgets에 대한 경계를 나눠서 코드를 작성하려고 하는 것은 무지개에서 빨강과 노랑 사이의 주황의 경계를 정하는 것과 같다 여겨집니다. 이게 맞는가에 대해서도 애매하고 완벽한 구분도 잘 모르겠습니다. 하지만 중요한 건 경계를 선명하게 하는 것보다 하나의 경계를 정했다면, 그 기준을 일관되게 유지하는 게 중요합니다. 그래야 선명해지는 법이니까요.
이 말에 정말 공감이 간다. FSD를 배우며 얻은 가장 큰 수확은 ‘정답’이 아니라 ‘판단 기준’을 세우는 연습이었고, 그 기준을 얼마나 흔들림 없이 유지할 수 있느냐가 곧 설계의 힘이라는 걸 느낄 수 있었다.
BP를 받았습니다...!
중요한 것은 딱 떨어지는 정답이 아니라 한 번 기준을 정했다면 그 기준을 일관되게 지켜나가는 태도라는 사실이었다. 이 말에 매우 공감합니다 ㅎㅎ