“높은 응집도와 낮은 결합도”는 외우기 쉬운 문장이지만 code review에서 그대로 쓰면 판단이 모호하다. 더 실용적인 질문은 두 가지다.
- 이 module 안의 code가 같은 이유로 함께 바뀌는가
- 한 module의 변경이 몇 개의 외부 module까지 연쇄 수정시키는가
응집도(cohesion)는 한 module 내부의 책임이 얼마나 한 목적에 모여 있는지, 결합도(coupling)는 module 사이의 지식과 의존이 얼마나 강한지를 나타낸다. 목표는 dependency를 없애는 것이 아니라 필요한 dependency를 드러내고 변경이 새는 범위를 줄이는 것이다.
응집도는 코드가 가까이 있다는 뜻이 아니다
한 class에 method가 모여 있다고 응집도가 높은 것은 아니다. 다음 신호가 있으면 서로 다른 변경 이유가 섞였을 수 있다.
- payment provider 변경과 email 문구 변경이 같은 file을 건드린다.
- 한 method가 validation, persistence, HTTP call, formatting을 모두 한다.
- class 이름이
Manager,Helper,Util인데 public method의 대상이 제각각이다. - field 일부는 method A만, 나머지는 method B만 사용한다.
- test fixture가 서로 무관한 dependency를 매번 준비한다.
반대로 작은 class라고 자동으로 cohesive한 것도 아니다. 지나치게 잘게 쪼개면 한 use case를 이해하려고 file 여러 개를 왕복하고 interface만 늘어날 수 있다.
결합도는 import 개수만으로 재지 않는다
module A가 B를 import한다는 사실만이 coupling은 아니다. 다음처럼 B의 내부 사정을 얼마나 아는지가 중요하다.
- concrete class 생성 방식과 constructor argument를 모두 앎
- 상대 module의 database table과 private field를 직접 수정
- return object의 내부 구조를 여러 단계 탐색
- 호출 순서와 숨은 global state에 의존
- network API의 error·retry·timeout을 domain code 전체가 앎
stable standard library에 의존하는 것과 자주 바뀌는 vendor SDK에 domain logic이 직접 묶이는 것도 같은 무게로 볼 수 없다. coupling의 수와 방향, 대상의 안정성을 함께 본다.
낮은 결합도를 보여 주는 실행 예제
checkout use case가 payment와 저장소의 구체 구현을 직접 만들지 않고 필요한 contract만 받도록 해 보자.
from dataclasses import dataclass
from typing import Protocol
class PaymentGateway(Protocol):
def charge(self, order_id: str, amount: int) -> str: ...
class OrderRepository(Protocol):
def mark_paid(self, order_id: str, payment_id: str) -> None: ...
@dataclass
class CheckoutService:
payments: PaymentGateway
orders: OrderRepository
def checkout(self, order_id: str, amount: int) -> str:
if amount <= 0:
raise ValueError("amount must be positive")
payment_id = self.payments.charge(order_id, amount)
self.orders.mark_paid(order_id, payment_id)
return payment_id
class FakeGateway:
def charge(self, order_id: str, amount: int) -> str:
return f"pay-{order_id}-{amount}"
class MemoryOrderRepository:
def __init__(self) -> None:
self.paid: dict[str, str] = {}
def mark_paid(self, order_id: str, payment_id: str) -> None:
self.paid[order_id] = payment_id
orders = MemoryOrderRepository()
service = CheckoutService(FakeGateway(), orders)
assert service.checkout("A-17", 12000) == "pay-A-17-12000"
assert orders.paid == {"A-17": "pay-A-17-12000"}
CheckoutService는 checkout 순서와 rule에 집중하므로 cohesive하다. payment SDK와 database 구현은 composition root에서 연결할 수 있다. Protocol은 static type checker가 구조를 확인하게 하지만 runtime에 type을 자동 강제하지는 않는다.
여기서 interface를 만들었다는 사실만으로 coupling이 사라지는 것은 아니다. service는 여전히 charge()와 mark_paid() contract에 의존한다. 좋아진 점은 dependency가 use case에 필요한 작은 surface로 드러났고, vendor detail이 domain logic으로 번지지 않는다는 것이다.
상속·Protocol·composition의 언어 차원 선택은 Python 상속과 Interface 설계, dependency direction을 포함한 큰 원칙은 SOLID 원칙에서 이어서 볼 수 있다.
예외 흐름까지 설계해야 실제 결합도가 보인다
happy path만 interface로 감싸면 retry와 transaction에서 concrete detail이 다시 샌다.
- payment는 성공했는데 database update가 실패하면 무엇을 보상할까
- timeout 뒤 provider가 실제 charge했는지 어떻게 확인할까
- idempotency key의 owner는 어느 module인가
- vendor error를 domain error로 어디서 변환할까
- repository transaction boundary와 external API call 순서를 어떻게 둘까
이 질문의 owner가 여러 module에 흩어지면 high cohesion처럼 보였던 class도 실제 변경에는 취약하다. interface method 수보다 failure policy와 data ownership을 더 먼저 본다.
Review에서 쓸 수 있는 판단 기준
변경 이유
요구사항 하나가 바뀔 때 수정할 file과 module을 적는다. 같은 use case의 code가 함께 바뀌면 cohesion 신호이고, unrelated feature가 매번 함께 바뀌면 분리 후보다.
Public Surface
다른 module이 실제로 필요한 type과 operation만 노출하는지 본다. “나중에 쓸 것 같아서” internal model 전체를 공개하면 coupling이 커진다.
Dependency Direction
business rule이 framework·database·vendor SDK를 직접 아는지, 바깥 detail이 안쪽 contract를 구현하는지 본다.
Data Ownership
같은 table·object를 여러 module이 자유롭게 변경하면 code import가 없어도 강하게 결합된다. write owner와 consistency boundary를 명시한다.
Test 경계
한 rule을 test하려고 network·database·clock을 모두 띄워야 한다면 dependency boundary가 흐릴 수 있다. 다만 모든 것을 mock으로 바꿔 integration contract를 잃지도 않는다.
Runtime 운영
module을 나눠 network service로 만들면 source code coupling은 줄어 보여도 latency, partial failure, deployment order, schema compatibility라는 runtime coupling이 생긴다. 낮은 결합도를 이유로 무조건 microservice로 분리하지 않는다.
숫자 하나로 품질을 판정하지 않기
afferent/efferent coupling, lack of cohesion 같은 metric은 hotspot을 찾는 단서가 될 수 있다. 그러나 generated code, framework convention, test code, domain boundary에 따라 해석이 달라진다.
metric이 높다는 이유만으로 class를 쪼개기보다 change history와 incident, review cost를 함께 본다.
- 서로 다른 변경이 같은 file에 반복되는가
- 작은 수정의 blast radius가 넓은가
- interface가 실제 consumer보다 큰가
- dependency cycle이 생기는가
- 팀 경계와 data ownership이 code boundary와 충돌하는가
좋은 module은 diagram에서 독립적으로 보이는 module이 아니라, 책임과 contract가 선명해 변경 결과를 예측할 수 있는 module이다.
자주 묻는 질문
응집도가 높으면 class가 작아야 하나
크기와 직접 같은 개념은 아니다. 한 목적을 위해 협력하는 code라면 어느 정도 커질 수 있고, 작은 class도 unrelated method를 담으면 cohesion이 낮을 수 있다.
결합도는 무조건 낮을수록 좋은가
system은 협력해야 하므로 coupling은 필요하다. domain invariant를 한 transaction으로 지켜야 할 code를 억지로 분리하면 오히려 distributed failure가 늘어난다. 최소하고 명시적인 coupling이 목표다.
Interface를 추가하면 결합도가 낮아지나
항상 그렇지 않다. consumer가 필요한 작은 contract를 소유하고 concrete detail을 바깥으로 밀어낼 때 효과가 있다. 구현체와 똑같이 큰 interface를 복제하면 추상화 층만 늘어난다.
참고 자료
'배움과 성장 > 소프트웨어 개발' 카테고리의 다른 글
| Fetch API로 AJAX 구현하기: JSON 응답·PHP 연동·오류 처리 (0) | 2024.08.15 |
|---|---|
| 디자인 패턴 고르는 법: Strategy·Factory·Decorator·Observer의 변화 지점 (0) | 2024.08.13 |
| 동기·비동기와 블로킹·논블로킹 차이: 통신과 코드에서 헷갈리지 않기 (0) | 2024.08.11 |
| Python nonlocal은 언제 필요한가: 중첩 함수의 변수 재바인딩 (0) | 2024.08.09 |
| Python 클로저와 데코레이터: late binding·nonlocal·wraps까지 (1) | 2024.08.09 |
댓글