Framework/Nextjs

FSD 아키텍처로 대규모 Next.js 프로젝트 구조 설계하기: 실무 적용 가이드

Joonfluence 2025. 8. 24.

요약

Feature-Sliced Design(FSD)은 대규모 프론트엔드 애플리케이션의 구조를 체계적으로 관리하기 위한 아키텍처 방법론입니다. 전통적인 기능별 폴더 구조나 Atomic Design의 한계를 극복하고, 비즈니스 로직의 응집도를 높이면서 계층 간의 의존성을 명확히 관리할 수 있습니다. Next.js App Router와 결합했을 때, Server Components와 Client Components의 역할을 명확히 분리하고, 코드 재사용성과 유지보수성을 극대화할 수 있는 현대적인 프론트엔드 아키텍처 패턴입니다.

핵심 기능/개념 정리

FSD 계층 구조

계층명 역할 의존성 방향 Next.js 적용 예시
app 전역 설정, 프로바이더 모든 계층 의존 layout.tsx, providers.tsx
pages 라우팅, 페이지 컴포지션 widgets, features 의존 App Router page.tsx
widgets 페이지 섹션 단위 UI features, entities 의존 Header, ProductList, Footer
features 사용자 기능, 비즈니스 로직 entities, shared 의존 로그인, 장바구니, 결제
entities 비즈니스 엔티티, 도메인 모델 shared만 의존 User, Product, Order
shared 공통 코드, 유틸리티 외부 라이브러리만 의존 UI Kit, API Client, Utils

슬라이스 내부 세그먼트 구조

슬라이스(Slice)란 각 레이어 내에서 비즈니스 엔티티별로 구분 한 것을 말합니다.

  • 사진 갤러리: photo/, album/, gallery/
  • 소셜 네트워크: post/, user/, newsfeed/
  • 이커머스: product/, cart/, order/

세그먼트(Segment)란 목적별로 코드 분할한 것을 말합니다.

feature-slice/
├── ui/           # React 컴포넌트
├── model/        # 상태 관리, 훅, 비즈니스 로직
├── api/          # API 호출 로직
├── lib/          # 유틸리티 함수
├── config/       # 설정값, 상수
└── index.ts      # Public API 정의

핵심 원칙 : 계층 구조

app       ⬆️ 상위 레이어
pages     |
widgets   |
features  |
entities  |
shared    ⬇️ 하위 레이어

FSD의 장점

  • 유연성: 컴포넌트 쉬운 교체/추가/제거
  • 표준화: 일관된 아키텍처 구조
  • 확장성: 프로젝트 규모에 따른 확장 용이
  • 스택 독립성: 기술 스택에 무관하게 적용 가능
  • 비즈니스 지향: 도메인 중심의 구조
  • 제어된 결합: 예측 가능한 모듈 간 연결

FSD의 단점

  • 높은 진입 장벽: 학습 곡선 존재
  • 팀 문화: 전체 팀의 개념 이해 필요
  • 즉시 해결: 문제를 미루지 않고 바로 해결해야 함

사용 예시 및 코드 스니펫

실제 이커머스 프로젝트 구조 예시

app/
├── entities/
│   ├── product/
│   │   ├── model/
│   │   │   ├── types.ts          # Product 타입 정의
│   │   │   └── hooks.ts          # 상품 관련 훅
│   │   ├── ui/
│   │   │   ├── product-card.tsx  # 상품 카드 컴포넌트
│   │   │   └── product-image.tsx # 상품 이미지 컴포넌트
│   │   └── index.ts              # Public API
│   └── user/
│       ├── model/types.ts        # User 타입 정의
│       └── api/customer-api.ts   # 고객 API 호출
├── features/
│   ├── auth/
│   │   ├── model/
│   │   │   ├── hooks/
│   │   │   │   ├── use-login.ts      # 로그인 훅
│   │   │   │   └── use-logout.ts     # 로그아웃 훅
│   │   │   └── types.ts              # Auth 타입
│   │   ├── ui/
│   │   │   ├── login-form.tsx        # 로그인 폼
│   │   │   └── kakao-login-button.tsx # 카카오 로그인
│   │   └── api/auth-service.ts       # 인증 API
│   └── cart/
│       ├── model/hooks/use-cart.ts      # 장바구니 훅
│       ├── ui/add-to-cart-button.tsx    # 장바구니 추가 버튼
│       └── api/cart-api.ts              # 장바구니 API
├── widgets/
│   ├── header/
│   │   ├── ui/header.tsx             # 헤더 컴포넌트
│   │   └── model/use-navigation.ts   # 네비게이션 로직
│   └── product-list/
│       ├── ui/product-grid.tsx       # 상품 그리드
│       └── model/use-product-list.ts # 상품 목록 로직
├── shared/
│   ├── ui/
│   │   ├── button/button.tsx         # 공통 버튼
│   │   ├── input/input.tsx           # 공통 인풋
│   │   └── modal/modal.tsx           # 공통 모달
│   ├── api/
│   │   ├── client.ts                 # API 클라이언트
│   │   └── graphql-client.ts         # GraphQL 클라이언트
│   ├── lib/
│   │   ├── utils.ts                  # 유틸 함수
│   │   └── validation.ts             # 검증 로직
│   └── constants/
│       ├── query-keys.ts             # TanStack Query 키
│       └── routes.ts                 # 라우트 상수
└── (routes)/
    ├── page.tsx                      # 홈페이지
    ├── products/page.tsx             # 상품 목록 페이지
    └── cart/page.tsx                 # 장바구니 페이지

