견고한 객체 지향 프로그래밍: SOLID 원칙

정재욱·2022년 7월 12일
0

Development_common_sense

목록 보기
4/4

SOLID 원칙이란?

Solid 원칙이란 로버트 마틴이 개발한 객체 지향 프로그래밍 및 설계의 다섯가지 원칙을 말합니다. 
프로그래머가 시간이 지나도 유지보수와 확장이 쉬운 시스템을 만들고자할 때 이 원칙들을 함께 적용할 수 있습니다. 
Solid 원칙은 각 원칙들의 첫 글자를 따서 만들어졌는데 다음과 같이 나뉩니다. 

  • 단일 책임 원칙 (Single Responsibility Principle)
  • 개방 폐쇄 원칙 (Open-closed Principle)
  • 리스코프 치환 원칙 (Liskov Substitution Principle)
  • 인터페이스 분리 원칙 (Interface Segregation Principle)
  • 의존 관계 역전 원칙 (Dependency Inversion Principle)

1.단일 책임 원칙 (Single Responsibility Principle)

모든 클래스는 단 한 가지의 책임만을 갖고, 클래스 안에 정의되어 있는 모든 기능은 이 하나의 책임을 수행하는데 집중되어 있어야 합니다.

쉽게 말하면 "하나의 클래스로 한가지 일만 하자!" 입니다.

예를 들어 Ship이라는 배 클래스가 하나 있다고 생각해 봅시다. 배를 구성하는 것들은 아주 많습니다. 배의 엔진, 배에 필요한 물자, 인력, 손님등 엄청나게 많습니다. 이렇게 수많은 요소들을 Ship이라는 클래스안에 모두 넣을 수는 있습니다.

하지만 어떤 문제가 발생해 클래스 전체를 뜯어 유지 보수하기 보다 Ship 이라는 클래스안에 배의 엔진을 담당하는 Engine 클래스, 물자를 담당하는 Material, 손님을 담당하는 Guest등을 만들어 각각의 클래스 책임을 부여하는 방식으로 만든다면 변경사항에 대해서만 코드를 수정하게 되는 좋은 구조로 만들 수 있을 것입니다. 

단일 책임 원칙을 사용하기 위해서는 어떠한 책임을 기준으로 나누어야 할지 생각해야 합니다.
책임은 수정되어야 할 이유를 기준으로 범위를 정하면 단일 책임 원칙을 잘 지킬 수 있습니다. 

class Ship:
    """배 클래스"""
    def __init__(self, fuel, fuel_per_hour, supplies, num_crew):
        """연료량, 시간당 연료 소비량, 물자량, 선원 수를 인스턴스 변수로 갖는다"""
        self.fuel = fuel
        self.fuel_per_hour = fuel_per_hour
        self.supplies = supplies
        self.num_crew = num_crew

    def report_fuel(self):
        """연료량 보고 메소드"""
        print("현재 연료는 {}l 남아 있습니다".format(self.fuel))

    def load_fuel(self, amount):
        """연료 충전 메소드"""
        self.fuel += amount

    def report_supplies(self):
        """물자량 보고 메소드"""
        print("현재 물자는 {}명분이 남아 있습니다".format(self.supplies))

    def load_supplies(self, amount):
        """물자 보급 메소드"""
        self.supplies += amount

    def distribute_supplies_to_crew(self):
        """물자 배분 메소드"""
        if self.supplies >= self.num_crew:
            self.supplies -= self.num_crew
            return True
        print("물자가 부족하기 때문에 배분할 수 없습니다")
        return False

    def report_crew(self):
        """선원 수 보고 메소드"""
        print("현재 선원 {}명이 있습니다".format(self.num_crew))

    def load_crew(self, number):
        """선원 승선 메소드"""
        self.num_crew += number

    def run_engine_for_hours(self, hours):
        """엔진 작동 메소드"""
        if self.fuel > self.fuel_per_hour * hours:
            self.fuel -= self.fuel_per_hour * hours
            print("엔진을 {}시간 동안 돌립니다!".format(hours))
        else:
            print("연료가 부족하기 때문에 엔진 작동을 시작할 수 없습니다")

