목차
자식으로 바꿔 넣었더니 깨진다
새를 나타내는 Bird가 있고, 난다는 메서드가 있다.
public class Bird {
public void fly() { /* 하늘로 */ }
}펭귄도 새니까 Bird를 상속한다. 그런데 펭귄은 못 난다.
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("펭귄은 못 난다");
}
}이제 “모든 새를 날게 하는” 코드에 펭귄이 섞이면 어떻게 될까.
public void migrate(List<Bird> birds) {
for (Bird bird : birds) {
bird.fly(); // 모든 새가 난다고 믿는다
}
}Bird인 줄 알고 fly()를 불렀는데, 펭귄에서 예외가 터진다.
Penguin은 분명 Bird인데, Bird가 와야 할 자리에 넣으니 깨졌다.
LSP = 자식은 부모 자리를 대신할 수 있어야
리스코프 치환 원칙(LSP)이 겨누는 게 이거다.
자식 타입은 부모 타입이 오는 자리에 넣어도 프로그램이 깨지지 않아야 한다.
즉 Bird를 기대하는 모든 곳에 Penguin을 넣어도 멀쩡해야 한다. 그래야 진짜 하위 타입이다. 위 펭귄은 이걸 어겼으니, 상속은 했지만 LSP 위반이다.
”is-a”로는 부족하다
“펭귄은 새다(Penguin is-a Bird)“는 맞는 말이다. 그런데 상속의 기준을 “is-a”에만 두면 방금 같은 사고가 난다.
상속은 구조(필드·메서드 목록)만 물려받는 게 아니다. 부모가 한 **약속(계약)**까지 물려받는 것이다. Bird가 “나는 fly()를 부르면 난다”고 약속했다면, 그 자식은 그 약속을 지켜야 한다. 펭귄은 못 지킨다. 그러니 말로는 새여도, fly()를 약속한 Bird의 하위 타입으로는 부적격이다.
LSP는 “is-a인가”가 아니라 **“부모의 약속을 다 지킬 수 있는가”**를 상속의 기준으로 삼으라고 말한다.
무엇이 계약을 깨나
자식이 부모 계약을 깨는 모습은 대개 이렇게 드러난다.
- 재정의한 메서드가 예외를 던진다 - 펭귄의
fly()처럼. 부모는 되던 일이 자식에선 안 된다. - 재정의한 메서드가 아무것도 안 한다 - 예외 대신 빈 몸으로 조용히 넘어가면, 부모를 믿던 코드가 틀린 결과를 얻는다. 터지지 않아 더 위험하다.
- 호출부가 타입을 확인하게 만든다 -
if (bird instanceof Penguin)으로 갈라 처리해야 한다면, 이미 다형성이 깨진 것이다. 자식을 부모로 못 대하고 있다는 증거다.
고전: 정사각형은 직사각형인가
가장 유명한 예가 이거다. 수학적으로 정사각형은 직사각형이다. 그래서 상속시키고 싶어진다.
public class Rectangle {
protected int width, height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area() { return width * height; }
}
public class Square extends Rectangle {
@Override public void setWidth(int w) { this.width = w; this.height = w; } // 정사각형이라
@Override public void setHeight(int h) { this.width = h; this.height = h; } // 같이 바꾼다
}이제 Rectangle을 받아 크기를 바꾸는 코드를 보자.
void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
// Rectangle이면 넓이는 5 × 4 = 20
}Rectangle을 쓰는 사람은 “폭과 높이는 따로 논다”고 믿는다. 그런데 여기에 Square를 넣으면, setHeight(4)가 폭까지 4로 바꿔서 넓이가 16이 된다. 20을 기대한 코드가 조용히 틀린다. 정사각형은 직사각형이지만, Rectangle의 하위 타입으로는 대체될 수 없다.
입력을 더 좁히지 마라
계약을 조금 더 또렷하게 보면, 먼저 입력 쪽 규칙이 있다.
부모가 받던 입력은 자식도 다 받아야 한다. 부모는 0도 받는데 자식은 양수만 받는다면, 0을 넣던 코드가 자식에서 터진다. 자식이 입력을 부모보다 더 까다롭게 받는 순간, 부모를 믿고 짠 코드가 깨진다. 자식은 받는 범위를 좁히면 안 된다 - 넓히는 건 괜찮아도.
결과를 덜 주지 마라
반대편에는 결과 쪽 규칙이 있다.
부모가 주기로 한 결과는 자식도 다 줘야 한다. 부모가 “정렬된 결과를 준다”고 약속했는데 자식이 안 지키면, 그 약속을 믿던 코드가 깨진다. 자식이 결과를 부모보다 덜 보장하는 순간 대체가 무너진다. 자식은 주는 것을 줄이면 안 된다 - 더 후하게 주는 건 괜찮아도.
한 줄로 줄이면, 자식은 부모의 약속을 그대로 지키거나 더 후하게 지켜야 한다. 입력을 더 좁히거나 결과를 덜 주는 순간 대체가 깨진다.
상속을 다시 본다
그럼 펭귄은 어떻게 해야 하나. 답은 애초에 Bird에 fly()를 넣은 게 문제였다는 것이다. 모든 새가 나는 게 아니니까.
계층을 다시 짠다. 나는 능력을 Bird에서 떼어내 따로 둔다.
참새는 Bird이면서 Flyable이고, 펭귄은 그냥 Bird다. 이제 “나는 것들”을 다루는 코드는 Flyable을 받으니, 펭귄이 끼어들 자리 자체가 없다. 못 지킬 약속을 애초에 물려주지 않는 것이 고침이다.
정리하면, 상속은 “is-a로 보이니까”가 아니라 “자식이 부모의 약속을 다 지킬 수 있으니까” 하는 것이다. 못 지키면 그 상속이 틀린 것이다.
실무: 재정의가 예외·빈 몸이면 신호
실무에서 LSP 위반은 이런 모습으로 잡힌다.
- 재정의한 메서드가
throw new UnsupportedOperationException()이거나 빈 몸이다 (자바 표준의 불변 컬렉션이add()에서 예외를 던지는 게 자주 논쟁되는 사례다). - 호출부에
instanceof로 자식을 골라내는 분기가 생긴다.
이런 게 보이면 “이 자식이 정말 부모의 약속을 다 지키나”를 되묻는다. 못 지킨다면, 상속을 끊고 계층을 다시 그리거나 조합으로 바꾼다. 상속은 코드 재사용의 지름길이 아니라, 약속을 물려받겠다는 선언이다.
정리
| LSP란 | 자식은 부모가 오는 자리에 넣어도 깨지지 않아야 한다 |
| is-a로는 부족 | 구조가 아니라 **계약(약속)**을 물려받는 게 상속이다 |
| 깨는 모습 | 재정의가 예외·빈 몸 / 호출부가 instanceof로 갈라야 함 |
| 고전 예 | 정사각형은 직사각형이지만 Rectangle 대체는 못 한다 |
| 계약 규칙 | 입력은 더 까다롭게 받지 말고, 결과는 덜 보장하지 마라 |
| 고침 | 못 지킬 약속은 물려주지 않는다 - 계층 재설계·조합 |
“is-a니까 상속”이 아니라 “약속을 지킬 수 있으니까 상속”이다. 자식을 부모 자리에 넣어도 아무도 안 놀라는 것 - 그게 리스코프 치환이 지키는 것이다.