Notice
Recent Posts
Recent Comments
Link
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Archives
Today
Total
관리 메뉴

co-cherry

폴더 구조와 아키텍처: 프로젝트 구조 개선 본문

React

폴더 구조와 아키텍처: 프로젝트 구조 개선

co-cherry 2026. 5. 3. 22:48

나의 프로젝트 구조

React 프로젝트를 처음 시작할 때, 파일 구조 같은 건 신경 쓰지 않았다.

기능 구현에만 중점을 두고, '일단 되게 만들자'가 목표였으니까.

하지만 프로젝트가 커지면서 기존 파일들조차 찾기 힘들어지는 경우가 많았고,

기능을 한번 추가하려고 하면 components, hooks, utils, api 폴더를 전부 뒤져야 했다.

관련 파일이 여러 폴더에 흩어져 있으니 수정할 때마다 탐색기를 끝없이 스크롤 했다. 

팀 프로젝트를 해보면서 팀원마다 각자 본인만의 기준으로 파일을 배치해 팀원들의 코드를 찾기 힘들 때도 많았다. 

 

파일을 빨리 찾거나 추가할 수 있으며, 누가 보더라도 직관적으로 이해되는 프로젝트 구조를 만들 수 있을까? 

FSD(Feature-Sliced Design) Architecture 

프론트엔드 애플리케이션을 구조화하는 아키텍처 방법론 

애플리케이션을 기능 단위로 묶어 프로젝트를 더 이해하기 쉽고 구조적으로 만들어, 규모가 커져도 유지보수가 쉽다. 

src/
├── features/
│   ├── send-message/    // 메시지 보내기 기능만
│   ├── edit-message/    // 메시지 수정 기능만
│   └── user-search/     // 사용자 검색 기능만
├── entities/
│   ├── message/         // 메시지 엔티티
│   └── user/            // 유저 엔티티
└── shared/
    └── ui/              // 공통 UI

 

FSD의 3가지 핵심 구조 

FSD는 Layers(레이어) → Slices(슬라이스) → Segments(세그먼트) 3단계로 구성된다. 

프로젝트 형태로 보면 이와 같다. 

📂 features (레이어)
  📁 send-message (슬라이스)
    📁 ui (세그먼트)
      └── MessageInput.tsx
    📁 model (세그먼트)
      └── useSendMessage.ts
    📁 api (세그먼트)
      └── sendMessageApi.ts

 

Layers: 표준화된 7개 계층

현재 processes 레이어가 사용되지 않으므로 6개의 레이어만이 사용된다.

레이어 역할 예시
app 앱 실행에 필요한 전역 설정 라우터, 프로바이더, 전역 스타일
pages 라우트 페이지 HomePage, ArticlePage
widgets 페이지를 구성하는 큰 UI 블록 Header, Sidebar, Footer
features 사용자가 할 수 있는 행동/기능 좋아요 누르기, 댓글 작성, 로그인
entities 비즈니스 엔티티 User, Post, Comment, Message
shared 재사용 가능한 공통 코드 UI 컴포넌트, utils, API 클라이언트

 

Slices: 기능 단위 분리 

각 레이어 안에서 기능/도메인 별로 나눈 폴더 

features/              // 레이어
├── send-message/      // 슬라이스 1
├── edit-message/      // 슬라이스 2
└── delete-message/    // 슬라이스 3

entities/              // 레이어
├── user/              // 슬라이스 1
├── message/           // 슬라이스 2
└── chatroom/          // 슬라이스 3
  • 슬라이스 이름은 자유롭게 선택 가능
  • 같은 레이어의 슬라이스끼리 서로 참조 불가 

Segments: 기술적 분류 

슬라이스 안에서 코드의 목적별로 나눈 폴더

세그먼트 용도 예시
ui/ React 컴포넌트, 스타일 MessageInput.tsx, Button.module.css
model/ 상태 관리, 비즈니스 로직, 타입 useMessageStore.ts, types.ts
api/ 서버 통신 함수 fetchMessages.ts, sendMessage.ts
lib/ 슬라이스 내부 헬퍼 함수 formatDate.ts, validate.ts
config/ 설정, 상수  constants.ts, endpoints.ts
features/send-message/
├── ui/
│   └── MessageInput.tsx       // 메시지 입력 컴포넌트
├── model/
│   ├── useSendMessage.ts      // 메시지 전송 로직
│   └── types.ts               // 타입 정의
├── api/
│   └── sendMessageApi.ts      // API 호출
└── index.ts                   // Public API

 

 

