더 좋은 폴더 구조 만들기 : FSD 철학에 Deep dive

진밥·2026년 2월 19일
post-thumbnail

🪄 TL;DR

  • FSD의 핵심 철학은 co-location + 의존성 방향 통제변경 영향 범위를 줄이는 것이다.
  • 공식 문서의 전체 구조를 옮겨와 도입하는 것보다, features를 중심으로 최소 조합 폴더를 만든 후 필요에 따라 확장하는 방식이 현실적인 선택이 될 수 있다.
  • shared를 쓰레기통으로 쓰지 않으려면 정확한 기준을 명시하고, feature-local shared로 먼저 해결하는 게 좋다.
  • public API는 경계를 지킬 곳에서만 사용한다. 전체에 적용하면 운영 비용이 늘어난다.

들어가며

우선, 이 글은 실무에서 직접 FSD를 운영해본 경험에 대한 글이 아니다. 주니어 개발자로서 사이드 프로젝트에 적용하며 겪은 시행착오, 주변 실무자들에게 들은 경험담, 공개 글/공식 문서에서 읽은 규칙들을 바탕으로 정리해본 글이다. 정답을 정의하는 것보다 내 프로젝트에 맞게 어떻게 어디까지 가져올지 판단하는 기준을 고민하며 기록해보았다. FSD 활용에 관한 하나의 의견으로 봐주었으면 좋겠고, 틀린 부분에 대한 피드백은 언제든 환영이다.

결론부터 말하면, FSD를 풀세트로 도입하는 게 아닌 FSD의 핵심 철학을 가져와 프로젝트에 맞게 축소 적용하는 법을 위주로 정리해보려고 한다.

왜 폴더 구조가 문제가 되었나

리액트 스타터킷에서 흔히 보는 구조는 components / hooks / utils / styles 같은 역할별 분류이다. 이 구조는 파일 성격만 보고 폴더를 정하면 되기 때문에, 작은 규모에서는 만들기 쉽고 탐색도 빠르다.

문제는 프로젝트가 커질 때부터다. “훅”이라는 형태보다 “어느 기능에 속하는지”가 더 중요해진다. 예를 들어 hooks/ 폴더 안에 로그인/뉴스/북마크 훅이 한데 섞이면, 기능 하나를 수정할 때 관련 코드를 찾기 위해 폴더를 여러 번 횡단하게 된다. 이때부터는 도메인(무엇을 다루는가), 유즈케이스(사용자가 무엇을 하는가), 데이터 흐름(어디서 오고 어디로 가는가) 같은 관점이 얽히면서 구조가 더 빨리 복잡해진다.

그 결과 아래와 같은 증상이 나타난다.

  • 기능 하나를 고치기 위해 여러 폴더를 횡단해야 하며, 탐색 비용이 증가한다.
  • 경로가 ../../../..처럼 길어지며, 폴더 구조를 바꾸는 일이 더 부담스러워진다.
  • 이 코드가 어디에 속하는지에 대해 폴더만 보고 판단하기 어려워진다. 결국 파일을 열어봐야 한다.

단순히 폴더를 간결하게 정리하는 것보다, 기능 단위로 변경 영향 범위를 줄이는 구조의 필요성을 느끼게 되었다.

FSD의 핵심 규칙

FSD(Feature-Sliced Design)는 프론트엔드 애플리케이션을 Layers / Slices / Segments로 구조화하는 방법론이다. 더 자세한 내용은 FSD 공식 가이드에 잘 나와있고, 이 글에서는 FSD의 핵심이 되는 두 가지 제약을 정리해보려고 한다.

1. co-location: 관련 파일을 한곳에 모으는 배치

co-location은 기능 구현에 필요한 UI/상태/요청/유틸을 가깝게 두어, 기능 단위 탐색을 쉽게 만드는 배치 전략이다. 역할별 폴더에서는 UI는 components, 요청은 api, 상태는 store와 같이 구조가 흩어지게 된다. 반면 co-location에서는 기능 폴더 안에서 대부분의 코드 탐색이 끝나게 만든다.

예를 들면 로그인 기능이라면 아래처럼 배치를 구성하는 것이다.

📂 features
   └─ 📂 login
      ├─ 📂 api
      ├─ 📂 lib
      ├─ 📂 model
      └─ 📂 ui

로그인 기능에 대한 수정이 필요할 때, 확인해야 하는 위치가 명확해진다. 이 단순함이 생각보다 강력하게 탐색 비용을 줄여준다.

2. 단방향 의존성: import 방향을 강제로 통제한다

FSD는 상위 레이어 → 하위 레이어로만 의존 가능하다는 단방향 의존성 규칙이 있다. 여기서 더 중요한 포인트는, 같은 레이어 안에서도 슬라이스끼리 내부 파일을 직접 import하지 못하게 막는 것이다.

