Python

asyncio 에 대한 개념적 개요

HOWTO 기사는 asyncio 가 기본적으로 어떻게 작동하는지에 대한 견고한 개념 모델을 구축하도록 돕고, 권장되는 패턴 뒤에 숨겨진 원리와 이유를 이해할 수 있게 돕기 위해 작성되었습니다.

asyncio 의 핵심 개념에 대해 궁금한 점이 있을 수 있습니다. 이 기사를 끝까지 읽고 나면 다음 질문들에 자신 있게 답변할 수 있게 될 것입니다.

  • 객체를 어웨이트(await)할 때 내부에서 어떤 일이 일어나는가?

  • asyncio 는 CPU 시간이 필요 없는 작업(네트워크 요청이나 파일 읽기 등)과 CPU 시간이 필요한 작업(n의 계승 계산 등)을 어떻게 구분하는가?

  • 비동기 슬립이나 데이터베이스 요청과 같은 작업의 비동기 변형 버전을 작성하는 방법은 무엇인가?

더 보기

개념적 개요 파트 1: 상위 수준

파트 1에서는 asyncio 의 주요 상위 수준 구성 요소인 이벤트 루프, 코루틴 함수, 코루틴 객체, 태스크, 그리고 await 에 대해 다룹니다.

이벤트 루프

asyncio 의 모든 것은 이벤트 루프를 기준으로 일어납니다. 이벤트 루프는 이 무대의 주인공이지만, 뒤에서 자원을 관리하고 조정하며 보이지 않게 작동하는 것을 선호합니다. 마치 오케스트라 지휘자와 같습니다. 명시적인 권한이 부여되기도 하지만, 많은 일을 완수할 수 있는 능력은 밴드 멤버들의 존중과 협력에서 나옵니다.

더 기술적인 관점에서 말하면, 이벤트 루프는 실행될 작업들의 집합을 포함합니다. 어떤 작업은 사용자가 직접 추가하고, 일부는 asyncio 에 의해 간접적으로 추가됩니다. 이벤트 루프는 대기 중인 작업 목록에서 하나를 가져와 호출(또는 “제어권을 부여”)하며, 이는 함수를 호출하는 것과 유사하며 그 후 해당 작업이 실행됩니다. 작업이 일시 중지되거나 완료되면 제어권이 다시 이벤트 루프에 반환됩니다. 그러면 이벤트 루프는 풀에서 다른 작업을 선택하여 호출합니다. 이 작업들의 집합을 대략적으로 큐라고 생각할 수 있습니다. 즉, 작업이 추가된 후 일반적으로(항상 그런 것은 아니지만) 순차적으로 하나씩 처리되는 것입니다. 이 과정은 반복되며 이벤트 루프는 끊임없이 사이클을 돌며 돌아갑니다. 실행 대기 중인 작업이 더 이상 없으면, 이벤트 루프는 현명하게 휴식하며 불필요하게 CPU 사이클을 낭비하지 않고, I/O 작업이 완료되거나 타이머가 만료되는 등 수행할 작업이 생기면 다시 돌아옵니다.

효과적인 실행은 작업들이 서로 잘 공유하고 협력하는 것에 달려 있습니다. 탐욕적인 작업이 제어권을 독점하면 다른 작업들이 굶주리는 상황(starve)이 발생하여 전체 이벤트 루프 방식이 무용지물이 될 수 있습니다.

import asyncio

# 이 코드는 이벤트 루프를 생성하고
# 그에 속한 작업들의 집합을 반복해서 실행합니다.
event_loop = asyncio.new__event_loop()
event_loop.run_forever()

비동기 함수와 코루틴

이것은 기본적인, 평범한 파이썬 함수입니다:

def hello_printer():
    print(
        "Hi, I am a lowly, simple printer, though I have all I "
        "need in life -- \nfresh paper and my dearly beloved octopus "
        "partner in crime."
    )

일반 함수를 호출하면 해당 로직이나 본문이 실행됩니다:

>>> hello_printer()
Hi, I am a lowly, simple printer, though I have all I need in life --
fresh paper and my dearly beloved octopus partner in crime.

일반적인 def 와 달리 async def 를 사용하면 이를 비동기 함수(또는 “코루틴 함수”)로 만듭니다. 이를 호출하면 코루틴 객체가 생성되어 반환됩니다.

