junyeokk
Blog
React·2025. 11. 14

Domain-Driven 폴더 구조

프로젝트가 커지면 폴더 구조가 개발 속도에 직접적인 영향을 미치기 시작한다. "이 컴포넌트 어디 있지?"를 매번 검색해야 하는 순간이 오면, 구조에 문제가 있다는 신호다.


기존 방식의 한계

React 프로젝트에서 가장 흔한 구조는 기술 역할 기반(Technical Role-Based) 분리다.

text
src/
  components/
    Button.tsx
    Modal.tsx
    CommentSection.tsx
    DrawingCanvas.tsx
    ProjectSidebar.tsx
    LoginCard.tsx
    ...80개 파일
  hooks/
    useAuth.ts
    useComment.ts
    useDrawing.ts
    useProject.ts
    ...
  stores/
    authStore.ts
    commentStore.ts
    drawingStore.ts
    ...
  helpers/
    commentHelpers.ts
    drawingHelpers.ts
    ...

프로젝트 초기에는 문제가 없다. 컴포넌트가 20개일 때는 components/ 폴더를 열어도 한눈에 파악할 수 있다. 그런데 프로젝트가 성장하면서 컴포넌트가 80개, 100개가 되면 상황이 달라진다.

첫 번째 문제: 관련 코드가 흩어진다. 댓글 기능 하나를 수정하려면 components/CommentSection.tsx, hooks/useComment.ts, stores/commentStore.ts, helpers/commentHelpers.ts를 모두 열어야 한다. 4개 폴더를 오가면서 작업해야 하고, 파일 탐색기에서 관련 파일을 찾기 위해 계속 스크롤해야 한다.

두 번째 문제: 의존 관계가 보이지 않는다. CommentSection.tsxuseDrawing.ts를 import하고 있다면, 그건 댓글 기능이 드로잉 기능에 의존한다는 뜻이다. 그런데 기술 역할 기반 구조에서는 이런 도메인 간 의존이 자연스럽게 숨겨진다. hooks/ 폴더 안의 파일끼리는 아무 제약 없이 서로 import할 수 있으니까.

세 번째 문제: 삭제가 어렵다. 어떤 기능을 완전히 제거해야 할 때, 관련 파일이 여러 폴더에 흩어져 있으면 하나라도 빠뜨리기 쉽다. 사용하지 않는 헬퍼 함수나 상수가 프로젝트에 계속 남게 된다.


Domain-Driven 폴더 구조란

Domain-Driven 폴더 구조는 기능(도메인) 단위로 코드를 묶는 방식이다. 기술 역할(components, hooks, stores)이 아니라 비즈니스 도메인(auth, comment, project)이 최상위 분류 기준이 된다.

text
src/
  domain/
    auth/
      components/
      hooks/
      constants/
    comment/
      components/
      hooks/
      stores/
      constants/
      helpers/
    drawing/
      components/
      hooks/
      constants/
      helpers/
      utils/
    project/
      components/
      hooks/
      stores/
      constants/
      helpers/

핵심 아이디어는 단순하다. 하나의 기능에 필요한 모든 코드를 한 폴더에 모은다. 댓글 기능을 수정하려면 domain/comment/ 폴더만 열면 된다. 관련 컴포넌트, 훅, 스토어, 상수, 헬퍼가 전부 그 안에 있다.

이 구조의 장점은 단순히 "정리가 깔끔하다"가 아니다. 코드의 응집도(cohesion)가 높아지고, 결합도(coupling)가 낮아진다. 도메인 경계가 폴더 구조로 명시적으로 드러나기 때문에, 한 도메인이 다른 도메인에 의존하는 상황을 쉽게 인식할 수 있다.


도메인 내부 구조 설계

각 도메인 폴더의 내부는 기술 역할로 분리한다. 결국 도메인 안에서는 components, hooks, stores 같은 폴더가 다시 나타난다. 다만 이 분리가 "전체 프로젝트 레벨"이 아니라 "하나의 도메인 레벨"에서 이루어진다는 점이 다르다.

components/

