Next.js App Router에서 FSD 대신 Colocation을 선택한 이유

배경

우리 팀은 프론트엔드 아키텍처로 Feature-Sliced Design(FSD)을 선택했었습니다. Pages Router와 App Router가 공존하고 있는 상황에서 일관성을 위해 FSD만을 사용하자고 정했었습니다. 두 라우터 모두 같은 폴더 구조를 따르면 컨텍스트 스위칭 없이 작업할 수 있으리라 봤습니다.

그런데 App Router로 개발하면서 비효율을 느끼는 의견들이 있었습니다. 특정 페이지에서만 쓰는 컴포넌트를 굳이 features/widgets/로 올려보내야 했고, 파일 간 거리가 멀어지니 코드를 읽고 수정할 때 피로도가 쌓였습니다. 여러 라우트가 화면을 공유하는 도메인에서는 "이 코드를 라우트에 둘까, FSD 슬라이스에 둘까"라는 모호함이 생기기도 했습니다.

이 글에서는 Colocation이 무엇인지, Pages Router에서 왜 온전한 Colocation이 어려운지, App Router에서 프레임워크 지원 덕분에 Colocation이 쉬워진 이유를 설명합니다. 마지막으로 팀이 FSD 고수에서 App Router Colocation 설계를 하나의 선택지로 채택한 과정을 공유합니다.

참고 문서

TL;DR

1. Colocation이란

Colocation은 관련 코드를 물리적으로 가까운 곳에 배치하는 방식입니다. 특정 기능이나 페이지 전용 컴포넌트, 훅, 유틸리티 함수를 해당 기능 폴더 바로 옆에 둡니다.

반대로 타입별로 코드를 분류하는 방식도 있습니다. 모든 컴포넌트는 components/에, 훅은 hooks/에, 유틸리티는 utils/에 모으는 구조입니다.

타입별 분류의 문제

src/
├── components/
│   ├── DashboardChart.tsx      # 대시보드 전용
│   ├── DashboardFilter.tsx     # 대시보드 전용
│   ├── ProfileForm.tsx         # 프로필 전용
│   └── Button.tsx              # 공통
├── hooks/
│   ├── useDashboardData.ts     # 대시보드 전용
│   └── useProfileUpdate.ts     # 프로필 전용
└── pages/
    ├── dashboard.tsx
    └── profile.tsx

대시보드 페이지를 수정하려면 pages/dashboard.tsx, components/DashboardChart.tsx, hooks/useDashboardData.ts 등 여러 폴더를 오가야 합니다. 파일 간 거리가 멀어질수록 코드를 읽고 수정하는 피로도가 올라갑니다.

Colocation 적용 후

src/
├── dashboard/
│   ├── page.tsx
│   ├── DashboardChart.tsx
│   ├── DashboardFilter.tsx
│   └── useDashboardData.ts
├── profile/
│   ├── page.tsx
│   ├── ProfileForm.tsx
│   └── useProfileUpdate.ts
└── shared/
    └── Button.tsx

대시보드 관련 코드가 한 폴더에 모여 있으니 수정할 때 그 폴더만 보면 됩니다. 여러 곳에서 공유하는 Buttonshared/로 분리합니다.

Colocation의 핵심 원칙

2. Colocation은 FSD v2.1과 같은 방향이다

FSD v2.1은 Pages-First 사고방식을 도입합니다[1].

개발자가 코드베이스를 탐색할 때 Page 단위로 구조를 파악하는 게 자연스럽고, 구성 요소를 찾는 출발점도 대부분 Page이기 때문입니다. 주요 UI와 비즈니스 로직은 Page 내부에 두고, Shared layer에는 순수 재사용 요소만 유지하며, 여러 Page에서 실제로 공유되는 로직만 Feature/Entity로 분리합니다.

App Router 기반 Colocation도 같은 원칙을 공유합니다. 라우트 세그먼트가 Page의 책임 단위가 되고, 해당 화면의 UI, 훅, 비즈니스 로직을 라우트 내부에 배치합니다. 공유가 필요해진 코드만 상위 세그먼트나 _shared로 승격하므로 과도한 선제 추상화를 줄이고 Page 중심으로 책임과 탐색 경로를 명확히 잡습니다.

결국 Colocation은 FSD를 부정하기보다, App Router가 제공하는 라우트 중심 경계 위에서 FSD v2.1의 Pages-First 방식을 더 자연스럽게 구현한 것입니다.

3. Pages Router에서 Colocation이 어려운 이유