의존성 방향 규칙(단방향 의존성): 각 레이어는 자기보다 아래에 있는 레이어만 참조(import) 할 수 있다. 

  • 하위 레이어는 재사용 가능하게 독립적으로 유지
  • 순환 참조(Circular Dependency) 방지 
app
 ↓ (참조 가능)
pages
 ↓
widgets
 ↓
features
 ↓
entities
 ↓
shared

 

Public API 패턴: index.ts 의 역할 

각 모듈에 외부에 공개할 인터페이스를 명시적으로 정의하는 패턴

외부에서 모듈을 사용할 때는 내부 경로를 직접 참조하지 않고 Public API를 통해서만 접근한다.

 

FSD에서는 일반적으로 각 슬라이스나 세그먼트의 최상위에 index.ts 파일을 두어 이 역할을 수행한다. 

*index.ts는 폴더의 대표 파일로 폴더 import 시, 자동으로 불러와지므로 index.ts에서 export 한 것만 외부에서 사용 가능 

features/
├── send-message/
│   ├── ui/MessageInput.tsx
│   ├── model/useSendMessage.ts
│   ├── lib/validate.ts
│   └── index.ts              // 슬라이스 레벨
└── edit-message/
    ├── ui/EditModal.tsx
    └── index.ts              // 슬라이스 레벨
// features/send-message/index.ts
export { MessageInput } from './ui/MessageInput';
export { useSendMessage } from './model/useSendMessage';
export type { SendMessageParams } from './model/types';

 

JS/TS에서는 폴더를 import하면 자동으로 그 폴더의 index.ts를 찾아서 실행하므로, 사용할 때는 아래와 같이 적어주면 된다. 

import { MessageInput, useSendMessage } from '@/features/send-message';

 

만약, 슬라이스가 없는 shared나 app 레이어는 각 세그먼트마다 index.ts를 두는 방식을 사용한다. 

 

장점

  • 내부 구조 자유롭게 변경 가능
  • 외부에서 뭘 써야 하는지(공개할 것만) 설정 가능 
  • 번들 사이즈 최적화 

 

실제 프로젝트에 적용해보기 

 

현재 React 스터디 과제로 진행 중인 프로젝트에 FSD를 적용해보면서, 실제로 어떻게 달라지는지 확인해보려 한다.

 

변경 전 

 

적용 전, 그냥 page 따로, hook 따로 api 따로 대충 비슷한 기능을 하는 애들끼리 묶어서 만든 구조이다. 

파일이 적어서 찾기 쉽지만 파일이 늘어나고 프로젝트 구조가 커진다면 원하는 파일 찾는 데만 한 세월이 걸릴 것이다. 

 

변경 후

src/
├── main.tsx
├── assets/
│
├── app/
│   ├── providers/index.tsx        (QueryClient, ErrorBoundary)
│   ├── store/index.ts             (Redux store 조립)
│   ├── router/index.tsx           (라우트 정의)
│   └── styles/index.css
│
├── pages/
│   ├── main/
│   ├── redux-demo/
│   ├── zustand-demo/
│   ├── context-api-demo/
│   └── tanstack-query-demo/
│
├── features/
│   ├── todo-manager/
│   ├── product-filter/
│   ├── settings-panel/
│   └── movie-browser/
│       ├── model/
│       │   ├── movieKeys.ts
│       │   ├── useGetMovies.ts
│       │   ├── useGetInfiniteMovies.ts
│       │   └── useGetMovieDetail.ts
│       └── ui/
│           ├── MovieDetailModal.tsx
│           └── ModalSkeleton.tsx   
│
├── entities/
│   └── movie/
│       ├── api/
│       │   └── moviesApi.ts       
│       ├── model/
│       │   └── types.ts            ← 실제 타입 정의
│       ├── ui/
│       │   └── MovieCard.tsx
│       └── index.ts
│
└── shared/
    ├── api/
    │   ├── instance.ts             ← HTTP 인프라만
    │   ├── errors.ts
    │   └── index.ts
    └── ui/
        └── ModalError/

 

