본문 바로가기
스터디/오브젝트

10장 - 상속과 코드 재사용

by 검은도자기 2023. 8. 6.

개요

이번 장에서는 객체지향에서 중복 코드를 제거하는 대표적인 기법인 상속에 대해 설명합니다.

 

 

왜 중복 코드를 제거해야 할까?

  • 중복 코드가 가지는 가장 큰 문제는 코드를 수정하는 데 필요한 노력을 몇 배로 증가시킵니다.
  • 프로세스 예시) 어떤 코드가 중복인지 확인 → 찾았다면 찾아낸 모든 코드를 일관되게 수정 → 테스트하여 동일한 결과가 출력되는지 확인
  • 위 프로세스의 노력이 늘어날수록 코드의 변경이 힘들게 됩니다. 따라서 이러한 이슈 때문에 중복 코드를 제거해야 합니다.
  • 중복 여부를 판단하는 기준은 변경입니다.
  • 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복이며,  함께 수정할 필요가 없다면 중복이 아닙니다.

 

 

어떻게 중복 코드를 제거할 수 있을까?

  • 여러 가지 방법이 있겠지만 이 책에서 소개하는 개념인 DRY 원칙과 상속을 통해 중복코드를 제거하는 것을 소개하고 있기에 두 개념을 적용해서 중복코드를 제거를 하면 됩니다.

 

DRY 원칙

  • DRY는 반복하지 마라의 뜻의 ‘Don’t Repoeat Yourself’의 첫 글자로 모아 만든 용어로 동일한 지식을 중복하지 말라는 원칙입니다.
  • 원칙의 핵심은 코드 안에 중복이 존재해서는 안 된다는 것입니다.

 

상속을 이용한 중복 코드 제거 예시

  • 상속을 사용하여 중복 코드를 제거하는 간단한 예제를 보겠습니다.

 

요구사항

  • 각 자동차는 연료와 연비 정보를 가지고 있습니다.
  • 자동차는 주어진 거리를 주행할 때 필요한 연료량을 계산할 수 있어야 합니다.
  • 스포츠카는 일반 자동차보다 연료 소비가 10% 더 많습니다.

 

상속 적용 전

class Car:
    def __init__(self, fuel_efficiency):  # 연비 (km/l)
        self.fuel_efficiency = fuel_efficiency

    def fuel_needed(self, distance):
        return distance / self.fuel_efficiency

class SportsCar:
    def __init__(self, fuel_efficiency):  # 연비 (km/l)
        self.fuel_efficiency = fuel_efficiency

    def fuel_needed(self, distance):
        base_fuel = distance / self.fuel_efficiency
        return base_fuel + (base_fuel * 0.10)

 

  • 위 코드에서는 fuel_needed 메서드가 Car와 SportsCar 두 클래스에 중복되어 있습니다.
  • 중복 코드를 제거하지 않은 상태에서 코드를 수정할 수 있는 유일한 방법은 새로운 중복 코드를 추가하는 것뿐입니다.
  • 새로운 중복 코드가 늘어날수록 앱은 변경에 취약해지고 버그가 발생할 가능성이 높아집니다.
  • 민첩하게 변경하기 위해서는 중복 코드를 추가하는 대신 제거해야 합니다.

 

상속 적용 후

class Car:
    def __init__(self, fuel_efficiency):  # 연비 (km/l)
        self.fuel_efficiency = fuel_efficiency

    def fuel_needed(self, distance):
        return distance / self.fuel_efficiency

    def additional_fuel(self, base_fuel):
        return 0

    def total_fuel_needed(self, distance):
        base_fuel = self.fuel_needed(distance)
        return base_fuel + self.additional_fuel(base_fuel)

class SportsCar(Car):
    def additional_fuel(self, base_fuel):
        return base_fuel * 0.10

# 실행 코드
car = Car(10)  # 10 km/l 연비
print(car.total_fuel_needed(100))  # 100km 주행에 필요한 연료는 10l

