25. async와 await
- 비동기 프로그래밍이 무엇이고 언제 쓰는지 이해한다.
async def로 코루틴을 만들고await로 기다린다.asyncio.gather로 여러 작업을 동시에 실행한다.- 스레드와 async의 차이를 안다.
먼저: async가 뭔가요?
24장에서 I/O 바운드 작업을 스레드로 동시 처리했습니다. async(비동기)는 같은 목표(대기 시간 겹치기)를 다른 방식으로 달성합니다. 특히 수백·수천 개의 동시 작업(많은 네트워크 요청 등)에 강합니다.
핵심 아이디어는 이렇습니다. 한 요리사가 1번 요리를 오븐에 넣고(await로 "기다림" 표시), 그 즉시 2번 요리를 시작합니다. 오븐이 끝나면 다시 1번으로 돌아옵니다. 혼자서도 여러 일을 매끄럽게 겹쳐 처리하죠. 스레드(여러 요리사)와 달리, async는 한 흐름이 똑똑하게 작업을 전환합니다.
먼저 효과를 봅시다. asyncio.sleep은 "비동기적으로 기다리기"입니다(그동안 다른 작업 진행 가능).
import asyncio import time async def say(msg, delay): await asyncio.sleep(delay) # 비동기 대기 return msg async def main(): start = time.perf_counter() results = await asyncio.gather( # 셋을 동시에! say("A", 0.1), say("B", 0.1), say("C", 0.1), ) print(f"{time.perf_counter()-start:.2f}초", results) asyncio.run(main()) # 0.10초 ['A', 'B', 'C'] (0.3초가 아니라 0.1초!)
각 작업이 0.1초씩이지만, 동시에 진행돼 전체가 0.1초에 끝났습니다.
async def와 await
비동기 코드의 두 핵심 키워드입니다.
async def: 비동기 함수(코루틴)를 정의한다.await: 비동기 작업이 끝나기를 기다린다. 기다리는 동안 다른 작업이 진행될 수 있다.
import asyncio async def fetch_data(): # async def → 코루틴 print("요청 시작") await asyncio.sleep(1) # 1초 대기 (그동안 양보) print("응답 도착") return "데이터"
⚠️ 코루틴은 그냥 부르면 실행되지 않는다
일반 함수와 결정적으로 다른 점입니다. async def 함수를 그냥 호출하면 실행되지 않고 "코루틴 객체"만 만들어집니다. 실제로 돌리려면 await하거나 asyncio.run으로 실행해야 합니다.
import asyncio async def greet(): return "안녕" coro = greet() # 실행 안 됨! 코루틴 객체만 생김 print(type(coro).__name__) # coroutine # 실제 실행하려면: result = asyncio.run(greet()) # 프로그램 진입점에서 print(result) # 안녕
await 없이 호출하면 아무 일도 안 일어나고, "coroutine was never awaited" 경고가 납니다. 코루틴은 반드시 await하거나 asyncio.run/gather에 넘겨야 실행됩니다.📎 코루틴(coroutine)은 "중간에 멈췄다 재개할 수 있는 함수"입니다. 중급편 14장의 제너레이터(yield로 멈췄다 재개)와 사촌 관계입니다.await가 그 "멈추는 지점"이 되어, 그 사이 다른 코루틴이 실행됩니다.
asyncio.run: 시작점
비동기 프로그램은 asyncio.run(코루틴)으로 시작합니다. 이것이 비동기 세계로 들어가는 입구입니다.
import asyncio async def main(): print("시작") await asyncio.sleep(1) print("끝") asyncio.run(main()) # 여기서 비동기 실행이 시작됨
asyncio.run은 프로그램에서 보통 한 번만, 최상위에서 호출합니다. 그 안에서 다른 코루틴들을 await로 부릅니다.⭐ 동시 실행: gather
async의 진가는 여러 작업을 동시에 돌릴 때 나옵니다. await를 하나씩 쓰면 순차 실행이지만, asyncio.gather로 묶으면 동시 실행입니다.
import asyncio import time async def task(n): await asyncio.sleep(0.1) return n * 10 async def main(): # ❌ 순차: 하나씩 기다림 → 0.3초 start = time.perf_counter() a = await task(1) b = await task(2) c = await task(3) print(f"순차: {time.perf_counter()-start:.2f}초") # 0.30초 # ✅ 동시: gather로 한꺼번에 → 0.1초 start = time.perf_counter() results = await asyncio.gather(task(1), task(2), task(3)) print(f"동시: {time.perf_counter()-start:.2f}초", results) # 0.10초 [10, 20, 30] asyncio.run(main())
flowchart TD
subgraph Seq["순차 await (0.3초)"]
direction LR
S1["task1<br/>0.1초"] --> S2["task2<br/>0.1초"] --> S3["task3<br/>0.1초"]
end
subgraph Gat["gather 동시 (0.1초)"]
direction LR
G1["task1 0.1초"]:::p
G2["task2 0.1초"]:::p
G3["task3 0.1초"]:::p
end
classDef p fill:#b8e6c1,stroke:#34a853,color:#14532d
리스트로 많은 작업을 만들어 한 번에 넘길 수도 있습니다(*로 펼치기, 중급 17장).
async def main(): tasks = [task(i) for i in range(100)] # 100개 코루틴 results = await asyncio.gather(*tasks) # 모두 동시 실행! print(len(results)) # 100
await를 하나씩 쓰면 순차, asyncio.gather(*코루틴들)로 묶으면 동시 실행이다. 동시성의 효과는 gather에서 나온다.create_task: 작업 예약
asyncio.create_task는 코루틴을 즉시 스케줄해 백그라운드로 돌리기 시작합니다. 만들어두고 나중에 await로 결과를 받습니다.
import asyncio async def work(n): await asyncio.sleep(0.1) return n * 10 async def main(): task1 = asyncio.create_task(work(1)) # 즉시 시작 task2 = asyncio.create_task(work(2)) # 즉시 시작 # 두 작업이 백그라운드로 도는 동안 다른 일 가능 r1 = await task1 # 결과 받기 r2 = await task2 print(r1, r2) # 10 20 asyncio.run(main())
gather가 "여러 개를 한 번에 모아 기다리기"라면, create_task는 "각각을 미리 시작해두고 개별로 기다리기"입니다. 둘 다 동시 실행을 만듭니다.
async vs 스레드: 무엇을 쓸까
둘 다 I/O 바운드에 쓰이지만 차이가 있습니다.
스레드 (ThreadPoolExecutor) | async (asyncio) | |
|---|---|---|
| 동시 작업 수 | 수십~수백 | 수천 이상도 |
| 코드 변경 | 기존 함수 그대로 | async/await 필요 |
| 라이브러리 | 대부분 호환 | 비동기 지원 라이브러리 필요 |
| 적합한 경우 | 간단한 병렬화 | 대규모 동시 I/O |
ThreadPoolExecutor), 수백~수천 개 동시 네트워크 작업 → async. async는 코드 전체가 비동기 방식이어야 하고, 쓰는 라이브러리도 비동기를 지원해야 효과가 납니다(예: 비동기 HTTP 라이브러리).time.sleep()(일반 sleep)이나 동기 네트워크 호출을 쓰면, 그동안 전체가 멈춰 async의 이점이 사라집니다. async 안에서는 asyncio.sleep처럼 비동기 버전을 써야 합니다. "async 세계에서는 async 도구만"이 원칙입니다.나쁜 예 ❌ vs 좋은 예 ✅
import asyncio async def fetch(n): await asyncio.sleep(0.5) return n # ❌ 나쁜 예: await를 하나씩 → 순차 (느림) async def slow(): results = [] for i in range(10): results.append(await fetch(i)) # 매번 끝까지 기다림 → 5초 return results # ✅ 좋은 예: gather로 동시 → 0.5초 async def fast(): return await asyncio.gather(*[fetch(i) for i in range(10)])
같은 작업이지만 gather를 쓰면 10배 빨라집니다. "여러 비동기 작업은 gather로 모아라"가 핵심입니다.
이 장에서 배운 것
- async는 한 흐름에서 작업을 전환하며 겹쳐 실행한다. 대규모 동시 I/O에 강하다.
async def로 코루틴을 정의하고await로 기다린다. 코루틴은 그냥 부르면 실행 안 되고,await/run/gather가 필요하다.asyncio.run으로 비동기 프로그램을 시작한다(보통 최상위에서 한 번).asyncio.gather(*코루틴들)로 여러 작업을 동시 실행한다 — 동시성의 효과는 여기서 나온다.create_task로 미리 예약할 수도 있다.- 간단한 병렬화는 스레드, 대규모 동시 I/O는 async. async 안에서는 블로킹 호출을 피하고 비동기 도구만 쓴다.
🧪 실습 문제
문제 1. async def로 정의한 함수를 그냥 func()로 호출하면 어떻게 되나요?
문제 2. 다음 코드의 실행 시간은 대략 몇 초일까요?
import asyncio async def t(): await asyncio.sleep(1) async def main(): await asyncio.gather(t(), t(), t()) asyncio.run(main())
문제 3. 문제 2에서 gather 대신 await t()를 세 번 순차로 쓰면 몇 초일까요?
문제 4. 코루틴 download(url)이 있을 때, URL 리스트 urls의 모든 다운로드를 동시에 실행해 결과를 받는 코드를 작성하세요. (gather 사용)
문제 5. async 코드 안에서 time.sleep(1)을 쓰면 왜 문제가 되나요? 무엇으로 바꿔야 하나요?
<details>
<summary>✅ 정답·해설 보기</summary>
1. 실행되지 않고 코루틴 객체만 생성됩니다. "coroutine was never awaited" 경고가 날 수 있습니다. await하거나 asyncio.run/gather에 넘겨야 실행됩니다.
2. 약 1초. 세 작업이 gather로 동시 실행되므로, 각 1초짜리가 겹쳐 전체 1초입니다.
3. 약 3초. 순차로 하나씩 기다리므로 1+1+1초입니다.
4.
import asyncio async def main(urls): results = await asyncio.gather(*[download(url) for url in urls]) return results
5. time.sleep은 동기(블로킹) 함수라, 그 1초 동안 전체 이벤트 루프가 멈춰 다른 코루틴이 진행되지 못합니다. async의 동시성 이점이 사라지죠. await asyncio.sleep(1)로 바꿔야 합니다.
</details>
◀️ 이전 장: 24. 동시성 기초 | ▶️ 다음 장: 26. 복잡도와 핵심 자료구조