작업 단위 패턴(Unit of Work)
이번 장에서 작업 단위 패턴을 이용해 서비스 계층과 데이터 계층을 완전히 분리해본다.
기존에는 플라스크 앱이 직접 데이터베이스에 요청해 세션을 시작하고, 저장소 계층과 대화하여 SQLAlchemyRepository 를 초기화하며 서비스 계층에 할당을 요청한다.
Unit of Work(UoW)는 데이터베이스 작업을 하나의 논리적인 단위로 묶어서 처리하는 디자인 패턴입니다.
이 패턴은 데이터베이스에 대한 접근을 중앙에서 관리하고, 트랜잭션의 시작과 끝을 명확하게 하여 데이터 일관성을 유지합니다. 파이썬에서는 콘텍스트 매니저를 사용하여 UoW를 쉽게 구현할 수 있습니다.
1. UoW 클래스 정의
UoW 클래스는 데이터베이스 세션을 관리하고, 커밋 및 롤백 작업을 처리합니다. 또한, 배치 저장소에 대한 접근을 제공합니다.
class UnitOfWork:
def __init__(self, session_factory):
self.session_factory = session_factory
self.session = None
def __enter__(self):
self.session = self.session_factory()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
self.rollback()
else:
self.commit()
self.session.close()
def commit(self):
self.session.commit()
def rollback(self):
self.session.rollback()
@property
def batches(self):
# 배치 저장소를 반환하는 메서드
pass
Python
복사
약간 fastapi 내에 존재하는 sqlalchemy session di와 유사하게 생겼다.
2. 배치 저장소 정의
배치 저장소는 데이터베이스와의 인터페이스 역할을 합니다.
배치 데이터를 데이터베이스에서 추가하거나 조회하는 기능을 제공합니다.
class BatchRepository:
def __init__(self, session):
self.session = session
def add(self, batch):
self.session.add(batch)
def get(self, batch_id):
return self.session.query(Batch).filter_by(id=batch_id).one()
Python
복사
3. 도우미 함수 정의
테스트를 쉽게 하기 위해 도우미 함수를 정의합니다.
테스트용 데이터 삽입 및 조회를 쉽게 하기 위해 사용합니다.
def insert_batch(session, ref, sku, qty, eta):
session.execute(
'INSERT INTO batches (reference, sku, qty, eta) VALUES (:ref, :sku, :qty, :eta)',
dict(ref=ref, sku=sku, qty=qty, eta=eta)
)
def get_allocated_batch_ref(session, orderid, sku):
result = session.execute(
'SELECT b.reference FROM allocations a JOIN batches b ON a.batch_id = b.id JOIN order_lines l ON a.orderline_id = l.id WHERE l.orderid = :orderid AND l.sku = :sku',
dict(orderid=orderid, sku=sku)
)
return result.fetchone()[0]
Python
복사
4. UoW 통합 테스트
통합 테스트는 UoW를 실제로 사용해보는 테스트입니다.
def test_uow_can_retrieve_a_batch_and_allocate_to_it(sqlite_session_factory):
session = sqlite_session_factory()
insert_batch(session, 'batch1', 'sku1', 100, None)
session.commit()
uow = UnitOfWork(session_factory=sqlite_session_factory)
with uow:
batch = uow.batches.get('batch1')
batch.allocate(OrderLine('order1', 'sku1', 10))
uow.commit()
batch_ref = get_allocated_batch_ref(session, 'order1', 'sku1')
assert batch_ref == 'batch1'
Python
복사
UoW 가 있는 경우 플라스크 API 는 작업 단위를 초기화하고, 서비스를 호출하는 일만 하게 된다. 파이썬에서 콘텍스트 매니저 기능을 통해 UoW 를 구현하여 이러한 것들이 가능하도록 한다.
UoW 가 동작하는 모습을 미리 보면 아래와 같다.
def add_batch(
ref: str, sku: str, qty: int, eta: Optional[date],
uow: unit_of_work.AbstractUnitOfWork,
):
with uow:
uow.batches.add(model.Batch(ref, sku, qty, eta))
uow.commit()
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
복사
•
UoW 가 with 문과 함께 시작한다. 콘텍스트 관리자로 정의했기 때문이다.
•
uow.batches 는 배치 저장소다. UoW가 데이터베이스에 대한 접근을 가능하게 한다.
•
작업이 완료되면 UoW 를 통해 커밋 혹은 롤백한다.
UoW 는 디비에 대한 단일 진입점으로 작용한다.
또한 어떤 객체가 메모리에 적재됐고 어떤 객체가 최종 상태인지를 기억한다.
UoW 에 대한 통합 테스트는 아래와 같이 할 수 있다.
import pytest
from allocation.domain import model
from allocation.service_layer import unit_of_work
def insert_batch(session, ref, sku, qty, eta):
session.execute(
"INSERT INTO batches (reference, sku, _purchased_quantity, eta)"
" VALUES (:ref, :sku, :qty, :eta)",
dict(ref=ref, sku=sku, qty=qty, eta=eta),
)
def get_allocated_batch_ref(session, orderid, sku):
[[orderlineid]] = session.execute(
"SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku",
dict(orderid=orderid, sku=sku),
)
[[batchref]] = session.execute(
"SELECT b.reference FROM allocations JOIN batches AS b ON batch_id = b.id"
" WHERE orderline_id=:orderlineid",
dict(orderlineid=orderlineid),
)
return batchref
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)
with uow:
batch = uow.batches.get(reference="batch1")
line = model.OrderLine("o1", "HIPSTER-WORKBENCH", 10)
batch.allocate(line)
uow.commit()
batchref = get_allocated_batch_ref(session, "o1", "HIPSTER-WORKBENCH")
assert batchref == "batch1"
Python
복사
UoW 를 테스트할 때 필요한 도우미 함수 insert_batch 와 get_allocated_batch_ref 를 따로 정의하였다.
•
커스텀 세션 팩토리를 사용해 UoW 를 초기화하고 블록 안에서 사용할 uow 객체를 얻는다.
•
uow.batches 를 통해 배치 저장소에 대한 접근을 제공한다.
•
작업이 끝나면 commit 한다.
그러면 이러한 일이 가능하게 하는 UoW 를 추상 클래스로 인터페이스를 명시해본다.
from typing import TypeVar, Generic
T = TypeVar('T', bound=AbstractRepository)
class AbstractUnitOfWork(abc.ABC):
batches: repository.AbstractRepository
def __enter__(self) -> AbstractUnitOfWork:
return self
def __exit__(self, *args):
self.rollback()
@abc.abstractmethod
def commit(self):
raise NotImplementedError
@abc.abstractmethod
def rollback(self):
raise NotImplementedError
@property
def batches(self) -> T:
return self._batches
Python
복사
•
UoW 는 .batches 라는 속성을 제공한다.
•
__enter__ , __exit__ 는 with 블록에 들어갈 때와 나올 때 호출되는 매직 매서드다.
•
커밋을 하지 않거나 예외를 발생시켜서 콘텍스트 관리자를 빠져나가면 rollback 을 수행한다.
•
commit() 이 이미 호출된 경우에는 롤백을 해도 아무 일이 발생하지 않는다.
위의 조건들을 만족하는 SQLAlchemy 세션을 사용하는 UoW 를 정의하면 아래와 같다.
DEFAULT_SESSION_FACTORY = sessionmaker(
bind=create_engine(
config.get_postgres_uri(),
)
)
class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):
self.session_factory = session_factory
def __enter__(self):
self.session = self.session_factory()# type: Session
self.batches = repository.SqlAlchemyRepository(self.session)
return super().__enter__()
def __exit__(self, *args):
super().__exit__(*args)
self.session.close()
def commit(self):
self.session.commit()
def rollback(self):
self.session.rollback()
Python
복사
•
이 모듈은 Postgres와 연결하는 default session factory 를 정의한다.
•
__enter__ 메서드는 디비 세션을 시작하고 세션을 사용할 실제 저장소를 인스턴스화한다.
•
콘텍스트 관리자에서 나올 때 세션을 닫는다.
•
구체적인 commit() 과 rollback() 메서드를 제공한다.
이제 구현된 UoW 를 기반으로 서비스 계층 테스트에서 가짜 UoW 를 사용할 수 있다.
class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork):
def __init__(self):
self.batches = FakeRepository([])
self.committed = False
def commit(self):
self.committed = True
def rollback(self):
pass
def test_add_batch():
uow = FakeUnitOfWork()
services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow)
assert uow.batches.get("b1") is not None
assert uow.committed
def test_allocate_returns_allocation():
uow = FakeUnitOfWork()
services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, uow)
result = services.allocate("o1", "COMPLICATED-LAMP", 10, uow)
assert result == "batch1"
Python
복사
커밋과 롤백이 제대로 작동하는지 확인하기 위한 테스트를 작성해 볼수도 있다.
def test_rolls_back_uncommitted_work_by_default(session_factory):
uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)
with uow:
insert_batch(uow.session, "batch1", "MEDIUM-PLINTH", 100, None)
new_session = session_factory()
rows = list(new_session.execute('SELECT * FROM "batches"'))
assert rows == []
def test_rolls_back_on_error(session_factory):
class MyException(Exception):
pass
uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)
with pytest.raises(MyException):
with uow:
insert_batch(uow.session, "batch1", "LARGE-FORK", 100, None)
raise MyException()
new_session = session_factory()
rows = list(new_session.execute('SELECT * FROM "batches"'))
assert rows == []
Python
복사
커밋, 롤백과 관련하여 디폴트로 결과를 커밋하고 예외가 생겼을 때만 롤백을 하는 방식으로 구현해볼 수도 있다.
class AbstractUnitOfWork(abc.ABC):
batches: repository.AbstractRepository
def __enter__(self) -> AbstractUnitOfWork:
return self
def __exit__(self, exn_type, exn_value, traceback):
if exn_type is None:
self.commit()
else:
self.rollback()
Python
복사
이렇게 하면 commit() 을 명시하는 한 줄을 줄일 수 있다. 하지만 언제 상태를 디비에 반영할지(flush) 선택해야 한다면 명시적인 커밋을 요구하는 쪽이 더 선호된다.
명시적 커밋이 요구되면 소프트웨어 동작이 안전해진다. 또한 시스템의 상태를 바꾸는 경로가 단 하나(커밋을 명시하는 경로) 만 존재하므로 코드를 추론하기도 쉬워진다.
작업 단위 패턴(UoW) 을 통해서 트랜잭션 시작과 끝을 명시적으로 제어할 수 있다. 또한 원자적 연산을 표현하는 추상화이며, 콘텍스트 관리자를 사용하여 원자적으로 한 그룹으로 묶여야하는 코드 블록을 시각적으로 쉽게 알아볼 수 있다.
참고) 원자적 연산 : 수행되는 동안 어떠한 방해도 받지 않아야 하는 더 이상 분리할 수 없는 작업 단위를 말한다. 위의 예제에서는 디비와의 통신 과정에서 동시에 공유하고 있는 디비에 여러 접근이 이루어져, 데이터의 정합성에 영향을 줄 수 있는 상황을 방지할 수 있도록 원자적인 연산 행위에 대한 commit() 과 rollback() 을 콘텍스트 관리자로 구현해놓은 부분을 말한다.
반면, ORM 이 이미 좋은 추상화를 제공하는 경우가 있기 때문에 굳이 따로 UoW 를 구현하지 않아도 된다. 또한 다중 스레딩, 롤백 등 직접 구현할 때 상당히 신중해야 하는 케이스에 대해서는 ORM 이 제공하는 기능을 사용하는 것이 더 안전할 수 있다.
참고
•
모킹의 원칙:
◦
"자신이 만든 것이 아니면 모킹하지 말라"는 원칙은 복잡한 하위 시스템 위에 간단한 추상화를 만들도록 유도하는 기본 규칙입니다.
•
세션(Session)과 UoW(Unit of Work) 비교:
◦
세션(Session):
▪
데이터베이스에 대한 영속성 관련 기능을 노출하는 복잡한 객체입니다.
▪
데이터베이스 접근 코드가 코드베이스 곳곳에 흩어질 위험이 있습니다.
◦
Unit of Work (UoW):
▪
세션보다 간단한 추상화입니다.
▪
서비스 계층이 작업 단위를 시작하거나 중단할 수 있게 도와줍니다.
▪
세션을 모킹하는 것과 같은 성능상의 이점을 제공하지만, 더 간단하고 명확한 책임 분리를 가능하게 합니다.
•
가짜 객체 사용의 목적:
◦
UoW와 세션을 가짜 객체로 사용하면 실제 데이터베이스를 사용하지 않고 메모리 상에서 테스트를 진행할 수 있습니다.
◦
UoW는 세션보다 더 간단한 추상화를 제공하여 테스트 작성이 더 편리합니다.
•
권장 사항:
◦
실행이 빠른 테스트를 작성하려면 SQLAlchemy 대신 UoW를 모킹하고, 이를 코드베이스 전체에서 사용하는 것이 좋습니다.
◦
SQLAlchemy 세션과 결합하는 것보다 더 간단한 추상화를 선택하여 책임을 명확히 분리하는 것이 좋습니다.
질문 정리
•
그래서 작업 단위 세션 작업을 통해 얻을 수 있는 장점이 뭐야