Software Engineering

SOLID 원칙과 커피머신

aiden0729

 

 

앞서 OOP를 커피머신의 예를들어 설명하려고 했다.

 

https://aiden0729.tistory.com/30

 

OOP ( Object-Oriented Programming )와 커피머신

OOP ( Object-Oriented Programming ) 는 기본적으로 4가지 원칙을 제시한다. 1) 추상화2) 캡슐화3) 상속4) 다형성 아래는 OOP 원칙을 커피머신에 비교하여 설명하려고 노력하였다. 1. 추상화 ( Abstraction ) 커피

aiden0729.tistory.com

 

다만 OOP만으로는 대형 프로젝트의 구조와 병렬적 작업의 구성을 완벽히 컨트롤 할 수 없었다. 따라서 이 OOP를 더 구체화하고 독립성, 확장성 등을 강화한 것이 SOLID 원칙이다. 결국 이 OOP를 '어떻게 더 구체적으로 잘 실현'할 수 있을까에 대한 방법론이라고 생각한다.

 

앞서 예시와 같이 커피에 관련된 케이스로 이야기를 이어나가볼까 한다.

 

 

 

 

S: 단일 책임 원칙 (Single Responsibility Principle, SRP)

 

정의 : 하나의 클래스는 하나의 변경 이유만 가져야 한다.

 

 

정의상은 매우 명확한 개념이지만, 때로는 애매모호할 수 있는 원칙 중 하나라고 생각한다.

원칙적으로는 거의 모든 수준의 코드 Action을 분리하는 것이 맞다. method 수준도 아니고 class 수준으로 분리하는 것이 맞다.

 

다만 이렇게 될 경우 커피 한 잔 내리는데 수십가지의 class가 생성 될 것이고, 이는 최종 코드의 가독성이 많이 저해될 뿐만 아니라 실제 클래스에 대한 옵션 자체가 거의 없어진다. 다른 코드에서 모듈로 사용할 수 있지만 파악 자체가 다소 힘들다. 

# 너무 작고 의미 없는 클래스들

class WaterProvider:
    def provide(self):
        return "물"

class WaterHeater:
    def heat(self, water):
        print(f"{water}을(를) 90도로 가열합니다.")
        return "뜨거운 물"

class BeanProvider:
    def provide(self):
        return "원두"

class BeanGrinder:
    def grind(self, beans):
        print(f"{beans}를 분쇄합니다.")
        return "분쇄된 원두"

class Brewer:
    def brew(self, hot_water, ground_beans):
        print(f"{hot_water}과 {ground_beans}로 커피를 추출합니다.")
        return "커피"

class CupDispenser:
    def get_cup(self):
        print("컵을 제공합니다.")
        return "컵"

class CoffeeLogger:
    def log(self, msg):
        print(f"[LOG]: {msg}")

class CoffeeMachine:
    def __init__(self):
        self.water_provider = WaterProvider()
        self.water_heater = WaterHeater()
        self.bean_provider = BeanProvider()
        self.bean_grinder = BeanGrinder()
        self.brewer = Brewer()
        self.cup_dispenser = CupDispenser()
        self.logger = CoffeeLogger()

    def make_coffee(self):
        cup = self.cup_dispenser.get_cup()
        water = self.water_provider.provide()
        hot_water = self.water_heater.heat(water)
        beans = self.bean_provider.provide()
        ground_beans = self.bean_grinder.grind(beans)
        coffee = self.brewer.brew(hot_water, ground_beans)
        self.logger.log(f"{coffee}가 {cup}에 준비되었습니다.")

 

 

 

따라서 일반적으로는 아래와 같이 도메인/비즈니스상 하나의 절차 및 액션을 Class로 두고, 그 옵션들을 매서드로 둔다

class CoffeeMachine:
    def make_coffee(self):
        cup = self._prepare_cup()
        hot_water = self._heat_water()
        ground_beans = self._grind_beans()
        coffee = self._brew(hot_water, ground_beans)
        print(f"{coffee}가 {cup}에 준비되었습니다.")

    def _prepare_cup(self):
        return "컵"

    def _heat_water(self):
        print("물을 90도로 가열 중")
        return "뜨거운 물"

    def _grind_beans(self):
        print("원두를 분쇄 중")
        return "분쇄된 원두"

    def _brew(self, hot_water, ground_beans):
        print(f"{hot_water}와 {ground_beans}로 커피 추출")
        return "커피"

 

 

 

