본문 바로가기
Python

비동기 프로그래밍에 대하여

by 윤팍 2025. 6. 24.

📌 1. 비동기 프로그래밍이란?

대부분의 백엔드 개발 환경에서는 웹 요청, 파일 입출력, DB 쿼리 등 입출력(I/O) 대기 시간이 긴 작업들이 많습니다. 이런 상황에서 쓰레드를 무작정 늘리는 방식은 비용도 크고 한계가 있습니다.
Python의 비동기 프로그래밍은 async/await 문법을 기반으로, 동시성(concurrency) 을 효율적으로 처리해줍니다.

 

✅ 동기 vs 비동기

 
방식 설명 예시
동기 순차적으로 처리 DB 조회 → 응답 대기 → 다음 요청
비동기 대기 시간 중 다른 작업 수행 DB 요청 중 파일 읽기 진행

 

✅ 기본 개념 정리

용어 설명
Coroutine async def로 정의된 함수. 호출 시 실행되지 않고, 코루틴 객체를 반환함
await 코루틴이나 awaitable 객체의 실행을 일시 중단하고, 다른 작업 수행 기회를 줌
이벤트 루프 (Event Loop) 대기 중인 비동기 작업들을 순차적으로 실행해주는 중심 컨트롤러
Task 코루틴을 이벤트 루프에 등록하여 동시 실행이 가능하도록 만든 객체
Future 비동기 작업의 결과나 예외를 담는 그릇

 

🧭 2. 언제 비동기를 써야 할까?

 

실무에서는 다음과 같은 상황에서 비동기가 큰 효과를 발휘합니다:

  • 외부 API 연동: 여러 외부 API를 동시에 호출해야 할 때
  • DB + 파일 저장: DB 저장과 동시에 로그 파일도 써야 할 때
  • 웹 크롤링: 수백 개 URL을 빠르게 요청해야 할 때

단, CPU 연산이 많은 작업(예: 이미지 처리, 대용량 통계 분석 등)은 asyncio 보단 멀티프로세싱이 적합합니다.

 

🧩 3. 전체 흐름 예제

아래 코드를 기준으로 설명드릴게요:

import asyncio

async def say_after(delay, what):
    print(f"start: {what}")
    await asyncio.sleep(delay)
    print(f"done: {what}")

async def main():
    task1 = asyncio.create_task(say_after(2, 'hello'))
    task2 = asyncio.create_task(say_after(1, 'world'))
    print("Tasks created")
    await task1
    await task2

asyncio.run(main())

 

🔍 단계별 흐름 설명

1️⃣ asyncio.run(main()) 호출

  • 이벤트 루프가 시작됩니다.
  • main()은 코루틴 함수 → 실행되지 않고 코루틴 객체가 반환됨.
  • 이벤트 루프는 해당 코루틴을 Task로 감싸 실행합니다.

✅ 내부적으로는 loop.create_task(main()) → loop.run_until_complete(task) 형태로 실행

 

2️⃣ main() 실행

task1 = asyncio.create_task(say_after(2, 'hello'))
task2 = asyncio.create_task(say_after(1, 'world'))

 

  • say_after()도 async def → 실행 X → 코루틴 객체 반환
  • create_task()는 해당 코루틴을 Task로 등록 → 즉시 실행 가능 상태로 만듬
  • 이 시점에 두 작업이 이벤트 루프 큐에 등록됨 (둘 다 동시 실행 가능)
이벤트 루프 큐:
  Task1: say_after(2, "hello")
  Task2: say_after(1, "world")

 

 

3️⃣ await task1

  • main()은 task1이 끝나길 기다림 → 여기서 중단점 발생
  • task1은 say_after(2, 'hello')의 실행을 시작함
    • print("start: hello")
    • await asyncio.sleep(2)에서 sleep task 등록 후 일시 중단
  • 이때 루프는 다른 task(task2)로 넘어감

