Post

SOLID 원칙, 실전에서 어떻게 적용할까?

SOLID 원칙을 Python/Django 백엔드 프로젝트에 실제로 녹여내는 방법과 체크리스트, 폴더링, 테스트 전략까지 한 번에 정리

SOLID 원칙, 실전에서 어떻게 적용할까?

“SOLID를 지킨 설계”는 멋진 슬로건이 아니라, 요구사항을 유스케이스 단위로 쪼개고(단일 책임), 변화 가능 지점을 확장 포인트로 고정해 두며(개방/폐쇄), 교체 가능한 계약을 중심으로(리스코프, 인터페이스 분리) 의존성을 역전시키는 일상적 습관이다. 아래는 백엔드(특히 Python/Django) 관점에서 프로젝트 전개 흐름과 실전 체크리스트다.

1) 설계 진행 순서(프로세스)

  1. 유스케이스 정의 “누가 무엇을 왜 한다”를 한 문장으로 쓰고 입출력 명세를 표로 만든다. 예) “사용자가 쿠폰을 발급받는다: 입력=배치ID, 사용자ID / 출력=쿠폰코드”
  2. 도메인 모델 스케치 핵심 개념(엔티티·값객체·정책)을 종이에 그리고, 상태·불변조건을 적는다. 변경 가능성이 높은 규칙(할인 정책, 발급 정책)은 전략 객체로 분리한다.
  3. 포트/어댑터(경계) 세우기 DB, 메시지큐, 외부 API는 **포트(Protocol 인터페이스)**로 먼저 정의한다. 애플리케이션 서비스는 포트(계약)만 의존하고, 구현은 어댑터가 맡는다.
  4. TDD로 유스케이스 단위 구현 서비스 레벨의 성공/경계/에러 테스트 → 도메인 규칙 테스트 → 어댑터 계약 테스트(스텁/페이크). 테스트 이름이 곧 유스케이스가 되게 한다.
  5. 의존성 역전(주입) 구성 서비스는 생성자 주입으로 포트를 받는다. Django에서는 view/DRF viewset에서 조립하거나 “container” 모듈을 둔다.
  6. 확장 포인트 고정 “새 정책 추가 시 서비스 수정 금지” 같은 PR 규칙을 정한다(= OCP 가드레일).
  7. 리뷰 체크리스트 적용 & 리팩토링 루프 SRP 위반, 거대한 함수, 다중 책임 모델, 넓은 인터페이스, 구체 구현 의존 등을 잡는다.

2) 원칙별로 “프로젝트 레벨”에서 적용하는 방법

SRP(단일 책임)

  • 유스케이스 하나 = 서비스 하나를 기본 단위로 삼는다.
  • 모델도 책임을 좁힌다: Coupon은 상태와 불변조건, CouponPolicy는 규칙, CouponRepository는 영속화.
  • 리팩토링 패턴: Extract Function/Class, 명령-질의 분리(CQS).

OCP(개방-폐쇄)

  • 변경이 예상되는 곳(정책, 계산, 외부 연동)을 전략/팩토리/데코레이터로 고정점화한다.
  • “새로운 정책 추가 시 기존 서비스 수정 금지”를 팀 규칙으로 문서화한다.
  • 리팩토링 패턴: switch/if-elif 사다리를 전략 객체로 치환.

LSP(리스코프 치환)

  • “대체 가능한 계약”을 테스트로 고정한다: 계약 테스트(Contract Test). 동일한 테스트를 FixedDiscount, PercentDiscount 전부에 돌려 호환성 보장.
  • 사전조건 강화·사후조건 약화 금지(입력 더 엄격/출력 더 약하게 만들지 않기).

ISP(인터페이스 분리)

  • “읽기/쓰기/검색”을 쪼갠 작은 포트로 나눈다. 예: CouponReader, CouponWriter 분리 → 테스트와 재사용성이 좋아진다.
  • 한 클라이언트가 쓰지 않는 메서드는 인터페이스에서 제거한다.

DIP(의존성 역전)

  • 서비스 → 포트(Protocol) → 어댑터(ORM/HTTP) 구조로, 상위 레벨이 추상에 의존.
  • 조립은 가장 바깥(프레임워크 계층)에서 수행: Django view에서 서비스에 어댑터를 주입.

3) 미니 예시: “쿠폰 발급” 유스케이스(파이썬 3.10+, Django 호환)

반환 타입 힌트는 생략했다는 점 참고

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# domain/ports.py
from typing import Protocol, runtime_checkable

@runtime_checkable
class CouponReader(Protocol):
    def get_batch(self, batch_id): ...
    def exists_issued(self, batch_id, user_id): ...

@runtime_checkable
class CouponWriter(Protocol):
    def save_coupon(self, coupon): ...

@runtime_checkable
class DiscountPolicy(Protocol):
    def apply(self, base_amount, rule): ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# domain/model.py
from dataclasses import dataclass

@dataclass(frozen=True)
class Coupon:
    code: str
    batch_id: int
    user_id: str

@dataclass(frozen=True)
class CouponBatch:
    id: int
    rule: dict
    active: bool
1
2
3
4
5
6
7
8
# domain/policies.py
class FixedDiscount:
    def apply(self, base_amount, rule):
        return max(0, base_amount - rule["amount"])

class PercentDiscount:
    def apply(self, base_amount, rule):
        return int(base_amount * (100 - rule["percent"]) / 100)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# application/services.py
