행동 치환은 상속 문법이나 타입 검사 결과가 아니라 기존 client가 관찰하는 계약을 보존하는지에 대한 판단입니다. 입력 허용 범위, 반환 보장, 예외와 외부 효과, 수명과 호출 순서를 작은 시나리오로 고정한 뒤 상속과 조합 중 변화 축을 더 안전하게 표현하는 쪽을 선택합니다.
Rectangle·Square 변경 계약의 면적 반례
부모 Rectangle이 두 크기를 독립적으로 바꿀 수 있다고 약속하면 setWidth(5); setHeight(4) 뒤 면적은 20이어야 합니다. Square가 각 setter에서 두 변을 같이 바꾸면 마지막에 4×4=16입니다. 수학적 포함 관계보다 변경 API의 관찰 가능한 계약이 문제입니다.
리스코프 치환은 부모를 사용하던 client(호출하는 코드)에 자식을 대신 넣어도 같은 호출의 계약을 지키는 조건입니다. 자식은 부모가 허용한 입력보다 좁은 입력만 받도록 사전조건을 강화할 수 없고, 부모가 보장한 결과를 약하게 하는 사후조건도 바꿀 수 없습니다. 예외, 상태 변화, 외부 호출 횟수, 명시된 비블로킹 약속도 client가 관찰하는 계약이면 함께 보존해야 합니다.
타입 Variance와 행동 계약의 분리
함수 타입이 입력의 반공변(부모가 받던 입력보다 넓게 받을 수 있는 관계)·반환의 공변(부모가 약속한 반환보다 구체적인 값을 줄 수 있는 관계)을 만족해도 행동까지 같아지는 것은 아닙니다. 자식이 부모가 허용한 0을 거절하거나 결제를 두 번 보내면 타입 검사는 통과해도 실제 계약은 깨집니다. 구체 언어의 override와 generic 타입 variance 지원을 일반 함수 타입 관계와 따로 확인하고, 타입 검사 결과를 상태·효과 보장의 증명으로 확대하지 않습니다.
| 부모 계약 | 치환 실패 예 |
|---|---|
| 음수가 아닌 입력 허용 | 자식은 양수만 허용 |
| 반환 목록 정렬 보장 | 자식은 정렬 안 됨 |
| 전송 한 번·불확정 오류 노출 | 자식이 재전송·오류 숨김 |
| 독립 크기 변경 | 한 setter가 다른 크기도 변경 |
단순히 느리다고 항상 치환 위반은 아닙니다.
계약을 실행 가능한 질문으로 바꾸면 resize(width=5); resize(height=4) 뒤 관찰 가능한 크기가 부모의 약속인 (5,4)인지 확인할 수 있습니다. 자식이 (4,4)를 반환하면 타입은 같아도 사후조건 위반입니다. 같은 방식으로 음수 입력, 한 번의 전송, timeout 뒤 재시도 결과를 부모 구현과 대체 구현에 반복해 비교하면 “우연히 동작한 예제”와 실제 치환 계약을 구분할 수 있습니다.
조합을 선택할 때는 Channel·RetryPolicy·Logger 중 하나만 바꿔도 나머지 계약 시험이 통과하는지 봅니다. 외부 전송 timeout처럼 결과가 불확정이면 재시도 횟수보다 먼저 멱등 key와 “확실한 미전송/불확정” 상태를 모델링해야 중복 효과를 숨기지 않습니다. 부모가 지연 상한·비블로킹을 보장했는지 봅니다. 우연한 구현 세부를 모두 고정하면 대안 구현이 불가능해집니다. client가 의존해도 되는 최소 보장으로 계약 test를 만들고 모든 구현에 적용합니다.
독립 변화 축의 조합과 위임
Email·SMS·Push 채널마다 로깅과 retry 정책을 상속으로 조합하면 조합 수만큼 class가 늘어납니다. 대신 Notifier가 Channel·RetryPolicy·Logger를 받아 각 역할을 위임하면 채널, 재시도, 기록이라는 변화 축을 따로 바꿀 수 있습니다. 다만 timeout 뒤에 외부에서 실제 수신됐는지 알 수 없는 경우가 있으므로, RetryPolicy에는 확실한 미전송과 불확정 결과를 구분하는 규칙, 그리고 멱등 key(같은 요청을 중복 적용하지 않게 식별하는 키) 계약이 필요합니다.
그림을 글로 읽기
- Notifier · 전송 흐름
- Channel · 외부 전송
- RetryPolicy · 실패 의미
- Logger · 안전한 관측
- Notifier · 전송 흐름 → Channel · 외부 전송: 전송 위임
- Notifier · 전송 흐름 → RetryPolicy · 실패 의미: 재시도 판단
- Notifier · 전송 흐름 → Logger · 안전한 관측: 결과 기록
조합도 객체 생성·추적 비용·순환 callback·재진입 문제가 있습니다. 작은 고정 계층을 무조건 분해하지 않습니다. 상속은 공통 타입의 입력·상태·수명·호출 순서를 모든 자식이 자연스럽게 지킬 때 선택하고 단순 코드 재사용만으로 선택하지 않습니다. 읽기 전용 Shape와 별도 resize 역할로 나누면 Rectangle/Square의 불필요한 변경 계약 충돌을 줄일 수 있습니다.
생성 중 Virtual dispatch의 언어별 규칙
Java에서 Base 생성자가 override 가능한 initialize()를 호출하면, Derived override가 실행될 때 자식 field initializer가 아직 끝나지 않아 기본값 0/null을 읽을 수 있습니다. 반대로 C++는 Base 생성·소멸 단계에서 virtual 호출을 해도 아직 존재하지 않거나 이미 끝난 Derived 단계로 같은 방식으로 dispatch하지 않습니다. 따라서 언어별 현재 생성·소멸 클래스 규칙을 따르고, pure virtual 호출이라는 별도 위험도 피해야 합니다.
두 언어를 하나의 “항상 자식 호출” 규칙으로 설명하지 않습니다. 생성 중 this escape·callback 등록·외부 공개를 피하고 완전히 만들어진 뒤 명시적 start나 factory로 초기화합니다. 이 경우 start 실패·부분 자원 회수와 중복 start도 계약에 포함합니다.
부모 계약 변경과 자식 전제의 검증
template method를 유지한다면 확장 지점·호출 순서·예외 정리를 제한하고 문서화합니다. 모든 subtype에 부모의 유효 입력·예외·수명·효과 test를 적용합니다. 이 노트는 언어 계약과 설계 예시이며 생성 중 dispatch 예제를 이 작업에서 별도로 컴파일한 결과는 아닙니다.