Cosmic Python 제1부(3): Unit of Work와 Aggregate 패턴
커밋/롤백은 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_number | SELECT 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에는 과하다 |
| Repository | DB 독립적인 테스트, 저장소 교체 가능 | ORM 매핑 설정이 추가됨 |
| Service Layer | 유스케이스가 명확히 분리됨 | 추상화 레이어가 하나 더 생긴다 |
| Unit of Work | 트랜잭션이 깔끔하게 관리됨 | SQLAlchemy가 이미 제공하는 기능이기도 하다 |
| Aggregate | 동시성 환경에서 일관성 보장 | 개발자의 사고방식 전환이 필요하다 |
이 패턴들은 도메인이 복잡할수록 빛을 발한다. 단순한 CRUD 앱이라면 오히려 과한 투자일 수 있다. 복잡도에 따라 판단하자.
이전 글: 제1부(2): Service Layer와 TDD 전략
다음: 제2부: 이벤트 기반 아키텍처(Event-Driven Architecture)에서 이어진다.
참고 자료
