티스토리 뷰

해당 장의 목표

  • 도메인 모델을 다시 살펴보면서 불변조건과 제약에 대해 살펴보기
  • 도메인 객체가 개념적으로나 영속적 저장소 안에서나 내부적 일관성을 유지하는 방법
  • 일관성 경계(consistency boundary)를 설명하고 일관성 경계가 어떻게 유지보수 편의를 해치지 않으면서 고성능 소프트웨어를 만들수있게 해주는지 살펴보기
    • 일관성 경계: 도메인 개체에 대한 변경 사항이 일관성과 무결성을 유지해야 하는 범위를 정의하는 도메인 내의 경계
더보기
ACID(데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질)

원자성(Atomicity)
트랜잭션과 관련된 작업들이 부분적으로 실행되다가 중단되지 않는 것을 보장하는 능력이다. 예를 들어, 자금 이체는 성공할 수도 실패할 수도 있지만 보내는 쪽에서 돈을 빼 오는 작업만 성공하고 받는 쪽에 돈을 넣는 작업을 실패해서는 안된다. 원자성은 이와 같이 중간 단계까지 실행되고 실패하는 일이 없도록 하는 것이다.

일관성(Consistency) - 혹은 정합성
트랜잭션 처리 전과 처리 후 데이터 모순이 없는 상태를 유지하는 것을 의미한다. 무결성 제약이 모든 계좌는 잔고가 있어야 한다면 이를 위반하는 트랜잭션은 중단된다.

독립성(Isolation)
트랜잭션을 수행 시 다른 트랜잭션의 연산 작업이 끼어들지 못하도록 보장하는 것을 의미한다. 이것은 트랜잭션 밖에 있는 어떤 연산도 중간 단계의 데이터를 볼 수 없음을 의미한다. 은행 관리자는 이체 작업을 하는 도중에 쿼리를 실행하더라도 특정 계좌간 이체하는 양 쪽을 볼 수 없다. 공식적으로 고립성은 트랜잭션 실행내역은 연속적이어야 함을 의미한다. 성능관련 이유로 인해 이 특성은 가장 유연성 있는 제약 조건이다. 자세한 내용은 관련 문서를 참조해야 한다.

영속성(Durability)
성공적으로 수행된 트랜잭션은 영원히 반영되어야 함을 의미한다. 시스템 문제, DB 일관성 체크 등을 하더라도 유지되어야 함을 의미한다. 전형적으로 모든 트랜잭션은 로그로 남고 시스템 장애 발생 전 상태로 되돌릴 수 있다. 트랜잭션은 로그에 모든 것이 저장된 후에만 commit 상태로 간주될 수 있다.

7.1 모든 것을 스프레드시트에서 처리하지 않는 이유

도메인 모델의 요점이란?

  • 시스템상의 제약이 있는데 도메인 로직의 일부는 이런 제약을 강제로 지키게 해서 시스템이 만족하는 불변조건을 유지하려는 목적으로 작성
    • 불변조건: 어떤 연산을 끝낼 때마다 항상 참이어야하는 요소를 의미

7.2 불변 조건, 제약, 일관성

제약과 불변조건은 대치가 가능해보인다

  • 제약: 모델이 취할수 있는 상태의 수를 제한
  • 불변조건: 항상 참이어야하는 조건

경우에 따라 일시적으로 규칙을 완화할 수 있겠지만, 작업이 끝나면 도메인 모델은 모든 불변조건을 만족하는 일관성있는 최종 상태로 끝난다는 사실을 보장한다.

비즈니스 요구 사항에서 구체적인 예제 1

주문 라인은 한번에 한 배치에만 할당
-> 불변 조건: 주문 라인이 0 또는 1개의 배치에만 할당될 수 있고, 2개 이상의 배치에 할당될 수는 없다.

7.2.1 불변조건, 동시성, 락

주문 라인 수량보다 더 작은 배치에 라인을 할당할 수는 없다.

  • 제약조건: 배치에 있는 재고보다 많은 재고를 주문 라인에 할당할 수는 없다.
  • 불변 조건으로 바꾼다면? 가용 재고 수량이 0 이상이 되어야한다는 조건
    • 시스탬 상태를 업데이트할때마다 코드가 이 불변조건을 어기지 않는지 확인 필요

동시성을 도입할 경우, 더 복잡해지는데 이 경우 데이터베이스 테이블에 락을 적용해서 해결

  • 락을 사용하면 같은 테이블이나 같은 행에 대해 두 연산이 동시에 일어나는 경우 방지

 하지만 규모가 커질수록 전체 batches 테이블의 행마다 락을 추가하는 식으로는 해결할 수 없다 - 교착 상태가 되거나 성능에 문제가 발생할 가능성이 있음

