코드베이스 설계
**깊은 모듈(deep modules)**을 설계하세요: 작은 인터페이스 뒤에 많은 행동(behavior)이 있고, 깨끗한 경계(seam)에 위치하며, 그 인터페이스를 통해 테스트 가능합니다. 코드를 설계하거나 재구성할 때마다 이 언어와 원칙을 사용하세요. 목표는 호출자에게 레버리지(leverage)를, 유지보수자에게 지역성(locality)을, 모든 사람에게 테스트 용이성을 제공하는 것입니다.
용어집
다음 용어를 정확히 사용하세요: '컴포넌트', '서비스', 'API', '경계(boundary)'로 대체하지 마세요. 일관된 언어가 핵심입니다.
모듈(Module): 인터페이스와 구현이 있는 모든 것. 의도적으로 규모에 무관(scale-agnostic)하며 함수, 클래스, 패키지, 또는 계층을 넘나드는 조각일 수 있습니다. 피할 것: 유닛, 컴포넌트, 서비스.
인터페이스(Interface): 호출자가 모듈을 올바르게 사용하기 위해 알아야 할 모든 것: 타입 시그니처뿐만 아니라, 불변 조건, 순서 제약, 오류 모드, 필수 구성, 성능 특성까지 포함합니다. 피할 것: API, 시그니처(너무 좁으며 타입 수준의 표면만 가리킵니다).
구현(Implementation): 모듈 내부, 코드 본문. **어댑터(Adapter)**와 구분: 어떤 것은 큰 구현을 가진 작은 어댑터(PostgreSQL 저장소)일 수도 있고, 작은 구현을 가진 큰 어댑터(인메모리 테스트 대역)일 수도 있습니다. 경계(seam)가 주제일 때는 '어댑터'를, 그 외에는 '구현'을 사용하세요.
깊이(Depth): 인터페이스에서의 레버리지. 호출자(또는 테스트)가 배워야 할 인터페이스 단위당 행사할 수 있는 행동의 양입니다. 모듈이 깊다는 것은 많은 양의 행동이 작은 인터페이스 뒤에 있는 경우이고, 얕다는 것은 인터페이스가 구현만큼이나 복잡한 경우입니다.
경계(Seam) (Michael Feathers): 그 자리에서 편집하지 않고도 행동을 변경할 수 있는 곳; 모듈의 인터페이스가 위치하는 장소. 경계를 어디에 둘지는 그 자체로 설계 결정이며, 그 뒤에 무엇을 둘지와는 별개입니다. 피할 것: 경계(boundary, DDD의 bounded context와 중의적임).
어댑터(Adapter): 경계에서 인터페이스를 만족하는 구체적인 것. 역할(어떤 자리를 채우는지)을 설명할 뿐, 본질(내부가 무엇인지)은 설명하지 않습니다.
레버리지(Leverage): 호출자가 깊이로부터 얻는 것. 배우는 인터페이스 단위당 더 많은 능력. 하나의 구현이 N개의 호출 지점과 M개의 테스트에 걸쳐 보답합니다.
지역성(Locality): 유지보수자가 깊이로부터 얻는 것. 변경, 버그, 지식, 검증이 호출자에게 퍼지지 않고 한 곳에 집중됩니다. 한 번 수정하면 모든 곳이 수정됩니다.
깊음 vs 얕음
깊은 모듈 = 작은 인터페이스 + 많은 구현:
┌─────────────────────┐
│ 작은 인터페이스 │ ← 적은 메서드, 단순한 매개변수
├─────────────────────┤
│ │
│ 깊은 구현 │ ← 복잡한 로직이 숨겨짐
│ │
└─────────────────────┘
얕은 모듈 = 큰 인터페이스 + 적은 구현 (피하세요):
┌─────────────────────────────────┐
│ 큰 인터페이스 │ ← 많은 메서드, 복잡한 매개변수
├─────────────────────────────────┤
│ 얇은 구현 │ ← 그냥 통과만 시킴
└─────────────────────────────────┘
인터페이스를 설계할 때 다음을 물어보세요:
- 메서드 수를 줄일 수 있나요?
- 매개변수를 단순화할 수 있나요?
- 더 많은 복잡성을 내부에 숨길 수 있나요?
원칙
- 깊이는 인터페이스의 속성이지, 구현의 속성이 아닙니다. 깊은 모듈은 내부적으로 작고, 목업 가능하고, 교체 가능한 부품들로 구성될 수 있습니다. 그 부품들은 단지 인터페이스의 일부가 아닐 뿐입니다. 모듈은 내부 경계(internal seams)(구현에 비공개이며 자체 테스트에서 사용됨)와 외부 경계(external seam)(인터페이스에 있음)를 모두 가질 수 있습니다.
- 삭제 테스트. 모듈을 삭제한다고 상상해보세요. 복잡성이 사라지면 통과용(pass-through)이었습니다. 복잡성이 N명의 호출자에게 다시 나타나면 그 가치를 하고 있었습니다.
- 인터페이스는 테스트 표면입니다. 호출자와 테스트는 동일한 경계를 넘나듭니다. 인터페이스 너머를 테스트하려고 한다면, 모듈의 형태가 잘못되었을 가능성이 큽니다.
- 하나의 어댑터는 가상의 경계입니다. 두 개의 어댑터는 실제 경계입니다. 실제로 어떤 것이 경계를 가로질러 변하지 않는 한 경계를 도입하지 마세요.
테스트 용이성을 위한 설계
좋은 인터페이스는 테스트를 자연스럽게 만듭니다:
-
의존성을 받아들이고, 생성하지 마세요.
// 테스트 가능 function processOrder(order, paymentGateway) {} // 테스트 어려움 function processOrder(order) { const gateway = new StripeGateway(); } -
결과를 반환하고, 부작용을 만들지 마세요.
// 테스트 가능 function calculateDiscount(cart): Discount {} // 테스트 어려움 function applyDiscount(cart): void { cart.total -= discount; } -
작은 표면적. 메서드가 적을수록 필요한 테스트도 적습니다. 매개변수가 적을수록 테스트 설정이 간단해집니다.
관계
- 모듈은 정확히 하나의 인터페이스(호출자와 테스트에 제시하는 표면)를 가집니다.
- 깊이는 모듈의 속성이며, 인터페이스에 대해 측정됩니다.
- 경계는 모듈의 인터페이스가 있는 곳입니다.
- 어댑터는 경계에 있으며 인터페이스를 만족합니다.
- 깊이는 호출자에게 레버리지를, 유지보수자에게 지역성을 제공합니다.
거부된 관점
- 구현 줄 수 대 인터페이스 줄 수의 비율로서의 깊이 (Ousterhout): 구현을 부풀리게 만듭니다. 우리는 레버리지로서의 깊이(Depth-as-leverage)를 사용합니다.
- TypeScript
interface키워드나 클래스의 public 메서드로서의 '인터페이스': 너무 좁습니다. 여기서 인터페이스는 호출자가 알아야 할 모든 사실을 포함합니다. - '경계(Boundary)': DDD의 bounded context와 중의적입니다. 경계(seam) 또는 인터페이스라고 말하세요.
더 깊이 들어가기
- 의존성이 주어진 클러스터 심화, DEEPENING.md 참조: 의존성 범주, 경계 규율, 그리고 계층화 대신 교체하는 테스트.
- 대안 인터페이스 탐색, DESIGN-IT-TWICE.md 참조: 병렬 하위 에이전트를 실행하여 인터페이스를 근본적으로 다른 여러 방식으로 설계한 후, 깊이, 지역성, 경계 배치 측면에서 비교합니다.