항해플러스 프론트엔드 5기 후기(6주차) - 관심사 분리와 폴더구조(FSD)

유한별·2025년 5월 6일
7
post-thumbnail

이번 과제는 FSD(Feature-Sliced Design)를 직접 적용해보는 것이 핵심이었다.
평소에 폴더 구조나 아키텍처 설계에서 자주 어려움을 겪었기 때문에, 이번 기회를 통해 구조화에 대한 감을 잡고 싶었다.

하지만 과제를 진행하면서 판단 기준이 명확하게 세워지지 않았고, 오히려 도메인 중심으로 응집도 높은 폴더 구조를 가져가는 게 더 낫지 않나 하는 의문이 계속 들었다.

이런 의문을 가지고 멘토인 테오에게 질문을 했고, 테오는 다음과 같은 답변을 해주었다.

“FSD는 멘탈모델이다. 과제는 절취선을 만드는 기준을 세우는 과정이다. 어디를 잘라야 하는지 보는 눈을 기르고, 필요할 때 자르자.”

이 답변을 들으면서 흐릿하던 FSD의 개념이 조금은 선명해졌다.
단순히 폴더 구조를 칼같이 나누는 것이 중요한 것이 아니라, 왜 그렇게 나누는지를 먼저 깨닫고, 그 과정에서 기준을 세워 더 편하게 사용하는 것이 진짜 FSD의 목적이라는 생각이 들었다.

이러한 깨달음을 바탕으로, 이번 글에서는 내가 FSD를 이해하고 적용한 방식, 구조를 나누며 겪은 고민과 선택의 기록을 정리해보려 한다.

🧱 FSD란 무엇인가 – 폴더 구조를 넘어선 설계 철학

FSD는 단순한 폴더 구조 규칙이 아니다.

공식 문서에서도 “Feature-Sliced Design is an architectural methodology for designing frontends, focused on scalability and maintainability.”라고 말하듯, 프로젝트를 기능적으로 잘게 나누고, 비즈니스 변화에 유연하게 대응할 수 있도록 설계하는 전체적인 방법론에 가깝다.

FSD의 3요소: Layer, Slice, Segment

FSD의 핵심은 크게 세 가지 축으로 정리된다.

  • 레이어(Layer): 프로젝트를 수직적으로 나누는 기준. app, processes, pages, widgets, features, entities, shared 순으로 구조화되며, 상위 레이어는 하위 레이어만을 참조할 수 있다.

  • 슬라이스(Slice): 각 도메인을 기준으로 나누는 수평적 분리 단위. 예를 들어 user, post, comment 같은 실제 비즈니스 단위로 나뉜다.

  • 세그먼트(Segment): 슬라이스 내부에서 역할을 나누는 기준. model, ui, lib, api 등 기술적 관심사를 기준으로 디렉토리를 분리한다.

즉, 단순히 컴포넌트를 어디에 넣을지만 고민하는 것이 아니라, 비즈니스 기능 중심으로 구조화하고, 그 안에서 기술적인 관심사를 분리해나가는 것이 핵심이다.

이러한 접근은 규모가 커져도 구조의 일관성을 유지하는 데 큰 장점이 있다.

각 Layer의 역할과 의존성 규칙

FSD에서 레이어는 각자의 명확한 책임을 가지며, 하위 레이어만을 참조할 수 있는 단방향 의존성이 원칙이다.
이 원칙은 프로젝트가 확장되어도 하위 기능이 상위 기능에 영향을 주지 않도록 만들며, 유지보수성을 확보하는 데 중요한 역할을 한다.

