가장 이해가 갔던 작업 단위 패턴 설명 두 가지를 정리했다.
“A Unit of Work is used to maintain a list of objects affected by a business transaction and to coordinate the writing out of these changes.” — Martin Fowler
"점차 애플리케이션의 비즈니스 로직이 복잡해지면 하나의 요청에 대해
여러 연관된 데이터들을 조작하게 되는 경우가 생기게 된다.
그리고 하나의 비즈니스 로직 흐름안에서 데이터 또는 발생되는 작업들 간의 무결성을 유지하기 위해서 수행되어야 할 작업들 중 하나라도 실패할 경우, 실패한 작업 전까지 실행됐던 모든 작업을 되돌려놓아야 할 필요성도 생길 수도 있다.
이를 위해서 데이터베이스의 트랜잭션처럼 비즈니스 계층에서도 트랜잭션의 개념을 도입 하기 위한 구조가 필요하다. 이를 위한 패턴이 있는데 바로 "Unit Of Work"이다."
출처: <Golang과 Unit of work> https://blog.puppyloper.com/menus/Golang/articles/Golang%EA%B3%BC%20Unit%20of%20work
UoW가 적용된 서비스 계층의 allocate 함수를 보자.
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()
batchref = model.allocate(line, batches)
uow.commit()
❶ with uow
라는 컨텍스트 관리자로 원자적 연산의 시작을 알리고 있다.
❷ uow.batches.list()
를 보면 uow를 통해 배치 목록을 가져오고 있다. 즉, uow가 데이터 계층에 접근하고 있다.
❸ 모든 작업이 오류 없이 끝나면, uow는 단 한 번의 commit을 진행한다.
요약하면 이렇다.
서비스 계층이 데이터 계층에 바로 접근하지 않는다.
서비스 계층은 UoW를 import해오며 의존하고 있고(DIP),
UoW가 데이터 계층에 접근한다.
커밋을 한 번만 하니, 그 동안의 객체의 스냅샷을 추적한다는 의미가 되고
스냅샷 추적은 메모리에서 진행한다.
uow는 영속적 저장소에 대한 단일 진입점이다.
Q. 왜 이렇게까지 하는 건데??
A. 복잡해진 비즈니스 로직 환경에서 데이터베이스 무결성을 확보하기 위함이다.
위에서 UoW를 서비스 계층에 도입해봤다.
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)
# 원자적 연산이 보장되는 단위 블록
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로 세션에 저장된 배치를 가져오고,
❷ 주문라인에 할당을 하고 커밋까지 진행했다.
❸ 그 결과, 잘 반영됐다.
즉, uow를 통해 데이터베이스에 접근하여 객체를 가져와서 연관된 객체도 함께 업데이트 했다.
아직까지는 메모리에서 변경된 사항을 저장하고 있다.
그러나 uow.commit()을 호출하는 순간, 블록 단위의 영속화가 진행됐다.
즉, uow가 데이터 계층의 접근을 추상화해줄 뿐만 아니라, 데이터베이스 무결성까지 보장해주고 있는 것이 통합 테스트를 통해 확인됐다.
uow패턴을 서비스 계층에 도입했고, integration 테스트까지 진행했다.
uow를 통해 서비스계층과 데이터 계층을 분리되고 있음이 확인됐다.
의사코드를 작성해봤다.
def confirm_payment(request, *args, **kwargs):
"""결제 최종 승인 처리 API"""
(request 파싱 내용 생략)
try:
with transaction.atomic():
# PG: 카카오페이
kakaopay = PaymentAction.set_pg("KakaoPay")
# 카카오페이에 결제 최종 승인 요청
response = kakaopay.approve(request)
if response.status_code == 200:
billing = response.billing
api_id = response.json()['aid']
captured_amount = response.json()['amount']['total']
# billing 업데이트
billing.api_id = api_id
billing.pg_name = "KakaoPay"
billing.captured_amount = captured_amount
billing.status = cur_status
billing.save()
# 쿠폰 사용처리
coupon_action = CouponAction(coupon_id, order_id)
coupon_action.use()
# 캐시 사용처리
cash_action = CashAction(order_id)
cash_action.use()
return render(request, 'billing/billing_success.html')
else:
raise PaymentAPIException(response)
except PaymentAPIException as e:
return render(request, 'pf_billing/billing_fail.html')
작업 단위 패턴 개념의 존재를 몰랐지만,
필요성을 느껴 코드에 적용한 내용은 아래와 같다.
쿠폰의 사용과 복구 관련 코드를 보자.
class Coupon(models.Model):
(중략)
@transaction.atomic
def use(self, order_id):
if self.is_validated:
self.used_at = timezone.now()
self.save()
# 주문에 쿠폰 적용
if CouponOrder.objects.filter(
coupon=self,
order_id=order_id,
is_used=True,
).exists():
raise ValueError(f"이미 order_id: {order_id}에 사용한 쿠폰입니다.")
else:
new_value = {"order_id": order_id, "is_used": True}
CouponOrder.objects.update_or_create(
coupon=self,
defaults=new_value
)
else:
raise ValueError("쿠폰 사용불가")
@transaction.atomic
def restore(self, order_id):
# 쿠폰 주문내역에 있으면
if CouponOrder.objects.filter(
coupon=self,
order_id=order_id,
is_used=True
).exists():
coupon_order = CouponOrder.objects.get(
coupon_id=self,
order_id=order_id,
is_used=True
)
coupon_order.is_used = False
self.used_at = None
coupon_order.save()
self.save()
else:
raise ValueError("사용 내역에 없어서 쿠폰 환불 불가")
from django.contrib.auth.models import User
from django.db import transaction
from web.models import Coupon
@transaction.atomic
def test_atomic():
# 유저 객체 가져오기
(중략)
# 유저 객체 업데이트
user.profile.name = "Transaction.atomic test"
user.save()
# 쿠폰 객체 업데이트
coupon = Coupon.objects.filter(user=user).last()
coupon.use()
raise ValueError("강제로 에러 일으키기")
최외각에 추가로 transaction.atomic 데코레이터가 적용됐다.
이때, atomic이 이미 적용된 쿠폰의 모델 메소드 use를 호출시
기대하던 대로 원자성을 보존해주는지 확인하고 싶었다.
강제로 에러를 일으켰을 때, 어떻게 처리하는지 보자.
TypeError: use() missing 1 required positional argument: 'order_id'
결과
user.save() 이후 coupon.use()에서 에러가 발생했으나,
유저 이름이 변경되지 않았다.
즉, 데이터베이스 무결성을 보존해줬다.
결과
user.save(), coupon.save()이후에 raise ValueError를 일으켰고,
유저 및 쿠폰 데이터가 변경되지 않았다.
마찬가지로, 데이터 무결성을 보존해줬다.
플라스크, SQLAlchemy 조합으로 아키텍처 패턴 개념에 대해서 배우고 있지만,
실무에서는 장고로 업무를 하고 있다. 배운 내용을 도입하고 싶지만, 팀원과 협의를 이끌어내는 것 그리고 장고에서 제공해주는 유사 기능들로 인해 적용하기가 어려운 점이 있다.
그래서 나는
실무에서 쓰이는 코드가 어떤 의미를 갖는지 인지
를 하는게 중요하다고 느꼈다. 장고는 웹 프레임워크이면서 데이터베이스와의 결합도가 높은 점, 데이터베이스와의 결합도를 떼어내는 과정은 불가능한 것은 아니지만, 장고를 왜 사용하는가로 귀결되는 점 등 아키텍처 패턴 개념을 배우지 않았으면 생각하지 못한 점들을 느끼게 해준다.
이번 UoW(작업 단위 패턴)의 결론은 다음과 같다.
UoW를 통해 원자적 연산을 구현하는 추상화가 도입되어 안전한 방식으로 트랜잭션이 된다. 즉, 부분적으로 커밋되는 짜증나는 순간을 방지할 수 있다는 이야기다.
장고에서는 데이터 무결성을 도와주는 transaction.atomic이 있다.
모델 메소드 혹은 서비스 계층 함수가 명확하게 (추상화 계층 없이) 데이터베이스와 긴밀하게 연결되어 있다는 점을 인지
하고 있다.
하지만 결과는 동일하게 산출해내고 있다.
부분적으로 커밋되는 무결성 위배를 막아주고 있다는 것이다.
점점 "DB는 영속화의 관점에서만 접근해야 한다" 라는 말이 어렴풋이 이해가 간다.