책 & 스터디

[파이썬으로 살펴보는 아키텍처 패턴] 챕터 6 작업단위 패턴

haong_ 2024. 3. 31. 23:38

작업단위(Unit of Work) UoW 패턴 : 저장소와 서비스 계층을 하나로 묶어주는 조각

현재 API는 직접 데이터베이스에 요청해 세션 시작, 저장소 계층과 대화해 SQLAlchemyRepository를 초기화하며 서비스 계층에 할당 요청 
이를 1. 작업단위 초기화 2. 서비스 호출 두가지 일만 수행 할 수 있도록 변경할 예정 

작업 단위는 저장소와 협력

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
        if not is_valid_sku(line.sku, batches):
            raise InvalidSku(f'Invalid sku {line.sku}')
        batchref = model.allocate(line, batches)
        uow.commit() # 3
    return batchref
  1. 콘텍스트 관리자로 UoW 시작
  2. uow.batches는 배치 저장소, UoW는 영속적 저장소에 대한 접근 제공
  3. 작업이 끝나면 UoW를 사용해 커밋 or 롤백

장점 

  • 작업에 사용할 데이터베이스의 안정적인 스냅샷 제공, 연산 진행과정에서 변경하지 않는 객제에 대한 스냅샷 제공
  • 변경 내용을 한번에 영속화할 방법 제공
  • 영속성을 처리하기 위한 간단한 API와 저장소를 쉽게 얻을 수 있는 장소 제공

테스트 - 통합 테스트로 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() # 3

    batchref = get_allocated_batch_ref(session, 'o1', 'HIPSTER-WORKBENCH')
    assert batchref == 'batch1'
  1. 커스텀 세션 팩토리를 사용해 UoW를 초기화하고 블록안에서 사용할 uow 객체를 얻는다
  2. UoW는 uow.batches를 통해 배치 저장소에 대한 접근을 제공
  3. 작업이 끝나면 commit 호출 

작업 단위와 작업 단위의 콘텍스트 관리자 

class AbstractUnitOfWork(abc.ABC):
	batches: repository.AbstractRepository # 1

	def __exit__(self, *args): # 2
    	self.rollback() # 4
        
    @abc.abstractmethod
    def commit(self): # 3
        raise NotImplementedError

    @abc.abstractmethod
    def rollback(self): # 4
        raise NotImplementedError
  1. UoW 는 .batches 속성 제공해 배치 저장소에 접근할 수 있게 해줌
  2. 콘텍스트 관리자 class exit는 클래스 종료시 호출 
  3. 준비가 되면 이메서드 호출해 작업 커밋 
  4. 커밋을 하지 않거나 예외 발생시 rollback 수행 

SQLAlchemy 세션을 사용하는 실제 작업 단위 

DEFAULT_SESSION_FACTORY = sessionmaker(
    bind=create_engine( # 1
        config.get_postgres_uri(),
    )
)


class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
    def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):
        self.session_factory = session_factory # 1

    def __enter__(self):
        self.session = self.session_factory()  # type: Session # 2
        self.batches = repository.SqlAlchemyRepository(self.session) # 2
        return super().__enter__()

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

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

    def rollback(self): # 4
        self.session.rollback()
  1. postgres와 연결하는 디폴트 세션 팩토리 정의. 이를 통합테스트에서 오버라이드해서 SQlite를 사용할 수 있게 허용
  2. 데이터베이스 세션을 시작하고 사용할때 실제 저장소를 인스턴스화 
  3. 콘텍스트 관리자에서 나올때 세션 닫음
  4. 세션에서 사용할 구체적인 commit() rollback() 제공

테스트를 위한 가짜 작업 단위 

class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork):
    def __init__(self):
        self.batches = FakeRepository([]) # 1
        self.committed = False # 2

    def commit(self):
        self.committed = True # 2

    def rollback(self):
        pass


def test_add_batch():
    uow = FakeUnitOfWork() # 3
    services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow) # 3
    assert uow.batches.get("b1") is not None
    assert uow.committed


def test_allocate_returns_allocation():
    uow = FakeUnitOfWork() # 3
    services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, uow) # 3
    result = services.allocate("o1", "COMPLICATED-LAMP", 10, uow) # 3
    assert result == "batch1"
  1. FakeUnitOfWork 와 FakeRepository는 실제 UoW와 Repository 클래스가 결합된것 처럼 밀접하게 결합, 이 객체들은 협력자라고 인식하므로 문제가 되지 않음
  2. 제 3자가 만든 코드를 가짜로 구현하지 않고 코드를 가짜로 구현하므로 큰 개선으로 볼 수 있음 ("자신이 만든 것이 아니면 모킹하지말라")
  3. 테스트에서는 UoW를 인스턴스화하고, 서비스 계층에 저장소와 세션을 넘기는 대신 인스턴스화한 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 추상화 하나남 남게 되었음 

커밋/롤백 동작에 대한 명시적인 테스트

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

명시적 커밋과 암시적 커밋

  • 암시적 커밋 : 커밋을 UoW 안으로 감춰서 디폴트로 커밋할 수 있게 하는 방법 
  • 명시적 커밋 : 커밋을 UoW를 사용하고 닫는 시점에 명시적으로 적어주는 방법 

암시적 커밋을 하는 UoW

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

 

어느게 더 나은 방법인지는 때에 따라 다를 수 있음. (저자는 암시적 커밋 선호 하는듯) 
나는 트랜잭션 관리나 코드 추론 등 협업 관점에서는 명시적 커밋이 더 낫지 않나라는 의견 

작업단위 패턴 트레이드 오프 

장점 단점
원자적 연산을 표현하는 좋은 추상화가 생겼고, 콘텍스트 관리자를 사용하면 원자적으로 한 그룹으로 묶여야하는 코드 블록을 시각적으로 쉽게 알아 볼 수 있다 사용중인 ORM은 이미 원자성을 중심으로 완벽히 좋은 추상화를 제공할 수 도 있다. SQLAlchemy는 콘텍스트 관리자도 제공한다. 
트랜잭션 시작과 끝을 명시적으로 제어할 수 있고, 애플리케이션이 실패하면 기본적으로 안전한 방식으로 트랜잭션이 처리된다. 따라서 연산이 부분적으로 커밋될지 걱정할 필요가 없다 롤백, 다중스레딩, 내포된 트랜잭션등을 처리하는 코드를 만들때는 SQLAlchemy가 제공하는 기능만 사용한다면 좀 더 편하게 살 수 있다
다음 장에서 살펴보겠지만 원자성이 트랜잭션에서만 중요한 것이 아니고, 이벤트나 메시지 버스를 사용할때도 원자성이 있으면 도움이 된다   

정리

  • 작업 단위 패턴은 데이터 무결성 중심 추상화 
  • 작업 단위 패턴은 저장소와 서비스 계층 패턴과 밀접하게 연관되어 작동
  • 콘텍스트 관리자를 사용하는 멋진 유스케이스
  • SQLAlchemy는 이미 작업 단위 패턴을 제공