지난 글에서 GIL을 다루고 나니, 자연스럽게 따라오는 의문이 하나 있습니다. "그럼 asyncio는 GIL 안에서 어떻게 그렇게 빠른 건가요?" 솔직히 저도 파이썬 처음 입문해서 asyncio를 봤을 때 한참 헷갈렸어요. 단일 스레드라며? 그런데 HTTP 요청 1만 개를 1초 안에 끝낸다고? 알고 보니 asyncio는 GIL을 우회하는 게 아니라, 애초에 GIL과 싸울 일이 없게 설계된 동시성 모델입니다. 오늘은 이벤트 루프가 어떻게 돌아가는지, async/await 한 줄에 무슨 일이 벌어지는지, 그리고 실무에서 자주 터지는 함정까지 정리해 볼게요.
개념 / 원리
먼저 threading과 asyncio의 본질적인 차이부터 짚어야 합니다. 스레드는 선점형(preemptive) 동시성이에요. OS가 마음대로 스레드를 멈추고 다른 스레드로 넘깁니다. 그래서 GIL이라는 잠금장치가 필요했고요.
반면 asyncio는 협력형(cooperative) 동시성입니다. 코루틴은 자기가 "지금 좀 기다려야 해, 다른 일 봐"라고 명시적으로 양보할 때만 다른 코루틴에 제어권이 넘어가요. 그 양보 지점이 바로 await입니다.
비유하자면 스레드는 콜센터에서 슈퍼바이저가 강제로 다른 상담원에게 콜을 넘기는 방식이고, asyncio는 상담원이 "고객님 잠시만요" 하고 자발적으로 대기 걸어두는 방식입니다.
그래서 asyncio는 사실 단일 스레드, 단일 코어로 도는 동시성 모델이에요. GIL은 여전히 그대로 있지만, 스레드가 1개뿐이니 뺏고 뺏길 일이 없습니다.
그럼에도 빠른 이유는 I/O 대기 시간을 활용하기 때문입니다. HTTP 응답을 기다리는 동안 CPU는 그냥 놀거든요. 그 노는 시간 동안 다른 요청을 띄워두면, 10개를 동시에 보내든 1만 개를 보내든 전체 시간은 가장 오래 걸리는 한 건과 비슷해집니다.
이벤트 루프는 대략 이렇게 굴러갑니다.
- 코루틴을 시작한다.
await some_io()만나면 OS에 "이거 끝나면 알려줘" 등록하고 코루틴을 잠재운다.- 깨어 있는 다른 코루틴을 골라 실행한다.
- OS가 I/O 완료를 알려오면, 잠들었던 코루틴을 깨워서 이어서 실행한다.
한 줄로 말하면: GIL을 푸는 게 아니라, GIL이 풀려도 어차피 별 의미 없도록 모델을 바꾼 것.
실전 예제
말로만 들으면 와닿지 않으니, 동기와 비동기 HTTP 요청을 비교해 봅시다. 동기 버전부터요.
import time
import requests
URLS = ["https://httpbin.org/delay/1"] * 10
def fetch_all_sync():
t = time.time()
for url in URLS:
r = requests.get(url)
r.raise_for_status()
print(f"sync : {time.time() - t:.2f}s")
if __name__ == "__main__":
fetch_all_sync()
10번 순차 호출이라 응답 대기 1초씩 곱하면 10초 넘게 걸립니다. 이걸 asyncio + httpx로 바꾸면 이렇게 됩니다.
import asyncio
import time
import httpx
URLS = ["https://httpbin.org/delay/1"] * 10
async def fetch(client, url):
r = await client.get(url)
r.raise_for_status()
async def fetch_all_async():
t = time.time()
async with httpx.AsyncClient(timeout=30) as client:
await asyncio.gather(*(fetch(client, u) for u in URLS))
print(f"async: {time.time() - t:.2f}s")
if __name__ == "__main__":
asyncio.run(fetch_all_async())
대략 1초대로 떨어집니다. 10배 빨라진 셈인데, CPU를 더 쓴 게 아니라 그동안 놀고 있던 CPU에 다른 요청 대기를 얹은 것뿐이에요. 코어를 8개 가진 머신이든 1개짜리든 결과가 비슷합니다.
핵심 키워드 몇 개만 짚자면 이렇습니다.
async def: 코루틴 함수를 정의. 호출해도 바로 실행되지 않고 코루틴 객체를 반환.await: 코루틴 실행을 일시 정지하고 결과를 기다림. 양보 지점.asyncio.gather(...): 여러 코루틴을 동시에 돌리고 모두 끝날 때까지 기다림.asyncio.run(coro): 이벤트 루프를 만들고 코루틴을 실행하는 진입점.
주의할 점
1) 코루틴 안에 동기 blocking 호출을 넣으면 모든 게 멈춥니다. 이게 가장 흔한 함정입니다. await asyncio.sleep(1)은 1초 동안 다른 코루틴에 양보하지만, time.sleep(1)은 이벤트 루프 자체를 1초 동안 정지시킵니다. requests.get()도 마찬가지예요. async 함수 안에서는 반드시 httpx, aiohttp처럼 비동기 지원 라이브러리를 써야 합니다.
2) DB 드라이버도 비동기여야 효과가 납니다. psycopg2나 pymysql은 동기 라이브러리라 async 코드 안에서 호출하면 이벤트 루프가 막힙니다. asyncpg, aiomysql, databases 같은 비동기 드라이버를 쓰거나, 정 안 되면 asyncio.to_thread() 또는 loop.run_in_executor()로 별도 스레드에 떠넘기는 방법이 있습니다.
import asyncio
def heavy_sync_call(x):
# 동기 라이브러리만 있는 경우
import time; time.sleep(1)
return x * 2
async def main():
result = await asyncio.to_thread(heavy_sync_call, 21)
print(result)
asyncio.run(main())
3) CPU bound 작업에는 asyncio도 답이 아닙니다. 단일 스레드라 코루틴이 양보를 안 하면 다른 코루틴이 전혀 못 돕니다. 무거운 계산은 여전히 multiprocessing 또는 ProcessPoolExecutor로 떠넘겨야 합니다. "async면 다 빨라진다"는 잘못된 직관이에요.
4) asyncio.run()은 한 번만 호출하세요. 이벤트 루프를 만들고 닫는 함수라, 한 프로그램에서 진입점은 보통 1개입니다. 함수 안에서 또 asyncio.run()을 부르면 RuntimeError가 납니다. 라이브러리를 만든다면 async def를 노출하고, 호출 쪽에서 await 또는 asyncio.run()을 한 번 부르도록 설계합니다.
5) await을 까먹으면 코루틴이 그냥 안 돕니다. fetch(client, url)로 끝내면 코루틴 객체만 만들어지고 RuntimeWarning: coroutine was never awaited가 뜹니다. 저는 디버깅 두 시간 한 적이 있어요. 타입 힌트(-> None 등)와 mypy를 같이 쓰면 사전에 잡힙니다.
마무리
정리하면 세 줄입니다.
asyncio는 GIL을 푸는 게 아니라, 단일 스레드 협력형 동시성으로 GIL과 싸울 일이 없게 만든 모델이다.await은 "지금 I/O 대기하니 다른 코루틴 도와줘"라고 명시적으로 양보하는 지점이고, 그 사이 이벤트 루프가 스케줄링을 한다.- 동기 blocking 호출 하나면 전체가 멈춘다 — async 코드 안에서는 라이브러리 선택부터가 일의 8할이다.
'IT·리눅스 > Python' 카테고리의 다른 글
| 제너레이터 vs 이터레이터 - Python yield는 언제 쓰나 (0) | 2026.07.09 |
|---|---|
| dataclass vs Pydantic - 언제 뭘 써야 할까 (0) | 2026.06.23 |
| multiprocessing vs threading - GIL 때문에 헷갈리는 두 모듈 정리 (0) | 2026.06.09 |
| 데코레이터 만들기와 활용 - @ 한 줄 뒤에 숨은 클로저 (0) | 2026.06.03 |
| GIL의 정체 - 왜 멀티스레드가 안 빠를까 (0) | 2026.05.26 |