Search

[파이썬 아키텍처 패턴] 7장

[파이썬 아키텍처 패턴] 7장

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

먼저, 불변 조건과 제약사항을 검토하여, 도메인 모델 객체가 개념적으로나 영속적 저장소에서 어떻게 내부적인 일관성을 유지하는지 알아봅니다.
일관성 경계에 대해 설명하며, 이 경계가 유지보수 편의성을 해치지 않으면서 고성능 소프트웨어를 만드는 데 어떻게 도움이 되는지 확인합니다.

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

실제로 많은 비즈니스가 이메일로 스프레드시트를 주고받아 운영하는 경우가 많습니다.
그러나 이 방식은 로직을 적용하거나 일관성을 유지하는 데 어려움이 있어 확장하기 어렵습니다.
특정 필드를 볼 수 있는 권한이 있는 사람은 누구인지, 누가 해당 필드를 변경할 수 있는지 등의 질문은 시스템의 제약사항입니다.
일부 도메인 로직은 이러한 제약사항을 강제하여, 시스템이 불변 조건을 만족하도록 유지하는 것이 목표입니다.
불변 조건이란 어떤 연산을 마친 후에도 항상 참이어야 하는 조건을 의미합니다.

불변 조건, 제약, 일관성

'제약'과 '불변 조건'이라는 용어는 상호 치환이 가능하게 보입니다. 제약은 가능한 상태의 수를 제한하고, 불변 조건은 일정한 상태를 유지하는 것을 의미합니다.
예를 들어, 호텔 예약 시스템을 개발한다면, 중복 예약을 허용하지 않는 제약이 있을 수 있습니다. 이는 각 방에 예약이 하나만 있어야 한다는 불변 조건을 지키기 위함입니다.
특정 상황에서는 일시적으로 규칙을 완화해야 할 수도 있습니다. 예를 들어, VIP가 예약할 경우, VIP의 숙박 조건과 위치를 고려해 주변 방 예약을 조정해야 할 수도 있습니다.
예약을 조정하는 동안 메모리 상에서 한 방에 예약이 두 개 이상 발생할 수 있지만, 작업이 완료되면 도메인 모델은 모든 불변 조건을 만족하는 일관된 최종 상태를 보장해야 합니다.
만족스러운 결과를 찾지 못하고 연산이 완료되면 오류를 발생시켜야 합니다.
시스템 상태를 갱신할 때마다 코드는 불변 조건을 위반하지 않는지 확인해야 합니다.
'동시성' 개념의 도입은 상황을 더 복잡하게 만듭니다. 갑자기 재고를 여러 주문에 동시에 할당하거나, 배치를 변경하는 동안 주문을 할당할 수 있게 됩니다.
이러한 문제는 데이터베이스 테이블에 락을 적용하여 해결하게 됩니다. 락을 사용하면 동일한 테이블이나 행에 대해 두 연산이 동시에 발생하는 것을 방지할 수 있습니다.
그러나 락을 사용하면 교착 상태가 발생하거나 성능 문제가 생길 수 있습니다.

애그리게이트란 무엇인가?