sports_car = SportsCar(10)  # 10 km/l 연비
print(sports_car.total_fuel_needed(100))  # 100km 주행에 필요한 연료는 11l (10% 추가 연료 포함)

 

  •  Car 클래스에 기본 연료 계산 로직을 중앙화하고, 추가 연료 요구량을 계산하는 additional_fuel 메서드를 오버라이드하여 스포츠카의 추가 연료 요구량을 정의하였습니다.
  • 중복 코드를 줄이고 확장성을 증가시켰지만, Car 클래스와 SportsCar 클래스의 결합도가 높아졌습니다.

 

 

상속으로 만든 코드에 어떤 문제점이 있을까?

  • 다음과 같은 상속의 문제점들이 존재합니다.

 

1. 취약한 기반 클래스 문제 (Fragile Base Class Problem)

class Car:
    def total_fuel_needed(self, distance):
        return distance / 10

class SportsCar(Car):
    def total_fuel_needed(self, distance):
        base_fuel = super().total_fuel_needed(distance)
        return base_fuel + (base_fuel * 0.10)

print(SportsCar().total_fuel_needed(100))  # Expected: 11.0

# 부모 클래스 수정
class Car:
    def total_fuel_needed(self, distance):
        return distance / 12

print(SportsCar().total_fuel_needed(100))  # Result: 9.16667 (Unexpected!)

 

  • Car 클래스의 total_fuel_needed 메서드의 연비 계산 로직이 변경되면 SportsCar 클래스의 동작도 함께 변경됩니다. 이로 인해 SportsCar에서 예기치 않은 결과가 출력됩니다.
  • 부모 클래스에서 변경이 발생하면 자식 클래스의 동작에 예기치 않은 영향을 줄 수 있습니다.
  • 상속을 위한 경고 1 : 자식 클래스의 메서드 안에서 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합됩니다.

 

2. 불필요한 인터페이스 상속 문제

class Car:
    def total_fuel_needed(self, distance):
        return distance / 10
    
    def honk(self):
        print("Honk!")

class ElectricCar(Car):
    pass

my_electric_car = ElectricCar()
my_electric_car.honk()  # Output: "Honk!" (Unnecessary for an electric car)

 

  • ElectricCarCar 클래스로부터 honk 메서드를 상속받습니다.
  • 그러나 전기차에는 경적 기능이 필요하지 않을 수 있습니다. 이렇게 불필요한 인터페이스가 상속될 때 문제가 발생할 수 있습니다.
  • 부모 클래스에 불필요한 메서드나 속성이 있으면, 자식 클래스도 이를 상속받게 됩니다.
  • 상속을 위한 경고 2 : 상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있습니다.

 

3. 메서드 오버라이딩의 오작용 문제

class Car:
    def total_fuel_needed(self, distance):
        return distance / 10

class SportsCar(Car):
    def total_fuel_needed(self, distance):
        return distance / 9

print(SportsCar().total_fuel_needed(100))  # Result: 11.1111 (Not considering base fuel efficiency)

 

  • SportsCar 클래스에서 total_fuel_needed 메서드를 오버라이드하면서 부모 클래스인 Car의 연비 계산 로직을 누락시켰습니다. 이로 인해 연료 계산이 잘못된 결과를 출력합니다.
  • 자식 클래스에서 메서드를 오버라이드할 때, 부모 클래스의 기능을 누락하거나 변경할 위험이 있습니다.
  • 상속을 위한 경고 3: 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있습니다.

 

4. 부모 클래스와 자식 클래스의 동시 수정 문제

class Car:
    def __init__(self, fuel_efficiency):
        self.fuel_efficiency = fuel_efficiency
    
    # total_fuel_needed 메서드가 삭제됨

class SportsCar(Car):
    def total_fuel_needed(self, distance):
        base_fuel = super().total_fuel_needed(distance)
        return base_fuel + (base_fuel * 0.10)

# This will raise an error
# print(SportsCar(10).total_fuel_needed(100))

 

  • Car 클래스에서 total_fuel_needed 메서드를 제거한 경우, SportsCar 클래스에서 이 메서드를 호출하려고 하면 오류가 발생합니다.
  • 부모 클래스의 메서드나 속성이 변경될 경우, 자식 클래스도 그에 따라 수정해야 할 필요가 있을 수 있습니다.
  • 상속을 위한 경고 4 : 클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없습니다.

 

 

