-
도메인 모델이란?
도메인 : 우리가 해결하려는 문제
모델 : 유영한 특성을 포함하는 프로세스나 현상의 지도(map)
- 도메인 모델은 비즈니스를 수행하는 사람이 자신의 비즈니스에 대해 마음속에 가지고 있는 지도로 이 지도는 인간이 복잡한 프로세스에 대해 생각하는 방식
- 복잡한 시스템을 다루기 위해 전문용어가 생기고 도메인 모델링이 필요해짐
도메인 언어 탐구
비즈니스 전문가들과의 대화를 통해 도메인 모델에 사용할 용어와 규칙을 정해야 함
→ 비즈니스 전문용어(DDD 용어로는 유비쿼터스 언어(ubiquitous language))
- 제품 - SKU(재고 유지 단위- stock keeping unit)
- 고객 - 주문
- 주문 참조 번호(order reference) - 한줄 이상의 주문라인(order line) - sku - 수량
- 구매 부서 - 재고를 배치로 주문 - 유일한 ID, SKU, 수량
배치에 주문 라인을 할당하고, 주문 라인을 배치에 할당하면 해당 배치에 속하는 재고를 고객의 주소로 배송
어떤 배치의 재고를 주문 라인에 x 단위로 할당하면 가용 재고 수량도 x만큼 줄어듬
도메인 모델 단위 테스트
OrderLine은 동작이 없는 불변 데이터 클래스
Batch는 allocate가 일어날때마다 OrderLine의 qty만큼 available_quantity를 감소시킨다.
def test_allocating_to_a_batch_reduces_the_available_quantity(): batch = Batch("batch-001", "SMALL-TABLE", qty=20, eta=date.today()) line = OrderLine("order-ref", "SMALL-TABLE", 2) batch.allocate(line) assert batch.available_quantity == 18
@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.available_quantity = qty def allocate(self, line: OrderLine): self.available_quantity -= line.qty
할당을 할 수 있으려면, 라인의 sku와 배치의 sku과 같거나, available_quantity가 라인에 들어온 수보다 크거나 같아야 한다.
def can_allocate(self, line: OrderLine) -> bool: return self.sku == line.sku and self.available_quantity >= line.qty
할당할 수 있는 대상을 보여주는 테스트 코드
def make_batch_and_line(sku, batch_qty, line_qty): return ( Batch("batch-001", sku, batch_qty, eta=date.today()), OrderLine("order-123", sku, line_qty), ) def test_can_allocate_if_available_greater_than_required(): large_batch, small_line = make_batch_and_line("ELEGANT-LAMP", 20, 2) assert large_batch.can_allocate(small_line) def test_cannot_allocate_if_available_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_available_equal_to_required(): batch, line = make_batch_and_line("ELEGANT-LAMP", 2, 2) assert batch.can_allocate(line) def test_cannot_allocate_if_skus_do_not_match(): batch = Batch("batch-001", "UNCOMFORTABLE-CHAIR", 100, eta=None) different_sku_line = OrderLine("order-123", "EXPENSIVE-TOASTER", 10) assert batch.can_allocate(different_sku_line) is False
deallocate를 하려면 라인에 할당되지 않은 배치를 해제할때 배치의 가용 수량에 영향을 주지 않아야 한다.
def test_can_only_deallocate_allocated_lines(): batch, unallocated_line = make_batch_and_line("DECORATIVE-TRINKET", 20, 2) batch.deallocate(unallocated_line) assert batch.available_quantity == 20
테스트가 제대로 작동하려면 Batch가 자신이 할당된 라인을 알고 있어야 한다.
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 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
배치는 OrderLine 객체들의 set으로 유지하고 배치 할당시 가용 수량이 충분하면 이를 set에 추가하기만 하면된다. available_quantity는 구매 수량에서 할당 수량을 빼는 공식에 의해 제공되는 property가 된다.
set안에 있는 원소는 모두 유일하기 때문에 아래와 같은 테스트를 수행하는게 쉬워짐
def test_allocation_is_idempotent(): batch, line = make_batch_and_line("ANGULAR-DESK", 20, 2) batch.allocate(line) batch.allocate(line) assert batch.available_quantity == 18
값 객체로 사용하기 적합한 데이터 클래스
주문에는 여러 라인이 원소로 있고 한 라인은 한 쌍의 SKU와 수량으로 이루어짐.
Order_reference: 12345 Lines: - sku : RED-CHAIR qty : 25 - sku : BLUE-CHAIR qty : 25
- 주문에는 그 주문을 식별할 수 있는 참조 번호가 있지만, 라인은 그렇지 않음.
- 이처럼 데이터는 있지만, 유일한 식별자가 없는 비즈니스 개념은 값 객체 패턴을 선택하는 경우가 있음.
- 값 객체는 안에 있는 데이터에 따라 유일하게 식별될 수 있는 도메인 객체. 보통 값 객체를 불변 객체(immutable object)로 만들곤 함
@dataclass(frozen=True) class OrderLine: orderid: str sku: str qty: int
데이터 클래스의 장점은 값 동등성을 부여할 수 있다는 것 → orderid, sku, qty가 같은 두 라인은 같다라는 의미
값 객체와 엔티티
주문 라인은 그라인의 주문 ID, SKU, 수량에 의해 유일하게 식별됨 → 하나라도 달라지면 새로운 라인 생성
값 객체는 내부에 있는 데이터에 의해 결정되며 오랫동안 유지되는 정체성이 존재하지 않음
오랫동안 유지되는 정체성이 존재하는 도메인 객체를 설명할 때, 엔티티(entity)라는 용어를 사용
사람의 경우, 이름은 바꿀 수 있지만 사람은 영속적인 정체성이 있음
값 객체와 달리 엔티티에는 정체성 동등성(identity equality)가 있음 → 엔티티의 값을 바꿔도, 바뀐 엔티티는 이전과 같은 엔티티로 인식됨
현재 예시에서 배치가 엔티티임 → 라인을 배치에 할당할 수 있고, 배치 도착 예정 날짜를 변경할 수도 있지만, 이런 값을 바꿔도 배치는 여전히 정체성이 같은 배치
class Batch: def __eq__(self, other): if not isinstance(other, Batch): return False return other.reference == self.reference def __hash__(self): return hash(self.reference)
이 코드에서 __eq__ 메서드는 두 개의 Batch 객체가 동일한지를 판단하기 위해 사용
other.reference == self.reference는 다른 객체의 reference 속성이 현재 객체의 reference 속성과 동일한지 확인하는 것을 의미
__hash__ 메서드는 객체를 해시 가능(hashable)하게 만듬
객체를 딕셔너리의 키로 사용하거나, 해시 기반의 자료구조(예: set, frozenset)에 포함시키는 데 필요한 속성
해시 가능한 객체는 그 값이 생명 주기 동안 변경되지 않으므로, 불변성(immutable)을 가져야 함
__hash__ 메서드는 Batch 객체의 해시 값을 계산 → hash(self.reference)는 reference 속성의 해시 값을 반환
이 클래스에서 Batch 객체의 동등성은 reference 속성에 의해 결정되며, Batch 객체의 해시 값은 reference의 해시 값에 의해 결정 -> Batch 객체를 딕셔너리의 키로 사용하거나, 해시 기반의 자료구조에 저장하는 등의 작업이 가능해짐
모든 것을 객체로 만들 필요는 없다: 도메인 서비스 함수
배치를 표현하는 모델을 만들었지만, 실제로는 모든 재고를 표현하고 구체적인 배치 집합에서 주문 라인을 할당할 수 있어야 함. 이런 함수를 테스트 하는 방법은 다음과 같음
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
그리고 서비스는 다음과 같음
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
파이썬 마법 메서드 사용 시 모델과 파이썬 숙어 함께 사용 가능
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
sorted() 함수는 Python에서 리스트나 그 외 iterable 객체를 정렬하는 데 사용되는 내장 함수
"작다", "크다", "같다" 등의 관계를 정의하는 특수 메서드인 ‘__lt__’, ‘__gt__’, ‘__le__’, ‘__ge__’, ‘__eq__’, ‘__ne__’를 통해 이루어짐 ‘__gt__’ 메서드는 "greater than"을 의미하며, 두 객체의 대소 관계를 비교할 때 사용
예외를 사용해 도메인 개념 표현 가능
예외로 도메인 개념을 표현하는 것 → 품절로 주문을 할당할 수 없는 개념을 도메인 예외를 사용해 찾아낼 수 있음
품절 예외 테스트
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])
도메인 예외 발생
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}")
도메인 모델링 정리
- 도메인 모델링
- 비즈니스와 가장 가까운 코드 부분
- 변화가 생길 가능성이 높은 부분
- 비즈니스에 가장 큰 가치를 제공하는 부분
- 엔티티와 값 객체 구분
- 값 객체는 배수 속성들에 의해 결정 → 불변 타입을 사용해 값 객체를 구하는 것이 좋음
- 값 객체의 속성을 변경하면 새로운 값 객체가 됨
- 엔티티는 시간에 따라 변하는 속성이 포함될 수 있고, 이런 속성이 바뀌더라도 여전히 똑같은 엔티티
- 어떤 요소가 엔티티를 유일하게 식별하는지 정의하는 것이 중요
- 모든 것을 객체로 만들 필요가 없음
- 파이썬은 다중 패러다임 언어 → 코드에서 동사에 해당하는 부분을 표현하려면 함수를 사용하는 것이 좋음
- 관리 객체, 빌더 객체, 팩토리 객체 대신에 각각 함수로 쓰는 편이 가독성이 더 좋고 표현력이 좋음
- 가장 좋은 OO 설계 원칙을 적용할 때
- SOLID 원칙이나 ‘has-a와 is-a의 관계’, ‘상속보다는 구성을 사용하라’와 같은 좋은 설계법을 다시 살펴보자
- 일관성 경계나 애그리게이트 → 7장에서
'책 & 스터디' 카테고리의 다른 글
데이터 메시를 읽고 (1) 2024.02.25 [파이썬으로 살펴보는 아키텍처 패턴] 챕터 2 저장소 패턴 (0) 2023.07.30 [파이썬으로 살펴보는 아키텍처 패턴] 챕터 0 (0) 2023.07.30 함께자라기를 읽고 (1) 2022.08.27 데이터 파이프라인 핵심 가이드 7챕터 요약 (0) 2022.08.03 댓글