코드베이스 설계
깊은 모듈(deep module) 설계: 작은 인터페이스 뒤에 많은 동작을 숨기고, 깨끗한 경계(seam)에 배치하며, 해당 인터페이스를 통해 테스트 가능하게 만듭니다. 코드를 설계하거나 재구성할 때마다 이 언어와 원칙을 사용하세요. 목표는 호출자에게는 레버리지, 유지보수자에게는 지역성, 모두에게는 테스트 용이성입니다.
용어집
다음 용어를 정확히 사용하세요. '컴포넌트', '서비스', 'API', '경계(boundary)' 등으로 대체하지 마세요. 일관된 언어가 핵심입니다.
모듈(Module): 인터페이스와 구현을 가진 모든 것. 의도적으로 규모에 무관합니다: 함수, 클래스, 패키지, 계층을 가로지르는 조각 등. 피할 것: 유닛, 컴포넌트, 서비스.
인터페이스(Interface): 호출자가 모듈을 올바르게 사용하기 위해 알아야 할 모든 것: 타입 시그니처뿐만 아니라 불변 조건, 순서 제약, 오류 모드, 필요한 설정, 성능 특성까지 포함합니다. 피할 것: API, 시그니처 (너무 좁아서 타입 수준의 표면만 가리킴).
구현(Implementation): 모듈 내부에 있는 것, 그 코드 본체. **어댑터(Adapter)**와 구별됩니다: 어떤 것은 큰 구현을 가진 작은 어댑터(PostgreSQL 저장소)일 수도 있고, 작은 구현을 가진 큰 어댑터(인메모리 가짜 객체)일 수도 있습니다. 경계가 주제일 때는 '어댑터'를 사용하고, 그 외에는 '구현'을 사용하세요.
깊이(Depth): 인터페이스에서 얻는 레버리지. 호출자(또는 테스트)가 배워야 하는 인터페이스 단위당 실행할 수 있는 동작의 양. 모듈이 **깊다(deep)**는 것은 많은 동작이 작은 인터페이스 뒤에 숨겨져 있음을 의미하며, **얕다(shallow)**는 것은 인터페이스가 구현만큼이나 복잡함을 의미합니다.
경계(Seam) (마이클 훼더스): 해당 위치에서 직접 편집하지 않고도 동작을 변경할 수 있는 장소; 모듈의 인터페이스가 위치하는 지점. 경계를 어디에 둘지는 그 자체로 별도의 설계 결정이며, 그 뒤에 무엇을 넣을지와는 구별됩니다. 피할 것: boundary (DDD의 bounded context와 혼동됨).
어댑터(Adapter): 경계에서 인터페이스를 충족하는 구체적인 대상. 역할(어떤 슬롯을 채우는지)을 설명하며, 본질(내부에 무엇이 있는지)은 설명하지 않습니다.
레버리지(Leverage): 호출자가 깊이로부터 얻는 것. 배운 인터페이스 단위당 더 많은 기능. 하나의 구현이 N개의 호출 지점과 M개의 테스트에 걸쳐 보답합니다.
지역성(Locality): 유지보수자가 깊이로부터 얻는 것. 변경, 버그, 지식, 검증이 여러 호출자로 퍼지지 않고 한 곳에 집중됩니다. 한 번 고치면 모든 곳에서 고쳐집니다.
깊은 vs 얕은
깊은 모듈 = 작은 인터페이스 + 많은 구현:
┌─────────────────────┐
│ 작은 인터페이스 │ ← 적은 메서드, 단순한 매개변수
├─────────────────────┤
│ │
│ 깊은 구현 │ ← 복잡한 로직이 숨겨짐
│ │
└─────────────────────┘
얕은 모듈 = 큰 인터페이스 + 적은 구현 (피할 것):
┌─────────────────────────────────┐
│ 큰 인터페이스 │ ← 많은 메서드, 복잡한 매개변수
├─────────────────────────────────┤
│ 얇은 구현 │ ← 그냥 통과만 시킴
└─────────────────────────────────┘
인터페이스를 설계할 때 다음을 질문하세요:
- 메서드 수를 줄일 수 있는가?
- 매개변수를 단순화할 수 있는가?
- 더 많은 복잡성을 내부에 숨길 수 있는가?
원칙
- 깊이는 인터페이스의 속성이지 구현의 속성이 아니다. 깊은 모듈은 내부적으로 작고, 모킹 가능하며, 교체 가능한 부품들로 구성될 수 있습니다. 그 부품들은 단지 인터페이스의 일부가 아닐 뿐입니다. 모듈은 내부 경계(구현에 비공개이고 자체 테스트에서 사용됨)와 외부 경계(인터페이스에 있는)를 모두 가질 수 있습니다.
- 삭제 테스트. 모듈을 삭제한다고 상상해보세요. 복잡성이 사라진다면 단순한 통과(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): 구현을 부풀리도록 유도합니다. 대신 레버리지로서의 깊이를 사용합니다.
- TypeScript
interface키워드 또는 클래스의 public 메서드로서의 '인터페이스': 너무 좁습니다. 여기서의 인터페이스는 호출자가 알아야 할 모든 사실을 포함합니다. - '경계(Boundary)': DDD의 bounded context와 혼동됩니다. seam 또는 interface라고 말하세요.
더 깊이 들어가기
- 의존성을 고려한 클러스터 심화, DEEPENING.md 참조: 의존성 범주, 경계 규율, 계층화 테스트 대신 교체 테스트.
- 대체 인터페이스 탐색, DESIGN-IT-TWICE.md 참조: 병렬 하위 에이전트를 실행하여 인터페이스를 근본적으로 다른 여러 방식으로 설계한 후, 깊이, 지역성, 경계 배치 측면에서 비교하세요.