비동기 작업

UI를 막지 않고 데이터를 읽어 오는 장치다. 읽는 동안 로딩 상태를 보여 주고, 성공·실패를 따로 다룬다.

메서드 용도
assign_async() 간단한 비동기. AsyncResult가 상태를 대신 추적한다
start_async() 이름 붙은 비동기. 취소·교체할 수 있다
cancel_async() 진행 중인 작업을 취소한다
handle_async() start_async 결과를 받는 콜백

assign_async()#

가장 단순한 방법이다. 로딩·성공·실패를 추적하는 AsyncResult를 돌려준다.

작업 코루틴을 만드는 메서드는 _로 시작한다. 밑줄 없는 메서드는 클라이언트가 이벤트로 부를 수 있는 핸들러가 된다.

from wireview import Component, AsyncResult


class Dashboard(Component):
    class Meta:
        template_name = "dashboard.html"

    stats: AsyncResult[dict] | None = None

    async def joined(self):
        # 즉시 loading 상태의 AsyncResult를 받고,
        # 끝나면 success/error로 바뀐다
        self.stats = await self.assign_async(self._load_stats())

    async def _load_stats(self):
        # 느린 작업을 흉내 낸다
        import asyncio
        await asyncio.sleep(1)
        return {"users": 100, "revenue": 50000}

템플릿:

{% load wireview %}

<div {% tag_header %}>
  {% if this.stats.loading %}
    <div class="skeleton">통계를 불러오는 중...</div>
  {% elif this.stats.failed %}
    <div class="error">오류: {{ this.stats.error_message }}</div>
  {% elif this.stats.ok %}
    <div class="stats">
      <p>사용자: {{ this.stats.result.users }}</p>
      <p>매출: {{ this.stats.result.revenue }}</p>
    </div>
  {% endif %}
</div>

AsyncResult 속성#

속성 타입 뜻
loading bool 진행 중
ok bool 성공으로 끝났다
failed bool 오류로 끝났다
done bool 끝났다 (성공이든 실패든)
pending bool 아직 시작하지 않았다
state AsyncState 위 넷을 하나로: AsyncState.PENDING·LOADING·SUCCESS·ERROR (str 열거형이라 "loading"과도 같다)
result T 결과값 (ok일 때)
error Exception 예외 (failed일 때)
error_message str 사람이 읽을 오류 메시지

AsyncResult 메서드#

# 상태 만들기
AsyncResult.loading_state()  # 로딩 상태
AsyncResult.success(value)   # 성공 상태
AsyncResult.failure(error)   # 오류 상태

# 결과 다루기
result.map(lambda x: x * 2)  # 성공일 때만 변환
result.get_or(default)       # 결과 또는 기본값
result.get_or_raise()        # 결과 또는 예외 발생

start_async() / cancel_async()#

더 세밀하게 다뤄야 하면 이름 붙은 작업을 쓴다. 취소하거나 교체할 수 있다.

class Search(Component):
    class Meta:
        template_name = "search.html"

    query: str = ""
    results: list[dict] = []
    loading: bool = False
    error: str | None = None

    async def search(self, query: str):
        self.query = query
        self.loading = True
        self.error = None

        # 이름 붙은 비동기 작업을 시작한다.
        # "search"가 이미 돌고 있으면 그것은 취소된다
        await self.start_async("search", self._do_search(query))

    async def _do_search(self, query: str) -> list[dict]:
        import asyncio
        await asyncio.sleep(0.5)  # 디바운스
        return await SearchService.search(query)

    async def handle_async(self, name: str, result):
        """start_async가 끝나면 호출된다."""
        if name == "search":
            self.loading = False
            if result.ok:
                self.results = result.result
            else:
                self.error = result.error_message
                self.results = []

    async def clear_search(self):
        # 진행 중인 검색을 취소한다
        await self.cancel_async("search")
        self.query = ""
        self.results = []
        self.loading = False

handle_async() 콜백#

start_async 작업이 끝나면 호출된다. result는 assign_async가 채우는 것과 같은 AsyncResult이고, 끝난 상태(ok 또는 failed)로만 온다. 취소된 작업은 handle_async를 부르지 않는다.

