프로그래밍/Computer Science

[OOP] 객체 지향 설계의 다섯가지 원칙: SOLID

Churnobyl 2023. 10. 2. 21:25
728x90
반응형


SOLID란?

 객체 지향 프로그래밍에서 항상 등장하는 SOLID원칙은 개발 및 보수를 하는 데 있어, 소스코드를 읽기 쉽고 확장이 원활하도록 만드는 다섯가지 원칙이다. SOLID는 각 원칙의 첫번째 글자를 합친 것으로 각각 Single Responsivility Principle, Open/Closed Principle, Liskov Substitution Principle, Interface Segregation Principle, Dependency Inversion Principle이다.

 

 SOLID 원칙을 사용하지 않더라도 객체 지향적인 프로그래밍은 가능하지만, 원칙을 지키면 동료들과의 협업이 원활해지며, 추후 소프트웨어를 확장할 때도 실수를 미연에 방지할 수 있다.

 

 


단일 책임 원칙(Single Responsibility Principle; SRP)

 THERE SHOULD NEVER BE MORE THAN ONE REASON FOR A CLASS TO CHANGE.

 

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

 

 즉, 한 클래스가 하나의 책임을 넘어서는 너무 다양한 기능을 가져서는 안된다는 의미다. 예를 들어 자동차라는 클래스가 있을 때, 자동차의 메소드 중에 자동차에 비해 아주 사소한 메소드, 예컨데 핸들을 조작할 때 작동하는 톱니바퀴의 움직임에 대한 메소드까지 직접 가지게 되면 자동차 클래스의 소스 코드가 너무 길어지고 유지보수도 너무 힘들어 질 것이다. 따라서 이런 경우에는 자동차 클래스의 기능들을 기능에 따라 더 작은 클래스들로 나누어서 단일한 책임을 가지도록 해야한다는 것이 첫번째 원칙인 SRP이다.

 


class Car:
    """단일 자동차 클래스"""
    def __init__(self, fuel, capacity, fuel_efficiency) -> None:
        self.fuel = fuel
        self.capacity = capacity
        self.fuel_efficiency = fuel_efficiency
        self.distance = 0
        
    def run_the_engine(self, litter):
        """엔진 작동 메소드"""
        self.burn_fuel(litter)
        return self.fuel_efficiency * litter
        
    def run(self, litter):
        """자동차 운행 메소드"""
        distance = self.run_the_engine(litter)
        self.distance += distance
        
    def load_fuel(self, amount: int):
        """연료 충전 메소드"""
        if amount <= 0:
            print("올바른 숫자를 기입해 주세요.")
            return False

        if self.capacity < self.fuel + amount:
            self.fuel = self.capacity
            print("연료 탱크가 가득 찼습니다. 남은 연료는 버려집니다.")
        else:
            self.fuel += amount

        return True

    def burn_fuel(self, amount: int):
        """연료 소비 클래스"""
        if self.fuel - amount >= 0:
            self.fuel - amount
            return True

        print("연료가 부족합니다.")
        return False

    def fuel_check(self):
        print("{}L가 남았습니다.".format(self.fuel))

 

 위는 단일한 클래스로 자동차의 메소드들을 구현했다. 한 클래스 안에서 연료 관련된 메소드, 엔진에 관한 메소드, 실제 동작에 관한 메소드들을 작동하게끔 되어 있는데 이는 직관적이지 못하다. 그래서 위 클래스를 여러 개의 작은 단위의 클래스로 나누는 것이 합리적이다.

 

 한 클래스가 여러가지 역할을 하는 위와 같은 클래스를 갓 오브젝트라고 한다.

 

 


class Car:
    """자동차 클래스"""
    def __init__(self, fuel, capacity, fuel_efficiency) -> None:
        self.fuel_tank = FuelTank(fuel, capacity)
        self.engine = Engine(self.fuel_tank, fuel_efficiency)
        self.distance = 0

    def run(self, litter):
        """자동차 운행 메소드"""
        distance = self.engine.run_the_engine(litter)
        self.distance += distance


