SOLID는 클래스를 많이 만들라는 규칙이 아니다. 서로 다른 이유로 바뀌는 코드를 분리하고, 한쪽의 변경이 다른 쪽으로 번지는 범위를 줄이는 설계 기준에 가깝다. Python에서는 모든 구현에 추상 클래스를 붙이기보다 실제 변경 지점을 찾고 Protocol, 작은 함수, 합성 같은 도구를 필요한 만큼 쓰는 편이 자연스럽다.
다섯 글자를 외우기 전에 “누가 이 코드를 바꾸며, 무엇이 함께 바뀌어야 하는가?”를 먼저 묻는 이유다.
SOLID를 한 문장씩 다시 정의하면
| 원칙 | 실무적인 질문 | 흔한 오해 |
|---|---|---|
| SRP | 이 모듈의 변경을 요구하는 주체가 하나인가? | 클래스에는 메서드가 하나만 있어야 한다 |
| OCP | 이미 검증한 핵심 흐름을 덜 건드리고 새 동작을 붙일 수 있는가? | 기존 코드는 절대 수정하면 안 된다 |
| LSP | 구현을 바꿔도 호출자가 기대한 계약이 유지되는가? | 상속 문법만 맞으면 치환 가능하다 |
| ISP | 각 호출자가 쓰는 기능만 의존하는가? | 인터페이스는 무조건 작을수록 좋다 |
| DIP | 정책 코드가 구체적인 외부 도구보다 안정된 경계에 의존하는가? | 의존성 주입 프레임워크를 써야 한다 |
SRP의 “하나의 책임”은 함수 개수보다 하나의 변경 이유에 가깝다. 같은 데이터를 다루더라도 보고서 형식을 정하는 쪽과 메시지 전송 채널을 운영하는 쪽이 다르면, 두 코드를 한 클래스에 넣었을 때 관계없는 변경이 서로를 흔들 수 있다.
하나의 예제로 다섯 원칙 연결하기
사용량 보고서를 만들고 전달하는 작은 흐름을 생각해 보자. 보고서 문구는 서비스 정책에 따라 바뀌고, 전달 채널은 이메일·메신저 같은 외부 시스템에 따라 바뀐다.
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class Usage:
account_id: str
minutes: int
class ReportFormatter(Protocol):
def render(self, usage: Usage) -> str:
...
class ReportSender(Protocol):
def send(self, recipient: str, message: str) -> None:
...
class TextUsageReport:
def render(self, usage: Usage) -> str:
return f"{usage.account_id}: {usage.minutes}분 사용"
class InMemorySender:
def __init__(self) -> None:
self.messages: list[tuple[str, str]] = []
def send(self, recipient: str, message: str) -> None:
if not recipient:
raise ValueError("recipient must not be empty")
self.messages.append((recipient, message))
class ReportService:
def __init__(
self,
formatter: ReportFormatter,
sender: ReportSender,
) -> None:
self._formatter = formatter
self._sender = sender
def deliver(self, usage: Usage, recipient: str) -> None:
message = self._formatter.render(usage)
self._sender.send(recipient, message)
이 코드는 “SOLID를 적용했다”는 도장을 찍기 위한 완성형이 아니다. 변경 경계를 눈에 보이게 만든 최소 예제다.
SRP: 같은 이유로 바뀌는 것끼리 둔다
TextUsageReport는 보고서 내용이 달라질 때 바뀐다. InMemorySender는 테스트용 전달 방식이 달라질 때 바뀌고, 실제 시스템의 EmailSender나 SlackSender는 각 채널의 API 계약이 달라질 때 바뀔 것이다. ReportService는 “만들어서 보낸다”는 애플리케이션 흐름만 조정한다.
한 클래스에 render_email_html(), save_to_database(), send_slack()를 모두 넣지 않은 이유는 코드 길이가 아니라 변경 주체가 다르기 때문이다.
OCP: 예상한 변형 지점만 열어 둔다
JSON 형식이 필요하면 ReportFormatter 계약을 지키는 JsonUsageReport를 추가할 수 있다. 메신저 전송이 필요하면 ReportSender 구현을 하나 더 만들 수 있다. ReportService.deliver()의 검증된 흐름은 그대로 둔다.
그렇다고 모든 가능성을 미리 인터페이스로 만들 필요는 없다. 구현이 하나뿐이고 바뀔 근거도 없다면 단순한 함수가 더 낫다. OCP는 미래를 모두 예측하라는 뜻이 아니라, 반복해서 변하는 축이 확인됐을 때 안정된 코드와 변동 코드를 분리하라는 기준이다.
LSP: 타입 모양이 아니라 행동 계약을 지킨다
ReportSender의 구현은 같은 입력을 받는 것만으로 충분하지 않다. 호출자가 기대하는 행동도 맞아야 한다. 예를 들어 다음 계약을 정했다면 모든 구현이 따라야 한다.
- 비어 있지 않은 수신자와 메시지를 받는다.
- 전달을 수락했으면 정상 반환한다.
- 전달할 수 없으면 문서화한 예외를 발생시킨다.
- 성공처럼 반환한 뒤 실제로 메시지를 조용히 버리지 않는다.
어떤 하위 구현이 특정 수신자를 이유 없이 거부하거나, 실패를 숨긴 채 정상 반환한다면 문법상 ReportSender처럼 보여도 치환 가능하지 않다. LSP의 핵심은 상속 트리보다 precondition, postcondition, invariant를 포함한 관찰 가능한 행동이다.
outbox = InMemorySender()
service = ReportService(TextUsageReport(), outbox)
service.deliver(Usage("team-a", 45), "ops@example.com")
assert outbox.messages == [
("ops@example.com", "team-a: 45분 사용")
]
위 assert는 테스트에서 구현 계약을 확인하는 용도다. 외부 입력 검증을 대신하는 운영 코드가 아니다.
ISP: 구현이 아니라 사용하는 쪽에서 경계를 자른다
보고서 서비스가 필요한 것은 send() 하나다. 메일 공급자의 템플릿 관리, 주소록 조회, 캠페인 통계까지 담은 거대한 MessagingClient에 의존하면 사용하지 않는 기능의 변경에도 영향을 받는다.
ISP는 “메서드 한 개짜리 인터페이스를 많이 만들자”가 아니다. 서로 다른 호출자가 서로 다른 기능 묶음을 필요로 한다면, 각 호출자의 관점에서 경계를 나누라는 뜻이다.
DIP: 정책이 세부 도구를 직접 고르지 않게 한다
ReportService는 SMTP SDK나 특정 메신저 클래스가 아니라 ReportSender라는 역할에 의존한다. 실제 구현을 생성해 연결하는 일은 애플리케이션 시작 지점에서 맡는다. 생성자 인자는 이 의존 관계를 드러내는 한 가지 방법일 뿐, 별도의 DI 프레임워크가 필수는 아니다.
여기서도 추상화의 방향이 중요하다. 외부 SDK의 메서드를 그대로 복사한 인터페이스라면 정책을 보호하지 못한다. send(recipient, message)처럼 애플리케이션이 실제로 필요로 하는 의미를 경계에 담아야 한다.
SOLID가 오히려 복잡도를 늘리는 순간
다음 상황에서는 원칙의 이름보다 단순성이 우선이다.
- 변경 가능성이 확인되지 않은 코드에 확장 지점을 미리 만드는 경우
- 구현 하나를 감싸기 위해 이름만 다른 계층을 여러 개 두는 경우
- 서로 항상 함께 바뀌는 로직을 억지로 분리하는 경우
- 작은 함수로 충분한데 class와 factory를 늘리는 경우
- 테스트 대역을 만들기 쉽다는 이유만으로 실제 도메인 경계를 왜곡하는 경우
좋은 설계는 파일 수가 아니라 변경 비용으로 평가해야 한다. 기능 하나를 추가할 때 관련 없는 모듈까지 수정해야 하는지, 테스트 실패가 어느 경계를 가리키는지, 구현 교체가 호출자의 의미를 깨뜨리는지를 보는 편이 낫다.
적용 전에 확인할 질문
- 최근 비슷한 변경이 두 번 이상 일어난 축은 무엇인가?
- 그 변경을 요구하는 사람이나 시스템은 누구인가?
- 호출자가 구현에 기대하는 입력·출력·오류 계약은 무엇인가?
- 새 구현이 기존 계약 테스트를 그대로 통과하는가?
- 추상화를 추가했을 때 수정 범위가 실제로 줄어드는가?
SOLID를 가장 안전하게 쓰는 방법은 다섯 원칙을 한꺼번에 적용하는 것이 아니다. 먼저 실제 변경을 관찰하고, 함께 바뀌는 코드는 모으고 다른 이유로 바뀌는 코드는 분리한다. 그다음 치환 계약과 의존 방향을 필요한 범위에서만 명시하면 된다.
디자인 패턴을 선택하는 기준은 반복되는 구조적 문제를 패턴과 연결해 볼 때 도움이 된다. 코드 표현 자체를 더 단순하게 다듬고 싶다면 간결하고 읽기 쉬운 코드 작성 기준을 함께 볼 수 있다.
참고 자료
'배움과 성장 > 소프트웨어 개발' 카테고리의 다른 글
| Python 추상 클래스와 추상 메서드: ABC·abstractmethod의 실제 동작 (1) | 2024.09.08 |
|---|---|
| Python 상속과 Interface 설계: super·MRO·Protocol·Composition 선택 기준 (1) | 2024.09.08 |
| Assertion은 운영에서 꺼야 할까: Java·Python·TypeScript·Go의 실제 차이 (0) | 2024.08.28 |
| 비트 연산과 2의 보수: 고정 비트폭·마스크·Python 음수 표현 (0) | 2024.08.16 |
| Fetch API로 AJAX 구현하기: JSON 응답·PHP 연동·오류 처리 (0) | 2024.08.15 |
댓글