도메인에 속하는 UI 컴포넌트를 담는다. 컴포넌트가 많아지면 하위 폴더로 더 세분화할 수 있다.

text
domain/comment/components/
  desktop/
    Comment.tsx
    CommentEditForm.tsx
    CommentMenu.tsx
    CommentSection.tsx
    CommentSectionContainer.tsx
    CommentInput.tsx
    CommentListView.tsx
    CommentThreadView.tsx
    CommentTimelineView.tsx
  mobile/
    CommentMobile.tsx
    CommentBarMobile.tsx
    CommentSearchMobile.tsx
    CommentSheetMobile.tsx

desktop/mobile 분리처럼 플랫폼별로 나누거나, 기능 단위(sidebar, modal, form 등)로 나누는 것이 일반적이다. 분류 기준은 도메인의 특성에 따라 유연하게 정하면 된다.

hooks/

도메인 전용 커스텀 훅을 담는다. API 호출 관련 훅은 queries/ 하위 폴더로 분리하면 찾기 편하다.

text
domain/comment/hooks/
  useClickOutside.ts
  queries/
    useGetComments.ts
    useCreateComment.ts
    useDeleteComment.ts

stores/

도메인 전용 상태 관리 스토어다. Zustand, Jotai 같은 전역 상태 관리 도구를 쓴다면 여기에 둔다.

text
domain/comment/stores/
  commentStore.ts

constants/

도메인에서 사용하는 상수 값. UI 텍스트, 키보드 단축키, 아이콘 크기, 설정 값 등.

text
domain/comment/constants/
  comment.ts    // UI_TEXT, KEYBOARD_SHORTCUTS, ICON_SIZES, SCROLL_CONFIG

helpers/

도메인 로직을 포함하는 유틸리티 함수. 순수한 범용 유틸리티(formatDate 같은)와 구분된다. 도메인 지식이 필요한 함수가 여기에 온다.

text
domain/comment/helpers/
  handleCommentTimestampClick.ts

어디까지가 도메인인가

도메인 분리에서 가장 어려운 질문은 "이건 어느 도메인에 속하지?"다. 명확한 규칙은 없지만, 판단 기준은 있다.

비즈니스 개념 단위로 나눈다. 사용자가 인식하는 기능 단위가 도메인이다. "댓글", "프로젝트", "미디어 뷰어", "드로잉"은 각각 사용자가 독립적으로 인식하는 기능이므로 별도 도메인이 된다.

CRUD 대상이 다르면 다른 도메인이다. 댓글을 생성/수정/삭제하는 코드와 프로젝트를 생성/수정/삭제하는 코드는 다른 도메인에 속한다. API 엔드포인트가 다른 리소스를 가리키고 있다면, 대체로 다른 도메인이다.

애매하면 합쳐두고 나중에 분리한다. 처음부터 완벽한 도메인 분리를 할 필요는 없다. 코드가 늘어나면서 "이 폴더가 너무 커졌다"고 느낄 때 분리해도 늦지 않다.

실제로 어떤 프로젝트에서는 다음과 같은 도메인을 가질 수 있다:

text
domain/
  auth/          # 로그인, 회원가입, 비밀번호 재설정
  comment/       # 댓글 작성, 스레드, 타임라인 뷰
  drawing/       # 캔버스 드로잉, 도구 선택, 오버레이
  feedback/      # 리액션, 피드백 툴바
  media/         # 미디어 뷰어, 썸네일, 업로드
  project/       # 프로젝트 CRUD, 사이드바, 공유
  version/       # 버전 관리, 업로드, 비교
  workspace/     # 워크스페이스 관리, 멤버 초대

공유 코드는 어떻게 처리하나

모든 코드가 특정 도메인에 깔끔하게 속하지는 않는다. 여러 도메인에서 공통으로 사용하는 코드가 반드시 존재한다.

공유 컴포넌트

Button, Modal, Input 같은 범용 UI 컴포넌트는 도메인에 속하지 않는다. 이런 컴포넌트는 components/ (또는 shared/, common/) 폴더에 둔다.