async def loudmouth_penguin(magic_number: int):
    print(
     "I am a super special talking penguin. Far cooler than that printer. "
     f"By the way, my lucky number is: {magic_number}."
    )

비동기 함수인 loudmouth_penguin 을 호출하면 print 문이 실행되지 않고 대신 코루틴 객체가 생성됩니다:

>>> loudmouth_penguin(magic_number=3)
<coroutine object loudmouth_penguin at 0x104ed2740>

“코루틴 함수”와 “코루틴 객체”라는 용어는 흔히 코루틴이라는 하나의 단어로 섞여서 사용되곤 합니다. 이는 혼란을 줄 수 있습니다! 이 기사에서 코루틴은 구체적으로 코루틴 객체, 더 정확하게는 types.CoroutineType 의 인스턴스(네이티브 코루틴)를 의미합니다. 코루틴은 collections.abc.Coroutine 의 인스턴스로도 존재할 수 있으며, 이는 타입 체크 시 중요한 차이점이 됩니다.

코루틴은 함수의 본체 또는 로직을 나타냅니다. 코루틴은 명시적으로 시작되어야 하며, 단순히 코루틴을 생성하는 것만으로는 실행되지 않습니다. 특히, 코루틴은 함수 본문 내의 여러 지점에서 일시 중단되고 재개될 수 있습니다. 이러한 일시 중단 및 재개 기능이 비동기 동작을 가능하게 하는 핵심 요소입니다.

코루틴과 코루틴 함수는 제너레이터제너레이터 함수 의 기능을 활용하여 구축되었습니다. 제너레이터 함수는 아래 예시와 같이 yield s를 사용하는 함수임을 상기하십시오:

def get_random_number():
    # 이것은 좋지 않은 난수 생성기입니다!
    print("Hi")
    yield 1
    print("Hello")
    yield 7
    print("Howdy")
    yield 4
    ...

코루틴 함수와 마찬가지로 제너레이터 함수를 호출한다고 해서 실행되는 것은 아닙니다. 대신, 제너레이터 객체를 생성합니다:

>>> get_random_number()
<generator object get_random_number at 0x1048671c0>

내장 함수인 next`를 사용하여 제너레이터의 다음 ``yield`() 지점으로 진행할 수 있습니다. 다시 말해, 제너레이터는 실행된 후 일시 중지됩니다. 예를 들면 다음과 같습니다:

>>> generator = get_random_number()
>>> next(generator)
Hi
1
>>> next(generator)
Hello
7

Task

대략적으로 말하자면, tasks 는 이벤트 루프에 결합된 코루틴(코루틴 함수가 아님)입니다. 또한 태스크는 콜백 함수 목록을 유지하며, 이 기능의 중요성은 나중에 await 를 논의할 때 명확해질 것입니다.

태스크를 생성하면 자동으로 실행이 예약됩니다(즉, 이벤트 루프의 할 일 목록인 작업 모음에 실행을 위한 콜백을 추가하는 것입니다). 태스크를 생성하는 권장 방식은 asyncio.create_task() 를 사용하는 것입니다.

asyncio`는 태스크를 이벤트 루프와 자동으로 연결해 줍니다. 이러한 자동 연관은 단순성을 위해 :mod:!asyncio`에 의도적으로 설계된 기능입니다. 이 기능이 없다면, 사용자는 이벤트 루프 객체를 추적하고 태스크를 생성하려는 모든 코루틴 함수에 이를 전달해야 하며, 이는 코드에 불필요한 복잡함을 더하게 됩니다.

coroutine = loudmouth_penguin(magic_number=5)
# 이것은 Task 객체를 생성하고 이벤트 루프를 통해 실행을 예약합니다.
task = asyncio.create_task(coroutine)

앞선 예제에서 우리는 이벤트 루프를 수동으로 생성하고 무한히 실행되도록 설정했습니다. 실제로는 이벤트 루프 관리와 제공된 코루틴이 완료될 때까지 진행을 보장해 주는 asyncio.run() 을 사용하는 것이 권장되며 일반적입니다. 예를 들어, 많은 비동기 프로그램은 다음과 같은 설정을 따릅니다:

import asyncio

async def main():
    # 온갖 기발하고 파격적인 비동기 작업들을 수행합니다...
    ...

if __name__ == "__main__":
    asyncio.run(main())
    # 코루틴 main()이 종료될 때까지 프로그램은 다음 출력문에 도달하지 않습니다.
    print("coroutine main() is done!")

