1 비동기 프로그래밍 (Asynchronous Programming) 개요
- 비동기 프로그래밍은 단일 스레드(Single Thread) 환경에서 I/O 작업으로 인한 대기 시간(Latency)을 효율적으로 활용하여 동시성(Concurrency)을 달성하고 시스템의 처리량(Throughput)을 극대화하는 프로그래밍 패러다임
- 동시에 여러 이벤트를 효율적으로 처리할 수 있지만 병렬작업과는 완전히 다른 개념
1.1 비동기란?
비동기 방식의 핵심은 “빠른 주체(CPU)를 느린 주체(I/O 장치)가 멈추게 하지 않도록 보호”하는 것
- 배경: 프로그램 실행 주체인 CPU는 초고속이지만, I/O 작업 (네트워크 통신, 파일 접근, 데이터베이스 조회 등)은 수백~수천 배 느림
- 동기(Synchronous) 방식의 문제: CPU가 느린 I/O 작업이 끝날 때까지 멈춰서 강제로 대기(Blocking)하며 에너지를 낭비
- 비동기 방식으로 해결: CPU는 느린 I/O 작업을 OS 커널에 위임하고, 완료를 기다리지 않고 그 시간 동안 다른 요청을 처리
결론: 비동기는 물리적인 동시 실행(병렬)보다는 CPU의 유휴(노는) 시간을 제거하여 프로그램의 반응성과 효율을 높이는 데 초점
1.2 핵심 작동 원리: 이벤트 루프와 협력
비동기 프로그래밍이 순서가 꼬이지 않고 안정적으로 작동할 수 있는 핵심은 이벤트 루프(Event Loop)와 협력적 멀티태스킹
- 이벤트 루프 (Event Loop):
- 비동기 작업의 교통 정리 관제탑
- CPU(메인 스레드)의 상태와 I/O 작업의 완료 여부를 끊임없이 감시하며, CPU가 비어 있을 때 처리할 다음 작업을 스케줄링
- 코루틴 (Coroutine):
async def로 정의된 함수로, 실행을 일시 중지했다가 나중에 재개할 수 있는 특별한 실행 단위 await키워드:- 코루틴 내에서 I/O 대기가 필요한 지점을 명시적으로 표시
- CPU가
await을 만나면 스스로 제어권을 이벤트 루프에게 반납하고, 이벤트 루프는 그 시간에 다른 준비된 코루틴을 실행 - 주의) CPU가
await을 만나면 무조건 제어권을 이벤트 루프에게 반납하진 않음
1.3 비동기 프로그래밍 설계 시 유의사항 (CPU 작업의 함정)
비동기 환경에서 가장 중요한 원칙은 CPU의 블로킹을 절대 허용하지 않는 것
- CPU 집약적 연산의 위험:
await이 없는 오래 걸리는 순수 CPU 연산 (예: 복잡한 계산, 머신러닝 최적화)은 CPU가 제어권을 이벤트 루프에게 반납할 수 있는 논리적인 틈이 없기 때문에, 메인 스레드를 블로킹하여 비동기 시스템 전체를 마비시킴 - 올바른 설계:
- 코루틴 내부에는 I/O 작업(
await)과 매우 짧은 CPU 연산만 배치하는 것이 원칙 - 오래 걸리는 CPU 작업은
asyncio.to_thread()등을 사용하여 별도의 스레드에 위임(병렬 처리)해야함
- 코루틴 내부에는 I/O 작업(
2 세부 설명
2.1 비동기 프로그래밍의 등장 배경: 속도의 불균형
앞서 배경에서 언급했듯이, 하나의 프로그램을 실행하는 데 관여하는 주체들(Components)의 연산 속도 차이가 매우 심하다는 것이라는 것을 명심.
- CPU (중앙 처리 장치): 초당 수십억 번의 연산을 수행하는 ‘매우 빠른 주체’.
- I/O (입출력 장치): 네트워크 통신, 디스크 파일 읽기/쓰기, 데이터베이스 조회 등 ‘매우 느린 주체’.
전통적인 동기(Synchronous) 방식에서는 CPU가 코드를 순서대로 실행하다가 I/O 작업을 만나면, 그 느린 작업이 완료될 때까지 CPU는 아무 일도 하지 않고 멈춰서 대기(Blocking) 하는 것은 엄청난 자원 낭비로 이어짐.
2.2 비동기(Asynchronous) 방식의 핵심 목표: 효율성
비동기 프로그래밍은 이 낭비를 막기 위해 등장
“빠른 주체(CPU)가 느린 주체(I/O)를 기다리며 쉴 틈을 주지 않고, 그 시간에 다른 일을 하게 만드는 것.”
- 이것이 궁극적으로 도출된 비동기 프로그래밍의 핵심 목표.
- 즉, 비동기는 CPU의 유휴 시간(Idle Time)을 최소화하여 시스템 전체의 효율(처리량)을 극대화하는 방식
2.3 비동기 vs. 병렬: 중요한 구분
우리는 이것이 병렬 작업(Parallelism)과 다르다는 점을 명확히 해야함
- 병렬 작업: 여러 개의 CPU 코어를 동원해 하나의 무거운 계산(CPU 작업)을 동시에 처리하여 ’총 작업 시간’을 줄이는 것. (예: 머신러닝 학습)
- 비동기 작업 (동시성): 하나의 CPU 코어가 여러 개의 느린 I/O 작업 사이를 효율적으로 오가며 ’대기 시간’을 활용하는 것
2.4 ‘어떻게’ 비동기 프로그래밍을 구현하나? (Await 태깅과 이벤트 루프)
- 그렇다면 CPU가 어떻게 I/O 작업을 기다리지 않고 다른 일을 할 수 있을까?
- 여기서
async와await의 진짜 의미가 등장
2.4.1 프로그래머의 약속: async와 await 태깅
프로그래머가 CPU와 I/O가 할 일을 구분해서 코딩해야함
async def(태깅):- 함수를 일반 함수가 아닌 ‘코루틴(Coroutine)’ 객체로 선언
- “이 함수는 내부에 I/O 대기(
await)가 있을 수 있으니, 비동기적으로 관리되어야 합니다”라는 태그를 붙이는 행위
await(마킹):- 코루틴 내부에서 실제로 느린 I/O 작업이 발생하는 지점을 마킹
- 즉,
await은 프로그래머가 CPU에게 보내는 신호
“이 명령어(
await)는 외부 I/O 작업이라 오래 걸릴 거야. 그러니 이 작업은 OS 커널 같은 다른 주체에게 맡기고, 너(CPU)는 멈춰서 기다리지 말고 다른 일 하러 가.”
2.4.2 교통 정리의 핵심: 이벤트 루프 (Event Loop)
await을 만난 CPU는await명령문의 연산 제어권을 포기하고 이 제어권은 이벤트 루프로 이동.- 즉, 이벤트 루프는 “비동기 이벤트의 교통 정리 관제탑”
- 역할:
- 이벤트 루프는 단일 스레드 내에서 수많은 코루틴(Task)들의 상태를 관리
- CPU가 놀지 않도록 다음에 실행할 작업을 스케줄링
- 작동 방식 (교통 정리):
- CPU가 Task A를 실행하다가
await(I/O 작업 1)을 만난다. - CPU는 I/O 작업 1을 OS 커널에 위임하고 제어권을 이벤트 루프에게 반납.
- 이벤트 루프는 CPU가 비었으니, 대기 중인 Task B를 CPU에게 넘긴다.
- CPU가 Task B를 실행하다가
await(I/O 작업 2)을 만난다. - CPU는 I/O 작업 2를 위임하고 제어권을 다시 반납한다.
- 이벤트 루프는 CPU가 비었지만, 만약 다른 Task가 없다면 대기
- 이때, OS 커널로부터 “I/O 작업 1이 완료되었습니다!”라는 알림(콜백)이 도착
- 이벤트 루프는 이 알림을 받고, “Task A의 다음 작업을 실행할 차례”라고 판단하여 Task A를 다시 CPU에게 넘긴다.
- CPU는 Task A의
await다음 줄부터 연산을 재개
- CPU가 Task A를 실행하다가
이 모든 과정이 큐(Queue)와 콜백(Callback)이라는 알고리즘을 기반으로 순서가 뒤죽박죽되지 않고 안정적으로 처리
2.4.3 예시
이 예시는 단일 스레드 내에서 여러 개의 코루틴(메서드)이 어떻게 CPU와 I/O 작업을 협력적으로 처리하며 전체 시간을 단축하는지를 보여줌
3 복합 비동기 시나리오 예시: OrderProcessor
- 온라인 쇼핑몰의 주문을 처리하는
OrderProcessor클래스를 가정 - 주문 처리 과정은 여러 단계를 거치며, 각 단계에는 CPU 연산과 I/O 대기(DB 조회, 결제)가 혼합되어 있다.
import asyncio
import time
class OrderProcessor:
def __init__(self, order_id):
self.order_id = order_id
self.data = {}
print(f"[{order_id}] 주문 프로세서 초기화 시작.") # <--- CPU 작업
# 1. 코루틴: 사용자 정보를 비동기적으로 조회
async def fetch_user_info(self):
# [CPU 작업] 현재 시각 기록 및 로그 메시지 출력
start = time.time()
print(f"[{self.order_id}] 1. 사용자 정보 조회 요청...")
# [I/O 위임 (await)] 1초간 네트워크 대기 시뮬레이션
# <--- 제어권 반납 (이벤트 루프가 다른 Task로 전환)
await asyncio.sleep(1)
# [CPU 작업] I/O 완료 후 데이터 처리
user_info = f"User-{self.order_id}-Data"
self.data['user'] = user_info
print(f"[{self.order_id}] 1. 사용자 정보 조회 완료. ({time.time() - start:.2f}초 소요)")
return user_info
# 2. 코루틴: 재고 확인 (CPU 집약적인 로직 가정)
# 일반 CPU 연산이므로 await을 사용하지 않아야 하지만, 시간이 너무 길면 블로킹 위험.
# 여기서는 비동기 흐름을 깨지 않는 짧은 CPU 연산으로 가정.
async def check_inventory(self):
# [CPU 작업] 시작 시간 기록
start = time.time()
print(f"[{self.order_id}] 2. 재고 복잡 연산 시작...")
# [CPU 작업] 복잡한 재고 연산을 시뮬레이션 (블로킹이 없다고 가정)
inventory_status = "Available"
# [CPU 작업] 결과 저장 및 출력
self.data['inventory'] = inventory_status
print(f"[{self.order_id}] 2. 재고 연산 완료. ({time.time() - start:.2f}초 소요)")
return inventory_status
# 3. 코루틴: 결제 요청 (가장 오래 걸리는 I/O)
async def process_payment(self):
# [CPU 작업] 시작 시간 기록
start = time.time()
print(f"[{self.order_id}] 3. 결제 요청 시작...")
# [I/O 위임 (await)] 4초간 외부 결제 API 대기 시뮬레이션
# <--- 제어권 반납 (이벤트 루프가 다른 Task로 전환)
await asyncio.sleep(4)
# [CPU 작업] I/O 완료 후 결과 처리
payment_result = "Success"
self.data['payment'] = payment_result
print(f"[{self.order_id}] 3. 결제 완료. ({time.time() - start:.2f}초 소요)")
return payment_result
# 4. 코루틴: 모든 단계를 비동기적으로 실행하고 결과를 취합
# 이 메서드 자체가 여러 코루틴을 묶는 상위 코루틴입니다.
async def run_full_process(self):
print(f"[{self.order_id}] >>> 전체 주문 처리 파이프라인 시작 <<<")
# 4-1. [CPU/I/O] 사용자 정보 조회
user_info = await self.fetch_user_info()
# 4-2. [CPU] 재고 확인
inventory = await self.check_inventory()
# 4-3. [CPU/I/O] 결제 처리
payment = await self.process_payment()
# 4-4. [CPU] 최종 결과 조합 및 출력
final_status = f"Order {self.order_id}: {user_info}, {inventory}, {payment}"
print(f"[{self.order_id}] >>> 최종 결과: {final_status} <<<")
return final_status
# -------------------------------------------------------------
# 메인 실행 로직
async def main():
start_time = time.time()
print("--- 2개의 주문을 비동기적으로 처리 시작 ---")
# 두 개의 독립적인 주문 프로세스를 생성 (서로 다른 Task)
order_a = OrderProcessor("Order-A")
order_b = OrderProcessor("Order-B")
# 두 개의 run_full_process 코루틴을 Task로 만들고 동시에 실행
# 여기서 비동기 동시성(Concurrency)이 발생합니다.
task_a = asyncio.create_task(order_a.run_full_process())
task_b = asyncio.create_task(order_b.run_full_process())
# 두 Task가 모두 완료될 때까지 기다림
await asyncio.gather(task_a, task_b)
end_time = time.time()
print(f"\n--- 최종 소요 시간: {end_time - start_time:.2f}초 ---")
if __name__ == "__main__":
# 동기적으로 실행했다면: (1+4)초 + (1+4)초 = 약 10초 소요
# 비동기적으로 실행하면: 가장 긴 I/O 시간인 4초에 가까워야 함
asyncio.run(main())연산 처리 순서 및 흐름 설명
총 소요 시간은 \(4\text{초} + \text{약간의 CPU 시간}\)에 가깝게 나와야함.
| 시간대 (대략) | CPU(메인 스레드) 역할 | Order-A (Task A) | Order-B (Task B) |
|---|---|---|---|
| 0.0초 | Task A 시작 | run_full_process 실행 |
대기 |
| 0.0초 | Task B 시작 | fetch_user_info 실행 (CPU) |
대기 |
| 0.0초 | 제어권 반납 | await 1초 대기 (I/O 위임) | 대기 |
| 0.0초 | Task B 전환 | 대기 (I/O 진행 중) | run_full_process 실행 |
| 0.0초 | 제어권 반납 | 대기 (I/O 진행 중) | await 1초 대기 (I/O 위임) |
| 0.0초 | 이벤트 루프 유휴 | I/O 진행 중 | I/O 진행 중 |
| 1.0초 | Task A 재개 | I/O 완료, 재고 연산 (CPU) | 대기 |
| 1.0초 | 제어권 반납 | process_payment 실행 (CPU) |
I/O 완료, 재고 연산 (CPU) |
| 1.0초 | Task B 재개 | 대기 | process_payment 실행 (CPU) |
| 1.0초 | 제어권 반납 | await 4초 대기 (I/O 위임) | await 4초 대기 (I/O 위임) |
| 1.0 ~ 5.0초 | I/O 진행 중 | 결제 I/O 진행 중 | 결제 I/O 진행 중 |
| 5.0초 | Task A 재개 | 결제 완료, 최종 출력 (CPU) | 대기 |
| 5.0초 | Task B 재개 | 완료 | 결제 완료, 최종 출력 (CPU) |
| 5.0초 | 모든 Task 완료 | 완료 | 완료 |
결론: 동시성 달성
- CPU는 Order-A의 1초 I/O 대기 시간이 발생했을 때 Order-B의 1초 I/O 요청을 처리하는 데 활용
- Order-A와 Order-B의 4초 결제 대기 시간 동안 CPU는 아무 작업도 하지 않고 대기하는 대신, 두 결제 요청을 동시에 진행
- 결과적으로 두 주문의 총 I/O 대기 시간(4초)에 가까운 시간 안에 모든 작업이 완료되어 효율적인 동시 처리가 이루어진다.
3.1 ‘무엇을’ 조심해야 하는가? (CPU 연산의 함정)
- 이 논리대로라면, 프로그래머가
async def와await을 잘 설계하는 것이 핵심 - 프로그래머는 모든 명령문에 대해서 CPU작업과 I/O작업을 구분짓기 힘들 수 있음
3.1.1 코루틴의 구성: CPU 작업과 I/O 작업의 혼재
우리는 코루틴 내부가 “CPU가 할 일과 OS(I/O)가 할 일이 섞여 있을 수밖에 없다”는 것을 확인
- CPU가 할 일 (Await이 없는 코드): 변수 할당, 간단한 연산, 로그 찍기.
- OS가 할 일 (Await이 있는 코드):
await asyncio.sleep(1),await client.get(url).
비동기 함수는 이 두 가지가 번갈아 나타나는 “얇은 껍질(Thin Wrapper)”처럼 설계되어, CPU가 간단한 연산을 빠르게 처리하고 즉시 await에서 제어권을 반납하도록 유도해야 바람직하다.
3.1.2 치명적인 실수: CPU 집약적 작업에 await을 붙인다면?
만약 머신러닝의 ‘가중치 최적화’ 같은 무거운 CPU 연산에 await을 붙이면 어떻게 될까?
- 잘못된 가정:
await을 붙였으니 CPU가 이 작업을 I/O처럼 다른 곳에 위임하고 다른 일을 할 것이다. - 현실:
- CPU 연산은 OS 커널에 위임할 수 없다.
- CPU 자신이 직접 처리해야 하는 일이다.
- 즉, CPU 연산에
await을 마킹해도 CPU연산이 일어남 - 즉,
await을 붙이더라도, CPU는 “이건 내가 해야 하는 계산이네”라고 판단하고, 그 계산이 끝날 때까지 제어권을 이벤트 루프에게 반납하지 않는다.
결과: 이벤트 루프가 멈춘다 (Blocking).
- CPU가 무거운 계산을 하는 동안 이벤트 루프는 스케줄링을 할 수 없게 되고,
- 다른 모든 비동기 작업(네트워크 요청, 다른 사용자의 응답)이 완전히 정지
- 이는
async를 쓰지 않고 동기 코드를 실행한 것과 똑같거나 오히려 더 나쁜 결과를 초래
3.1.3 최종 결론: 올바른 비동기 설계
- 비동기 프로그램은 I/O 대기 시간이 긴 작업(네트워크, DB)에 압도적으로 유리
- 비동기 코루틴(
async def) 안에는 무거운 CPU 연산을 절대로 배치해서는 안 된다. - 만약 무거운 CPU 연산이 필요하다면,
asyncio.to_thread()등을 사용해 별도의 스레드(병렬 처리)로 분리하여 이벤트 루프를 막지 않도록 설계해야 한다.