python

Architecture Patterns with Python(6장)

monsterkos 2021. 11. 12. 00:53

작업 단위 패턴(Unit of Work)

 

이번 장에서 작업 단위 패턴을 이용해 서비스 계층과 데이터 계층을 완전히 분리해본다.

 

기존에는 플라스크 앱이 직접 데이터베이스에 요청해 세션을 시작하고, 저장소 계층과 대화하여 SQLAlchemyRepository 를 초기화하며 서비스 계층에 할당을 요청한다.

 

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

 

- 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"

 

UoW 를 테스트할 때 필요한 도우미 함수 insert_batch 와 get_allocated_batch_ref 를 따로 정의하였다.

- 커스텀 세션 팩토리를 사용해 UoW 를 초기화하고 블록 안에서 사용할 uow 객체를 얻는다.

- uow.batches 를 통해 배치 저장소에 대한 접근을 제공한다.

- 작업이 끝나면 commit 한다.

 

그러면 이러한 일이 가능하게 하는 UoW 를 추상 클래스로 인터페이스를 명시해본다.

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

- 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()

- 이 모듈은 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"

 

커밋과 롤백이 제대로 작동하는지 확인하기 위한 테스트를 작성해 볼수도 있다.

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 == []

 

커밋, 롤백과 관련하여 디폴트로 결과를 커밋하고 예외가 생겼을 때만 롤백을 하는 방식으로 구현해볼 수도 있다.

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()

 

이렇게 하면 commit() 을 명시하는 한 줄을 줄일 수 있다. 하지만 언제 상태를 디비에 반영할지(flush) 선택해야 한다면 명시적인 커밋을 요구하는 쪽이 더 선호된다.

명시적 커밋이 요구되면 소프트웨어 동작이 안전해진다. 또한 시스템의 상태를 바꾸는 경로가 단 하나(커밋을 명시하는 경로) 만 존재하므로 코드를 추론하기도 쉬워진다.

 

작업 단위 패턴(UoW) 을 통해서 트랜잭션 시작과 끝을 명시적으로 제어할 수 있다. 또한 원자적 연산을 표현하는 추상화이며, 콘텍스트 관리자를 사용하여 원자적으로 한 그룹으로 묶여야하는 코드 블록을 시각적으로 쉽게 알아볼 수 있다.

 

참고) 원자적 연산 : 수행되는 동안 어떠한 방해도 받지 않아야 하는 더 이상 분리할 수 없는 작업 단위를 말한다. 위의 예제에서는 디비와의 통신 과정에서 동시에 공유하고 있는 디비에 여러 접근이 이루어져, 데이터의 정합성에 영향을 줄 수 있는 상황을 방지할 수 있도록 원자적인 연산 행위에 대한 commit() 과 rollback() 을 콘텍스트 관리자로 구현해놓은 부분을 말한다.

 

반면, ORM 이 이미 좋은 추상화를 제공하는 경우가 있기 때문에 굳이 따로 UoW 를 구현하지 않아도 된다. 또한 다중 스레딩, 롤백 등 직접 구현할 때 상당히 신중해야 하는 케이스에 대해서는 ORM 이 제공하는 기능을 사용하는 것이 더 안전할 수 있다.

 

 

이미지 출처 : https://www.cosmicpython.com/book/chapter_06_uow.html