from wireview import AsyncResult

async def handle_async(self, name: str, result: AsyncResult) -> None:
    # name: start_async에 넘긴 이름
    if name == "load_data":
        if result.ok:
            self.data = result.result
        else:
            self.error = f"Failed to load: {result.error_message}"  # 예외는 result.error

작업 이름#

아무 문자열이나 되지만, 무엇을 하는지 드러나게 짓는다.

# 좋음: 설명적인 이름
await self.start_async("load_user_profile", self._fetch_profile(user_id))
await self.start_async("search_products", self._search_products(query))
await self.start_async(f"load_page_{page}", self._fetch_page(page))

# 취소되었는지 확인할 수 있다
if await self.cancel_async("search_products"):
    print("Search was cancelled")

자동 교체#

같은 이름으로 start_async를 다시 부르면 앞의 작업이 자동으로 취소된다.

async def search(self, query: str):
    # 키를 누를 때마다 새 검색이 시작되고
    # 앞선 검색은 저절로 취소된다
    await self.start_async("search", self._do_search(query))

앞의 작업이 이미 결과를 내고 handle_async에 있어도 취소된다. handle_async가 결과를 반영하기 전에 await하는 동안(보강 조회, 저장) 새 검색이 시작되면, 낡은 결과는 반영되지 않고 새 결과만 화면에 남는다. cancel_async도 같다.