태스크 자체가 이벤트 루프에 추가되는 것이 아니라 태스크에 대한 콜백만 추가된다는 점을 유의해야 합니다. 이는 생성한 태스크 객체가 이벤트 루프에 의해 호출되기 전에 가비지 컬렉션(garbage collected)될 경우 중요하게 작용합니다. 예를 들어, 다음 프로그램을 살펴보십시오:

 1async def hello():
 2    print("hello!")
 3
 4async def main():
 5    asyncio.create_task(hello())
 6    # 일정 시간 동안 실행되며 이벤트 루프에 제어권을 넘기는
 7    # 다른 비동기 명령들...
 8    ...
 9
10asyncio.run(main())

5행에서 생성된 태스크 객체에 대한 참조가 없기 때문에 이벤트 루프가 이를 호출하기 전에 가비지 컬렉션될 수 있습니다. 코루틴 main() 의 후속 명령들은 다른 작업을 실행할 수 있도록 제어권을 이벤트 루프에 돌려줍니다. 이벤트 루프가 결국 태스크를 실행하려고 할 때, 태스크 객체가 존재하지 않아 실패할 수도 있습니다! 이는 코루틴이 태스크에 대한 참조를 유지하더라도 해당 태스크가 끝나기 전에 코루틴이 완료되는 경우에도 발생할 수 있습니다. 코루틴이 종료되면 로컬 변수가 범위를 벗어나 가비지 컬렉션의 대상이 될 수 있기 때문입니다. 실제로는 asyncio 와 파이썬의 가비지 컬렉터가 이러한 상황이 발생하지 않도록 상당히 노력합니다. 하지만 그렇다고 해서 부주의하게 코드를 작성해서는 안 됩니다.

await

await 은 일반적으로 다음 두 가지 방식 중 하나로 사용되는 파이썬 키워드입니다:

await task
await coroutine

결정적으로 await 의 동작은 대기(await)하는 객체의 유형에 따라 달라집니다.

태스크 대기

태스크를 기다리면(await) 현재 태스크나 코루틴에서 이벤트 루프로 제어권이 넘어갑니다. 제어권을 넘기는 과정에서 몇 가지 중요한 일이 발생합니다. 다음 코드 예제를 통해 설명하겠습니다:

async def plant_a_tree():
    dig_the_hole_task = asyncio.create_task(dig_the_hole())
    await dig_the_hole_task

    # 나무 심기와 관련된 다른 명령들...
    ...

이 예제에서 이벤트 루프가 코루틴 plant_a_tree() 의 시작 부분으로 제어권을 넘겼다고 가정해 봅시다. 위에서 보았듯이, 코루틴은 태스크를 생성하고 이를 대기합니다. await dig_the_hole_task 명령은 dig_the_hole_task 객체의 콜백 리스트에 (plant_a_tree() 를 재개할) 콜백을 추가합니다. 그런 다음, 해당 명령은 제어권을 이벤트 루프에 넘깁니다. 잠시 후 이벤트 루프는 dig_the_hole_task 에 제어권을 넘기고 태스크는 필요한 작업을 완료합니다. 태스크가 완료되면, 시스템은 다양한 콜백을 이벤트 루프에 추가하며, 이 경우 plant_a_tree() 를 재개하는 호출이 포함됩니다.

일반적으로 대기 중인 태스크(dig_the_hole_task)가 완료되면, 원래의 태스크 또는 코루틴(plant_a_tree())이 재개되기 위해 이벤트 루프의 할 일 목록에 다시 추가됩니다.

이것은 기본적이지만 신뢰할 수 있는 정신 모델입니다. 실제로 제어권의 전달은 약간 더 복잡하지만 아주 큰 차이는 없습니다. 파트 2에서 이를 가능하게 하는 세부 사항들을 살펴보겠습니다.

코루틴 대기

태스크와 달리 코루틴을 대기하는 것은 제어권을 이벤트 루프에 돌려주지 않습니다! 먼저 코루틴을 태스크로 감싸고 그 태스크를 기다리면 제어권이 넘어갑니다. await coroutine 의 동작은 사실상 일반적인 동기 파이썬 함수를 호출하는 것과 동일합니다. 다음 프로그램을 살펴보십시오:

import asyncio

async def coro_a():
   print("I am coro_a(). Hi!")

async def coro_b():
   print("I am coro_b(). I sure hope no one hogs the event loop...")