7.3 애그리게이트

시스템의 불변 조건을 보호하면서 동시성을 최대한 살리려면?

  • 불변조건을 유지하려면 동시쓰기를 막아야하는데 여러 사용자가 DEADLY-SPOON을 동시에 할당할 수 있다면 과할당이 이루어짐
  • 반면 서로 다른 품목을 동시에 할당 못할 이유는 없는데, 두 제품에 동시에 적용되는 불변조건이 없어서 두 제품을 동시에 할당해도 안전하다. 따라서 서로 다른 두 제품에 대한 할당사이에 일관성이 있을 필요는 없다.

애그리게이트 패턴

  • 다른 도메인 객체들을 포함하여 이 객체 컬렉션 전체를 한꺼번에 다룰 수 있게 해주는 도메인 객체
  • 애그리게이트에 있는 객체를 변경하는 유일한 방법은 애그리게이트와 그 안의 객체 전체를 불러와 애그리게이트 자체에 대해 매서드를 호출하는 것
  • 누가 어떤 객체를 변경했는지 추적하기가 어려워지는데, 우리의 코드처럼 모델안에 컬렉션이 있으면 어떤 엔티티를 선정해서 해당 엔티티와 관련된 모든 객체를 변경할 수있는 단일 진입점으로 삼으면 좋다
  • 시스템이 더 개념적으로 간단해지고 어떤 객체가 다른 객체의 일관성을 책임지게 하면 시스템에 대해 추론하기가 쉬워짐
  • 애그리게이트는 데이터 변경이라는 목적을 위해 단위로 취급할 수 있는 연관된 객체의 묶음

쇼핑몰 예시

  • 장바구니: 액리게이트. 한 단위로 다뤄야하는 상품들로 이뤄진 컬렉션. 장바구니에 대한 모든 변경을 단일 데이터베이스 트랜잭션으로 실행하고 싶다. 하지만 여러 고객들의 장바구니를 한 트랜잭션에서 바꾸고 싶진 않다.
    • 각 장바구니는 자신만의 불변조건을 유지할 책임을 담당하는 한 동시성 경계

7.4 애그리게이트 선택

시스템에 어떤 애그리게이트를 사용해야할까?

  • 애그리게이트는 모든 연산이 일관성있는 상태에서 끝난다는 점을 보장하는 경계로, 소프트웨어에 대해 추론하고 이상한 경합을 방지할 수 있게 도와준다. 애그리게이트 내부에서 다뤄야하는 객체 - Batch. 배치의 컬렉션을 뭐라 하지? Shipment? Warehouse?
    • → 둘 다 아니다. 다른 두 상품을 동시에 할당할 수 있어야한다. 

주문 라인 할당시 주문라인으로 같은 SKU에 속하는 배치만 주의하면 된다.

도움 되는 개념: GlobalSkuStock - 주어진 SKU에 속한 모든 배치의 컬렉션

→ Product라고 부르자!

계획

  • 주문 라인 할당시 세계에 있는 모든 Batch 객체를 살펴보고 이들을 allocate() 도메인 서비스에 전달
class Product:
	def __init__(self, sku: str, batches: List[Batch]):
        self.sku = sku
        self.batches = batches
	
	def allocate(self, line: OrderLine) -> str:
        try:
            batch = next(
                    b for b in sorted(self.batches) if b.can_allocate(line)
            )
            batch.allocate(line)
            return batch.reference
        except StopIteration:
            raise OutOfStock(f'Out of stock for sku {line.sku}')
*애그리게이트, 제한된 콘텍스트, 마이크로서비스

제한된 콘텍스트

전체 비즈니스를 한 모델에 넣으려는 시도에 대한 반응 고객이란 단어는 판매, 고객서비스, 배송, 등의 분야의 사람들에게 다른 사람을 의미 한 콘텍스트에서 필수적 속성이 다른 콘텍스트에선 불필요하며, 콘텍스트가 달라지면 어떤 개념은 이름이 같아도 전혀 다른 의미가 되기도 한다. 여러 모델을 만들고 각 콘텍스트간 경계를 설정하고 여러 콘텍스트를 왔다갔다할때 명시적으로 변환을 처리하는 편이 낫다 대략적 규칙으로 도메인 모델은 계산을 수행하기 위해 필요한 데이터만 포함해야한다.

7.5 한 애그리게이트 = 한 저장소

애그리게이트가 될 엔티티는 외부 세계에서 접근할 수 있는 유일한 엔티티가 되어야한다.