A 기능이 B 기능의 내부 파일을 직접 import하지 못하게 하면,

  • 의존 방향이 강제된다.
  • 리팩토링 시 영향 범위가 경계에서 멈춘다.
  • “어디까지 고쳐야 하는지” 예측 가능성이 올라간다.

이 제약이 실제로 높은 응집도낮은 결합도를 만들게 된다.

예를 들어 features/news가 features/stock 내부 파일을 직접 import할 수 없게 하면, stock의 폴더 구조를 바꾸거나 내부 구현을 갈아엎어도 ‘외부에서 접근하는 공개된 API(예: index.ts)’만 유지하면 된다. 결과적으로 변경은 한 슬라이스 경계에서 멈추고, 영향 범위가 예측 가능해지게 된다.

공식 Layers 구조는 참고만 하자

FSD의 정석 레이어는 App / Pages / Widgets / Features / Entities / Shared으로 구성된다.

Layer역할메모
App엔트리/라우팅/프로바이더/전역 설정대부분 유지
Pages라우트 단위 화면features로 흡수되기도 함
Widgets화면을 구성하는 큰 UI 블록기준이 흔들리기 쉬워서 종종 생략
Features사용자 행동/유즈케이스(동사)가장 핵심이 되는 레이어
Entities도메인 모델(명사)도메인 모델이 커질 때 필요
Shared공용 UI/유틸/리소스망가지기 쉬움

최소 구조로 FSD를 적용할 때 사용하는 건, App과 Features 레이어이다. Pages, Widgets, Entities는 팀마다 다르게 쓰거나 덜 쓰는 경우가 많다.

어떻게 폴더를 구성하여 사용하는지에 따라 레이어가 다른 레이어의 하위로 갈수도, 생략할 수도, 추가될 수도 있다. 중요한 건, FSD를 사용하는 데 정답은 없다는 것이다. 팀과 프로젝트의 성격과 목적에 맞게 판단하여 유연하게 사용하는 것이 가장 중요하다.

현실적인 FSD 도입

FSD를 풀세트로 도입하면 구조는 일관되지만, 컨벤션 비용이 급격히 올라간다. 특히 팀 규모가 크지 않거나, 합의가 어렵거나, MVP 속도가 중요한 환경에서는 도입 비용이 바로 병목이 된다. 그래서 현업에서는 축소형이 자주 등장한다. 내가 사이드 프로젝트를 진행하며 가장 현실적인 시작점으로 차용한 방법은 features 위주의 최소 조합 방식이었다.

features-only + app + shared (최소 조합)

가장 현실적인 시작점이다. 기능 단위로 co-location을 확보하고, 나머지 레이어는 필요해질 때 강화한다. 예시는 다음과 같다.

📂 src
├─ 📂 app
│  ├─ 📂 providers
│  ├─ 📂 router
│  └─ 📂 styles
├─ 📂 features
│  ├─ 📂 news
│  │  ├─ 📂 api
│  │  ├─ 📂 lib
│  │  ├─ 📂 model
│  │  └─ 📂 ui
│  └─ 📂 stock
│     ├─ 📂 api
│     ├─ 📂 model
│     └─ 📂 ui
└─ 📂 shared
   ├─ 📂 assets
   ├─ 📂 lib
   └─ 📂 ui

이 구조의 장점은 탐색 비용이 낮고, 기능 단위로 변경 영향 범위를 예측하기 쉽다는 점이다. 단점은 도메인 모델이 커지면(예: User/Ticker 규칙이 여러 기능에 흩어짐) 중복이 생기기 시작하며, 이 시점부터 entities 레이어가 필요해질 수 있다는 점이다.

해당 방식을 출발점으로 삼아 프로젝트가 커질 수록 상황에 맞게 확장하며 규칙을 강화해야 한다.

shared가 쓰레기통이 되는 이유와 막기 위한 규칙

shared는 어디서든 참조 가능하다는 성질 때문에 기준 없이 여러 파일들이 들어가는 쓰레기통이 되기도 한다. 애매한 파일이 몽땅 들어간 복잡한 폴더가 되어 도메인 의미가 섞이고 경계(boundary)가 무너지면서 변경 영향 범위가 넓어지게 되는 것이다.

이러한 shared 쓰레기통 현상을 막기 위해서, shared 사용 규칙을 정의해보았다.

1. shared에 넣을 수 있는 기준을 명시한다

다음 조건을 모두 만족할 때만 shared를 허용하는 방식이다.

  • 두 개 이상의 feature에서 재사용된다.
  • 도메인 의미를 가지지 않는다. (뉴스/종목 같은 의미가 드러나면 shared 금지)
  • UI/유틸 성격이다. (버튼, 모달 셸, 공통 포맷 함수 등)

실제 예시를 들자면 아래와 같다.

  • shared/lib/date.ts의 formatDate()는 가능하지만, getNewsCategoryColor()처럼 뉴스 도메인에 종속된 로직은 features/news에 둔다.
  • ModalShell은 shared를 허용하지만, 뉴스 내용을 담는 모달은 features/news에 있어야 한다.
    공용은 껍데기까지만 공용으로 두는 것이 안전하다.

