이벤트 루프의 정체 — asyncio
지금까지 우리는 select, poll, epoll, kqueue를 직접 다뤘습니다. 손이 많이 가는 저수준 도구들이었습니다. 그런데 Python에는 이 모든 것을 우아하게 감싼 고수준 도구가 있습니다. asyncio입니다. 이 장에서 asyncio로 에코 서버를 다시 만들어 보고, 그 매끈한 표면 아래에 우리가 배운 epoll과 kqueue가 돌아가고 있음을 확인합니다.
콜백의 늪과 async await
epoll로 짠 서버를 떠올려 보세요. 이벤트가 올 때마다 어느 소켓인지 확인하고, 듣기 소켓인지 따지고, 딕셔너리에서 찾고, 상태를 직접 관리했습니다. 동작은 훌륭하지만 코드가 장황하고, 로직이 이벤트 처리 곳곳에 흩어집니다. 연결마다 진행 상태를 손으로 추적해야 해서, 복잡한 프로토콜을 다루면 코드가 금세 얽힙니다.
asyncio는 이 장황함을 걷어 냅니다. 핵심은 async와 await라는 두 단어입니다. 함수 앞에 async를 붙이면 비동기 함수가 되고, 그 안에서 시간이 걸리는 일 앞에 await를 붙이면 "여기서 기다리되, 기다리는 동안 다른 일을 처리해도 좋다"는 뜻이 됩니다.
이 한 줄의 의미가 깊습니다. 블로킹 서버에서 recv는 데이터가 올 때까지 멈춰 다른 일을 못 했습니다. 그런데 await reader.read()는 데이터를 기다리는 동안 다른 연결의 일을 처리하도록 흐름을 양보합니다. 멈추는 것이 아니라 양보하는 것입니다. 덕분에 한 흐름으로 수많은 연결을 동시에 다루면서도, 코드는 마치 한 연결만 상대하는 것처럼 단순하게 짤 수 있습니다.
asyncio 에코 서버
asyncio로 에코 서버를 만듭니다. epoll 서버와 비교하면 놀랄 만큼 짧습니다.
# 새 파일: async_server.pyimport asyncioasync def handle(reader, writer): addr = writer.get_extra_info("peername") print(f"{addr} 연결") while True: data = await reader.read(1024) if not data: break writer.write(data) await writer.drain() writer.close() print(f"{addr} 종료")async def main(): server = await asyncio.start_server(handle, "127.0.0.1", 9000) print("asyncio 에코 서버 대기 중") async with server: await server.serve_forever()asyncio.run(main())
handle 함수를 보세요. 마치 PART 02에서 만든 한 클라이언트짜리 에코 서버처럼 단순합니다. await reader.read(1024)로 데이터를 받고, writer.write(data)로 돌려주고, await writer.drain()으로 다 보내질 때까지 기다립니다. 그런데 이 단순한 함수가 수많은 클라이언트에 대해 동시에 돌아갑니다. asyncio가 알아서 여러 handle을 한 흐름 안에서 번갈아 실행해 주기 때문입니다.
await reader.read()에서 데이터를 기다리는 동안, asyncio는 다른 클라이언트의 handle로 흐름을 옮겨 일을 처리합니다. 한 연결이 기다리는 시간을 다른 연결의 일로 채우는 것입니다. 우리가 epoll로 손수 짜던 그 일을, asyncio가 우리 대신 해 줍니다.
writer.drain()은 PART 02에서 강조한 부분 송신과 관련이 있습니다. 보낼 데이터가 운영체제 버퍼에 다 들어갈 때까지 기다려, 받는 쪽이 따라오지 못해 버퍼가 넘치는 것을 막습니다. 백프레셔를 다루는 장치인데, 바로 다음 장의 주제입니다.
이벤트 루프가 정체다
asyncio의 심장은 이벤트 루프입니다. 이름이 익숙하지 않나요. 사실 우리는 이미 이벤트 루프를 여러 번 만들었습니다. PART 04의 select 서버, 이 PART의 epoll 서버와 kqueue 서버. 모두 "준비된 것을 기다렸다가, 준비된 것을 처리하고, 다시 기다리는" 무한 반복이었습니다. 그것이 바로 이벤트 루프입니다.
asyncio의 이벤트 루프도 똑같은 일을 합니다. 차이는 그 루프를 우리가 손으로 짜지 않고 asyncio가 대신 돌려 준다는 것입니다. 그리고 그 루프의 엔진으로, asyncio는 운영체제가 제공하는 가장 좋은 도구를 씁니다. 리눅스에서는 epoll을, macOS에서는 kqueue를 내부적으로 사용합니다. 앞서 본 selectors 모듈이 바로 그 다리 역할을 합니다.
이 사실을 깨닫는 것이 이 PART의 큰 수확입니다. asyncio는 마법이 아닙니다. 우리가 손으로 짠 그 epoll 루프를, 더 우아한 문법으로 감싸 자동화한 것입니다. async와 await라는 매끈한 표면 아래에서, 우리가 이 PART에서 배운 epoll과 kqueue가 묵묵히 돌아가고 있습니다. 저수준을 직접 만들어 본 우리는, 이제 asyncio를 쓰면서도 그 안에서 무슨 일이 벌어지는지 훤히 알 수 있습니다.
무엇을 쓸 것인가
그렇다면 실무에서는 무엇을 쓸까요. 대부분의 경우 asyncio처럼 고수준 도구를 씁니다. 코드가 단순하고, 운영체제 차이도 알아서 처리되고, 충분히 빠릅니다. epoll이나 kqueue를 손으로 짜는 일은 흔치 않습니다.
그렇다면 저수준을 배운 것은 헛수고일까요. 결코 아닙니다. asyncio가 느리게 동작하거나, 이상하게 멈추거나, 예상과 다르게 행동할 때, 그 아래에서 epoll이 어떻게 도는지 아는 사람만이 문제를 풀 수 있습니다. 추상화는 평소에는 고맙지만 언젠가 새며, 그때 진가를 발휘하는 것은 그 아래를 아는 지식입니다. PART 00에서 한 그 약속을, 우리는 이 PART에서 지킨 셈입니다.
asyncio의 정체를 밝혔습니다. 다음 장에서는 빠른 서버에 반드시 필요한 또 하나의 주제, 받는 쪽이 따라오지 못할 때 생기는 백프레셔와 그 대처법을 다룹니다.