Nguyen Le Phong

소프트웨어 아키텍처 기초6부 중 4부

코드베이스 구조화: 레이어별, 기능별, 또는 도메인별?

최상위 폴더가 controllers/services/models여야 할까요, 아니면 orders/billing/auth여야 할까요? 이 선택은 조용히 코드베이스의 성장 방식을 결정합니다. 레이어별, 기능별, 소리치는 아키텍처의 실용적인 여행 — 각 회사 규모별 트레이드오프와 함께.

새 프로젝트를 열고 가장 먼저 하는 것은 — 로직 한 줄도 쓰기 전에 — 폴더를 만드는 것입니다. 아마 controllers/라고 부를 수도 있습니다. 아마 orders/라고 부를 수도 있습니다. 어느 쪽이든, 그 조용한 선택은 이미 아키텍처 결정이며, 이후에 일어나는 모든 것을 조용히 형성할 것입니다: 새 팀원이 얼마나 빨리 적응하는지, 하나의 기능이 변할 때 얼마나 많은 파일을 수정해야 하는지, 그리고 6개월 후 코드베이스가 비즈니스처럼 읽히는지 프레임워크처럼 읽히는지.

이 글은 세 가지 주요 접근법 — 레이어별, 기능별, 소리치는(도메인별) — 의 실용적인 여행으로, 각 회사 규모별 솔직한 트레이드오프를 담고 있습니다. 모든 상황에 맞는 단일 답변은 없지만, 기본값 대신 의도적으로 선택할 수 있는 명확한 정신 모델을 갖게 될 것입니다.

왜 폴더 구조가 자전거 창고 논쟁이 아닌 아키텍처 결정인가

사람들은 폴더 구조를 "단순한 정리"라고 일축합니다. 그것은 그것보다 훨씬 더 중요합니다. 코드를 그룹화하는 방식은 실제로 중요한 세 가지를 제어합니다:

  • 결합. 함께 변하는 코드가 멀리 떨어져 있으면, 모든 작은 업데이트가 폴더를 넘나드는 탐험이 됩니다. 함께 있으면, 관련 변경들이 한 곳에 모입니다.
  • 발견 가능성. 코드베이스에 익숙하지 않은 개발자는 "청구(billing) 코드는 어디에 있나요?"라고 묻습니다. 그 질문의 답 — 한 폴더, 또는 다섯 개의 흩어진 폴더 — 이 온보딩 시간을 결정합니다.
  • 영향 범위. 새 기능을 출시하거나 오래된 것을 안전하게 삭제하려면 얼마나 많은 파일을 열어야 하나요? 구조는 그 영향 범위를 제한하거나 무제한으로 확산시킵니다.
정신 모델

폴더 구조를 지도로 생각하세요. 좋은 지도는 실제 세계에서 관련된 방식으로 사물을 그룹화합니다 — 어떤 재료로 만들어졌는지가 아니라. 타입별로 파일을 그룹화하는 것(컨트롤러, 모델)은 지도 아이콘을 모양으로 정렬하는 것과 같습니다. 기능별로 그룹화하는 것은 이웃으로 정렬하는 것과 같습니다. 한 가지는 탐색에 도움이 되고; 다른 것은 기술적으로 맞지만 실용적으로는 쓸모없습니다.

Conway의 법칙 — 소프트웨어 시스템이 그것을 구축하는 팀의 소통 구조를 반영한다는 관찰 — 도 여기에 적용됩니다. 팀이 비즈니스 기능을 중심으로 구성되어 있다면(결제 스쿼드, 주문 스쿼드), 기능별 구조가 자연스럽게 느껴지고 올바른 경계를 강제합니다. 레이어별 구조는 여러분과 싸울 것입니다.

레이어별: 익숙한 기본값

레이어별 구성은 기술적인 역할로 파일을 그룹화합니다. 일반적인 Node나 Spring Boot 프로젝트는 다음과 같이 됩니다:

src/
├─ controllers/
│  ├─ OrderController.ts
│  ├─ UserController.ts
│  └─ BillingController.ts
├─ services/
│  ├─ OrderService.ts
│  ├─ UserService.ts
│  └─ BillingService.ts
├─ repositories/
│  ├─ OrderRepository.ts
│  ├─ UserRepository.ts
│  └─ BillingRepository.ts
└─ models/
   ├─ Order.ts
   ├─ User.ts
   └─ Invoice.ts
레이어별 구조: 모든 기술 레이어가 자체 최상위 폴더를 갖습니다. 기능을 추가하면 네 개 모두의 폴더에 파일을 추가해야 합니다.

