이 내용은 “파이썬으로 살펴보는 아키텍처 패턴” 을 읽고 작성한 내용입니다. 블로그 게시글과, 작성한 코드를 함께 보시면 더욱 좋습니다.
6장은 해당 코드를 살펴봐주세요. 코드 링크
작업 단위 패턴(UoW Pattern) 은 저장소와 서비스 계층 패턴을 하나로 묶어주는 것을 의미한다.
저장소 패턴이 영속적 저장소 개념에 대한 추상화라면 UoW 패턴은 원자적 연산(atomic operation) 개념에 대한 추상화를 의미한다. 이 패턴을 사용하면 서비스 계층과 데이터 계층의 분리가 가능하다.
이게…
이런 식의 UoW를 추가하여 DB의 상태를 관리하게 된다.
목표는 다음과 같다
이 패턴을 적용한 코드는 대충 이런 모습이다:
def allocate(
orderid: str, sku: str, qty: int,
uow: unit_of_work.AbstractUnitOfWork,
) -> str:
line = OrderLine(orderid, sku, qty)
with uow: #(1)
batches = uow.batches.list() #(2)
...
batchref = model.allocate(line, batches)
uow.commit() #(3)
contextmanager
로 UoW 시작uow.batches
는 배치 저장소다. 즉 UoW는 영속적 저장소에 대한 접근을 제공한다.(흠… uow 컨텍스트 매니저 끝에 try-except-finally 등으로 명시하는건 어떨까? → 라고 생각했다면 6.6장을 보십시오)
UoW는 영속적 저장소에 대한 단일 진입점으로 작용한다. UoW는 어떤 객체가 메모리에 적재되었으며 어떤 객체가 최종 상태인지 기억한다1.
이 방식의 장점은 아래와 같다:
UoW의 통합 테스트는 아래와 같다:
def test_uow_can_retrieve_a_batch_and_allocate_to_it(session_factory):
session = session_factory()
insert_batch(session, 'batch1', 'HIPSTER-WORKBENCH', 100, None)
session.commit()
uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) # (1)
with uow:
batch = uow.batches.get(reference='batch1') # (2)
line = model.OrderLine('o1', 'HIPSTER-WORKBENCH', 10)
batch.allocate(line)
uow.commit()
batchref = get_allocated_batch_ref(session, 'o1', 'HIPSTER-WORKBENCH')
assert batchref == 'batch1'
UoW는 “뭘 해야할지”를 테스트한다.
uow.batches
를 통해서 배치 저장소에 대해 접근한다commit()
을 호출한다insert_batch
나 get_allocated_batch_ref
는 헬퍼 함수다
contextmanager
테스트 코드에서는 UoW의 인터페이스가 뭘 해야될지 기재했다. 그렇다면 추상 클래스를 통해 인터페이스를 제공하자.
import abc
from pt1.ch06.adapters import repository
class AbstractUnitOfWork(abc.ABC):
batches: repository.AbstractRepository # (1)
def __aexit__(self, exc_type, exc_val, exc_tb): # (2)
self.rollback() # (4)
@abc.abstractmethod
async def commit(self): # (3)
raise NotImplementedError
@abc.abstractmethod
async def rollback(self): # (4)
raise NotImplementedError
with
구문을 쓸 수 있다.
__aenter__
나 __aexit__
은 비동기 처리를 위한 구문이다.class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
def __init__(self, session_factory):
self._session_factory = session_factory # (1)
async def __aenter__(self): # (2)
self._session = self._session_factory()
self.batches = repository.SqlAlchemyRepository(self.session)
return await super().__aenter__()
async def __aexit__(self, exc_type, exc_val, exc_tb):
await super().__aexit__(exc_type, exc_val, exc_tb)
await self._session.close() # (3)
async def commit(self): # (4)
await self._session.commit()
async def rollback(self): # (4)
await self._session.rollback()
__aenter__
는 DB세션 시작 및 저장소를 인스턴스화한다commit()
과 rollback()
을 제공한다class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork):
def __init__(self):
self.batches = FakeRepository([]) # (1)
self.committed = False # (2)
async def commit(self):
self.committed = True # (2)
async def rollback(self):
pass
...
@pytest.mark.asyncio
async def test_add_batch():
uow = FakeUnitOfWork() # (3)
await services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow)
assert await uow.batches.get("b1") is not None
assert uow.committed
@pytest.mark.asyncio
async def test_returns_allocation():
uow = FakeUnitOfWork() # (3)
repo = FakeRepository.for_batch("b1", "COMPLICATED-LAMP", 100, eta=None)
result = await services.allocate("o1", "COMPLICATED-LAMP", 10, uow)
assert result == "b1"
FakeSession
은 제 3자의 코드가 아니라 “내 코드” 를 가짜로 구현한 것이다. 이것은 큰 개선이다! ‘당신이 만든 것이 아니면 모킹하지 마라’ 하는 말에 부합하기 때문이다.세션보다 UoW를 모킹한게 편한 이유는 뭘까? 두가지 가짜(UoW, 세션)는 목적이 같다. 영속성 게층을 바꿔서 실제 DB를 안 쓰고 메모리 상에서 테스트할 수 있게 하는 것이다. 가짜 객체 두 개를 써서 얻을 수 있는 최종 설계에 차이가 있다.
예를들어 SQLAlchemy 대신 목 객체를 만들어서 Session을 코드 전반에 쓰면, DB 접근 코드가 코드베이스 여기저기에 흩어진다. 이런 상황을 피하기 위해 영속적 계층에 대한 접근을 제한해서 필요한 것”만” 가지게 한다.
코드를 Session 인터페이스와 결합하면 SQLAlchemy의 모든 복잡성과 결합하기로 하는 대신 더 간단한 추상화를 택하고 이를 통해 책임을 명확히 분리한다.
이 문단이 시사하는 바는 복잡한 하위 시스템 위에 간단한 추상화를 만들도록 해주는 기본 규칙이다. 간단한 추상화를 하면 성능상으로는 동일하나 내 설계가 맞는 방안인지 보다 신중하게 생각하도록 해준다.
이런식으로 리팩토링이 된다.
async def add_batch(
ref: str,
sku: str,
qty: int,
eta: Optional[date],
uow: unit_of_work.AbstractUnitOfWork # (1)
):
async with uow:
await uow.batches.add(model.Batch(ref, sku, qty, eta))
await uow.commit()
async def allocate(
orderid: str,
sku: str,
qty: int,
uow: unit_of_work.AbstractUnitOfWork # (1)
) -> str:
line = model.OrderLine(orderid, sku, qty)
async with uow:
batches = await uow.batches.list()
if not is_valid_sku(line.sku, batches):
raise InvalidSku(f'Invalid sku {line.sku}')
batchref = model.allocate(line, batches)
await uow.commit()
return
UoW를 구현해봤으니, 이러면 커밋/롤백을 테스트 하고싶어진다.
@pytest.mark.asyncio
async def test_rolls_back_uncommitted_work_by_default(session_factory):
uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)
async with uow:
insert_batch(uow._session, 'batch1', 'MEDIUM-PLINTH', 100, None)
new_session = session_factory()
rows = list(
await new_session.execute(text('SELECT * FROM batches'))
)
assert rows == []
@pytest.mark.asyncio
async def test_rolls_back_on_error(session_factory):
class MyException(Exception):
pass
uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)
with pytest.raises(MyException):
async with uow:
insert_batch(uow._session, 'batch1', 'MEDIUM-PLINTH', 100, None)
raise MyException()
new_session = session_factory()
rows = list(
await new_session.execute(text('SELECT * FROM batches'))
)
assert rows == []
아래 내용을 테스트한다!
Tip
트랜잭션 같은 ‘불확실한’ DB동작을 ‘실제’ DB엔진에 대해 테스트할 가치가 있다. Postgres같은 RDBMS로 바꾸고 나서 테스트하면 훨씬 편리할 것이다.
디폴트로 결과를 커밋하고 예외 발생 시에만 롤백하는 (처음 내 생각대로) 의 UoW는 __aexit__
에서 exn_type
이 None
일 때 커밋하는 것이다.
그런데 저자는 명시적 커밋이 낫다고 생각한다. 소프트웨어가 명령을 안 내리면 아무 것도 안 한다가 낫다라고 생각한다. 코드의 실행 상태를 추론하기도 보다 나아진다. 명시적이니까.
그리고 롤백하면 걍 마지막 지점으로 돌아가니까 중간 변화를 모두 포기한다. 로직 파악이 수월하다는 장점이 있다.
UoW를 통한 코드 추론이 쉬워지는 것을 살펴보자!
def reallocate(
line: OrderLine,
uow: AbstractUnitOfWork,
) -> str:
with uow:
batch = uow.batches.get(sku=line.sku)
if batch is None:
raise InvalidSku(f"invalid sku {line.sku}")
batch.deallocate(line) # (1)
allocate(line) # (2)
uow.commit()
deallocate()
이 실패하면 당연히 allocate()
이 안 돌기를 바란다allocate()
이 실패하면 deallocate()
한 결과만 커밋하고 싶지는 않을 것이다둘 다 제대로 작동하기를 바란다는 뜻
운송 중 문제가 생겨 제대로 배송이 안 되었다는 상황을 코드로 풀어보자
def change_batch_quantity(
batchref: str,
new_qty: int,
uow: AbstractUnitOfWork,
):
with uow:
batch = uow.batches.get(reference=batchref)
batch.change_purchased_quantity(new_qty)
while batch.available_quantity < 0:
line = batch.deallocate_one() # (1)
uow.commit()
integration
디렉토리 안을 보면 테스트 관련 코드가 3개 있다.
test_orm.py
test_repository.py
test_uow.py
UoW의 유용성과 contextmanager
를 통한 pythonic code 생성을 맛보았다.
근데 이미 사실 SQLAlchemy 내부적으로 Session 객체가 UoW대로 구현되어있다. SQLAlchemy의 세션객체는 DB에서 새 엔티티를 읽을 때마다 엔티티의 변화를 추적하고, 세션 flush
를 수행할 때 모든 내용을 한꺼번에 영속화한다.
근데 쓰는 이유가 있겠지? 여기까지의 트레이드오프를 살펴보자:
장점 | 단점 |
---|---|
원자적 연산을 표현하는 좋은 추상화 레벨을 가진다. contextmanager 를 사용해서 atomic하게 한 그룹으로 묶어야 하는 코드 블록을 시각적으로 쉽게 알아볼 수 있다. | ORM은 이미 원자성을 중심으로 좋은 추상화를 제공할 수도 있다. SQLAlchemy에는 이미 contextmanager를 제공한다. 세션을 주고받는 것 만으로도 많은 기능을 꽁으로 먹을 수 있다 |
트랜잭션 시작-끝 을 명시적으로 제어할 수 있고, 앱이 실패하면 롤백한다. 연산이 부분적으로 커밋되는 걱정을 덜어낼 수 있다 | 롤백, 다중스레딩, nested transactions등의 코드를 짤 때는 보다 더 신중하게 접근해야 한다. |
원자성은 트랜잭션 뿐 아니라 이벤트, 메시지 버스를 사용할 때도 도움이 된다. |
SQLAlchemy의 Session API는 풍부한 기능과 도메인에서 불필요한 연산을 제공한다. UoW는 세션을 단순화해 핵심부분만 쓸 수 있게 해준다. UoW를 시작하고, 커밋하거나 작업결과를 갖다버릴 수도 있다(thrown away).
UoW를 써서 Repository 객체에 접근하는건 그냥 SQLAlchemy Session 만 써선 쓸 수 없는 장점을 가진다.