class FuelTank:
    """연료 탱크 클래스"""
    def __init__(self, fuel: int, capacity: int) -> None:
        self.fuel = fuel
        self.capacity = capacity

    def load_fuel(self, amount: int):
        """연료 충전 메소드"""
        if amount <= 0:
            print("올바른 숫자를 기입해 주세요.")
            return False

        if self.capacity < self.fuel + amount:
            self.fuel = self.capacity
            print("연료 탱크가 가득 찼습니다. 남은 연료는 버려집니다.")
        else:
            self.fuel += amount

        return True

    def burn_fuel(self, amount: int):
        """연료 소비 클래스"""
        if self.fuel - amount >= 0:
            self.fuel - amount
            return True

        print("연료가 부족합니다.")
        return False

    def fuel_check(self):
        print("{}L가 남았습니다.".format(self.fuel))


class Engine:
    """자동차 엔진 클래스"""
    def __init__(self, fuel_tank: FuelTank, fuel_efficiency: int) -> None:
        self.fuel_tank = fuel_tank
        self.fuel_efficiency = fuel_efficiency

    def run_the_engine(self, litter):
        """엔진 작동 메소드"""
        self.fuel_tank.burn_fuel(litter)
        return self.fuel_efficiency * litter


car = Car(50, 100, 10)
print(car.distance)
car.run(20)
print(car.distance)
0
200

 

 코드 자체는 길어졌지만 각 클래스의 코드 자체는 짧아졌다. 따라서 이 코드의 각 부분이 무슨 역할을 하는지 직관적으로 알 수 있게 되었다. 이렇게 코드 관리를 하는 것이 SRP다.

 

 


개방 폐쇄 원칙(Open/Closed Principle; OCP)

YOU SHOULD BE ABLE TO EXTEND A CLASSES BEHAVIOR, WITHOUT MODIFYING IT.

 

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

 

 즉, 어떤 클래스의 코드를 수정하지 않아도 기존 기능을 확장할 수 있어야 한다는 뜻이다. 위의 정의에서 확장에 열려 있어야 한다는 것은 프로그램의 기존 기능을 확장할 수 있어야 한다는 것이고, 수정에 닫혀 있어야 한다는 것은 한 번 작성한 코드를 바꾸지 않아도 되어야 한다는 것이다. 예를 들어, 여러 핸드폰의 텍스트 데이터를 수집해 사용 경향을 수집하는 소프트웨어를 만들고 있다고 하자. 아이폰과 갤럭시에서 원활히 작동되는 소프트웨어에 모토로라의 핸드폰이 작동되도록 추가할 때 기존의 코드를 수정하지 않아도 확장할 수 있도록 해야한다는 뜻이다.

 

 


class TextCollectingSoftware:
    def __init__(self) -> None:
        self.phone = None

    def connect_phone(self, phone):
        self.phone = phone

    def collect(self):
        return self.phone.data_export()


class Iphone:
    def __init__(self) -> None:
        self.data = []

    def data_input(self, data):
        self.data.append(data)

    def data_export(self):
        return self.data


class Galaxy:
    def __init__(self) -> None:
        self.data = []

    def data_input(self, data):
        self.data.append(data)

    def data_export(self):
        return self.data


class Motorola:
    def __init__(self) -> None:
        self.data = []

    def text_input(self, data):
        self.data.append(data)

    def text_export(self):
        return self.data


# 각 인스턴스 생성
tcs = TextCollectingSoftware()
iphone = Iphone()
galaxy = Galaxy()
motorola = Motorola()

# 각 핸드폰에 텍스트 삽입
iphone.data_input("김치만두")
iphone.data_input("고기만두")

galaxy.data_input("감자튀김")
galaxy.data_input("고기완자")

motorola.text_input("후라이드치킨")
motorola.text_input("스시")