모든 웹 프레임워크 튜토리얼이 여기서 시작합니다. Rails도, Spring Boot의 스타터도, Express 생성기도 그렇습니다. 그 친숙함이 주요 장점입니다.

레이어별이 빛나는 곳:

  • 기능이 두세 개인 소규모 앱 — 구조가 너무 평평해서 거의 중요하지 않습니다.
  • 모든 사람이 모든 기능을 담당하는 팀; 레이어가 전문화의 단위입니다.
  • 초기 설정 속도가 장기적 명확성보다 중요한 프로토타입과 내부 도구.

레이어별이 아픈 곳:

  • 단일 기능이 폴더에 흩어집니다. "환불" 기능을 추가하면 controllers/, services/, repositories/, models/를 수정해야 합니다. 개념적으로 하나인 것에 대해 네 개의 폴더입니다.
  • 기능 삭제가 고고학 프로젝트입니다. services/의 어떤 파일이 청구와 주문에 속하는지 어떻게 알 수 있나요? 폴더 이름이 알려주지 않습니다.
  • 병합 충돌이 증가합니다. 모든 기능 변경이 같은 레이어 수준 파일을 수정하여, 두 개발자가 병렬로 작업할 때 충돌을 일으킵니다.
확장성 절벽

레이어별은 10~15개 기능까지는 잘 작동합니다. 그 이후부터, 각 최상위 폴더가 잡동사니 서랍이 됩니다: 40개 파일짜리 services/ 폴더, 명확한 소유권 없음, 그리고 검토자가 이전에 본 적 없는 파일을 수정하는 풀 리퀘스트.

기능별: 변경이 하나의 폴더에 삽니다

기능별 구성은 서비스하는 제품 슬라이스별로 파일을 그룹화합니다. 기술 레이어(컨트롤러, 서비스, 모델)는 여전히 존재합니다 — 단지 최상위 수준이 아니라 각 기능 폴더 안에 있을 뿐입니다:

src/
├─ orders/
│  ├─ OrderController.ts
│  ├─ OrderService.ts
│  ├─ OrderRepository.ts
│  └─ Order.ts
├─ billing/
│  ├─ BillingController.ts
│  ├─ BillingService.ts
│  ├─ BillingRepository.ts
│  └─ Invoice.ts
├─ auth/
│  ├─ AuthController.ts
│  ├─ AuthService.ts
│  └─ User.ts
└─ shared/
   ├─ database.ts
   ├─ logger.ts
   └─ Money.ts
기능별 구조: 각 제품 개념이 자체 폴더를 소유합니다. 환불 기능을 추가하면 폴더 하나가 추가됩니다 — 다른 것은 아무것도 이동하지 않습니다.

기능별이 빛나는 곳:

  • 변경이 하나의 폴더에 삽니다. 환불 흐름을 추가하면 billing/ 안에 파일을 추가하는 것입니다 — 다른 폴더는 수정되지 않습니다. 영향 범위가 작고 국소적입니다.
  • 기능 삭제가 단순합니다. 폴더를 제거하세요. 외부에서 아직 그것을 import한다면, IDE가 즉시 알려줄 것입니다.
  • 소유권이 명확합니다. 결제 팀이 billing/을 소유하고; 신원 팀이 auth/를 소유합니다. 모노레포 도구와 코드 리뷰 규칙이 이것을 기계적으로 강제할 수 있습니다.
  • 온보딩이 더 빠릅니다. 주문을 담당하는 신입 직원은 orders/만 읽습니다. 먼저 전체 코드베이스를 이해할 필요가 없습니다.

기능별이 주의가 필요한 곳:

  • 공유 코드 배치가 어색합니다. Money 타입은 orders/billing/ 모두에서 사용됩니다. orders/ 안에 있어야 할까요? 그건 이상합니다. shared/에 들어가게 되는데 — 아무도 소유하지 않는다면 그것 자체가 두 번째 잡동사니 서랍이 될 수 있습니다.
  • 기능 간 의존성에 훈련이 필요합니다. orders/OrderService.tsbilling/BillingService.ts에서 직접 import한다면, 폴더 구조가 볼 수 없는 결합을 만든 것입니다. 팀들은 린팅 규칙(eslint-plugin-import/no-restricted-paths)이나 모듈 경계 도구(Nx, NestJS 모듈)를 사용하여 이것을 명시적으로 만듭니다.

소리치는 아키텍처: 최상위 수준이 무엇을 말하는가?

