파이썬(PYTHON) 클린코드 #4_ 상속과 컴포지션
안녕하세요. 문범우입니다.
이번 포스팅에서는 상속과 컴포지션에 대해서 알아보도록 하겠습니다.
어떤 경우에 상속을 쓰는 것이 올바른 것인지, 그리고 파이썬에서 상속과 파이썬을 어떻게 사용하는지에 대해서 코드로 함께 살펴보겠습니다.
1. 상속(Inheritance)
상속이란 기본적으로 자신이 가진 속성과 메소드를 물려주는 클래스(부모클래스, Parent class, Super class)와 이를 물려받는 클래스(자식클래스, Child class, Sub class)로 이루어진다.
객체 지향적 소프트웨어를 디자인하고 구현할 때 상속은 중요한 개념으로, 또 자주 사용된다. 하지만 이를 다시 생각해보자. 상속개념을 활용하면 부모클래스와 자식클래스간에 강력한 결합력(Coupling)이 발생하게 된다.
좋은 코드, 유지보수를 위한 코드를 생각할 때 결합력을 낮추고, 응집도는 높여야 한다고 했는데, 이러한 점을 고려하면 상속이 무조건적으로 좋은 방안은 아닐 수 있다.
즉, 상속이란 것이 좋은 경우와 그렇지 않은 경우를 잘 선별해가며 활용할 수 있어야한다.
만약 새로운 자식클래스를 만들었을 때 해당 자식클래스가 올바르게 정의된 클래스인지 확인하기 위해서는 상속받은 모든 메서드를 실제로 자식클래스에서 사용하는지를 살펴보아야 한다. 만약 대부분의 메소드를 실제로 필요로 하지 않는다면 다음과 같은 이유로 설계상의 실수일 수 있다.
- 부모클래스가 잘 정의된 인터페이스 대신 막연한 정의와 너무 많은 책임을 가진 경우
- 자식클래스가 확장하려고 하는 부모클래스의 적절한 세분화가 아닌경우
따라서, 상속을 쓰는 올바른 경우는 새로 정의하고자 하는 자식클래스가 부모클래스의 기능을 그대로 물려받으면서 이중 특정 기능을 수정하고자 하거나, 추가적인 기능을 구현하고자 할 때이다.
또한 상속에서도, 인터페이스 정의는 또 다른 상속의 올바른 경우로 생각할 수 있다. 어떤 객체에 인터페이스 방식을 강제하고자 할 때, 실제 기능을 구현하지 않은 추상클래스를 만들고, 이를 상속받는 자식클래스에서 실제로 기능을 구현하게 한다.
그럼 올바르지 않은 상속의 경우는 어떠한 케이스가 있을까? 바로 예시 코드를 살펴보자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import collections from datetime import datetime class TransactionalPolicy(collections.UserDict): """잘못된 상속의 예시""" def change_in_policy(self, customer_id, **new_policy_data): self[customer_id].update(**new_policy_data) policy = TransactionalPolicy({ "client001":{ "fee": 1000.0, "expiration_date": datetime(2020,1,3), } }) policy["client001"] # {'fee': 1000.0, 'expiration_date': datetime.datetime(2020, 1, 3, 0, 0)} policy.change_in_policy("client001",expiration_date=datetime(2020,1,4)) policy["client001"] # {'fee': 1000.0, 'expiration_date': datetime.datetime(2020, 1, 4, 0, 0)} | cs |
위의 코드를 살펴보면 TransactionalPolicy 클래스는 파이썬 내장 collections의 사전자료형을 상속받아 구현하였다. 그리고 TransactionalPolicy 클래스에서 사용하고자 하는 change_in_policy 함수를 구현하였다.
이후 policy 라는 TransactionalPolicy 객체를 만들어 추가한 함수를 사용하였다. 이처럼 원하는 기능을 수행하는 클래스를 구현하였지만, 과연 이 클래스가 사전자료형을 상속하는 것이 올바른 것일까? 파이썬에서 객체가 사용가능한 메서드를 출력해주는 dir함수를 이용하여 policy객체가 이용가능한 메서드를 살펴보자.
1 2 3 4 5 6 7 8 | dir(policy) # ['_MutableMapping__marker', '__abstractmethods__', '__class__', '__contains__', '__copy__', '__delattr__', # '__delitem__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', # '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__module__', # '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__setattr__', '__setitem__', # '__sizeof__', '__slots__', '__str__', '__subclasshook__', '__weakref__', '_abc_impl', # 'change_in_policy', 'clear', 'copy', 'data', 'fromkeys', 'get', 'items', # 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values'] | cs |
policy 객체를 보면 우리가 추가로 구현한 change_in_policy 라는 함수 이외에도 다양한 함수가 있다. 정말 해당 객체에서 pop, popitem 등의 함수가 필요로 한 것일까? 필요하지 않을텐데 포함되어 있다. 더군다나 해당 함수들은 public 메서드이기 때문에 이를 사용하게 될 가능성도 있다. 이러한 것을 살펴보아, TransactionalPolicy 클래스가 사전자료형을 상속받은 것은 올바르지 않다고 볼 수 있다.
더군다나, 의미적으로 보았을 때 부모클래스로부터 상속을 받은 자식클래스는 개념적으로 확장되거나, 세부적이라는 것을 의미해야 한다. 하지만 TransactionalPolicy 클래스가 사전자료형에 대해서 개념적으로 확장되거나 세부적이라는 것이라고 생각할 수 있을까?
그럼, 우리는 TransactionalPolicy 클래스를 정의하기 위해서 어떻게 해야할까?
이러한 경우 컴포지션이라는 개념을 활용해볼 수 있다.
2. 컴포지션(Composition)
컴포지션이란, 상속과 다르게 단순히 사용한다는 개념이다. 즉, 기존의 상속 개념에서의 자식클래스가 부모클래스의 모든 속성을 물려받는게 아니라, 자식클래스가 필요한 속성만 부모클래스로부터 가져와 사용하는 것이다.
일반적으로 상속은 암시적 선언이라고 하며, 컴포지션은 명시적 선언이라고 한다.
그럼 바로 컴포지션은 어떻게 사용되는지 위에서의 예시를 가져와 살펴보자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | class NewTransactionalPolicy: """컴포지션을 활용한 예시""" def __init__(self, policy_data, **extra_data): self._data = {**policy_data, **extra_data} def change_in_policy(self, customer_id, **new_policy_data): self._data[customer_id].update(**new_policy_data) def __getitem__(self, customer_id): return self._data[customer_id] def __len__(self): return len(self._data) new_policy = NewTransactionalPolicy({ "client001":{ "fee": 1000.0, "expiration_date": datetime(2020,1,3), } }) new_policy["client001"] # {'fee': 1000.0, 'expiration_date': datetime.datetime(2020, 1, 3, 0, 0)} new_policy.change_in_policy("client001",expiration_date=datetime(2020,1,4)) new_policy["client001"] # {'fee': 1000.0, 'expiration_date': datetime.datetime(2020, 1, 4, 0, 0)} | cs |
이전의 예시와 달리 NewTransactionalPolicy 클래스에서는 사전자료형을 상속받지 않았다.
하지만 __init__함수에서 _data를 private로 선언하면서 사전자료형의 데이터로 초기화한다. 그리고 이후 우리가 사용하고자 하는 change_in_policy함수와 __getitem__함수에서 _data, 즉 사전 자료형을 활용하여 구현하고자 하는 기능을 구현하였다.
이렇게 직접 사전자료형을 상속받지 않고, 단순히 사전자료형을 가져와서 사용하였다.
이를 통해 추후 사전자료형에서 변경이 발생하더라도 그 인터페이스만 잘 유지된다면 NewTransactionalPolicy에서는 별도의 변경이 필요없다. 더군다나 이전에 dir함수를 통해 policy 객체가 사용가능한 메서드를 살펴본 것 처럼 new_policy 객체를 살펴보면 다음과 같다.
1 2 3 4 5 6 | dir(new_policy) # ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', # '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', # '__len__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', # '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', # '_data', 'change_in_policy'] | cs |
확실히 상속을 받았던 policy 객체에 비해, 사용 가능하지만 필요하지 않았던 함수가 사라진 것을 확인할 수 있다.
이렇게 상속과 컴포지션은 단순히 별개의 것이 아니라, 기존의 클래스를 재사용하는 상황에 있어서, 상속을 사용해야 할지 컴포지션을 사용해야 할 지 신중히 판단해야 한다. 단순히 현상황만을 고려한다기 보다는 상속과 컴포지션의 특성과, 장단점을 잘 생각하고 활용해야 새롭게 정의하는 클래스가 추후 가져올 문제를 최소화 할 수 있다.