Next.js Pages Router는 pages/ 폴더 안의 모든 파일을 라우트로 인식합니다. 그래서 페이지 전용 컴포넌트를 페이지 파일 옆에 둘 수가 없습니다.

# 이렇게 하고 싶지만...
pages/
├── dashboard/
│   ├── index.tsx           # /dashboard 라우트
│   ├── DashboardChart.tsx  # ❌ /dashboard/DashboardChart 라우트가 되어버림
│   └── _components/        # ❌ 언더스코어 prefix로도 라우팅 제외 불가
│       └── Filter.tsx

Pages Router에서 pages/ 안에 컴포넌트 파일을 두면 그 파일도 라우트가 됩니다. 언더스코어(_) prefix를 붙여도 라우팅에서 빠지지 않습니다. 결국 페이지 전용 코드도 pages/ 바깥의 components/, features/ 등에 둬야 합니다.

# Pages Router + FSD 구조
├── pages/                        # Next.js 라우트 (루트)
│   └── dashboard/
│       └── index.tsx             # src/pages/dashboard를 연결만 함
└── src/
    ├── pages/                    # 실제 페이지 컴포넌트
    │   └── dashboard/
    │       └── index.tsx
    ├── features/
    │   └── dashboard/
    │       ├── DashboardChart.tsx
    │       └── useDashboardData.ts
    └── shared/
        └── ui/
            └── Button.tsx

이 구조에서 대시보드를 수정하려면 src/pages/dashboard/src/features/dashboard/를 함께 열어야 합니다. 코드가 흩어져 있어 변경 범위를 파악하기 어렵습니다.

4. App Router에서 Colocation이 쉬운 이유

Next.js App Router는 파일 기반 라우팅 규칙이 다릅니다. app/ 폴더 안에서 page.tsx, layout.tsx, loading.tsx 같은 특정 파일명만 라우팅에 관여하고, 나머지 파일은 자동으로 무시됩니다.

# App Router에서 가능한 구조
app/
└── dashboard/
    ├── page.tsx              # /dashboard 라우트
    ├── DashboardChart.tsx    # ✅ 라우팅 무시됨
    ├── _components/          # ✅ 프라이빗 폴더, 라우팅 무시됨
    │   └── Filter.tsx
    └── hooks/
        └── useDashboardData.ts

프레임워크 자체가 Colocation을 지원하니 페이지 전용 컴포넌트나 훅, 유틸리티를 라우트 폴더 안에 마음껏 둘 수 있습니다.

App Router의 프라이빗 폴더 규칙

폴더명 앞에 언더스코어(_)를 붙이면 그 폴더와 하위 폴더 전체가 라우팅에서 빠집니다. 프라이빗 폴더(Private Folders)라고 부릅니다.

이 규칙 덕분에 Pages Router에서는 불가능했던 완전한 Colocation을 App Router에서는 프레임워크가 직접 지원해 줍니다. 자세한 내용은 Next.js 공식 문서를 참고하세요.

5. Colocation의 구체적 이점

Colocation이 FSD의 폴더 구조를 그대로 따르진 않지만, FSD가 추구하는 목적은 똑같이 달성합니다.

관점 Colocation의 이점
삭제 용이성 라우트 폴더 삭제 = 관련 코드 전부 삭제
코드 리뷰 PR 변경 파일이 한 라우트 폴더에 모임
탐색 용이성 "이 페이지 코드 어디?" → app/products/[productId]/ 한 곳
코드 스플리팅 라우트 단위 자동 스플리팅과 일치

FSD: 대시보드 페이지의 구성을 파악하려면 두 폴더를 탐색해야 함

├── app/dashboard/
│   └── page.tsx              # 여기서 시작 → @/widgets/dashboard 를 import
│
└── src/widgets/dashboard/    # 여기로 이동해야 실제 구현 확인
    ├── index.ts
    ├── DashboardContent.tsx
    ├── DashboardChart.tsx
    └── DashboardFilter.tsx

Colocation: 대시보드 폴더 하나만 열면 전부 보임

app/dashboard/                # 이 폴더 하나만 보면 됨
├── page.tsx
└── _components/
    ├── DashboardContent.tsx
    ├── DashboardChart.tsx
    └── DashboardFilter.tsx

FSD에서는 app/src/widgets/를 오가야 하지만, Colocation에서는 app/dashboard/ 폴더 안에서 모든 파일을 확인합니다.

FSD의 목적을 Colocation으로 달성하기