async def main():
   task_b = asyncio.create_task(coro_b())
   num_repeats = 3
   for _ in range(num_repeats):
      await coro_a()
   await task_b

asyncio.run(main())

코루틴 main()``의 번째 문장은 ``task_b``를 생성하고 이벤트 루프를 통해 실행을 예약합니다. 다음, ``coro_a()``가 반복적으로 대기됩니다. 제어권이 이벤트 루프에 넘어가지 않으므로, ``coro_b()``의 출력이 나오기 전에 번의 ``coro_a() 호출 결과가 모두 출력되는 것입니다.

I am coro_a(). Hi!
I am coro_a(). Hi!
I am coro_a(). Hi!
I am coro_b(). I sure hope no one hogs the event loop...

await coro_a()await asyncio.create_task(coro_a()) 로 변경하면 동작이 달라집니다. 코루틴 main() 은 해당 문장에서 제어권을 이벤트 루프에 넘깁니다. 그러면 이벤트 루프는 대기 중인 작업 목록을 처리하며, task_b 를 호출한 다음 coro_a() 를 포함하는 태스크를 실행하고 그 후 코루틴 main() 을 재개합니다.

I am coro_b(). I sure hope no one hogs the event loop...
I am coro_a(). Hi!
I am coro_a(). Hi!
I am coro_a(). Hi!

이러한 await coroutine 의 동작은 많은 사람을 혼란스럽게 할 수 있습니다! 해당 예제는 단지 await coroutine 만을 사용할 경우 의도치 않게 다른 태스크로부터 제어권을 독점하여 이벤트 루프를 사실상 정지시킬 수 있음을 보여줍니다. asyncio.run()debug=True 플래그를 통해 이러한 상황을 감지하는 데 도움을 주며, 이 플래그는 debug mode 를 활성화합니다. 이를 통해 100ms 이상 실행을 독점하는 코루틴을 로그에 기록하는 등 여러 기능을 수행합니다.

이 설계는 성능 향상을 위해 await 사용에 관한 개념적 명확성을 의도적으로 희생한 것입니다. 태스크를 대기할 때마다 제어권은 호출 스택을 따라 끝까지 올라가 이벤트 루프에 전달되어야 합니다. 그 후, 이벤트 루프는 내부 상태를 관리하고 처리 로직을 거쳐 다음 작업을 재개해야 합니다. 사소하게 들릴 수 있지만, 많은 await이 포함된 대규모 프로그램에서는 이러한 오버헤드가 무시할 수 없는 성능 저하로 이어질 수 있습니다.

개념적 개요 파트 2: 핵심 원리

파트 2에서는 asyncio 가 제어 흐름을 관리하기 위해 사용하는 메커니즘에 대해 자세히 설명합니다. 바로 이 부분이 마법이 일어나는 곳입니다. 이 섹션을 마치고 나면 여러분은 await 이 내부적으로 어떻게 작동하는지, 그리고 자신만의 비동기 연산자를 어떻게 만드는지 알게 될 것입니다.

코루틴의 내부 동작 원리

asyncio 는 제어권을 전달하기 위해 파이썬의 네 가지 구성 요소를 활용합니다.

coroutine.send(arg) 은 코루틴을 시작하거나 재개하는 데 사용되는 메서드입니다. 만약 코루틴이 일시 중단되었다가 현재 재개되는 중이라면, 인자 arg 는 원래 해당 코루틴을 중단시킨 yield 문에서 반환값으로 전달됩니다. 처음으로 코루틴을 사용하는 경우(재개가 아닌 경우)에는 arg 가 반드시 None 이어야 합니다.

 1class Rock:
 2    def __await__(self):
 3        value_sent_in = yield 7
 4        print(f"Rock.__await__ resuming with value: {value_sent_in}.")
 5        return value_sent_in
 6
 7async def main():
 8    print("Beginning coroutine main().")
 9    rock = Rock()
10    print("Awaiting rock...")
11    value_from_rock = await rock
12    print(f"Coroutine received value: {value_from_rock} from rock.")
13    return 23
14
15coroutine = main()
16intermediate_result = coroutine.send(None)
17print(f"Coroutine paused and returned intermediate value: {intermediate_result}.")
18
19print(f"Resuming coroutine and sending in value: 42.")
20try:
21    coroutine.send(42)
22except StopIteration as e:
23    returned_value = e.value
24print(f"Coroutine main() finished and provided value: {returned_value}.")