Layer설명
app전역 설정 및 초기화 레이어. 라우팅, 글로벌 스타일, 상태 관리 등의 설정이 이곳에 위치한다.
processes페이지 간 프로세스를 다룬다. 예: 로그인 → 메인 이동, 결제 플로우 등.
pages라우팅에 연결된 페이지 단위 컴포넌트. URL과 1:1 대응된다.
widgets여러 feature와 entity가 결합된 UI 블록. 예: 헤더, 댓글 블록, 검색 필터 등.
features독립적인 비즈니스 기능 단위. 예: 정렬 기능, 댓글 작성 폼, 좋아요 버튼 등.
entities핵심 비즈니스 도메인 로직. API, 타입, 상태, CRUD 등 entity 관련 내용을 포함한다.
shared모든 곳에서 사용할 수 있는 공통 자원. 예: 버튼, 인풋, 유틸 함수, 타입 유틸리티 등.

내가 해석한 Layer 분리 기준과 실제 적용 방식

공식 문서를 그대로 따르는 것도 의미가 있지만, 과제를 진행하면서 더 중요하게 느껴졌던 건 "내가 납득할 수 있는 기준"을 만드는 것이었다. 구조를 억지로 끼워 맞추기보다, 역할에 맞게 자연스럽게 나누는 방향을 택했다.

이번 과제에서는 특히 Layer를 기준으로 구조를 설계했다. 다음은 실제로 내가 사용한 주요 디렉토리와 그 기준이다.

  • entities: 타입 정의, API 요청, 단일 도메인의 상태 (예: post, user, comment)
  • features: 여러 entity가 조합된 비즈니스 로직, useQuery, useMutation을 포함한 비동기 로직, zustand 기반의 전역 상태 관리
  • widgets: 여러 컴포넌트를 묶은 UI 조각으로, 내부에서 feature를 호출하거나 데이터를 표시하는 데 집중
  • pages: 라우팅 단위로, 상태 초기화 및 URL 쿼리 파라미터 처리와 같은 진입 시점의 로직을 담당

모든 걸 정답처럼 나누진 않았지만, 최소한 각 레이어가 어떤 책임을 가져야 할지 스스로 납득할 수 있는 기준을 갖게 된 것이 가장 큰 수확이었다.

🔧 FSD 관점에서의 리팩토링 과정

타입 정의: 구조를 정리하는 출발점

이번 과제는 타입 정의가 전혀 되어 있지 않은 상태에서 시작해야 했다.
처음에는 빠르게 작업하기 위해 any로 넘길까 고민도 했지만, 이후 로직 분리나 컴포넌트 재사용을 고려하면 결국 다시 돌아와야 할 작업이라는 걸 알고 있었다. 그래서 아예 초반에 타입을 먼저 정의하기로 결정했다.

이 과정에서 API 응답 구조를 기준으로 도메인을 나누고, post, user, comment 같은 엔티티 중심으로 타입을 설계해나갔다.
RESTful API의 응답을 기준으로 하다 보니, 어떤 값이 필수인지, 어떤 구조가 중첩되어 있는지 자연스럽게 정리되었고, 나중에 로직을 분리할 때 기준이 되어주었다.

이후에 나오는 entities 디렉토리 구조도, 이 타입 정의를 기반으로 맞춰나갈 수 있었다.

API 분리와 설계 기준

타입 정의가 완료된 뒤에는, 실제 API 요청 로직을 분리하는 작업을 시작했다.
가장 먼저 고민한 건 'API 요청 함수는 어디에 위치시켜야 할까?'였다.

나는 다음 기준을 세우고, 이를 entities 레이어에 적용했다.

  • 요청 대상이 명확한 단일 도메인(post, user, comment 등)에 속할 것
  • 외부 API 통신만을 책임지고, 내부 상태를 다루지 않을 것

이 기준에 따라 fetchPosts, fetchUsers, fetchComments 등 기본적인 요청 함수들을 각각 entities/post/api, entities/user/api, entities/comments/api에 정리했다.

다만 이 과정에서도 애매한 경우가 있었다.
예를 들어 tagpost와 강하게 연결된 값인데, fetchPostsByTag는 결국 post 데이터를 다루는 API였다.
그래서 tag를 별도의 엔티티로 분리하지 않고, 해당 요청 함수는 post 엔티티 내부에 포함시켰다.