FSD의 목적 Colocation에서의 실현
단방향 의존 page → _components → _hooks → _shared 단방향 유지
관련 코드의 응집 라우트 폴더 안에 전부 모임 (FSD보다 응집도 높음)
public API bind 패턴의 index.ts 배럴로 유지
역할 분리 _components/_hooks/_lib prefix로 구분
공유 코드 승격 2개 이상 라우트 공유 시 _shared/로 이동 (FSD와 동일 원칙)

핵심은 App Router가 이미 FSD의 pages+widgets 역할을 품고 있다는 점입니다. 굳이 중복 레이어를 만들 이유가 없습니다.

6. App Router Colocation이 FSD보다 더 응집도가 높았던 사례

두 플로우(웹뷰/카카오톡 인앱)가 같은 화면 네 개를 공유하는 도메인이 있었습니다. 라우트는 플로우별로 폴더가 갈라져 있는데, 구현은 FSD에 맞춰 단일 슬라이스에 모았더니 응집도가 떨어졌습니다.

FSD 구조: 라우트와 슬라이스의 경계 불일치

├── app/
│   ├── webview/health/               # 웹뷰 플로우
│   │   ├── layout.tsx                # 🤔 구현을 슬라이스에서 import? 여기에 직접?
│   │   ├── error.tsx                 # 🤔 라우트 세그먼트 파일들의 위치가 모호
│   │   ├── page.tsx                  # 얇은 셸, widgets import만
│   │   ├── result/page.tsx
│   │   └── history/page.tsx
│   │
│   └── kakaotalk/health/             # 카카오톡 인앱 플로우
│       ├── layout.tsx                # webview와 같은 레이아웃인데 중복?
│       ├── page.tsx
│       ├── result/page.tsx
│       └── history/page.tsx
│
└── src/widgets/health/               # 단일 슬라이스
    ├── webview/
    │   ├── HealthMain.tsx
    │   └── ResultView.tsx
    ├── kakaotalk/
    │   ├── HealthMain.tsx
    │   └── ResultView.tsx
    ├── shared/
    │   └── HistoryList.tsx
    └── HealthLayout.tsx              # 🤔 두 플로우가 공유하는 레이아웃

문제: 슬라이스 내부를 디렉터리로 나눠도 근본적인 문제는 남습니다. 페이지 로직을 추적하려면 app/src/widgets/를 오가야 하고, 라우트에 넣는 게 더 자연스러울 것 같지만 원칙을 지켜야 하니 슬라이스에 둬야 하는 부자연스러움이 생깁니다.

또한 App Router의 layout.tsx, error.tsx, loading.tsx 같은 라우트 세그먼트 파일은 app/에만 존재합니다. FSD 원칙대로라면 이 파일들의 구현도 슬라이스에 두고 app/에서 불러와야 하는데, 라우트 구조와 슬라이스 구조가 1:1로 대응하지 않으면 불일치가 생깁니다.

Colocation 구조: 라우트가 곧 경계

app/
├── webview/health/                   # 웹뷰 플로우 — 이 폴더가 경계
│   ├── layout.tsx                    # ✅ 라우트 세그먼트 파일이 제자리에
│   ├── error.tsx                     # ✅ 에러 처리도 라우트 안에
│   ├── page.tsx
│   ├── _components/
│   │   └── HealthMain.tsx
│   ├── result/
│   │   ├── page.tsx
│   │   └── _components/
│   │       └── ResultView.tsx
│   └── history/
│       └── ...
│
├── kakaotalk/health/                 # 카카오톡 플로우 — 이 폴더가 경계
│   ├── layout.tsx                    # ✅ 플로우별 레이아웃 분리 명확
│   ├── error.tsx
│   ├── page.tsx
│   ├── _components/
│   │   └── HealthMain.tsx
│   ├── result/
│   │   └── ...
│   └── history/
│       └── ...
│
└── _shared/health/                   # 두 플로우가 공유하는 코드만
    └── HealthHeader.tsx              # 공유 컴포넌트만 승격

해결: 라우트 폴더 자체가 경계가 됩니다. 플로우별 구현이 각 라우트 안에 있으니 페이지 로직을 추적할 때 해당 폴더만 보면 됩니다. 두 플로우가 실제로 공유하는 코드만 _shared/로 승격합니다.

7. 우리 팀의 의사결정 과정

배경에서 언급한 비효율을 해결하기 위해 FE Weekly 회의에서 App Router Colocation을 논의했습니다. 팀원들 의견은 이랬습니다.

예상 반론과 대응

Colocation도 만능은 아닙니다. 논의 중 나온 반론과 대응을 정리했습니다.