Uncle Bob(Robert C. Martin)은 유명한 블로그 포스트에서 소리치는 아키텍처(Screaming Architecture)라는 용어를 만들었습니다: "당신의 애플리케이션 아키텍처는 무엇을 소리치는가? 최상위 디렉터리 구조와 가장 상위 패키지의 소스 파일을 볼 때; 그것들이 소리치는가: 의료 시스템, 또는 회계 시스템, 또는 재고 관리 시스템? 아니면 소리치는가: Rails, 또는 Spring/Hibernate, 또는 ASP.Net?"

통찰이 우아합니다: 최상위 폴더가 controllers/, models/, views/라면, 코드베이스는 MVC 프레임워크를 소리치고 있습니다. 프레임워크는 도구입니다 — 독자가 처음 접하는 것이 되어서는 안 됩니다.

소리치는 아키텍처(또는 도메인별이라고도 불림)는 기능별에서 한 단계 더 나아갑니다. 기능이 최상위 수준에만 있는 것이 아니라 — 그 기능들의 이름이 기술적인 역할이 아닌 비즈니스 언어(도메인 주도 설계의 유비쿼터스 언어)에서 직접 나옵니다:

// "이커머스 플랫폼"을 소리칩니다
src/
├─ catalog/          // 도메인 개념: 제품 카탈로그
├─ ordering/         // 도메인 개념: 주문 배치 및 추적
├─ fulfillment/      // 도메인 개념: 수령, 포장, 발송
├─ payments/         // 도메인 개념: 청구, 환불, 인보이스
└─ identity/         // 도메인 개념: 계정, 인증, 권한

// 프레임워크를 소리치는 대안과 비교:
src/
├─ controllers/      // "MVC"를 소리침
├─ models/           // "ORM"을 소리침
├─ views/            // "템플릿 엔진"을 소리침
└─ services/         // "서비스 레이어 패턴"을 소리침

각 도메인 폴더 내부의 레이아웃은 팀에게 달려 있습니다 — 그것은 괜찮습니다. 핵심 약속은 최상위 수준이 인프라가 아닌 비즈니스를 전달한다는 것입니다. 새 개발자(또는 CTO, 또는 비즈니스 쪽의 도메인 전문가)가 최상위 폴더를 읽고 어떻게 구축되었는지 모르면서도 시스템이 무엇을 하는지 이해할 수 있어야 합니다.

나란히 비교

다섯 가지 실용적인 차원에서의 솔직한 평가입니다:

차원 레이어별 기능별 도메인별 (소리침)
발견 가능성 규모가 커지면 낮음 — "청구 코드는 어디에 있나요?"에 빠른 답이 없습니다 높음 — 개념당 하나의 폴더 가장 높음 — 폴더 이름이 비즈니스 자체에서 옵니다
변경 지역성 나쁨 — 하나의 기능 변경이 모든 레이어에 흩어집니다 좋음 — 변경이 하나의 폴더에 클러스터됩니다 좋음 — 기능별과 같고, 명시적인 도메인 언어 추가
온보딩 속도 느림 — 신입이 무언가를 수정하려면 전체 레이어를 이해해야 합니다 빠름 — 하나의 기능을 담당하려면 하나의 폴더를 공부하세요 가장 빠름 — 도메인 이름이 사전 컨텍스트 없이도 이해를 안내합니다
결합 위험 높음 — 공유 레이어가 묵시적 결합 표면이 됩니다 중간 — 기능 간 import가 가능하지만 보입니다 낮음 — 명시적 경계 컨텍스트와 강제된 경계
"빅볼오브머드" 위험 높음 — 기능이 증가하면서 레이어가 잡동사니 서랍이 됩니다 중간 — shared/ 폴더가 무제한으로 커질 수 있습니다 낮음 — 도메인 경계가 초기에 혼돈을 가시화합니다

좋은 팀들이 도달하는 실용적인 하이브리드

실제로는, 좋은 팀들이 하나의 접근법을 선택하고 종교적인 일관성으로 모든 곳에 적용하지 않습니다. 그들은 실용적인 하이브리드를 사용합니다: 최상위에는 기능 또는 도메인 폴더, 각각의 내부에는 작은 기술 레이어.