애그리게이트 패턴은 도메인 주도 설계(DDD)의 핵심 개념으로, 관련된 도메인 객체들의 집합을 의미합니다.
이 집합은 일관성 있는 상태를 유지하기 위해 함께 관리되어야 하는 객체들로 구성됩니다.
애그리게이트는 내부 상태의 변경을 관리하고 외부에서 접근할 수 있는 유일한 진입점을 제공합니다.
애그리게이트 패턴은 다른 도메인 객체들을 포함하여, 이 객체들의 컬렉션을 한번에 처리할 수 있게 해주는 도메인 패턴입니다.
모델이 복잡해지고 엔티티와 값 객체가 늘어나면서, 각각의 참조가 복잡하게 얽혀 그래프가 형성됩니다.
이로 인해, 어떤 객체를 변경할 수 있는지 추적하기 어려워집니다.
특히, 모델 안에 컬렉션이 있을 경우, 어떤 엔티티를 선택하여 그와 관련된 모든 객체를 변경할 수 있는 단일 진입점을 설정하는 것이 좋습니다.
이렇게 하면 시스템이 개념적으로 더 단순해지고, 어떤 객체가 다른 객체의 일관성을 책임지게 함으로써 시스템에 대한 추론이 더 간편해집니다.
더 나아가 위에서 언급한 동시성 문제를 해결할 수 있습니다.
애그리게이트는 관련된 객체들을 하나의 경계 안에서 관리합니다. 이 경계 내의 모든 변경 사항은 하나의 트랜잭션으로 처리되며, 이는 애그리게이트 루트를 통해서만 접근하고 수정할 수 있습니다. 이렇게 함으로써 애그리게이트 내의 불변 조건을 유지할 수 있습니다.
이를 통해 데이터 무결성이 보장되며 여러 작업이 동시에 발생할 때 발생할 수 있는 충돌을 방지할 수 있습니다
예를 들어, 호텔 예약 시스템에서 Room이 애그리게이트 루트라고 가정합시다. Room 애그리게이트는 해당 방의 예약 상태를 관리합니다. 이 경계 내에서 모든 예약 변경은 트랜잭션으로 처리되며, 불변 조건(예: 한 방에 하나의 예약만 존재해야 함)을 유지합니다.
이는 아래 예제에서 더 자세히 다루겠습니다.

애그리게이트의 주요 구성 요소

1.
루트 엔티티 (Aggregate Root):
애그리게이트의 진입점이 되는 유일한 엔티티입니다.
외부에서 애그리게이트에 접근할 수 있는 유일한 방법은 이 루트 엔티티를 통해서입니다.
즉, 애그리게이트에 속한 객체를 변경하는 유일한 방법은 애그리게이트와 그 안의 객체 전체를 불러와서 애그리게이트 자체에 메서드를 호출하는 것입니다.
루트 엔티티는 애그리게이트 내의 다른 객체들의 생명 주기를 관리합니다.
2.
내부 엔티티 및 값 객체:
루트 엔티티 외에 애그리게이트 내부에 포함된 엔티티와 값 객체들이 있습니다.
이들은 루트 엔티티를 통해서만 접근하고 변경할 수 있습니다.

애그리게이트 선택 시 고려 사항

1.
일관성 경계:
시스템에서 어떤 애그리게이트를 선택할지는 매우 중요합니다. 애그리게이트는 모든 연산이 일관성 있는 상태에서 완료되도록 보장하는 경계를 제공합니다.
일관성을 유지해야 하는 객체들을 하나의 애그리게이트로 묶어야 합니다. 이를 통해 중요한 비즈니스 규칙을 일관성 있게 적용할 수 있습니다.
2.
성능:
위 일관성 경계는 소프트웨어에 대한 추론을 가능하게 하고, 이상한 경합 상황을 방지하는 데 도움이 됩니다. 더 나아가, 일관성이 필요한 소수의 객체 주변에 경계를 설정하려 합니다.
애그리게이트 경계가 너무 크면 성능에 영향을 미칠 수 있습니다. 필요한 최소한의 객체들만 포함하여 경계를 설정하는 것이 좋습니다.
올바른 애그리게이트가 하나만 있는 것은 아니지만, 성능을 위해서는 경계가 작을수록 좋습니다.
따라서 이 경계에 적절한 이름을 부여하는 것이 필요합니다.
3.
명확한 책임:
애그리게이트는 명확한 책임을 가져야 합니다. 애그리게이트 내의 객체들은 서로 강하게 결합되어 있어야 하며, 외부 객체와는 느슨하게 결합되어야 합니다.

하나의 애그리게이트 = 한 저장소

정의할 엔티티가 애그리게이트가 되면, 외부 세계에서 접근할 수 있는 유일한 엔티티가 되어야 한다는 규칙을 적용해야 합니다.
즉, 저장소가 반환할 수 있는 것은 애그리게이트만이어야 합니다.
주의: 저장소가 애그리게이트만 반환해야 한다는 규칙은 애그리게이트가 도메인 모델에 접근할 수 있는 유일한 경로가 되어야 한다는 관계를 강조하는 중요한 규칙입니다. 이 규칙을 어기지 않도록 주의해야 합니다.