어떻게 상속의 문제점을 해결할 수 있을까?

  • 추상화를 통해 문제를 완전히 없앨 수는 없지만 어느 정도까지 위험을 완화시키는 것은 가능합니다.
  • 문제점을 해결한 방법은 아래 예제들을 참고하면 되겠습니다.

 

코드 중복을 제거하기 위해 상속을 도입할 때 따르는 두 가지 원칙

  • 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라. 메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있습니다.
  • 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식 클래스로 내리는 것보다 자식 클래스의 추상적인 메서드를 부모 클래스로 올리는 것이 재사용성과 응집도 측면에서 더 뛰어난 결과를 얻을 수 있습니다.

 

1. 차이를 메서드로 추출하라.

  • 가장 먼저 할 일은 중복 코드 안에서 차이점을 별도의 메서드로 추출하는 것입니다.
  • 이것은 흔히 말하는 “변하는 것으로부터 변하지 않는 것을 분리하라” 또는 “변하는 부분을 찾고 이를 캡슐화하라”라는 조언을 메서드 수준에서 적용한 것입니다.
  • 변하는 부분: 각 자동차의 연료 계산 방식
  • 변하지 않는 부분: 연료를 계산하여 출력하는 메커니즘
class Car:
    def __init__(self, model):
        self.model = model

    def fuel_efficiency(self):
        """변하는 부분: 연료 효율, 각 자동차의 서브클래스에서 오버라이드됩니다."""
        raise NotImplementedError

    def discount(self):
        """변하는 부분: 할인, 기본적으로 0으로 설정하고 필요한 서브클래스에서 오버라이드됩니다."""
        return 0

    def print_fuel_needed(self, distance):
        """변하지 않는 부분: 연료를 계산하여 출력하는 메커니즘"""
        total_fuel = (self.fuel_efficiency() - self.discount()) * distance
        print(f"{self.model} needs {total_fuel} liters for {distance} km.")

class Sedan(Car):
    def fuel_efficiency(self):
        return 0.05  # 0.05 liters/km

class SUV(Car):
    def fuel_efficiency(self):
        return 0.08  # 0.08 liters/km

    def discount(self):
        """SUV에만 특별한 할인이 적용됩니다."""
        return 0.01  # 0.01 liters/km discount

sedan = Sedan("SedanModelX")
sedan.print_fuel_needed(100)  # Output: SedanModelX needs 5.0 liters for 100 km.

suv = SUV("SUVModelY")
suv.print_fuel_needed(100)  # Output: SUVModelY needs 7.0 liters for 100 km.

 

  • fuel_efficiency는 각 자동차의 연료 효율을 나타내므로, 변하는 부분입니다. 이를 각 서브클래스에서 오버라이드하여 구체적인 값을 제공합니다.
  • discount는 할인을 나타내는 부분으로, 기본적으로는 0입니다. 특정 모델에서만 할인이 적용될 경우, 해당 서브클래스에서 이 메서드를 오버라이드하여 할인 값을 제공합니다.
  • print_fuel_needed는 모든 자동차에 공통적인 출력 로직을 수행하므로, 이를 Car 클래스에서 구현합니다.
  • 이렇게 변하는 부분(fuel_efficiency, discount)을 별도의 메서드로 추출함으로써, 각 자동차 모델의 특성에 따라 연료 계산 방식을 유연하게 변경할 수 있습니다.

 

 

2. 중복 코드를 부모 클래스로 올려라

  • 공통 코드를 옮길 때 인스턴스 변수보다 메서드를 먼저 이동시키는 게 편한데, 메서드를 옮기고 나면 그 메서드에 필요한 메서드나 인스턴스 변수가 무엇인지를 컴파일 에러를 통해 자동으로 알 수 있기 때문입니다.
  • 중복되는 부분: 연료와 속도를 출력하는 메커니즘
class Car:
    def __init__(self, model):
        self.model = model

    def fuel_efficiency(self):
        raise NotImplementedError

    def max_speed(self):
        raise NotImplementedError

    def print_specs(self):
        """중복되지 않는 부분: 연료 효율과 최대 속도를 출력하는 메커니즘"""
        print(f"{self.model} - Fuel Efficiency: {self.fuel_efficiency()} liters/km, Max Speed: {self.max_speed()} km/h.")

