환영합니다, Rolling Ress의 카루입니다.
이번에는 비동기 프로그래밍입니다. 프로그래밍 언어마다 스레드를 지원하는 방법은 다르지만, 최근에는 async / await을 이용하여 비동기 프로그래밍을 지원하는 언어가 많아졌습니다. 이러한 언어들은 함수 선언 앞에 async를 붙이고, await을 통해 기다린다는 공통점이 있습니다. 파이썬 또한 3.5 버전부터 async / await을 지원합니다.
동기 프로그래밍 (Synchronous)
import time
def make_coffee(name, delay):
print(f"[{name}] 커피 추출 시작...")
time.sleep(delay) # 일시 정지 -> 대기 상태
print(f"[{name}] 커피 완성!")
start = time.time()
make_coffee("아메리카노", 2)
make_coffee("카페라떼", 3)
print(f"동기 방식 총 소요 시간: {time.time() - start:.1f}초")
비동기 프로그래밍의 단점은 설명하는 사람도 어렵고 이해하는 사람도 난해하다는 점입니다. 그래서, 우선 동기 프로그래밍에 대해서 알아볼 겁니다. 카페에서 음료를 주문하고 있습니다. 직원은 두 명이 있네요. 앞 사람은 아메리카노를 시켰는데, 저는 카페라떼를 시켰습니다. 그런데 두 명이서 아메리카노를 함께 만들고, 아메리카노가 완성되고 나서야 카페라떼를 만들기 시작하는 겁니다. 비효율의 극치가 따로 없죠.
아메리카노 제작에 2초, 카페라떼 제작에 3초. 제가 카페라떼를 받기까지 5초의 시간이 걸렸습니다. 이걸 동시에 실행할 수 없을까요?
비동기 프로그래밍 (Asynchronous)
import asyncio
import time
# async 함수: coroutine
async def make_coffee_async(name, delay):
print(f"[{name}] 커피 추출 시작...")
# time.sleep() 대신 asyncio.sleep()
# await: 제어권 넘기기
await asyncio.sleep(delay)
print(f"[{name}] 커피 완성!")
async def main():
start = time.time()
# asyncio.gather(): 여러 코루틴 동시에 작동
await asyncio.gather(
make_coffee_async("아메리카노", 2),
make_coffee_async("카페라떼", 3)
)
print(f"비동기 방식 총 소요 시간: {time.time() - start:.1f}초")
asyncio.run(main())
함수 앞에 async를 붙이면 그 함수는 코루틴이 됩니다. 사실 async 키워드 자체는 큰 의미가 없다고 보아도 됩니다. async는 해당 함수 내에서 await 키워드를 사용할 수 있게 해주는 도구입니다. async가 붙은 비동기 함수는 일반 함수처럼 그냥 호출할 수 없기 때문에, asyncio.run을 통해 이벤트 루프를 이용하여 실행해야 합니다.
async main()을 보겠습니다. 시간을 재기 시작하고 asyncio.gather를 사용하여 두 개의 함수(코루틴)를 동시에 호출합니다. 그럼 async make_coffee_async()가 호출됩니다. async 함수 내에서 실행 흐름을 잠시 멈추고 싶을 때는 time.sleep() 대신 await asyncio.sleep()을 이용해야 합니다. async 함수의 경우 함수를 단순히 호출하면 함수가 실행되지 않고 코루틴 객체만 반환됩니다. await 키워드를 사용하여야 그 코루틴 객체를 실행하고, 함수가 반환할 때까지 기다릴 수 있습니다.
import asyncio
import time
# 서버에 데이터를 요청하는 가상의 비동기 함수
async def fetch_user_info(user_id):
print(f"▶️ [유저 {user_id}] 정보 요청 시작...")
await asyncio.sleep(1) # 네트워크 지연 1초 가정
# 작업이 끝나면 딕셔너리 데이터를 반환합니다.
return {"id": user_id, "name": f"사용자_{user_id}", "status": "정상"}
async def main():
print("=== 1. 단일 비동기 함수의 반환값 받기 ===")
# 잘못된 코드: await를 안 쓰면 데이터가 아니라 '껍데기(코루틴)'가 들어옵니다.
# bad_result = fetch_user_info(999)
# print(bad_result) # <coroutine object fetch_user_info at 0x...>
# await를 써야만 진짜 반환값이 변수에 담깁니다.
single_result = await fetch_user_info(777)
print(f"✅ 단일 반환값: {single_result['name']}님의 상태는 {single_result['status']}")
print("\n")
print("=== 2. 여러 비동기 함수의 반환값을 리스트로 한 번에 받기 ===")
start_time = time.time()
user_ids = [101, 102, 103, 104, 105]
# 1. 실행할 비동기 작업(코루틴)들을 리스트로 모아둡니다.
tasks = [fetch_user_info(uid) for uid in user_ids]
# 2. asyncio.gather()에 묶어서 던지면, 동시에 실행된 후
# 결과값들이 '요청했던 순서 그대로' 리스트에 담겨서 반환됩니다.
results = await asyncio.gather(*tasks)
print(f"\n✅ 동시 요청 완료! (소요 시간: {time.time() - start_time:.2f}초)")
# 반환받은 결과(results)는 일반 리스트이므로 마음대로 다룰 수 있습니다.
print("[최종 반환된 리스트 데이터]")
for res in results:
print(res)
asyncio.run(main())

