파이썬의 async/await가 10년째임에도 대중화되지 못한 이유를 네트워크/파일 I/O의 한계, GIL, 스레드·코루틴 모델 차이, 프리 스레딩과 다중 인터프리터(3.14) 도입, 동기/비동기 API 이중 유지보수와 테스트 비용 관점에서 짚고, 앞으로의 병렬성·동시성 방향을 제시합니다.
Python Documentary가 오늘 아침 공개됐다. 다큐 중반에는 Python 2에서 3으로의 전환이 커뮤니티를 분열시켰다는 극적인 장면이 등장한다(스포일러: 결국 그렇지 않았다).
파이썬 3의 초기 버전(3.0–3.4)은 주로 안정성과 2.7에서 넘어오는 사용자를 위한 이행 경로에 초점을 맞췄다. 그러던 2015년, 3.5에서 새로운 기능이 등장했다: 코루틴 실행을 위한 async와 await 키워드.
10년과 아홉 번의 릴리스가 지난 지금, 파이썬 3.14는 출시가 몇 주 남지 않았다.
모두가 3.14의 반짝이고 알록달록한 REPL 기능에 시선을 빼앗기겠지만, 릴리스 노트에는 동시성과 병렬성에 관련된 굵직한 발표가 숨어 있다.

이 두 기능은 파이썬이 동시 코드를 실행하는 방식에서 엄청난 도약이다. 그런데 async가 이미 10년이나 있었는데, 왜 이들이 필요할까?
async의 킬러 유스케이스는 웹 개발이다. 코루틴은 HTTP 요청과 데이터베이스 쿼리 같은 프로세스 외부 네트워크 호출에 잘 맞는다. 다른 서버에서 실행되는 SQL 쿼리를 기다리느라 파이썬 인터프리터 전체를 막아둘 필요가 있을까?
그런데 가장 인기 있는 세 가지 파이썬 웹 프레임워크 중에서도 async 지원은 아직 보편적이지 않다. FastAPI는 바닥부터 async로 설계됐고, Django는 일부 지원하지만 ORM(데이터베이스) 같은 핵심 영역에서 여전히 “async 지원 작업 중”이다. Flask는 지금도, 아마 앞으로도 동기 방식일 것이다(Quart는 유사 API를 가진 async 대안). 파이썬에서 가장 인기 있는 ORM인 SQLAlchemy는 2023년에야 asyncio 지원을 추가했다(변경 기록).
나는 몇몇 개발자들에게 “왜 async가 더 대중적이지 않을까”라는 질문을 던져보았다.
Christopher Trudeau, Real Python Podcast 공동 진행자는 이렇게 말했다:
어떤 종류의 오류는 컴파일러가 잡아주지만, 어떤 것들은 그냥 사라진다. 왜 그 함수가 실행되지 않았지? 앗, await를 빼먹었네. 코루틴에서 오류가 났다고? 올바른 파라미터로 실행했는지? 아니면 위로 전파되지 않는다. 나는 아직도 스레드가 더 이해하기 쉽다.
Michael Kennedy는 이런 통찰을 덧붙였다:
[GIL]이 너무 만연해서 대부분의 파이썬 개발자는 멀티스레딩/async 사고방식을 체득하지 못했다. 게다가 async/await는 I/O 바운드에서만 먹히고 CPU 작업에는 먹히지 않아서 쓸모가 훨씬 적다. 예컨대 웹에서는 쓸 수 있어도, 대부분의 서버는 어차피 4–8개의 웹 워커로 포크 뜬다.
그렇다면 여기서 무슨 일이 벌어지고 있는 걸까? 또, 3.14의 프리 스레딩과 다중 인터프리터에 이 교훈을 적용해 10년 뒤에 가서는 왜 ‘그것들’이 더 인기 없었는지 되묻게 되는 상황을 피할 수 있을까?
코루틴은 I/O 관련 작업에 가장 큰 가치를 발휘한다. 파이썬에서는 수백 개의 코루틴을 시작해 네트워크 요청을 보낸 다음, 하나씩 실행하지 않고 모두 끝나기를 기다릴 수 있다. 코루틴의 개념은 꽤 단순하다. 루프(이벤트 루프)가 있고, 거기에 평가할 코루틴을 넘긴다.
고전적인 HTTP 요청 사례로 돌아가 보자:
def get_thing_sync():
return http_client.get('/thing/which_takes?ages=1')
동등한 async 함수는 깔끔하고 읽기 쉽다:
async def get_thing_async():
return await http_client.get('/thing/which_takes?ages=1')
get_thing_sync()를 호출하는 것과 await get_thing_async()를 호출하는 것은 걸리는 시간이 똑같다. ‘✨ 비동기로 ✨’ 호출한다고 해서 갑자기 빨라지는 게 아니다. 이득은 여러 코루틴이 동시에 실행될 때 생긴다.
여러 HTTP 리소스를 가져올 때는 OS 네트워크 스택을 통해 모든 요청을 한꺼번에 시작해 각 응답이 도착하는 대로 처리할 수 있다. 중요한 점은 실제 작업 — 패킷 전송과 원격 서버 대기 — 이 여러분의 파이썬 프로세스 밖에서 일어나고, 여러분의 코드는 기다린다는 것이다. async의 효율은 여기에 있다. 작업을 시작하고, await할 수 있는 핸들(태스크/퓨처)을 받고, 이벤트 루프는 각 작업이 완료될 때 코루틴에 효율적으로 알려준다. 바쁜 대기(busy‑polling)로 CPU를 낭비하지 않는다.
이 시나리오가 잘 맞는 이유는 다음과 같다.
여기까지는 좋다. 하지만 나는 아까 “코루틴은 I/O 관련 작업에 가장 가치가 있다”라고 해놓고, asyncio가 정말 잘하는 한 가지 작업인 HTTP를 골랐다.
그렇다면 디스크 I/O는 어떨까? 나는 파이썬으로 디스크나 메모리에서 파일을 읽고 쓰는 응용 프로그램이 HTTP 요청을 보내는 것보다 훨씬 많다. 또한 subprocess로 다른 프로그램을 실행하는 파이썬 프로그램도 있다.
그 모든 것을 async로 만들 수 있을까?
아니다, 사실상 어렵다. asyncio 위키는 이렇게 말한다:
asyncio는 파일시스템에 대한 비동기 연산을 지원하지 않는다. 파일을 O_NONBLOCK으로 열어도, read와 write는 블로킹된다.
해결책은 파일 I/O를 비동기로 다룰 수 있게 해주는 서드파티 패키지 aiofiles를 쓰는 것이다:
async with aiofiles.open('filename', mode='r') as f:
contents = await f.read()
그럼 임무 완수인가? 아니다. aiofiles는 블로킹 파일 I/O를 오프로딩하기 위해 실제로는 스레드 풀을 사용한다.
Windows에는 IoRing이라는 비동기 파일 I/O API가 있다. 리눅스에서는 최신 커널에 io_uring이 있다. 파이썬에서 io_uring 구현을 찾아보면 Cython으로 작성된 동기 API 정도만 보인다.
다른 플랫폼에도 io_uring 계열 API가 있었다. 러스트는 tokio로, C++는 Asio, Node.js는 libuv를 갖고 있다.
그러니 asyncio 위키는 조금 오래됐다고 할 수 있지만,
io_uring이다.io_uring은 보안 문제에 시달려서 Red Hat, Google 등 여러 곳에서 접근을 제한하거나 사용을 없애기도 했다. Google은 io_uring 관련 버그 바운티로 100만 달러를 지급한 끝에 일부 제품에서 이를 비활성화했다. 문제는 심각했다. 관련 버그 바운티 보고서 상당수에 io_uring 익스플로잇이 포함됐다.그러니 당분간은 서두르지 말고 기다리는 게 좋겠다. 운영체제는 오래전부터 동시 I/O를 위해 스레드를 다루는 파일 I/O API를 제공해왔다. 지금으로서는 그걸로 충분히 일이 된다.
정리하면, “코루틴은 I/O 관련 작업에 가장 가치가 있다”는 말은 파이썬에서는 사실상 네트워크 I/O에만 맞는 말이다. 그리고 네트워크 소켓은 애초에 파이썬에서 블로킹 연산이 아니었다. 파이썬의 소켓 오픈은 GIL을 해제하는 몇 안 되는 연산 중 하나이며, 논블로킹 연산으로 스레드 풀에서 동시 실행된다.
| 연산 | asyncio API | 설명 |
|---|---|---|
| 잠자기 | asyncio.sleep() | 주어진 시간만큼 비동기적으로 잠자기. |
| TCP/UDP 스트림 | asyncio.open_connection() | TCP/UDP 연결 열기. |
| HTTP | aiohttp.ClientSession() | 비동기 HTTP 클라이언트. |
| 서브프로세스 실행 | asyncio.subprocess | 서브프로세스를 비동기적으로 실행. |
| 큐 | asyncio.Queue | 비동기 큐 구현. |
Rich, Textualize 등 매우 인기 있는 여러 파이썬 라이브러리의 제작자인 Will McGugan은 async에 대해 이렇게 말했다:
나는 async 프로그래밍을 정말 좋아하지만, 네트워크 코드를 써본 배경이 없는 대부분의 개발자에게는 직관적이지 않다. Textual에서 반복적으로 보는 문제는, 사람들이 계획 중인 작업을 시뮬레이션하려고
time.sleep(10)을 끼워 넣어 동시성을 테스트한다는 점이다. 물론 그건 루프 전체를 막아버린다. 하지만 이것은 async를 많이 써보지 않은 개발자에게 설명하기 어려운 종류의 이슈다. 즉, 코드가 “블록한다”는 게 무슨 뜻인지, 언제 스레드에 위임해야 하는지. 이런 기초가 없으면 async 코드는 오동작하기 쉽다. 다만 당장 깨지지는 않는다. 그래서 개발자들은 파이썬에서 기대하는 빠른 반복과 피드백을 얻지 못한다.
async의 제한된 사용처를 살펴봤으니, 또 다른 도전이 반복해서 나타난다. 바로 파이썬의 GIL이다.
나는 CSnakes라는 C#/파이썬 브리지 프로젝트를 작업해왔는데, 가장 많은 고민을 낳은 기능 중 하나가 async였다.
async/await 문법을 차용해 온 C#은 TAP(Task‑based Asynchronous Pattern)을 구현해 코어 I/O 라이브러리에서 훨씬 광범위한 async 지원을 제공한다. 작업은 관리형 스레드 풀에 디스패치되고, 디스크·네트워크·메모리 I/O는 일반적으로 동기/비동기 메서드를 모두 제공한다.
사실 C#의 구현은 디스크에서부터 직렬화 라이브러리 같은 상위 레벨 API까지 올라간다. [JSON 역직렬화도 async](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializer.deserializeasync?view=net-9.0#system-text-json-jsonserializer-deserializeasync(system-io-stream-system-type-system-text-json-serialization-jsonserializercontext-system-threading-cancellationtoken)이고, XML도 마찬가지다.
C#의 Async 모델과 파이썬의 Async 모델에는 중요한 차이가 있다:
C# 모델의 장점은 Task가 스레드나 코루틴 위의 더 높은 추상화라는 점이다. 즉, 하부의 스레드 관리를 신경 쓰지 않고도 여러 태스크를 동시에 await하거나, TPL(Task Parallel Library)로 병렬 실행할 수 있다.
파이썬에서는 “이벤트 루프는 한 스레드(보통 메인 스레드)에서 실행되며, 그 스레드에서 모든 콜백과 태스크를 실행한다. 루프에서 한 태스크가 실행되는 동안에는 같은 스레드에서 다른 태스크가 실행될 수 없다. 태스크가 await 표현식을 실행하면 그 태스크는 일시 중단되고, 이벤트 루프는 다음 태스크를 실행한다.” 1
Will의 말 “물론 그건 루프 전체를 막아버린다”로 돌아가 보자. 그는 async 함수 내부의 블로킹 연산을 말하고 있으며, 따라서 이벤트 루프 전체가 막힌다. 1번 문제에서 다뤘듯이, 네트워크 호출과 잠자기를 빼면 사실상 대부분이 그렇다.
파이썬의 GIL이 있는 한, 스레드가 1개든 10개든 상관없이 GIL은 한 번에 하나만 실행되도록 잠근다.