# 데이터 추출
tcs.connect_phone(iphone)
print(tcs.collect())

tcs.connect_phone(galaxy)
print(tcs.collect())

tcs.connect_phone(motorola)
print(tcs.collect())
['김치만두', '고기만두']
['감자튀김', '고기완자']
AttributeError: 'Motorola' object has no attribute 'data_export'

 

 각 클래스를 보면 먼저 TextCollectingSoftware는 connect_phone 메소드로 각 핸드폰을 연결한 뒤 collect메소드로 핸드폰의 데이터를 추출하는 간단한 클래스다. 그리고 Iphone, Galaxy, Motorola 클래스는 각각 핸드폰의 데이터 input, export만을 구현한 간단한 클래스다. 이 때, Motorola클래스는 앞선 두 개의 핸드폰 클래스와 다르게 메소드가 text_input, text_export다. 따라서 TextCollectingSoftware에서 collect메소드를 호출할 때 AttributeError가 발생한다.

 

 이를 해결하기 위해서는 collect메소드에서 isinstance함수로 어떤 클래스의 인스턴스인지에 따라 다른 메소드를 호출해 주도록 할 수 있지만, 앞으로 샤오미, 구글폰 등등 핸드폰이 계속해서 늘어날 경우 isinstance함수로 하나하나 비교할 수는 없다. 또한 이는 기존의 클래스를 계속해서 수정해야 하므로 OCP를 위반한다.

 

 OCP를 만족하는 방식으로 해결하는 방법은 인터페이스를 만드는 것이다. 파이썬에는 인터페이스 개념이 없지만 추상 메소드만으로 만들어진 클래스를 만드는 방식으로 구현할 수 있다. 인터페이스란 추상 메소드만으로 만들어진 추상 클래스로 이를 상속받아 각 메소드를 오버라이딩하는 방식으로 기존 클래스의 수정을 피할 수 있다.

 

 


from abc import ABC, abstractmethod


class InterfacePhone(ABC):

    @abstractmethod
    def data_input(self, data):
        pass

    @abstractmethod
    def data_export(self):
        pass


class TextCollectingSoftware:
    def __init__(self) -> None:
        self.phone = None

    def connect_phone(self, phone):
        self.phone = phone

    def collect(self):
        return self.phone.data_export()


class Iphone(InterfacePhone):
    def __init__(self) -> None:
        self.data = []

    def data_input(self, data):
        self.data.append(data)

    def data_export(self):
        return self.data


class Galaxy(InterfacePhone):
    def __init__(self) -> None:
        self.data = []

    def data_input(self, data):
        self.data.append(data)

    def data_export(self):
        return self.data


class Motorola(InterfacePhone):
    def __init__(self) -> None:
        self.data = []

    def data_input(self, data):
        self.data.append(data)

    def data_export(self):
        return self.data


# 각 인스턴스 생성
tcs = TextCollectingSoftware()
iphone = Iphone()
galaxy = Galaxy()
motorola = Motorola()

# 각 핸드폰에 텍스트 삽입
iphone.data_input("김치만두")
iphone.data_input("고기만두")

galaxy.data_input("감자튀김")
galaxy.data_input("고기완자")

motorola.data_input("후라이드치킨")
motorola.data_input("스시")

# 데이터 추출
tcs.connect_phone(iphone)
print(tcs.collect())

tcs.connect_phone(galaxy)
print(tcs.collect())

tcs.connect_phone(motorola)
print(tcs.collect())
['김치만두', '고기만두']
['감자튀김', '고기완자']
['후라이드치킨', '스시']


 파이썬에서 abc모듈의 ABC, abstractmethod를 각각 불러와서 InterfacePhone 추상 클래스를 만들어 주었다. 이 때, 입출력 역할을 하는 data_input, data_export 추상 메소드를 지정해줌으로서 Motorola 클래스의 입출력 메소드 또한 이 이름을 따르도록 강제할 수 있다.

 

 이제 TextCollectingSoftware의 수정을 하지 않고도 모든 핸드폰의 텍스트를 출력할 수 있게 되었다.

 

 

 