반론 대응
라우트가 늘면 _components가 중복될 텐데? 두 곳 이상에서 쓰이면 상위 세그먼트나 _shared로 올립니다. FSD의 승격 원칙과 같습니다.
FSD는 lint로 강제되는데 Colocation은 규칙이 약하다 맞는 지적입니다. App Router 앱에 대한 최소한의 lint 규칙은 필요합니다. (역의존성 방지, 네이밍 규칙 등)
하나의 앱에 두 라우터가 공존하면 더 헷갈릴 수 있다 신규 App Router 앱에 한해 적용합니다.
FSD + Pages Router인 앱에 App Router를 도입하고 싶으면? Codeowner가 App Router + FSD를 유지할지, Colocation으로 전환할지 선택합니다.

최종 결론: 앱별로 같은 설계

논의 끝에 우리 팀은 앱별로 같은 설계를 적용하기로 했습니다.

8. 폴더 구조 예시

apps/<dashboard-app>/
├── app/                                  # 라우터 + 앱 코드 전부
│   ├── layout.tsx                        # 루트 레이아웃
│   ├── globals.css
│   ├── global-error.tsx
│   │
│   ├── _shared/                          # 앱 전역 공유
│   │   ├── ui/                           # 공유 컴포넌트
│   │   ├── lib/                          # 공유 유틸리티
│   │   └── hooks/                        # 공유 훅
│   │
│   └── dashboard/                        # 라우트 세그먼트 = 도메인 경계
│       ├── layout.tsx                    # 사이드바, 헤더 등 대시보드 셸
│       ├── page.tsx                      # 개요 페이지
│       ├── loading.tsx
│       ├── error.tsx
│       ├── _components/
│       │   └── OverviewChart.tsx
│       ├── _hooks/                       # 쿼리, 상태 훅
│       ├── _lib/                         # 순수 로직 + 테스트 콜로케이션
│       │
│       ├── patients/                     # 목록
│       │   ├── page.tsx
│       │   ├── _components/
│       │   ├── _hooks/
│       │   └── _lib/searchParams.ts      # 필터, 페이지네이션 파라미터 스키마
│       │
│       ├── patients/[patientId]/         # 상세
│       │   ├── page.tsx
│       │   ├── loading.tsx
│       │   └── _components/
│       │
│       ├── reports/
│       │   ├── page.tsx
│       │   └── _components/
│       │
│       └── settings/
│           ├── page.tsx
│           └── _components/

9. Import 규칙: 단방향 의존성 흐름

Colocation은 파일을 가까이 두는 것만으로 끝나지 않습니다. 의존성 방향을 한쪽으로 통일해야 코드가 엉키지 않습니다.

단방향 의존성은 계층형 설계를 기반으로 합니다. 하위 계층일수록 재사용성이 높고, 상위 계층일수록 추가·수정이 쉽습니다.

왜 단방향이어야 하는가?

flowchart LR
  A["page"] --> B["_components"]
  B --> C["_hooks"]
  C --> D["_lib"]
  D --> E["_shared"]
  B --> E
  C --> E

10. 기대 효과

  1. 빠른 초기 개발: 페이지 안(_components/)에서 빠르게 개발하고, 필요할 때만 승격합니다.
  2. 높은 응집도: 특정 페이지를 수정할 때 해당 폴더 안에서만 작업하면 됩니다.
  3. 쉬운 삭제: 라우트 폴더 삭제 = 관련 코드 전부 삭제.
  4. YAGNI 원칙 준수: 재사용이 필요해지기 전까지 불필요한 추상화를 만들지 않습니다.

정리

App Router의 Colocation은 FSD를 부정하지 않습니다. App Router가 이미 FSD의 pages+widgets 역할을 품고 있으니 중복 레이어 없이 FSD v2.1의 Pages-First 방식을 더 자연스럽게 구현하는 셈입니다.

프레임워크 기능을 최대한 활용하면서 개발 편의성을 챙기는 게 이번 의사결정의 핵심이었습니다. 원칙을 고수하기보다 프레임워크가 제공하는 기능을 활용해 실용성을 높이는 방향으로 설계했습니다.

물론 이 설계가 모든 상황에 맞는 정답은 아닙니다. 콘웨이 법칙이 말하듯, 소프트웨어 구조는 조직 구조를 반영합니다.

"시스템을 설계하는 조직은 그 조직의 의사소통 구조를 그대로 복제한 설계를 만들어낼 수밖에 없다."

팀 상황과 비즈니스 요구에 따라 설계는 언제든 달라집니다. 그 상황에서 최선의 설계를 찾는 게 좋지 않을까 생각합니다.