또한 단일 API 호출이 아닌, 여러 요청을 조합해서 처리해야 하는 경우도 있었다.
대표적으로 게시글(post)과 사용자(user) 정보를 각각 받아와서 병합하는 로직이 있었는데,
이러한 도메인 간 조합 로직은 비즈니스 로직으로 판단하여 features/post 아래에 따로 분리해 관리했다.

즉, API 요청 함수는 최대한 단순하게 유지하고, 그 결과를 가공하거나 병합하는 역할은 feature 레이어에서 담당하도록 구조를 나눴다.

Zustand 기반의 전역 상태 분리

API 로직을 정리한 뒤에는 전역 상태 관리를 어디에 둘지에 대한 고민이 시작됐다.
searchQuery, selectedTag, pagination, sortOrder처럼 기능 단위에서 공유하는 상태들이 꽤 많았기 때문이다.

처음엔 store 파일을 entitiesshared에 둘까도 고민했지만,
해당 상태들은 특정 비즈니스 흐름에서만 사용되기 때문에 결국 features 내부에서 관리하는 것이 더 자연스럽다고 판단했다.
예를 들어, 게시글 리스트에 필요한 필터 조건은 features/post/model/usePostFilterStore.ts에,
검색창 입력 상태는 features/post/model/useSearchStore.ts에 각각 분리했다.

이 외에도 다이얼로그 열림 여부처럼 UI에 가까운 전역 상태들도 존재했다.
이런 UI 상태도 마찬가지로, 단순히 "공통 상태니까 shared에" 두는 방식보다는 관련 feature 내부에 함께 묶는 편이 응집도가 높다고 판단했다.
그래서 post 관련 다이얼로그 상태는 features/post/model/usePostDialogStore.ts에서 관리하도록 했다.

zustand를 사용할 때 가장 주의했던 점은 단순히 "전역"이라는 이유로 shared에 몰아넣지 않는 것이었다.
그 상태가 실제로 어디에 쓰이는가, 어떤 도메인에 속해 있는가를 먼저 고민했고, 그 결과 상태도 결국 기능 단위로 응집시키는 방향으로 구조가 잡혔다.

UI 분리와 props 관리의 어려움

초기에는 하나의 파일에 모든 로직과 UI를 통합해두고 있었지만, FSD의 구조적 기준에 맞춰 UI를 점진적으로 분리해나가기 시작했다.
먼저 각 기능을 기준으로 feature 단위로 나누고, 이후 재사용성이 있거나 역할이 명확한 UI 컴포넌트는 별도로 추출했다.

이 과정에서 자연스럽게 상태 전달 구조를 다시 설계해야 했다.
현재 전역 상태는 zustand를 통해 UI 관련 상태만 관리하고 있었고, API 응답 데이터는 따로 zustand로 보관하지 않았다.
그래서 테이블에서 렌더링할 데이터나 핸들러 같은 값은 컴포넌트 간에 props로 직접 전달할 수밖에 없었다.

덕분에 데이터의 흐름은 명확했지만, 컴포넌트가 깊어질수록 넘겨야 할 값이 많아졌다.
특히 PostTablePostTableContentPostTableItem처럼 2단계 이상 중첩된 구조에서는
단순히 보여주기 위한 데이터임에도 불구하고 상위 컴포넌트에서 계속 전달해줘야 하는 상황이 발생했고, 결국 props drilling 문제가 불거졌다.

UI를 분리하면서 가장 크게 느낀 건 단순히 컴포넌트를 나누는 것만으로는 충분하지 않다는 점이었다.
나눈 후 각 컴포넌트 간의 데이터 전달 구조까지 고려하지 않으면, 오히려 전체 구조가 더 복잡해질 수 있다는 걸 경험적으로 알게 됐다.