위 클래스를 연료, 선원, 물자, 엔진 총 4가지 클래스로 분리해보자.

class Ship:
    """배 클래스"""
    def __init__(self, fuel, fuel_per_hour, supplies, num_crew):
        self.fuel_tank = FuelTank(fuel)
        self.crew_manager = CrewManager(num_crew)
        self.supply_hold = SupplyHold(supplies, self.crew_manager)
        self.engine = Engine(self.fuel_tank, fuel_per_hour)


class FuelTank:
    """연료 탱크 클래스"""
    def __init__(self, fuel):
        """연료 탱크에 저장된 연료량을 인스턴스 변수로 갖는다"""
        self.fuel = fuel

    def load_fuel(self, amount):
        """연료 충전 메소드"""
        self.fuel += amount

    def use_fuel(self, amount):
        """연료 사용 메소드"""
        if self.fuel - amount >= 0:
            self.fuel -= amount
            return True
        print("연료가 부족합니다!")
        return False

    def report_fuel(self):
        """연료량 보고 메소드"""
        print("현재 연료는 {}l 남아 있습니다".format(self.fuel))


class Engine:
    """엔진 클래스"""
    def __init__(self, fuel_tank, fuel_per_hour):
        """연료 탱크 인스턴스와 시간당 연료 소비량을 인스턴스 변수로 갖는다"""
        self.fuel_tank = fuel_tank
        self.fuel_per_hour = fuel_per_hour

    def run_for_hours(self, hours):
        """엔진 작동 메소드, 연료 탱크 인스턴스를 사용한다"""
        if self.fuel_tank.use_fuel(self.fuel_per_hour * hours):
            print("엔진을 {}시간 동안 돌립니다!".format(hours))
            return True
        print("연료가 부족하기 때문에 엔진 작동을 시작할 수 없습니다")
        return False


class CrewManager:
    """선원 관리 클래스"""
    def __init__(self, num_crew):
        """승선한 선원 수를 인스턴스 변수로 갖는다"""
        self.num_crew = num_crew

    def load_crew(self, number):
        """선원 승선 메소드"""
        self.num_crew += number

    def report_crew(self):
        """선원 수 보고 메소드"""
        print("현재 선원 {}명이 있습니다".format(self.num_crew))


class SupplyHold:
    """물자 창고 클래스"""
    def __init__(self, supplies, crew_manager):
        """물자량과 선원 관리 인스턴스를 인스턴스 변수로 갖는다"""
        self.supplies = supplies
        self.crew_manager = crew_manager

    def load_supplies(self, amount):
        """물자 충전 메소드"""
        self.supplies += amount

    def distribute_supplies_to_crew(self):
        """물자 배분 메소드, 각 선원들에게 동일한 양의 물자를 배분한다"""
        if self.supplies >= self.crew_manager.num_crew:
            self.supplies -= self.crew_manager.num_crew
            return True
        print("물자가 부족하기 때문에 배분할 수 없습니다")
        return False

    def report_supplies(self):
        """물자량 보고 메소드"""
        print("현재 물자는 {}명분이 남아 있습니다".format(self.supplies))

2.개방 폐쇄 원칙 (Open-closed Principle)

클래스는 확장에 열려 있어야하며, 수정에는 닫혀 있어야한다.

  • 확장에 열리다: 기존 기능을 확장할 수 있어야 함.
  • 수정에 닫히다: 한 번 작성한 코드를 바꾸지 않아도 되야함.
  • 즉, 어떤 클래스의 코드를 수정하지 않아도 기존 기능을 확장할 수 있어야 한다.
  • 이를 위해 추상 클래스와 추상 메서드를 정의하고 이를 상속받게 해서 사용하는 것이 좋다.
    • 다형성을 가진 객체 지향 프로그래밍을 하기 위해서  추상 클래스를 이용했었다. 모듈들은 고정된 추상화에 의존하기 때문에 수정에 대해서 닫혀 있을 뿐만 아니라 추상 클래스의 새 파생 클래스를 만드는 것을 통해서도 확장이 가능하다. 이렇게 추상화는 개방 폐쇄 원칙의 핵심 요소라고 할 수 있다. 