class Sedan(Car):
    def fuel_efficiency(self):
        return 0.05  # 0.05 liters/km

    def max_speed(self):
        return 200  # 200 km/h

class SUV(Car):
    def fuel_efficiency(self):
        return 0.08  # 0.08 liters/km

    def max_speed(self):
        return 180  # 180 km/h

sedan = Sedan("SedanModelX")
sedan.print_specs()  # Output: SedanModelX - Fuel Efficiency: 0.05 liters/km, Max Speed: 200 km/h.

suv = SUV("SUVModelY")
suv.print_specs()  # Output: SUVModelY - Fuel Efficiency: 0.08 liters/km, Max Speed: 180 km/h.

 

  • fuel_efficiency와 max_speed 메서드는 각 자동차의 특징에 따라 다르므로, Car 클래스에서 추상 메서드로 정의하고 서브클래스에서 오버라이드하여 구체적인 값을 제공합니다.
  • print_specs 메서드는 모든 자동차에 공통적인 출력 로직을 수행하므로, 이를 Car 클래스에서 구현합니다. 이로써 각 서브클래스에서 중복되는 출력 로직을 피할 수 있습니다.

 

 

정리

  • 상속을 사용하면 이미 존재하는 클래스의 코드를 기반으로 다른 부분을 구현함으로써 새로운 기능을 쉽고 빠르게 추가할 수 있습니다.
  • 기존 코드와 다른 부분만을 추가함으로써 앱의 기능을 확장하는 방법을 차이에 의한 프로그래밍이라 부릅니다.
  • 상속을 이용하면 이미 존재하는 클래스의 코드를 쉽게 재사용할 수 있기 때문에 앱의 점진적인 정의가 가능해집니다.
  • 차이에 의한 프로그래밍의 목표는 중복 코드를 제거하고 코드를 재사용하는 것입니다.
  • 중복을 제거하기 위해서는 코드를 재사용 가능한 단위로 분해하고 재구성해야 하며,  코드를 재사용하기 위해서는 중복 코드를 제거해서 하나의 모듈로 모아야 합니다.
  • 객체 지향에서 중복 코드를 제거하고 코드를 재사용할 수 있는 가장 유명한 방법은 상속입니다.
  • 상속은 이용하면 새로운 기능을 추가하기 위해 직접 구현해야 하는 코드의 양을 최소화할 수 있습니다.
  • 하지만 상속의 오용과 남용은 앱을 이해하고 확장하기 어렵게 만듭니다.
  • 따라서 필요한 경우에만 상속을 사용해야 합니다.
  • 상속은 코드 재사용과 관련된 대부분의 경우에 우아한 해결 방법이 아닙니다.
  • 상속의 단점을 피하면서도 코드를 재사용할 수 있는 더 좋은 방법은 다음 장에서 소개하겠습니다.

 

 

마무리

오늘은 오브젝트 10장에 대한 스터디를 진행하며, 중요하다고 생각되는 부분을 정리해 보았습니다.

이번 장에는 상속에 대한 문제점들을 대부분 알고 있다고 생각이 들었기에 예제를 많이 추가하는 방향으로 작성해 보았습니다.

예제를 많이 작성해 보니 내가 과연 상속의 문제점에 대해 제대로 알고 있었나? 에 대한 많은 반성이 들었습니다..

이번 포스팅은 마무리하면서 다음 포스팅에서 뵙겠습니다.

 

 

참고

http://www.yes24.com/Product/Goods/74219491

 

오브젝트 - 예스24

역할, 책임, 협력을 향해 객체지향적으로 프로그래밍하라!객체지향으로 향하는 첫걸음은 클래스가 아니라 객체를 바라보는 것에서부터 시작한다. 객체지향으로 향하는 두번째 걸음은 객체를

www.yes24.com

 

'스터디 > 오브젝트' 카테고리의 다른 글

12장 - 다형성  (0) 2023.09.20
11장 - 합성과 유연한 설계  (0) 2023.08.28
9장 - 유연한 설계  (0) 2023.07.24
8장 - 의존성 관리하기  (0) 2023.05.22
7장 - 객체 분해  (0) 2023.04.30