text
src/
  components/        # 공유 UI 컴포넌트 (Button, Modal, ...)
  domain/            # 도메인별 코드
  hooks/             # 공유 훅 (useDebounce, useMediaQuery, ...)
  lib/               # 공유 유틸리티 (API 클라이언트, 포맷터, ...)
  stores/            # 공유 스토어 (테마, 사이드바 상태, ...)
  types/             # 공유 타입 정의

판단 기준은 간단하다. 두 개 이상의 도메인에서 사용하고, 도메인 지식 없이 동작하는 코드는 공유 영역에 둔다. useDebounce는 댓글에서도 검색에서도 쓰이고 도메인 지식이 필요 없으므로 공유 훅이다. 반면 useGetComments는 댓글 도메인 전용이므로 domain/comment/hooks/에 둔다.

도메인 간 의존

도메인 A의 코드가 도메인 B의 코드를 import해야 하는 상황이 생길 수 있다. 예를 들어, 댓글에 드로잉 기능이 포함되어 있어서 comment/ 컴포넌트가 drawing/ 컴포넌트를 가져다 쓰는 경우다.

이런 의존은 존재할 수 있지만, 의식적으로 관리해야 한다. 기술 역할 기반 구조에서는 아무 파일이나 자유롭게 import할 수 있어서 의존이 보이지 않았다. 도메인 기반 구조에서는 domain/comment/에서 domain/drawing/을 import하는 순간 "댓글이 드로잉에 의존한다"는 사실이 명시적으로 드러난다.

의존 방향에 규칙을 두면 더 좋다:

  • 단방향 의존만 허용: comment → drawing은 OK, drawing → comment는 금지
  • 순환 의존 금지: A → B → A 형태가 되면 구조를 재검토
  • 공유가 너무 많으면 별도 도메인으로 추출: comment와 drawing이 서로 너무 많이 의존하면, 공통 부분을 별도 도메인이나 공유 레이어로 올린다

배럴 파일(index.ts)의 함정

도메인 폴더를 만들면 자연스럽게 배럴 파일(index.ts)을 만들고 싶어진다.

typescript
// domain/comment/index.ts
export { CommentSection } from './components/desktop/CommentSection';
export { CommentMobile } from './components/mobile/CommentMobile';
export { useGetComments } from './hooks/queries/useGetComments';
export { commentStore } from './stores/commentStore';

외부에서 import { CommentSection } from '@/domain/comment'처럼 깔끔하게 가져올 수 있어서 매력적이다. 그런데 실제로 사용해보면 문제가 생길 수 있다.

순환 참조(Circular Dependency): 배럴 파일이 도메인의 모든 export를 모아두기 때문에, 도메인 내부에서 같은 도메인의 다른 파일을 배럴을 통해 import하면 순환 참조가 발생할 수 있다.

typescript
// domain/comment/hooks/useComment.ts
// ❌ 배럴 파일을 통해 import → 순환 참조 위험
import { commentStore } from '@/domain/comment';

// ✅ 직접 경로로 import → 안전
import { commentStore } from '../stores/commentStore';

번들 사이즈: 배럴 파일에서 하나만 import해도 트리 셰이킹이 완벽하지 않은 환경에서는 배럴에 연결된 모든 모듈이 로드될 수 있다.

실용적인 해결책은 도메인 내부에서는 상대 경로를 쓰고, 도메인 외부에서는 직접 경로를 쓰는 것이다. 배럴 파일 없이도 IDE의 자동 import 기능이 올바른 경로를 잡아주기 때문에 개발 경험에 큰 차이가 없다.


Next.js App Router와의 조합

Next.js App Router를 사용하면 app/ 디렉토리가 라우팅을 담당한다. 이때 app/domain/의 역할을 명확히 구분하는 것이 중요하다.

text
src/
  app/
    (auth)/
      login/
        page.tsx       # 라우트 + 레이아웃만 담당
      signup/
        page.tsx
    (main)/
      workspace/
        page.tsx
      project/
        [projectId]/
          page.tsx
  domain/
    auth/
      components/      # 실제 UI 로직
    project/
      components/

