동기와 비동기를 이해할 때 저는 카페의 진동벨을 떠올립니다. 커피를 주문한 뒤 카운터 앞에서 기다려야 한다면, 커피가 나올 때까지 다른 일을 하기 어렵습니다. 반면 진동벨을 받으면 자리에 앉아 이야기를 나누다가 벨이 울릴 때 커피를 받으러 갈 수 있습니다.
커피를 만드는 시간은 같습니다. 달라진 것은 기다리는 동안 무엇을 할 수 있는가입니다.
프로그램도 비슷합니다. 서버의 응답을 기다리거나 데이터베이스에서 결과를 받아 오는 동안, 그 결과가 필요하지 않은 다른 작업을 처리할 수 있습니다. 비동기 I/O는 이런 대기 시간을 활용하는 방법입니다.
그런데 코드를 보면 조금 헷갈립니다. await도 결국 기다린다는 뜻인데, 일반 함수가 끝날 때까지 기다리는 것과 무엇이 다를까요? JavaScript가 한 스레드에서 실행된다면 여러 요청은 어떻게 처리할까요?
이 글에서는 이 질문에서 출발해 Node.js의 이벤트 루프와 libuv, Python의 코루틴과 asyncio를 살펴보겠습니다. 마지막에는 Python의 이벤트 루프를 교체하는 uvloop가 어떤 역할을 하는지도 정리합니다.
주문한 커피를 받아 출력하는 함수를 생각해 보겠습니다.
coffee = order_coffee()
print(coffee)동기 함수인 order_coffee()가 결과를 반환해야 다음 줄을 실행할 수 있습니다. 흐름이 순서대로 이어지므로 읽기 쉽습니다. 다만 함수 안에서 네트워크 응답을 기다린다면, 호출한 스레드가 그동안 다음 작업을 하지 못할 수 있습니다.
비동기 방식에서는 작업의 완료와 이후 처리를 나누어 다룹니다. 요청을 시작한 뒤 결과가 준비되면 콜백을 실행하거나, 중단해 둔 함수의 나머지 부분을 이어 갑니다. 그 사이에는 다른 작업을 처리할 여지가 생깁니다.
여기서 동기·비동기와 블로킹·논블로킹은 구분할 필요가 있습니다.
예를 들어 논블로킹 소켓은 아직 읽을 데이터가 없으면 즉시 돌아올 수 있습니다. 그렇다고 결과가 준비되었을 때 나머지 코드를 실행해 주는 구조까지 자동으로 생기지는 않습니다. 재시도하거나 준비 알림을 받아 처리하는 방법이 따로 필요합니다.
진동벨 비유는 기다림에서 벗어난다는 감각을 이해하는 데 도움이 됩니다. 실제 프로그램에서는 작업이 준비되었다는 사실을 감지하고, 이어서 실행할 코드를 선택하는 역할도 필요합니다. 그 역할을 맡는 것이 이벤트 루프입니다.
Node.js에서 JavaScript 코드는 보통 하나의 이벤트 루프 스레드에서 실행됩니다. 그렇다면 파일을 읽는 동안에는 어떻게 다른 요청을 처리할 수 있을까요?
다음 예제는 주문 내역 파일을 읽습니다. 같은 디렉터리에 coffee-orders.txt를 만들고, 코드를 read-orders.mjs로 저장한 뒤 node read-orders.mjs로 실행할 수 있습니다.
import { readFile } from "node:fs/promises";
async function main() {
console.log("주문 내역을 읽기 시작합니다.");
const orders = await readFile("coffee-orders.txt", "utf8");
console.log(orders);
}
main().catch((error) => {
console.error("주문 내역을 읽지 못했습니다:", error.message);
process.exitCode = 1;
});readFile()은 비동기 파일 읽기를 시작하고 Promise를 반환합니다. Promise는 작업의 성공 결과나 실패를 나중에 전달받기 위한 객체입니다.
await에서는 main()의 이후 실행을 미룹니다. 파일 내용이 아직 없으니 console.log(orders)로 넘어갈 수는 없습니다. 하지만 기다리는 동안 이벤트 루프 스레드를 붙잡지는 않으므로, 실행할 다른 콜백이 있다면 처리할 수 있습니다.
파일 읽기가 완료되어 Promise가 이행되면 await 다음 부분이 마이크로태스크로 실행됩니다. 이벤트 루프가 파일 읽기 옆에 서서 기다리는 것이 아니라, 결과가 준비된 뒤 JavaScript 실행을 이어 주는 구조입니다.
Node.js의 이벤트 루프와 비동기 I/O를 뒷받침하는 라이브러리는 libuv입니다. 다만 모든 작업을 같은 방식으로 처리하지는 않습니다.
네트워크 I/O에는 운영체제의 기능을 활용합니다. Linux의 epoll과 macOS·BSD의 kqueue는 소켓의 읽기·쓰기 준비 상태를 알려 주고, Windows의 IOCP는 I/O 완료를 통지합니다. libuv는 이런 차이를 추상화합니다.
반면 앞의 파일 읽기와 같은 비동기 파일 시스템 작업은 일반적으로 libuv의 스레드 풀에서 처리합니다. 일부 DNS 작업도 이 풀을 사용합니다. 작업 스레드에서 처리를 마치면 이벤트 루프 쪽으로 결과가 전달됩니다. 자세한 구조는 libuv 설계 문서에 설명되어 있습니다.
따라서 “Node.js는 싱글 스레드다”라는 설명은 JavaScript를 실행하는 중심 흐름을 이해하는 데는 도움이 되지만, 런타임 전체를 설명하지는 못합니다. 운영체제와 작업 스레드가 함께 움직이며, 별도로 Worker Threads를 사용하는 것도 가능합니다.
이 구조에서도 JavaScript로 오래 걸리는 계산을 직접 실행하면 이벤트 루프를 막습니다. I/O가 끝나 있어도 그 결과를 처리할 JavaScript가 실행 기회를 얻지 못할 수 있습니다. Node.js 공식 문서가 이벤트 루프를 막지 말라고 강조하는 이유입니다.
Python에서는 asyncio를 사용해 이러한 실행 흐름을 구성합니다. async def로 정의한 함수는 코루틴 함수이며, 실행 중 잠시 멈췄다가 이어 갈 수 있는 코드를 작성하는 데 사용합니다.
import asyncio
async def order_coffee(name: str) -> str:
await asyncio.sleep(1)
return f"{name} 완성"
async def main() -> None:
coffee = await order_coffee("아메리카노")
print(coffee)
asyncio.run(main())여기서는 커피를 기다리는 시간을 asyncio.sleep(1)로 표현했습니다. 대기 중에는 실행권을 이벤트 루프에 돌려주고, 타이머가 만료되면 다시 실행될 수 있습니다. 그동안 실행할 다른 Task가 있다면 이벤트 루프가 처리합니다.
asyncio.run(main())은 이 예제의 진입점입니다. 이벤트 루프를 준비해 main()을 실행하고, 종료할 때 남은 작업과 루프를 정리합니다.
JavaScript의 async 함수는 호출하면 본문 실행을 시작하고 Promise를 반환합니다. Python의 코루틴 함수는 호출할 때 코루틴 객체를 만들며, 본문은 아직 실행하지 않습니다.
앞에서 정의한 order_coffee()를 사용하면 다음처럼 확인할 수 있습니다. 아래 코드는 실행 중인 async main() 안에 넣습니다.
coffee = order_coffee("아메리카노") # 아직 본문은 실행되지 않음
print(await coffee) # 실행하고 결과를 기다림코루틴을 별도의 작업으로 스케줄링하려면 asyncio.create_task() 등을 사용할 수 있습니다. 이때 만들어지는 Task는 코루틴의 실행과 완료 상태를 관리하는 객체입니다. 생성한 코루틴을 실행하지 않은 채 버리면 경고가 발생할 수 있습니다.
또한 Python에서 await가 항상 다른 Task로 실행권을 넘기는 것은 아닙니다. 기다리는 대상이 실제로 중단되는 지점에서 다른 작업이 실행될 수 있습니다. 코루틴과 Task의 구분은 Python 공식 문서에서도 확인할 수 있습니다.
비동기 함수를 만들었다고 여러 작업이 자동으로 함께 진행되는 것은 아닙니다. 다음 두 호출은 순서대로 실행됩니다.
americano = await order_coffee("아메리카노")
latte = await order_coffee("카페라테")첫 번째 결과를 받은 뒤에야 두 번째 코루틴을 호출합니다. 각 주문이 1초씩 걸리면 총 대기 시간은 약 2초입니다. 각 대기 중에 다른 Task가 실행될 수는 있지만, 이 두 주문의 대기는 겹치지 않습니다.
두 주문이 서로의 결과에 의존하지 않는다면 asyncio.gather()로 함께 실행할 수 있습니다. 차이를 직접 측정해 보겠습니다.
import asyncio
from time import perf_counter
async def order_coffee(name: str) -> str:
await asyncio.sleep(1)
return f"{name} 완성"
async def main() -> None:
start = perf_counter()
await order_coffee("아메리카노")
await order_coffee("카페라테")
print(f"순차 실행: {perf_counter() - start:.2f}초")
start = perf_counter()
orders = await asyncio.gather(
order_coffee("아메리카노"),
order_coffee("카페라테"),
)
print(f"동시 진행: {perf_counter() - start:.2f}초")
print(orders)
asyncio.run(main())실행 시간은 환경에 따라 조금씩 다르지만, 대략 다음 결과를 얻습니다.
순차 실행: 2.00초
동시 진행: 1.00초
['아메리카노 완성', '카페라테 완성']각 주문의 대기 시간은 여전히 1초입니다. 두 번째 방식은 그 시간을 겹쳐 사용한 것입니다. gather()의 결과 목록은 완료 순서가 아니라 전달한 순서대로 반환됩니다.
이를 **동시성(concurrency)**이라고 합니다. 여러 작업을 진행 중인 상태로 두고 번갈아 처리하는 것입니다. 여러 CPU 코어에서 계산을 같은 순간에 수행하는 **병렬성(parallelism)**과는 구분됩니다.
다만 이 예제는 서로 독립적인 대기 두 개를 흉내 낸 것입니다. 실제 카페의 커피 머신이나 서버의 데이터베이스처럼 처리 용량에 제한이 있다면, 작업을 함께 시작해도 전체 시간이 그만큼 줄어들지는 않을 수 있습니다.
앞의 order_coffee()를 다음처럼 바꾸면 어떻게 될까요?
import time
async def order_coffee(name: str) -> str:
time.sleep(1)
return f"{name} 완성"asyncio.sleep()과 달리 time.sleep()은 현재 스레드를 멈춥니다. 이벤트 루프와 같은 스레드에서 실행하므로 다른 Task도 기다려야 합니다. 앞의 비교 예제에 이 함수를 넣으면 gather()로 실행해도 약 2초가 걸립니다.
함수 선언에 async가 있어도 내부의 동기 호출을 자동으로 바꿔 주지는 않습니다. 네트워크나 데이터베이스 작업도 이벤트 루프에서 기다릴 수 있도록 비동기를 지원하는 API를 사용해야 합니다.
기존 동기 I/O 함수를 사용해야 한다면 별도 스레드로 보내는 방법도 있습니다. 예를 들어 실행 중인 코루틴 안에서 파일을 읽을 수 있습니다.
from pathlib import Path
orders = await asyncio.to_thread(
Path("coffee-orders.txt").read_text,
encoding="utf-8",
)이 코드는 파일 읽기를 다른 스레드에서 수행해 이벤트 루프가 직접 기다리는 일을 피합니다. 다만 스레드에서 수행하는 작업 자체가 더 빨라지는 것은 아닙니다.
오래 걸리는 계산은 별도로 판단해야 합니다. 일반적인 GIL 활성화 CPython에서 순수 Python 계산을 여러 스레드로 옮긴다고 CPU 병렬 처리가 되는 것은 아닙니다. 이런 작업은 프로세스로 분리하거나 GIL을 해제하는 연산 라이브러리를 사용하는 방식을 검토할 수 있습니다. to_thread()의 용도와 제약은 스레드 실행 문서에 설명되어 있습니다.
여기까지는 코루틴을 어떻게 작성하고 실행하는지 살펴봤습니다. 그렇다면 그 코루틴을 관리하는 이벤트 루프 자체를 바꿀 수도 있을까요?
Python의 asyncio는 이벤트 루프 구현을 교체할 수 있습니다. 그중 uvloop는 Cython으로 작성된 libuv 기반 구현입니다. Node.js의 I/O 처리에서 등장했던 라이브러리를 Python의 이벤트 루프 구현에도 사용하는 것입니다.
사용 방법은 간단합니다. uvloop를 지원하는 Linux나 macOS 환경에서 설치합니다. Windows에서는 uvloop를 직접 사용할 수 없으므로 기본 asyncio 루프를 사용하거나 WSL 같은 Linux 환경에서 실행해야 합니다.
pip install uvloopimport asyncio
import uvloop
async def main() -> None:
await asyncio.sleep(1)
print("완료")
uvloop.run(main())asyncio.run(main())을 uvloop.run(main())으로 바꿨습니다. uvloop.run()은 uvloop 0.18부터 제공되는 실행 방법이며, 코루틴을 작성하는 async와 await 문법은 그대로입니다. 사용법은 uvloop 공식 저장소에서 확인할 수 있습니다.
이벤트 루프는 I/O 이벤트를 처리하고 실행 가능한 작업을 다시 깨우는 일을 반복합니다. 이 처리 비용이 성능에 영향을 주는 프로그램이라면 uvloop가 도움이 될 수 있습니다.
하지만 앞의 asyncio.sleep(1)이 더 짧아지거나, 데이터베이스가 쿼리를 더 빨리 처리하는 것은 아닙니다. 동기 호출이 이벤트 루프를 막는 문제 역시 그대로 남습니다.
따라서 적용 여부는 실제 작업으로 비교하는 편이 좋습니다. 같은 요청과 동시 연결 수를 사용해 처리량, 응답 시간, CPU 사용량이 어떻게 달라지는지 확인해야 합니다. 위 예제는 이벤트 루프를 교체하는 방법을 보여 주며, 성능을 비교하는 벤치마크는 아닙니다.
libuv를 공유한다고 Node.js와 Python의 실행 규칙까지 같아지는 것도 아닙니다. Promise와 코루틴의 동작은 각 언어와 런타임이 정합니다. uvloop는 그중 Python의 이벤트 루프 구현을 바꾸는 선택지입니다.
비동기를 이해할 때는 async라는 표시보다 어디서 기다리고, 그동안 누가 실행될 수 있는지를 살펴보는 것이 도움이 됩니다.
Node.js에서는 Promise로 비동기 결과를 전달받고, Python에서는 코루틴과 Task로 중단과 재개를 구성합니다. 이벤트 루프는 그 사이에 실행 가능한 일을 처리하며, libuv는 Node.js와 uvloop에서 I/O 처리의 기반을 제공합니다.
진동벨이 커피를 더 빨리 만들지는 않지만, 기다리는 동안 다른 일을 할 수 있게 해 주는 것처럼 비동기 I/O도 대기 시간을 활용합니다. 이 구분을 알고 나면 비동기가 유용한 작업과, 계산이나 외부 시스템의 성능을 먼저 개선해야 하는 작업을 나누어 볼 수 있습니다.