Quick Reference
치환 가능성은 subtype이 base보다 “특별해 보이는가”가 아니라, consumer가 type을 바꿔도 같은 contract로 실행되는가입니다. 입력 precondition을 몰래 강화하거나, sync success를 async·retry 필요 결과로 바꾸거나, cleanup을 생략하면 caller가 subtype 분기를 해야 합니다.
public interface IRewardGrant
{
// 호출자는 성공·보류·실패를 결과로 처리한다.
ValueTask<GrantResult> GrantAsync(RewardRequest request, CancellationToken token);
}
// LocalReward와 OnlineReward는 같은 cancellation·result contract를 지켜야 합니다.| 계약 변화 | 치환 가능성 | 설계 선택 |
|---|---|---|
| 같은 input·result·cleanup | 유지 | base/interface 그대로 사용 |
| 특정 subtype만 network·approval 필요 | 약함 | async result·state를 공통 contract에 포함 |
| subtype에서 지원하지 않는 method | 깨짐 | interface 축소·composition 분리 |
| caller가 subtype switch/cast | 의심 신호 | contract 또는 hierarchy 재설계 |
caller가 믿는 조건
contract에는 method 이름뿐 아니라 허용 input, success·failure result, side effect, cancellation, lifecycle이 들어갑니다. Save()가 “즉시 local file에 성공”을 뜻하는데 cloud implementation이 pending·retry·network error를 갖는다면 같은 method 이름으로는 충분하지 않습니다. ValueTask<GrantResult>처럼 completion model을 contract에 넣거나 local과 remote workflow를 다른 abstraction으로 나눕니다.
subtype이 base보다 더 강한 precondition을 요구하거나 더 약한 postcondition을 주면 consumer가 예외 처리를 시작합니다. 예를 들어 base Weapon.TryUse가 range 밖이면 false만 돌려주는데 특정 weapon이 exception을 던지거나 global cooldown을 무시하면 caller의 판단이 깨집니다.
hierarchy를 바꿀 신호
base type를 받는 code에 if (weapon is OnlineWeapon) 같은 분기가 반복되면 type hierarchy가 실제 variation을 표현하지 못할 수 있습니다. common flow는 base·template method로 남기고 특수 calculation은 strategy로 빼거나, capability contract를 더 작게 나눕니다. code reuse만을 위해 subclass를 만들면 LSP failure가 뒤늦게 나타납니다.
contract test 만들기
base/interface마다 representative implementation에 공통으로 적용하는 test를 둡니다. 정상 input, boundary input, cancellation, repeated call, disable/unload cleanup을 같은 test suite로 돌리고, subtype-specific feature는 extension contract로 따로 test합니다. 이 방식이 documentation보다 caller에게 보장되는 동작을 명확하게 만듭니다.
LSP는 “상속을 쓰지 말자”가 아닙니다. caller가 의존하는 input·result·시간·cleanup 의미를 먼저 고정하고, 모든 implementation이 그 의미를 지킬 수 있을 때만 같은 type으로 묶으세요.
참고 링크
2 sources