리스코프 치환 원칙(Liskov Substitution Principle; LSP)

 FUNCTIONS THAT USE POINTERS OR REFERENCES TO BASE CLASSES MUST BE ABLE TO USE OBJECTS OF DERIVED CLASSES WITHOUT KNOWING IT.

 

 정의: 부모 클래스의 인스턴스를 사용하는 위치에 자식 클래스의 인스턴스를 대신 사용했을 때 코드가 원래 의도대로 작동되어야 한다.

 

 A라는 부모를 상속받은 B라는 자식 클래스가 있을 때 각각의 인스턴스인 a, b에서 a의 위치에 b를 대신 넣어도 a의 역할을 온전히 수행할 수 있어야 한다는 것이며 즉, 쉽게 말해서 오버라이딩을 신중하게 해야 한다는 뜻이다. 자식 클래스가 오버라이딩을 잘못하는 경우는 크게 두 가지로 나눌 수 있는데, 첫째로 자식 클래스가 부모 클래스의 변수의 타입을 바꾸거나 메소드의 파라미터 또는 리턴값의 타입 혹은 갯수를 바꿔버리는 경우, 두번째로 자식 클래스가 부모 클래스의 의도와 다르게 메소드를 오버라이딩하는 경우다.

 

 

 

 


인터페이스 분리 원칙(Interface Segregation Principle; ISP)

CLIENTS SHOULD NOT BE FORCED TO DEPEND UPON INTERFACES THAT THEY DO NOT USE.

 

 정의: 클래스가 사용하지 않을 메소드에 의존할 것을 강요하면 안된다.

 

 간단히 말해서 A라는 인터페이스를 B라는 클래스가 오버라이딩했을 경우 A 인터페이스의 메소드 중에 B 클래스에서 사용하지 않을 메소드가 포함되어 있으면 안된다는 뜻이다. 이렇게 사용하지 않는 메소드들을 포함한 A 인터페이스를 뚱뚱한 인터페이스라고 하며 이런 경우 A 인터페이스를 더 작게 나누어서 부분 부분 사용할 수 있도록 만들어야 한다. 이렇게 작게 나눈 인터페이스를 역할 인터페이스(Role Interface)라고 한다.

 

 더 작게 나누었을 경우에, 이를 상속받는 클래스들은 각 역할 인터페이스들을 다중 상속받으면 된다.

 

 


from abc import ABC, abstractmethod


class IPrinter(ABC):
    @abstractmethod
    def print_file(self, file: str) -> bool:
        """문서 출력 메소드"""
        pass


class IScanner(ABC):
    @abstractmethod
    def scan(self, content: str) -> bool:
        """문서 스캔 메소드"""
        pass


class SamsungPrinter(IPrinter, IScanner):
    def __init__(self, has_ink, has_paper, is_connected):
        self.has_ink = has_ink
        self.has_paper = has_paper
        self.is_connected = is_connected

    def print_file(self, file):
        """문서 출력 메소드"""
        if self.has_ink and self.has_paper and self.is_connected:
            print("문서 {}을/를 출력 중입니다!".format(file))
            return True
        return False

    def scan(self, content):
        """문서 스캔 메소드"""
        if self.is_connected:
            print("{}을/를 이미지 파일로 저장합니다.".format(content))
            return True
        return False


class LGPrinter(IPrinter):
    def __init__(self, has_ink, has_paper, is_connected):
        self.has_ink = has_ink
        self.has_paper = has_paper
        self.is_connected = is_connected

    def print_file(self, file):
        """문서 출력 메소드"""
        if self.has_ink and self.has_paper and self.is_connected:
            print("문서 {}을/를 출력합니다.".format(file))
            return True
        return False


samsung_printer = SamsungPrinter(True, True, True)
lg_printer = LGPrinter(True, True, True)