단일 코루틴의 경우 await을 사용하여 결과값을 받아올 수 있으며, 여러 코루틴을 동시에 실행할 경우 gather를 통해 한 번에 값을 받아올 수 있습니다. 여기서 독특한 점은 결과값들이 실제 반환되는 순서와 상관 없이 요청한 순서대로 들어온다는 점입니다. 비동기 상황에서 마구잡이로 정렬이 틀어지는 걸 막을 수 있습니다.
하지만 왜 asyncio.gather를 써야 할까요? 단순히 await를 여러 번 쓰면 안 될까요?
import asyncio
import time
async def boil_water():
print("💧 물 끓이기 시작... (3초)")
await asyncio.sleep(3)
return "끓은 물"
async def chop_vegetables():
print("🥕 채소 썰기 시작... (2초)")
await asyncio.sleep(2)
return "썰린 채소"
async def main():
start = time.time()
# 잘못된 비동기 사용법 (사실상 동기)
# 물이 다 끓을 때까지 3초간 멍하니 기다렸다가, 그제야 채소를 썰기 시작합니다.
water = await boil_water()
veg = await chop_vegetables()
print(f"종료 시간: {time.time() - start:.1f}초")
asyncio.run(main())

이렇게 < await + 함수호출() > 형태의 구문을 연속해서 써버리면 동기 프로그래밍과 다를 바가 없기 때문입니다. 함수를 호출하여 실행하고 그 결과값을 기다리므로, 한 번에 하나씩 실행하는 꼴이 되기 때문이죠. 그래서 아래와 같은 방식으로 task를 만들어 사용해야 합니다.
import asyncio
import time
async def boil_water():
print("💧 물 끓이기 시작... (3초)")
await asyncio.sleep(3)
return "끓은 물"
async def chop_vegetables():
print("🥕 채소 썰기 시작... (2초)")
await asyncio.sleep(2)
return "썰린 채소"
async def main_with_task():
start = time.time()
# 두 개의 작업을 task로 만듦. 그리고 "실행함".
# 이때 await을 사용하지 않았으므로, 프로그램 흐름이 즉시 넘어감
water_task = asyncio.create_task(boil_water())
veg_task = asyncio.create_task(chop_vegetables())
# 실행이 완료되었다면 값을 각각 받아옴.
water = await water_task # 얘가 먼저 끝나고
veg = await veg_task # 1초 뒤 얘가 끝남.
print(f"종료 시간: {time.time() - start:.1f}초")
asyncio.run(main_with_task())

'아니, 이 번에도 두 줄에 걸쳐서 await을 쓰잖아?'라고 생각하실 수도 있습니다. 하지만 자세히 보세요. create_task를 이용하여 task를 만들고, 그 task에 await을 걸었습니다. 코루틴에 직접 await을 건 게 아닙니다. task로 변환된 코루틴에 await을 건 겁니다. 이렇게 하면 두 개의 코루틴이 백그라운드로 동시에 실행되어, 시간 단축 효과를 볼 수 있습니다.
한참 위에서 사용한 asyncio.gather()는 내부적으로 create_task를 여러 번 호출해주는 도우미 함수입니다. 일괄 처리할 때는 gather()를 사용하여 여러 코루틴을 전달하는 것이 좋고, 세밀한 작업 순서 컨트롤이 필요하다면 create_task()를 이용하여 task 객체로 만든 뒤 제어하는 것이 좋습니다.
'Python' 카테고리의 다른 글
| Python 고급 문법 3: with 블럭과 @contextmanager (0) | 2026.04.22 |
|---|---|
| Python 고급 문법 2: 이터레이터(iterator)와 제너레이터(generator) (0) | 2026.04.22 |
| Python 고급 문법 1: 함수객체(Functor), 클로저(Closure)와 데코레이터(Decorator) (0) | 2026.04.22 |
| 파이썬 코딩 효율을 높여주는 10가지 문법들 (ft. Pythonic한 코드란?) (0) | 2024.09.24 |
| 파이썬을 활용한 네트워크 분석 (고양국제고 진로특강) (0) | 2022.04.10 |