“SOLID를 지킨 설계”는 멋진 슬로건이 아니라, 요구사항을 유스케이스 단위로 쪼개고(단일 책임), 변화 가능 지점을 확장 포인트로 고정해 두며(개방/폐쇄), 교체 가능한 계약을 중심으로(리스코프, 인터페이스 분리) 의존성을 역전시키는 일상적 습관이다. 아래는 백엔드(특히 Python/Django) 관점에서 프로젝트 전개 흐름과 실전 체크리스트다.
1) 설계 진행 순서(프로세스)
- 유스케이스 정의 “누가 무엇을 왜 한다”를 한 문장으로 쓰고 입출력 명세를 표로 만든다. 예) “사용자가 쿠폰을 발급받는다: 입력=배치ID, 사용자ID / 출력=쿠폰코드”
- 도메인 모델 스케치 핵심 개념(엔티티·값객체·정책)을 종이에 그리고, 상태·불변조건을 적는다. 변경 가능성이 높은 규칙(할인 정책, 발급 정책)은 전략 객체로 분리한다.
- 포트/어댑터(경계) 세우기 DB, 메시지큐, 외부 API는 **포트(Protocol 인터페이스)**로 먼저 정의한다. 애플리케이션 서비스는 포트(계약)만 의존하고, 구현은 어댑터가 맡는다.
- TDD로 유스케이스 단위 구현 서비스 레벨의 성공/경계/에러 테스트 → 도메인 규칙 테스트 → 어댑터 계약 테스트(스텁/페이크). 테스트 이름이 곧 유스케이스가 되게 한다.
- 의존성 역전(주입) 구성 서비스는 생성자 주입으로 포트를 받는다. Django에서는 view/DRF viewset에서 조립하거나 “container” 모듈을 둔다.
- 확장 포인트 고정 “새 정책 추가 시 서비스 수정 금지” 같은 PR 규칙을 정한다(= OCP 가드레일).
- 리뷰 체크리스트 적용 & 리팩토링 루프 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개 레이어로 쪼개지 않는다”는 팀 감각을 유지한다.