[파이썬 아키텍처 패턴] 7장
애그리게이트와 일관성 경계
•
먼저, 불변 조건과 제약사항을 검토하여, 도메인 모델 객체가 개념적으로나 영속적 저장소에서 어떻게 내부적인 일관성을 유지하는지 알아봅니다.
•
일관성 경계에 대해 설명하며, 이 경계가 유지보수 편의성을 해치지 않으면서 고성능 소프트웨어를 만드는 데 어떻게 도움이 되는지 확인합니다.
스프레드시트로 모든 것을 처리하지 않는 이유
•
실제로 많은 비즈니스가 이메일로 스프레드시트를 주고받아 운영하는 경우가 많습니다.
•
그러나 이 방식은 로직을 적용하거나 일관성을 유지하는 데 어려움이 있어 확장하기 어렵습니다.
•
특정 필드를 볼 수 있는 권한이 있는 사람은 누구인지, 누가 해당 필드를 변경할 수 있는지 등의 질문은 시스템의 제약사항입니다.
•
일부 도메인 로직은 이러한 제약사항을 강제하여, 시스템이 불변 조건을 만족하도록 유지하는 것이 목표입니다.
•
불변 조건이란 어떤 연산을 마친 후에도 항상 참이어야 하는 조건을 의미합니다.
불변 조건, 제약, 일관성
•
'제약'과 '불변 조건'이라는 용어는 상호 치환이 가능하게 보입니다. 제약은 가능한 상태의 수를 제한하고, 불변 조건은 일정한 상태를 유지하는 것을 의미합니다.
•
예를 들어, 호텔 예약 시스템을 개발한다면, 중복 예약을 허용하지 않는 제약이 있을 수 있습니다. 이는 각 방에 예약이 하나만 있어야 한다는 불변 조건을 지키기 위함입니다.
•
특정 상황에서는 일시적으로 규칙을 완화해야 할 수도 있습니다. 예를 들어, VIP가 예약할 경우, VIP의 숙박 조건과 위치를 고려해 주변 방 예약을 조정해야 할 수도 있습니다.
•
예약을 조정하는 동안 메모리 상에서 한 방에 예약이 두 개 이상 발생할 수 있지만, 작업이 완료되면 도메인 모델은 모든 불변 조건을 만족하는 일관된 최종 상태를 보장해야 합니다.
•
만족스러운 결과를 찾지 못하고 연산이 완료되면 오류를 발생시켜야 합니다.
•
시스템 상태를 갱신할 때마다 코드는 불변 조건을 위반하지 않는지 확인해야 합니다.
•
'동시성' 개념의 도입은 상황을 더 복잡하게 만듭니다. 갑자기 재고를 여러 주문에 동시에 할당하거나, 배치를 변경하는 동안 주문을 할당할 수 있게 됩니다.
•
이러한 문제는 데이터베이스 테이블에 락을 적용하여 해결하게 됩니다. 락을 사용하면 동일한 테이블이나 행에 대해 두 연산이 동시에 발생하는 것을 방지할 수 있습니다.
•
그러나 락을 사용하면 교착 상태가 발생하거나 성능 문제가 생길 수 있습니다.
애그리게이트란 무엇인가?
•
애그리게이트 패턴은 도메인 주도 설계(DDD)의 핵심 개념으로, 관련된 도메인 객체들의 집합을 의미합니다.
◦
이 집합은 일관성 있는 상태를 유지하기 위해 함께 관리되어야 하는 객체들로 구성됩니다.
◦
애그리게이트는 내부 상태의 변경을 관리하고 외부에서 접근할 수 있는 유일한 진입점을 제공합니다.
•
애그리게이트 패턴은 다른 도메인 객체들을 포함하여, 이 객체들의 컬렉션을 한번에 처리할 수 있게 해주는 도메인 패턴입니다.
•
모델이 복잡해지고 엔티티와 값 객체가 늘어나면서, 각각의 참조가 복잡하게 얽혀 그래프가 형성됩니다.
•
이로 인해, 어떤 객체를 변경할 수 있는지 추적하기 어려워집니다.
•
특히, 모델 안에 컬렉션이 있을 경우, 어떤 엔티티를 선택하여 그와 관련된 모든 객체를 변경할 수 있는 단일 진입점을 설정하는 것이 좋습니다.
•
이렇게 하면 시스템이 개념적으로 더 단순해지고, 어떤 객체가 다른 객체의 일관성을 책임지게 함으로써 시스템에 대한 추론이 더 간편해집니다.
•
더 나아가 위에서 언급한 동시성 문제를 해결할 수 있습니다.
◦
애그리게이트는 관련된 객체들을 하나의 경계 안에서 관리합니다. 이 경계 내의 모든 변경 사항은 하나의 트랜잭션으로 처리되며, 이는 애그리게이트 루트를 통해서만 접근하고 수정할 수 있습니다. 이렇게 함으로써 애그리게이트 내의 불변 조건을 유지할 수 있습니다.
◦
이를 통해 데이터 무결성이 보장되며 여러 작업이 동시에 발생할 때 발생할 수 있는 충돌을 방지할 수 있습니다
◦
예를 들어, 호텔 예약 시스템에서 Room이 애그리게이트 루트라고 가정합시다. Room 애그리게이트는 해당 방의 예약 상태를 관리합니다. 이 경계 내에서 모든 예약 변경은 트랜잭션으로 처리되며, 불변 조건(예: 한 방에 하나의 예약만 존재해야 함)을 유지합니다.
◦
이는 아래 예제에서 더 자세히 다루겠습니다.
애그리게이트의 주요 구성 요소
1.
루트 엔티티 (Aggregate Root):
•
애그리게이트의 진입점이 되는 유일한 엔티티입니다.
•
외부에서 애그리게이트에 접근할 수 있는 유일한 방법은 이 루트 엔티티를 통해서입니다.
•
즉, 애그리게이트에 속한 객체를 변경하는 유일한 방법은 애그리게이트와 그 안의 객체 전체를 불러와서 애그리게이트 자체에 메서드를 호출하는 것입니다.
•
루트 엔티티는 애그리게이트 내의 다른 객체들의 생명 주기를 관리합니다.
2.
내부 엔티티 및 값 객체:
•
루트 엔티티 외에 애그리게이트 내부에 포함된 엔티티와 값 객체들이 있습니다.
•
이들은 루트 엔티티를 통해서만 접근하고 변경할 수 있습니다.
애그리게이트 선택 시 고려 사항
1.
일관성 경계:
•
시스템에서 어떤 애그리게이트를 선택할지는 매우 중요합니다. 애그리게이트는 모든 연산이 일관성 있는 상태에서 완료되도록 보장하는 경계를 제공합니다.
•
일관성을 유지해야 하는 객체들을 하나의 애그리게이트로 묶어야 합니다. 이를 통해 중요한 비즈니스 규칙을 일관성 있게 적용할 수 있습니다.
2.
성능:
•
위 일관성 경계는 소프트웨어에 대한 추론을 가능하게 하고, 이상한 경합 상황을 방지하는 데 도움이 됩니다. 더 나아가, 일관성이 필요한 소수의 객체 주변에 경계를 설정하려 합니다.
•
애그리게이트 경계가 너무 크면 성능에 영향을 미칠 수 있습니다. 필요한 최소한의 객체들만 포함하여 경계를 설정하는 것이 좋습니다.
◦
올바른 애그리게이트가 하나만 있는 것은 아니지만, 성능을 위해서는 경계가 작을수록 좋습니다.
◦
따라서 이 경계에 적절한 이름을 부여하는 것이 필요합니다.
3.
명확한 책임:
•
애그리게이트는 명확한 책임을 가져야 합니다. 애그리게이트 내의 객체들은 서로 강하게 결합되어 있어야 하며, 외부 객체와는 느슨하게 결합되어야 합니다.
하나의 애그리게이트 = 한 저장소
•
정의할 엔티티가 애그리게이트가 되면, 외부 세계에서 접근할 수 있는 유일한 엔티티가 되어야 한다는 규칙을 적용해야 합니다.
•
즉, 저장소가 반환할 수 있는 것은 애그리게이트만이어야 합니다.
주의:
저장소가 애그리게이트만 반환해야 한다는 규칙은
애그리게이트가 도메인 모델에 접근할 수 있는 유일한 경로가 되어야 한다는 관계를 강조하는 중요한 규칙입니다.
이 규칙을 어기지 않도록 주의해야 합니다.
예제 1 - 애그리게이트 루트, 내부 객체
아래 코드에서 애그리게이트 패턴은 Product와 Batch 객체를 통해 적용됩니다.
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: 명령과 쿼리를 분리하여 처리합니다. 명령은 상태 변경을 담당하고, 쿼리는 데이터 읽기를 담당합니다. 이로 인해 읽기 작업이 쓰기 작업과 분리되므로 동시성 문제가 줄어듭니다.
•
이벤트 소싱: 상태 변경을 이벤트로 기록하고, 이벤트 스트림을 재생하여 현재 상태를 구성합니다. 이는 모든 변경 사항을 불변의 이벤트로 저장하므로 충돌 감지 및 해결이 용이해집니다.
정리
애그리게이트와 일관성 경계
•
애그리게이트는 도메인 모델의 진입점입니다.
⇒ 도메인의 변경 가능한 방식을 제한하면 시스템의 추론이 더 쉬워집니다.
•
애그리게이트는 일관성 경계 문제를 책임집니다.
⇒ 애그리게이트의 역할은 연관된 여러 객체로 구성된 그룹에 적용할 불변 조건에 대한 비즈니스 규칙을 관리하는 것입니다.
⇒ 자신이 담당하는 객체들과 그들의 비즈니스 규칙 간의 일관성을 확인하고, 일관성을 해치는 변경을 거부하는 것도 애그리게이트의 역할입니다.
•
애그리게이트의 동시성 문제는 병렬로 존재합니다.
⇒ 동시성 검사의 구현 방법을 고려하다 보면 결국 트랜잭션과 락에 이르게 됩니다. 애그리게이트를 적절히 선택하는 것은 도메인을 개념적으로 잘 조직화하는 것뿐만 아니라 성능 문제도 고려하는 것입니다.