리스코프 치환 원칙(LSP, Liskov Substitution Principle)은 자식 객체를 부모 객체가 있던 자리에 그대로 넣어도 프로그램이 여전히 올바르게 동작해야 한다는 원칙이다. 상속(부모의 기능을 물려받는 것)이 문법적으로 성립한다고 해서 항상 옳은 것은 아니다. 자식이 부모가 걸어 둔 약속, 곧 계약을 깨면 그 상속은 리스코프 치환을 위반한다. 컴파일이나 문법 검사로는 이 위반이 잡히지 않기 때문에 조용히 숨어 있다가 엉뚱한 곳에서 프로그램을 틀린 답으로 몰고 간다.


이 원칙이 필요한 이유는 다형성(같은 호출이 객체마다 다르게 동작하는 성질)이 안전하게 굴러가게 만드는 안전장치이기 때문이다. 부모 타입을 받아 처리하는 코드는 어떤 자식이 들어올지 모른 채 부모의 약속만 믿고 짠다. 그 믿음이 배신당하면 부모를 믿었던 코드가 통째로 무너진다. 가장 널리 알려진 함정이 정사각형과 직사각형이다.

13.1 정사각형-직사각형 함정

정사각형은 직사각형의 한 종류처럼 보인다. 그래서 자연스럽게 Rectangle을 상속시키고 싶어진다. 하지만 정사각형은 가로를 바꾸면 세로도 함께 바뀌어야 한다. 이 강제가 직사각형의 약속을 깬다. 아래는 정사각형이 직사각형을 상속한 잘못된 코드다.


class RectangleLSP:
def __init__(self, w, h):
self._w = w
self._h = h
def set_width(self, w):
self._w = w
def set_height(self, h):
self._h = h
def area(self):
return self._w * self._h
class SquareLSP(RectangleLSP):
def set_width(self, w):
self._w = w
self._h = w
def set_height(self, h):
self._w = h
self._h = h

def resize_and_check(rect):
rect.set_width(5)
rect.set_height(4)
return rect.area()

print("직사각형 기대 20:", resize_and_check(RectangleLSP(1, 1)))
print("정사각형은 16(계약 위반!):", resize_and_check(SquareLSP(1, 1)))


실행 결과는 다음과 같다.


직사각형 기대 20: 20
정사각형은 16(계약 위반!): 16


resize_and_check는 가로 5, 세로 4면 넓이가 20이라는 직사각형의 약속을 믿고 짠 코드다. 그런데 정사각형을 넣으니 마지막 set_height가 가로까지 4로 바꿔 넓이가 16이 나왔다. 자식을 넣었더니 프로그램이 틀린 답을 낸 것, 이것이 리스코프 치환 위반이다. 이 코드는 SquareLSP의 존재조차 모른다. 그저 직사각형의 계약을 믿었을 뿐인데 자식이 그 계약을 배신했다.

13.2 상속 관계를 끊어 해결

해법은 정사각형과 직사각형을 상속으로 묶지 않는 것이다. 둘 다 도형(Shape)이라는 공통 부모를 두되, 서로는 남남으로 둔다. 공통점은 넓이를 계산할 수 있다는 행동일 뿐, 하나가 다른 하나의 특수한 경우라는 구현 관계가 아니다.


from abc import ABC, abstractmethod

class ShapeLSP(ABC):
@abstractmethod
def area(self): ...
class Rectangle3(ShapeLSP):
def __init__(self, w, h):
self.w, self.h = w, h
def area(self):
return self.w * self.h
class Square3(ShapeLSP):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2

print("사각형:", Rectangle3(5, 4).area())
print("정사각형:", Square3(5).area())


실행 결과는 다음과 같다.


사각형: 20
정사각형: 25


정사각형은 직사각형이다라는 말이 수학적으로는 맞아도, 값을 바꿀 수 있는(mutable) 객체에서는 행동이 달라 상속이 깨진다. Square3는 이제 직사각형의 set_width 약속을 지킬 의무가 없다. 각자 자기 방식대로 area만 올바르게 내놓으면 되므로 치환 사고가 사라진다. 공통 부모만 두고 자식끼리 상속시키지 않는 것이 안전하다.

13.3 예외로 계약을 깨는 자식

계약을 깨는 또 다른 방식은 자식이 물려받은 메서드를 예외로 막아 버리는 것이다. 모든 새가 난다고 보고 fly를 부모에 두었는데, 타조는 못 난다며 예외를 던지면 fly를 믿고 쓰던 코드가 타조에서 터진다.


class BirdLSP:
def fly(self):
return "난다"
class OstrichBad(BirdLSP):
def fly(self):
raise NotImplementedError("타조는 못 난다")
def let_it_fly(bird):
return bird.fly()
print("일반 새:", let_it_fly(BirdLSP()))
try:
let_it_fly(OstrichBad())
except NotImplementedError as e:
print("LSP 위반:", e)


이제 능력으로 갈라 리팩터링한다. 모두가 공통으로 지킬 수 있는 수준인 move로 부모를 잡고, 나는 능력과 걷는 능력을 별도의 자식으로 나눈다.


class AnimalLSP(ABC):
@abstractmethod
def move(self): ...
class FlyingBird(AnimalLSP):
def move(self): return "날아서 이동"
class WalkingBird(AnimalLSP):
def move(self): return "걸어서 이동"
def travel(a):
return a.move()
print("나는 새:", travel(FlyingBird()))
print("걷는 새:", travel(WalkingBird()))


두 코드를 이어 실행한 결과는 다음과 같다.


일반 새: 난다
LSP 위반: 타조는 못 난다
나는 새: 날아서 이동
걷는 새: 걸어서 이동


자식이 부모 메서드를 구현 안 됨으로 막아 버리면, 그 메서드를 믿고 쓰던 코드가 자식에서 깨진다. travel은 모든 동물이 지키는 move만 부르므로 어떤 자식을 넣어도 안전하다. 모든 새는 난다는 잘못된 전제를 애초에 만들지 않은 것이 무엇이 좋아졌는지의 핵심이다.

13.4 정리

리스코프 치환 원칙은 상속이 진짜 종류 관계인지 판별하는 냉정한 잣대다. 서명이 맞는다고 상속이 옳은 것이 아니라, 자식이 부모의 행동 계약을 남김없이 지킬 때만 옳다. 자식이 물려받은 메서드를 예외로 막거나, 부모를 받는 코드가 자식을 따로 검사해 특별 취급해야 한다면 그 상속을 의심해야 한다. 그럴 때는 상속을 끊고 공통 부모 아래 독립된 자식으로 두거나, 능력 단위로 갈라 각자 올바른 행동만 책임지게 만든다. 자식을 만들 때마다 부모의 자리에 넣어도 프로그램이 여전히 옳은가를 물어야 한다.