예제 1 - 애그리게이트 루트, 내부 객체

아래 코드에서 애그리게이트 패턴은 ProductBatch 객체를 통해 적용됩니다.
1.
애그리게이트 루트:
Product 객체가 애그리게이트 루트입니다.
Product는 여러 Batch 객체를 포함하고 있으며, 외부에서 접근할 수 있는 유일한 진입점입니다.
UnitOfWork 패턴을 사용하여 트랜잭션 경계를 설정하고, Product 애그리게이트를 통해 배치를 추가하거나 할당함으로써 일관성을 유지합니다.
2.
애그리게이트 내부 객체:
Batch 객체는 Product 애그리게이트 내의 내부 객체입니다.
Product를 통해서만 접근하고 조작할 수 있습니다.
모든 배치 관련 작업은 Product 객체를 통해 수행됩니다.
Product 애그리게이트 루트를 통해 내부 Batch 객체들을 캡슐화하여 외부에서 직접 접근하지 못하도록 합니다.
def allocate( orderid: str, sku: str, qty: int, uow: unit_of_work.AbstractUnitOfWork, ) -> str: line = OrderLine(orderid, sku, qty) with uow: batches = uow.batches.list() if not is_valid_sku(line.sku, batches): raise InvalidSku(f"Invalid sku {line.sku}") batchref = model.allocate(line, batches) uow.commit() return batchref
Python
복사
Product 객체가 애그리게이트 루트로서, Batch 객체들을 관리합니다.
새로운 배치를 추가할 때, Product 객체를 통해서만 추가하도록 하여 일관성을 유지합니다.
UnitOfWork(단위 작업) 패턴을 사용하여 트랜잭션 경계를 설정하고, 모든 변경 사항을 하나의 트랜잭션으로 처리합니다.
def add_batch( ref: str, sku: str, qty: int, eta: Optional[date], uow: unit_of_work.AbstractUnitOfWork, ): with uow: product = uow.product.get(sku=sku) if not product: product = model.Product(sku=sku, batches=[]) uow.products.append(model.Batch(ref, sku, qty, eta)) product.batches.append(model.Batch(ref, sku, qty, eta)) uow.batches.add(model.Batch(ref, sku, qty, eta)) uow.commit()
Python
복사
Product 애그리게이트를 통해 Batch 객체들을 관리하면서, 주문 할당 로직을 실행합니다.
UnitOfWork 패턴을 사용하여 배치 목록을 가져오고, 주문을 적절한 배치에 할당합니다.
SKU 유효성 검사를 통해 시스템의 일관성을 유지합니다. SKU가 유효하지 않으면 예외를 발생시킵니다.

예제 2- 애그리게이트 경계를 통한 일관성 유지 및 동시성 문제를 해결하는 방법

위에서 언급한대로 애그리게이트는 경계 내의 모든 변경 사항은 하나의 트랜잭션으로 처리되며, 애그리게이트 루트를 통해서만 접근하고 수정할 수 있기에, 애그리게이트 내의 불변 조건을 유지할 수 있다고 했습니다.
예를 들어, 호텔 예약 시스템에서 Room이 애그리게이트 루트라고 가정합시다.
Room 애그리게이트는 해당 방의 예약 상태를 관리합니다.
이 경계 내에서 모든 예약 변경은 트랜잭션으로 처리되며, 불변 조건(예: 한 방에 하나의 예약만 존재해야 함)을 유지합니다.
아래 코드에서 애그리게이트는 트랜잭션 경계를 형성하여 동시에 발생하는 변경 작업을 하나의 트랜잭션으로 묶습니다.
이로 인해 한 애그리게이트 내에서 발생하는 동시성 문제가 자연스럽게 해결됩니다. 트랜잭션 경계를 사용하여 불변 조건이 항상 만족되도록 보장합니다.
def add_booking(room_id: str, booking_details: BookingDetails, uow: unit_of_work.AbstractUnitOfWork): with uow: room = uow.rooms.get(room_id=room_id) if not room: raise RoomNotFound(f"Room with id {room_id} not found") room.add_booking(booking_details) uow.commit()
Python
복사
add_booking 함수는 room_id로 방을 가져오고, add_booking 메서드를 통해 예약을 추가합니다.
이 모든 작업은 UnitOfWork를 통해 트랜잭션으로 처리되며, commit 메서드가 호출될 때까지 변경 사항이 확정되지 않습니다. 이렇게 하면 동시에 발생하는 다른 작업이 같은 방에 영향을 주지 않도록 보장할 수 있습니다.

