저번 시간에는 두번째 SOLID, 개방 폐쇄 원칙에 대해 알아봤습니다.

이번 시간에는 SOLID 세 번째이자 L에 해당하는 리스코프 치환 원칙에 대해 함께 배워봅시다.

💞 리스코프 치환 원칙

리스코프 치환 원칙(Liskov substitution principle). 이름만 들으면 굉장히 어려워보입니다. 이 리스코프는 컴퓨터 과학자 Barbara Liskov의 이름을 따서 만들어졌는데요.

이 원칙의 의미는 부모 클래스의 인스턴스를 사용하는 위치에 자식 클래스의 인스턴스를 대신 사용했을 때 코드가 원래 의도대로 작동해야 한다는 것입니다.

앞서 우리는 자식 클래스의 인스턴스가 부모 클래스의 인스턴스이기도 하다는 것을 배웠습니다. 이를 확인하기 위한 방법으로 isinstance 함수를 사용했었는데요.

isinstance(자식 클래스의 인스턴스, 부모 클래스) # True

이 성질은 반대로도 적용이 됩니다. 즉, 부모 클래스의 인스턴스 자리에 자식 클래스의 인스턴스도 들어갈 수 있다는 거죠.

이때, 리스코프 치환 원칙에 따르면 자식 클래스의 인스턴스가 부모 클래스의 인스턴스의 행동 범위 안에서 행동해야 합니다. 다시 말해, 부모 클래스의 행동 규약을 자식 클래스가 위반하면 안 된다고 하는 것입니다.

그렇다면 행동 규약을 위반한다는 것은 어떤 경우에 해당하는 걸까요? 만약, 자식 클래스가 부모 클래스의 변수와 메소드를 그대로 물려 받는다면 부모 클래스의 행동 규약을 어길 일이 없습니다.

문제가 되는 경우는 바로 자식 클래스가 오버라이딩을 할 때입니다. 자칫 잘못하면 리스코프 치환 원칙을 위배할 수 있으니까요.

자식 클래스가 오버라이딩을 잘못하는 경우는 크게 두 가지로 나뉩니다. 첫번째는 자식 클래스가 부모 클래스의 변수 타입을 바꾸거나 메소드의 파라미터 혹은 리턴값의 타입이나 갯수를 바꾸는 경우입니다. 두번째는 자식 클래스가 부모 클래스의 의도와 다르게 메소드를 오버라이딩 하는 경우입니다.

첫번째 경우를 코드를 통해 함께 봅시다.

class Employee:
    """직원 클래스"""
    company_name = "스타커피"
    raise_percentage = 1.02

    def __init__(self, name, wage):
        self.name = name
        self._wage = wage

    def raise_pay(self):
        """직원 시급을 인상하는 메소드"""
        self._wage *= self.raise_percentage

    @property
    def wage(self):
        return self._wage

    def __str__(self):
        """직원 정보를 문자열로 리턴하는 메소드"""
        return Employee.company_name + " 직원: " + self.name


class Cashier(Employee):
    """리스코프 치환 원칙을 지키지 않는 계산대 직원 클래스"""
    coffee_price = 3000

    def __init__(self, name, wage, number_sold=0):
        super().__init__(name, wage)
        self.number_sold = number_sold

    def raise_pay(self, raise_amount):
        """직원 시급을 인상하는 메소드"""
        self.wage += self.raise_amount

    @property
    def wage(self):
        return "시급 정보를 알려줄 수 없습니다"

Cashier 클래스는 Employee 클래스를 상속 받고 있습니다. 그리고 raise_pay 메소드와 wage라는 getter 메소드를 오버라이딩 하고 있습니다.

위 클래스들을 사용해보겠습니다. 인스턴스부터 만들죠.

employee_1 = Employee("타키탸키", 8000)
employee_2 = Employee("파이리", 6000)

cashier = Cashier("고질라", 9000)

다음으로 직원들의 목록을 만들어보겠습니다.

employee_list = []
employee_list.append(employee_1)
employee_list.append(employee_2)
employee_list.append(cashier)

그리고 직원들의 시급을 인상해주고 그 합을 출력해보겠습니다.

for employee in employee_list:
    employee.raise_pay()
    
total_wage = 0

for employee in employee_list:
    total_wage += employee.wage

print(total_wage)

실행을 하면, 에러가 납니다!

TypeError: raise_pay() missing 1 required positional argument: 'raise_amount'

raise_pay 메소드의 파라미터 raise_amount가 없다고 나오네요. 첫번째 반복문의 employee.raise_pay() 부분에서 에러가 난 겁니다.

지금 이 부분은 Employee 클래스의 인스턴스를 쓸 것이라 예상하고 작성되어 있습니다. 해당 메소드의 선언 부분을 보면 파라미터가 없습니다. 그러니 위와 같은 방식으로 호출하는 것은 문제가 없습니다.