Features 계층 구현 예시

// app/features/auth/model/hooks/use-logout.ts
'use client'

import { useLogoutMutation } from 'app/gql/hooks'
import { QUERY_KEYS } from 'app/shared/constants/query-keys'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { removeAuthToken, removeCartId } from 'app/shared/lib/auth-utils'

export function useLogout() {
  const queryClient = useQueryClient()
  const router = useRouter()

  const logoutMutation = useLogoutMutation({
    onSuccess: () => {
      removeAuthToken()
      removeCartId()
      queryClient.invalidateQueries({ 
        queryKey: [QUERY_KEYS.USER_ATTRIBUTES] 
      })
      router.push('/')
    },
    onError: (error) => {
      console.error('로그아웃 실패:', error)
    },
  })

  return {
    logout: logoutMutation.mutateAsync,
    isLoggingOut: logoutMutation.isPending,
  }
}

Entities 계층 구현 예시

// app/entities/product/model/types.ts
export interface Product {
  id: string
  name: string
  price: number
  imageUrl: string
  categoryId: string
  availability: 'Available' | 'Preorder' | 'Disabled'
}

export interface ProductCard {
  product: Product
  onAddToCart?: (productId: string) => void
  showQuickView?: boolean
}

// app/entities/product/ui/product-card.tsx
import { Product } from '../model/types'
import { Button } from 'app/shared/ui/button'

interface ProductCardProps {
  product: Product
  onAddToCart?: (productId: string) => void
}

export function ProductCard({ product, onAddToCart }: ProductCardProps) {
  return (
    <div className="border rounded-lg overflow-hidden shadow-sm">
      <img 
        src={product.imageUrl} 
        alt={product.name}
        className="w-full h-48 object-cover"
      />
      <div className="p-4">
        <h3 className="font-semibold text-lg">{product.name}</h3>
        <p className="text-gray-600 text-xl font-bold">
          {product.price.toLocaleString()}원
        </p>
        {onAddToCart && (
          <Button 
            onClick={() => onAddToCart(product.id)}
            className="mt-3 w-full"
          >
            장바구니 추가
          </Button>
        )}
      </div>
    </div>
  )
}

Widgets 계층 구현 예시

// app/widgets/product-list/ui/product-grid.tsx
import { ProductCard } from 'app/entities/product'
import { useAddToCart } from 'app/features/cart'
import { Product } from 'app/entities/product/model/types'

interface ProductGridProps {
  products: Product[]
  title?: string
}

export function ProductGrid({ products, title }: ProductGridProps) {
  const { addToCart } = useAddToCart()

  return (
    <section className="py-8">
      {title && (
        <h2 className="text-2xl font-bold mb-6">{title}</h2>
      )}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
        {products.map((product) => (
          <ProductCard
            key={product.id}
            product={product}
            onAddToCart={addToCart}
          />
        ))}
      </div>
    </section>
  )
}

실제 사용 시 주의점 / Best Practice

1. 의존성 방향 준수

  • 상위 계층만 하위 계층을 import: features에서 entities를 사용할 수 있지만, 그 반대는 불가
  • 같은 계층 내 교차 의존성 금지: features/auth에서 features/cart를 직접 import하지 않음
  • Shared 계층 의존성 관리: 모든 계층에서 shared를 사용할 수 있지만, shared는 외부 라이브러리만 의존

2. Public API 활용

// app/entities/product/index.ts
export { ProductCard } from './ui/product-card'
export { useProductData } from './model/hooks'
export type { Product, ProductCard } from './model/types'

// 다른 계층에서 사용
import { ProductCard, useProductData } from 'app/entities/product'

3. Next.js App Router와의 통합

// app/(routes)/products/page.tsx
import { ProductGrid } from 'app/widgets/product-list'
import { getProducts } from 'app/entities/product/api'

// Server Component에서 초기 데이터 로딩
export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <main className="container mx-auto px-4">
      <ProductGrid products={products} title="전체 상품" />
    </main>
  )
}

4. TanStack Query와의 조화

// app/shared/constants/query-keys.ts
export const QUERY_KEYS = {
  PRODUCTS: 'products',
  USER_ATTRIBUTES: 'user-attributes',
  CART: 'cart',
} as const

// app/entities/product/model/hooks/use-products.ts
import { useQuery } from '@tanstack/react-query'
import { QUERY_KEYS } from 'app/shared/constants'
import { fetchProducts } from '../api/product-api'

export function useProducts() {
  return useQuery({
    queryKey: [QUERY_KEYS.PRODUCTS],
    queryFn: fetchProducts,
  })
}

5. 타입 안전성 보장

// app/shared/types/global.ts
export interface ApiResponse<T> {
  data: T
  message: string
  success: boolean
}

// Entity 타입에서 활용
export interface Product {
  id: string
  name: string
  price: number
}

export type ProductsResponse = ApiResponse<Product[]>

6. 코드 재사용성 극대화

  • UI 컴포넌트 분리: 비즈니스 로직과 UI 로직을 분리하여 재사용성 향상
  • Custom Hook 활용: 상태 관리 로직을 훅으로 분리하여 여러 컴포넌트에서 활용
  • 유틸리티 함수 공유: shared/lib에 공통 로직을 모아 중복 제거

참고자료 / 공식 문서 출처

공식 문서

아티클

GitHub 예시 프로젝트

반응형

댓글