하지만 대규모 프로덕트는 위의 CoffeMachine 는 너무 단일화된 프로세스 및 Class로 느낄 수 있다. 따라서 Class로 나누고 Service에서 다양한 Class를 매서드처럼 합쳐서 서비스를 구조화한다.

해당 설계에는 Clean Architecture나 DDD( Domain- Driven Design ) 등으로 설계하므로 CoffeeMachine의 매서드보다 복잡한 형태가 된다. 다만 일반적으로 어느정도까지 프로덕트가 확장될지 가늠하는 것이 중요하며, 그에 따라 독립성이나 Layer 등을 정해야한다고 생각한다.

from abc import ABC, abstractmethod

# === 추상화 ( 인터페이스 정의 ) ===

class Grinder(ABC):
    """
    원두를 분쇄하는 기능을 위한 인터페이스.
    다양한 그라인더 종류(Electric, Burr 등)를 교체할 수 있도록 함.
    """
    @abstractmethod
    def grind(self, bean_type: str, level: str) -> str:
        pass


class Heater(ABC):
    """
    물을 원하는 온도로 데우는 기능을 위한 인터페이스.
    전기 히터, 가스 히터 등의 구현이 가능하도록 추상화.
    """
    @abstractmethod
    def heat(self, water: str, temperature: int) -> str:
        pass


class Brewer(ABC):
    """
    뜨거운 물과 분쇄된 원두를 이용해 커피를 추출하는 기능의 인터페이스.
    추출 방식(에스프레소, 드립 등)에 따라 다양한 구현이 가능.
    """
    @abstractmethod
    def brew(self, water: str, beans: str) -> str:
        pass


# === 구체 클래스 구현 ===

class DefaultGrinder(Grinder):
    """일반적인 전기 그라인더 구현체."""
    def grind(self, bean_type: str, level: str) -> str:
        print(f"{bean_type} 원두를 {level}로 분쇄 중")
        return f"{level} 분쇄된 {bean_type} 원두"


class ElectricHeater(Heater):
    """전기 방식으로 물을 가열하는 구현체."""
    def heat(self, water: str, temperature: int) -> str:
        print(f"{water}를 {temperature}도로 가열 중")
        return f"{temperature}도 {water}"


class EspressoBrewer(Brewer):
    """고압으로 커피를 추출하는 에스프레소 방식의 브루어."""
    def brew(self, water: str, beans: str) -> str:
        print(f"{water}와 {beans}로 에스프레소 추출")
        return "에스프레소"


# === 도메인 서비스 ===

class CoffeeMachine:
    """
    커피를 만드는 도메인 서비스.
    외부 구성 요소(그라인더, 히터, 브루어)를 주입받아 책임 분리 및 테스트 가능성 확보.
    """
    def __init__(self, grinder: Grinder, heater: Heater, brewer: Brewer):
        """
        의존성 주입을 통해 구체 구현체를 유연하게 교체 가능하도록 구성.

        Parameters:
            grinder (Grinder): 원두 분쇄기 구현체
            heater (Heater): 물 가열기 구현체
            brewer (Brewer): 커피 추출기 구현체
        """
        self.grinder = grinder
        self.heater = heater
        self.brewer = brewer

    def make_coffee(self, bean_type: str = "콜롬비아", level: str = "fine", temp: int = 90):
        """
        커피 한 잔을 만드는 절차 실행.
        - 컵을 준비하고
        - 물을 가열하고
        - 원두를 분쇄하고
        - 추출한다.

        Parameters:
            bean_type (str): 사용할 원두 종류
            level (str): 분쇄 정도 (e.g., 'fine', 'coarse')
            temp (int): 원하는 물 온도
        """
        print("컵 준비 중...")
        water = "정수된 물"
        ground_beans = self.grinder.grind(bean_type, level)
        hot_water = self.heater.heat(water, temp)
        coffee = self.brewer.brew(hot_water, ground_beans)
        print(f"{coffee}가 준비되었습니다.\n")


# === 실행 예시 ===

if __name__ == "__main__":
    grinder = DefaultGrinder()
    heater = ElectricHeater()
    brewer = EspressoBrewer()

    machine = CoffeeMachine(grinder, heater, brewer)
    machine.make_coffee()

 

 

 

 

 

 

