로버트 마틴이 정의한 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙입니다. 유지보수와 확장이 쉬운 객체 지향 프로그래밍을 위해서 이 원칙을 따르는 것이 좋습니다.
1. 단일책임의 원칙 (Single Responsibility Principle)
- 클래스는 단 하나의 책임을 가져야 한다는 원칙
- 필요 이상의 책임을 가진 클래스는 책임을 분산시켜야 합니다.
- 클래스가 하나의 책임만을 가져 결합력을 낮추고 응집력을 높일 수 있습니다.
다음과 같은 비디오 클래스가 있습니다.
class Video:
def __init__(self, running_time):
self.running_time = running_time
self.format = format
def play(self): ...
def move(self): ...
비디오 클래스는 비디오에 관한 정보, 기능에 대한 책임만 집중해야 합니다. play, move와 같은 재생 관련 기능이 있는 것은 적절하지 않습니다.
단일책임의 원칙을 적용하면 다음과 같습니다.
class Video:
def __init__(self, running_time, format):
self.running_time = running_time
self.format = format
class Player:
def __init__(self, video):
self.video = video
def play(self): ...
def move(self): ...
재생 관련 기능을 분리하여 비디오 클래스는 비디오에 대한 기능, 정보만 집중할 수 있게 되었습니다.
2. 개방폐쇄의 원칙 (Open Close Principle)
- 클래스가 확장에는 열려있고 변경에는 닫혀있어야 한다는 원칙
- 변경/추가사항이 발생할 경우 기존 코드를 수정하는 방식이 아닌 확장을 통해 해결해야 합니다.
- 추상화와 다형성을 이용합니다.
Animal을 상속받는 Person, Cat, Dog 클래스가 존재하고 간단한 인사말을 출력하는 Printer 클래스의 hello라는 메소드가 존재하는 프로그램이 있습니다.
class Animal: ...
class Person(Animal): ...
class Cat(Animal): ...
class Dog(Animal): ...
class Printer:
def hello(self, animal: Animal):
if isinstance(animal, Person):
print("안녕")
elif isinstance(animal, Cat):
print("미야옹")
elif isinstance(animal, Dog):
print("멍멍")
동물의 종류가 늘어나면 hello 메소드 내부 역시 elif 구문이 추가되어야 합니다. 즉 기존 코드의 변경을 통해서 해결을 하고 있는 코드입니다.
개방폐쇄의 원칙을 적용한다면 다음과 같습니다.
class Animal(abc.ABC):
@abc.abstractmethod
def hello(self): ...
class Person(Animal):
def hello(self):
return "안녕"
class Cat(Animal):
def hello(self):
return "미야옹"
class Dog(Animal):
def hello(self):
return "멍멍"
class Printer:
def hello(self, animal: Animal):
print(animal.hello())
베이스 클래스에hello 메소드를 정의하고 구현 클래스에서 각자에 맞게 구현하도록 하였습니다.
Printer 클래스의 hello 메소드는 이제 animal 파라미터의 type 비교를 하지 않습니다. animal의 hello 메소드만 호출하면 됩니다.
동물의 종류가 늘어나도 클래스 확장만 해준다면 Printer는 변경되지 않습니다. 확장에는 열려있고 변경에는 닫혀있는 코드가 되었습니다.
3. 리스코프 치환 원칙 (Liskov Substitution Principle)
- S가 T의 하위 타입이라면 T 타입의 객체를 S 타입의 객체로 치환이 가능해야 한다는 원칙
한마디로 하위 클래스가 상위 클래스를 대체할 수 있어야 하는 것입니다.
class T: ...
class S(T): ...
def foo(bar: T): ...
foo 함수는 bar라는 파라미터의 타입으로 T를 허용합니다. 이때 T의 하위 타입인 S로 대체해도 동작되어야 합니다. 이와 같이 상위 클래스를 하위 클래스로 치환하기 위해서는 하위 클래스는 상위 클래스의 인터페이스를 준수해야 합니다. 리스코프 치환 원칙은 어떻게 하면 인터페이스를 준수하는 것인지 요구사항이 존재합니다.
- 시그니처 요구사항
- 하위형에서 메소드 인수의 반공변성
- 하위형에서 반환형의 공변성
- 하위형에서 메소드는 새로운 예외를 던지면 안 된다.
- 행동 요구사항
- 하위형에서 선행조건은 강화될 수 없다.
- 하위형에서 후행조건은 약화될 수 없다.
- 하위형에서 상위형의 불변조건은 반드시 유지되어야 한다
제네릭에 대해 알지 못하는 분들에겐 생소한 용어인 공변성과 반공변성이 등장합니다. 간략한 설명은 다음과 같습니다.
공변성이란 자기 자신과 하위타입을 허용하는 것을 뜻합니다.
반공변성이란 자기 자신과 상위타입을 허용하는 것을 뜻합니다.
그리고 요구사항의 설명이 너무 간략하여 한 번에 이해되지 않습니다. 이를 하나씩 살펴보겠습니다.
하위형에서 메소드 인수의 반공변성
리스코프 치환 원칙의 핵심은 하위 클래스가 상위 클래스를 추가적인 코드 수정 없이 대체할 수 있어야 하는 것입니다. 그러기 위해선 하위형이 오버라이드한 메소드의 파라미터, 리턴 타입, 예외처리도 호환이 되어야 합니다. 첫 번째 요구사항인 "하위형에서 메소드 인수의 반공변성"은 메소드 파라미터(인수)에 해당합니다. 하위형의 메소드 인수는 왜 반공변성이어야 할까요?
우선 주의할게 함수 인수의 타입은 공변성입니다. (물론 파이썬은 덕타이핑 언어이므로 타입이 맞지 않아도 실제 프로그램은 실행됩니다.)
class T: ...
class S(T): ...
def foo(bar: T): ...
foo 함수의 bar 파라미터의 타입은 T입니다만 T를 상속받은 S 역시 가능합니다.
t = T()
s = S()
foo(t) # it works.
foo(s) # it works too.
함수처럼 메소드 역시 마찬가지로 공변성입니다. 이 개념은 변하지 않습니다. 해당 요구사항과 별개의 개념입니다. 요구사항이 말하는 "하위형의 메소드 인수"라는 것은 상위 클래스와 하위 클래스 간의 상황을 의미합니다.
class Programmer(abc.ABC):
@abc.abstractmethod
def work(self): ...
class Backend(Programmer):
def work(self):
print("backend work")
def create_server_app(self):
print("create server app")
class Frontend(Programmer):
def work(self):
print("frontend work")
def create_html(self):
print("create html")
class Fullstack(Backend, Frontend):
def work(self):
print("fullstack work")
def create_web_service(self):
print("create web service")
self.create_html()
self.create_server_app()
##################################
class WebProgramming:
def do_project(self, worker: Fullstack):
worker.work()
worker.create_web_service()
class ServerProgramming(WebProgramming):
def do_project(self, worker: Backend):
worker.work()
worker.create_server_app()
Backend, Frontend, 이들을 다중 상속받은 Fullstack 클래스가 있습니다. 그리고 이 클래스들을 사용하는 WebProgramming과 ServerProgramming 클래스가 있습니다.
여기서 주목해야 할 부분은 do_project 메소드입니다. 상위 클래스인 WebProgramming.do_project 메소드 파라미터의 타입은 Fullstack이고 하위 클래스인 ServerProgramming.do_project 메소드 인수의 타입은 Backend입니다. 하위형 메소드 인수가 상위형 메소드 인수의 상위 타입이므로 반공변성입니다. 즉, 이 요구사항을 준수하고 있습니다. 그래서 클라이언트 코드에서도 객체 대체가 가능합니다.
fullstack = Fullstack()
programming = WebProgramming()
programming.do_project(fullstack) # it works.
programming = ServerProgramming() # it can substitutes to sub class.
programming.do_project(fullstack) # it works too.
추가적인 코드 변화 없이 하위 클래스가 상위 클래스를 대체가 가능합니다. Fullstack은 Backend를 상속받았기 때문에 Backend의 일을 다 할 수 있습니다. 하지만 공변성이 된다면 이는 불가능합니다.
class ServerProgramming:
def do_project(self, worker: Backend):
worker.work()
worker.create_server_app()
class WebProgramming(ServerProgramming):
def do_project(self, worker: Fullstack):
worker.work()
worker.create_web_service()
backend = Backend()
fullstack = Fullstack()
programming = ServerProgramming()
programming.do_project(backend) # it works.
programming = WebProgramming()
programming.do_project(backend) # it doesn't works.
Backend는 create_web_service 메소드가 구현되어 있지 않았기 때문에 대체가 불가능합니다. 그래서 하위형 메소드의 인수 타입이 공변성이라면 LSP가 깨지게 됩니다.
하위형에서 반환형의 공변성
파라미터에 대한 요구사항과 반대로 리턴타입은 공변성입니다. 이 역시 상위 클래스를 하위 클래스로 대체가 가능하도록 하기 위한 요구사항입니다.
class Product: ...
class WebProduct(Product): ...
class ServerProduct(Product): ...
class WebProgramming:
def do_project(self, worker: Fullstack) -> Product:
worker.work()
product = worker.create_web_service()
return product
class ServerProgramming(WebProgramming):
def do_project(self, worker: Backend) -> ServerProduct:
worker.work()
product = worker.create_server_app()
return product
backend = Backend()
fullstack = Fullstack()
server = WebProgramming()
product: Product = server.do_project(fullstack) # if works.
server = ServerProgramming() # it can substitutes to sub class.
product: Product = server.do_project(fullstack) # if works too.
위 코드처럼 Product, WebProduct, ServerProduct 3가지 클래스가 존재하고 하위형의 반환 타입이 공변성일 때 상위 클래스는 하위 클래스로 대체가 가능합니다.
하위형에서 메소드는 새로운 예외를 던지면 안 된다.
하위형에서 새로운 예외를 던진다면 상위 클래스에서 하위 클래스로 대체되었을 때 기존 코드에 새로운 예외에 대한 예외처리를 추가해야 합니다. 이를 방지하기 위해서는 상위 클래스가 던진 예외와 상위 클래스가 던진 예외의 하위형만 사용해야 합니다.
class WebException(Exception): ...
class ServerException(WebException): ...
class WebProgramming:
def do_project(self, worker: Fullstack) -> Product:
try:
worker.work()
except Exception:
raise WebException()
product = worker.create_web_service()
return product
class ServerProgramming(WebProgramming):
def do_project(self, worker: Backend) -> ServerProduct:
try:
worker.work()
except Exception:
raise ServerException()
product = worker.create_server_app()
return product
backend = Backend()
fullstack = Fullstack()
server = WebProgramming()
try:
product: Product = server.do_project(fullstack) # it works.
except WebException:
print("WebException")
server = ServerProgramming() # it can substitues to sub class.
try:
product: Product = server.do_project(fullstack) # it works too.s
except WebException:
print("WebException")
WebException과 하위형인 ServerException을 구현했을 때 하위 클래스에서 ServerException를 발생시키더라도 WebException 예외처리가 되어있기 때문에 추가적인 코드 수정은 필요하지 않습니다.
하위형에서 선행조건은 강화될 수 없다
선행조건이란 함수 Body에서 비즈니스 로직을 수행하기 전에 검증하는 조건을 뜻합니다. 만약 하위형에서 선행조건이 더 엄격하다면 클라이언트 코드는 예상하지 못할 수 있고 추가적인 코드 수정이 필요해집니다.
class WebProgramming:
def do_project(self, worker: Fullstack) -> Product:
# Preconditions
if worker.payment > 100:
raise WebException()
try:
worker.work()
except Exception:
raise WebException()
product = worker.create_web_service()
return product
class ServerProgramming(WebProgramming):
def do_project(self, worker: Backend) -> ServerProduct:
##### Preconditions cannot be strengthened in the subtype
# if worker.payment > 80:
# raise ServerException()
#####
if worker.payment > 150: # it works.
raise ServerException()
try:
worker.work()
except Exception:
raise ServerException()
product = worker.create_server_app()
return product
worker에게 payment 속성이 추가되었고, 상위클래스에서 worker의 payment가 100 이상이라면 do_project를 수행하지 않도록 조건을 추가하였습니다. 이때 하위 클래스에서 worker의 payment가 80 이상만 되어도 do_project를 수행하지 않도록 조건을 추가한다면 상위 클래스일 때 수행되던 payment 90짜리 worker들이 하위 클래스일 때는 수행되지 않을 것입니다. 그러면 하위형이 상위형을 대체할 수가 없게 됩니다.
이처럼 하위형에서 로직을 수행하기 위한 어떤 조건들이 강화된다면 LSP를 위반하는 것입니다.
하위형에서 후행조건은 약화될 수 없다.
후행조건이란 함수 Body에서 비즈니스 로직을 수행 후 값을 리턴하기 전에 값 검증에 대한 조건입니다. 만약 후행조건이 느슨하다면 클라이언트 코드는 예상하지 못할 수 있고 추가적인 코드 수정이 필요해집니다.
class WebProgramming:
def do_project(self, worker: Fullstack) -> Product:
# Preconditions
if worker.payment > 100:
raise WebException()
try:
worker.work()
except Exception:
raise WebException()
product = worker.create_web_service()
# Postconditions
if product.count_of_bug > 5:
raise WebException()
return product
class ServerProgramming(WebProgramming):
def do_project(self, worker: Backend) -> ServerProduct:
if worker.payment > 150:
raise ServerException()
try:
worker.work()
except Exception:
raise ServerException()
product = worker.create_server_app()
##### Postconditions cannot be weakened in the subtype
# if product.count_of_bug > 10:
# raise ServerException()
#####
if product.count_of_bug > 1: # it works.
raise ServerException()
return product
product에게 count_of_bug라는 속성이 추가되었고 상위 클래스 메소드에서 product의 count_of_bug가 5 이상이라면 에러를 발생시키도록 후행조건을 추가하였습니다. 이때 하위 클래스는 count_of_bug가 10 이상일 때 에러를 발생시키도록 후행조건을 느슨하게 한다면 클라이언트 코드에서는 예상보다 많은 버그의 product를 반환받게 됩니다. 이 역시 하위형이 상위형을 대체할 수 없게 됩니다. 반면 후행조건이 강화된 count_of_bug가 1 미만일 때만 반환되도록 하는 것은 괜찮습니다.
하위형에서 상위형의 불변조건은 반드시 유지되어야 한다
상위 클래스에서 정의한 불변 조건을 하위 클래스에서 변경하면 안 된다는 뜻입니다.
class Animal:
def __init__(self, age: int):
self._age = age
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if value < 0:
self._age = 0
else:
self._age = value
class Human(Animal):
@property
def age(self):
return self._age
@age.setter
def age(self, value):
self._age = value
상위 클래스에서 age속성을 0 미만으로 설정할 수 없도록 하였습니다. 그런데 하위 클래스에서는 이러한 조건을 없애버렸습니다. 상위 클래스에서 유지하던 age 속성의 불변성이 깨져 문제가 발생할 수 있습니다. 이러한 이유로 상위형의 불변조건을 유지하지 않으면 하위형이 상위형을 대체할 수 없게 됩니다.
4. 인터페이스 분리 원칙 (Interface Segregation Principle)
- 인터페이스는 작을수록 좋다는 원칙
- 하위 클래스에 필요한 메소드만 인터페이스에서 정의해야 합니다.
- 인터페이스를 작은 단위로 분리하여 인터페이스 간 독립성을 유지하고 다중 상속을 통해 기능을 유연하게 조합할 수 있습니다.
- 인터페이스를 구현한 클래스는 매우 명확한 동작과 책임을 지니기 때문에 응집력이 높아집니다.
class MultiFunctioPrinterInterface(abc.ABC):
@abc.abstractmethod
def scan(self): ...
@abc.abstractmethod
def print(self): ...
@abc.abstractmethod
def copy(self): ...
위의 복합기 인터페이스는 scan, print, copy 기능을 구현하도록 정의된 인터페이스입니다. 복합기는 프린터, 복사기, 스캐너를 합친 것입니다. 다시 말해 이들은 분리될 수도 있다는 뜻입니다. 그럼 인터페이스 분리 원칙을 적용해 보겠습니다.
class PrinterInterface(abc.ABC):
@abc.abstractmethod
def print(self): ...
class ScannerInteface(abc.ABC):
@abc.abstractmethod
def scan(self): ...
class CopierInteface(abc.ABC):
@abc.abstractmethod
def copy(self): ...
class MultiFunctioPrinter(PrinterInterface, ScannerInteface, CopierInteface):
def print(self): ...
def copy(self): ...
def scan(self): ...
이제 각 인터페이스는 명확한 동작과 책임을 지니게 되고 유연하게 조합을 할 수 있게 됩니다. 추후에 복합기뿐 아니라 프린터, 복사기, 스캐너들이 개별로 구현이 될 때 재사용이 가능합니다.
5. 의존관계 역전 원칙 (Dependency Inversion Principle)
- 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안된다는 원칙
- 상대적으로 큰 틀인 상위 수준의 모듈에서 의존 관계를 맺을 때 변화하기 쉬운 구체적인 구현에 의존하는 것이 아닌 변화가 거의 없는 추상화에 의존해야 합니다.
- 모듈 간의 독립성을 높이고 결합력을 낮추게 됩니다.
의존관계 역전 원칙을 준수하지 않는 자동차와 일반타이어의 관계입니다.
class NomalTire: ...
class Car:
def __init__(self) -> None:
self.tire: NomalTire
상위 수준의 모듈인 자동차가 일반타이어에 의존하고 있습니다. 일반타이어의 기능이 변경되면 자동차도 그에 맞게 변화해야 합니다. 이런 식으로 구체화에 의존하게 되면 두 모듈 간의 결합력이 높아집니다. 그리고 타이어는 스노우타이어, 머드타이어 스터드타이어 등 종류가 다양합니다. 다양한 타이어에 대한 확장성 역시 떨어지게 됩니다.
추상화 계층을 생성해 의존관계 역전 원칙을 준수한다면 문제를 해결할 수 있습니다.
class Tire(abc.ABC): ...
class NomalTire(Tire): ...
class SnowTire(Tire): ...
class Car:
def __init__(self) -> None:
self.tire: Tire
Tire라는 추상 계층을 생성하여 고수준 모듈은 더 이상 저수준 모듈에 의존하지 않아도 됩니다.
'객체 지향' 카테고리의 다른 글
객체 지향 프로그래밍 (0) | 2023.09.12 |
---|