3.리스코프 치환 원칙 (Liskov Substitution Principle)

프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.

즉, 부모 클래스의 인스턴스를 사용하는 위치에 자식 클래스의 인스턴스를 대신 사용했을때 코드가 원래 의도대로 동작하면 된다.

이 말이 무슨 말일까?
자식 클래스의 인스턴스는 부모 클래스의 인스턴스이기도 하기 때문에, isinstance(자식클래스 인스턴스, 부모 클래스)에 넣으면 True가 리턴된다.
자식 클래스의 인스턴스는 부모 클래스의 인스턴스이기 때문에 변수나 메소드를 그냥 물려 받으면 문제가 없지만 만약 변수나 메소드를 잘못 오버라이딩한다면 문제가 생길 수 있다. 

잘못 오버라이딩 한다는 것은 아래 두가지의 경우다.
(1) 자식 클래스가 부모 클래스의 변수 타입을 바꾸거나 메소드의 파라미터 또는 리턴값이 타입 or 갯수를 바꾸는 경우
(2) 자식 클래스가 부모 클래스의 의도와 다르게 메소드를 오버라이딩 하는 경우

(1)자식 클래스가 부모 클래스의 변수 타입을 바꾸거나 메소드의 파라미터 또는 리턴값이 타입 or 갯수를 바꾸는 경우
부모 Employee클래스의 raise_pay 메서드는 파라미터를 받지 않고, wage메서드는 숫자를 리턴하는 반면,
자식 Cashier클래스는 raise_pay메서드에 파라미터를 하나 추가로 받고, wage메서드에서 str을 리턴한다.

이게 잘못된 예시이다.

class Employee:
    """직원 클래스"""
    company_name = "코드잇 버거"
    raise_percentage = 1.03

    def __init__(self, name, wage):
        self.name = name
        self._wage = wage

    def raise_pay(self):
        """직원 시급을 인상하는 메소드"""
        self._wage *= self.raise_percentage

    @property
    def wage(self):
        return self._wage

    def __str__(self):
        """직원 정보를 문자열로 리턴하는 메소드"""
        return Employee.company_name + " 직원: " + self.name


class Cashier(Employee):
    """리스코프 치환 원칙을 지키지 않는 계산대 직원 클래스"""
    burger_price = 4000

    def __init__(self, name, wage, number_sold=0):
        super().__init__(name, wage)
        self.number_sold = number_sold

    def raise_pay(self, raise_amount):
        """직원 시급을 인상하는 메소드"""
        self.wage += self.raise_amount

    @property
    def wage(self):
        return "시급 정보를 알려줄 수 없습니다"

(2)자식 클래스가 부모 클래스의 의도와 다르게 메소드를 오버라이딩 하는 경우
sqaure 클래스의 width, height메서드는 부모 클래스와 다르게 작동한다.
사실 정사각형은 직사각형의 행동 규약을 따르기 어렵기 때문에 애초에 상속을 안하는게 맞다.
아래 코드를 옳게 고칠 땐 상속 관계를 제거한다.

class Rectangle:
    """직사각형 클래스"""

    def __init__(self, width, height):
        """세로와 가로"""
        self.width = width
        self.height = height

    def area(self):
        """넓이 계산 메소드"""
        return self.width * self.height

    @property
    def width(self):
        """가로 변수 getter 메소드"""
        return self._width

    @width.setter
    def width(self, value):
        """가로 변수 setter 메소드"""
        self._width = value if value > 0 else 1

    @property
    def height(self):
        """세로 변수 getter 메소드"""
        return self._height

    @height.setter
    def height(self, value):
        """세로 변수 setter 메소드"""
        self._height = value if value > 0 else 1