O: 개방-폐쇄 원칙 (Open/Closed Principle, OCP)

 

정의 : 소프트웨어 요소는 확장에는 열려 있고, 수정에는 닫혀 있어야 한다.

 

기본적으로 매서드에 if-else 등이 하드 코딩 되면 OCP를 어길 확률이 많이 높아진다. 아래의 예시에도 커피 메뉴가 추가되면 기존의 코드를 필수적으로 '수정' 해야하기 때문이다. 다만 if-else는 아주 기본적인 코딩 방식인데 해당 방식을 피하기 위해 Registry 등의 추가적인 Class 구현이 필요하다. 분기가 많아질 코드에는 필수적이며, 분기가 고정되어있는 코드에는 레이어가 하나 더 생기므로 직관성이 다소 떨어질 수 있다. 

class CoffeeMachine:
    def make_coffee(self, type: str):
        if type == "espresso":
            print("에스프레소 추출")
        elif type == "drip":
            print("드립 커피 추출")
        elif type == "coldbrew":
            print("콜드브루 추출")
        else:
            raise ValueError("알 수 없는 커피 타입")

 

 

 

 

OCP를 준수하기 위해서는 Registry를 클래스를 추가적으로 활용하여 Coffee가 추가되면 Registry가 동적으로 추가되며, 최종적으로 '#커피종류등록' 이나 '#테스트' 정도만 신경쓰는 형태가 된다. 필요에 따라 최종 메뉴만 바꾸면 되는 구조가 완성되는 것이다. 

from abc import ABC, abstractmethod

# 추상 커피 클래스
class Coffee(ABC):
    @abstractmethod
    def brew(self):
        pass

# 커피 종류별 구현
class Espresso(Coffee):
    def brew(self):
        print("에스프레소 추출")

class DripCoffee(Coffee):
    def brew(self):
        print("드립 커피 추출")

class ColdBrew(Coffee):
    def brew(self):
        print("콜드브루 추출")

# Registry 클래스
class CoffeeRegistry:
    _registry = {}

    @classmethod
    def register(cls, coffee_type: str, coffee_cls):
        cls._registry[coffee_type] = coffee_cls

    @classmethod
    def get(cls, coffee_type: str) -> Coffee:
        if coffee_type not in cls._registry:
            raise ValueError(f"알 수 없는 커피 타입: {coffee_type}")
        return cls._registry[coffee_type]()

# CoffeeMachine 클래스
class CoffeeMachine:
    def make_coffee(self, coffee_type: str):
        coffee = CoffeeRegistry.get(coffee_type)
        coffee.brew()

# 커피 종류 등록
CoffeeRegistry.register("espresso", Espresso)
CoffeeRegistry.register("drip", DripCoffee)
CoffeeRegistry.register("coldbrew", ColdBrew)

# 테스트
if __name__ == "__main__":
    machine = CoffeeMachine()
    machine.make_coffee("espresso")
    machine.make_coffee("drip")
    machine.make_coffee("coldbrew")

 

 

 

 

 

L : 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)

 

정의 : 서브 타입은 언제나 자신의 기반 타입(부모 클래스)으로 교체할 수 있어야 한다

 

이는 @abstractmethod의 엄격함을 나타내는 원칙이다. 해당 @abstractmethod의 형식으로 종속 클래스가 관리되어야 일정한 형태와 품질의 결과물이 나올 수 있기 때문이다. 

 

좋지 않은 케이스는 아래와 같다. @abstractmethod 가 들어가는 필수적인 메서드에서 다르게 동작하는 등의 케이스가 가장 대표적이다. 

from abc import ABC, abstractmethod

class Coffee(ABC):
    @abstractmethod
    def brew(self):
        pass

class Espresso(Coffee):
    def brew(self):
        print("에스프레소 추출")

class BrokenCoffee(Coffee):
    def brew(self):
        # 부모의 기대를 깨뜨림: brew가 동작하지 않음
        raise NotImplementedError("이 커피는 추출을 지원하지 않음")

class CoffeeMachine:
    def make_coffee(self, coffee: Coffee):
        coffee.brew()

if __name__ == "__main__":
    machine = CoffeeMachine()

    espresso = Espresso()
    machine.make_coffee(espresso)  # 정상

    broken = BrokenCoffee()
    machine.make_coffee(broken)    # 런타임 예외 발생 → LSP 위반

 

 

 

 

