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