본문으로 건너뛰기
Advertisement

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 원칙의 궁극적 목표는 변경 비용을 낮추는 것입니다. 새 요구사항이 생겼을 때 최소한의 코드 변경으로 대응할 수 있는 설계를 만들어 줍니다.

Advertisement