문제는 자식 클래스인 Cashier입니다. Cashier가 raise_pay를 오버라이딩 하는 도중 자기 멋대로 raise_amount라는 파라미터를 추가했기 때문인데요. 이는 리스코프 치환 원칙에 위반됩니다.

문제가 되는 반복문을 주석 처리하고 실행을 해보겠습니다.

TypeError: unsupported operand type(s) for +=: 'int' and 'str

또 다시 에러가 나네요. 이번에는 두번째 반복문의 employee.wage 부분이 잘못된 건데요. 에러 문구를 해석하면 정수형과 문자열을 연산할 수 없다고 합니다.

이 부분은 Employee 클래스의 wage를 사용할 것이라 예상해서 작성되었습니다. 그리고 이 wage는 반환값으로 정수형을 리턴하죠. 그런데 Cashier 클래스를 보면 또 자기 멋대로 반환값을 문자열로 설정했습니다. 이는 Cashier가 오버라이딩을 잘못했기 때문에 리스코프 치환 원칙을 위반했다고 할 수 있습니다.

정리하자면, Cashier는 부모 클래스 Employee로부터 raise_pay 메소드를 상속 받을 때 파라미터의 갯수를 추가했고 wage 메소드를 상속 받을 때 리턴값의 타입을 바꿈으로써 부모 클래스의 행동 규약을 어겼고 리스코프 치환 원칙을 위반했습니다.

💞 리스코프 치환 원칙 적용

그럼 이제 리스코프 치환 원칙을 적용하여 Cashier 클래스의 코드를 수정해보겠습니다.

class Cashier(Employee):
    """계산대 직원 클래스"""
    raise_percentage = 1.03
    coffee_price = 3000

    def __init__(self, name, wage, number_sold=0):
        super().__init__(name, wage)
        self.number_sold = number_sold

    def take_order(self, money_received):
        """손님이 낸 돈을 받아 주문 처리를 하고 거스름돈을 리턴한다"""
        if Cashier.coffee_price > money_received:
            print("돈이 충분하지 않습니다. 돈을 다시 계산해서 주세요!")
            return money_received
        else:
            self.number_sold += 1
            change = money_received - Cashier.coffee_price
            return change
            
    def __str__(self):
        return Cashier.company_name + "계산대 직원: " + self.name 

수정된 코드에는 문제가 된 raise_pay와 wage 메소드가 사라졌습니다. 이는 리스코프 치환 원칙을 따르기 위해서 부모 클래스의 메소드를 그대로 물려받는 것이 안전하다고 판단한 결과입니다. 만약 오버라이딩을 하고 싶다면 메소드의 파라미터와 리턴값의 타입과 갯수를 맞춰서 오버라이딩 하면 됩니다.

take_order 메소드는 Employee 클래스에는 없는, 새롭게 추가된 메소드이므로 자유롭게 정의해도 상관 없습니다.

위에 작성한 테스트 코드를 실행하면 문제 없이 잘 출력됩니다.

23550.0

💞 행동 규약을 어기는 자식 클래스

이번에는 잘못된 오버라이딩 두번째 경우의 코드를 보겠습니다. 두번째 경우는 자식 클래스가 부모 클래스의 의도와 다르게 메소드를 오버라이딩 하는 것이었죠?

형식적인 규칙을 어기는 첫번째 경우와 다르게 내용적인 규칙을 어기는 두번째 경우는 코드가 정상적으로 작동하기 때문에 잡아내기 어렵습니다.

코드를 통해 예시를 보겠습니다.

class Rectangle:
    """직사각형 클래스"""

    def __init__(self, width, height):
        """세로와 가로"""
        self.width = width
        self.height = height

    def area(self):
        """넓이 계산 메소드"""
        return self.width * self.height

    @property
    def width(self):
        """가로 변수 getter 메소드"""
        return self._width

    @width.setter
    def width(self, value):
        """가로 변수 setter 메소드"""
        self._width = value if value > 0 else 1

    @property
    def height(self):
        """세로 변수 getter 메소드"""
        return self._height

    @height.setter
    def height(self, value):
        """세로 변수 setter 메소드"""
        self._height = value if value > 0 else 1


class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)

    @property
    def width(self):
        """가로 변수 getter 메소드"""
        return self._width

    @width.setter
    def width(self, value):
        """가로 변수 setter 메소드"""
        self._width = value if value > 0 else 1
        self._height = value if value > 0 else 1

    @property
    def height(self):
        """세로 변수 getter 메소드"""
        return self._height

    @height.setter
    def height(self, value):
        """세로 변수 setter 메소드"""
        self._width = value if value > 0 else 1
        self._height = value if value > 0 else 1

