
오늘 시간을 갈아넣어서 기존의 프론트엔드 폴더 구조를 전부 개선하는 작업을 했습니다.
프로젝트 중간에 폴더 구조를 싹다 바꾸는 게 굉장히 부담스럽고 시간이 많이 드는 작업이지만, 과감히 결정하게 된 과정을 공유드리겠습니다
기존 프로젝트는 역할 중심으로 폴더를 나누는 방식으로 구성되어 있었습니다. UI 컴포넌트는 components/, 페이지는 pages/, 전역 상태는 stores/, 타입은 customTypes/ 등 관심사의 분리에 초점을 둔 구조였습니다.
📦 src
├── 📁 api // API 호출 함수들
├── 📁 assets // 정적 리소스 (이미지, 아이콘 등)
├── 📁 components // 페이지에 공통적으로 사용되는 UI 컴포넌트
│ ├── 📁 common // Header, Router 등 공용 컴포넌트
│ ├── 📁 homePage // 홈 페이지 전용 컴포넌트
│ ├── 📁 loginPage // 로그인 페이지 전용 컴포넌트
│ └── 📁 practicePage // 실습 페이지 전용 컴포넌트
├── 📁 constants // 상수 정의
├── 📁 customTypes // 타입스크립트 타입 정의
├── 📁 hooks // 커스텀 훅 정의
├── 📁 layouts // 공용 레이아웃 컴포넌트
├── 📁 mocks // 테스트용 목 데이터
├── 📁 pages // 라우팅 단위의 실제 페이지 컴포넌트
│ ├── 📁 homePage
│ ├── 📁 loginPage
│ ├── 📁 myPage
│ ├── 📁 pairPracticePage
│ ├── 📁 practicePage
│ └── 📁 soloPracticePage
├── 📁 stores // Zustand 기반 전역 상태 관리
├── 📄 App.tsx // 루트 컴포넌트
├── 📄 main.tsx // 앱 진입점
└── 📄 index.css // 글로벌 스타일
기존 코드는 관심사별로 코드를 분리하는 데에는 좋았지만, 문제는 관심사로 분리하다 보니 하나의 기능에 필요한 코드가 여러 폴더에 흩어지는 문제가 있었습니다.
그래서 리팩토링하거나, 기능을 추가하게 될 경우 너무 많은 폴더를 이동해서 수정해야하는 불편함이 있었고 생산성도 떨어졌습니다.
예를 들어 practicePage에 간단한 버튼 컴포넌트를 수정한다고 가정해보겠습니다.
📦 src
├── 📁 components/practicePage/
│ └── 📄 FeedbackButton.tsx ← UI 컴포넌트 정의
├── 📁 hooks/practicePage/
│ └── 📄 useFeedbackHandler.ts ← 버튼 클릭에 사용할 커스텀 훅
├── 📁 customTypes/practicePage/
│ └── 📄 FeedbackButtonProps.ts ← 버튼의 Props 타입
├── 📁 constants/
│ └── 📄 feedbackConstants.ts ← 버튼에서 사용할 상수 정의
├── 📁 stores/
│ └── 📄 useFeedbackStore.tsx ← 상태 저장 및 전역 관리
├── 📁 pages/practicePage/
│ └── 📄 index.tsx ← 페이지에서 버튼 컴포넌트 사용
단 하나의 기능을 구현하기 위해 6개의 폴더를 이동해야 했고, 폴더 내에서 해당 파일을 찾기도 힘들었습니다.
그래서 폴더 구조를 변경해야겠다는 필요성을 느끼고 여러 자료를 공부하던 중 하나의 키워드를 찾았습니다
토스 모닥불 - 디렉토리 관리
토스 - 프론트엔드 지침서
토스에서는 수정하기 쉬운 코드가 좋은 코드라고 합니다.
여기서 수정은 무엇을 의미할까요?
객체지향을 공부하셨던 분들은 OCP에서는 확장에는 열려있고, 수정에는 닫힘이라고 했는데, 왜 수정하는 게 좋은 코드이지 의아해 하실 수도 있습니다.
OCP에서 말하는 수정은, 새로운 요구사항이 생겼을 때 기존 코드를 변경하지 않고 새로운 코드를 추가하여 확장할 수 있어야 한다는 의미입니다.
반면 토스에서 말하는 수정하기 쉬운 코드는, 어떤 기능을 변경하거나 개선할 때 관련된 코드들이 한 곳에 모여 있어 쉽게 파악하고 수정할 수 있는 코드를 말합니다.
그것이 바로 응집도가 높은 코드입니다.
응집도가 높아지려면 역할 기반이 아닌 기능 기반으로 코드를 모으면 됩니다.
예를 들어, 아래처럼 하나의 기능을 하는 코드들이 멀리 떨어져 있으면 수정하기 불편할 겁니다
📦 src
├── 📁 components/practicePage/
│ └── 📄 FeedbackButton.tsx ← UI 컴포넌트 정의
...(수 없이 많은 파일들)
...
...
├── 📁 hooks/practicePage/
│ └── 📄 useFeedbackHandler.ts ← 버튼 클릭에 사용할 커스텀 훅
...
...
...
├── 📁 customTypes/practicePage/
│ └── 📄 FeedbackButtonProps.ts ← 버튼의 Props 타입
...
...
...
├── 📁 constants/
│ └── 📄 feedbackConstants.ts ← 버튼에서 사용할 상수 정의
...
...
...
├── 📁 stores/
│ └── 📄 useFeedbackStore.tsx ← 상태 저장 및 전역 관리
...
...
...
├── 📁 pages/practicePage/
│ └── 📄 index.tsx ← 페이지에서 버튼 컴포넌트 사용
...
...
...
하지만 이렇게 기능 단위로 묶이게 되면, 응집성이 높아 기능 수정이나 추가가 수월해집니다.
한 눈에 봐도 기능으로 묶이는 게 편해보이죠?
📦 src
├── 📁 features
│ └── 📁 feedbackButton
│ ├── 📄 index.ts // 컴포넌트 export
│ ├── 📄 FeedbackButton.tsx // UI 컴포넌트
│ ├── 📄 useFeedback.ts // 관련 커스텀 훅
│ ├── 📄 feedbackStore.ts // Zustand 전역 상태
│ ├── 📄 types.ts // 타입 정의
│ └── 📄 constants.ts // 상수 정의
├── 📁 pages
│ └── 📁 practicePage
│ ├── 📄 index.tsx // 피드백 버튼 import 및 사용
FSD는 프론트엔드에서 쓰이는 기능 기반 폴더 구조의 대표적인 예시입니다.
FSD를 설명하는 글은 너무나 많으니 이번 글에서는 저희 프로젝트에 어떤 기준으로 적용했는지 설명드리겠습니다.
우선 FSD에서는 계층적인 구조를 가집니다.
📦 src
├── 📁 app # 앱의 진입점
├── 📁 pages # 페이지 단위
├── 📁 widgets # 페이지를 구성하는 컴포넌트
├── 📁 features # 사용자 중심의 기능 단위 (ex. 로그인, 채팅)
├── 📁 entities # 모델 단위 (ex. User, Room 등)
├── 📁 shared # 프로젝트 전반에서 재사용되는 코드들
막상 적용하려보면 무엇을 어디에 넣어야 할지 감 잡기가 어렵습니다.
실제 저희 프로젝트의 홈페이지를 보면서 설명드리겠습니다.