samsung_printer.print_file("4월 보고서.docx")
lg_printer.print_file("4월 보고서.docx")

samsung_printer.scan("스캔 테스트 문서")
# lg_printer.scan("스캔 테스트 문서")

print(SamsungPrinter.mro())
print(LGPrinter.mro())
문서 4월 보고서.docx을/를 출력 중입니다!
문서 4월 보고서.docx을/를 출력합니다.
스캔 테스트 문서을/를 이미지 파일로 저장합니다.
[<class '__main__.SamsungPrinter'>, <class '__main__.IPrinter'>, <class '__main__.IScanner'>, <class 'abc.ABC'>, <class 'object'>]
[<class '__main__.LGPrinter'>, <class '__main__.IPrinter'>, <class 'abc.ABC'>, <class 'object'>]

 

 위와 같이 IPrinter와 IScanner로 추상 메소드를 나누어서 구현하므로서 프린터 기능과 스캐너 기능이 함께 있는 SamsungPrinter는 두 추상 메소드를 다중상속하고, 프린터 기능만 있는 LGPrinter는 IPrinter만을 상속할 수 있도록 만들었다. 만약 IPrinter, IScanner 두 추상 메소드를 합쳐서 하나로 만들었을 경우 LGPrinter가 하나의 추상 메소드를 상속받았을 경우 사용하지도 않은 scan 메소드를 가지게 된다.

 

 이러한 경우를 방지하는 것이 ISP다.

 

 


의존 관계 역전 원칙(Dependency Inversion Principle; DIP)

A. HIGH LEVEL MODULES SHOULD NOT DEPEND UPON LOW LEVEL MODULES. BOTH SHOULD DEPEND UPON ABSTRACTIONS.
B. ABSTRACTIONS SHOULD NOT DEPEND UPON DETAILS. DETAILS SHOULD DEPEND UPON ABSTRACTIONS.

 

 정의: 상위 모듈은 하위 모듈의 구현 내용에 의존하면 안된다. 상위 모듈과 하위 모듈 모두 추상화된 내용에 의존해야 한다.

 


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)


class Gun(IWeapon):
    """총 클래스"""

    def __init__(self, damage, num_rounds) -> None:
        self.damage = damage
        self.num_rounds = num_rounds

    def use_on(self, other_character):
        if self.num_rounds > 0:
            other_character.get_damage(self.damage)
            self.num_rounds -= 1
        else:
            print("총알이 없어 공격할 수 없습니다.")


class GameCharacter:
    """게임 캐릭터 클래스"""

    def __init__(self, name, hp, weapon: IWeapon):
        self.name = name
        self.hp = hp
        self.weapon = weapon

    def attack(self, other_character):
        """다른 유저를 공격하는 메소드"""
        if self.hp > 0:
            self.weapon.use_on(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)


bad_sword = Sword(1)
gun = Gun(100, 10)

game_character_1 = GameCharacter("홍길동", 100, bad_sword)
game_character_2 = GameCharacter("아무개", 1000, gun)

game_character_1.attack(game_character_2)
game_character_1.attack(game_character_2)
game_character_1.attack(game_character_2)

game_character_2.attack(game_character_1)

print(game_character_1)
print(game_character_2)
홍길동님은 사망했습니다.
홍길동님은 hp: 0이(가) 남았습니다.
아무개님은 hp: 997이(가) 남았습니다.

 

 이렇게 GameCharacter클래스 안에서 Sword나 Gun클래스를 사용하는 경우 GameCharacter클래스를 상위 모듈, Sword, Gun을 하위 모듈이라고 한다. 이 때 GameCharacter클래스 안에서 Sword난 Gun 클래스를 직접적으로 의존하지 않고 IWeapon 인터페이스를 통해 한 개의 레이어를 더 만들어 관계를 느슨하게 만드는 것이 DIP이다. 이는 하위 모듈의 변경으로 인해 상위 모듈이 변경되는 경우가 없도록 하기 위해 사용된다.

반응형