GIL을 막지 않는 연산(예: 파일 I/O)도 있고, 그런 경우에는 스레드에서 돌릴 수 있다. 예를 들어 httpx의 스트리밍 기능을 사용해 큰 네트워크 다운로드를 디스크로 스트리밍한다면:
import httpx
import tempfile
def download_file(url: str):
with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
with httpx.stream("GET", url) as response:
for chunk in response.iter_bytes():
tmp_file.write(chunk)
return tmp_file.name
httpx의 스트림 이터레이터도, tmp_file.write도 GIL을 막지 않으므로 별도 스레드에서 돌리면 이점을 얻는다.
이 동작을 asyncio API와 결합해 이벤트 루프의 run_in_executor()에 스레드 풀을 넘겨 사용할 수 있다:
import asyncio
import concurrent.futures
async def main():
loop = asyncio.get_running_loop()
URLS = [
"https://example.place/big-file-1",
"https://example.place/big-file-2",
"https://example.place/big-file-3",
# etc.
]
tasks = set()
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as pool:
for url in URLS:
tasks.add(loop.run_in_executor(pool, download_file, url))
files = await asyncio.gather(*tasks)
print(files)
이게 스레드 풀을 돌리고 pool.submit을 호출하는 것보다 무엇이 좋은지 나는 바로 와닿지 않는다. 다만 async API를 유지한다는 점에서, 그게 중요하다면 흥미로운 우회로다.
무엇이 블로킹인지, 무엇이 블로킹이 아닌지(그리고 그 경계가 파이썬에서 계속 변한다는 사실)를 외우고 문서화하고 설명하는 일은 혼란스럽고 피곤하다.
파이썬 3.13에는 GIL을 제거하고 더 작고 세밀한 락으로 대체한, 매우 불안정한 “프리 스레딩” 빌드가 도입됐다. 병렬성 요약은 내 PyCon US 2024 발표를 참고하라. 3.13의 빌드는 프로덕션에 쓰기엔 충분히 안정적이지 않았다. 3.14는 훨씬 개선되어 보이고, 2026년쯤에는 제한적이고 충분히 테스트된 시나리오에서 프리 스레딩을 도입해 볼 수 있다고 본다.
코루틴이 스레드보다 갖는 주요 장점 하나는 메모리 풋프린트가 훨씬 작고, 컨텍스트 스위칭 오버헤드가 낮으며, 시작이 빠르다는 점이다. async API는 구성하고 추론하기도 더 쉽다.
파이썬에서 스레드를 이용한 병렬성이 오랫동안 제한적이었던 탓에, 표준 라이브러리의 API는 꽤 소박하다. 프리 스레딩이 안정화되면 표준 라이브러리에 태스크 병렬성 API를 도입할 기회가 있다고 생각한다.
지난주에 나는 두 가지 분리된 작업을 수행하는 레지스트리 함수를 구현했다. 하나는 아주 느린 동기 전용 API를 호출하고, 다른 하나는 여러 async API를 호출한다.
내가 원하는 동작은 다음과 같다:
flowchart LR
Start([시작]) --> Invoke["tpl.invoke()"]
Invoke --> f1["f1()"]
Invoke --> f2["f2()"]
f1 -->|f1 -> T1| Join["Tuple[T1, T2]"]
f2 -->|f2 -> T2| Join
Join --> End([끝])
태스크가 두 개뿐이니, 스레드 풀이나 워커 수를 따로 정하고 싶지 않다. 또 피호출자들을 매핑하거나 gather하고 싶지도 않다. 타입 정보를 유지해서, 결과 변수가 function_a와 function_a의 반환 타입에서 강한 타입을 얻도록 하고 싶다. 본질적으로 이런 API다:
import tpl
def function_a() -> T1:
...
def function_b() -> T2:
...
result_a: T1, result_b: T2 = tpl.invoke(function_a, function_b)
이건 오늘날에도 가능하긴 하지만, GIL 때문에 제약이 많다. 프리 스레딩은 파이썬에서 병렬 프로그래밍을 더 대중화할 것이고, 여러 API를 재고해야 할 것이다.
패키지 메인트너 입장에서, 동기와 비동기 API를 모두 지원하는 일은 큰 도전이다. 어디에 async를 지원할지 선택적이어야 하기도 한다. 표준 라이브러리의 많은 부분은 네이티브 async를 지원하지 않는다(예: 로깅 백엔드).
파이썬의 매직(__dunder__) 메서드는 async가 될 수 없다. 예컨대 __init__는 async가 될 수 없으므로, 초기화자에서 네트워크 요청을 사용할 수 없다.
조금 특이한 패턴이지만, 요지를 보여주기 위해 단순하게 가자. User 클래스에 records라는 프로퍼티가 있고, 이 프로퍼티는 해당 사용자의 레코드 목록을 준다. 동기 API는 간단하다:
class User:
@property
def records(self) -> list[RecordT]:
# 데이터베이스에서 지연(lazy) 조회
...
지연 초기화된 인스턴스 변수를 써서 이 데이터를 캐시할 수도 있다.
이 API를 async로 옮기는 일은 쉽지 않다. @property 메서드는 async가 될 수 있지만, 표준 속성은 그렇지 않기 때문이다. 어떤 인스턴스 속성은 await하고, 어떤 것은 안 해도 되는 상황은 매우 이상한 API를 만든다:
class AsyncDatabase:
@staticmethod
async def fetch_many(id: str, of: Type[RecordT]) -> list[RecordT]:
...
class User:
@property
async def records(self) -> list[RecordT]:
# 데이터베이스에서 지연(lazy) 조회
return await AsyncDatabase.fetch_many(self.id, RecordT)
이 프로퍼티에 접근할 때마다 await해야 한다:
user = User(...)
# 단일 접근
await user.records
# if
if await user.records:
...
# 컴프리헨션?
[record async for record in user.records]
이 구현을 더 파고들수록, 사용자가 프로퍼티에 await을 빠뜨리고 조용히 실패하도록 방치하게 된다.
방대한 파이썬 프로젝트인 Azure Python SDK는 동기와 비동기를 모두 지원한다. 이를 유지하려면 많은 코드 생성 인프라가 필요하다. 상근 엔지니어가 수십 명인 프로젝트라면 괜찮지만, 작은 프로젝트나 자원봉사 프로젝트에서는 async 버전을 만들기 위해 코드 베이스의 상당 부분을 복사+붙여넣기 해야 한다. 그러고 나면 수정과 변경을 양쪽에 패치하고 백포트해야 한다. 차이점(대체로 await 호출)은 Git이 헷갈릴 정도로 크다. 작년에 langchain 구현 몇 개를 리뷰했는데, 동기/비동기 구현이 모두 있었고, 모든 메서드가 복붙돼 있었으며, 작은 동작 차이와 각자의 버그가 있었다. 사람들이 버그 수정 PR을 한쪽 구현에만 올려서, 메인트너는 바로 머지하는 대신 수정사항을 다른 쪽에 이식하거나, 건너뛰거나, 기여자에게 양쪽 모두 수정해달라고 부탁해야 했다.
우리가 주로 다루는 건 HTTP/네트워크 I/O이므로, 동기/비동기 각각에 백엔드를 골라야 한다. 동기 HTTP 호출에는 requests, httpx가 적합하다. async에는 aiohttp, httpx가 있다. 이들 모두 표준 라이브러리의 일부가 아니므로, CPython의 주요 플랫폼 지원과 채택 속도가 엇박자다. 예를 들어 현재 시점에서 aiohttp는 파이썬 3.14 휠도, 프리 스레딩 지원도 없다. 이벤트 루프의 대안 구현인 uvloop 역시 파이썬 3.14 지원이 없고, Windows 지원도 없다. (파이썬 3.14가 아직 나오지 않았으니, 두 오픈소스 프로젝트에 지원이 없어도 합리적이다.)
복붙 유지보수의 연장선이 테스트다. async 코드를 테스트하려면 다른 목, 다른 호출 방식이 필요하고, Pytest에서는 픽스처를 위한 확장과 패턴 전체가 따로 필요하다. 이 상황이 너무 헷갈려서 글을 썼는데, 내 블로그에서 가장 인기 있는 글 중 하나다.
요약하면, asyncio의 사용처는 제한적이라고 생각한다(asyncio의 통제 범위를 넘어선 이유가 대부분) — 그리고 이것이 인기의 성장을 제약했다. 중복된 코드 베이스를 유지하는 일도 부담이다.
바닥부터 async 기반인 웹 프레임워크 FastAPI는 인기가 다시 상승해 파이썬 웹 프레임워크 점유율 29%에서 38%로 올라 1위를 차지했다. 월 다운로드 1억 회를 넘는다. async의 가장 큰 사용처가 HTTP와 네트워크 I/O임을 고려하면, 1위 웹 프레임워크가 async라는 사실은 asyncio의 성공을 보여준다.
나는 3.14의 서브 인터프리터 실행기와 프리 스레딩 기능이 더 많은 병렬·동시성 사용처를 실용적으로 만든다고 본다. 그런 경우에는 async API가 꼭 필요하지 않으며, 이 글에서 강조한 많은 문제를 덜어준다.