폴더를 나누는 일은 취향처럼 보이지만, 사실은 선언이다.
이 경계는 무엇을 청구하는가
파일을 어느 폴더에 둘지 정하는 일은 하루에도 여러 번 벌어집니다. 변수 이름을 짓는 것만큼이나 고민을 하는 경우도 있지만, 대개 별생각 없이 지나갑니다. 그러나 그 순간 우리는 이 코드가 무엇과 함께 바뀔 것인가를 선언하고 있습니다.
모듈 경계는 이 시리즈에서 가장 조용한 경계입니다. 네트워크 경계처럼 지연을 청구하지도 않고, 캐시 경계처럼 일관성을 청구하지도 않습니다. 청구되는 것은 다른 종류입니다.
- 탐색 비용: 한 기능을 고치기 위해 열어야 하는 폴더의 개수가 달라집니다.
- 삭제 비용: 어떤 기능을 걷어낼 때, 지워도 되는 파일을 판단하는 데 시간이 듭니다.
- 오염 비용: 경계가 잘못 그어지면, 아무 관계 없는 코드가 서로를 참조하기 시작합니다.
이 비용들은 즉시 청구되지 않습니다. 프로젝트 3개월 차에는 어떤 구조든 잘 굴러갑니다. 청구서는 2년 차쯤에 도착합니다. 그때는 이미 구조를 바꾸는 비용이 유지하는 비용보다 커져 있습니다.
모듈 경계가 답하는 질문은 하나입니다. 이 코드들은 왜 같은 덩어리에 있는가.
이 경계에 붙어 있는 이름들
응집도와 결합도
1970년대에 정리된 두 단어가 아직도 기준이 되고 있습니다.
응집도(cohesion) 는 한 모듈 안의 것들이 얼마나 서로 관련 있는가입니다.
결합도(coupling) 는 모듈과 모듈이 얼마나 서로를 알고 있는가입니다.
목표는 언제나 같습니다. 안은 촘촘하게, 밖은 느슨하게 만드는 것입니다.
여기서 흔한 오해가 하나 생깁니다. “관련 있다”를 모양이 비슷하다로 판단해버리는 것입니다. 모든 버튼은 버튼끼리, 모든 훅은 훅끼리, 모든 타입은 타입끼리 모으면 정리된 것처럼 보입니다. 그러나 함께 바뀌지 않는 것들을 모아둔 것뿐입니다.
진짜 기준은 변경의 이유입니다. 같은 이유로 같이 바뀌는 것들이 한 모듈입니다.
Package by Layer
가장 흔한 구조입니다. 기술적 역할로 폴더를 나눕니다.
src/
├── components/
│ ├── Button.tsx
│ ├── CheckoutForm.tsx
│ └── ProductCard.tsx
├── hooks/
│ ├── useAuth.ts
│ ├── useCheckout.ts
│ └── useProducts.ts
├── api/
│ ├── auth.ts
│ ├── checkout.ts
│ └── products.ts
└── types/
├── auth.ts
├── checkout.ts
└── products.ts
깔끔해 보입니다. 그리고 결제 로직을 고치려면 네 폴더를 모두 열어야 합니다.
이 구조의 문제는 변경의 축과 폴더의 축이 직교한다는 점입니다. 우리는 “결제”를 단위로 일하는데, 폴더는 “컴포넌트·훅·API·타입”을 단위로 나뉘어 있습니다. 매 작업이 폴더를 가로지릅니다.
기능을 삭제할 때 더 분명해집니다. 결제 기능을 걷어내려면 네 폴더에서 파일을 찾아 지워야 하고, 하나라도 남기면 죽은 코드가 됩니다.
Package by Feature
변경의 축에 폴더를 맞춥니다.
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ ├── use-auth.ts
│ │ ├── api.ts
│ │ ├── types.ts
│ │ └── index.ts ← 공개 표면
│ ├── checkout/
│ │ ├── components/
│ │ ├── use-checkout.ts
│ │ ├── api.ts
│ │ ├── types.ts
│ │ └── index.ts
│ └── products/
└── shared/
├── ui/ ← Button, Input 등
├── lib/
└── config/
결제를 고치려면 features/checkout/만 열면 됩니다. 결제를 지우려면 폴더를 통째로 지우면 됩니다. 삭제 가능성이 곧 경계의 품질을 재는 자입니다.
index.ts가 모듈의 공개 표면입니다. 외부는 이 파일에 없는 것을 가져다 쓸 수 없습니다. 내부는 자유롭게 바뀔 수 있습니다.
// features/checkout/index.ts
export { CheckoutForm } from './components/checkout-form'
export { useCheckout } from './use-checkout'
export type { CheckoutState } from './types'
// 내부 구현은 내보내지 않습니다
// calculateTax, validateCardNumber 등은 밖에서 보이지 않습니다
안티패턴
배럴 파일의 과용: index.ts로 재수출하는 것은 좋은 도구입니다. 그러나 모든 폴더마다 배럴을 두면 문제가 생깁니다. 순환 참조가 쉽게 만들어지고, 트리 셰이킹이 깨지고, 파일 하나를 고쳐도 번들러가 그래프 전체를 다시 계산합니다. 배럴은 모듈의 공개 표면에만 둡니다. 내부 하위 폴더에는 두지 않습니다.
shared의 비대화: 두 기능이 함께 쓰는 코드를 shared/로 옮기는 일은 자연스럽습니다. 그러나 그 판단이 반복되면 shared/가 프로젝트에서 가장 큰 폴더가 됩니다. 그 시점에 경계는 사라져 있습니다. 두 곳에서 쓴다고 바로 옮기지 않습니다. 세 번째 사용처가 나타날 때 옮깁니다. 그전까지는 복제해 두는 편이 나을 수도 있습니다.
모양 기준 분류: hooks/, utils/, helpers/, constants/ 같은 폴더는 모양으로 모은 것입니다. utils/는 특히 위험합니다. 무엇을 넣어도 어색하지 않기 때문에, 곧 아무 관계 없는 함수 50개가 모인 폴더가 됩니다.
순환 참조: A가 B를 가져오고 B가 A를 가져오면, 두 모듈은 사실 하나입니다. 경계가 있다고 착각하고 있을 뿐입니다. TypeScript는 이것을 대체로 허용하므로, eslint-plugin-import의 no-cycle 규칙 같은 도구로 강제하는 편이 안전합니다.
모듈 경계와 청크 경계는 다릅니다
프론트엔드에는 함정이 하나 더 있습니다. 소스 코드의 모듈 경계와, 브라우저가 실제로 내려받는 번들 청크 경계는 같지 않습니다.
features/checkout/을 깔끔하게 분리해 두어도, 어느 컴포넌트가 그것을 정적으로 import 하면 초기 번들에 통째로 들어갑니다. 폴더는 나뉘어 있지만 다운로드는 나뉘지 않습니다.
// 정적 import — 초기 번들에 포함됩니다
import { CheckoutForm } from '@/features/checkout'
// 동적 import — 별도 청크로 분리됩니다
const CheckoutForm = lazy(() =>
import('@/features/checkout').then(m => ({ default: m.CheckoutForm })),
)
소스의 경계는 사람이 읽는 단위이고, 청크의 경계는 브라우저가 내려받는 단위입니다. 둘을 일치시킬지 어긋내게 둘지는 별도의 결정이며, 그 결정은 ep.11 배포 경계에서 다시 다룹니다.
이 경계가 화면에 드러나는 자리
모듈 경계는 사용자에게 직접 보이지 않습니다. 그러나 디자인 조직에는 정확히 같은 질문이 있습니다.
‘Button은 시스템이고 CheckoutButton은 프로덕트다.’ 이 문장은 폴더 구조에 대한 문장이면서 동시에 조직에 대한 문장입니다. 어디까지가 디자인 시스템이고 어디부터가 개별 프로덕트인가.
선을 긋는 기준은 코드와 같습니다. 함께 바뀌는가.
- 결제 화면이 바뀔 때 함께 바뀌어야 한다면 → 프로덕트
- 결제 화면이 바뀌어도 그대로여야 한다면 → 시스템
Button은 결제 화면 개편과 무관하게 그대로여야 합니다. 그래서 시스템입니다. CheckoutButton은 결제 플로우가 바뀌면 함께 바뀝니다. 그래서 프로덕트입니다.
이 선을 잘못 그으면 나타나는 증상이 있습니다.
프로덕트 일정에 시스템이 묶입니다: CheckoutButton이 디자인 시스템 저장소에 들어가면, 결제팀의 스프린트가 시스템 릴리스를 기다립니다. 시스템 팀은 자기 일이 아닌 요구사항을 받게 됩니다.
시스템이 프로덕트 내부에 파편화됩니다: 반대로 선을 너무 좁게 그으면, 각 프로덕트가 자기만의 버튼을 만듭니다. 여섯 개의 비슷한 버튼이 여섯 저장소에 살게 됩니다.
코드에서 shared/가 비대해지는 문제와 정확히 같은 구조입니다. 두 프로덕트가 같은 컴포넌트를 원한다고 바로 시스템에 올리지 않습니다. 세 번째가 나타날 때 올립니다. 그전까지는 각자 갖고 있는 편이 낫습니다. 복제가 잘못된 추상화보다 쌉니다.
디자인 토큰에도 같은 판단이 적용됩니다. color.brand.primary는 시스템입니다. color.checkout.cta는 프로덕트입니다. 후자가 시스템 토큰 파일에 들어가는 순간, 토큰 파일은 모든 프로덕트의 요구사항을 받는 utils/ 폴더가 됩니다.
참고 자료
- Yourdon, E., & Constantine, L. (1979). Structured Design. Prentice-Hall.
- Martin, R. C. (2017). Clean Architecture. Prentice Hall.: 컴포넌트 응집 원칙(REP·CCP·CRP)
- Feature-Sliced Design. https://feature-sliced.design/
- Fowler, M. (2015). PresentationDomainDataLayering. https://martinfowler.com/bliki/PresentationDomainDataLayering.html
다음 편 예고
모듈을 나누고 나면 다음 질문이 옵니다. 이 모듈은 저 모듈을 알아도 되는가. 화살표의 방향 하나가 시스템 전체의 교체 가능성을 결정합니다.