4️⃣ task2 실행

  • say_after(1, 'world') 실행됨
    • print("start: world")
    • await asyncio.sleep(1)에서 1초 타이머 등록 후 일시 중단

→ 이제 루프는 1초 대기 후 다시 wake up.

 

5️⃣ 1초 후: task2 재개

  • await asyncio.sleep(1) 완료 → print("done: world")
  • task2 종료됨

6️⃣ 2초 후: task1 재개

  • await asyncio.sleep(2) 완료 → print("done: hello")
  • task1 종료됨

7️⃣ task1, task2 모두 완료 → main() 종료 → asyncio.run() 종료

 

📊 실행 결과 (타이밍 기준)

start: hello
start: world
Tasks created
done: world     ← 1초 후
done: hello     ← 2초 후

 

🔁 전체 처리 흐름 정리

1. asyncio.run(main()) -> 이벤트 루프 시작
2. main() 진입 → task1, task2 생성 및 등록
3. task1 await → 실행 잠시 멈춤 → 루프가 task2 실행
4. task2 await → 멈춤 → 루프가 1초 대기
5. 1초 후 task2 resume → 종료
6. 2초 후 task1 resume → 종료
7. main resume → 종료
8. 루프 종료

 

🧠 실무 응용 팁

  • create_task()를 쓰면 작업을 병렬 실행할 수 있음.
  • await는 현재 task를 중단시키고, 루프에게 다른 작업 기회를 줌
  • asyncio.sleep()도 I/O 작업의 예시임 (DB, API, 파일 등으로 대체 가능)
  • 동시 처리할 작업이 많을 때는 gather()로 묶어서 처리하면 좋음

⚠️ 5. 비동기 사용 시 주의할 점

❌ 블로킹 코드와 혼용 금지

아래처럼 time.sleep() 같은 동기 함수를 사용하면 비동기 코드가 무력화됩니다.

# 잘못된 예시
def slow():
    time.sleep(3)

# 올바른 방식
async def slow():
    await asyncio.sleep(3)

 

💥 예외처리도 신경 써야

gather()를 쓸 땐 return_exceptions=True를 넣어 예외를 개별로 다루세요:

results = await asyncio.gather(fetch(url1), fetch(url2), return_exceptions=True)

 

이벤트 루프 중복 실행 방지

  • asyncio.run()은 루프를 하나만 실행 가능
  • FastAPI/Quart 등 프레임워크 내 루프와 충돌할 수 있음 → 루프 중복 확인 필수

 

🧰 7. 추천 비동기 라이브러리

라이브러리 용도 특징
aiohttp HTTP 요청 FastAPI보다 가볍고 빠름
aiomysql MySQL 비동기 클라이언트 커넥션 풀 관리 지원
aioredis Redis 비동기 클라이언트 pub/sub, stream 지원
anyio 다양한 백엔드 호환 Trio, asyncio 모두 지원

 

🧾 마무리하며

Python의 asyncio는 초반 진입장벽이 다소 있지만, 실무에서 병렬 처리 성능을 극대화하고 싶다면 반드시 익혀야 할 기술입니다. 특히 다음과 같은 경우에 비동기는 큰 도움이 됩니다:

  • 다수의 API 호출을 병렬로 처리해야 할 때
  • 파일, DB, 외부 서비스 요청이 복합적으로 얽힌 로직에서
  • 웹 서버에서 처리량(Throughput)을 높이고 싶을 때

동기 방식으로 처리하면 막히는 부분에서 시스템 전체가 느려질 수 있지만, 비동기는 그런 병목 구간을 유연하게 넘길 수 있는 무기가 되어줍니다.

실무에서는 비동기와 블로킹 코드의 혼용, 세션 관리 누락, 예외 처리 미흡 등으로 예상치 못한 문제가 종종 발생하므로, 작동 원리를 정확히 이해하고 설계에 반영하는 것이 중요합니다.