SOLID 원칙이란 객체 지향 프로그래밍(OOP)의 5가지 핵심 원리를 말한다.
5가지 원칙의 약자를 따서 S.O.L.I.D 원칙인데 아래에서 하나하나 살펴보면서 공부해보도록 하자
단일 책임의 원칙(SRP, Single Responsibility Principle)
이론적으로는 하나의 클래스는 하나의 책임을 져야한다는 원칙이다.
쉽게 풀어서 말하자면 클래스가 변경되는 이유가 한가지만 있어야지 여러가지 이유로 클래스가 변경되어야 하면 시스템의 유지보수 측면에서 까다로워 질 수 있다는 말이다.
아래에서 한가지 예시를 들어보자
from pathlib import Path
from zipfile import ZipFile
class FileManager:
def __init__(self, filename):
self.path = Path(filename)
def read(self, encoding="utf-8"):
return self.path.read_text(encoding)
def write(self, data, encoding="utf-8"):
self.path.write_text(data, encoding)
def compress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="w") as archive:
archive.write(self.path)
def decompress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="r") as archive:
archive.extractall()
FileManager 클래스는 읽기와 쓰기, 압축과 압축해제 기능이 모두 들어가 있다.
딱 봐도 클래스 하나가 하는일도 많고 클래스를 변경하는 두가지 기능이 같이 들어있으니 단일 책임의 원칙을 위반한다.
이처럼 클래스가 둘 이상의 작업을 처리해야 할 경우 별도의 클래스로 분리하는게 객체지향적 설계를 개선하는데 도움이 된다. 아래에서 위 코드를 분리해보자.
from pathlib import Path
from zipfile import ZipFile
class FileManager:
def __init__(self, filename):
self.path = Path(filename)
def read(self, encoding="utf-8"):
return self.path.read_text(encoding)
def write(self, data, encoding="utf-8"):
self.path.write_text(data, encoding)
class ZipFileManager:
def __init__(self, filename):
self.path = Path(filename)
def compress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="w") as archive:
archive.write(self.path)
def decompress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="r") as archive:
archive.extractall()
이렇게 클래스를 2개로 나눠서 역할을 나눠주면 클래스의 역할이 줄어들어 유지보수도 쉽고 테스트, 디버그 모두 간편해진다. 그니까 모두들 객체 지향적 설계를 할때에는 단일 책임의 원칙을 고려해서 설계를 해보도록 하자.
개방 폐쇄 원칙(Open-Closed principle, OCP)
소프트웨어에 있는 클래스, 모듈, 기능이 확장에 대해 열려있어야 하며, 수정에 대해 닫혀있어야 한다는 원칙.
쉽게 풀어서 말하자면 요구사항에 대하여 추가사항이나 변경 사항이 있을 때, 확장은 쉽게 가능해야 하며 기존의 코드를 수정하지 않고도 변경사항 적용이 가능해야 한다는걸 의미한다.
아래에서 한가지 예시를 들어보자
from math import pi
class Shape:
def __init__(self, shape_type, **kwargs):
self.shape_type = shape_type
if self.shape_type == "rectangle":
self.width = kwargs["width"]
self.height = kwargs["height"]
elif self.shape_type == "circle":
self.radius = kwargs["radius"]
def calculate_area(self):
if self.shape_type == "rectangle":
return self.width * self.height
elif self.shape_type == "circle":
return pi * self.radius**2
위 코드는 직사각형과 원을 정의하고 면적을 계산할 수 있게 해주는 코드이다.
>>> from shapes_ocp import Shape
>>> rectangle = Shape("rectangle", width=10, height=5)
>>> rectangle.calculate_area()
50
>>> circle = Shape("circle", radius=5)
>>> circle.calculate_area()
78.53981633974483
실행을 해보면 이런식으로 직사각형과 원의 크기를 정해주고 면적까지 계산할 수 있는 잘 만들어진 코드처럼 보일 수 있다.
하지만 여기서 삼각형이나 정사각형같은 모양을 추가한다면 어떻게 될까?
모양을 추가할 때 마다 Shape 클래스를 수정해 나가야 할 것이다. 이게 바로 개방 폐쇄 원칙을 위반하는 상황이다.
이 코드를 개방 폐쇄원칙을 지켜서 다시 수정하려면 아래와 같이 짜면 된다.
from abc import ABC, abstractmethod
from math import pi
class Shape(ABC):
def __init__(self, shape_type):
self.shape_type = shape_type
@abstractmethod
def calculate_area(self):
pass
class Circle(Shape):
def __init__(self, radius):
super().__init__("circle")
self.radius = radius
def calculate_area(self):
return pi * self.radius**2
class Rectangle(Shape):
def __init__(self, width, height):
super().__init__("rectangle")
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
super().__init__("square")
self.side = side
def calculate_area(self):
return self.side**2
모양이 생길때마다 클래스를 추가해주면(확장에 대해 열려있어야 한다.) 기존의 클래스를 수정하지 않아도(수정에 대해 닫혀있다.) 추가적인 기능 수행이 가능해진다. 보통 요구사항은 계속 추가되거나 변경되기 일수니까 웬만하면 개방폐쇄원칙을 지키면서 코딩하도록 해보자.
리스코프 치환 원칙(Liskov Substitution Principle, LSP)
개방 폐쇄원칙처럼 이름만 들어도 잘 외워지는 이름이면 좋겠지만, 리스코프라는 사람이 자기이름을 따서 원칙을 만들어놨다. 서브타입은 항상 기반타입을 대체할 수 있어야 한다는 원칙인데 쉽게 말하면 자식클래스는 최소한 부모클래스의 역할을 모두 수행할 수 있어야 한다는 의미이다.
부모 클래스가 사용되는 위치에 자식클래스를 가져다 놔도 잘 돌아가야한다는 말인데 말로만 들으면 이해를 하기 어려우니 아래 코드예시를 통해 이해해보자.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side)
def __setattr__(self, key, value):
super().__setattr__(key, value)
if key in ("width", "height"):
self.__dict__["width"] = value
self.__dict__["height"] = value
이 코드에서는 Square는 Rectangle를 상속받아 만들어졌는데 __init__에서 부모클래스의 width와 height를 초기화시키고 side만 인수로 사용한다.
이 코드를 이용해서 실행을 해보면
>>> from shapes_lsp import Square
>>> square = Square(5)
>>> vars(square)
{'width': 5, 'height': 5}
>>> square.width = 7
>>> vars(square)
{'width': 7, 'height': 7}
>>> square.height = 9
>>> vars(square)
{'width': 9, 'height': 9}
이렇게 정사각형은 잘 만들 수 있지만 직사각형은 만들 수가 없게된다. 자식클래스가 부모의 역할을 대신할수 없으니까 이건 리스코프 치환 원칙에 위배되고 이걸 위배되지 않게 코딩을 하려면 아래와 같이 하면 된다.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def calculate_area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def calculate_area(self):
return self.side ** 2
이렇게 구현하게 되면 Shape 클래스는 다형성을 활용하여 Rectangle과 Square로 대체가 가능한 코드가 된다.
객체지향적인 설계를 할 때 클라이언트의 입장에서 자식클래스가 부모 클래스를 대체할 수 있는지 고려하고 설계를 해보자.
인터페이스 분리 원칙(Interface segregation principle, ISP)
처음에 말했던 단일책임의 원칙과 비슷한 생각에서 만들어진 원칙이다.
프로그램을 사용하는 클라언트의 목적에 따라 다른 인터페이스가 제공되어야 한다는 원칙인데 인터페이스계의 단일책임의 원칙이라고 이해하면 편할 것 같다.
프린터기를 예로 들어서 공부해보자
from abc import ABC, abstractmethod
class Printer(ABC):
@abstractmethod
def print(self, document):
pass
@abstractmethod
def fax(self, document):
pass
@abstractmethod
def scan(self, document):
pass
class OldPrinter(Printer):
def print(self, document):
print(f"Printing {document} in black and white...")
def fax(self, document):
raise NotImplementedError("Fax functionality not supported")
def scan(self, document):
raise NotImplementedError("Scan functionality not supported")
class ModernPrinter(Printer):
def print(self, document):
print(f"Printing {document} in color...")
def fax(self, document):
print(f"Faxing {document}...")
def scan(self, document):
print(f"Scanning {document}...")
위 코드를 한번 보자. 프린터 클래스에서 스캔이 정의되어있기 때문에 OldPrinter는 스캔 기능이 없어도 인터페이스를 구현하기는 해야한다. 팩스기능 또한 마찬가지.
이런 번거로운 문제점들을 해결하기 위해서 인터페이스 분리원칙을 적용하여 다시 코드를 짜보자.
from abc import ABC, abstractmethod
class Printer(ABC):
@abstractmethod
def print(self, document):
pass
class Fax(ABC):
@abstractmethod
def fax(self, document):
pass
class Scanner(ABC):
@abstractmethod
def scan(self, document):
pass
class OldPrinter(Printer):
def print(self, document):
print(f"Printing {document} in black and white...")
class NewPrinter(Printer, Fax, Scanner):
def print(self, document):
print(f"Printing {document} in color...")
def fax(self, document):
print(f"Faxing {document}...")
def scan(self, document):
print(f"Scanning {document}...")
프린터 팩스 스캐너를 따로 class를 만들어주고 기능이 있는것만 상속받아와서 사용하면 되니까 낭비되는 메소드가 없어진다. 나중에 뭐 팩스랑 스캔만되는 secondprinter가 생긴다면 그 두개만 상속받아와서 사용하면 된다.
이러한 설계원리를 따라 설계를 하게 되면 유연하고 확장이 쉬운 설계가 되니까 이점 기억해 두고 설계할때 두고두고 써먹자.
의존성 역전의 원칙(Dependency Inversion Principle, DIP)
드디어 SOLID의 마지막 원칙이다.
의존성 역전의 원칙은 고수준 모듈이 저수준 모듈에게 의존하면 안됨, 대신 저수준 모듈이 고수준 모듈에서 정의한 추상화타입을 통해 고수준 모듈에 의존해야하는 원칙이다.
무슨소리인가 싶겠지만 아래 코드를 보면서 더 자세히 이해해 보자
class FrontEnd:
def __init__(self, back_end):
self.back_end = back_end
def display_data(self):
data = self.back_end.get_data_from_database()
print("Display data:", data)
class BackEnd:
def get_data_from_database(self):
return "Data from the database"
위 코드를 보면 프론트엔드와 백엔드는 긴밀하게 연관되어 있다.
이 코드에서 API에서 데이터를 가져오는 기능을 추가하려면 어떻게 해야할까?
백엔드 클래스에 메소드를 추가하면 되겠지만 이러면 개방 폐쇄 원칙을 위배하게 된다.
이러한 문제를 해결하려면 의존성 역전의 원칙을 적용하고 백엔드 클래스가 아닌 추상화타입을 만들어서 의존할 수 있게 만들어야한다.
아래 코드를 한번 보자
from abc import ABC, abstractmethod
class FrontEnd:
def __init__(self, data_source):
self.data_source = data_source
def display_data(self):
data = self.data_source.get_data()
print("Display data:", data)
class DataSource(ABC):
@abstractmethod
def get_data(self):
pass
class Database(DataSource):
def get_data(self):
return "Data from the database"
class API(DataSource):
def get_data(self):
return "Data from the API"
필수 인터페이스나 메소드를 제공하는 Datasource 클래스를 만들어서 상속을 통해 새로운 기능들이 의존할 수 있게 만들어줬다. 후에 다른 기능을 추가하려면 datasource 클래스를 상속받아서 기능을 추가하면 된다. 이렇게하면 개방 폐쇄의 원칙도 지킬 수 있고 의존성 역전의 원칙도 지킬수 있는 코드라고 볼 수 있겠다.
결론
SOLID원칙은 왜 지켜져야할까?
객체지향적인 설계를 개선시킴으로서 객체지향설계의 장점을 극대화 할 수 있고 유지보수 및 확장이 편해져서 SOLID원칙을 적용하여 코드를 짜는것 같다. 다른사람과 협업을 할 때는 위 원칙들을 잘 지켜서 개발을 해보도록 하자.
공부할 때 도움받은 자료 및 출처
https://realpython.com/solid-principles-python/#single-responsibility-principle-srp
SOLID Principles: Improve Object-Oriented Design in Python – Real Python
In this tutorial, you'll learn about the SOLID principles, which are five well-established standards for improving your object-oriented design in Python. By applying these principles, you can create object-oriented code that is more maintainable, extensibl
realpython.com
'프로그래밍' 카테고리의 다른 글
리눅스 기본 명령어 정리 -2 (0) | 2024.01.17 |
---|---|
리눅스 기본 명령어 정리 -1 (1) | 2024.01.14 |