2. shared로 올리기 전에 feature-local shared로 먼저 해결한다

공용처럼 보이는 코드가 사실은 특정 기능 내부에서만 재사용되는 경우가 많다. 이 경우 shared로 올리기 전에 기능 내부에서 먼저 해결한다.

📂 features
   └─ 📂 news
      ├─ 📂 shared
      ├─ 📂 ui
      └─ 📂 model

이 방식은 shared로 새는 코드를 줄이고, 도메인 결합을 유지한다.

public API는 장점만큼 운영 비용도 있다

public API는 보통 index.ts를 의미한다. 외부로 노출할 것만 export해 경계를 만들고 내부 구현을 감추는 방식이다.

장점은 명확하다.

  • 경계가 고정되면 내부 변경이 외부로 새지 않는다.
  • import 경로가 안정된다.
  • 변경 영향 범위가 줄어든다.

다만 작은 프로젝트에서 모든 slice마다 index.ts를 만들면 오히려 운영 비용이 늘어날 수 있다. export를 계속 유지·정리해야 하고, 경계가 아직 안정되지 않은 시기에 API를 먼저 굳히면 변경 속도가 떨어질 수 있다.

내가 선택한 타협

  1. 경계가 필요한 곳만 public API를 둔다.
    예: toast/modal 같은 전역 사용 기능, 여러 feature가 의존할 수밖에 없는 모듈

  2. shared는 shared/index.ts만 노출하고 내부 폴더 직접 import를 금지해서, shared가 외부에 드러나는 범위(=public API surface)를 작게 유지한다.

public API는 만능이 아니라, 경계를 운영할 의지가 있을 때만 효과가 나는 장치이다.

프로젝트 적용기: Decision log

직접 적용한 구조보다, 판단에 대한 로그를 작성해보았다.

Decision 1

  • Decision: NewsModal은 widgets가 아니라 features/news/ui에 둔다
  • Why: 도메인 콘텐츠를 담는 모달은 공용 UI가 아니라 뉴스 기능의 일부이기 때문이다(co-location을 유지한다)
  • Trade-off: 다른 기능에서도 모달이 필요해지면 콘텐츠가 아닌 ModalShell만 shared로 분리한다

Decision 2

  • Decision: Bookmark는 entities가 아니라 features에 둔다
  • Why: 북마크는 데이터 그 자체보다 “저장/해제”라는 유즈케이스(동사)에 가깝기 때문이다
  • Trade-off: 북마크 모델이 여러 기능에 걸쳐 커지면 entities로 승격하는 시점을 잡는다

Decision 3

  • Decision: User/Ticker를 entities로 바로 올리지 않고, 초기에 features 내부에서 시작한다
  • Why: 아직 공통 도메인 모델로 확정되지 않은 상태에서 entities를 만들면, 구조는 깔끔해 보여도 변경 비용이 커질 수 있기 때문이다
  • Trade-off: 동일 모델이 두 개 이상의 feature에서 중복되기 시작하면 entities로 올린다(승격 트리거)

실무에서 사용하는 features-only

주변 실무자에게 들은 이야기를 종합하면, 아래 같은 환경에서는 풀세트보다 features 중심 축소형이 선택되는 경우가 많다고 한다.

  • 빠른 MVP가 필요한 상황
  • 팀 합의/온보딩 비용을 낮춰야 하는 상황
  • 컨벤션 비용이 곧 병목이 되는 상황

지금 시점에 감당 가능한 규칙의 무게에 따라 가능한 만큼의 FSD 방식을 우선 도입하는 것이다. FSD의 핵심 철학인 co-location에 따라 features를 메인으로 나누어 폴더를 구성한다. 그리고 필수 규칙부터 시작한 후, 단계적으로 필요에 따라 강화해나가는 전략이다.

마무리

다음 프로젝트에서 FSD를 어떻게 사용하면 좋을지에 대해 체크리스트 형태로 정리해보았다.

  • 우리 팀은 지금 속도(MVP)가 우선인지, 확장성이 우선인지 판단한다
  • 간소화된 features-only 방식으로 시작할지, FSD 가이드의 기준을 따라 전체적으로 구성할지 결정한다
  • shared 허용 기준을 합의한다 (재사용 + 도메인 없음 + UI/유틸)
  • public API는 “필요한 모듈에만” 둘지 범위를 정한다
  • entities 승격 트리거를 정한다(같은 모델이 2개 이상 feature에서 중복되면 승격)

결론은 단순하다. 풀세트 FSD가 아니라 우리 팀이 감당 가능한 규칙의 무게로 시작한다. features-only로 출발하고, shared가 커지기 시작하면 규칙을 강화한다.

참고 자료

profile
꼬들밥 말고 진밥

0개의 댓글