class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)

    @property
    def width(self):
        """가로 변수 getter 메소드"""
        return self._width

    @width.setter
    def width(self, value):
        """가로 변수 setter 메소드"""
        self._width = value if value > 0 else 1
        self._height = value if value > 0 else 1

    @property
    def height(self):
        """세로 변수 getter 메소드"""
        return self._height

    @height.setter
    def height(self, value):
        """세로 변수 setter 메소드"""
        self._width = value if value > 0 else 1
        self._height = value if value > 0 else 1

4.인터페이스 분리 원칙 (Interface Segregation Principle)

특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.

이미 공부했다시피 추상클래스를 상속받으면 자식 클래스는 추상 메소드들을 반드시 오버라이딩해야 한다.
어떤 인터페이스(추상 클래스)를 상속받았을 때 사용하지 않을 메서드가 있다면 인터페이스 분리 원칙 위반이다.
인터페이스 분리 원칙을 위반하지 않기 위해서는 인터페이스를 작게 분리해야 한다. (뚱뚱한 인터페이스를 만들지 말자.)
인터페이스를 정의할 땐 항상 더 작게 쪼갤 수 있을지 고민해야 한다.
그렇다고 인터페이스 하나당 메서드 하나만 있을 정도로 잘게 쪼개라는 것은 아니다.
같은 기능이나 역할로 묶어서 인터페이스를 잘 만들어야 한다.

아래 코드에서 IMessage를 상속받는 Memo 클래스가 생긴다고 해보자.
메모는 Email, TextMessage클래스들과 달리 send기능이 없어야 하는데 IMessage를 상속받았다는 이유로 send메서드를 꼭 갖게 된다.
이게 바로 인터페이스 분리 원칙 위반이다.

이를 해결하기 위해 IMessage인터페이스를 IText(content, edit_content 메서드 소유)와 ISendable(send메서드 소유)로 분리하고, 메모는 IText만 상속받게 한다.

from abc import ABC, abstractmethod


class IMessage(ABC):
    @property
    @abstractmethod
    def content(self):
        """추상 getter 메소드"""
        pass

    @abstractmethod
    def edit_content(self, new_content: str) -> None:
        """작성한 메시지를 수정하는 메소드"""
        pass

    @abstractmethod
    def send(self, destination: str) -> bool:
        """작성한 메시지를 전송하는 메소드"""
        pass


class Email(IMessage):
    def __init__(self, content, owner_email):
        """이메일은 그 내용과 보낸 사람의 이메일 주소를 인스턴스 변수로 가짐"""
        self._content = content
        self.owner_email = owner_email

    @property
    def content(self):
        """_content 변수 getter 메소드"""
        return self._content

    def edit_content(self, new_content):
        """이메일 내용 수정 메소드"""
        self._content = self.owner_email + "님의 메일\n" + new_content

    def send(self, destination):
        """이메일 전송 메소드"""
        print("{}에서 {}로 이메일 전송!\n내용: {}").format(self.owner_email, destination, self._content)
        return True


class TextMessage(IMessage):
    def __init__(self, content):
        """문자 메시지는 그 내용을 인스턴스 변수로 가짐"""
        self._content = content

    @property
    def content(self):
        """_content 변수 getter 메소드"""
        return self._content

    def edit_content(self, new_content):
        """문자 메시지 내용 수정 메소드"""
        self._content = new_content

    def send(self, destination):
        """문자 메시지 전송 메소드"""
        print("{}로 문자 메시지 전송!\n내용: {}").format(destination, self._content)


