SOLID 원칙 파이썬 적용
SOLID는 객체지향 설계의 5가지 핵심 원칙으로, 유지보수하기 쉽고 확장 가능한 코드를 만들기 위한 지침입니다. 각 글자는 원칙의 첫 글자를 나타냅니다.
- S: Single Responsibility Principle (단일 책임 원칙)
- O: Open/Closed Principle (개방-폐쇄 원칙)
- L: Liskov Substitution Principle (리스코프 치환 원칙)
- I: Interface Segregation Principle (인터페이스 분리 원칙)
- D: Dependency Inversion Principle (의존 역전 원칙)
S — 단일 책임 원칙 (Single Responsibility Principle)
"클래스는 변경해야 하는 이유가 오직 하나뿐이어야 한다."
클래스가 너무 많은 역할을 담당하면, 한 가지 이유로 변경할 때 다른 기능도 깨질 위험이 있습니다.
위반 예시
# SRP 위반: 사용자 데이터 관리 + 이메일 전송 + 데이터베이스 저장 + 리포트 생성
class User:
def __init__(self, name: str, email: str):
self.name = name
self.email = email
def send_welcome_email(self):
"""이메일 전송 — User 클래스의 책임이 아님"""
print(f"{self.email}으로 환영 이메일을 전송합니다.")
# SMTP 설정, 템플릿 렌더링 등 모든 것이 여기에...
def save_to_db(self, connection):
"""DB 저장 — User 클래스의 책임이 아님"""
connection.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
(self.name, self.email)
)
def generate_report(self) -> str:
"""리포트 생성 — User 클래스의 책임이 아님"""
return f"사용자 리포트: {self.name} ({self.email})"
개선 코드
# SRP 준수: 각 클래스가 하나의 책임만 가짐
class User:
"""사용자 데이터만 담당"""
def __init__(self, name: str, email: str):
self.name = name
self.email = email
def __repr__(self) -> str:
return f"User(name={self.name!r}, email={self.email!r})"
class EmailService:
"""이메일 전송만 담당"""
def __init__(self, smtp_host: str, smtp_port: int = 587):
self.smtp_host = smtp_host
self.smtp_port = smtp_port
def send_welcome(self, user: User) -> None:
print(f"[{self.smtp_host}:{self.smtp_port}] {user.email}으로 환영 이메일 전송")
def send_notification(self, user: User, message: str) -> None:
print(f"[알림] {user.email} → {message}")
class UserRepository:
"""사용자 데이터 영속성만 담당"""
def __init__(self):
self._store: dict[str, User] = {}
def save(self, user: User) -> None:
self._store[user.email] = user
print(f"사용자 저장 완료: {user.name}")
def find_by_email(self, email: str) -> User | None:
return self._store.get(email)
def find_all(self) -> list[User]:
return list(self._store.values())
class UserReportGenerator:
"""리포트 생성만 담당"""
def generate(self, user: User) -> str:
return f"사용자 리포트: {user.name} ({user.email})"
def generate_summary(self, users: list[User]) -> str:
lines = [f"=== 사용자 현황 ({len(users)}명) ==="]
for u in users:
lines.append(f" - {u.name}: {u.email}")
return "\n".join(lines)
# 사용 예시
user = User("김철수", "kim@example.com")
email_svc = EmailService("smtp.example.com")
repo = UserRepository()
reporter = UserReportGenerator()
repo.save(user)
email_svc.send_welcome(user)
print(reporter.generate(user))
O — 개방-폐쇄 원칙 (Open/Closed Principle)
"소프트웨어 개체(클래스, 모듈, 함수)는 확장에는 열려 있어야 하고 수정에는 닫혀 있어야 한다."
새 기능을 추가할 때 기존 코드를 수정하지 않고 새로운 클래스를 추가하는 방식으로 확장합니다.
위반 예시
# OCP 위반: 새 할인 방식 추가 시마다 이 함수를 수정해야 함
def calculate_discount(price: float, discount_type: str) -> float:
if discount_type == "percentage":
return price * 0.9
elif discount_type == "fixed":
return price - 1000
elif discount_type == "vip":
return price * 0.7
# 새 할인 타입이 생기면 elif를 계속 추가해야 함 → OCP 위반!
return price
개선 코드
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
"""할인 전략 추상 기반 클래스 — 확장 지점"""
@abstractmethod
def apply(self, price: float) -> float:
"""할인 적용 후 가격 반환"""
...
@abstractmethod
def describe(self) -> str:
"""할인 설명"""
...
class NoDiscount(DiscountStrategy):
def apply(self, price: float) -> float:
return price
def describe(self) -> str:
return "할인 없음"
class PercentageDiscount(DiscountStrategy):
def __init__(self, percent: float):
if not (0 < percent <= 100):
raise ValueError(f"퍼센트는 0~100 사이여야 합니다: {percent}")
self.percent = percent
def apply(self, price: float) -> float:
return price * (1 - self.percent / 100)
def describe(self) -> str:
return f"{self.percent:.0f}% 할인"
class FixedDiscount(DiscountStrategy):
def __init__(self, amount: float):
self.amount = amount
def apply(self, price: float) -> float:
return max(0.0, price - self.amount)
def describe(self) -> str:
return f"{self.amount:,.0f}원 할인"
class VIPDiscount(DiscountStrategy):
"""VIP 고객 전용 — 기존 코드 수정 없이 새 전략 추가"""
def apply(self, price: float) -> float:
return price * 0.7
def describe(self) -> str:
return "VIP 30% 할인"
class SeasonalDiscount(DiscountStrategy):
"""시즌 할인 — 기존 코드 수정 없이 추가 가능"""
def __init__(self, percent: float, season: str):
self.percent = percent
self.season = season
def apply(self, price: float) -> float:
return price * (1 - self.percent / 100)
def describe(self) -> str:
return f"{self.season} 시즌 {self.percent:.0f}% 특가"
class PriceCalculator:
"""OCP 준수: 새 할인 전략 추가 시 이 클래스를 수정할 필요 없음"""
def __init__(self, strategy: DiscountStrategy):
self.strategy = strategy
def calculate(self, price: float) -> float:
return self.strategy.apply(price)
def receipt(self, price: float) -> str:
discounted = self.calculate(price)
saved = price - discounted
return (f"원가: {price:,.0f}원 | {self.strategy.describe()} | "
f"최종: {discounted:,.0f}원 (절약: {saved:,.0f}원)")
# 다양한 할인 전략 사용
original_price = 50_000
strategies = [
NoDiscount(),
PercentageDiscount(10),
FixedDiscount(5_000),
VIPDiscount(),
SeasonalDiscount(25, "여름"),
]
for strategy in strategies:
calc = PriceCalculator(strategy)
print(calc.receipt(original_price))
L — 리스코프 치환 원칙 (Liskov Substitution Principle)
"서브타입은 언제나 기반 타입으로 교체할 수 있어야 한다."
자식 클래스는 부모 클래스가 사용되는 모든 곳에서 문제없이 대체될 수 있어야 합니다. 즉, 자식 클래스가 부모의 계약(사전 조건, 사후 조건)을 위반해서는 안 됩니다.
위반 예시
class Rectangle:
def __init__(self, width: float, height: float):
self._width = width
self._height = height
@property
def width(self) -> float:
return self._width
@width.setter
def width(self, value: float) -> None:
self._width = value
@property
def height(self) -> float:
return self._height
@height.setter
def height(self, value: float) -> None:
self._height = value
def area(self) -> float:
return self._width * self._height
class Square(Rectangle):
"""LSP 위반: Square는 Rectangle을 대체할 수 없음"""
@Rectangle.width.setter
def width(self, value: float) -> None:
self._width = value
self._height = value # 정사각형이므로 높이도 같이 변경!
@Rectangle.height.setter
def height(self, value: float) -> None:
self._width = value # 정사각형이므로 너비도 같이 변경!
self._height = value
def resize_rectangle(rect: Rectangle, width: float, height: float) -> None:
"""Rectangle이 있어야 할 곳에 Square를 넣으면 동작이 달라짐"""
rect.width = width
rect.height = height
expected_area = width * height
actual_area = rect.area()
print(f"예상 넓이: {expected_area}, 실제 넓이: {actual_area}")
assert actual_area == expected_area, "LSP 위반!"
rect = Rectangle(4, 5)
resize_rectangle(rect, 6, 3) # OK: 예상 넓이 18, 실제 넓이 18
square = Square(4, 4)
# resize_rectangle(square, 6, 3) # LSP 위반: 실제 넓이 9 (3×3), 예상 넓이 18
개선 코드
from abc import ABC, abstractmethod
import math
class Shape(ABC):
"""LSP 준수를 위한 올바른 계층 구조"""
@abstractmethod
def area(self) -> float:
...
@abstractmethod
def perimeter(self) -> float:
...
def describe(self) -> str:
return (f"{self.__class__.__name__}: "
f"넓이={self.area():.2f}, 둘레={self.perimeter():.2f}")
class Rectangle(Shape):
def __init__(self, width: float, height: float):
if width <= 0 or height <= 0:
raise ValueError("너비와 높이는 양수여야 합니다.")
self._width = width
self._height = height
@property
def width(self) -> float:
return self._width
@property
def height(self) -> float:
return self._height
def area(self) -> float:
return self._width * self._height
def perimeter(self) -> float:
return 2 * (self._width + self._height)
def with_size(self, width: float, height: float) -> "Rectangle":
"""크기를 변경한 새 Rectangle 반환 (불변 설계)"""
return Rectangle(width, height)
class Square(Shape):
"""Square는 Rectangle을 상속하지 않고 Shape를 직접 상속"""
def __init__(self, side: float):
if side <= 0:
raise ValueError("변의 길이는 양수여야 합니다.")
self._side = side
@property
def side(self) -> float:
return self._side
def area(self) -> float:
return self._side ** 2
def perimeter(self) -> float:
return 4 * self._side
def with_side(self, side: float) -> "Square":
return Square(side)
class Circle(Shape):
def __init__(self, radius: float):
if radius <= 0:
raise ValueError("반지름은 양수여야 합니다.")
self._radius = radius
def area(self) -> float:
return math.pi * self._radius ** 2
def perimeter(self) -> float:
return 2 * math.pi * self._radius
def print_shape_info(shape: Shape) -> None:
"""Shape의 모든 서브클래스를 안전하게 대체 가능 — LSP 준수"""
print(shape.describe())
shapes: list[Shape] = [
Rectangle(6, 4),
Square(5),
Circle(3),
]
for shape in shapes:
print_shape_info(shape) # 모든 Shape 서브클래스가 문제없이 대체됨
I — 인터페이스 분리 원칙 (Interface Segregation Principle)
"클라이언트가 자신이 사용하지 않는 메서드에 의존하도록 강요해서는 안 된다."
하나의 거대한 인터페이스보다 여러 개의 작은 인터페이스가 낫습니다.
위반 예시
from abc import ABC, abstractmethod
# ISP 위반: 너무 많은 것을 요구하는 인터페이스
class WorkerInterface(ABC):
@abstractmethod
def work(self): ...
@abstractmethod
def eat(self): ...
@abstractmethod
def sleep(self): ...
@abstractmethod
def code(self): ...
@abstractmethod
def manage_team(self): ...
@abstractmethod
def write_report(self): ...
class Robot(WorkerInterface):
def work(self):
return "로봇이 일합니다."
def eat(self):
# 로봇은 먹지 않지만 구현 강제됨 — ISP 위반!
raise NotImplementedError("로봇은 먹지 않습니다.")
def sleep(self):
# 로봇은 자지 않지만 구현 강제됨 — ISP 위반!
raise NotImplementedError("로봇은 자지 않습니다.")
def code(self): ...
def manage_team(self): ...
def write_report(self): ...
개선 코드
from abc import ABC, abstractmethod
from typing import Protocol
# ISP 준수: 작은 단위로 분리된 인터페이스 (Protocol 사용)
class Workable(Protocol):
def work(self) -> str: ...
class Eatable(Protocol):
def eat(self) -> str: ...
class Sleepable(Protocol):
def sleep(self) -> str: ...
class Codeable(Protocol):
def code(self, language: str) -> str: ...
class Manageable(Protocol):
def manage_team(self, team_size: int) -> str: ...
class Developer:
"""필요한 인터페이스만 구현"""
def __init__(self, name: str):
self.name = name
def work(self) -> str:
return f"{self.name}이(가) 개발 중..."
def eat(self) -> str:
return f"{self.name}이(가) 식사 중..."
def sleep(self) -> str:
return f"{self.name}이(가) 수면 중..."
def code(self, language: str) -> str:
return f"{self.name}이(가) {language}로 코딩 중..."
class Manager:
"""매니저는 팀 관리 관련 인터페이스만 구현"""
def __init__(self, name: str):
self.name = name
def work(self) -> str:
return f"{self.name}이(가) 업무 조율 중..."
def eat(self) -> str:
return f"{self.name}이(가) 점심 미팅 중..."
def sleep(self) -> str:
return f"{self.name}이(가) 수면 중..."
def manage_team(self, team_size: int) -> str:
return f"{self.name}이(가) {team_size}명 팀을 관리 중..."
class Robot:
"""로봇은 필요한 것만 구현 — 먹기/자기 강제 없음"""
def __init__(self, model: str):
self.model = model
def work(self) -> str:
return f"{self.model} 작동 중..."
def code(self, language: str) -> str:
return f"{self.model}이(가) {language} 코드 자동 생성 중..."
def assign_work(worker: Workable) -> None:
"""Workable 인터페이스만 요구"""
print(worker.work())
def assign_coding_task(coder: Codeable, language: str) -> None:
"""Codeable 인터페이스만 요구"""
print(coder.code(language))
dev = Developer("Alice")
manager = Manager("Bob")
robot = Robot("GPT-Bot")
for worker in [dev, manager, robot]:
assign_work(worker) # 세 객체 모두 work() 메서드가 있으므로 OK
assign_coding_task(dev, "Python")
assign_coding_task(robot, "TypeScript")
# assign_coding_task(manager, "Java") # Manager에 code()가 없으므로 타입 오류
D — 의존 역전 원칙 (Dependency Inversion Principle)
"고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다."
구현 세부 사항이 아닌 추상화(인터페이스)에 의존하면 코드가 더 유연해지고 테스트하기 쉬워집니다.
위반 예시
# DIP 위반: 고수준 모듈(OrderService)이 저수준 모듈(MySQLDatabase)에 직접 의존
class MySQLDatabase:
def save(self, data: dict) -> None:
print(f"MySQL에 저장: {data}")
class EmailNotifier:
def notify(self, message: str) -> None:
print(f"이메일 전송: {message}")
class OrderService:
def __init__(self):
# 직접 구체 클래스를 생성 — DIP 위반!
self.db = MySQLDatabase()
self.notifier = EmailNotifier()
def place_order(self, order: dict) -> None:
self.db.save(order)
self.notifier.notify(f"주문 완료: {order}")
# MySQLDatabase나 EmailNotifier를 교체하려면 이 클래스를 수정해야 함
개선 코드
from abc import ABC, abstractmethod
from typing import Any
# 추상화 계층 — 인터페이스 정의
class Database(ABC):
@abstractmethod
def save(self, data: dict[str, Any]) -> None: ...
@abstractmethod
def find(self, query: dict[str, Any]) -> list[dict[str, Any]]: ...
class Notifier(ABC):
@abstractmethod
def send(self, recipient: str, message: str) -> None: ...
# 저수준 구현체들
class MySQLDatabase(Database):
def __init__(self, host: str, port: int = 3306):
self.host = host
self.port = port
self._data: list[dict] = []
def save(self, data: dict[str, Any]) -> None:
self._data.append(data)
print(f"[MySQL {self.host}:{self.port}] 저장: {data}")
def find(self, query: dict[str, Any]) -> list[dict[str, Any]]:
return [d for d in self._data
if all(d.get(k) == v for k, v in query.items())]
class MongoDatabase(Database):
"""MySQL을 MongoDB로 교체 — OrderService 수정 없음"""
def __init__(self, uri: str):
self.uri = uri
self._collections: dict[str, list] = {}
def save(self, data: dict[str, Any]) -> None:
collection = data.get("_collection", "default")
self._collections.setdefault(collection, []).append(data)
print(f"[MongoDB {self.uri}] {collection}에 저장: {data}")
def find(self, query: dict[str, Any]) -> list[dict[str, Any]]:
results = []
for docs in self._collections.values():
results.extend(d for d in docs
if all(d.get(k) == v for k, v in query.items()))
return results
class InMemoryDatabase(Database):
"""테스트용 인메모리 DB"""
def __init__(self):
self._store: list[dict] = []
def save(self, data: dict[str, Any]) -> None:
self._store.append(data.copy())
def find(self, query: dict[str, Any]) -> list[dict[str, Any]]:
return [d for d in self._store
if all(d.get(k) == v for k, v in query.items())]
class EmailNotifier(Notifier):
def send(self, recipient: str, message: str) -> None:
print(f"[이메일 → {recipient}] {message}")
class SMSNotifier(Notifier):
def send(self, recipient: str, message: str) -> None:
print(f"[SMS → {recipient}] {message}")
class SlackNotifier(Notifier):
def __init__(self, channel: str):
self.channel = channel
def send(self, recipient: str, message: str) -> None:
print(f"[Slack #{self.channel}] @{recipient}: {message}")
# 고수준 모듈 — 추상화에만 의존
class OrderService:
"""DIP 준수: 추상화(Database, Notifier)에만 의존"""
def __init__(self, db: Database, notifier: Notifier):
self._db = db # 의존성 주입 (Dependency Injection)
self._notifier = notifier
def place_order(self, customer: str, items: list[str], total: float) -> str:
order = {
"customer": customer,
"items": items,
"total": total,
"status": "confirmed",
}
self._db.save(order)
self._notifier.send(customer, f"주문 완료! 총 {total:,.0f}원")
return f"주문 접수: {customer} — {', '.join(items)}"
def get_customer_orders(self, customer: str) -> list[dict]:
return self._db.find({"customer": customer})
# 프로덕션 환경
prod_service = OrderService(
db=MySQLDatabase("db.example.com"),
notifier=EmailNotifier(),
)
prod_service.place_order("김철수", ["Python 책", "마우스"], 55_000)
# 테스트 환경 — 실제 DB/이메일 없이
test_service = OrderService(
db=InMemoryDatabase(),
notifier=SlackNotifier("alerts"),
)
test_service.place_order("테스트유저", ["상품A"], 10_000)
# DB는 MySQL, 알림은 SMS로 — 교체 시 OrderService 수정 없음
mixed_service = OrderService(
db=MySQLDatabase("mysql.local"),
notifier=SMSNotifier(),
)
mixed_service.place_order("이영희", ["의자"], 120_000)
실전 예제: SOLID 원칙 종합 적용
from abc import ABC, abstractmethod
from typing import Protocol
import json
# --- 인터페이스 (I: 작게 분리) ---
class Storable(Protocol):
def save(self, key: str, data: str) -> None: ...
def load(self, key: str) -> str | None: ...
class Serializable(Protocol):
def serialize(self, obj: dict) -> str: ...
def deserialize(self, data: str) -> dict: ...
class Validatable(Protocol):
def validate(self, data: dict) -> list[str]: ...
# --- 저수준 구현 (D: 추상화에 의존) ---
class FileStorage:
"""파일 저장소 구현"""
def __init__(self, base_dir: str = "/tmp"):
self.base_dir = base_dir
self._store: dict[str, str] = {} # 시뮬레이션
def save(self, key: str, data: str) -> None:
self._store[key] = data
print(f"[파일] {self.base_dir}/{key} 저장")
def load(self, key: str) -> str | None:
return self._store.get(key)
class JSONSerializer:
"""JSON 직렬화"""
def serialize(self, obj: dict) -> str:
return json.dumps(obj, ensure_ascii=False)
def deserialize(self, data: str) -> dict:
return json.loads(data)
class ProductValidator:
"""상품 유효성 검사 (S: 유효성 검사만 담당)"""
def validate(self, data: dict) -> list[str]:
errors = []
if not data.get("name"):
errors.append("상품명이 필요합니다.")
if not isinstance(data.get("price"), (int, float)) or data["price"] <= 0:
errors.append("가격은 양수여야 합니다.")
if not isinstance(data.get("stock"), int) or data["stock"] < 0:
errors.append("재고는 0 이상의 정수여야 합니다.")
return errors
# --- 고수준 모듈 (O: 확장에 열림, D: 추상화에 의존) ---
class ProductService:
"""SOLID 원칙을 준수하는 상품 서비스"""
def __init__(
self,
storage: Storable,
serializer: Serializable,
validator: Validatable,
):
self._storage = storage
self._serializer = serializer
self._validator = validator
def create_product(self, product_data: dict) -> dict:
# 유효성 검사
errors = self._validator.validate(product_data)
if errors:
raise ValueError(f"유효성 검사 실패: {errors}")
# 저장
key = f"product:{product_data['name']}"
serialized = self._serializer.serialize(product_data)
self._storage.save(key, serialized)
print(f"상품 등록: {product_data['name']}")
return product_data
def get_product(self, name: str) -> dict | None:
key = f"product:{name}"
data = self._storage.load(key)
if data is None:
return None
return self._serializer.deserialize(data)
# 조립 (Dependency Injection)
service = ProductService(
storage=FileStorage("/data/products"),
serializer=JSONSerializer(),
validator=ProductValidator(),
)
try:
service.create_product({"name": "Python 책", "price": 35000, "stock": 100})
service.create_product({"name": "키보드", "price": 150000, "stock": 50})
except ValueError as e:
print(f"오류: {e}")
product = service.get_product("Python 책")
print(f"조회 결과: {product}")
# 잘못된 데이터 테스트
try:
service.create_product({"name": "", "price": -100, "stock": -1})
except ValueError as e:
print(f"예상된 오류: {e}")
고수 팁
1. SOLID는 규칙이 아닌 지침
# 항상 모든 원칙을 엄격히 따를 필요는 없음
# 작은 스크립트나 단순한 코드에서는 과도한 추상화가 오히려 해롭다
# 나쁜 예: 간단한 설정 읽기에 불필요한 추상화
class ConfigLoader(ABC):
@abstractmethod
def load(self) -> dict: ...
class FileConfigLoader(ConfigLoader):
def load(self) -> dict:
return {"host": "localhost"}
# 좋은 예: 단순한 경우 그냥 함수로 충분
def load_config() -> dict:
return {"host": "localhost"}
2. DIP를 위한 의존성 주입 컨테이너 패턴
class Container:
"""간단한 의존성 주입 컨테이너"""
def __init__(self):
self._bindings: dict = {}
def bind(self, interface, implementation) -> None:
self._bindings[interface] = implementation
def make(self, interface):
if interface not in self._bindings:
raise KeyError(f"등록되지 않은 인터페이스: {interface}")
impl = self._bindings[interface]
return impl() if callable(impl) else impl
# 등록
container = Container()
container.bind("db", InMemoryDatabase)
container.bind("notifier", lambda: SlackNotifier("general"))
# 사용
db = container.make("db")
notifier = container.make("notifier")
3. Protocol로 구조적 서브타이핑
from typing import Protocol, runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> str: ...
def resize(self, factor: float) -> None: ...
class Circle:
def __init__(self, r: float):
self.r = r
def draw(self) -> str:
return f"Circle(r={self.r})"
def resize(self, factor: float) -> None:
self.r *= factor
# 명시적 상속 없이도 Protocol 준수 확인 가능
c = Circle(5)
print(isinstance(c, Drawable)) # True (runtime_checkable)
정리
| 원칙 | 핵심 키워드 | 적용 효과 |
|---|---|---|
| SRP | 단일 책임 | 코드 변경 영향 범위 최소화 |
| OCP | 추상화·확장 | 기존 코드 수정 없이 기능 추가 |
| LSP | 올바른 상속 | 다형성 안전 보장 |
| ISP | 작은 인터페이스 | 불필요한 의존성 제거 |
| DIP | 의존성 주입 | 테스트 용이, 유연한 교체 |
SOLID 원칙의 궁극적 목표는 변경 비용을 낮추는 것입니다. 새 요구사항이 생겼을 때 최소한의 코드 변경으로 대응할 수 있는 설계를 만들어 줍니다.