📦 HomePage
├── 📁 헤더
│ ├── 📄 로고
│ ├── 📄 네비게이션 메뉴 (홈, 마이페이지, 면접 질문)
│ └── 📄 로그인 버튼
├── 📁 좌측
│ ├── 📄 히어로 텍스트
│ │ ├── "꼬리에 꼬리를 무는 면접 질문"
│ │ └── "이제는 꼬리🦊 와 함께 AI로 연습하세요"
│ ├── 📄 혼자 연습하기 버튼
│ └── 📄 같이 연습하기 버튼
└── 📁 우측
└── 📄 여우 캐릭터 일러스트 (정장+책상)
우선 헤더의 경우 공통적으로 쓰이는 컴포넌트입니다.
실제로는 MainLayout.tsx 내부에서 관리되고, React Router의 <Outlet />을 통해 <HomePage />가 보여지는 구조입니다.
이때 "공통적으로 사용된다"는 이유만으로 shared에 넣을지, 아니면 widgets에 둘지를 고민하게 됩니다.
<Header />의 경우 공통적으로 쓰이긴 하지만, 어떤 도메인과 직접적으로 연결되어 사용되는 재사용 UI가 아니고 그냥 페이지를 구성하는 하나의 기능 블록이라는 점에서 widgets이라고 판단했습니다.
. 📂 widgets
└── 📂 header/
│ ├── 📄 index.tsx
│ └── 📂 model/
│ ├── 📄 constants.ts
│ └── 📂 test/
│ ├── 📄 Header.test.tsx
│ ├── 📄 LogoLink.test.tsx
│ └── 📂 ui/
│ ├── 📄 LogoLink.tsx
다만 <Header />를 구성하는 요소 중 로그인 버튼은 다르게 관점에서 고려해야 합니다.
<LoginButton />은 단순한 UI 요소처럼 보일 수 있지만, 실제로는 인증 로직과 연결된 사용자 중심 기능을 포함하고 있기 때문입니다.
소셜 로그인 API 요청, 로그인 상태 판단, 리디렉션 등 인증 도메인과 밀접하게 연결된 로직이 포함되어 있다면, 이 컴포넌트는 단순한 UI가 아닌 기능 단위(feature)로 봐야 합니다.
따라서 저희는 로그인 버튼을 features/auth/ui로 분리하였습니다.
. 📂 features
└── 📂 auth/
│ └── 📂 model/
│ ├── 📄 constants.ts
│ └── 📂 test/
│ ├── 📄 GoToLoginButton.test.tsx
│ └── 📂 ui/
│ └── 📄 GoToLoginButton.tsx
그러면 나머지 좌측, 우측에 해당하는 컴포넌트들은 어디에 넣을 수 있을까요?
<HomePage/>에서만 사용되므로, /pages/homePage/ui 에 위치시키는 것이 적절합니다.
. 📂 homePage
│ ├── 📄 index.tsx
└── 📂 test/
│ ├── 📄 BackgroundShadow.test.tsx
│ ├── 📄 HeroText.test.tsx
│ ├── 📄 LeftSection.test.tsx
│ ├── 📄 PairPracticeButton.test.tsx
│ ├── 📄 PracticeButton.test.tsx
│ ├── 📄 SoloPracticeButton.test.tsx
│ ├── 📄 ThumbnailContainer.test.tsx
│ ├── 📄 index.test.tsx
└── 📂 ui/
│ ├── 📄 BackgroundShadow.tsx
│ ├── 📄 HeroText.tsx
│ ├── 📄 LeftSection.tsx
│ ├── 📄 PairPracticeButton.tsx
│ ├── 📄 PracticeButton.tsx
│ ├── 📄 SoloPracticeButton.tsx
│ └── 📄 ThumbnailContainer.tsx
이렇게 인증 도메인과 관련된 로직은 feature/auth 안에서 응집되고, <Header />는 여전히 레이아웃 구성 UI로써의 역할만 담당하게 되어 역할이 명확하게 분리됩니다.
또한, 하나의 페이지에서만 사용되는 컴포넌트들은 pages/page/ui에서 응집된 구조를 가지게 됐습니다.
이러한 구조 덕분에 컴포넌트의 역할과 책임이 명확히 분리되었고,
FSD가 지향하는 응집도 높은 구성과 명확한 책임 분리를 적용할 수 있었습니다.
이 글을 읽으시다 의문이 드셨을 수도 있습니다
처음부터 기능 기반으로 폴더 구조를 잘 짰으면 되는 거 아닌가요?
맞습니다.
사실 오늘의 리팩토링 과정은 프로젝트 초반부터 예상된 일이기도 했습니다.
처음 폴더 구조를 설계할 때, 여러 자료를 찾아보면서 FSD가 근 1~2년 사이에 프론트엔드에서 많이 쓰인다는 걸 알게 됐습니다.
다만, 단순히 트렌드라서 적용하기 보다는 실제로 내가 기능 중심으로 묶어야 할 필요성을 느끼고, 구조를 바꿨을 때 얻는 장점을 체감하며 적용하고 싶었습니다.
따라서 역할 기반 설계를 먼저 적용하고, 불편함이 명확해지면 기능 기반 구조로 변경하기로 결정했습니다.
이 의사결정을 하는데 큰 근거가 되었던 건 TDD였습니다.
테스트 코드가 존재하니 구조를 변경해도 기존 기능이 깨지지 않았는지 빠르게 확인할 수 있다는 믿음이 있었습니다.
그리고 폴더 구조 리팩토링을 겪으면 TDD의 가장 큰 장점인 안전한 리팩토링을 실제로 체감할 수 있으니 일석이조라는 생각으로 결정했습니다.

폴더 구조를 기능 기반으로 변경하고, 연관된 코드들을 리팩토링하니 총 105개의 파일 변경이 있었습니다.
컴포넌트를 옮기고 경로를 수정하고, import 경로를 재정리하는 과정에서 수많은 에러가 발생했습니다.
처음에는 쌓여가는 에러에 겁이 났지만, 테스트 코드 있으니까 괜찮을 거야라고 동료와 대화를 나누며 걱정을 떨쳐낼 수 있었습니다.
실제로 테스트를 실행하면 어떤 컴포넌트에서 문제가 발생했는지 즉시 확인할 수 있었고,
UI나 로직을 수정해도 기능이 여전히 정상 동작하는지 자동으로 검증할 수 있었습니다.
이 과정을 겪으면서 두 가지를 깨달았습니다.