타입 힌트로 인터페이스를 정리했다면, 이제는 I/O를 효율적으로 처리하는 비동기 코드에 눈을 돌릴 차례입니다. Python 3.5 이후 도입된 async/await 키워드는 단일 스레드에서도 네트워크·파일 작업을 동시에 처리하는 구조를 제공합니다. 처음에는 새로운 용어 때문에 어렵게 느껴지지만, "대기 시간이 길면 잠깐 양보한다"라는 한 줄 요약을 기억하고 예시를 따라가면 생각보다 단순합니다.
이번 글에서 새로 나오는 용어
- 비동기: 기다려야 하는 작업을 겹쳐 실행해 전체 시간을 줄이는 방식
- 코루틴:
async def로 정의한 비동기 함수로await를 사용해 제어권을 돌려줌 - 태스크: 코루틴을 이벤트 루프에 등록해 실제로 실행되도록 만든 객체
- 이벤트 루프: 대기 중인 태스크를 관리하며 다시 실행시키는 스케줄러 역할의 러너
개념 정리
학습 메모
- 소요 시간: 70~90분(실습 포함)
- 준비물: requests·파일 IO 경험, 함수·타입 힌트 이해, 기본 테스트 습관
- 학습 목표: 코루틴을 정의하고
asyncio.run,gather,create_task흐름 안에서 I/O 대기 시간을 겹치기
- 비동기(asynchronous)는 대기 시간이 긴 작업을 겹쳐 처리하는 전략입니다.
- 코루틴(coroutine)은
async def로 만든 비동기 함수입니다. - 태스크(task)는 코루틴을 이벤트 루프에 등록해 실행 가능한 상태로 만든 객체입니다.
- 이벤트 루프(event loop)는 대기 중인 태스크를 돌려가며 다시 실행시키는 스케줄러입니다.
- Core 섹션에 먼저 집중하고, "선택 확장" 표시가 있는 부분은 나중에 돌아와도 됩니다.
코드로 이해하기
동기와 비동기의 차이 (Core)
- 동기(synchronous): 호출이 끝날 때까지 다음 작업을 기다립니다.
- 비동기(asynchronous): 작업이 대기 상태에 들어가면 제어권을 이벤트 루프로 돌려줍니다. 대기 중에도 다른 작업을 실행할 수 있습니다.
- 코루틴(coroutine):
async def로 만든 비동기 함수입니다. - 태스크(task): 코루틴을 이벤트 루프에 등록해 실제 실행 가능한 상태로 만든 객체입니다.
- 이벤트 루프(event loop): 대기 중인 비동기 작업을 순서대로 다시 깨워 실행하는 스케줄러입니다.
CPU 연산이 많은 경우에는 멀티프로세싱이나 C 확장이 필요하지만, 네트워크/디스크 대기가 길다면 비동기 모델이 큰 이득을 줍니다. 즉, async는 I/O 대기가 긴 작업에 적합하고, CPU를 계속 쓰는 계산 문제에는 오히려 큰 효과가 없을 수 있습니다.
코루틴 정의와 실행 (Core)
async def fetch_user(user_id: int) -> dict:
await asyncio.sleep(0.2)
return {"id": user_id, "name": "민지"}
async def main():
user = await fetch_user(1)
print(user)
asyncio.run(main())
async def로 정의한 함수는 코루틴(coroutine)입니다.await는 코루틴 결과가 준비될 때까지 양보(yield)합니다.asyncio.run은 이벤트 루프를 생성하고main을 실행합니다.
동시 실행: asyncio.gather (Core)
async def gather_users():
results = await asyncio.gather(
fetch_user(1),
fetch_user(2),
fetch_user(3),
)
return results
gather는 여러 코루틴을 동시에 예약하고 모든 결과를 리스트로 돌려줍니다. 한 작업이 대기 상태일 때 다른 작업이 진행됩니다.
기대 출력 비교
비동기는 설명만 보면 추상적이므로, 먼저 결과 숫자를 보는 편이 좋습니다.
동기 실행 결과
- fetch_user 3개 순차 호출
- 총 시간: 0.61s
비동기 실행 결과
- asyncio.gather로 3개 동시 호출
- 총 시간: 0.22s
이 비교가 뜻하는 바는 단순합니다. 계산이 빨라진 것이 아니라, 기다리는 시간을 겹쳐 썼기 때문에 전체 시간이 줄어든 것입니다.
태스크와 이벤트 루프 (Core → Plus)
gather로 동시에 돌려 본 경험이 있다면 이제 태스크 객체를 직접 다뤄 볼 차례입니다. 한 줄씩 천천히 따라 하며 코드 아래 주석으로 "지금 무엇을 기다리는가"를 적어 두면 이해가 빠릅니다.
- 태스크(Task): 코루틴 실행을 이벤트 루프에 등록한 객체입니다.
asyncio.create_task(coro)로 만들 수 있습니다. - 이벤트 루프(Event Loop): 대기 중인 작업을 관리하고 준비된 작업을 다시 실행하는 스케줄러입니다.
async def generate_reports(ids):
tasks = [asyncio.create_task(fetch_user(i)) for i in ids]
for task in tasks:
user = await task
print(user)
이 그림처럼 이벤트 루프가 태스크를 순환시키며 I/O 대기 시간 사이에 다른 작업을 실행합니다.
블로킹 코드 주의하기 (Core)
time.sleep같은 블로킹 함수는 이벤트 루프를 멈춥니다. 대신await asyncio.sleep()을 사용하세요.- CPU 집중 작업은
asyncio.to_thread또는concurrent.futures로 별도 스레드/프로세스로 보내야 합니다.
외부 라이브러리 (Optional)
- HTTP 클라이언트:
httpx,aiohttp - 데이터베이스:
asyncpg,databases - 웹 프레임워크:
FastAPI,Quart
라이브러리가 async 버전을 제공하는지 확인한 뒤 혼합 사용을 피하세요. 동기 함수 안에서 비동기 코드를 즉시 실행하려면 asyncio.run 같은 진입점을 명확히 합니다.
왜 중요할까
async/await는 I/O 대기 시간을 겹쳐 전체 처리량을 높여 줍니다.- 코루틴, 태스크, 이벤트 루프의 역할을 이해하면 구조 설계가 쉬워집니다.
- 블로킹 함수와 비동기 함수를 섞지 않도록 호출 경계를 명확히 해야 합니다.
실습
- 따라 하기:
fetch_user/main코드를 그대로 작성해asyncio.run(main())이 출력하는 시간을 확인합니다. - 확장하기:
asyncio.gather와create_task를 사용해 동시에 3개 이상의 코루틴을 실행하고 완료 순서 로그를 남깁니다. 여유가 있으면 외부 라이브러리로 httpx 비동기 요청을 시도합니다. - 디버깅:
time.sleep을 고의로 넣어 이벤트 루프가 멈추는 상황을 만들고await asyncio.sleep으로 바꿔 정상화합니다. - 완료 기준: 한 스크립트에서 동기·비동기 차이를 출력으로 비교하고
asyncio도구 두 가지 이상을 직접 실행해 본 기록이 있을 때입니다.
마무리
다음 글에서는 표준 라이브러리 탐색과 배포 패키징 기초를 통해 프로젝트를 외부에 공개하는 과정을 준비합니다.
💬 댓글
이 글에 대한 의견을 남겨주세요