from .model import Coupon, CouponBatch

class IssueCouponService:
    def __init__(self, reader, writer, discount_policy):
        self.reader = reader
        self.writer = writer
        self.discount_policy = discount_policy

    def execute(self, batch_id, user_id):
        batch = self.reader.get_batch(batch_id)
        if not batch.active:
            raise ValueError("inactive batch")

        if self.reader.exists_issued(batch_id, user_id):
            raise ValueError("already issued")

        code = self._generate_code(batch_id, user_id)
        coupon = Coupon(code=code, batch_id=batch_id, user_id=user_id)
        self.writer.save_coupon(coupon)
        return coupon

    def _generate_code(self, batch_id, user_id):
        return f"{batch_id}-{hash(user_id) & 0xffff}"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# infrastructure/django_adapters.py (Django ORM 어댑터)
from coupons.models import Coupon as CouponORM, CouponBatch as CouponBatchORM
from domain.model import Coupon, CouponBatch

class DjangoCouponReader:
    def get_batch(self, batch_id):
        obj = CouponBatchORM.objects.get(id=batch_id)
        return CouponBatch(id=obj.id, rule=obj.rule, active=obj.active)

    def exists_issued(self, batch_id, user_id):
        return CouponORM.objects.filter(batch_id=batch_id, user_id=user_id).exists()

class DjangoCouponWriter:
    def save_coupon(self, coupon):
        CouponORM.objects.create(
            code=coupon.code, batch_id=coupon.batch_id, user_id=coupon.user_id
        )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# interface/http.py (Django view/DRF viewset에서 조립)
from domain.policies import FixedDiscount, PercentDiscount
from application.services import IssueCouponService
from infrastructure.django_adapters import DjangoCouponReader, DjangoCouponWriter

def issue_coupon_view(request):
    payload = request.data
    batch_id = payload["batch_id"]
    user_id = payload["user_id"]

    # 정책 선택(룰 기반 팩토리로 바꿔도 된다)
    policy = FixedDiscount()

    svc = IssueCouponService(
        reader=DjangoCouponReader(),
        writer=DjangoCouponWriter(),
        discount_policy=policy,
    )
    coupon = svc.execute(batch_id, user_id)
    return JsonResponse({"code": coupon.code})

적용 포인트

  • SRP: IssueCouponService는 “발급” 유스케이스에만 집중한다.
  • OCP: 할인 전략은 DiscountPolicy로 고정 → 새 정책 추가 시 서비스 수정 없이 주입만 교체.
  • LSP: FixedDiscount, PercentDiscount가 같은 계약을 따른다 → 동일 테스트 재사용.
  • ISP: CouponReader/CouponWriter로 분리 → 읽기/쓰기 독립.
  • DIP: 서비스는 Django ORM을 모르고 포트만 의존한다. 조립은 view 레이어에서.

4) 폴더링 & 레이어링(예시)

1
2
3
4
5
6
project/
  domain/           # 규칙·모델·포트(프레임워크 몰라야 함)
  application/      # 유스케이스 서비스(Tx 경계, orchestration)
  infrastructure/   # ORM/HTTP/외부API 어댑터
  interface/        # 웹/API·CLI 등 입출력
  container.py      # 조립(선택)
  • Django “앱”은 어댑터가 된다. 도메인은 Django에 의존하지 않는다.

5) 테스트 전략(설계의 안전벨트)

  • 서비스 테스트(유스케이스): 성공/중복/비활성 배치 등 경계 케이스.
  • 도메인 규칙 테스트: 할인·발급 제약 불변조건.
  • 계약 테스트: CouponReader/Writer 포트에 대한 공통 테스트 세트 → Django, Fake 모두 통과해야 함.
  • 어댑터 통합 테스트: ORM 실제 쿼리/트랜잭션 확인.

6) PR 리뷰 체크리스트(팀에 붙여두기)

  • [SRP] 유스케이스 기준으로 서비스가 1가지 책임만 가지는가
  • [OCP] 새로운 정책/타입 추가 시 기존 서비스 수정이 필요한가
  • [LSP] 대체 구현이 동일 계약 테스트를 통과하는가
  • [ISP] 사용하지 않는 메서드가 포트에 포함되어 있지 않은가
  • [DIP] 상위 모듈이 구체 구현에 import로 묶여 있지 않은가
  • [추가] 사이클릭 의존, 긴 함수, 데이터 클래스에 로직 과도 탑재, 거대한 DTO 여부

7) 자동화로 습관 만들기

  • 아키텍처 린트: “domain이 infrastructure를 import하면 실패” 같은 규칙을 스크립트로.
  • 스캐폴딩: add_usecase.py 같은 템플릿 생성기로 포트/서비스/테스트 뼈대 자동 생성.
  • CI 가드: 계약 테스트, 커버리지, 마이그레이션 체크, 마이너 린트.

8) 과도한 SOLID 적용을 피하는 법

  • 초기엔 수직 슬라이스 1~2개만 SOLID로 구현해 뼈대를 검증한다.
  • 복잡도와 변화 가능성이 충분히 관측될 때 전략/팩토리로 일반화한다.
  • “한 파일 50줄짜리를 굳이 6개 레이어로 쪼개지 않는다”는 팀 감각을 유지한다.
This post is licensed under CC BY 4.0 by the author.