참고한 바와 같이, yield 는 실행을 중단하고 제어권을 호출자에게 돌려줍니다. 위 예제에서 3행의 yield 는 11행의 ... = await rock 에 의해 호출됩니다. 더 넓게 말하자면, await 은 주어진 객체의 __await__() 메서드를 호출합니다. 또한 await 은 매우 특별한 일을 하나 더 수행합니다. 바로 전달받은 모든 yield 를 호출 체인을 따라 위로 전파(또는

코루틴은 21행의 coroutine.send(42) 호출을 통해 재개됩니다. 코루틴은 3행에서 yield (또는 일시 중단)되었던 지점부터 다시 시작하여 본문의 나머지 문장들을 실행합니다. 코루틴이 종료되면, value 속성에 반환값이 포함된 StopIteration 예외를 발생시킵니다.

해당 코드 조각은 다음과 같은 출력을 생성합니다.

Beginning coroutine main().
Awaiting rock...
Coroutine paused and returned intermediate value: 7.
Resuming coroutine and sending in value: 42.
Rock.__await__ resuming with value: 42.
Coroutine received value: 42 from rock.
Coroutine main() finished and provided value: 23.

여기서 잠시 멈춰 제어 흐름과 값이 전달되는 다양한 방식을 제대로 이해했는지 확인해 볼 필요가 있습니다. 많은 중요한 개념들이 다루어졌으므로, 충분히 숙지되었는지 확인하는 것이 좋습니다.

코루틴에서 제어권을 양도(또는 효과적으로 양도)하는 유일한 방법은 __await__ 메서드 내에서 yield를 수행하는 객체를 await 하는 것입니다. 이것이 생소하게 들릴 수 있습니다. 여러분은 아마 이렇게 생각할지도 모릅니다.

1. What about a yield directly within the coroutine function? The coroutine function becomes an async generator function, a different beast entirely.

2. What about a yield from within the coroutine function to a (plain) generator? That causes the error: SyntaxError: yield from not allowed in a coroutine. This was intentionally designed for the sake of simplicity – mandating only one way of using coroutines. Despite that, yield from and await effectively do the same thing. Initially yield was barred as well, but was re-accepted to allow for async generators.

퓨처(Futures)

future 는 연산의 상태와 결과를 나타내기 위한 객체입니다. 이 용어는 아직 발생하지 않았거나 앞으로 일어날 무언가를 뜻하며, 해당 객체는 그 무언가를 지켜보는 수단이 됩니다.

Future는 몇 가지 중요한 속성을 가집니다. 하나는 상태로, 이는 “pending”, “cancelled” 또는 “done” 중 하나가 될 수 있습니다. 다른 하나는 결과이며, 상태가 done으로 전환될 때 설정됩니다. 코루틴과 달리 Future는 수행되어야 할 실제 계산을 나타내지 않고, 대신 해당 계산의 상태와 결과를 나타냅니다. 일종의 상태 표시등(빨강, 노랑, 초록)이나 표시기라고 생각하면 됩니다.

asyncio.Task 는 이러한 다양한 기능을 얻기 위해 asyncio.Future 를 서브클래스화합니다. 이전 섹션에서 태스크가 콜백 목록을 저장한다고 언급했지만 이는 완전히 정확한 설명은 아니었습니다. 실제 이 로직을 구현하는 것은 Future 클래스이며, Task 는 이를 상속받습니다.

Future는 태스크를 거치지 않고 직접 사용될 수도 있습니다. 태스크는 코루틴이 완료되면 스스로를 완료 상태로 표시합니다. 반면 Future는 훨씬 더 다재다능하며 사용자가 지정할 때 완료 상태로 표시됩니다. 이러한 방식으로, Future는 대기 및 재개에 대한 사용자 정의 조건을 설정하기 위한 유연한 인터페이스 역할을 합니다.

직접 만든 asyncio.sleep

asyncio.sleep`을 모방하는 사용자 정의 비동기 대기(``async_sleep`()) 변형을 생성하기 위해 Future를 활용하는 사례를 살펴보겠습니다.

이 코드 조각은 몇 개의 태스크를 이벤트 루프에 등록한 다음, async_sleep(3) 코루틴을 래핑하는 asyncio.create_task 로 생성된 태스크를 기다립니다(await). 우리는 다른 태스크의 실행을 방해하지 않으면서 해당 태스크가 정확히 3초 후에 완료되기를 원합니다.

async def other_work():
    print("I like work. Work work.")

