[Python 시리즈 18편] async/await로 비동기 기본기 다지기

English version

타입 힌트로 인터페이스를 정리했다면, 이제는 I/O를 효율적으로 처리하는 비동기 코드에 눈을 돌릴 차례입니다. Python 3.5 이후 도입된 async/await 키워드는 단일 스레드에서도 네트워크·파일 작업을 동시에 처리하는 구조를 제공합니다. 처음에는 새로운 용어 때문에 어렵게 느껴지지만, "대기 시간이 길면 잠깐 양보한다"라는 한 줄 요약을 기억하고 예시를 따라가면 생각보다 단순합니다.

이번 글에서 새로 나오는 용어

  1. 비동기: 기다려야 하는 작업을 겹쳐 실행해 전체 시간을 줄이는 방식
  2. 코루틴: async def로 정의한 비동기 함수로 await를 사용해 제어권을 돌려줌
  3. 태스크: 코루틴을 이벤트 루프에 등록해 실제로 실행되도록 만든 객체
  4. 이벤트 루프: 대기 중인 태스크를 관리하며 다시 실행시키는 스케줄러 역할의 러너

개념 정리

학습 메모

  • 소요 시간: 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 확장이 필요하지만, 네트워크/디스크 대기가 길다면 비동기 모델이 큰 이득을 줍니다. 즉, asyncI/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)
이벤트 루프asyncio.runcreate_taskTask1~N비동기 I/Oawait fetch_user결과 소비print/log 스케줄await로 제어권 양도I/O 완료 통보데이터 전달

이 그림처럼 이벤트 루프가 태스크를 순환시키며 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.gathercreate_task를 사용해 동시에 3개 이상의 코루틴을 실행하고 완료 순서 로그를 남깁니다. 여유가 있으면 외부 라이브러리로 httpx 비동기 요청을 시도합니다.
  • 디버깅: time.sleep을 고의로 넣어 이벤트 루프가 멈추는 상황을 만들고 await asyncio.sleep으로 바꿔 정상화합니다.
  • 완료 기준: 한 스크립트에서 동기·비동기 차이를 출력으로 비교하고 asyncio 도구 두 가지 이상을 직접 실행해 본 기록이 있을 때입니다.

마무리

다음 글에서는 표준 라이브러리 탐색과 배포 패키징 기초를 통해 프로젝트를 외부에 공개하는 과정을 준비합니다.

💬 댓글

이 글에 대한 의견을 남겨주세요