도메인 모델링
대부분 시스템을 설계할 때, 데이터베이스 스키마를 그리기 시작하고 그 다음 객체 모델을 생각한다. 여기서부터 모든 것이 잘못되기 시작한다.
먼저 행동하고 저장에 대한 요구 사항은 행동에 맞춰 정해져야 한다.
고객들은 데이터 모델에 대해 신경쓰지 않는다. 시스템이 어떤 일을 하는지만 신경 쓴다.
이 책에서는 4가지 핵심 설계 패턴을 보여준다.
- 저장소 패턴은 영속적인 저장소에 대한 추상화이다.
- 서비스 계층 패턴은 usecase의 시작과 끝을 명확하게 정의하기 위한 패턴이다.
- 작업 단위 패턴은 원자적 연산을 제공한다.
- 애그리게이트 패턴은 데이터 정합성을 강화하기 위한 패턴이다.
챕터 1에서는 비즈니스 프로세스를 코드로 모델링하는 방법을 배운다. 이 때 TDD와 호환이 잘 되는 방식을 살펴본다. 그리고 도메인 모델링의 중요성과 도메인을 모델링하기 위한 핵심 패턴인 엔티티(entity), 값 객체(value object), 도메인 서비스(domain service) 에 대해 살펴본다.
도메인 : 해결하려는 문제. 시스템에 따라 구매 및 조달, 제품 설계, 물류 및 배달 등의 여러 분야를 뜻할 수 있다.
모델 : 유용한 특성을 포함하는 프로세스나 현상의 지도
참고) DDD 관련 도서 - 도메인 주도 설계 / 도메인 주도 설계 핵심
다음 예시를 가구 회사의 도메인 모델을 통해 모델을 구축하는 방법에 대해 알아본다.
제품은 SKU로 식별된다. 고객은 주문을 넣는다. 주문은 주문 참조 번호(order reference) 에 의해 식별되며, 한 줄 이상의 주문 라인(order line)을 포함한다. 각 주문 라인에는 SKU와 수량이 있다.
구매 부서는 재고를 배치로 주문한다. 재고 배치는 유일한 ID, SKU, 수량으로 이루어진다.
배치에 주문 라인을 할당해야 한다. 주문 라인을 배치에 할당하면 해당 배치에 속하는 재고를 고객의 주소로 배송한다. 어떤 배치의 주문 라인에 x 단위로 할당하면 가용 재고 수량은 x 만큼 줄어든다.
배치의 가용 재고 수량이 주만 라인의 수량보다 작으면 이 주문 라인을 배치에 할당할 수 없다.
같은 주문 라인을 두 번 이상 할당해서는 안된다.
배치가 현재 배송 중이면 ETA 정보(Estimated Time of Arrival;도착예정일)가 배치에 들어있다. ETA가 없는 배치는 창고 재고다. 창고 재고를 배송 중인 배치보다 더 먼저 할당해야 한다. 배송 중인 배치를 할당할 때는 ETA가 가장 빠른 배치르 먼저 할당한다.
위의 내용을 바탕으로 구현된 첫 모델은 다음과 같다.
import datetime
from dataclasses import dataclass
@dataclass(frozen=True)
class OrderLine:
orderid: str
sku: str
qty: int
class BatchV1:
def __init__(self, ref: str, sku: str, qty: int, eta: datetime.date):
self.reference = ref
self.sku = sku
self.qty = qty
self.eta = eta
self.available_quantity = qty
def allocate(self, order: OrderLine):
self.available_quantity -= order.qty
OrderLine 객체 자체는 값의 변화가 없는 객체이므로 불변 데이터 클래스로 생성한다. namedtuple 을 사용할 수도 있다.
여기서 설명하는 비즈니스 규칙의 핵심을 잡아내는 단위 테스트를 작성해본다.
먼저, 할당에 대한 테스트를 구성해본다.
1. 어떤 배치의 재고를 주문 라인 x 에 할당하면 가용 재고 수량은 x만큼 줄어든다.
- 20단위의 SMALL-TABLE로 이루어진 배치가 있고, 2단위의 SMALL-TABLE을 요구하는 주문 라인이 있다.
- 주문 라인을 할당하면 배치에 18단위의 SMALL-TABLE 이 남아야 한다.
def test_allocating_to_a_batch_reduces_the_available_quantity():
batch = BatchV1("batch-001", "SMALL-TABLE", qty=20, eta=date.today())
order = OrderLine('order-001', "SMALL-TABLE", qty=2)
batch.allocate(order)
assert batch.available_quantity == 18
SMALL-TABLE 제품 20개를 batch-001 이라는 고유 아이디를 부여하여 생성하였다.
그리고 order-001 이라는 고유 아이디로 2개의 SMALL-TABLE 을 주문하는 주문 라인을 생성하여, 배치하였다.
배치 후 가용한 수량은 18개가 일치하는지를 테스트한다.
위의 테스트 코드에서 실패하는 케이스는 아래와 같다.
def test_can_allocate_if_availalbe_smaller_than_required():
small_batch, large_line = make_batch_and_line("ELEGANT-LAMP", 2, 20)
small_batch.allocate(large_line)
assert small_batch.available_quantity > 0
> def test_can_allocate_if_availalbe_smaller_than_required():
small_batch, large_line = make_batch_and_line("ELEGANT-LAMP", 2, 20)
small_batch.allocate(large_line)
> assert small_batch.available_quantity > 0
E assert -18 > 0
E + where -18 = <model.model_v1.BatchV1 object at 0x10d64d8e0>.available_quantity
test/test_batches.py:23: AssertionError
배치 수량보다 주문 수량이 많은 경우에 주문 라인을 배치하면 가용 재고가 마이너스 값이 된다. 현재 배치 재고를 기준으로 신규 주문 라인의 수량을 감당할 수 있는지 판단할 수 있는 방법이 필요하다.
이를 위해 모델에 새로운 메서드를 추가한다.
class BatchV2(BatchV1):
def can_allocate(self, line: OrderLine) -> bool:
return self.sku == line.sku and self.available_quantity >= line.qty
sku가 일치하면서 배치 가용 재고 수량이 주문 라인의 수량보다 크거나 같은 경우에는 참을 반환하는 메서드를 추가했다.
이를 사용하여 직접 배치 후에 수량을 확인하는 방식이 아닌, 단순 배치 가능여부를 판단할 수 있는 테스트 코드를 작성할 수 있다.
def test_can_allocate_if_availalbe_greater_than_required():
large_batch, small_line = make_batch_and_line("ELEGANT-LAMP", 20, 2)
assert large_batch.can_allocate(small_line)
def test_can_allocate_if_availalbe_smaller_than_required():
small_batch, large_line = make_batch_and_line("ELEGANT-LAMP", 2, 20)
assert small_batch.can_allocate(large_line) is False
def test_can_allocate_if_availalbe_equal_to_required():
batch, line = make_batch_and_line("ELEGANT-LAMP", 20, 20)
assert batch.can_allocate(line)
def test_cannot_allocate_if_skus_do_not_match():
batch = BatchV2("batch-001", "CHAIR", qty=10, eta=None)
line = OrderLine("order-002", "TOASTER", qty=2)
assert batch.can_allocate(line) is False
수량에 대한 케이스와 sku 가 다른 경우에 대한 케이스까지 테스트했다.
새롭게 구현한 can_allocate 메서드를 활용하여 다음의 문제를 해결할 수 있다.
2. 배치의 가용 재고 수량이 주문 라인의 수량보다 작으면 이 주문 라인을 배치에 할당할 수 없다.
이와 더불어, 이번엔 만약 할당된 주문 라인의 배치가 해제되면 어떨까? 2개 수량의 주문 라인 배치가 할당되었다가 취소 되면, 배치의 가용 재고는 취소된 수량 만큼 회복되어야 한다. 또한 배치가 해제 되었음을 인지할 수 있어야 한다. 이를 위한 메서드를 추가하고 배치 클래스의 속성을 변경해야 한다.
class BatchV3:
def __init__(self, ref: str, sku: str, qty: int, eta: datetime.date):
self.reference = ref
self.sku = sku
self._purchased_qty = qty
self.eta = eta
self._allocations = set()
def allocate(self, line: OrderLine):
if self.can_allocate(line):
self._allocations.add(line)
else:
print("SKU 불일치 혹은 가용 재고 수량 부족으로 배치 할당이 불가합니다.")
def deallocation(self, line: OrderLine):
if line in self._allocations:
self._allocations.remove(line)
@property
def allocated_quantity(self) -> int:
return sum(line.qty for line in self._allocations)
@property
def available_quantity(self) -> int:
return self._purchased_qty - self.allocated_quantity
def can_allocate(self, line: OrderLine):
return self.sku == line.sku and self.available_quantity >= line.qty
새로 구현된 메서드를 통해 라인이 할당된 배치를 할당 해제하였을 때 가용 수량의 변화를 테스트하는 코드를 짤 수 있다. 추가로 책에서는 없지만, 주문 라인에 배치를 할당할 때 할당이 불가한 경우에는 프린트 문을 통해서 알리는 부분을 추가했다. 이에 대한 테스트는 아래와 같다.
def test_can_only_deallocate_allocated_lines(capsys):
batch = BatchV3("batch-001", "SOFA", qty=10, eta=date.today())
line = OrderLine("order-001", "SOFA", qty=4)
batch.allocate(line)
assert batch.available_quantity == 6
batch.deallocation(line)
assert batch.available_quantity == 10
line2 = OrderLine("order-002", "TABLE", 1)
batch.allocate(line2)
captured = capsys.readouterr()
assert captured.out == 'SKU 불일치 혹은 가용 재고 수량 부족으로 배치 할당이 불가합니다.\n'
배치 할당 후에 할당 해제하는 경우, 배치의 가용 재고가 원래대로 돌아오는지를 테스트한다. 또한 위의 예제에서는 sku 가 일치하지 않아 배치 할당이 불가한 경우에 대한 프린트 문이 잘 출력되는지를 확인한다.
pytest의 테스트 함수에서는 cpasys 라는 매개 변수를 받아, 콘솔에 출력되는 문자를 캡쳐하여 비교가 가능하다. 주의 사항은 끝에 항상 개행 문자를 붙여야 한다.
이제부터는 배치가 주문 라인 객체들의 집합을 통해 추적 및 관리가 가능해졌다. 더하여, _allocations 가 set 이므로, 중복된 라인이 할당 되더라도 문제가 없다. 이에 대한 테스트 코드는 아주 간단하다.
3. 같은 주문 라인을 두 번 이상 할당해서는 안 된다.
def test_allocations_is_idempotent():
batch = BatchV3("batch-001", "SOFA", qty=10, eta=date.today())
line = OrderLine("order-001", "SOFA", qty=4)
batch.allocate(line)
batch.allocate(line)
assert batch.available_quantity == 6
아주 기본적인 기능들을 구현할 수 있도록 모델일 구현하였다.
위 예제에서는 line 을 별다른 정의 없이 사용하였지만, 실제 비즈니스 용어에서 주문은 여러 라인을 원소로하고, 한 라인은 한 쌍의 SKU와 수량으로 이루어진다.
예를 들면 이와 같다.
<YAML>
Order_reference: 12345
Lines:
- sku: RED-CHAIR
qty: 25
- sky: BLUE-CHAIR
qty: 25
라인은 식별가능한 유일한 값이 없다. 이럴 때, 값 객체 패턴을 사용한다. 보통 불변 객체로 만드는데 파이썬에서는 데이터 클래스를 주로 사용한다. 이를 통해 값 동등성을 부여하는데, 'orderid, sku, qty' 가 동일한 두 라인은 같다' 라는 의미와 같다.
값 객체는 내부에 있는 데이터에 의해 결정되며, 오랫동안 유지되는 정체성이 존재하지는 않는다.
반대로 오랫동안 유지되는 정체성이 존재하는 도메인 객체를 설명할 때, 엔티티(entity) 라는 용어를 사용한다. 예를 들면 사람이라는 객체는 이름이나 결혼 상태를 바꿔도 그 정체성이 바뀌지 않는 영속적인 정체성(persistent identity) 이 있다. 값과 달리 엔티티는 정체성 동등성이 있다.
예제에서의 배치도 마찬가지로 엔티티다. 엔티티에 대한 동등성 연산자를 구현함으로써 엔티티의 정체성 관련 동작을 명시적으로 코드로 작성할 수 있다.
class Batch:
def __init__(self, ref: str, sku: str, qty: int, eta: Optional[date]):
self.reference = ref
self.sku = sku
self.eta = eta
self._purchased_quantity = qty
self._allocations = set() # type: Set[OrderLine]
def __eq__(self, other):
if not isinstance(other, Batch):
return False
return other.reference == self.reference
def __hash__(self):
return hash(self.reference)
매직 메서드 던더이큐는 클래스가 == 연산자에 대해 작동하는 방식을 정의한다.
__hash__ 는 객체를 집합에 추가하거나 딕셔너리의 키로 사용할 때 동작을 제어하기 위해 파이썬이 사용하는 매직 메서드다.
지금까지는 배치를 표현하는 모델을 만들었다. 하지만 실제 해야 할 일은 모든 재고를 표현하는 구체적인 배치 집합에서 주문 라인을 할당하는 것이다.
배치 집합이라는 것은 현재 배송 중인 배치, 창고 재고인 배치처럼 상황에 따라 구분되는 여러 종류의 배치를 의미한다.
엔티티나 값 객체로 자연스럽게 표현할 수 없는 도메인 서비스 연산은 객체보다는 단독 함수로 구현할 수 있다.
def allocate(line: OrderLine, batches: List[Batch]) -> str:
batch = next(b for b in sorted(batches) if b.can_allocate(line))
batch.allocate(line)
return batch.reference
배치 리스트를 정렬하고, 주어진 라인을 할당 가능한 배치에 해당 라인을 배치하고 배치 id 를 반환하는 함수이다. 이 때, sorted() 함수가 작동하기 위해서는 __gt__ 를 도메인 모델에 구현해야 한다.
class Batch:
...
def __gt__(self, other):
if self.eta is None:
return False
if other.eta is None:
return True
return self.eta > other.eta
배치를 정렬하는 순서는 아래의 조건에 따른다.
4. 창고 재고를 배송 중인 배치보다 더 먼저 할당해야 한다. 배송 중인 배치를 할당할 때는 ETA 가 가장 빠른 배치를 먼저 할당한다.
sorted 의 기본 조건이 오름차순이다. 따라서 기준이 되는 현재 배치의 eta 가 없다면 False 로써 리스트 가장 앞에 위치하게 된다. False ==0, True == 1 에 따른다. 반면, 비교 대상이 되는 리스트의 eta 가 없다면 현재 배치에 대한 boolean 을 True 로 반환하여 리스트의 뒤로 보낸다.
이에 대한 테스트 코드는 아래와 같다.
today = date.today()
tomorrow = today + timedelta(days=1)
later = tomorrow + timedelta(days=10)
def test_prefers_current_stock_batches_to_shipments():
in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None)
shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow)
line = OrderLine("oref", "RETRO-CLOCK", 10)
allocate(line, [in_stock_batch, shipment_batch])
assert in_stock_batch.available_quantity == 90
assert shipment_batch.available_quantity == 100
def test_prefers_earlier_batches():
earliest = Batch("speedy-batch", "MINIMALIST-SPOON", 100, eta=today)
medium = Batch("normal-batch", "MINIMALIST-SPOON", 100, eta=tomorrow)
latest = Batch("slow-batch", "MINIMALIST-SPOON", 100, eta=later)
line = OrderLine("order1", "MINIMALIST-SPOON", 10)
allocate(line, [medium, earliest, latest])
assert earliest.available_quantity == 90
assert medium.available_quantity == 100
assert latest.available_quantity == 100
def test_returns_allocated_batch_ref():
in_stock_batch = Batch("in-stock-batch-ref", "HIGHBROW-POSTER", 100, eta=None)
shipment_batch = Batch("shipment-batch-ref", "HIGHBROW-POSTER", 100, eta=tomorrow)
line = OrderLine("oref", "HIGHBROW-POSTER", 10)
allocation = allocate(line, [in_stock_batch, shipment_batch])
assert allocation == in_stock_batch.reference
마지막으로 예외 조건을 본다.
품절과 같은 예외 조건은 도메인 예외 조건이다.
class OutOfStock(Exception):
pass
def allocate(line: OrderLine, batches: List[Batch]) -> str:
try:
batch = next(b for b in sorted(batches) if b.can_allocate(line))
batch.allocate(line)
return batch.reference
except StopIteration:
raise OutOfStock(f"Out of stock for sku {line.sku}")
이에 대한 테스트 코드는 아래와 같다.
def test_raises_out_of_stock_exception_if_cannot_allocate():
batch = Batch("batch1", "SMALL-FORK", 10, eta=today)
allocate(OrderLine("order1", "SMALL-FORK", 10), [batch])
with pytest.raises(OutOfStock, match="SMALL-FORK"):
allocate(OrderLine("order2", "SMALL-FORK", 1), [batch])
with pytest.raises() 문을 통해서 예측되는 exception 객체를 테스트 할 수 있다.
지금까지 본 내용들을 정리한 코드는 아래와 같다.
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from typing import Optional, List, Set
class OutOfStock(Exception):
pass
def allocate(line: OrderLine, batches: List[Batch]) -> str:
try:
batch = next(b for b in sorted(batches) if b.can_allocate(line))
batch.allocate(line)
return batch.reference
except StopIteration:
raise OutOfStock(f"Out of stock for sku {line.sku}")
@dataclass(frozen=True)
class OrderLine:
orderid: str
sku: str
qty: int
class Batch:
def __init__(self, ref: str, sku: str, qty: int, eta: Optional[date]):
self.reference = ref
self.sku = sku
self.eta = eta
self._purchased_quantity = qty
self._allocations = set() # type: Set[OrderLine]
def __repr__(self):
return f"<Batch {self.reference}>"
def __eq__(self, other):
if not isinstance(other, Batch):
return False
return other.reference == self.reference
def __hash__(self):
return hash(self.reference)
def __gt__(self, other):
if self.eta is None:
return False
if other.eta is None:
return True
return self.eta > other.eta
def allocate(self, line: OrderLine):
if self.can_allocate(line):
self._allocations.add(line)
def deallocate(self, line: OrderLine):
if line in self._allocations:
self._allocations.remove(line)
@property
def allocated_quantity(self) -> int:
return sum(line.qty for line in self._allocations)
@property
def available_quantity(self) -> int:
return self._purchased_quantity - self.allocated_quantity
def can_allocate(self, line: OrderLine) -> bool:
return self.sku == line.sku and self.available_quantity >= line.qty
위의 코드에서 하나 참고할 점은 바로 __future__ 모듈이다.
__future__ 모듈은 import 모듈을 분석하여 파이썬 버전이 다른 경우, 상호 호환이 가능하도록 하는 기능을 한다. 따라서 반드시 스크립트의 최상단에 위치해야 한다. __future__ 모듈에서도 annotations 는 파이썬의 코드 순서로 인해 forward referencing 이 불가했던 점을 보완해준다.
함수 allocate 에서 타입 힌트를 적용할 때, line 과 batches 에 각각 사용자가 정의한 객체를 사용하였다. 하지만 각각의 객체는 해당 함수보다 밑에 정의되어 있으므로, lazy evaluation 하여 에러가 발생하지 않도록 하였다.
지금까지 알아본 내용들을 정리하면 다음과 같다.
1. 도메인 모델링
- 코드에서 비즈니스와 가장 가까운 부분이며, 변화가 생길 가능성이 가장 높은 부분이다. 또한 비즈니스에게 가장 큰 가치를 제공하는 부분이다. 구현하고자 하는 도메인을 하나의 프로세스나 지도로 만드는 과정을 말한다.
2. 엔티티와 값 객체의 구분
- 값 객체는 그 내부의 속성들에 의해 정의된다. 일반적으로 불변 타입을 사용해 구현한다. 따라서 값 객체의 속성을 변경하면 새로운 값 객체가 된다. 반면, 엔티티에는 시간에 따라 변화하하는 속성이 포함될 수 있고, 이런 속성은 바뀌더라도 같은 엔티티로 남는다. 엔티티는 식별 가능한 유일한 요소를 정의하는 것이 중요하다.
3. 모든 것을 객체로 만들 필요는 없다.
- 코드에서 동사(verb)에 해당하는 부분을 표현하려면 함수를 사용하는 것이 좋다.
'python' 카테고리의 다른 글
Fluent Python (챕터 4) (0) | 2021.10.11 |
---|---|
Fluent Python (챕터 3) (0) | 2021.10.09 |
Fluent Python (챕터 2) (0) | 2021.10.07 |
Fluent Python (챕터1) (0) | 2021.10.06 |
ChainMap (0) | 2021.05.07 |