async def main():
    # Add a few other tasks to the event loop, so there's something
    # to do while asynchronously sleeping.
    work_tasks = [
        asyncio.create_task(other_work()),
        asyncio.create_task(other_work()),
        asyncio.create_task(other_work())
    ]
    print(
        "Beginning asynchronous sleep at time: "
        f"{datetime.datetime.now().strftime("%H:%M:%S")}."
    )
    await asyncio.create_task(async_sleep(3))
    print(
        "Done asynchronous sleep at time: "
        f"{datetime.datetime.now().strftime("%H:%M:%S")}."
    )
    # asyncio.gather effectively awaits each task in the collection.
    await asyncio.gather(*work_tasks)

아래에서 우리는 해당 태스크가 언제 완료로 표시될지에 대한 사용자 정의 제어를 가능하게 하기 위해 Future를 사용합니다. future.set_result() (해당 Future를 완료 상태로 표시하는 역할을 하는 메서드)이 호출되지 않으면, 이 태스크는 결코 끝나지 않습니다. 또한 조금 뒤에 확인하게 될 또 다른 태스크의 도움을 받아 경과 시간을 모니터링하고 그에 따라 future.set_result() 를 호출합니다.

async def async_sleep(seconds: float):
    future = asyncio.Future()
    time_to_wake = time.time() + seconds
    # 감시자 태스크를 이벤트 루프에 추가합니다.
    watcher_task = asyncio.create_task(_sleep_watcher(future, time_to_wake))
    # Future가 완료 상태로 표시될 때까지 대기(block)합니다.
    await future

아래에서는 다소 단순한 YieldToEventLoop() 객체를 사용하여 __await__ 메서드에서 yield 를 수행하고 제어권을 이벤트 루프에 양도합니다. 이는 실질적으로 asyncio.sleep(0) 을 호출하는 것과 같지만, 이 방식이 더 명확함을 제공합니다. 게다가 구현 방법을 보여주는 상황에서 asyncio.sleep 을 사용하는 것은 약간 속임수나 다름없기 때문입니다.

평소와 같이 이벤트 루프는 태스크를 순환하며 제어권을 부여하고, 태스크가 일시 중지되거나 종료될 때 제어권을 다시 돌려받습니다. 코루틴 _sleep_watcher(...) 를 실행하는 watcher_task 는 이벤트 루프의 한 주기마다 한 번씩 호출됩니다. 재개될 때마다 시간(time)을 확인하며, 충분한 시간이 지나지 않았다면 다시 중단되고 제어권을 이벤트 루프에 반환합니다. 충분한 시간이 흐르면 _sleep_watcher(...) 는 Future를 완료 상태로 표시하고 무한 while 루프를 빠져나오며 종료됩니다. 이 도우미 태스크가 이벤트 루프의 한 주기마다 한 번만 호출된다는 점을 고려할 때, 이 비동기 대기가 정확히 3초가 아니라 최소 3초 동안 수행될 것이라고 언급하는 것은 올바른 지적입니다. 이는 asyncio.sleep 도 마찬가지입니다.

class YieldToEventLoop:
    def __await__(self):
        yield

async def _sleep_watcher(future, time_to_wake):
    while True:
        if time.time() >= time_to_wake:
            # Future를 완료 상태로 표시합니다.
            future.set_result(None)
            break
        else:
            await YieldToEventLoop()

전체 프로그램의 출력 결과는 다음과 같습니다.

$ python custom-async-sleep.py
Beginning asynchronous sleep at time: 14:52:22.
I like work. Work work.
I like work. Work work.
I like work. Work work.
Done asynchronous sleep at time: 14:52:25.

이 비동기 대기 구현이 불필요하게 복잡하다고 느낄 수도 있습니다. 실제로 그렇습니다. 이 예제는 더 복잡한 요구사항에 맞게 모방할 수 있는 단순한 사례를 통해 Future의 다재다능함을 보여주기 위한 것이었습니다. 참고로, 다음과 같이 Future 없이도 구현할 수 있습니다:

async def simpler_async_sleep(seconds):
    time_to_wake = time.time() + seconds
    while True:
        if time.time() >= time_to_wake:
            return
        else:
            await YieldToEventLoop()

지금은 여기까지입니다. 이제 더 자신 있게 비동기 프로그래밍에 뛰어들거나, 문서의 나머지 부분 에서 고급 주제를 확인해 보시기 바랍니다.