app/의 page.tsx는 가능한 얇게 유지한다. page.tsx는 라우트 정의와 레이아웃 조합만 담당하고, 실제 비즈니스 로직과 UI는 domain/에서 가져온다.

tsx
// app/(main)/project/[projectId]/page.tsx
import { ProjectView } from '@/domain/project/components/ProjectView';

export default function ProjectPage({ params }: { params: { projectId: string } }) {
  return <ProjectView projectId={params.projectId} />;
}

이렇게 하면 라우팅 구조가 바뀌어도 도메인 코드를 수정할 필요가 없다. URL이 /project/123에서 /workspace/456/project/123으로 바뀌어도 domain/project/는 그대로다.


기술 역할 vs 도메인 기반 비교

기준기술 역할 기반도메인 기반
최상위 분류components, hooks, storesauth, comment, project
파일 탐색"이 훅 어디 있지?" → hooks/"댓글 관련 코드" → domain/comment/
기능 삭제여러 폴더 순회하며 관련 파일 제거폴더 하나 삭제
의존 관계암묵적, 파악 어려움명시적, import 경로로 드러남
초기 비용낮음 (폴더 몇 개면 끝)중간 (도메인 설계 필요)
확장성파일 수 증가 시 급격히 복잡해짐도메인 추가로 선형 확장
적합한 규모소규모 (컴포넌트 ~30개)중대규모 (컴포넌트 50개+)

마이그레이션 전략

기존 프로젝트를 한 번에 전환하는 건 위험하다. 점진적으로 옮기는 것이 안전하다.

1단계: 가장 독립적인 도메인부터 시작한다. 다른 코드에 의존하지 않는 도메인(예: auth)을 먼저 분리한다. auth 관련 컴포넌트, 훅, 상수를 domain/auth/로 이동하고 import 경로를 업데이트한다.

2단계: 한 도메인씩 순차적으로 이동한다. auth가 안정되면 다음 도메인(comment, drawing 등)을 옮긴다. 매 단계마다 빌드가 정상인지 확인한다.

3단계: 공유 코드를 정리한다. 도메인 분리 과정에서 "이건 어디에 속하지?"라는 코드가 드러난다. 여러 도메인에서 쓰는 코드는 공유 영역(components/, hooks/, lib/)에 남긴다.

4단계: 배럴 파일을 정리한다. 순환 참조가 발생하는 배럴 파일을 삭제하고, 직접 경로 import로 전환한다.

실제로 이런 리팩토링을 하면, 커밋 메시지가 자연스럽게 도메인별로 정리된다. "refactor: Comment 도메인을 domain-based 구조로 이동"처럼 한 커밋이 하나의 도메인 이동을 담당하게 되어 리뷰도 쉬워진다.


언제 도입해야 하나

소규모 프로젝트에서는 기술 역할 기반이 더 낫다. 컴포넌트 20~30개 규모에서 도메인 구조를 도입하면 오히려 폴더만 깊어지고 복잡해진다. "components 폴더가 너무 커서 못 찾겠다"는 느낌이 들 때가 도입 시점이다.

반대로, 처음부터 규모가 클 것이 예상되는 프로젝트라면 초기부터 도메인 구조로 시작하는 것이 좋다. 나중에 마이그레이션하는 비용이 처음부터 잡는 비용보다 훨씬 크기 때문이다.

결국 폴더 구조는 "코드를 찾는 속도"와 "변경의 영향 범위"를 최적화하는 문제다. 도메인 기반 구조는 "이 기능과 관련된 코드가 전부 여기에 있다"는 확신을 준다. 그 확신이 개발 속도와 유지보수 품질을 높인다.


정리

  • 도메인 단위로 코드를 묶으면 관련 파일이 한 폴더에 모여서 탐색·수정·삭제가 빨라진다
  • 공유 코드는 별도 레이어로 분리하되, 도메인 간 의존은 단방향·최소화를 원칙으로 관리한다
  • 컴포넌트 50개 이상이거나 규모 확장이 예상되면 도메인 구조를 도입할 시점이다 — 작은 프로젝트에서는 기술 역할 기반이 더 간결하다

관련 문서