[파이썬으로 살펴보는 아키텍처 패턴] 챕터 1 도메인 모델링

2023. 7. 30. 19:44·책 & 스터디

도메인 모델이란?

도메인 : 우리가 해결하려는 문제

모델 : 유영한 특성을 포함하는 프로세스나 현상의 지도(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
'책 & 스터디' 카테고리의 다른 글
  • 데이터 메시를 읽고
  • [파이썬으로 살펴보는 아키텍처 패턴] 챕터 2 저장소 패턴
  • [파이썬으로 살펴보는 아키텍처 패턴] 챕터 0
  • 함께자라기를 읽고
haong_
haong_
블로그 이전합니다 >> www.hajinnote.me
  • haong_
    일단 시작하는 블로그
    haong_
  • 전체
    오늘
    어제
    • 분류 전체보기
      • 데이터 엔지니어링
      • 이슈 해결 일지
      • 개발 환경 및 운영
        • Kubernetes
      • CS
        • 운영체제
        • 데이터베이스시스템
      • 프로그래밍
      • 알고리즘
      • 머신러닝
        • 통계학
        • 딥러닝
        • 데이터 분석
        • 논문
        • 프로젝트
      • 회고
      • 책 & 스터디
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    데이터메시
    data mesh
    이
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
haong_
[파이썬으로 살펴보는 아키텍처 패턴] 챕터 1 도메인 모델링
상단으로

티스토리툴바