변경 전과 후 비교를 간단히 설명해보자면, 

1. main.tsx가 얇아졌다

변경 전, main.tsx에는 QueryClient 생성, GlobalError 컴포넌트, ErrorBoundary 설정이 모두 뭉쳐 있었다. 

변경 후, main.tsx는 진입점 역할만 하고, 이런 전역 설정들은 app/ 레이어로 분리해 역할을 명확하게 나눴다. 

2. Redux store 조립이 app 레이어로 이동했다

변경 전, src/store/store.tstodoSlice를 직접 가져와서 store를 구성했다.

변경 후, Redux store는 전체 앱에 영향을 주는 전역 설정이므로 app/ 레이어로 옮겼다.

slice 로직은 각 feature 슬라이스에 두고, store는 feature들의 reducer를 가져와 조립만 한다.

// app/store/index.ts
import { todoReducer } from '@/features/todo-manager'

export const store = configureStore({
  reducer: { todo: todoReducer },
})

3. 관련 코드가 한 곳에 모였다

변경 전에는 영화 기능 하나를 수정하려면 이 모든 폴더들을 모두 뒤져야 했다.

src/components/MovieDetailModal.tsx
src/hooks/queries/useGetInfiniteMovies.ts
src/hooks/queries/movieKeys.ts
src/api/movies.ts
src/types/movie.ts

 

변경 후에는 features/movie-browser/ 한 곳에서 시작하면 된다. 

기능을 수정하거나 삭제할 때, 어느 파일을 봐야 하는지 고민할 필요가 없다. 

src/features/movie-browser/
├── model/
│   ├── movieKeys.ts
│   ├── useGetMovies.ts
│   ├── useGetInfiniteMovies.ts
│   └── useGetMovieDetail.ts
└── ui/
    └── MovieDetailModal.tsx
    └── ModalSkeleton.tsx

4. index.ts가 문지기 역할을 한다 (Public API)

변경 전에는 내부 경로를 직접 참조해 파일 이동 시 모든 import가 깨지는 문제가 발생했다.

import { useGetMovieDetail } from '../hooks/queries/useGetMovieDetail'
import MovieCard from '../components/MovieCard'

 

변경 후에는 외부에서는 항상 index.ts를 통해서만 접근한다. 

내부 구조를 아무리 바꿔도 index.ts의 export만 유지하면 외부 코드는 전혀 영향받지 않는다.

import { MovieDetailModal, useInfiniteNowPlayingMovies } from '@/features/movie-browser'
import { MovieCard } from '@/entities/movie'

+) shared/api/ 에는 인프라만 둔다

shared는 가장 아래 레이어이므로 다른 레이어를 참조할 수 없다.

따라서 shared/api/에는 HTTP 요청의 기반이 되는 인프라 코드만 둔다. 

// shared/api/instance.ts
export const tmdbFetch = (endpoint: string) =>
  fetch(`${BASE_URL}${endpoint}`, { headers })

export const handleResponse = async (res: Response) => { ... }

 

영화 API 함수와 타입은 영화 도메인 코드이므로 entities/movie/가 담당한다.

entities가 shared를 참조하는 방향은 허용된 방향이므로 규칙을 지키면서 자연스럽게 분리할 수 있다.

// entities/movie/api/moviesApi.ts
import { tmdbFetch, handleResponse } from '@/shared/api/instance'

 

https://feature-sliced.design/docs/get-started/overview#incremental-adoption

 

Overview | Feature-Sliced Design

Feature-Sliced Design (FSD) is an architectural methodology for scaffolding front-end applications. Simply put, it's a compilation of rules and conventions on organizing code. The main purpose of this methodology is to make the project more understandable

feature-sliced.design

 

https://velog.io/@clydehan/FSDFeature-Sliced-Design-%EC%99%84%EB%B2%BD-%EA%B0%80%EC%9D%B4%EB%93%9C

 

FSD(Feature-Sliced Design) 완벽 가이드

프론트엔드 뜨거운 감자, FSD: 이 글만 읽으면 완벽하게 이해 할 수 있음.

velog.io