React Query 도입과 로직 재정의

UI 분리 이후, 상태 전달 구조의 복잡함뿐만 아니라 데이터 페칭 및 캐싱 처리에서도 불편함을 느꼈다.
처음에는 fetch 함수로 API 요청을 직접 호출하고, 그 응답값을 useState를 통해 수동으로 관리하고 있었다.
하지만 필터 변경, 검색, 태그 적용 등 다양한 상황에 따라 조건부로 데이터를 요청해야 하는 구조에서 상태 동기화가 점점 번거로워졌다.

그래서 React Query를 도입해 로직을 전면적으로 재정의하기로 했다.

React Query를 사용하면서 구조를 다음과 같이 나눴다:

  • API 요청 함수(fetch): entities 디렉토리 내에 작성해, 도메인 단위의 요청만을 담당하도록 분리
  • useQuery / useMutation 훅: features 내에 정의해, 조건에 따라 적절한 API 요청을 수행하고 로딩, 에러, 캐싱 등의 상태를 관리

예를 들어 usePostsQuery 훅에서는 tagsearchQuery가 있을 경우 조건에 따라 다른 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를 적용한 이후에는 쿼리 키만으로 이 흐름을 단순화할 수 있었고, 특히 searchQueryselectedTag의 조건 분기와 같은 복잡한 상황도 하나의 훅 내부에서 처리할 수 있어 유지보수가 훨씬 수월해졌다.

다음은 내 최종 폴더구조이다.

📦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

🧠 회고

이번 과제를 통해 다시금 느낀 건, 구조를 잘게 나눈다고 해서 자동으로 코드가 명확해지진 않는다는 점이었다. 오히려 폴더를 나누는 순간부터 "이건 어디에 넣어야 할까?"라는 고민이 시작됐고, 때론 단순한 기능 하나에도 수차례 디렉토리를 옮기며 기준을 재검토해야 했다.

하지만 그 과정에서 알게 된 건, 중요한 것은 딱 떨어지는 정답이 아니라 한 번 기준을 정했다면 그 기준을 일관되게 지켜나가는 태도라는 사실이었다.

처음엔 무조건 entityfeature, widget을 명확히 구분해야 한다고 생각했지만, 막상 프로젝트를 진행하다 보면 그런 경계는 생각보다 흐릿하게 다가왔다. 그리고 실제로 코드를 짜는 입장에서 그 경계를 결정하는 건 단순한 문서의 규칙이 아니라 도메인, 관심사, 협업 흐름을 종합적으로 고려한 판단의 결과였다.

테오는 본인의 기술 블로그에서 이렇게 말한다.

FSD에서 가장 어렵다 여겨지지만, entity, features, widgets에 대한 경계를 나눠서 코드를 작성하려고 하는 것은 무지개에서 빨강과 노랑 사이의 주황의 경계를 정하는 것과 같다 여겨집니다. 이게 맞는가에 대해서도 애매하고 완벽한 구분도 잘 모르겠습니다. 하지만 중요한 건 경계를 선명하게 하는 것보다 하나의 경계를 정했다면, 그 기준을 일관되게 유지하는 게 중요합니다. 그래야 선명해지는 법이니까요.

이 말에 정말 공감이 간다. FSD를 배우며 얻은 가장 큰 수확은 ‘정답’이 아니라 ‘판단 기준’을 세우는 연습이었고, 그 기준을 얼마나 흔들림 없이 유지할 수 있느냐가 곧 설계의 힘이라는 걸 느낄 수 있었다.

참고

과제 결과 및 코드

profile
세상에 못할 일은 없어!

3개의 댓글

comment-user-thumbnail
2025년 5월 7일

중요한 것은 딱 떨어지는 정답이 아니라 한 번 기준을 정했다면 그 기준을 일관되게 지켜나가는 태도라는 사실이었다. 이 말에 매우 공감합니다 ㅎㅎ

1개의 답글