🚀 서론
프로젝트를 진행할 때마다 저는 오랫동안 Layer-based 구조로 개발해 왔습니다. 처음 회사에서 접한 컨벤션에 익숙해지기도 했고, 컴포넌트는 components, 훅은 hooks, 유틸리티는 utils에 두는 방식이 자연스럽게 몸에 배어 있었습니다. 그래서 초기에는 큰 불편함을 느끼지 못했습니다.

하지만 프로젝트 규모가 커지고 기능이 복잡해질수록 몇 가지 문제가 반복해서 드러나기 시작했습니다.
- 기능 코드의 분산: 새로운 기능을 만들 때마다 역할별 폴더에 나누어 작성하다 보니, 하나의 기능과 관련된 코드가 여러 폴더에 흩어지는 상황이 발생했습니다.
- 예:
user기능 구현 시hooks/user/useUserQuery.tsutils/user/user-format.tscomponents/user/user-info.tsx
- 예:
- 느슨한 경계로 인한 의도치 않은 결합: 특정 페이지 전용 로직이 다른 페이지에서 별도 분리 없이 그대로
import되어 사용되거나, 프로젝트가 커질수록 "이미 쓰고 있으니 재사용하자"라는 이유로 코드를 가져다 쓰는 일이 반복되었습니다.- 그 결과, 해당 함수를 수정할 때 예상치 못한 Side Effect가 발생하는 위험이 커졌습니다.
이러한 경험을 통해 저는 "이런 사용 패턴을 구조 자체가 허용하고 있는 것은 아닐까?"라는 근본적인 의문을 품게 되었습니다.
이전 프로젝트에서 겪었던 불편함을 해결하고 책임을 명확히 분리할 수 있는 대안으로 Feature-based 아키텍처를 선택하게 되었습니다.
- 그 결과, 해당 함수를 수정할 때 예상치 못한 Side Effect가 발생하는 위험이 커졌습니다.
🏗️ Layer-based 구조의 정의와 한계
Layer-based 구조는 역할(레이어) 기준으로 파일을 나누는 방식입니다. 가장 흔히 사용되며, 대부분의 소규모 프로젝트가 이 형태로 개발을 시작합니다.
src/
├─ pages/
├─ components/
├─ hooks/
├─ services/
├─ utils/
└─ styles/
장점
- 구조의 직관성: 개발 초기 진입 장벽이 낮고, 파일의 역할(컴포넌트인지, 훅인지)을 바로 알 수 있습니다.
- 빠른 시작: 작은 프로젝트나 프로토타이핑 단계에서 빠르게 구조를 잡을 수 있습니다.
단점
- 높은 기능 탐색 비용: 하나의 기능을 수정하려면 여러 폴더(
hooks,utils,components)를 오가야 합니다. - 파일 관리의 어려움: 프로젝트 규모가 커질수록 파일 수가 급격히 늘어나 원하는 파일을 찾기 어렵습니다.
- 기능 간 경계의 모호성: 기능 간의 경계가 흐려져 느슨한 결합을 유발하고, 의도치 않은 재사용을 막기 어렵습니다.
Layer-based 구조의 가장 큰 문제는 기능의 맥락이 코드 구조에 명확히 드러나지 않는다는 점이라고 판단했습니다.
🧱 Feature-based 구조의 이해와 장점
Feature-based 구조는 기능(도메인) 단위로 코드를 묶는 방식입니다. 하나의 기능에 필요한 UI, 로직, 데이터 호출 등을 모두 한 디렉터리에 모아 응집도를 높입니다.
src/
├─ features/
│ ├─ auth/
│ │ ├─ components/
│ │ ├─ hooks/
│ │ └─ utils/
│ └─ payment/
│ ├─ components/
│ ├─ hooks/
│ └─ utils/
├─ shared/
│ ├─ constants/
│ ├─ hooks/
│ └─ utils/
장점
- 기능 맥락 파악 용이: 특정 기능을 수정하거나 파악할 때 해당 Feature 디렉터리(
features/auth)만 확인하면 되므로 맥락 파악이 쉽습니다. - 안전한 개발: 수정 범위가 해당 Feature 내부로 자연스럽게 제한되어 Side Effect 발생 위험이 낮습니다.
- 쉬운 유지보수: 기능 단위의 리팩토링이나 삭제가 비교적 안전합니다.
단점
- 초기 설계 비용: Feature를 나누는 기준과
shared영역을 분리하는 초기 설계에 비용과 논의가 필요합니다. - 엄격한 규칙 요구: 명확한 규칙(특히 Feature 간 참조 금지)이 없으면 오히려 구조가 난잡해질 수 있습니다.
- 과도한 복잡성: 매우 작은 프로젝트에서는 폴더 구조가 과하게 느껴질 수 있습니다.
🎯 Feature-based 구조를 선택하고 얻은 것