예외는 handle_async 자신이 제 이름을 다시 부를 때 하나다. handle_async 안에서 같은 이름으로 다시 시작해도(재시도, 폴링) 지금 돌고 있는 handle_async는 취소되지 않고 끝까지 돌며 렌더도 나간다(#147). 같은 까닭에 handle_async 안에서 제 이름으로 cancel_async를 부르면 취소하지 않고 False를 돌려준다. 작업 코루틴이 아직 결과를 내기 전에 제 이름으로 다시 시작하면 여느 교체처럼 취소된다.

제 이름을 다시 부른 handle_async는 이름을 새 작업에 넘겨준다. 그 뒤의 교체나 cancel_async는 새 작업만 취소하고, 이 handle_async는 컴포넌트가 떠날 때만 취소된다. 그래서 결과는 다시 시작하기 전에 반영한다 — 다시 시작한 뒤에 await하고 반영하면, 그 사이 시작된 새 작업의 결과를 낡은 결과가 덮을 수 있다.

사용 예#

타입어헤드 검색#

class Typeahead(Component):
    class Meta:
        template_name = "typeahead.html"

    query: str = ""
    suggestions: list[str] = []
    loading: bool = False

    async def on_input(self, query: str):
        self.query = query

        if len(query) < 2:
            self.suggestions = []
            # 취소된 작업은 handle_async를 부르지 않으므로 로딩 표시는 여기서 끈다
            await self.cancel_async("suggest")
            self.loading = False
            return

        self.loading = True
        await self.start_async("suggest", self._fetch_suggestions(query))

    async def _fetch_suggestions(self, query: str) -> list[str]:
        import asyncio
        await asyncio.sleep(0.3)  # 자연스러운 디바운스
        return await SuggestionService.get(query)

    async def handle_async(self, name, result):
        if name == "suggest":
            self.loading = False
            if result.ok:
                self.suggestions = result.result

병렬 로딩#

class Dashboard(Component):
    class Meta:
        template_name = "dashboard.html"

    users: AsyncResult[list[User]] | None = None
    orders: AsyncResult[list[Order]] | None = None
    stats: AsyncResult[dict] | None = None

    async def joined(self):
        # 세 가지를 동시에 읽는다
        self.users = await self.assign_async(self._load_users())
        self.orders = await self.assign_async(self._load_orders())
        self.stats = await self.assign_async(self._load_stats())

    async def _load_users(self):
        # QuerySet은 await할 수 없다. async for로 목록을 만든다.
        # 결과의 모델 인스턴스는 서명 상태에 pk로 실리고, 다시 join할 때 AsyncResult[list[User]]를 따라 다시 읽힌다
        return [user async for user in User.objects.all()[:10]]

    async def _load_orders(self):
        return [order async for order in Order.objects.filter(status="pending")[:10]]

    async def _load_stats(self):
        return {"total_users": await User.objects.acount()}

취소할 수 있는 파일 처리#

class FileProcessor(Component):
    class Meta:
        template_name = "processor.html"

    progress: int = 0
    processing: bool = False
    result: str | None = None

    async def process_file(self, file_id: str):
        self.processing = True
        self.progress = 0
        await self.start_async("process", self._do_process(file_id))

    async def _do_process(self, file_id: str):
        import asyncio
        for i in range(100):
            await asyncio.sleep(0.1)
            self.progress = i + 1
            await self.send_render()  # 진행률을 갱신한다
        return "Processing complete!"

    async def cancel_processing(self):
        if await self.cancel_async("process"):
            self.processing = False
            self.progress = 0

    async def handle_async(self, name, result):
        if name == "process":
            self.processing = False
            if result.ok:
                self.result = result.result

작업의 수명#

작업은 그것을 시작한 컴포넌트보다 오래 살지 않는다(#95). 컴포넌트가 떠나면 leaving()이 끝난 뒤 남은 start_async·assign_async 작업이 모두 취소된다. 컴포넌트가 떠나는 경우는 다음과 같다.

  • 탭이 닫히거나 연결이 끊긴다
  • 요소가 DOM에서 사라진다(leave). 부모가 더 이상 그리지 않는 LiveComponent도 여기에 든다
  • 내비게이션으로 같은 id의 새 인스턴스가 join한다
  • 핸들러가 예외를 던져 인스턴스가 버려진다(errors)

handle_async를 돌고 있는 작업도 취소된다. 작업이 아직 결과를 내는 중이면 다음 await에서 취소된다.

취소된 작업에는 handle_async가 불리지 않고 렌더도 요청되지 않는다. handle_async 도중에 취소되면 그 다음 await부터는 돌지 않고 렌더도 요청되지 않는다. cancel_async()로 직접 취소할 때도 같다. 작업 안에서 정리가 필요하면 asyncio.CancelledError를 받아 처리하고 다시 던진다.

async def export(self):
    try:
        await self._write_rows()
    except asyncio.CancelledError:
        await self._remove_partial_file()
        raise

재연결하면 진행 중이던 작업은 이어지지 않는다. 다시 join한 인스턴스는 서명 상태에서 복원된 새 인스턴스이고, 작업은 옛 인스턴스와 함께 취소됐다. 재연결 뒤에도 필요한 작업이면 joined()에서 다시 시작한다.

작업과 렌더#

라이브 렌더는 property를 읽고 템플릿을 그리는 일을 워커 스레드에서 하고(#120), 그동안 이벤트 루프는 계속 돈다. start_async·assign_async가 시작한 작업은 그 루프에서 돈다. 그래서 작업은 컴포넌트의 렌더와 겹치지 않게 돈다(#138). 렌더가 진행 중이면 작업의 다음 단계(두 await 사이의 코드)는 그 렌더가 끝날 때까지 기다린다. 작업 코루틴 자신, 결과가 AsyncResult에 들어가는 일, handle_async가 모두 그렇다. start_async·assign_async의 작업에 대해서는 한 프레임의 data-state와 본문이 같은 상태를 말한다. 위 FileProcessor처럼 작업 안에서 self.progress를 바꿔도 된다.

렌더를 하는 쪽은 둘이다. 이벤트·메시지를 처리하는 연결의 렌더와, 작업이 stream_insert·stream으로 그리는 스트림 항목이다. 둘은 겹칠 수 있고 끝나는 순서도 정해져 있지 않다. 작업의 다음 단계는 다른 쪽의 렌더가 하나도 남지 않았을 때 시작한다. 작업이 스스로 시작한 렌더는 그 작업을 막지 않는다. 한 메시지가 stream()으로 항목을 여럿 그리면 작업은 그 항목들을 다 그릴 때까지 기다린다(항목 수에 비례한다). 작업이 요청하는 렌더와 스트림 조작은 어차피 연결이 그 메시지를 다 처리한 뒤에 나가므로 이 기다림으로 늦어지지 않는다. 늦어지는 것은 작업의 다음 단계다. 그 단계가 쿼리나 HTTP 요청을 시작한다면 그 요청은 그 메시지를 처리하는 시간만큼 늦게 시작하고, 그 결과가 화면에 오는 것도 그만큼 늦다(#147).

기다리는 것은 그 컴포넌트의 작업뿐이다. 루프는 막히지 않으므로 다른 컴포넌트와 다른 연결은 그대로 돈다. 렌더 도중 작업을 취소하면 작업은 그 렌더가 끝난 뒤에 CancelledError를 받는다.

한계는 다음과 같다.

  • start_async·assign_async 밖의 코드는 해당하지 않는다. 아래 코드가 self를 바꾸면 워커 스레드가 렌더하는 도중에 바뀔 수 있다. 그러면 서명은 옛 상태로, 본문은 새 상태로 나가는 프레임이 생긴다. 컴포넌트를 바꾸는 백그라운드 작업은 start_async로 시작한다.

    • asyncio.create_task()로 직접 띄운 코루틴
    • 루프에 따로 붙인 그 밖의 코드(콜백, 타이머 등)
    • 작업 안이라도 thread-sensitive가 아닌 실행기에서 도는 코드: loop.run_in_executor(), sync_to_async(thread_sensitive=False). 이 코드는 렌더와 다른 스레드에서 동시에 돈다. db()는 렌더와 같은 스레드에서 차례로 돌므로 안전하다.

    라이브러리가 스스로 띄우는 태스크 중 게이트를 거치지 않는 것은 둘이다. PresenceMixin의 타이핑 만료 타이머와 allow_upload()의 설정 전송이다. 둘 다 렌더가 읽는 상태를 바꾸지 않는다. 앞의 것은 서명 상태에 들지 않는 내부 표시를 끄고 브로드캐스트를 보내고, 뒤의 것은 메시지 하나를 보낸다(#147). 게이트 안에서 임의의 코루틴을 돌리는 공개 API는 없다. 컴포넌트를 바꾸는 작업은 start_async로 시작한다.

  • async property가 그 컴포넌트의 작업을 기다리면 멈춘다. property는 렌더 안에서 기다려지고, 작업은 그 렌더가 끝나기를 기다린다. 작업의 결과는 property에서 기다리지 말고 handle_async나 AsyncResult 필드로 받는다. 예외도 취소도 나지 않으므로, 렌더가 async property를 10초 넘게 기다리는 동안 그 컴포넌트의 작업이 렌더를 기다리고 있으면 wireview 로거에 경고를 한 번 남긴다(#147). 워커 스레드에서 읽는 동안(느린 쿼리)은 작업을 기다릴 수 없으므로 경고하지 않는다. property가 제 작업을 기다리지 않고 그저 10초 넘게 걸려도 경고는 나온다. 그동안 작업이 멈춰 있는 것은 사실이다.

오류 처리#

assign_async#

async def joined(self):
    self.data = await self.assign_async(
        self._load_data(),
        on_error=lambda e: logger.error(f"Load failed: {e}")
    )

start_async#

async def handle_async(self, name, result):
    if name == "critical_task":
        if result.failed:
            logger.error(f"Critical task failed: {result.error}")
            # 필요하면 재시도한다
            await self.start_async("critical_task", self._retry_task())

Phoenix LiveView 대응#

기능 Phoenix LiveView django-wireview
간단한 비동기 assign_async/3 assign_async()
이름 붙은 비동기 start_async/3 start_async()
취소 cancel_async/3 cancel_async()
결과 처리 handle_async/3 handle_async()
결과 래퍼 AsyncResult 구조체 AsyncResult dataclass
자동 취소 같은 이름이면 교체 같은 이름이면 교체
떠날 때 프로세스와 함께 종료 leaving() 뒤 취소