class TextReader:
    """인스턴스의 텍스트 내용을 읽어주는 클래스"""

    def __init__(self):
        self.texts = []

    def add_text(self, text: IMessage):
        """인스턴스 추가 메소드, 파라미터는 IMessage 인터페이스를 상속받을 것"""
        self.texts.append(text)

    def read_all_texts(self):
        """인스턴스 안에 있는 모든 텍스트 내용 출력"""
        for text in self.texts:
            print(text.content)

5.의존 관계 역전 원칙 (Dependency Inversion Principle)

상위 모듈은 하위 모듈의 구현 내용에 의존하면 안 된다.

두 클래스 A, B가 있을 때 A가 B를 사용하면 A는 상위모듈, B는 하위모듈이다.
아래 코드에서 GameCharacter의 attack메소드가 Sword클래스를 사용하므로 GameCharacter는 상위 모듈, Sword클래스는 하위모듈이다.

그리고 아래 코드는 의존 관계 역전 원칙을 위반했는데, 이유는 attack에서 sowrd의 slash메소드를 의존하기 때문이다.
캐릭터가 검만 계속 사용한다면 문제는 없겠지만 만약 총이나 몽둥이 같이 다른 무기를 사용한다면 캐릭터 클래스 안에 미리 지정해 놓은 검의 slash 메소드는 의미가 없어질 뿐더러 공격을 할 수도 없다.

class Sword:
    """검 클래스"""
    def __init__(self, damage):
        self.damage = damage

    def slash(self, other_character):
        """검 사용 메소드"""
        other_character.get_damage(self.damage)


class GameCharacter:
    """게임 캐릭터 클래스"""
    def __init__(self, name, hp, sword: Sword):
        self.name = name
        self.hp = hp
        self.sword = sword

    def attack(self, other_character):
        """다른 유저를 공격하는 메소드"""
        if self.hp > 0:
            self.sword.slash(other_character)
        else:
            print(self.name + "님은 사망해서 공격할 수 없습니다.")

    def change_sword(self, new_sword):
        """검을 바꾸는 메소드"""
        self.sword = new_sword

    def get_damage(self, damage):
        """캐릭터가 공격받았을 때 자신의 체력을 깎는 메소드"""
        if self.hp <= damage:
            self.hp = 0
            print(self.name + "님은 사망했습니다.")
        else:
            self.hp -= damage

    def __str__(self):
        """남은 체력을 문자열로 리턴하는 메소드"""
        return self.name + "님은 hp: {}이(가) 남았습니다.".format(self.hp)

이에 대한 해결책은 추상 클래스로 상위 모듈과 하위 모듈 사이에 추상화 레이어를 만든다. 이렇게 되면
1. 상위 모듈에는 추상 클래스의 자식 클래스의 인스턴스를 사용한다는 가정 하에 그 하위 모듈을 사용하는 코드를 작성해두면 되고,
2. 하위 모듈은 추상 클래스의 추상 메소드들을 구현(오버라이딩)만 하면 된다.

즉, 위 문제에 대한 해결책으로 IWeapon이라는 무기 추상 클래스를 만들고 처음부터 사용할 메소드이름을 고정하고 상위 모듈과 하위 모듈 사이에 추상화 레이어를 만드는 것이다.
IWeapon 추상 클래스에 처음부터 use_on이라는 공격 메소드를 만들어 놓은 후, 검이든 총이든 IWeapon을 상속받아 use_on이라는 메소드를 사용한다면 캐릭터 클래스에 use_on 메소드를 사용해도 문제가 없다.

from abc import ABC, abstractmethod

class IWeapon(ABC):
	"""무기 클래스"""
    @abstractmethod
    def use_on(self, other_character):
    	pass
        
class Sword(IWeapon):
    """검 클래스"""
    def __init__(self, damage):
        self.damage = damage

    def use_on(self, other_character):
        """검 사용 메소드"""
        other_character.get_damage(self.damage)
...

참고
https://seungjuitmemo.tistory.com/52?category=908971

profile
AI 서비스 엔지니어를 목표로 공부하고 있습니다.

0개의 댓글