Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Async Cosmic Python

Async Cosmic Python

By Isac

Buzzvil

May 26, 2021
Tweet

More Decks by Buzzvil

Other Decks in Programming

Transcript

  1. async Cosmic Python 개요 Introduction 기존 Cosmic Python 문제점 1.

    Entity가 Domain Event를 관리함 (UOW와 Repository 또한 이 관리에 연관됨) 2. Domain Event가 실제로는 Synchronous 하게 처리됨 3. UOW에 Race Condition 문제가 있음 제안 사전 지식 ContextVar asyncio.Task 변경점 Event Queue async Event Dispatching 필요한 곳에 async, await 키워드 붙이기 추가적 이득 개선점 Limitation Django는 asyncio 지원이 좋지 않다 Discussion Domain에 asyncio라는 모듈에대한 의존성이 생겨도 괜찮은가? 개요 사내에서 Python DDD 구현에 사용하는 Cosmic Python의 몇가지 구조적 문제와 성능 문제를 해결하기위해 Python의 를 도입하자는 asyncio 제안. Introduction Cosmic Python은 DDD를 적용해서 프로젝트를 만들기에 좋은 틀을 가지고있다. 하지만 이것을 처음 사용해서 https://github.com/Buzzvil 를 개발할 때 느꼈던 불편함과 문제점들이 있었고, 이것을 적용과 함께 해결할 수 있음을 /cssvc asyncio https://github.com/Buzzvil/cssvc 에서 알 수 있었다. 또한 함으로써 도 챙길 수 있었다. /pull/21 adpacingsvc에 일부 적용 성능상 이득 기존 Cosmic Python 아키텍쳐 패턴 설명: (sync) Cosmic Python 문제점 1. Entity가 Domain Event를 관리함 (UOW와 Repository 또한 이 관리에 연관됨) Domain Event를 관리하는 주체가 별도로 존재하지 않고, 이것을 여러 layer에 걸쳐서 해결하려다보니 발생하는 문제 Entity가 Domain Event 관리 예시들 Cosmic Python Sample Code
  2. 1. 2. 3. 1. class Product: def __init__(self, sku: str,

    batches: List[Batch], version_number: int = 0): self.sku = sku self.batches = batches self.version_number = version_number self.events = [] # type: List[events.Event] def allocate(self, line: OrderLine) -> str: try: batch = next(b for b in sorted(self.batches) if b. can_allocate(line)) batch.allocate(line) self.version_number += 1 self.events.append( events.Allocated( orderid=line.orderid, sku=line.sku, qty=line.qty, batchref=batch.reference, ) ) return batch.reference except StopIteration: self.events.append(events.OutOfStock(line.sku)) return None def change_batch_quantity(self, ref: str, qty: int): batch = next(b for b in self.batches if b.reference == ref) batch._purchased_quantity = qty while batch.available_quantity < 0: line = batch.deallocate_one() self.events.append(events.Deallocated(line.orderid, line.sku, line.qty)) statssvc adpacingsvc 문제점 Entity와 상관없는 Domain Event가 있을 수 있다 Entity가 있어야 Domain Event 수집이 가능하다 → Repo가 Entity를 관리하게 만드는 근본적인 이유 Entity마다 작성 해야함 많아질수록 번거로움 Repository와 UOW의 수정 또한 필요함 가능한 해결책 https://martinfowler.com/eaaCatalog/layerSupertype.html 를 사용한다
  3. 1. 2. 1. 예시: https://github.com/Buzzvil/adpacingsvc/blob/f5f8221b615f5f0b7555ab7e5744dcccf99c032e/src/adpacing/domain /entity/base.py 하지만 여전히 Repo의 ORM

    Model ↔ Entity에서 어색한 부분이 있다. ( ) 예시 pydantic의 ( )를 사용하면 혹은 BaseModel.from_orm 문서 Entity.from_orm(orm_model) ORMModel 같이 de/serialize를 간편하게 할 수 있는데, 항상 변수를 신경써줘야한다. (**entity.dict()) events 여전히 문제 1을 해결할 수 없다 Domain Event 관리를 Entity에서 분리한다 Entity의 생명 주기와는 완전 분리되어야 하기 때문에 별도의 관리 체계가 필요하다 Repository가 Entity 생명 주기를 관리한다 예시들 Cosmic Python Sample Code class AbstractRepository(abc.ABC): def __init__(self): self.seen = set() # type: Set[model.Product] def add(self, product: model.Product): self._add(product) self.seen.add(product) def get(self, sku) -> model.Product: product = self._get(sku) if product: self.seen.add(product) return product def get_by_batchref(self, batchref) -> model.Product: product = self._get_by_batchref(batchref) if product: self.seen.add(product) return product @abc.abstractmethod def _add(self, product: model.Product): raise NotImplementedError @abc.abstractmethod def _get(self, sku) -> model.Product: raise NotImplementedError @abc.abstractmethod def _get_by_batchref(self, batchref) -> model.Product: raise NotImplementedError statssvc adpacingsvc 문제점 불필요한 를 강제한다 ( ) abstractmethod 예시
  4. 2. 1. 2. 3. Repository가 자신의 Entity가 Domain Event를 만드는지

    아닌지 알아야한다 원래 Domain Event를 만들지 않는 Entity가 어느날 Domain Event를 만들게 되었을 때 seen같은 관리 코드를 Repository에 추가하지 않으면 모든 Domain Event가 유실된다 AbstractRepository 부터 Concrete Repository까지 모든 method를 로 분리해야한다 abstractmethod UOW가 Repository를 통해 Domain Event를 수집한다 예시들 Cosmic Python Sample Code class AbstractUnitOfWork(abc.ABC): products: repository.AbstractRepository def __enter__(self) -> AbstractUnitOfWork: return self def __exit__(self, *args): self.rollback() def commit(self): self._commit() def collect_new_events(self): for product in self.products.seen: while product.events: yield product.events.pop(0) statssvc adpacingsvc 문제점 암묵적으로 실행 순서에대한 제약이 존재한다 UOW 내부에 Repository 객체들은 이 아니라 에서 초기화됨 __init__ __enter__ collect_new_events는 항상 이후에 실행되어야 valid함 __enter__ 그렇다고 으로 Repository 객체 생성 시점을 바꾸면 memory leak 문제가 발생 __init__ 이렇게 되면 서버 시작부터 끝까지 UOW와 각 Repository 객체는 각각 딱 한개의 instance만 생성되고, Repository 객 체의 변수는 만 되기 때문에 최악의 경우 DB의 모든 entity가 그대로 메모리에 올라갈 수 있음 seen append Race Condition 문제의 근원이 된다 Repository가 담당하는 Entity가 Domain Event를 만드는지 여부를 UOW가 알아야한다 Repository의 문제와 동일하게 중간에 요구사항이 바뀌었을 경우 문제가 발생할 여지가 있다 정리 MessageBus → UOW → Repository → Entity → Domain Event 결국 MessageBus가 Domain Event를 알아내기 위해 모든 layer에 관련 코드가 삽입됨 2. Domain Event가 실제로는 Synchronous 하게 처리됨 Domain Event dispatching의 주체가 별도로 존재하지 않기 때문에 발생하는 문제 문제의 코드 Cosmic Python Sample Code
  5. 1. 2. class MessageBus: def __init__( self, uow: unit_of_work.AbstractUnitOfWork, event_handlers:

    Dict[Type[events.Event], List[Callable]], command_handlers: Dict[Type[commands.Command], Callable], ): self.uow = uow self.event_handlers = event_handlers self.command_handlers = command_handlers def handle(self, message: Message): self.queue = [message] while self.queue: message = self.queue.pop(0) if isinstance(message, events.Event): self.handle_event(message) elif isinstance(message, commands.Command): self.handle_command(message) else: raise Exception(f"{message} was not an Event or Command") def handle_event(self, event: events.Event): for handler in self.event_handlers[type(event)]: try: logger.debug("handling event %s with handler %s", event, handler) handler(event) self.queue.extend(self.uow.collect_new_events()) except Exception: logger.exception("Exception handling event %s", event) continue def handle_command(self, command: commands.Command): logger.debug("handling command %s", command) try: handler = self.command_handlers[type(command)] handler(command) self.queue.extend(self.uow.collect_new_events()) except Exception: logger.exception("Exception handling command %s", command) raise 문제점 Domain Event는 항상 Command 실행 종료 이후에 실행된다 Event Handler에서 이벤트 발생 시점을 기록하고 싶어도 항상 Command 처리 시간 만큼의 delay가 추가된다
  6. 2. 1. 2. 3. 4. 1. 2. 3. 4. Domain

    Event의 처리가 길어지면 Command의 결과 반환 또한 지연된다 Domain Event의 처리가 모두 끝나야 이 끝나기 때문에 가 일찍 끝나더라도 이 끝날 때 까지 handle handle_command handle client는 기다려야한다 Event Handler에서 Domain Event를 재귀적으로 계속 생산할 경우 client는 영원히 기다릴 수 있다 결국 Python의 함수 재귀호출이기 떄문에 Stack Overflow Error가 발생할 수 있다 3. UOW에 Race Condition 문제가 있음 https://github.com/cosmicpython/book/issues/312 Happy Path MessageBus.handle_command Service Layer의 handler에 서 를 사용해서 커맨드 처리 ( ) with uow: 예시 MessageBus.handler_command의 self.queue.extend(self.uow.collect_new_events()) 으로 Domain Event 수집 MessageBus.handle에서 수집한 Domain Event들을 로 처리 MessageBus.handle_event Unhappy Path 상황 C1 : Command 1 : Command 2 C2 Multi thread 환경에서 두개의 Command가 외부에서 거의 동시에 들어와서 각각 다른 Thread에 매핑됨 실행 순서 C1 handle_command C2 handle_command C1 service layer에서 처리 시작 (여기서 처리를 마치든 말든 race condition에 변동 없음) C2 service layer에서 처리 시작 분석 3에서 이 service layer에서 로 돌아와서 Domain Event를 수집하는 동작까지 마치는게 아니라면 C1 MessageBus.handle_command 이후에 상황이 어떻게 되든 문제 발생함 Service layer에서 를 사용할 때 가 실행되는데, 이 때 항상 Repository 객체를 초기화한다 ( ) with uow UnitOfWork.__enter__ 코드 문제는 Repository 객체 내부에 Entity 객체를 담고, Entity 객체 내부에 Domain Event가 담겨있다 ( ) 코드 쉽게 말하면 Repository 객체가 사라지면 그동안 모았던 (해당 Entity의) Domain Event가 사라진다 때문에 과 가 사용하는 UOW는 같은데, 이 먼저 만들어 놓은 Repository 객체를 가 지웠기 때문에, 가 를 C1 C2 C1 C2 C2 with uow 실행하는 순간 의 Domain Event는 모두 사라진다 C1 제안 https://github.com/Buzzvil/async-cosmic-python/pull/1 : 예제 PR https://github.com/cosmicpython/code 을 포크해서 asyncio를 적용하도록 수정한 예시 PR 사내에서 적용한 사례 https://github.com/Buzzvil/cssvc/pull/21 https://github.com/Buzzvil/adpacingsvc/pull/41 : event 부분은 수정하지 사전 지식 ContextVar 공식 문서 Python 3.7에 추가됨
  7. https://valarmorghulis.io/tech/201904-contextvars-and-thread-local/ ThreadLocal은 Concurrency를 위해 Multi Thread를 사용할 때 Thread 끼리는

    서로 분리되지만 각 Thread 내부에서는 공유 할 변수를 만들 때 사용 하지만 에서는 에 여러 Task를 Concurrent하게 실행하기 때문에 은 무용지물 asyncio 하나의 Thread ThreadLocal asyncio를 쓰지 않는다면 과 동일하게 동작하지만, 를 쓴다면 Task끼리는 독립적이지만 한 Task 내부 ThreadLocal asyncio 에서는 공유할 수 있는 변수를 만들어준다 asyncio.Task 공식 문서 asyncio의 EventLoop가 스케줄링하는 단위 aysncio.create_task로 Coroutine을 EventLoop에 등록할 수 있음 변경점 Event Queue domain/events.py ( ) 커밋 import asyncio from contextvars import ContextVar from dataclasses import dataclass event_queue: ContextVar[asyncio.Queue[Event]] = ContextVar ('event_queue') def issue_event(event: Event) -> None: queue = event_queue.get(asyncio.Queue[Event]()) queue.put_nowait(event) event_queue.set(queue) ContextVar를 사용해서 Task 마다 하나씩 EventQueue를 생성 Domain Event가 더 이상 Entity 내부에서 수집되지 않는다 Entity와 상관 없는 Domain Event 수집가능 Domain Event를 위해서 Repository가 Entity 객체 관리 해야할 필요 없음 극단적으로는 UOW 또한 기존과 다르게 Domain Event나 Entity를 관리할 필요가 없다 asyncio는 single thread model이기 때문에 Race Condition이 없어짐 혹시 나중에 여러 thread를 만들고 EventLoop를 각각 돌리더라도 Task가 분리되기 때문에 여전히 Race Condition이 없음 Domain 어디서든 만 호출하면 이벤트를 만들 수 있다 domain.events.issue_event async Event Dispatching service/message_bus.py ( ) 커밋 handle_event와 에서 명시적으로 를 호출해서 실제 실행되는 handler들은 각각의 Task handle_command asyncio.create_task 로 분리시킴 CollectAndHandleLocalEvents 라는 ContextManager를 만든다. 이 객체는 context 내부에서 발생한 모든 Domain Event를 모아 서 각 이벤트를 처리하는 코루틴을 EventLoop에 새로운 Task로 등록시킨다 CollectAndHandleLocalEvents를 와 를 wrapping 해서 그 안에서 만들어진 Domain Event는 _handle_event _handle_command 무조건 잡아낸다 CollectAndHandleLocalEvents Context는 나 가 호출될 때마다 생성되기 때문에 한번에 복수개 handle_event handle_command 가 존재할 수 있고, 모두 라는 모듈 변수를 참조한다. 그럼에도 불구하고 오직 자신의 Context에서 발생하는 events.event_queue Domain Event만 모을 수 있는 이유는, 모두 각자의 Task로 만들어졌으며 가 ContextVar를 사용하기 때문이다 events.event_queue 발생한 Domain Event의 처리는 sync하게 일어나지 않고 EventLoop에 submit만 하기 때문에, 어떤 에서 아무리 많 handle_command 은 Domain Event가 생성되더라도 client가 기다릴 필요없다
  8. 필요한 곳에 async, await 키워드 붙이기 https://github.com/Buzzvil/async-cosmic-python/pull/1/commits/6e9938018b004f5300b0e37c677cf03c91b76a8b 추가적 이득 MessageBus가

    UOW를 주입받을 필요가 없다 (https://github.com/Buzzvil/async-cosmic-python/pull/1/commits /b2a447568e89d38485f5531715753f3704e4f497#diff- ) 57700e450745da7c5c27a78ae72ccd8b11f66a0773b40c0d60fdd028c6a9f2eaL38-L55 현재 MessageBus는 오직 사용을 위해서 UOW 객체를 주입받는다. UOW.collect_new_events 개선점 실시간으로 Event를 처리하고있진 않다. 만약 실시간이 필요하다면 하자마자 EventLoop에 submit 하도록 수정할 수 있 issue_event 다 Limitation Django는 asyncio 지원이 좋지 않다 Django 3.1에서 asyncio 지원이 일부 추가됐지만 ORM 쪽은 매우 좋지 않다. (링크) 내부적으로는 threadpool을 사용한다. 그나마 다행인 것은 Web Server 쪽은 ASGI를 사용해서 지원이 잘 되는것으로 보임 제안: 오직 ORM을 위해서 Django를 쓰는 경우가 대부분인것으로 알고있는데, SQLAlchemy (aiomysql) + Alembic을 사용하면 asyncio의 성능 이득과 생산성을 모두 잡을 수 있을 것 같다 Discussion Domain에 asyncio라는 모듈에대한 의존성이 생겨도 괜찮은가? domain 코드에 I/O 관련된 곳에는 키워드를 사용하도록 수정이 있어야한다. 수정하는 이유를 “I/O인지 아닌지를 domain이 알아 async await 야한다”라고 생각할 수도 있다. 하지만 두 키워드 모두 Python언어의 keyword 인것을 생각하면, “파라미터로 주입된 함수의 반환값이 Awaitab 인지 구분해야한다”라고 생각할 수도 있다. 결국 이것은 단순히 Python 언어에대한 의문이기 때문에 문제 없다는 결론을 내렸다 le