애그리게이트 패턴 with 잠금 메커니즘

애그리게이트 패턴을 사용할 때 잠금 메커니즘을 도입하여 동시성 문제를 해결할 수 있습니다.
잠금은 데이터베이스 수준에서 적용될 수 있으며, 특정 애그리게이트 루트에 대한 접근을 제어합니다.
1.
낙관적 잠금 (Optimistic Locking)
a.
간단 설명
i.
낙관적 잠금은 데이터에 대한 충돌이 드물 것이라는 가정하에 동작
ii.
데이터의 상태를 갱신할 때 충돌을 감지하고, 충돌이 발생한 경우 다시 시도
iii.
일반적으로 데이터를 읽을 때 잠금을 걸지 않으며, 데이터를 갱신할 때 충돌이 발생하는지 확인
iv.
객체가 업데이트될 때마다 버전 번호를 증가시킵니다.
v.
업데이트 시 현재 버전 번호와 저장된 버전 번호를 비교하여 다르면 충돌이 발생한 것으로 간주하고, 재시도 또는 오류를 발생시킵니다.
b.
낙관적 잠금은 일반적으로 읽기 작업에 대해 높은 성능을 유지하기에 유리하지만, 충돌이 발생할 수 있습니다.
c.
아래는 호텔 예약 시스템 예제에 낙관적 잠금을 적용한 예시입니다. (version 필드를 사용하여 데이터 변경 충돌을 감지)
from sqlalchemy.exc import StaleDataError class Room(Base): __tablename__ = 'rooms' room_id = Column(String, primary_key=True) version = Column(Integer, nullable=False, default=0) bookings = relationship("Booking", back_populates="room") def add_booking(self, booking_details: BookingDetails): # 예약 추가 로직 self.bookings.append(Booking(**booking_details)) self.version += 1 # 버전 번호 증가 def add_booking(room_id: str, booking_details: BookingDetails, uow: unit_of_work.AbstractUnitOfWork): with uow: room = uow.rooms.get(room_id=room_id) if not room: raise RoomNotFound(f"Room with id {room_id} not found") room.add_booking(booking_details) try: uow.commit() except StaleDataError: raise ConcurrentUpdateError(f"Room {room_id} was updated concurrently, please retry")
Python
복사
2.
비관적 잠금 (Pessimistic Locking)
a.
객체를 읽거나 쓸 때 잠금을 설정하여 다른 트랜잭션이 접근하지 못하도록 합니다.
b.
잠금을 해제할 때까지 다른 트랜잭션은 대기합니다.
c.
비관적 잠금은 충돌을 피할 수 있지만 잠금으로 인해 교착 상태나 성능 저하가 발생할 수 있습니다.
d.
참고로, 레디스의 키를 이용한 분산 락 개념, MySQL의 네임드 락(named lock)은 비관적 락에 해당합니다.
e.
아래는 Product 객체를 다룬 애그리게이트 예제에 비관적 잠금을 적용한 예시입니다
i.
비관적 잠금을 위해 SQLAlchemy의 with_for_update 메서드를 사용합니다.
1.
락을 사용할 행 또는 행들을 선택하는 방법
2.
두 개의 트랜잭션이 동시에 SELECT FOR UPDATE를 실행하면, 둘 중 하나만 성공하고 나머지는 상대방이 락을 해제할 때까지 기다려야 합니다.
참고 Repeatable Read는 트랜잭션이 시작된 시점 이후에 다른 트랜잭션이 변경한 데이터를 읽을 수 없도록 보장하는 격리 수준 (읽기 수준 격리) 트랜잭션 동안 읽은 모든 데이터에 대해 락을 설정하여 비관적 락을 사용합니다. 이는 다른 트랜잭션이 해당 데이터에 접근하지 못하도록 하여 일관성을 유지합니다. 광범위한 락 설정으로 인해 데드락이 발생할 가능성이 높습니다
반면에 Select for Update는 특정 레코드에 대한 비관적 락을 설정합니다. 필요한 레코드에 대해서만 락을 설정하기 때문에 보다 높은 동시성을 유지할 수 있습니다. 그러나 트랜잭션이 완료되기 전까지는 해당 레코드에 대해 다른 트랜잭션이 접근하지 못하므로 일부 동시성 문제가 있을 수 있습니다. 동일한 레코드를 동시에 접근하려고 할 때 데드락이 발생할 수 있습니다.
ii.
Product 객체가 잠금 상태로 읽혀지고, 다른 트랜잭션이 해당 객체에 접근하려면 잠금이 해제될 때까지 대기
from sqlalchemy.orm import Session from sqlalchemy.orm import with_for_update def allocate(orderid: str, sku: str, qty: int, uow: unit_of_work.AbstractUnitOfWork) -> str: line = OrderLine(orderid, sku, qty) with uow: product = uow.session.query(Product).filter(Product.sku == sku).with_for_update().one_or_none() if not product: raise InvalidSku(f"Invalid sku {line.sku}") batchref = product.allocate(line) uow.commit() return batchref
Python
복사
비교 항목
낙관적 잠금 (Optimistic Locking)
비관적 잠금 (Pessimistic Locking)
충돌 발생 빈도
낮을 것으로 가정
높을 것으로 가정
충돌 감지 시점
갱신 시점
데이터 읽기 시점
잠금 범위
없음
데이터 읽기 시점부터 잠금 해제 시점까지
성능
읽기 성능 높음, 충돌 시 갱신 성능 저하
충돌 없음, 잠금에 따른 성능 저하 가능성
교착 상태 발생 가능성
없음
있음
사용 사례
충돌이 드문 경우
충돌이 빈번한 경우
추가적으로, CQRS(Command Query Responsibility Segregation) 패턴과 이벤트 소싱(Event Sourcing) 패턴을 도입하여 동시성 문제를 더욱 효율적으로 관리할 수 있습니다.
CQRS: 명령과 쿼리를 분리하여 처리합니다. 명령은 상태 변경을 담당하고, 쿼리는 데이터 읽기를 담당합니다. 이로 인해 읽기 작업이 쓰기 작업과 분리되므로 동시성 문제가 줄어듭니다.
이벤트 소싱: 상태 변경을 이벤트로 기록하고, 이벤트 스트림을 재생하여 현재 상태를 구성합니다. 이는 모든 변경 사항을 불변의 이벤트로 저장하므로 충돌 감지 및 해결이 용이해집니다.

정리

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

애그리게이트는 도메인 모델의 진입점입니다.
⇒ 도메인의 변경 가능한 방식을 제한하면 시스템의 추론이 더 쉬워집니다.
애그리게이트는 일관성 경계 문제를 책임집니다.
⇒ 애그리게이트의 역할은 연관된 여러 객체로 구성된 그룹에 적용할 불변 조건에 대한 비즈니스 규칙을 관리하는 것입니다.
⇒ 자신이 담당하는 객체들과 그들의 비즈니스 규칙 간의 일관성을 확인하고, 일관성을 해치는 변경을 거부하는 것도 애그리게이트의 역할입니다.
애그리게이트의 동시성 문제는 병렬로 존재합니다.
⇒ 동시성 검사의 구현 방법을 고려하다 보면 결국 트랜잭션과 락에 이르게 됩니다. 애그리게이트를 적절히 선택하는 것은 도메인을 개념적으로 잘 조직화하는 것뿐만 아니라 성능 문제도 고려하는 것입니다.