하이브리드 구조: 최상위에 도메인/기능 폴더, 각 내부에 기술 레이어. PRAGMATIC HYBRID — domain on top, layers inside src/ orders/ billing/ shared/ controller.ts service.ts repository.ts Order.ts controller.ts service.ts repository.ts Invoice.ts Money.ts logger.ts database.ts
대부분의 팀이 수렴하는 하이브리드: 최상위 수준에서 도메인/기능, 그런 다음 각 폴더 내부에 작은 기술 레이어. 기술 레이어는 여전히 있습니다 — 단지 처음 보는 것이 아닐 뿐입니다.

이 하이브리드는 두 가지 세계의 최선을 제공합니다:

  • 최상위 수준이 비즈니스를 소리칩니다 — 신입들은 즉시 이것이 이커머스 앱임을 알고, 일반적인 MVC 스켈레톤이 아닙니다.
  • 각 기능이 자기 완결적입니다 — 청구 흐름의 변경은 billing/만 수정합니다.
  • 기술 레이어는 여전히 각 폴더 내부에 존재합니다, 따라서 MVC나 Clean Architecture 레이어로 생각하는 개발자들이 길을 잃지 않습니다. 기능 폴더 안을 보면 됩니다.
  • shared/ 폴더는 진정으로 가로로 잘라내는 관심사(Money 값 객체, 로거, 데이터베이스 연결)를 담습니다. 핵심 규칙: shared/에 무언가가 추가된다면, 의도적으로 추출되고 소유되어야 합니다 — 탈출구로 거기에 던져두는 것이 아닙니다.
공유 폴더 이름 짓기

어떤 팀들은 shared/라고 부르고, 다른 팀들은 common/, lib/, 또는 core/라고 합니다. 이름은 훈련보다 덜 중요합니다: 미니 내부 라이브러리처럼 취급하세요. 파일이 특정 기능과 함께 계속 수정된다면, 그것은 shared에 속하지 않습니다 — 그 기능 안에 속합니다.

팀 규모에서: 강제된 경계와 모노레포

코드베이스가 성장하고 여러 팀이 기여하면서, 폴더 규칙만으로는 충분하지 않습니다 — 개발자는 언제든 빠른 상대 import로 경계를 넘을 수 있습니다. 다음 단계는 경계를 강제하는 것입니다:

  • NestJS 모듈은 모듈 간 의존성을 명시적으로 만듭니다: 다른 모듈이 exports하는 것에만 접근할 수 있습니다. 공개 API를 거치지 않고 모듈 경계를 넘는 import는 컴파일 오류입니다.
  • Nx 경계 규칙은 어떤 기능이나 라이브러리가 어떤 다른 것에 의존할 수 있는지 선언하고, CI에서 린트 오류로 강제할 수 있게 합니다.
  • 모노레포는 이것을 가장 멀리 가져갑니다: 각 도메인(주문, 청구, 인증)은 자체 package.json, 자체 테스트, 명시적인 공개 export를 가진 자체 패키지가 됩니다. Turborepo와 Nx는 이를 관리하기 위한 JavaScript 생태계의 주요 도구입니다. 구조는 폴더 트리보다 패키지 그래프처럼 보입니다 — 하지만 기본 아이디어는 동일합니다: 비즈니스 기능으로 그룹화하고, 경계를 명시적으로 만들고, 기계적으로 강제합니다.

모노레포로의 도약이 항상 필요한 것은 아닙니다. 많은 팀들이 단일 패키지와 잘 훈련된 하이브리드 구조로 수년간 행복하게 운영합니다. 팀 간 결합이 실제 고통이 될 때 모노레포 도구를 사용하세요 — 선제적으로 하지 마세요.

회사 규모별 권장 사항

올바른 구조는 오늘 조직이 어디에 있는지에 달려 있습니다. 각 단계에 대한 직접적인 권장 사항입니다:

단계 선택할 구조 이유 실제 힌트
솔로 / 출시 전 스타트업 레이어별 또는 얕은 기능별 기능이 3~5개 있습니다. 어느 구조든 작동합니다. 속도와 단순성을 최적화하세요. 대부분의 Rails/Express 튜토리얼 기본값이 여기서는 괜찮습니다. 과도하게 설계하지 마세요.
성장하는 스타트업 (5~20명 엔지니어) shared/ 폴더를 가진 기능별 기능이 늘어나고 있습니다. 레이어별이 아프기 시작합니다. 결제 변경이 주문에 파급되지 않아야 합니다. 많은 성공적인 Series A 회사들이 여기서 삽니다: 하나의 레포, 하나의 서비스, 기능이 최상위 폴더로.
스케일업 (20~100명 엔지니어) 경계 린트 규칙을 가진 하이브리드 (최상위에 도메인, 내부에 레이어) 팀들이 도메인을 소유합니다. 우발적인 크로스 도메인 결합이 실제 위험입니다. CI에서 경계를 강제하세요. 이것이 Nx/NestJS 모듈의 최적 지점입니다. Shopify의 핵심 Rails 앱이 이 단계에서 강한 도메인 분리를 채택한 것으로 유명합니다.
엔터프라이즈 (100명+ 엔지니어) 도메인 패키지를 가진 모노레포, 또는 경계 컨텍스트당 독립 서비스 패키지 수준 격리, 독립 배포, 도메인 간 버전화된 공개 API가 필요해집니다. Google, Meta, Airbnb는 엄격한 소유권을 가진 거대한 모노레포를 운영합니다. 많은 엔터프라이즈들은 반대 방향으로 갑니다: 도메인당 마이크로서비스, 이것이 소리치는 아키텍처의 논리적 결론입니다.
흔한 실수

가장 흔한 구조적 실수는 잘못된 접근법을 선택하는 것이 아닙니다 — 너무 오래 잘못된 접근법에 머무는 것입니다. 팀들이 레이어별로 시작하고, 20개 기능에서 고통을 느끼면서도, "기술적으로는 작동하니까"라는 이유로 리팩터링하지 않습니다. 15개 기능에서 오후 한 나절 짜리 폴더 재구성이 50개에서의 수개월간의 혼란을 절약합니다.

차이 보기: 기능을 출시할 때 무엇이 켜지는가

레이어별과 기능별의 차이를 느끼는 가장 구체적인 방법은 묻는 것입니다: 새 기능을 추가할 때 어떤 박스들이 켜지는가?

레이어별: 새 기능이 네 개의 레이어 박스 모두를 수정합니다. 기능별: 새 기능이 하나의 박스만 수정합니다. BY LAYER — adding "refunds" controllers/ ← touched services/ ← touched repositories/ ← touched models/ ← touched 4 folders touched high blast radius BY FEATURE — adding "refunds" orders/ billing/ ← touched auth/ shared/ 1 folder touched contained blast radius
"환불" 기능 추가하기. 왼쪽(레이어별), 네 개의 기술 레이어가 모두 켜집니다 — 모든 레이어 폴더가 수정됩니다. 오른쪽(기능별), billing/만 켜집니다. 코드베이스의 나머지는 이 변경으로부터 구조적으로 격리됩니다.

이 다이어그램은 기능 기반 그룹화에 대한 가장 명확한 주장입니다. "영향 범위"는 추상적인 개념이 아닙니다 — 단일 풀 리퀘스트에서 여는 폴더와 파일의 수입니다. 검토자, CI 파이프라인, git blame은 기능 변경이 하나의 응집된 덩어리일 때, 모든 레이어에 흩어진 것이 아닐 때 모두 더 잘 작동합니다.

핵심 요약

  • 구조는 아키텍처입니다. 폴더 레이아웃은 결합, 발견 가능성, 그리고 모든 미래 변경의 영향 범위를 제어합니다.
  • 레이어별은 친숙하고 소규모 앱에 적합하지만, 10~15개 기능을 넘으면 잡동사니 서랍이 됩니다.
  • 기능별은 변경을 국소적으로 유지합니다. 새 기능은 하나의 폴더를 수정합니다. 기능을 삭제하면 하나의 폴더를 삭제합니다.
  • 소리치는 아키텍처는 최상위 수준이 프레임워크가 아닌 비즈니스를 설명해야 한다고 합니다. 비기술적인 이해관계자가 폴더 이름을 읽고 시스템이 무엇을 하는지 이해할 수 있다면, 성공한 것입니다.
  • 실용적인 하이브리드가 좋은 팀들이 도달하는 곳입니다: 최상위에 도메인/기능 이름, 각 폴더 내부에 작은 기술 레이어.
  • 규모가 커질수록 경계를 강제하세요. 폴더 규칙은 제안입니다; 모듈 시스템, 린트 규칙, 모노레포 패키지가 그것을 보증으로 만듭니다.
  • 일찍 재구성하세요 — 15개 기능에서 오후 한 나절 짜리 폴더 리팩터가 50개에서의 수개월간의 혼란을 피합니다.

단일 서비스 내에서 코드를 구조화하는 방법을 보았으니, 자연스럽게 다음 질문은 하나의 서비스로 충분하지 않을 때 무엇을 해야 하는가입니다. 계속 읽어보세요: 모놀리스 → 마이크로서비스.