→ 허용되는 모든 저장소는 애그리게이트만 반환해야한다.

BatchRepository → ProductRepository로 변경

class AbstractUnitOfWork(abc.ABC):
    products: repository.AbstractProductRepository
...

class AbstractProductRepository(abc.ABC):
    @abc.abstractmethod
    def add(self, product):
        ...

    @abc.abstractmethod
    def get(self, sku) -> model.Product:
        ...
# service layer
def add_batch(
    ref: str, sku: str, qty: int, eta: Optional[date],
    uow: unit_of_work.AbstractUnitOfWork,
):
    with uow:
        product = uow.products.get(sku=sku)
        if product is None:
            product = model.Product(sku, batches=[])
            uow.products.add(product)
        product.batches.append(model.Batch(ref, sku, qty, eta))
        uow.commit()


def allocate(
    orderid: str, sku: str, qty: int,
    uow: unit_of_work.AbstractUnitOfWork,
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        product = uow.products.get(sku=line.sku)
        if product is None:
            raise InvalidSku(f"Invalid sku {line.sku}")
        batchref = product.allocate(line)
        uow.commit()
    return batchref

7.6 성능은 어떨까?

배치를 하나만 요청해도 모든 배치를 읽어옴 → 비효율적으로 보이지만 괜찮은 이유

  1. 의도적으로 데이터베이스에서 읽기 질의를 한번만 하고 변경된 부분을 한번만 영속화하여 데이터를 모델링 중이다. 이런식으로 처리하는 시스템은 여러번 다양한 질의를 던지는 시스템보다 더 성능이 좋은 경향이 있다.
  2. 데이터 구조를 최소한으로 사용하여 한 행당 소수의 문자열과 정수만 만든다. 이 방식으로 몇 밀리초만에 몇십개에서 백여개의 배치를 메모리로 읽어올 수 있다.
  3. 어느 시점에서 상품마다 20개 정도의 배치만 있으리라 예상한다. 즉 시간이 지나도 가져오는 데이터의 양은 제어를 벗어나지 않는다는 뜻이다.

애그리게이트 패턴은 일관성과 성능을 중심으로 여러 기술적인 제약사항을 관리하는데 도움이 되도록 설계된 패턴으로 올바른 애그리게이트가 하나만 있는건 아니므로 설정한 경계가 성능을 저하시키는 것같으면 얼마든지 설계를 다시 하도록 해야한다.

7.7 버전 번호와 낙관적 동시성

데이터베이스 수준에서 데이터 일관성을 강제할 수 있는 방법에 대해 살펴보자

특정 SKU에 해당하는 행들에만 락을 걸 수 있을까?

Solution: Product 모델의 속성 하나를 사용해 전체 상태 변경 완료를 표시

  • 여러 동시성 작업자들이 이 속성을 획득하기 위해 경쟁하는 자원으로 활용하는 방법
  • → 두 트랜잭션이 batches에 대한 세계 상태를 동시에 읽고 둘 다 allocation 테이블을 업데이트하려는 상황: 이 때 트랜잭션이 product_table에 있는 version_number를 업데이트하도록 강제 -> 이럴 경우 경쟁하는 트랜잭션중 하나만 승리
낙관적 동시성 제어
- 두 사용자가 데이터베이스를 변경하고 싶을때 기본으로 모든 일이 잘 돌아가리라고 가정하는 낙관적 동시성 제어: 이번에 구현한건 여기에 해당
비관적 동시성 제어
- 두 사용자의 데이터베이스 변경이 충돌을 일으키기 쉽다고 가정 → 모든 경우 충돌을 피하려하고 안전성을 위해 모든 대상을 락을 사용해 잠근다.

비관적 잠금 사용시 데이터베이스가 충돌을 막아주기 때문에 실패 처리는 고민할 필요가 없고 교착상태에 빠지지 않느냐만 생각하면 된다. 낙관적 잠금의 경우엔 충돌이 발생해 실패시 어떻게 처리할지에 대해 명시해야 한다.
실패 처리의 일반적 방법은 실패 연산을 처음부터 재시도하는 것이다.

7.7.1 버전 번호를 구현하는 다양한 방법

  1. 도메인에 있는 version_number를 사용하는 방법: 이를 Product 생성자에 추가하고 Product.allocate()가 버전번호를 증
  2. 서비스계층이 할 수도 있다. 엄밀하게 말하자면 버전 번호는 도메인의 관심사가 아니므로 서비스 계층에서 Product 저장소를 통해 현재 버전 번호를 덧붙이고 서비스계층이 commit() 직전 버전 번호를 증가시키는걸 가정 → 상태를 변경할 책임을 서비스와 도메인 계층 사이에 나누므로 다소 지저분한 느낌이 든다.
  3. 버전번호는 인프라와 관련된 문제이므로 UoW와 저장소가 버전번호를 처리할 수도 있다. 저장소는 자신이 읽어온 모든 상품의 모든 버전 번호에 접근이 가능하고 UoW는 상품이 변경되었다는 가정하에 커밋시 자신이 아는 상품의 버전 번호를 증가할 수 있다. → 그닥 이상적이지는 않음
class Product:
    def __init__(self, sku: str, batches: List[Batch], version_number: int = 0):
        self.sku = sku
        self.batches = batches
        self.version_number = version_number

    def allocate(self, line: OrderLine) -> str:
        try:
            batch = next(b for b in sorted(self.batches) if b.can_allocate(line))
            batch.allocate(line)
            self.version_number += 1
            return batch.reference
        except StopIteration:
            raise OutOfStock(f"Out of stock for sku {line.sku}")

7.8 데이터 무결성 규칙 테스트

원하는 동작을 얻었는지 확인: 첫번째로 할당한 후 명시적으로 슬립해서 느린 트랜잭션을 시뮬레이션

# time.sleep 으로 동시성 행동방식 재현 가능
def try_to_allocate(orderid, sku, exceptions):
    line = model.OrderLine(orderid, sku, 10)
    try:
        with unit_of_work.SqlAlchemyUnitOfWork() as uow:
            product = uow.products.get(sku=sku)
            product.allocate(line)
            time.sleep(0.2)
            uow.commit()
    except Exception as e:
        print(traceback.format_exc())
        exceptions.append(e)


# 동시성 행동 방식에 대한 통합 테스트
def test_concurrent_updates_to_version_are_not_allowed(postgres_session_factory):
    sku, batch = random_sku(), random_batchref()
    session = postgres_session_factory()
    insert_batch(session, batch, sku, 100, eta=None, product_version=1)
    session.commit()

    order1, order2 = random_orderid(1), random_orderid(2)
    exceptions = []  # type: List[Exception]
    try_to_allocate_order1 = lambda: try_to_allocate(order1, sku, exceptions)
    try_to_allocate_order2 = lambda: try_to_allocate(order2, sku, exceptions)
    thread1 = threading.Thread(target=try_to_allocate_order1)
    thread2 = threading.Thread(target=try_to_allocate_order2)
    thread1.start()
    thread2.start()
    thread1.join()
    thread2.join()

    [[version]] = session.execute(
        "SELECT version_number FROM products WHERE sku=:sku",
        dict(sku=sku),
    )
    assert version == 2
    [exception] = exceptions
    assert "could not serialize access due to concurrent update" in str(exception)

    orders = session.execute(
        "SELECT orderid FROM allocations"
        " JOIN batches ON allocations.batch_id = batches.id"
        " JOIN order_lines ON allocations.orderline_id = order_lines.id"
        " WHERE order_lines.sku=:sku",
        dict(sku=sku),
    )
    assert orders.rowcount == 1
    with unit_of_work.SqlAlchemyUnitOfWork() as uow:
        uow.session.execute("select 1")

 

7.8.2 비관적 동시성 제어 예제: SELECT FOR UPDATE

SELECT FOR UPDATE: 락으로 사용할 행이나 행들을 선택하는 방법. 두 트랜잭션이 동시에 이 동작을 실행하면 둘 중 하나만 승리하고 나머지는 상대방이 락을 풀때까지 기다려야한다.

7.9 마치며

올바른 애그리게이트를 선택하는 것이 핵심

 

본 버넌이 작성한 효과적인 애그리게이트 설계에 대해 쓴 온라인 논문

 

Effective Aggregate Design by Vaughn Vernon

Effective Aggregate Design by Vaughn Vernon Posted on: 10-1-2011 Aggregates are one of the more challenging aspects of tactical modeling. Developers often end up with large clusters of objects that do not give good performance and scalability. In this thre

www.dddcommunity.org

 

더보기

Effective Aggregate Design Part I: Modeling a Single Aggregate 요약

 

"Effective Aggregate Design Part I: Modeling a Single Aggregate"는 Vaughn Vernon이 2011년에 발표한 논문입니다. 이 논문은 객체 지향 소프트웨어 설계에서 중요한 개념인 Aggregate(집합체)에 대한 설계 원칙과 모델링에 관한 것입니다.

논문의 핵심 내용은 다음과 같습니다:

1. **Aggregate의 개념**: 논문은 먼저 Aggregate의 개념을 소개합니다. Aggregate는 관련된 객체들의 집합으로, 하나의 루트 엔티티로 식별됩니다. 이러한 Aggregate는 엔티티 간의 일관성을 보장하고 일관된 경계를 갖도록 설계됩니다.

2. **Aggregate의 경계**: Aggregate는 일관된 상태를 유지하기 위해 경계를 갖습니다. 이 경계는 불변성을 보장하고 Aggregate 내부의 불변성을 유지하도록 합니다.

3. **집합체 구성원 간의 관계**: 논문은 Aggregate 내부의 엔티티와 값 객체 간의 관계를 다룹니다. 이 관계는 Aggregate 내부의 일관성과 불변성을 유지하는 데 중요합니다.

4. **Identity와 관계**: 논문은 Aggregate 내의 객체들 간의 Identity와 관계를 관리하는 방법을 다룹니다. 이는 데이터 일관성을 유지하고 Aggregate를 관리하기 위해 중요한 측면입니다.

5. **모델링의 원칙**: 논문은 객체 지향 설계에서 Aggregate를 모델링할 때 따라야 할 일반적인 원칙을 제안합니다. 이러한 원칙은 설계의 유연성과 확장성을 높이며, 복잡성을 줄이는 데 도움이 됩니다.

이러한 개념과 원칙은 객체 지향 소프트웨어 설계에서 중요한 역할을 합니다. 이 논문은 개발자들이 복잡한 도메인을 다루는 데 도움이 되는 구체적인 설계 지침을 제공합니다.

더보기

Effective Aggregate Design Part II: Making Aggregates Work Together 요약

 

"Effective Aggregate Design Part II: Making Aggregates Work Together"는 Vaughn Vernon이 2011년에 발표한 논문으로, 객체 지향 소프트웨어 설계에서 여러 Aggregate 간의 상호 작용에 대한 원칙과 모범 사례를 다룹니다.

이 논문의 주요 내용은 다음과 같습니다:

1. **Aggregates 간의 관계**: 논문은 여러 Aggregate 간의 관계를 다루며, 이러한 관계를 유지하고 관리하기 위한 방법을 제시합니다. 다양한 Aggregates 간의 관계를 정의하고 유지하는 것은 복잡한 도메인 모델링에서 중요합니다.

2. **Transactional boundaries(트랜잭션 경계)**: 논문은 Aggregate 내의 일관성과 불변성을 유지하기 위해 트랜잭션 경계를 어떻게 정의해야 하는지에 대해 다룹니다. 트랜잭션 경계를 명확히 정의하고 유지하는 것은 데이터 일관성을 보장하는 데 중요합니다.

3. **Command processing(명령 처리)**: 논문은 명령(Command)을 처리하는 방법과 이를 통해 Aggregate 간의 상호 작용을 관리하는 방법을 다룹니다. 명령은 Aggregate의 상태를 변경하고 일관성을 유지하는 데 사용됩니다.

4. **Consistency management(일관성 관리)**: 논문은 여러 Aggregate 간의 일관성을 관리하는 방법을 다룹니다. 이를 통해 전체 시스템의 데이터 일관성을 유지할 수 있습니다.

5. **Query handling(쿼리 처리)**: 논문은 쿼리를 처리하고 Aggregate 간의 관련 정보를 검색하는 방법을 다룹니다. 이는 사용자 인터페이스나 다른 시스템 컴포넌트와의 상호 작용에 필요합니다.

이러한 원칙과 모범 사례를 따르면 여러 Aggregate를 효과적으로 설계하고 관리할 수 있으며, 객체 지향 소프트웨어의 복잡성을 줄이고 유지보수성을 향상시킬 수 있습니다.

 

애그리게이트와 일관성 경계

  • 애그리게이트는 도메인 모델에 대한 진입점: 도메인에 속한 것을 바꿀수 있는 방식을 제한하면 시스템을 더 쉽게 추론할 수 있다
  • 애그리게이트는 일관성 경계를 책임진다.
    • 애그리게이트의 역할: 관련된 여러 객체 로 이루어진 그룹에 적용할 불변조건에 대한 비즈니스 규칙 관리 / 담당 객체 사이와 객체와 비즈니스 규칙 사이의 일관성 검사 / 일관성을 해치는 변경이 있을 경우 이를 거부
  • 애그리게이트와 동시성 문제는 공존: 동시성 검사 구현방법을 고민하다보면 결국 트랜잭션과 락에 도달하게 된다.
반응형
댓글