Feature-based 구조를 선택한 가장 큰 이유는 책임 분리가 구조 차원에서 강제된다는 점입니다.
이전에는 "이 함수는 여기서도 쓰고 저기서도 쓰니까 공통이겠지"라는 판단이 너무 쉽게 내려졌다면,
Feature-based 구조에서는 "이 코드가 어떤 기능(도메인)에 속하는가?"를 먼저 고민하게 됩니다.
그 결과, 프로젝트에 다음과 같은 긍정적인 변화를 가져왔습니다.
- 로직 오염 방지: 특정 페이지 전용 로직이 다른 페이지로 전파되는 일이 현저히 줄었습니다.
- 명확한 수정 범위: 기능 단위로 수정 범위가 명확해져 팀원 간의 협업 시 발생하는 충돌 위험이 감소했습니다.
📏 Feature-based 구조 전환 시 세운 핵심 규칙
Feature-based 구조는 높은 자유도를 가집니다. 따라서 명확하고 엄격한 규칙이 없으면 오히려 혼란을 초래할 수 있습니다.
그래서 다음과 같은 핵심 기준을 명확히 세워 구조를 정립하였습니다.
1. Feature는 서로를 직접 참조하지 않습니다.
Feature-based 구조에서 가장 중요한 기준이라고 생각합니다.
다른 Feature의 코드를 직접 가져와 사용한다는 것은 이미 기능 경계가 무너지고 있다는 강력한 신호입니다.
authFeature에서userFeature의 내부 로직을 직접import하지 않습니다.- 정말 공통으로 사용해야 하는 로직이라면, 해당 코드는 Feature가 아니라
shared영역으로 옮겨져야 합니다.
이 규칙 덕분에 기능 간 결합도가 눈에 띄게 낮아졌습니다
2. 공통 코드는 shared에만 위치하며 도메인 개념을 포함하지 않습니다.
공통으로 사용하는 로직은 반드시 shared에 둡니다. 다만, shared의 순수성을 유지하기 위해 특정 도메인 개념이 들어가면 안 된다는 기준을 세웠습니다.
// shared/utils/format.ts (O)
export function formatDate(date: Date) { /* ... */ }
// shared/hooks/useDebounce.ts (O)
export function useDebounce<T>(value: T, delay: number) { /* ... */ }
// shared/utils/user-name-format.ts (X) <- 특정 도메인(user) 개념 포함
Auth, Payment, User 등 어떤 Feature에서도 중립적으로 사용할 수 있는 코드만 shared에 위치합니다.
3. 의존성은 shared → feature → app 방향으로만 흐릅니다.
의존성의 방향은 항상 단방향이어야 합니다.
feature는shared를 사용할 수 있습니다.app은feature를 사용할 수 있습니다.- 반대 방향의 의존성은 허용하지 않습니다. (
feature가app을 참조하거나,shared가feature를 참조하는 경우)
이 규칙을 어기면 순환 의존성이 생기기 쉽고, 구조가 빠르게 무너지는 것을 경험했습니다.
4. App Router의 역할(app 디렉토리)을 최소화합니다.
Next.js의 App Router 기준으로 app 디렉토리는 다음의 역할만 담당하도록 제한했습니다.
- 라우팅
- RSC(Server Component)를 활용한 데이터 Fetching 및 컴포넌트 조합
// app/login/page.tsx
import { SignInForm } from "@/features/auth"; // Feature에서 컴포넌트 import
export default function LoginPage() {
return <SignInForm />;
}
실제 비즈니스 로직(상태 관리, 이벤트 핸들링 등 클라이언트 인터랙션이 필요한 로직)은 모두 features의 서버/클라이언트 컴포넌트 내부에 위치해야 합니다.
- 초기 로딩 성능 최적화: 페이지 자체는 서버에서 렌더링되므로, 사용자에게 초기 로딩 화면을 매우 빠르게 전달할 수 있습니다. 이는 LCP지표 개선에 직접적인 도움을 줍니다.
- 번들 크기 감소 및 스트리밍 효과: 페이지 컴포넌트가 서버 컴포넌트로 유지되면, 해당 컴포넌트가 참조하는
features의 로직 중 클라이언트에서 필요 없는 부분은 번들로 전송되지 않습니다. 또한, 데이터 Fetching이 완료되는 대로 스트리밍하여 화면의 일부를 먼저 보여주는 효과를 극대화할 수 있습니다. - 얇은 페이지 유지: 페이지 컴포넌트가 얇아지면서, 순수하게 데이터 구성과 컴포넌트 조립의 역할만 담당하게 되므로, 테스트와 유지보수가 용이해집니다.
5. Feature 내부 구조는 항상 일관되게 유지합니다.
각 Feature는 기능의 규모와 상관없이 항상 동일한 폴더 구조를 유지하도록 강제했습니다.
auth/
├─ components/
│ ├─ signin-form.tsx
│ └─ signup-form.tsx
├─ schemas/
│ ├─ signin.schema.ts
│ └─ signup.schema.ts
└─ actions/ // Server Action 등을 위한 폴더
user/
└─ components/
└─ info.tsx
현재 하나의 컴포넌트만 필요하더라도 구조를 생략하지 않습니다. 그 이유는 다음과 같습니다.
- 구조 예측 가능성: 팀원들이 다른 Feature의 코드를 빠르게 예측하고 탐색할 수 있습니다.
- 확장 시 고민 최소화: 기능이 확장될 때마다 구조를 어떻게 가져가야 할지 고민하는 비용이 줄어듭니다.
- 팀 간 암묵적 규칙 확립: 일관성이 팀 전체의 암묵적인 컨벤션으로 자리 잡게 됩니다.
마무리
Layer-based 구조는 여전히 좋은 선택이며, 특히 작은 프로젝트나 빠른 프로토타이핑에는 충분히 적합합니다.
하지만 프로젝트 규모가 커지고 협업 인원이 많아질수록, 기능 단위로 책임을 명확히 분리하고 느슨한 결합을 유지할 수 있는 Feature-based 구조는 반드시 고려해볼 만한 대안이라고 생각합니다.
'Frontend > Tech Insight' 카테고리의 다른 글
| React Custom Hook은 어떻게 작동할까? (2) | 2025.08.10 |
|---|---|
| React에서 다국어(i18n) 처리하기 (With ICU format) (2) | 2025.06.08 |
| Tailwind에서 조건부 클래스와 충돌 방지를 동시에 해결하기 (0) | 2025.05.20 |
| React에서 DOMPurify로 XSS 공격 방어하기 (0) | 2025.04.24 |
| Bun: 빠르고 혁신적인 JavaScript 런타임Bun이란? (0) | 2025.02.16 |