직사각형과 정사각형 클래스가 있습니다. 정사각형은 직사각형의 한 종류이죠? 그래서 '정사각형은 직사각형이다'라는 관계가 성립하고 상속 관계를 맺을 수 있습니다. 따라서, 정사각형 클래스는 직사각형 클래스를 상속 받고 있습니다.

직사각형의 setter 메소드 width는 가로의 길이만 설정하고 있습니다. 그런데 정사각형의 width는 가로와 세로 모두를 설정하고 있습니다. 마찬가지로 직사각형의 setter 메소드 height은 세로의 길이만 설정하고 있는데 반해, 정사각형의 height은 하나의 파라미터로 가로, 세로 둘 모두를 설정합니다.

정사각형의 정의 상, 가로 길이와 세로 길이 모두 동일하기 때문에 하나의 길이 파라미터로 두 가지를 모두 설정할 수 있습니다. 이렇게만 보면 정사각형 특징에 맞게 잘 오버라이딩 한 것 같은데요.

한 번 사용해볼까요? 먼저, 직사각형과 정사각형 인스턴스를 생성해줍니다.

rectangle_1 = Rectangle(3, 6)
rectangle_2 = Square(4)

다음으로 직사각형의 가로, 세로 길이를 변경하고 넓이를 출력해보겠습니다.

rectangle_1.width = 2
rectangle_1.height = 5

print(rectangle_1.area())

정사각형인 rectangle_2도 동일한 가로, 세로 길이로 변경해준 후 넓이를 출력해볼게요.

rectangle_2.width = 2
rectangle_2.height = 5

print(rectangle_2.area())
10
25

결과가 이상합니다. 둘 모두 같은 길이를 정의했는데 결괏값이 서로 다르기 때문이죠. 2와 5를 곱해서 10을 출력한 직사각형의 결과값은 문제 없지만 정사각형은 5의 제곱인 25가 나왔습니다.

위 코드 내용 상, 정사각형은 가로, 세로 길이 모두 동일하게 정의했습니다. 따라서, 마지막으로 들어간 수 5만이 계산이 되어 25가 나온 것이죠.

직사각형의 인스턴스는 직사각형 클래스의 인스턴스임과 동시에 정사각형 클래스의 인스턴스이기도 합니다. 리스코프 치환 원칙에 따르면 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대신하더라도 의도에 맞게 작동되어야 된다고 했습니다.

그런데 원래 이 코드를 작성한 사람은 마찬가지로 10이 나오길 예상했을 겁니다. 즉, 원래 의도와 다르게 코드가 작성된 것이죠.

이제 Square 클래스가 리스코프 치환 원칙을 지키도록 고쳐보겠습니다. 제일 먼저 해야 할 일은 Square의 이닛 메소드를 제외한 모든 코드를 삭제하는 것입니다. 이렇게 하면 Rectangle 클래스의 width와 height 메소드를 그대로 물려받게 됩니다.

하지만 이렇게 하면 가로, 세로 길이가 같은 정사각형의 특성을 완전히 무너뜨립니다. 이후, 실행 코드에서 가로 길이와 세로 길이를 따로 정의해야 하기 때문이죠.

정사각형의 정의에 맞게 오버라이딩 하면 리스코프 치환 원칙을 위반하게 되고 부모 클래스를 그대로 물려 받으면 정사각형의 정의에 어긋나게 됩니다.

사실 정사각형은 직사각형의 행동 규약을 지키기 어려운 객체입니다. 이 말은 즉, 애초에 정사각형 클래스를 만들 때, 직사각형 클래스를 상속 받으면 안되었다는 뜻입니다.

앞서 우리는 상속 관계의 조건으로 'A는 B다'를 만족하면 된다고 했습니다. 그러나 엄밀히 따지면, 이 조건 안에는 'A가 B의 행동 규약을 지키는가'의 여부도 포함되어야 합니다. 그래야 상속 관계를 맺을 때 문제가 없습니다.

따라서, 이 문제의 해결 방법은 둘 사이의 상속 관계를 끊는 것입니다. 그리고 정사각형 클래스를 새롭게 정의해야겠죠.

class Square(Rectangle):
    def __init__(self, side):
        return self.side = side

    def area(self):
        """정사각형 넓이 계산 메소드"""
        return self.side * self.side

    @property
    def side(self):
        """한 변 getter 메소드"""
        return self._side

    @side.setter
    def side(self, value):
        """한 변 setter 메소드"""
        self._side = value if value > 0 else 1

실행 코드도 수정해보겠습니다.

square = Square(4)
square.side = 6
print(square.area())
36

이처럼 두번째 경우에는 실행할 때 에러가 나진 않지만 원래 의도와는 다른, 엉뚱한 결과를 얻게 될 수도 있다는 큰 위험성이 있습니다.

💞 리스코프 치환 원칙의 중요성

리스코프 치환 원칙은 개발자 간 협력에 매우 중요합니다.

