![[OOP 09] 합성이 상속을 이긴다](https://img.thenullpage.com/posts/6628/6628_2_877305.webp)
상속(inheritance, 부모 클래스의 기능을 물려받기)은 강력하지만 남용하기 쉬운 도구다. 클래스를 막 배우면 코드를 재사용하고 싶을 때 반사적으로 상속부터 떠올린다. 그러나 상속은 부모와 자식을 강하게 묶는다. 부모가 바뀌면 자식이 전부 흔들리고, 자식은 물려받은 내부 전부를 떠안는다. 그래서 오래전부터 시니어 개발자들은 상속보다 합성(composition, 다른 객체를 부품으로 품기)을 우선하라고 말해 왔다.
상속을 쓸지 합성을 쓸지는 한 문장으로 판별한다. 두 클래스 사이에 "A is-a B"(A는 B의 한 종류다)가 자연스러우면 상속이다. 개는 동물의 한 종류이므로 Dog가 Animal을 상속하는 것은 옳다. 반면 "A has-a B"(A가 B를 가지고 있다)가 맞으면 합성이다. 자동차는 엔진의 한 종류가 아니라 엔진을 가진 것이다. 이 판별을 건너뛰고 코드 재사용만 노려 상속하면 관계가 어그러진다. 이번 편은 그 어그러짐을 세 가지 상황으로 짚고, 합성으로 바로잡는다.
09.1 상속이 어색해지는 순간
자동차에 엔진 기능이 필요하다고 하자. 엔진의 start를 재사용하고 싶어서 Car가 Engine을 상속하면 코드는 돌아간다. 하지만 "자동차 is-a 엔진"이라는 말이 안 되는 관계가 만들어진다. before 코드다.
class EngineInheritBad:
def start(self):
return "엔진 시동"
class CarBadInherit(EngineInheritBad): # 자동차는 엔진의 한 종류? 틀림
pass
print("잘못된 상속:", CarBadInherit().start())
문법적으로는 아무 문제가 없다. CarBadInherit는 start를 물려받아 잘 부른다. 문제는 의미다. 자동차는 엔진이 아니다. 자동차는 엔진을 가진 것이다. 관계를 바로잡으려면 상속을 끊고, 자동차가 엔진을 부품으로 소유하게 한다. after 코드다.
class Engine:
def start(self):
return "엔진 시동"
class CarComposed:
def __init__(self):
self.engine = Engine() # 부품으로 소유
def start(self):
return "차 출발: " + self.engine.start()
print("합성:", CarComposed().start())
두 코드를 실행하면 이렇게 나온다.
잘못된 상속: 엔진 시동
합성: 차 출발: 엔진 시동
무엇이 좋아졌나. CarComposed는 엔진의 start만 빌려 쓰고, 엔진의 다른 내부까지 물려받지 않는다. 자동차와 엔진이 각자 독립된 클래스로 남으니 나중에 어느 쪽을 고쳐도 다른 쪽이 딸려 흔들리지 않는다. 상속을 정하기 전에 "자식 is-a 부모"가 자연스러운지 소리 내어 확인하는 습관을 들이면 이런 어색한 결합을 대부분 피한다.
09.2 부품을 갈아 끼운다
합성이 상속을 이기는 두 번째 이유는 교체다. 같은 자동차에 일반 엔진 대신 전기 모터를 넣고 싶다고 하자. 상속은 실행 중에 부모를 바꿀 방법이 없다. 코드를 짜는 순간 부모가 고정되기 때문이다. 합성은 부품을 밖에서 주입받아 얼마든지 갈아 끼운다.
class ElectricEngine:
def start(self):
return "전기모터 가동"
class FlexibleCar:
def __init__(self, engine):
self.engine = engine # 어떤 엔진이든 받는다
def start(self):
return self.engine.start()
print(FlexibleCar(Engine()).start())
print(FlexibleCar(ElectricEngine()).start())
실행 결과다.
엔진 시동
전기모터 가동
FlexibleCar는 코드가 한 글자도 바뀌지 않았다. 생성자에 어떤 엔진 객체를 넘기느냐에 따라 동작이 달라졌다. 이렇게 필요한 부품을 밖에서 넣어 주는 방식을 의존성 주입(dependency injection, 부품을 외부에서 건네주기)이라 부른다. 부품이 start라는 약속만 지키면 무엇이든 끼울 수 있다. 상속이었다면 EngineCar, ElectricCar처럼 클래스를 둘로 나눠야 했을 것을, 합성은 클래스 하나로 끝낸다.
09.3 클래스가 폭발할 때
상속의 가장 위험한 함정은 조합이다. 커피에 우유, 설탕 같은 옵션을 상속으로 표현하면 옵션 조합마다 새 클래스가 필요해진다. before 코드를 보자.
class Coffee:
def cost(self):
return 3000
class CoffeeWithMilk(Coffee):
def cost(self):
return super().cost() + 500
class CoffeeWithMilkAndSugar(CoffeeWithMilk):
def cost(self):
return super().cost() + 200
print("상속 폭발:", CoffeeWithMilkAndSugar().cost())
우유만, 설탕만, 우유와 설탕, 여기에 시럽까지 더하면 조합이 기하급수로 늘어난다. 옵션이 N개면 상속 조합은 최대 2의 N제곱 개 클래스가 필요하다. 옵션을 데이터로 품으면 이 폭발이 사라진다. after 코드다.
class Beverage:
def __init__(self, base, options):
self.base = base
self.options = options
def cost(self):
return self.base + sum(self.options)
print("합성:", Beverage(3000, [500, 200]).cost())
두 방식 모두 결과는 같다.
상속 폭발: 3700
합성: 3700
무엇이 좋아졌나. Beverage는 옵션을 리스트라는 데이터로 담는다. "우유와 설탕과 시럽"이라는 새 조합을 만들려면 클래스를 추가할 필요 없이 리스트에 값만 더 넣으면 된다. 상속은 조합을 타입으로 표현하려다 클래스가 폭발했지만, 합성은 조합을 데이터로 표현해 클래스 하나로 모든 경우를 감당한다. 변하는 것을 상속 계층이 아니라 데이터에 담는 것이 핵심이다.
09.4 상속과 합성을 가르는 기준
정리한다. 상속은 관계가 "is-a"일 때만 쓴다. 개는 동물이고, 정사각형은 도형이다. 이런 진짜 종류 관계에서는 상속이 중복을 깔끔하게 제거한다. 반면 관계가 "has-a", 즉 무언가를 부품으로 가지는 것이면 합성이다. 자동차는 엔진을 가지고, 음료는 옵션을 가진다.
합성이 상속보다 나은 세 가지 실익도 분명하다. 첫째, 결합이 느슨하다. 부품과 소유자가 독립 클래스로 남아 한쪽 변경이 다른 쪽에 번지지 않는다. 둘째, 실행 중에 부품을 갈아 끼운다. 밖에서 주입받으므로 같은 클래스로 여러 동작을 낸다. 셋째, 클래스 폭발을 막는다. 조합을 데이터로 담아 종류가 늘어도 클래스는 늘지 않는다. 상속이 필요한 자리는 생각보다 좁다. 코드 재사용이 목적이라면 먼저 합성을 떠올리는 것이 안전한 기본값이다.