비동기 프로그래밍 (Asynchronous Programming)

단일 스레드 환경에서의 동시성 구현

비동기 프로그래밍의 개념, 작동 원리, 이벤트 루프와 코루틴을 활용한 효율적인 I/O 처리 방법
Engineering
저자

Kwangmin Kim

공개

2025년 10월 30일

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() 등을 사용하여 별도의 스레드에 위임(병렬 처리)해야함

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 작업을 기다리지 않고 다른 일을 할 수 있을까?
  • 여기서 asyncawait의 진짜 의미가 등장

2.4.1 프로그래머의 약속: asyncawait 태깅

프로그래머가 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가 놀지 않도록 다음에 실행할 작업을 스케줄링
  • 작동 방식 (교통 정리):
    1. CPU가 Task A를 실행하다가 await (I/O 작업 1)을 만난다.
    2. CPU는 I/O 작업 1을 OS 커널에 위임하고 제어권을 이벤트 루프에게 반납.
    3. 이벤트 루프는 CPU가 비었으니, 대기 중인 Task B를 CPU에게 넘긴다.
    4. CPU가 Task B를 실행하다가 await (I/O 작업 2)을 만난다.
    5. CPU는 I/O 작업 2를 위임하고 제어권을 다시 반납한다.
    6. 이벤트 루프는 CPU가 비었지만, 만약 다른 Task가 없다면 대기
    7. 이때, OS 커널로부터 “I/O 작업 1이 완료되었습니다!”라는 알림(콜백)이 도착
    8. 이벤트 루프는 이 알림을 받고, “Task A의 다음 작업을 실행할 차례”라고 판단하여 Task A를 다시 CPU에게 넘긴다.
    9. CPU는 Task A의 await 다음 줄부터 연산을 재개

이 모든 과정이 큐(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 defawait을 잘 설계하는 것이 핵심
  • 프로그래머는 모든 명령문에 대해서 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() 등을 사용해 별도의 스레드(병렬 처리)로 분리하여 이벤트 루프를 막지 않도록 설계해야 한다.

Subscribe

Enjoy this blog? Get notified of new posts by email: