Logo

고지민 개발 블로그

내가 FSD를 적용한 방법
  • #Architecture
  • #Project
  • #ModuReview

내가 FSD를 적용한 방법

Prolip

2025-10-26

FSD(Feature-Sliced Design)의 기본 개념과 슬라이스와 세그먼트를 실제로 프로젝트에서 어떻게 적용했는지, Next.js 환경에선 FSD를 어떻게 적용했는지 예시 코드와 함께 정리한 글입니다.

FSD의 기본적인 개념

이번 프로젝트에서 폴더 구조를 FSD 방법론에 맞게 설계했는데요. 먼저 FSD(Feature-Sliced Design)란 프로젝트를 도메인별로 나누고, 각 레이어를 독립적으로 관리하는 방법론입니다.

이전에 작성한 글이 있는데 이번 프로젝트에서 조금 더 제대로 다루어 보기도 했고, 예시를 첨부해놓는게 좋겠다 싶어 다시 작성해보려고 합니다.

image.png

FSD는 크게 레이어, 슬라이스, 세그먼트로 이루어진 계층 구조를 따르는데요.

1. 레이어

먼저 레이어는 프로젝트에서 주요한 논리적 분할을 제공합니다. 총 7가지가 있으며 각각 app, pages, widgets, features, entities, shared로 구성됩니다. (processes는 deprecated 됐다고 합니다.)

상위에 위치한 레이어일수록 사용자에게 가까운 UI나 비즈니스 플로우가 위치하고 하위 레이어일수록 구현 세부사항이나 재사용 가능한 요소들이 위치합니다.

위에 위치한 레이어는 아래에 위치한 레이어를 참조할 수 있지만, 아래에서 위는 참조가 불가능한데요. 예를 들어 features 레이어에선 entities 레이어를 참조할 수 있지만, 반대로 entities 레이어에선 features 레이어를 참조할 수 없습니다.

이건 FSD의 방향성이라는 설계 원칙 때문인데요. FSD는 각 레이어를 독립적으로 교체하거나 확장할 수 있게 설계하려는 목적이 있습니다.

만약 아래에 위치한 entities가 상위에 위치한 features를 참조하면 아래에 위치한 코드가 위쪽 구현 사항에 의존하게 된다는 문제가 발생하게 됩니다. 즉, 결합도가 높아져 상위 레이어의 변경이 하위 레이어에 전파되어 지옥의 유지보수가 시작됩니다.

조금 더 설명해보자면, 상위에 위치한 레이어일수록 UI 로직 등 변화가 자주 일어나는 코드가 위치하고 하위 레이어일수록 유틸 등 공통적으로 쓰이는 안정적인 코드가 위치합니다.

자주 바뀌는 코드가 안정적인 코드에 의존하는건 괜찮지만 그 반대는 위험하다는게 원칙입니다. 그런 이유로 의존성의 방향을 위에서 아래로만 허용하고 있습니다.

2. 슬라이스

슬라이스는 코드베이스를 비즈니스 도메인별로 분할한 단위로 위 사진과 같이 user, post, comment 같은 이름으로 정의할 수 있습니다.

FSD의 원칙상 같은 레이어 내에서는 다른 슬라이스를 참조하지 않아야 하는데요. 이 원칙은 슬라이스 간 결합도를 낮추고 각 슬라이스를 하나의 도메인 단위로 유지시키기 위함입니다.

  • 그냥 쉽게 entities 레이어에 user, post 슬라이스가 있으면 서로 참조하지 않아야 user 슬라이스를 독립적으로 확장하거나 수정할 수 있다는 의미입니다.

하지만 이번에 실제로 프로젝트를 진행하면서 이 원칙을 완벽하게 지키기 어렵다는걸 깨달았습니다. 몇가지 사례를 가져와봤는데요.

  1. 단일 리뷰 도메인에서 게시글 삭제 기능을 담당하고 있었는데요. 게시글 삭제 시 사용자가 조회 중인 리뷰 목록의 캐시를 무효화해주기 위해 리뷰 목록 도메인의 캐시 키를 참조하는 경우가 발생했습니다.
  2. 알림 도메인에서 실시간 알림 기능을 위해 SSE 연결을 시도하고 있습니다. 로그인 사용자만 연결하기 위해 유저 도메인의 로그인 상태 값을 참조하는 경우가 발생했습니다.

이렇게 같은 레이어 내에서 불가피하게 다른 슬라이스를 참조해야 하는 상황이 자주 발생했습니다.

사실 FSD가 절대적 규칙은 아니고 좋은 의존 방향을 만들기 위한 일종의 틀이기 때문에 실제로 많은 개발자들이 FSD의 원칙을 그대로 적용하기보단 팀의 개발 문화나 서비스 복잡도에 맞게 구조를 적절히 조정하는 사례가 많았습니다.

때문에 팀원과 의존 이유가 명확하고 도메인 책임이 겹치는 경우에 한해 동일 레이어 내에서 참조를 허용하기로 합의했고, 덕분에 FSD의 철학인 낮은 결합도와 높은 응집도는 유지하면서 유연성도 확보할 수 있었습니다.

3. 세그먼트

슬라이스와 레이어는 다시 세그먼트로 나뉘어 코드의 목적에 따라 그룹화되는데요. 세그먼트에는 주로

  • ui - UI 관련 코드
  • api - 서버와의 상호작용
  • model - 데이터 모델과 비즈니스 로직

등의 이름이 사용됩니다. 그 외 lib와 consts 세그먼트도 사용했는데요. lib는 각 도메인 내부에서만 사용하는 유틸리티 함수나 헬퍼 함수등을 관리하기 위해 사용했고, consts는 상수 파일을 관리하기 위해 사용했습니다.

적절히 세그먼트도 확장해두니 도메인 내부에서 역할이 명확하게 분리되고 같은 슬라이스 내에서도 UI, 비즈니스 로직, 상수, 헬퍼가 한눈에 보여서 유지보수하기 쉬웠습니다.

Next.js에서 FSD를 적용하기 위한 설정

먼저 이번 프로젝트에서 Next.js를 사용했는데요. FSD의 최상위 레이어는 app 레이어로 Next.js의 app 라우터와 충돌이 발생합니다.

  • app 디렉터리가 라우팅, 서버 컴포넌트 트리의 루트 역할을 하기 때문에 내부에 폴더를 생성하면 자동으로 라우팅이 되어버려서 슬라이스를 나누면 강제로 라우팅이 발생했습니다.

그래서 저는 프로젝트 생성 단계에서 src 디렉터리 사용을 선택하지 않았는데요. src 디렉터리를 사용할 것인지 여부를 물을 때 no를 선택하면 프로젝트 루트에 app 디렉터리가 생성됩니다. 반대로 yes를 선택하면 루트 경로에 src 폴더가 생성되고 내부에 app 디렉터리가 생성됩니다.

image.png

이렇게 루트 경로에 생성된 app 디렉터리를 Next.js의 라우팅 엔트리로 그대로 사용하고 별도로 루트 경로에 src 디렉터리를 생성해 내부에 FSD 레이어를 구성했습니다.

엔트리 파일을 통한 캡슐화

먼저 ‘각 레이어를 어떻게 구성했는가?’ 전에 한 가지 내용을 미리 정리해두고 가려고 합니다. 저는 이번 프로젝트에서 각 세그먼트에 index.ts 를 두고 엔트리 포인트로 사용했는데요.

각 세그먼트 내부의 세부 폴더 구조나 구현 파일을 외부로 노출하지 않고 세그먼트 내부의 구현체를 사용할 때 이 세그먼트만 import 하면 되도록 모듈화 시킨 파일입니다.

예시를 보여드리자면,

image.png

위 사진은 entities 레이어의 review 도메인으로 해당 도메인의 필요한 코드는 아래와 같이 import 하게 되는데요.

import {ReviewContent, Comment} from './model/type'; import {usePostReview} from './model/usePostReview'; import {getPresigned, uploadImage, getReviewDetail} from './apis/api-service'; import {CATEGORY_LIST} from './consts/category'; import {useDeleteReviewComment} from './model/useDeleteReviewComment';

이 방법은 내부 폴더 구조가 변경되면 외부의 의존 코드를 모두 수정해야만 합니다. 또, import 경로가 길어져 코드가 지저분해지는데요.

export * from './model/type'; export * from './consts/category'; export {getPresigned, uploadImage, getReviewDetail} from './apis/api-service'; export {default as usePostReview} from './model/usePostReview'; export {default as useGetReviewBookmarks} from './model/useGetReviewBookmarks'; export {default as useToggleBookmark} from './model/useToggleBookmark'; export {default as useGetReviewComments} from './model/useGetReviewComments'; export {default as usePostReviewComment} from './model/usePostReviewComment'; export {default as usePatchReview} from './model/usePatchReview'; export {default as useDeleteReviewFromMyPage} from './model/useDeleteReviewFromMyPage'; export {default as useDeleteReviewComment} from './model/useDeleteReviewComment';

이렇게 index.ts 에서 각 세그먼트의 필요한 요소만 선택해 export 하도록 구성하면 외부에서는 아래와 같이 간결하게 import 할 수 있습니다.

import {useDeleteReviewComment, useGetReviewComments, Comment} from "@/entities/review"

이 방식은 하나의 엔트리 파일을 통해 외부에 노출시키기 때문에 내부 폴더 구조가 변경되어도 외부 의존 코드를 수정할 필요가 없습니다.

공개할 API와 내부 구현을 구분할 수도 있고 import 문도 도메인 단위로 간결하게 표현 가능하다는 장점도 있습니다.

이제 각 레이어에 대해 알아보면서 제가 이번 프로젝트에서 FSD를 어떻게 적용했는지 살펴보겠습니다.

app 레이어

먼저 app 레이어는 어플리케이션 전역에 관련된 설정과 초기화를 담당하는 레이어입니다.

전역 설정은 특정 도메인에 속하지 않기 때문에 FSD에서 유일하게 슬라이스를 나누지 않고 세그먼트 단위로만 구성합니다.

저는 이번 프로젝트에서 app 레이어를 총 4개의 세그먼트로 구성했는데요.

image.png

fonts 세그먼트

프로젝트에서 사용하는 로컬 폰트 파일을 관리하는 세그먼트로 Next.js의 로컬 폰트를 사용해 woff2 폰트를 엔트리 파일에 연결해놓은 상태입니다.

layouts 세그먼트

Next.js의 루트 layout.tsx 파일을 설정한 세그먼트입니다. 프로젝트 전체 레이아웃 구조를 엔트리 파일에 바로 작성했습니다. 예시 코드의 일부를 아래 providers 세그먼트에 첨부했습니다.

providers 세그먼트

// /app/providers/index.ts import AuthProvider from './AuthProvider'; import GlobalErrorDetector from './GlobalErrorDetector'; import NotificationProvider from './NotificationProvider'; import ReactQueryProvider from './ReactQueryProvider'; import UnPredictableErrorBoundary from './UnPredictableErrorBoundary'; type Props = { children: React.ReactNode; }; export default function Providers({children}: Props) { return ( <UnPredictableErrorBoundary> <GlobalErrorDetector> <ReactQueryProvider> <AuthProvider> <NotificationProvider>{children}</NotificationProvider> </AuthProvider> </ReactQueryProvider> </GlobalErrorDetector> </UnPredictableErrorBoundary> ); }

프로젝트 전역에서 사용하는 컨텍스트 프로바이더들이 위치합니다. 리액트 쿼리 사용을 위해 쿼리 클라이언트를 공유하는 프로바이더, 알림 상태를 공유하기 위한 프로바이더, 로그인 정보를 공유하기 위한 프로바이더 등 전역 상태나 서비스 초기화를 담당하는 컴포넌트들이 이 세그먼트에 위치합니다.

// /app/layout/index.ts import type {Metadata} from 'next'; import '@/app/styles'; import pretandard from '@/app/fonts'; import Providers from '@/app/providers'; import {Header} from '@/widgets/header'; import {Footer} from '@/widgets/footer'; import {Toaster} from 'sonner'; export const metadata: Metadata = { // 메타데이터 설정 }; export function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className={`${pretandard.className} antialiased flex flex-col w-full`}> <Providers> <header className="sticky top-0 z-10 bg-white/85 backdrop-blur-xl"> <Header /> </header> <main className="grow w-full mx-auto">{children}</main> <div id="modal-root" /> </Providers> <Footer /> <Toaster position="top-center" /> </body> </html> ); }

이렇게 중앙화둔 프로바이더 컴포넌트를 사용하면 루트 레이아웃에서 여러개의 프로바이더를 일일이 중첩할 필요 없이 하나의 프로바이더 컴포넌트만을 사용해 초기화할 수 있습니다.

이후에 필요한 기능이 추가되어 확장이 필요할 때 해당 세그먼트만 수정하면 됩니다.

styles

기본적인 HTML 리셋이나 스크롤바 설정 등 전역 스타일링을 위한 globals.css 파일이 위치한 세그먼트입니다.

정리하자면

이렇게 app 레이어를 구성해두면 전역 수준의 설정과 도메인 수준의 로직을 분리할 수 있게 되는데요. 이후에 폰트를 교체하거나 전역 프로바이더를 추가하더라도 다른 레이어(features나 entities 등)에 영향을 주지 않고 독립적으로 수정이 가능해집니다.

views 레이어

다음으로 views 레이어입니다. 일반적으로 FSD에서 pages 라는 이름을 사용하는 레이어인데요. Next.js의 pages 라우터와 헷갈릴 수 있어 임의로 views라는 이름으로 사용했습니다.

views 레이어는 실제로 사용자가 보게 되는 화면 단위를 정의합니다. 이후 설명할 features와 entities 레이어의 조합으로 만들어진 UI 요소들을 하나의 완성된 페이지로 구성하는 레이어인데요.

image.png

제가 구성한 views 레이어는 위 사진과 같이 프로젝트의 각 도메인별로 슬라이스를 나누어 구성한 상태입니다. 간단하게 설명해보자면

  • main: 메인 페이지
  • contact: 피드백 페이지
  • mypage: 마이 페이지
  • notifications: 알림 페이지
  • oauth2: 소셜 로그인 리다이렉트 페이지
  • search: 검색 페이지
  • users/reviews: 작성자별 리뷰 목록 페이지
  • review: 단일 리뷰 도메인의 경우 기능 구성이 다양해 별도로 review 슬라이스 아래에 3개의 슬라이스를 나누어 구성했습니다.
    • /detail: 리뷰 상세 보기 페이지
    • /edit: 리뷰 수정 페이지
    • /new: 리뷰 작성 페이지

각 슬라이스는 모두 내부에 ui 세그먼트를 가지고 내부에 페이지 컴포넌트가 위치하고 있습니다. 혹은 페이지에 종속적인 컴포넌트들도 위치하고 있습니다. (메인 페이지의 히어로 컴포넌트 등)

// src/views/main/ui/MainPage.tsx import Hero from './Hero'; import ContactUs from './ContactUs'; import {BestReviews} from '@/features/reviews/best'; import {RecentReviews} from '@/features/reviews/recent'; import {getBestReviews} from '@/entities/reviews'; export default async function MainPage() { const data = await getBestReviews(); return ( <section> <Hero /> <BestReviews reviews={data} /> <RecentReviews /> <ContactUs /> </section> ); } // src/views/notifications/ui/NotificationsPage.tsx import {Notifications} from '@/features/notifications'; export default function NotificationsPage() { return ( <section className="h-full flex flex-col max-w-5xl mx-auto md:px-4 pt-4 md:pt-7 md:pb-7"> <h2 className="text-2xl font-semibold ml-6 mb-4 md:mb-6">알림</h2> <Notifications /> </section> ); }

이렇게 비즈니스 로직은 features 레이어와 entities 레이어로 이미 분리되어 있어 views 레이어는 UI 레이아웃과 기능을 조합하고만 있습니다.

// views/main/index.ts export {default as MainPage} from './ui/MainPage'; // app/page.ts export const revalidate = 3600; export {MainPage as default} from '@/views/main'; // views/notifications/index.ts export {default as NotificationsPage, metadata} from './ui/NotificationsPage'; // app/notifications/page.ts export {NotificationsPage as default, metadata} from '@/views/notifications';

그리고 이렇게 views 레이어에서 페이지 컴포넌트를 엔트리 파일에 연결하고, Next.js의 app 라우터에서 해당 컴포넌트를 불러와 라우팅만 담당하는 방식으로 레이어를 구성했습니다.

widgets 레이어

widgets 레이어는 보통 엔티티와 기능을 조합해 구성된 독립적인 UI가 위치하는 레이어라고 하는데요. 하나의 완성된 UI를 제공하되, 비즈니스 로직은 포함하지 말고 UI 수준에서 재사용 가능한 단위로 구성하라고 합니다.

이게 저도 이번에 FSD를 적용하면서 widgets 레이어를 굳이 사용해야 하나 싶어 찾아본 결과 widgets 레이어를 생략하는 경우도 많았고, 대부분의 기능이 features 레이어에 집중되니 UI 조합 단위가 굳이 따로 분리될 필요가 있나? 싶었습니다.

하지만 프로젝트를 진행하면서 widgets 레이어에 위치할 세그먼트가 몇가지 생겼는데요.

image.png

errors 슬라이스

이 슬라이스는 프로젝트 전역적으로 사용하는 에러 UI가 담긴 슬라이스입니다.

내부에는 예측하지 못한 에러가 발생했을 때 모든 페이지에 공통적으로 사용되는 전역 에러 바운더리, Next.js의 notFound() 호출, 혹은 잘못된 라우트 경로에 접근했을 때 연결되는 NotFound 페이지가 위치하고 있습니다.

도메인에 소속되지 않는 전역적인 UI면서 페이지 어디서나 사용 가능한 블럭이라고 생각해 별도로 widgets 레이어에 위치시켰습니다.

이건 가장 전형적인 예시라고 생각하는데요. 레이아웃의 일부지만 페이지 전역에서 반복적으로 사용되는 완성된 UI 블럭입니다.

header에는 네비게이션, 로그인, 알림 등 features와 entities의 조합으로 이루어진 UI 컴포넌트가, footer에는 프로젝트 정보가 표시되는 컴포넌트가 위치하고 있습니다.

각각 독립된 UI 컴포넌트지만 비즈니스 로직은 포함하지 않기 때문에 widgets 레이어에 위치시켰습니다.

pagination 슬라이스

'use client'; import { ShadcnPagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from '@/shared/shadcnComponent/ui/pagination'; type Props = { currentPage: number; totalPages: number; generateUrl: (page: number) => string; className?: string; scrollToTop?: boolean; }; export default function Pagination({totalPages, currentPage, generateUrl, className, scrollToTop = true}: Props) { function renderPageNumbers() { if (totalPages === 1) { return ( <PaginationItem key={1}> <PaginationLink href={generateUrl(1)} className="pointer-events-none" isActive={true} aria-disabled={true} scroll={scrollToTop} > 1 </PaginationLink> </PaginationItem> ); } const items = []; let startPage = Math.max(1, currentPage - 1); const endPage = Math.min(totalPages, startPage + 2); if (endPage === totalPages) { startPage = Math.max(1, endPage - 2); } if (currentPage > 2 && startPage > 1) { items.push( <PaginationItem key="first"> <PaginationLink href={generateUrl(1)} isActive={currentPage === 1} aria-disabled={currentPage === 1} scroll={scrollToTop} > 1 </PaginationLink> </PaginationItem>, ); } if (startPage > 1) { items.push( <PaginationItem key="ellipsis-start"> <PaginationEllipsis /> </PaginationItem>, ); } for (let i = startPage; i <= endPage; i++) { items.push( <PaginationItem key={i}> <PaginationLink href={generateUrl(i)} className={`${currentPage === i && 'pointer-events-none'}`} isActive={currentPage === i} aria-disabled={currentPage === i} tabIndex={currentPage === i ? -1 : 0} scroll={scrollToTop} > {i} </PaginationLink> </PaginationItem>, ); } if (endPage < totalPages) { items.push( <PaginationItem key="ellipsis-end"> <PaginationEllipsis /> </PaginationItem>, ); } if (currentPage < totalPages - 1 && endPage < totalPages) { items.push( <PaginationItem key="last"> <PaginationLink href={generateUrl(totalPages)} isActive={currentPage === totalPages} aria-disabled={currentPage === totalPages} scroll={scrollToTop} > {totalPages} </PaginationLink> </PaginationItem>, ); } return items; } return ( <ShadcnPagination> <PaginationContent className={className}> <PaginationItem> <PaginationPrevious href={generateUrl(currentPage - 1)} aria-disabled={currentPage === 1} tabIndex={currentPage === 1 ? -1 : 0} className={`${currentPage === 1 && 'pointer-events-none opacity-50'}`} scroll={scrollToTop} /> </PaginationItem> {renderPageNumbers()} <PaginationItem> <PaginationNext href={generateUrl(currentPage + 1)} aria-disabled={currentPage === totalPages} tabIndex={currentPage === totalPages ? -1 : 0} className={`${currentPage === totalPages && 'pointer-events-none opacity-50'}`} scroll={scrollToTop} /> </PaginationItem> </PaginationContent> </ShadcnPagination> ); }

프로젝트의 검색 페이지, 마이페이지 등 여러 페이지에서 페이지네이션 기능이 사용되고 있는데요. 재사용 가능한 페이지네이션 컴포넌트가 위치하고 있습니다.

페이지네이션 컴포넌트는 내부에 상태를 가지지 않고 currentPage, totalPages 등 필요한 상태값을 외부에서 인자로 주입받아 렌더링만 담당하고 있습니다.

저는 페이지네이션을 state가 아닌 url 기반으로 만들었는데요. (새로고침하면 사용자가 선택한 카테고리나 조회 중인 페이지가 날아가버리기 때문..)

페이지네이션을 통해 이동할 url을 generateUrl로 주입받기 때문에 검색, 마이페이지 등 어떤 도메인에서도 재사용이 가능한 구조입니다. 이건 특정 비즈니스 로직과 결합되지 않은 독립 컴포넌트라는 의미이기도 합니다.

<Pagination currentPage={currentPage} totalPages={total_pages} generateUrl={(page: number) => `?tabs=my&page=${page}`} /> <Pagination currentPage={currentPage} totalPages={total_pages} generateUrl={(page: number) => `/search/${keyword}?page=${page}&sort=${sort}`} /> <Pagination currentPage={currentPage} totalPages={total_pages} generateUrl={(page: number) => `/notifications?page=${page}`} />

사용하는 위치에선 이렇게 이동하고 싶은 경로를 반환하는 함수만 전달하면 됩니다. 정리하자면 페이지네이션 슬라이스의 컴포넌트는 비즈니스 로직은 포함하지 않고 UI 레벨에서 이미 완성된 블럭을 제공할 수 있기 때문에 widgets 레이어에 위치시켰습니다.

features 레이어

상위 레이어부터 내려오면서 설명하고 있는데요. 이번엔 features 레이어입니다. features 레이어는 사용자 인터랙션, 비즈니스 로직이 결합된 기능 단위가 위치합니다.

이후에 설명하겠지만 entities가 데이터 단위를 다루고 widgets가 UI 조합의 단위를 제공한다면 features는 사용자가 실제로 조작하는 기능 단위를 구현하게 됩니다.

간단하게 사용자가 상호작용에 의 무언가 결과물을 얻을 수 있는 기능이 포함된다고 생각하시면 됩니다.

image.png

저는 features 레이어의 슬라이스를 도메인 단위로 나누어 관리했는데요. 이렇게 나누면 각 도메인(review, reviews, auth)이 담당하는 기능적 책임이 명확해져 관련된 UI와 로직을 하나의 폴더 안에서 관리해 응집도를 올릴 수 있습니다.

우리 프로젝트의 경우 도메인 구조가 ‘단일 리뷰’와 ‘리뷰 목록 보기’로 크게 나뉘기 때문에

  • 리뷰 한 건에서 발생하는 기능(북마크, 댓글, 작성, 뷰어)을 포함한 review 슬라이스
  • 리뷰들을 리스트 형태로 제공하는 reviews 슬라이스

로 구분했습니다. 그 외에

  • 사용자 인증을 담당하는 auth 슬라이스
  • 메일 전송 폼을 이용해 의견을 수집하는 contact 슬라이스
  • 실시간 알림 기능을 담당하는 notifications 슬라이스

등이 위치하고 있습니다.

이렇게 도메인 단위로 기능을 묶어두면 기능 간 의존 관계가 줄어 새로운 기능을 추가하거나 수정할 때 영향 범위가 줄어듭니다. 2개의 슬라이스를 조금 더 자세히 설명하면서 features 레이어에 대한 설명을 마무리해보려고 합니다.

auth 슬라이스

image.png

먼저 auth 슬라이스는 2개의 세그먼트로 나뉘어있는데요. ui 세그먼트의 경우 사용자와 상호작용이 가능한 UI 요소, lib 세그먼트는 로그인과 관련된 커스텀 훅이 위치하고 있습니다.

import {useLogout} from '@/entities/auth'; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/shared/shadcnComponent/ui/tooltip'; import {LucideIcon} from '@/shared/ui/icons'; export default function LogoutButton() { const {logout} = useLogout(); const handleLogout = () => { logout(); }; return ( <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <button type="button" onClick={handleLogout} aria-label="로그아웃" className="text-boldBlue hover:text-extraboldBlue hover:scale-105 transition-all" > <LucideIcon name="LogOut" size={24} /> </button> </TooltipTrigger> <TooltipContent side="top">로그아웃</TooltipContent> </Tooltip> </TooltipProvider> ); }

features 레이어의 이해를 위해 LogoutButton 컴포넌트의 코드를 가져와봤습니다. 이 컴포넌트의 중요한 특징은 UI와 비즈니스 로직이 완전히 분리되어 있다는 점인데요.

LogoutButton 컴포넌트 자체에는 API 호출 로직이 없고, 실제 로그아웃 처리는 entities 레이어의 auth 도메인에서 제공하는 useLogout 훅을 가져와 사용합니다.

features는 어떤 기능을 제공하는가에 초점을 맞추고 데이터의 실제 처리나 통신은 하위 레이어인 entities에 위임하고 있는 구조입니다.

이 구조는 로직의 변경에 UI가 영향을 받지 않고 UI 변경에 로직이 영향을 받지 않습니다. LogoutButton 컴포넌트의 디자인을 바꾼다거나 로그아웃 로직을 바꾸기 위해 useLogout 훅을 수정해도 서로의 코드에 영향을 주지 않습니다.

review 슬라이스

image.png

review 슬라이스는 단일 리뷰 화면에서 일어나는 기능 단위를 모아놓은 도메인입니다.

내부적으로 다시 bookmarks, comments, editor, viewer 슬라이스로 나뉘는데요. 각각 북마크 기능, 댓글 작성 및 조회, 리뷰 작성, 리뷰 상세 보기 기능을 맡고 있습니다.

이렇게 도메인 내부에서 다시 세부 슬라이스로 나눈 이유는 각 기능의 책임을 나누기 위해서였습니다. 내부의 북마크 기능을 예시로 들어보겠습니다.

bookmarks 슬라이스에는 사용자의 클릭(상호작용)으로 북마크를 등록, 해제할 수 있으며 현재 북마크 수를 표시하는 UI 컴포넌트가 위치하고 있는데요. 코드는 아래와 같습니다.

'use client'; import {useIsLoggedIn} from '@/entities/auth'; import {useGetReviewBookmarks, useToggleBookmark} from '@/entities/review'; import {LucideIcon} from '@/shared/ui/icons'; type Props = { reviewId: number; openLoginModal: () => void; }; export default function Bookmarks({reviewId, openLoginModal}: Props) { const { data: {bookmarks, hasBookmarked}, } = useGetReviewBookmarks(reviewId); const isLoggedIn = useIsLoggedIn(); const {toggleBookmark, isPending} = useToggleBookmark(); const handleClick = () => { if (!isLoggedIn) { openLoginModal(); return; } toggleBookmark({reviewId}); }; return ( <button aria-label={`북마크 ${hasBookmarked ? '해제' : '추가'}하기`} aria-disabled={isPending} onClick={handleClick} disabled={isPending} tabIndex={isPending ? -1 : 0} className={`flex items-center border border-gray-300 rounded-md py-3 px-5 mb-10 gap-0.5 hover:bg-gray-100 transition-colors ${hasBookmarked && 'border-mediumBlue'}`} > {hasBookmarked ? ( <LucideIcon name="BookmarkCheck" className="w-7 h-7 md:w-8 md:h-8 text-mediumBlue" /> ) : ( <LucideIcon name="Bookmark" className="w-7 h-7 md:w-8 md:h-8" /> )} <p className={`text-lg font-semibold ${hasBookmarked && 'text-mediumBlue'}`}>{bookmarks}</p> </button> ); }

이 컴포넌트도 auth 슬라이스에서 봤던 로그아웃 버튼과 마찬가지로 비즈니스 로직을 직접 포함하지 않고 필요한 기능들을 각각 entities 레이어에서 불러오고 있습니다.

  • entities/auth - useIsLoggedIn: 현재 사용자가 로그인 사용자인지 boolean 값을 반환하는 훅입니다.
  • entities/review
    • useGetReviewBookmarks: 현재 리뷰의 북마크 수와 사용자가 리뷰를 북마크했는지 여부를 반환하는 훅입니다.
    • useToggleBookmark; 현재 리뷰를 북마크 등록 및 해제할 수 있는 토글 함수와 요청 상태를 반환하는 훅입니다.

북마크 기능의 실제 데이터 처리는 하위 레이어인 entities의 각 슬라이스에서 담당하고 features 레이어의 review/bookmarks 슬라이스는 이 기능이 UI에서 어떻게 동작하는지만 담당합니다.

auth 슬라이스에서 봤던 구조와 동일하게 이 구조는 비즈니스 로직이 바뀌어도 UI는 그대로 유지되며 UI를 수정해도 데이터 로직에는 영향을 주지 않습니다.

정리하자면

예시와 같이 features 레이어는 사용자와의 상호작용을 통해 무언가 결과물을 얻을 수 있는 기능 단위가 위치하는 레이어입니다.

데이터 조작을 위한 로직은 하위 레이어로 위임해 UI와 데이터 로직의 결합도를 낮추면서 기능 단위의 완성도는 높이는 FSD의 핵심 레이어라고 볼 수 있습니다.

그 외에도 각 도메인에 맞는 슬라이스가 있는데 위에서 예시로 들어본 auth, review 슬라이스만으로 features 레이어의 설명은 충분하다고 생각해 entities 레이어를 설명해보려고 합니다.

entities 레이어

features 레이어가 사용자가 어떤 기능을 수행하는가에 집중한다면 entities 레이어는 이 기능의 기반의 되는 데이터 모델과 그 데이터를 다루는 로직을 담당합니다.

저는 프로젝트의 도메인 구조에 맞게 entities 레이어 역시

  • 인증(auth)
  • 에러(error)
  • 알림(notifications)
  • 단일 리뷰(review)
  • 리뷰 목록(reviews)
  • 유저(users)

로 entities 레이어를 구성했습니다.

features 레이어와 동일한 도메인 단위를 사용하지만 목적은 다른데요. features가 기능 단위라면 entities는 데이터 단위라고 생각하시면 됩니다.

image.png

제가 구성한 entities 레이어 구조입니다. reviews 슬라이스만 폴더가 펼쳐져있는데, 각 슬라이스의 세그먼트 구조는 비슷합니다. 사용한 세그먼트는 아래와 같습니다.

  • apis: 이 도메인에서 사용하는 API 요청 함수가 위치합니다.
  • model: 리액트 쿼리 키, 옵션 팩터리인 query-service.ts, 데이터 모델을 위한 type.ts, 전역 스토어, 각 도메인 로직 훅 등이 위치합니다.
  • ui: 이 도메인의 데이터를 표시하기 위한 재사용 가능한 UI 컴포넌트들이 위치합니다.
  • consts: 도메인에 종속적인 상수들이 위치합니다.

왜 ui 세그먼트가 entities에 위치하는가?

‘왜 entities 레이어에 ui 세그먼트가 있는가?’ 생각하실 수 있습니다. 저도 처음엔 데이터 모델을 다루는 레이어라면 UI 컴포넌트는 features 레이어에 위치하는게 맞지 않나 생각했습니다.

하지만 features 레이어의 UI는 사용자와의 상호작용(클릭, 토글, 제출 등)을 통해 어떤 변화를 일으키는 UI고 entities 레이어의 UI는 단순히 도메인 데이터를 화면에 표시하기 위한 UI로 변화의 목적을 가지지 않습니다.

image.png

리뷰 목록에서 각 리뷰를 위 사진과 같이 카드 형태로 제공하고 있습니다. 이 카드 골격 내부에 제목, 내용, 작성자 등 내용은 CardDescription라는 컴포넌트를 재사용해 표시하고 있는데요.

type Props = { card: ReviewCard; priority: boolean; variant?: 'default' | 'my'; }; export default function CardDescription({ card: {category, image_url, title, preview, ...}, priority, variant = 'default', }: Props) { return ( <article className="h-full flex flex-col items-center justify-between font-semibold"> <Badge className={badgeVariants({variant})}>{CATEGORY_MAP[category]}</Badge> <div className="flex flex-col items-center text-center"> <div className={imageWrapperVariants({variant})}> <Image className="w-full h-full object-cover aspect-square" src={image_url} width={140} height={140} priority={priority} alt={`카드 이미지: ${title}`} /> </div> <h3 className="mt-4 mb-2 px-3 line-clamp-1">{title}</h3> <div className="min-h-[30px]"> <p className="text-[13px] font-light line-clamp-2 px-5">{preview}</p> </div> </div> ... </article> ); }

코드를 살펴보면 컴포넌트 자체에 상태를 가지거나 API 호출 등의 비즈니스 로직을 포함하지 않고, 이 컴포넌트 자체가 리뷰 데이터의 표현 모델로 동작하고 있습니다.

실제로 이 컴포넌트는 메인 페이지의 베스트 리뷰 영역, 마이페이지의 내가 저장한 후기, 내가 작성한 후기 등 여러 도메인에서 재사용되고 있습니다.

features 레이어의 특정 도메인 전용으로 사용되지 않고 리뷰 데이터를 표현하기 위한 UI의 형태로 동작하기 때문에 entities 레이어에 위치시키는 것이 자연스럽다고 판단했습니다.

그 외에도 ReviewArticle, NoSearchResults 등의 UI 컴포넌트가 features 레이어에서 비슷한 패턴으로 사용되고 있습니다. 훅, API 호출 함수 등과 같이 features 레이어가 entities 레이어의 ui를 가져다 사용하는 관계입니다.

정리하자면

저는 features 레이어는 무언가를 할 수 있게 해주는 레이어, entities 레이어는 무언가를 다루고 표현할 수 있게 해주는 레이어라고 보고 있습니다.

그런 이유로 데이터 모델을 표현하는 데 사용하는 UI 컴포넌트는 entities 레이어에 배치했습니다.

이렇게 entities 레이어에서 데이터 접근을 정리해두면 features 레이어는 UI, 사용자 행동에만 집중할 수 있게 됩니다.

이제 apis, model 레이어의 예시를 하나씩 들고 entities 레이어를 마무리해보려고 합니다.

apis 세그먼트

apis 세그먼트에는 api-service.ts 가 위치하고 있습니다. entities 레이어의 모든 슬라이스는 모두 apis 세그먼트에 api-service.ts 가 위치하고 있습니다.

이 파일은 해당 도메인에서 필요한 API 호출 함수만 모아둔 파일로 entities는 데이터 모델이므로 데이터를 어디서 가져오는지를 여기에 정의하고 있습니다.

import { BestReviewsResult, CategoryReviewsResult, KeywordReviewsResult, MyBookmarkedReviewsResult, MyReviewsResult, RecentReviewsResult, } from '../model/types'; import {requestGet} from '@/shared/apis'; export function getBestReviews() { return requestGet<BestReviewsResult>({ endpoint: '/reviews/best', }); } export function getRecentReviews() { return requestGet<RecentReviewsResult>({ endpoint: '/reviews/latest', }); } export function getKeywordReviews(keyword: string, page: number, sort: string) { return requestGet<KeywordReviewsResult>({ endpoint: '/search', queryParams: { keyword: keyword, page: page, sort: sort, }, }); } ...

코드의 일부를 가져와봤는데요. 이렇게 각 API 호출 함수를 정의해두면 features나 widgets는 API를 직접 알 필요 없이 이 API를 호출하는 훅만 호출하면 됩니다.

model 세그먼트

리액트 쿼리 키, 옵션 팩터리인 query-service.ts, 데이터 모델을 위한 type.ts, 각 도메인에서 사용되는 로직들이 위치합니다. 데이터를 어떻게 캐싱하고 읽고 수정하는 등의 규칙을 정의하는 세그먼트라고 볼 수 있습니다.

query-service.ts

import {keepPreviousData} from '@tanstack/react-query'; import { getCategoryReviews, getKeywordReviews, getMyBookmarkedReviews, getMyReviews, getRecentReviews, } from '../apis/api-service'; import {Category} from '@/entities/review/model/type'; export const reviewsQueryKeys = { all: () => ['reviews'] as const, my: { all: () => [...reviewsQueryKeys.all(), 'my'] as const, page: (page: number) => [...reviewsQueryKeys.my.all(), page] as const, }, myBookmarks: { all: () => [...reviewsQueryKeys.all(), 'myBookmarks'] as const, page: (page: number) => [...reviewsQueryKeys.myBookmarks.all(), page] as const, }, ... }; export const reviewsQueryOptions = { my: (page: number) => ({ queryKey: reviewsQueryKeys.my.page(page), queryFn: () => getMyReviews(page), placeholderData: keepPreviousData, }), myBookmarks: (page: number) => ({ queryKey: reviewsQueryKeys.myBookmarks.page(page), queryFn: () => getMyBookmarkedReviews(page), placeholderData: keepPreviousData, }), ... };

이 파일은 리액트 쿼리를 사용하기 위한 키, 옵션을 팩터리 형태로 관리하는 파일로 제가 이전에 작성한 리액트 쿼리로 선언형 프로그래밍 달성하기 게시글의 부록1에 내용을 작성해 두었습니다.

커스텀 훅

query-service.ts 에 데이터 패칭과 캐싱 규칙 등을 중앙화하고, 각 도메인에서 필요한 데이터를 반환하는 리액트 쿼리 훅들을 만들어두면, features 레이어는 만들어진 훅을 사용해 데이터를 소비하기만 하면 됩니다.

// entities/reviews/model/useMyBookmarkedReviews.ts 'use client'; import {useEffect} from 'react'; import {useQueryClient, useSuspenseQuery} from '@tanstack/react-query'; import {reviewsQueryOptions} from './query-service'; export default function useMyBookmarkedReviews(page: number) { const {data} = useSuspenseQuery(reviewsQueryOptions.myBookmarks(page)); const queryClient = useQueryClient(); useEffect(() => { if (page < data.total_pages) { queryClient.prefetchQuery(reviewsQueryOptions.myBookmarks(page + 1)); } if (page > 1) { queryClient.prefetchQuery(reviewsQueryOptions.myBookmarks(page - 1)); } }, [page, data.total_pages, queryClient]); return data; }

예시로 useMyBookmarkedReviews 파일의 코드를 가져와봤는데요. query-service.ts 에서 정의해둔 옵션 팩터리를 사용하고, 현재 페이지의 앞, 뒤 페이지를 미리 요청하는 훅입니다.

// features/reviews/my/ui/MyBookmarkedReviews.tsx import {useMyBookmarkedReviews} from '@/entities/reviews'; export default function MyBookmarkedReviews({currentPage}: Props) { const {results, total_pages} = useMyBookmarkedReviews(currentPage); return ( // results로 화면을 구성하기. ) }

features 레이어의 MyBookmarkedReviews 컴포넌트는 내가 저장한 후기를 화면에 표시하는 UI 컴포넌트로 데이터 요청 로직은 신경쓰지 않고, 오직 반환된 데이터를 사용해 UI를 어떻게 표시할지에만 집중할 수 있게 됩니다.

consts 세그먼트

도메인 상수가 위치하는 세그먼트로 UI나 데이터, 기능에서 공통으로 사용되는 데이터가 위치합니다.

export const CATEGORY_LIST = [ {id: 'all', value: 'all', label: '전체'}, {id: 'food', value: 'food', label: '음식'}, {id: 'car', value: 'car', label: '자동차'}, {id: 'cosmetic', value: 'cosmetic', label: '화장품'}, {id: 'clothes', value: 'clothes', label: '의류'}, {id: 'device', value: 'device', label: '전자제품'}, {id: 'book', value: 'book', label: '책'}, {id: 'sports', value: 'sports', label: '운동용품'}, ] as const;

너무 뻔해서 예시 코드만 첨부하겠습니다.

shared 레이어

image.png

이제 마지막으로 shared 레이어입니다. shared 레이어는 어느 도메인에도 속하지 않으면서 모든 곳에서 재사용 가능한 로우 레벨의 유틸들을 다루는 레이어입니다.

shared 레이어의 이해를 위해 몇가지 세그먼트와 각 세그먼트의 파일을 하나씩 첨부해보겠습니다.

apis 세그먼트

image.png

먼저 apis 세그먼트는 크게 3가지 파일이 위치하고 있는데요. 우리 프로젝트의 HTTP 요청을 일관된 규칙으로 보내기 위해 API 요청을 모듈화한 세그먼트입니다.

api-service.ts

entities 레이어의 api-service.ts 와 이름은 동일하지만, 내부 구현 사항은 다른데요. 이 파일은 fetch 함수를 감싸 프로젝트 전역에서 일관된 방식으로 요청을 보내기 위한 래퍼 함수가 작성된 파일입니다.

function prepareRequest({ baseUrl = process.env.NEXT_PUBLIC_API_URL, endpoint, method, headers, body, queryParams, cacheOptions, }: RequestProps): { url: string; requestInit: RequestInitWithMethod; } { let url = `${baseUrl}${endpoint}`; if (queryParams) { const queryString = Object.entries(queryParams) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); url += `?${queryString}`; } const requestInit = createRequestInit({method, body, headers, cacheOptions}); return {url, requestInit}; } async function request<T>(props: WithErrorHandling<RequestProps>): Promise<T> { const {url, requestInit} = prepareRequest(props); let response: Response = await fetch(url, requestInit); if (response.status === 401) { const refreshResponse = await fetch(process.env.NEXT_PUBLIC_API_URL + '/token/refresh', { cache: 'no-cache', next: {revalidate: 0}, credentials: 'include', }); if (refreshResponse.ok) { response = await fetch(url, requestInit); } else { if (isBrowser()) { throw new RequestError({ name: 'TOKEN_EXPIRED', message: '로그인 세션이 만료되었습니다.', status: 401, endpoint: url, method: requestInit.method, requestBody: requestInit.body ? JSON.stringify(requestInit.body) : null, }); } } } if (!response.ok) { throw await handleRequestError({ response, body: requestInit.body ? JSON.stringify(requestInit.body) : null, requestInit, errorHandlingType: props.errorHandlingType, }); } if (props.withResponse) { return await response.json(); } return undefined as T; } export async function requestGet<T>({ headers = {}, errorHandlingType = 'errorBoundary', withResponse = true, ...args }: WithErrorHandling<RequestMethodProps>): Promise<T> { return request<T>({ ...args, method: 'GET', headers, withResponse, errorHandlingType, }); } ...

간단하게 코드의 일부만 가져와봤는데요. 이렇게 request 함수를 각 HTTP 요청별로 래핑해 외부에 제공하고, 각 레이어에선 여기서 만들어진 공통 함수를 사용하게 됩니다.

요청을 어떻게 보낼지, 인증 실패로 인해 요청이 거부된 경우 토큰을 재발행하는 등의 규칙은 shared에서 미리 규격화하고 무슨 데이터를 요청할지는 entities에서 결정합니다.

lib 세그먼트

image.png

전역 유틸리티 함수들과 공통 상수가 위치합니다. shared 레이어는 비즈니스 로직에 의존하지 않는 순수한 UI, 훅, 유틸리티, 상수들이 모이는 레이어입니다.

lib 세그먼트는 프로젝트 전역에서 공통적으로 사용되는 유틸리티 함수나 상수들이 모이는 세그먼트로 저는 consts, utils로 다시 구분했습니다.

consts

도메인마다 상수값을 관리하게 되면 중복되는 값이 생길 수 있는데요. 전역에서 재사용 가능한 메세지나 라우트는 shared 레이어의 lib/consts에서 관리하고 있습니다.

type ErrorMessage = Record<string, string>; export const SERVER_ERROR_MESSAGE: ErrorMessage = { // OAuth2 로그인 에러 - Next.js API Routes EMPTY_USER_EMAIL: '이메일 정보를 찾을 수 없어 로그아웃 됐어요. 다시 로그인해주세요.', EMPTY_USER_NICKNAME: '닉네임 정보를 찾을 수 없어 로그아웃 됐어요. 다시 로그인해주세요.', // 슬랙 메세지 전송 에러 SLACK_MESSAGE_SEND_ERROR: '슬랙 메세지 전송에 실패했어요. 다시 시도해주세요.', MISSING_REQUIRED_FIELDS: '이름, 이메일, 메시지 필드가 모두 필요해요.', SLACK_WEBHOOK_URL_NOT_SET: '슬랙 웹훅 URL이 설정되지 않았어요. 관리자에게 문의해주세요.', // 공통 FORBIDDEN: '접근 권한이 없어요. 관리자에게 문의해주세요.', UNAUTHORIZED: '로그인이 필요한 서비스에요. 다시 로그인해주세요.', USER_NOT_FOUND: '존재하지 않는 사용자입니다. 다시 로그인해주세요.', INTERNAL_SERVER_ERROR: '서버에서 알 수 없는 오류가 발생했어요. 잠시 후 다시 시도해주세요.', ... } as const; export const ERROR_MESSAGE = { // 로그인 관련 에러 코드 LOGIN_REQUIRED: '로그인이 필요한 서비스에요.', // 검색 API 에러 코드 INPUT_EMPTY: '검색어를 입력해주세요.', INPUT_TOO_SHORT: '검색어는 2글자 이상 입력해주세요.', INPUT_TOO_LONG: '검색어는 20글자 이하로 입력해주세요.', ... } as const;

이렇게 에러 메세지들을 한 곳에서 관리하면 에러 메세지의 표현 방식을 일관되게 관리할 수 있는데요. 여러 기능에서 메세지를 복사, 붙여넣기 할 필요 없이 필요한 메세지를 불러오기만 하면 됩니다. 또, 변경이 필요할 때 이 곳만 수정하면 모든 사용 지점에 반영됩니다.

utils

특정 페이지나 컴포넌트에서만 사용하지 않고 전역적으로 재사용 가능한 로직을 shared 레이어의 lib/utils에서 관리하고 있습니다.

// shared/lib/utils/isClientError.ts import {ERROR_MESSAGE} from '../consts/errorMessage'; import {ClientError} from './client-error'; function isClientError(error: Error): error is ClientError { return error instanceof ClientError && ERROR_MESSAGE[error.name] !== undefined; } export default isClientError; // shared/lib/utils/checkSession.ts import {cookies} from 'next/headers'; import {redirect} from 'next/navigation'; async function checkSession() { const cookieStore = await cookies(); if (!cookieStore.has('refreshToken')) { redirect('/'); } } export default checkSession;

이렇게 비즈니스 로직을 포함하지 않는 순수 로직만 담긴 함수들을 관리하고 있습니다. ‘리뷰 삭제 후 캐시 무효화’ 같은 로직은 entities에서 처리해야 합니다.

정리하자면

shared 레이어는 비즈니스 로직, 도메인으로부터 모두 독립된 성격의 코드가 위치해야 합니다. 상위 레이어를 참조하거나 어떤 도메인에 속하는 코드가 위치할 수 없습니다.

위의 apis, lib 세그먼트로 shared 레이어가 어디에도 속하지 않는 순수한 재사용 레이어라는 내용을 충분히 설명했다고 생각합니다.

다루지 않은 그 외 세그먼트도 모두 이 개념을 지키고 있는데요. ui 세그먼트의 경우 아바타, 뱃지, 로딩 스피너, 모달 등 도메인에 종속되지 않고 어디든 사용할 수 있는 UI 컴포넌트들이 위치하고 있습니다.

마무리

여기까지 제가 Next.js 환경에서 FSD를 어떻게 적용했는지 정리해봤는데요. 다시 한 번 각 레이어를 간단하게 정리해보자면 아래와 같습니다.

  • app: 어플리케이션 전역에 관련된 설정과 초기화를 담당하는 레이어.
  • views(pages): 실제로 사용자가 보게 되는 화면 단위를 정의하는 레이어.
  • widgets: 엔티티와 기능을 조합해 구성된 독립적인 UI가 위치하는 레이어.
  • features: 사용자와의 상호작용을 통해 실제로 기능을 수행하는 UI가 위치하는 레이어.
  • entities: 도메인별 데이터 모델, API 호출, 공통 UI 등 데이터가 중심이 되는 레이어.
  • shared: 도메인에 의존하지 않는 UI, 유틸, 상수, API 호출 규칙 등 프로젝트의 공통 기반이 위치하는 레이어.

이제 개인적인 의견을 작성해보자면, 제가 실제로 FSD를 적용하면서 코드를 찾기 위한 피로도가 크게 줄었습니다.

예전에 프로젝트 규모가 커질수록 ‘이 기능이랑 관련된 코드는 어디에 있지?’ 하면서 코드를 막 열어보면서 찾는데 정말 많은 시간이 소요됐는데요. (특히 초반에 작성한 코드는 기억이 가물가물해 더 오래 걸렸습니다.)

특정 도메인의 기능이 여러 폴더에 흩어져 있거나 변경에 따른 영향 범위도 파악하기 어려웠습니다.

하지만! FSD를 적용한 뒤로 도메인 단위로 기능이 모이기 때문에 특정 기능을 수정할 때 해당 도메인 폴더만 열어도 돼서 코드를 탐색하는 데 드는 비용이 확실히 줄었습니다.

또, 데이터를 다루는 레이어는 entities, 사용자 조작과 기능 실행은 features, 화면 조립은 views. 이렇게 책임이 명확해 어디를 수정할지 판단하기 쉬웠고 기능 확장 시 발생하는 사이드 이펙트를 최소화할 수 있었습니다.

단순히 폴더 구조만 나눈다고 생각하실 수도 있습니다. 하지만 구조를 나누기 위해 ‘이 기능은 무엇을 하는가?’, ‘이 데이터는 어디에 속하는가?’, ‘이 UI는 어떤 역할을 담당하는가?’ 같은 질문을 스스로에게 계속 던지게 됐는데요.

그 과정에서 기능의 책임이 명확해지고 코드가 의도에 따라 정돈되기 시작했습니다. FSD는 파일 위치를 나누기 위한 일종의 규칙이기도 했지만 프로젝트를 이해하고 확장하는 사고방식 자체를 키워줬는데 한 번 적용해보시는 거 어떠신가요? (^~^ 영업하는 거 맞습니다.)

긴 글 읽어주셔서 감사합니다…

..

뿅..

리액트 쿼리로 선언형 프로그래밍 달성하기

리액트 쿼리로 선언형 프로그래밍 달성하기