코드 설계
사용자와 소통할 때는 한국어를 사용합니다. 다음 용어는 처음 등장할 때 영문과 함께 표기하고, 이후에는 통일된 용어로 사용합니다.
deep module 설계: 작은 인터페이스 뒤에 많은 행위를 숨기고, 깨끗한 seam 위에 배치하며, 그 인터페이스를 통해 테스트할 수 있도록 합니다. 코드를 설계하거나 리팩터링하는 모든 곳에서 이 언어와 원칙을 사용합니다. 목표는 호출자에게 레버리지(leverage)를, 유지보수자에게 지역성(locality)을 제공하고, 모든 사람이 쉽게 테스트할 수 있도록 하는 것입니다.
용어표
다음 용어를 정확히 사용하세요. '컴포넌트', '서비스', 'API', '경계' 등으로 바꾸지 마세요. 일관된 언어가 핵심입니다.
모듈(Module): 인터페이스와 구현을 모두 가진 모든 것. 규모를 의도적으로 제한하지 않습니다. 함수, 클래스, 패키지, 또는 여러 계층에 걸친 조각이 될 수 있습니다. 사용하지 말 것: 유닛, 컴포넌트, 서비스.
인터페이스(Interface): 호출자가 이 모듈을 올바르게 사용하기 위해 알아야 하는 모든 것: 타입 시그니처, 불변 조건, 호출 순서 제약, 오류 패턴, 필수 설정, 성능 특성. 사용하지 말 것: API, 시그니처(너무 좁아 타입 수준의 표면만 가리킴).
구현(Implementation): 모듈 내부, 즉 코드 본문. **어댑터(Adapter)**와 다릅니다. 어떤 것은 작은 어댑터에 많은 구현이 있을 수 있고(예: SQLite Repository), 어떤 것은 큰 어댑터에 적은 구현이 있을 수 있습니다(예: 인메모리 가짜 객체). Seam에 대해 논의할 때는 '어댑터'를, 그 외에는 '구현'을 사용합니다.
깊이(Depth): 인터페이스의 레버리지. 호출자(또는 테스트)가 인터페이스 단위 하나를 배울 때 얼마나 많은 행위를 사용할 수 있는지. 작은 인터페이스 뒤에 많은 행위가 숨겨진 모듈은 깊고, 인터페이스가 구현만큼 복잡한 모듈은 얕습니다.
Seam(Michael Feathers): 해당 위치를 수정하지 않고도 행위를 변경할 수 있는 지점, 즉 모듈 인터페이스가 위치한 장소. Seam을 어디에 둘 것인지는 그 자체로 설계 결정이며, seam 뒤에 무엇을 둘 것인지와는 별개입니다. 사용하지 말 것: 경계(DDD의 bounded context와 혼동됨).
어댑터(Adapter): 특정 seam에서 특정 인터페이스를 충족하는 구체적인 것. 내용(무엇이 들어 있는지)이 아니라 역할(어느 위치를 채우는지)을 설명합니다.
레버리지(Leverage): 호출자가 깊이로부터 얻는 이점. 인터페이스 단위 하나를 배울 때 더 많은 능력을 얻습니다. 하나의 구현 가치는 N개의 호출 지점과 M개의 테스트에서 회수됩니다.
지역성(Locality): 유지보수자가 깊이로부터 얻는 이점. 수정, 버그, 지식, 검증이 여러 호출 지점에 흩어지지 않고 한 곳에 집중됩니다. 한 번 수정하면 모든 곳에 적용됩니다.
깊음과 얕음
Deep module = 작은 인터페이스 + 많은 구현:
┌─────────────────────┐
│ 작은 인터페이스 │ ← 메서드 적고, 매개변수 단순
├─────────────────────┤
│ │
│ 깊은 구현 │ ← 복잡한 로직이 내부에 숨겨짐
│ │
└─────────────────────┘
얕은 모듈 = 큰 인터페이스 + 적은 구현 (피해야 함):
┌─────────────────────────────────┐
│ 큰 인터페이스 │ ← 메서드 많고, 매개변수 복잡
├─────────────────────────────────┤
│ 얇은 구현 │ ← 단순히 전달만 함
└─────────────────────────────────┘
인터페이스를 설계할 때 스스로에게 물어보세요:
- 메서드의 수를 줄일 수 있나요?
- 매개변수를 단순화할 수 있나요?
- 더 많은 복잡성을 내부에 숨길 수 있나요?
원칙
- 깊이는 인터페이스의 속성이지, 구현의 속성이 아닙니다. Deep module의 내부는 작고, 모킹 가능하며, 교체 가능한 부품들로 구성될 수 있습니다. 단, 이러한 부품들은 인터페이스에 속하지 않습니다. 모듈은 내부 seam(구현 전용, 자체 테스트용)과 인터페이스 상의 외부 seam을 가질 수 있습니다.
- 삭제 테스트. 이 모듈을 삭제한다고 상상해보세요. 복잡성이 사라진다면 단순한 전달자일 뿐입니다. 복잡성이 N개의 호출 지점에 다시 나타난다면, 제 역할을 하고 있는 것입니다.
- 인터페이스는 테스트 표면입니다. 호출자와 테스트는 동일한 seam을 넘나듭니다. 인터페이스를 넘어 테스트하고 싶다면, 모듈의 형태가 잘못되었을 가능성이 높습니다.
- 하나의 어댑터는 가상의 seam, 두 개의 어댑터는 진짜 seam입니다. 실제로 seam 양쪽에서 변화할 것이 없다면, seam을 도입하지 마세요.
테스트 용이성을 위한 설계
좋은 인터페이스는 테스트를 자연스럽게 만듭니다:
-
의존성을 직접 생성하지 말고, 주입받으세요.
# 테스트 가능 def fetch_alternates(mpn: str, client: PartSearchClient) -> list[Part]: ... # 테스트 어려움 def fetch_alternates(mpn: str) -> list[Part]: client = DistributorClient(api_key=os.environ["DIST_API_KEY"]) -
부작용을 만들지 말고, 결과를 반환하세요.
# 테스트 가능 def landed_cost(bom: Bom, freight: Freight) -> Money: ... # 테스트 어려움 def apply_landed_cost(bom: Bom) -> None: bom.total += freight_charge -
작은 표면적. 메서드가 적을수록 필요한 테스트가 적고, 매개변수가 적을수록 테스트 준비가 간단합니다.
관계
- 하나의 모듈은 정확히 하나의 인터페이스(호출자와 테스트에 제공하는 표면)를 가집니다.
- 깊이는 모듈의 속성이며, 인터페이스로 측정됩니다.
- Seam은 모듈의 인터페이스가 위치한 곳입니다.
- 어댑터는 seam에 위치하며 인터페이스를 충족합니다.
- 깊이는 호출자에게 레버리지를, 유지보수자에게 지역성을 제공합니다.
채택하지 않는 프레임워크
- 깊이를 구현 라인 수와 인터페이스 라인 수의 비율로 정의하는 것(Ousterhout): 이는 구현을 부풀리도록 장려합니다. 우리는 '깊이는 레버리지'라는 정의를 사용합니다.
- '인터페이스'를 Python의
Protocol/추상 클래스 또는 클래스의 public 메서드와 동일시하는 것: 너무 좁습니다. 여기서의 인터페이스는 호출자가 알아야 하는 모든 것을 포함합니다. - '경계': DDD의 bounded context와 혼동됩니다. seam 또는 인터페이스라고 말하세요.
추가 자료
- 기존 의존성 하에서 모듈 그룹을 깊게 만드는 방법: DEEPENING.md 참조: 의존성 분류, seam 규율, '겹쳐 쌓기보다 교체' 테스트 전략.
- 다양한 인터페이스 설계 탐구: DESIGN-IT-TWICE.md 참조: 여러 하위 에이전트를 병렬로 파견하여 몇 가지 근본적으로 다른 방식으로 인터페이스를 설계한 후, 깊이, 지역성 및 seam 위치를 비교합니다.