앞서 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()
'Software Engineering' 카테고리의 다른 글
| 소프트웨어 아키텍처 ( Software Architecture ) 와 커피 (0) | 2025.06.04 |
|---|---|
| OOP ( Object-Oriented Programming )와 커피머신 (0) | 2025.05.27 |
| Databricks는 어떤게 좋은가 ? - 60조 시장가치의 비상장 기업 (0) | 2025.05.07 |
| Github vs GitLab ? 뭐가 다르지 (0) | 2025.05.06 |
| Docker와 Kubernetes(k8s) ? - 오케스트레이션 (0) | 2025.05.06 |