이벤트 루프는 실제로 무엇을 하는가
"이벤트 루프" 라는 이름이 주는 오해
이벤트 루프라는 이름 때문에 뭔가 특별한 스케줄러가 백그라운드에서 마법처럼 돌아간다고 상상하기 쉽습니다. 실체는 훨씬 단순합니다. 이벤트 루프는 그냥 while 문입니다. 실행할 준비가 된 콜백들을 담은 큐를 하나 들고, 큐가 빌 때까지(또는 프로그램이 끝날 때까지) 다음을 반복합니다.
- 지금 당장 실행 가능한 콜백을 큐에서 꺼내 실행한다.
- 파일 디스크립터(소켓, 파이프 등)를 감시하고 있다가, 준비된 것이 있으면 관련 콜백을 큐에 넣는다.
- 타이머(
asyncio.sleep등)가 만료됐으면 관련 콜백을 큐에 넣는다. - 1번으로 돌아간다.
asyncio.run() 을 호출하는 순간 이 루프가 시작되고, 코루틴 하나가 await 를 만날 때마다 "나는 지금 결과를 기다려야 하니, 다른 콜백을 실행해도 좋다"고 루프에게 제어권을 돌려줍니다. 이게 전부입니다. 별도의 스레드도, 별도의 프로세스도 필요 없습니다.
flowchart TD A["실행 가능한 콜백이 큐에 있는가?"] -->|예| B["콜백 하나 실행 — 코루틴이 await를 만나면 중단하고 반환"] B --> C["대기 중인 소켓/타이머 중 준비된 것이 있는가?"] A -->|아니오| C C -->|있음| D["해당 콜백을 큐에 추가"] D --> A C -->|없음| E["다음 타이머까지 블로킹 대기(epoll/select)"] E --> A
소켓 준비 여부는 누가 알려주는가 — select/epoll
2번 단계 "파일 디스크립트를 감시한다"가 핵심입니다. 리눅스에서 asyncio의 기본 이벤트 루프 구현(SelectorEventLoop, 또는 3.8+ 기본값인 ProactorEventLoop는 윈도우용)은 결국 epoll(리눅스) 같은 OS 커널 기능을 씁니다. epoll은 "이 소켓들 중에 읽을 데이터가 준비된 게 있으면 알려줘"라고 커널에 등록해두고, 데이터가 올 때까지 스레드를 재우는 시스템 콜입니다. 스레드가 능동적으로 폴링하며 CPU를 태우는 게 아니라, 커널이 깨워줄 때까지 잠들어 있다는 뜻입니다.
이 부분을 직접 체험할 수 있는 코드가 있습니다. asyncio.sleep(0) 은 아무것도 기다리지 않는 것 같지만, 실제로는 "지금 당장 제어권을 루프에 돌려줬다가, 다음 턴에 바로 다시 실행해달라"는 요청입니다.
import asyncio
async def worker(name: str, steps: int):
for i in range(steps):
print(f"{name} - step {i}")
await asyncio.sleep(0) # 제어권을 이벤트 루프에 양보
async def main():
await asyncio.gather(worker("A", 3), worker("B", 3))
asyncio.run(main())출력은 A - step 0, B - step 0, A - step 1, B - step 1 … 순서로 교차됩니다. worker 함수 안에는 스레드도, 병렬 실행도 없습니다. 매 await 지점마다 이벤트 루프가 다음 실행할 콜백을 고르고 있을 뿐입니다. 이 교차 실행이 바로 "동시성(concurrency)"이지 "병렬성(parallelism)"이 아닙니다 — 둘을 구분하지 못하면 다음 장부터 나오는 코드가 왜 그렇게 동작하는지 설명이 안 됩니다.
왜 이 사실이 실전에서 중요한가
이벤트 루프가 단일 스레드에서 콜백을 하나씩 순서대로 실행한다는 사실은, 뒤에서 다룰 거의 모든 함정의 근본 원인입니다. await 없이 실행되는 코드 — 즉 일반 동기 함수처럼 시간이 걸리는 코드 — 는 그 시간 동안 이벤트 루프 전체를 멈춥니다. 다른 코루틴이 아무리 많이 대기 중이어도, 지금 실행 중인 콜백이 양보하지 않으면 아무 일도 일어나지 않습니다. 6장에서 이 문제를 실제 코드로 재현해봅니다.