Post

Cosmic Python 제1부(3): Unit of Work와 Aggregate 패턴

커밋/롤백은 Unit of Work로 깔끔하게, 동시성 문제는 Aggregate로 일관성 경계를 잡자.

Cosmic Python 제1부(3): Unit of Work와 Aggregate 패턴

Chapter 6: Unit of Work 패턴

문제: 트랜잭션 관리가 지저분하다

서비스 레이어에서 session을 직접 다루면, 커밋/롤백 로직이 서비스마다 반복된다. 그리고 서비스 레이어가 session이라는 인프라 세부사항을 알고 있어야 한다.

해결: Unit of Work로 원자적 연산 추상화

Unit of Work는 “이 작업들은 전부 성공하거나, 전부 실패해야 한다”를 표현하는 패턴이다. Python의 컨텍스트 매니저(with 문)와 찰떡궁합이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AbstractUnitOfWork(abc.ABC):
    products: AbstractRepository

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.rollback()

    @abc.abstractmethod
    def commit(self):
        raise NotImplementedError

    @abc.abstractmethod
    def rollback(self):
        raise NotImplementedError

SQLAlchemy 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
    def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):
        self.session_factory = session_factory

    def __enter__(self):
        self.session = self.session_factory()
        self.products = SqlAlchemyRepository(self.session)
        return self

    def __exit__(self, *args):
        super().__exit__(*args)
        self.session.close()

    def commit(self):
        self.session.commit()

    def rollback(self):
        self.session.rollback()

서비스 레이어가 깔끔해진다

1
2
3
4
5
6
7
8
9
10
def allocate(orderid: str, sku: str, qty: int,
             uow: AbstractUnitOfWork) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        product = uow.products.get(sku=sku)
        if product is None:
            raise InvalidSku(f"Invalid sku {sku}")
        batchref = product.allocate(line)
        uow.commit()
        return batchref

session이 사라지고, uow 하나로 통합되었다.

  • with uow: — 트랜잭션 시작
  • uow.products — Repository 접근
  • uow.commit() — 명시적 커밋
  • 예외 발생 시 — 자동 롤백 (__exit__에서)

명시적 커밋, 암묵적 롤백. 성공적인 경로에서만 commit()이 호출되고, 그 외에는 모두 롤백된다.


Chapter 7: Aggregate와 일관성 경계

문제: 동시성 환경에서 일관성을 어떻게 보장할까?

두 명의 사용자가 동시에 같은 SKU의 재고를 할당하려고 한다면? 둘 다 “재고 10개 남음”을 읽고, 둘 다 할당에 성공하면 재고가 마이너스가 될 수 있다.

해결: Aggregate로 일관성 경계 설정

Aggregate는 데이터 변경의 단위로 취급하는 관련 객체들의 클러스터다. (Eric Evans)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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}")

핵심 원칙들을 정리하면 이렇다.

1. Aggregate가 유일한 진입점

Batch를 직접 수정하지 않는다. 반드시 Product를 통해서만 접근한다. 이렇게 하면 Product가 내부 일관성을 보장할 수 있다.

2. 하나의 Aggregate = 하나의 Repository

1
BatchRepository (X) → ProductRepository (O)

Repository는 Aggregate 단위로 만든다. Batch를 직접 꺼내오는 Repository는 없다.

3. 낙관적 동시성 제어 (Optimistic Concurrency)

version_number를 사용한다.

  • 할당할 때마다 version_number를 증가시킨다
  • 커밋 시점에 DB의 version_number와 비교한다
  • 다른 트랜잭션이 먼저 수정했으면 충돌 감지 → 재시도
1
2
3
4
5
6
7
8
# 비관적 잠금 방식도 가능
def get(self, sku):
    return (
        self.session.query(model.Product)
        .filter_by(sku=sku)
        .with_for_update()  # SELECT FOR UPDATE
        .first()
    )
구분낙관적 잠금비관적 잠금
전략충돌이 드물다고 가정충돌이 빈번하다고 가정
동작커밋 시점에 충돌 감지읽기 시점에 잠금 획득
성능경합이 적으면 좋다경합이 많으면 좋다
구현version_numberSELECT FOR UPDATE

Part 1의 큰 그림

Part 1의 모든 패턴이 어떻게 조합되는지 정리해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Flask / CLI (진입점)
    │
    ▼
Service Layer (유스케이스 오케스트레이션)
    │
    ├─ Unit of Work (트랜잭션 관리)
    │   │
    │   └─ Repository (영속성 추상화)
    │       │
    │       └─ Aggregate (일관성 경계)
    │           │
    │           └─ Domain Model (비즈니스 로직)
    │
    ▼
Database (인프라)

의존성 방향은 항상 안쪽으로 향한다.

  • Flask는 Service Layer를 안다
  • Service Layer는 UoW와 도메인 모델을 안다
  • 도메인 모델은 아무것도 모른다 (순수 파이썬)

이것이 양파(Onion) 아키텍처의 핵심이다. 도메인 모델이 중심에 있고, 인프라가 바깥을 감싼다.


Part 1 트레이드오프 총정리

패턴얻는 것잃는 것
Domain Model비즈니스 로직이 명확하게 표현됨단순 CRUD에는 과하다
RepositoryDB 독립적인 테스트, 저장소 교체 가능ORM 매핑 설정이 추가됨
Service Layer유스케이스가 명확히 분리됨추상화 레이어가 하나 더 생긴다
Unit of Work트랜잭션이 깔끔하게 관리됨SQLAlchemy가 이미 제공하는 기능이기도 하다
Aggregate동시성 환경에서 일관성 보장개발자의 사고방식 전환이 필요하다

이 패턴들은 도메인이 복잡할수록 빛을 발한다. 단순한 CRUD 앱이라면 오히려 과한 투자일 수 있다. 복잡도에 따라 판단하자.


이전 글: 제1부(2): Service Layer와 TDD 전략

다음: 제2부: 이벤트 기반 아키텍처(Event-Driven Architecture)에서 이어진다.


참고 자료

This post is licensed under CC BY 4.0 by the author.