아래는 부모 클래스가 정한 필수원칙을 정상적으로 지켜 서로 필수적인 기능은 동일한 형태이다.

from abc import ABC, abstractmethod

class Coffee(ABC):
    @abstractmethod
    def brew(self):
        pass

class Espresso(Coffee):
    def brew(self):
        print("에스프레소 추출")

class ColdBrew(Coffee):
    def brew(self):
        print("콜드브루 추출 - 저온 추출 방식")

class CoffeeMachine:
    def make_coffee(self, coffee: Coffee):
        coffee.brew()

if __name__ == "__main__":
    machine = CoffeeMachine()

    espresso = Espresso()
    coldbrew = ColdBrew()

    machine.make_coffee(espresso)   # 에스프레소 추출
    machine.make_coffee(coldbrew)   # 콜드브루 추출

 

 

 

 

 

 

I : 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)

 

정의 : 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다.

 

이는 @abstractmethod의 범위를 정하는 원칙 중 하나라고 할 수 있다. 사용하지 않는 인터페이스(메서드)를 강제하면 코드가 너무 복잡해지고, 무거워지며 필요없는 기능이 실행되기 때문에 정말 필수적으로 진행되어야하는 메서드를 제외한건 다소 자유롭게 종속 클래스에 위임해야한다는 것이다. 

 

좋지 않은 예시 코드, steam_milk 등이 모든 커피 종류에 강제될 필요가 없다. 

from abc import ABC, abstractmethod

class AdvancedCoffeeMachine(ABC):
    @abstractmethod
    def brew_espresso(self): pass

    @abstractmethod
    def brew_coldbrew(self): pass

    @abstractmethod
    def steam_milk(self): pass

    @abstractmethod
    def clean_machine(self): pass

 

 

 

좋은 예시는 대규모 프로젝트라면 Class를 아예 나누던지, method의 강제성을 없애는 방식 등이 있다. 

class EspressoBrewable(ABC):
    @abstractmethod
    def brew_espresso(self): pass

class ColdBrewable(ABC):
    @abstractmethod
    def brew_coldbrew(self): pass

class MilkSteamer(ABC):
    @abstractmethod
    def steam_milk(self): pass

 

class SimpleEspressoMachine(EspressoBrewable):
    def brew_espresso(self):
        print("에스프레소 추출")

class FullMachine(EspressoBrewable, ColdBrewable, MilkSteamer):
    def brew_espresso(self):
        print("에스프레소 추출")

    def brew_coldbrew(self):
        print("콜드브루 추출")

    def steam_milk(self):
        print("우유 스팀")

 

 

 

 

 

D : 의존성 역전 원칙 (Dependency Inversion Principle, DIP)

 

정의 : 상위 모듈은 하위 모듈에 의존하면 안 된다. 둘 다 추상화(인터페이스)에 의존해야 한다.

 

즉 임의의 A-Class가 B-Class를 사용할 때 하위(종속) 클래스를 사용하면 안된다는 원칙이다. 종속 클래스에 의존하는 순간 해당 종속 클래스의 수가 변화 및 증가함에 따라 사용자인 A-Class의 모듈도 같이 실패하거나, 기능 추가 등이 어려울 수 있기 때문이다. 

 

좋지 않은 예시는 아래와 같다. 추상 클래스가 아닌 구체적인 클래스에 의존하고 있다.

class EspressoMachine:
    def extract(self):
        print("에스프레소 추출")

class Cafe:
    def __init__(self):
        self.machine = EspressoMachine()  # 구체 클래스에 직접 의존

    def serve(self):
        self.machine.extract()

 

 

아래와 같이 추상화 클래스를 만들고, Cafe Class는 해당 추상클래스에 의존하고 있다. 해당 방식은 추후 Mock 등의 테스트 시에도 더 유연하게 테스트 할 수 있다. 

from abc import ABC, abstractmethod

class CoffeeMachine(ABC):  # 추상화
    @abstractmethod
    def extract(self):
        pass

class EspressoMachine(CoffeeMachine):
    def extract(self):
        print("에스프레소 추출")

class ColdBrewMachine(CoffeeMachine):
    def extract(self):
        print("콜드브루 추출")

class Cafe:
    def __init__(self, machine: CoffeeMachine):  # 추상화에 의존
        self.machine = machine

    def serve(self):
        self.machine.extract()