영화 재생 프로그램을 만든다고 합시다. 먼저, MoviePlayer라는 추상 클래스를 만들고 이 클래스를 상속 받아서 다양한 장르의 플레이어 클래스도 만들어야 합니다. 다양한 플레이어 클래스들은 MoviePlayer 클래스의 play 메소드를 영화 장르에 맞게 오버라이딩 해야 하는데요.

부모 클래스이자 추상 클래스인 MoviePlayer 클래스의 play 메소드는 영화 장르에 맞게 영화 제목을 문자열로 리턴한다는 행동 규약이 있습니다. 각각의 플레이어 클래스들은 이 행동 규약에 맞게 오버라이딩 해야 합니다. 그리고 사용자가 영화 장르를 선택하면 알맞은 플레이어 인스턴스를 가져와서 play 메소드를 호출합니다.

이때, 개발자 A는 추상 클래스와 인스턴스 생성을 맡고 개발자 B는 여러 플레이어 클래스를 만들기로 했습니다. 동시에 개발을 마치고 실행을 해봤는데 엉뚱한 결과가 나왔습니다. SadMovie 클래스에서 리스코프 치환 원칙을 위반했기 때문입니다.

앞서 말한 것처럼 play 메소드는 영화 제목을 문자열로 리턴해야 하는데 SadMovie 클래스의 리턴값은 뜬금없는 정수값으로 설정되어 있었습니다.

이렇게 하면 프로그램 실행에는 문제가 없지만 MoviePlayer 클래스의 자식 클래스들이 play 메소드가 영화 장르에 맞는 제목을 리턴할 거라 믿고 코드를 작성했던 개발자 A의 입장에서는 당황스러울 수 밖에 없습니다. 개발자 B가 이 믿음을 배신하면서 엉뚱한 결과가 나오는 코드를 작성한 것입니다.

이러한 믿음을 위반하지 않고 부모 클래스의 행동 규약을 준수하면서 자식 클래스를 만들라는 것이 리스코프 치환 원칙입니다.

개발은 혼자하는 경우보다 여럿이 함께 하는 경우가 많습니다. 따라서, 리스코프 원칙은 자신뿐만 아니라 다른 개발자를 위해서라도 꼭 지켜줘야 합니다.

💞 리스코프 치환 정리

리스코프 치환 원칙은 부모 클래스의 인스턴스를 사용하는 위치에 자식 클래스의 인스턴스를 대신 사용했을 때 코드가 원래 의도대로 작동해야 한다는 것이었습니다.

이를 위해서 두 가지 조건을 만족해야 하는데요.

  • 형식적 측면
    • 자식 클래스가 오버라이딩하는 변수와 메소드가 부모 클래스에 있는 형식과 일치해야 한다
    • 변수: 타입
    • 메소드: 파라미터와 리턴값의 타입 및 개수
    • 지키지 않으면 에러가 난다
  • 내용적 측면
    • 자식 클래스가 부모 클래스의 메소드에 담긴 의도 즉, 행동 규약을 위반하지 않는다
    • 위반해도 실행에 에러가 나진 않는다
    • 그러나, 의도하지 않은 결과가 나온다

프로그래밍에서 에러는 나지 않지만 예상에 벗어나는 결과는 위험합니다. 짧은 코드의 경우 발견이 빠르지만 코드가 길어지면 문제를 찾기도 어렵습니다. 특히, 코드의 양이 많고 여러 객체 간의 관계가 복잡한 프로그램에서 이런 문제가 발생하면 치명적인 모순이 프로그램에 오랫동안 숨어 있을 수 있습니다.

이런 일이 발생하지 않으려면 자식 클래스를 설계할 때 부모 클래스의 행동 규약을 벗어나지 않도록 유의해야겠죠?

리스코프 치환 원칙은 협업하는 개발자 사이의 신뢰를 위한 원칙이기도 합니다. 한 개발자가 리스코프 원칙을 위반하고 코드를 작성하면 에러가 나거나, 에러가 나지 않더라도 비정상적으로 실행되는 프로그램을 만들게 됩니다. 이러한 결과가 나오면 개발자 간 상호 신뢰를 잃게 됩니다. 그러니 리스코프 치환 원칙을 머리에 새기며 개발을 하도록 노력해 봅시다.


이번 시간에는 SOLID 세번째, 리스코프 치환 원칙에 대해 알아봤습니다. 잘못된 오버라이딩과 상속 관계가 치명적인 결과를 낳을 수 있다는 사실을 깨닫게 되었습니다.

다음 시간에는 SOLID 네번째, 인터페이스 분리 원칙에 대해 함께 알아봅시다.

* 이 자료는 CODEIT의 '객체 지향 프로그래밍' 강의를 기반으로 작성되었습니다.
profile
There's Only One Thing To Do: Learn All We Can

0개의 댓글