Async 코드 속도 저하 원인 및 해결 (기술적 요약)

이 비디오는 Python의 asyncio 코드가 동기 코드보다 느려지는 일반적인 원인과 이를 해결하기 위한 기술적 방법론을 다룹니다.

1. Asyncio 핵심 개념

  • 이벤트 루프 (Event Loop): 모든 비동기 애플리케이션의 핵심입니다. asyncio.run()으로 시작되며, 단일 스레드에서 태스크 실행을 관리하고 스케줄링합니다.
  • 코루틴 (Coroutines): async def로 선언된 비동기 함수입니다. await 키워드를 만나면 실행을 일시 중단하고 이벤트 루프에 제어권을 반환할 수 있습니다.
  • 태스크 (Tasks): 코루틴을 감싸고 이벤트 루프에서 동시에 실행되도록 스케줄링합니다. asyncio.create_task()를 통해 생성됩니다.
  • 퓨처 (Futures): 비동기 작업의 최종 결과를 나타내는 저수준 객체입니다.

2. 동기 코드의 비동기 변환 예시

기존의 동기식 time.sleep()을 비동기 await asyncio.sleep()으로 대체하고, 함수를 async def로 선언하며, asyncio.run()으로 메인 코루틴을 실행합니다.


성능 저하를 유발하는 일반적인 실수와 해결책

실수 1: 순차적 실행 (Sequential Execution)

독립적인 태스크들을 병렬로 실행하지 않고 순차적으로 await하면, 총 실행 시간은 모든 태스크의 실행 시간을 합한 값이 됩니다.

  • 잘못된 예 (순차적):

    # 각 await가 이전 작업이 끝날 때까지 대기함  
    await get_user_notifications()  
    await get_recent_activity()  
    await get_unread_messages()  
    
  • 해결책 (병렬): asyncio.gather 또는 asyncio.TaskGroup을 사용하여 독립적인 태스크를 동시에 실행합니다. 총 실행 시간은 가장 오래 걸리는 태스크의 시간으로 줄어듭니다.

    # 세 개의 작업이 동시에 시작됨  
    await asyncio.gather(  
        get_user_notifications(),  
        get_recent_activity(),  
        get_unread_messages()  
    )  
    

병렬 실행 도구 비교

  • asyncio.gather:
    • 여러 코루틴을 동시에 실행합니다.
    • 단점: 오류 처리가 미흡합니다. 하나의 태스크에서 예외가 발생하면 다른 실행 중인 태스크들이 취소됩니다.
  • asyncio.create_task:
    • 태스크별 제어 및 오류 처리가 가능합니다.
    • 백그라운드 실행에 유용하지만, 여러 태스크를 개별적으로 await 해야 하는 번거로움이 있습니다.
  • asyncio.TaskGroup (Python 3.11+):
    • '구조적 동시성'을 위한 최신 대안입니다.
    • async with 문법을 사용하여 태스크 그룹을 관리하며, 컨텍스트를 벗어날 때 모든 태스크가 완료되거나 예외 처리가 보장됩니다.
    async with asyncio.TaskGroup() as tg:  
        tg.create_task(some_coro_1())  
        tg.create_task(some_coro_2())  
    # 'async with' 블록이 끝나면 모든 태스크가 await됨  
    

실수 2: 동기 라이브러리 사용

asyncio 코드 내에서 requestspathlib 같은 동기(blocking) 라이브러리를 사용하면 이벤트 루프 전체가 차단됩니다. asyncio.gather 안에서 사용하더라도 실제로는 순차적으로 동작합니다.

  • 해결책: aiohttp (requests 대용), aiofiles (files/pathlib 대용) 등 비동기(non-blocking)를 지원하는 전용 라이브러리를 사용해야 합니다.

실수 3: CPU 바운드 작업으로 이벤트 루프 차단

asyncio는 단일 스레드에서 실행되므로, 무거운 계산(CPU-bound) 작업은 이벤트 루프를 정지시켜 다른 I/O 작업들을 지연시킵니다.

  • 해결책: loop.run_in_executor()를 사용하여 CPU 바운드 작업을 별도의 스레드 풀(기본값)이나 프로세스 풀로 오프로드합니다.
    loop = asyncio.get_running_loop()  
    # CPU 집약적 함수를 별도 스레드에서 실행  
    await loop.run_in_executor(  
        None,  # 기본 스레드 풀 사용  
        cpu_bound_function,  
        arg1  
    )  
    

실수 4: 중요하지 않은 작업으로 인한 차단

사용자 응답과 관련 없는 로깅 같은 비(非)핵심 작업을 await하면, 불필요하게 응답 시간이 지연됩니다.

  • 해결책: asyncio.create_task()를 사용해 해당 작업을 백그라운드 태스크로 분리하고 await 하지 않습니다.
    user_profile = await get_user_profile()  
    # 로깅을 await하지 않고 백그라운드에서 실행  
    asyncio.create_task(send_logs_to_external_service())  
    return user_profile  
    

실수 5: 너무 많은 태스크 생성

아주 작은 작업들을 대량으로 태스크화하면 컨텍스트 스위칭 오버헤드가 발생하여 성능이 저하될 수 있습니다.

  • 해결책 1: 작은 작업들을 묶어(batching) 더 큰 태스크 몇 개로 만듭니다.
  • 해결책 2: asyncio.Semaphore를 사용하여 동시에 실행되는 최대 태스크 수를 제한합니다.
    # 동시에 최대 10개의 작업만 허용  
    semaphore = asyncio.Semaphore(10)  
    
    async with semaphore:  
        await fetch_data()  
    

기타 실수

  • "Never Awaited" 코루틴: 코루틴을 호출하고 await하지 않아 작업이 실행조차 되지 않고 조용히 실패합니다. flake8-async 같은 린터로 탐지할 수 있습니다.
  • 부적절한 리소스 관리: 파일, DB 커넥션 등을 try...finally 없이 사용하면 리소스 누수가 발생할 수 있습니다. async with를 사용한 비동기 컨텍스트 매니저로 해결합니다.

디버깅 및 동시성 모델 선택

Asyncio 디버그 모드

기본적으로 비활성화된 디버그 모드를 활성화(asyncio.run(debug=True))하면 다음과 같은 문제를 탐지하는 데 도움이 됩니다.

  • await 되지 않은 코루틴 (RuntimeWarning).
  • 잘못된 스레드에서 호출된 비동기 API.
  • 실행 시간이 100ms를 초과하는 콜백.
  • 느린 I/O 선택기(selector) 작업.

기타 디버깅 도구

  • Scalene: CPU 및 메모리 프로파일러.
  • aio-monitor: asyncio 애플리케이션을 위한 모니터링 및 CLI.
  • pdb: 파이썬 기본 디버거.
  • py-stack: 실행 중인 파이썬 프로세스의 스택 트레이스를 출력하여 블로킹 지점 탐지.

동시성 모델 선택 가이드

  • Asyncio (단일 스레드): 대기 시간이 긴 다량의 I/O 바운드 작업(예: 네트워크 요청, 파일 I/O)에 최적입니다.
  • Threads (멀티 스레드): 공유 데이터 접근이 필요한 I/O 바운드 작업에 사용됩니다. GIL(Global Interpreter Lock)로 인해 진정한 병렬 처리는 아니지만 I/O 대기 시 다른 스레드가 실행될 수 있습니다.
  • Processes (멀티 프로세스): CPU 바운드 작업(예: 이미지 처리, 무거운 계산)에 사용됩니다. 여러 CPU 코어를 활용하여 진정한 병렬성을 달성하지만, 메모리 및 통신 오버헤드가 큽니다.

https://youtu.be/wGDOwNW6lVk

파이썬은 훌륭한 언어가 맞지만 비동기 인터페이스는 잘못 설계된 기능인것 같네요

결론: python 비동기 인터페이스는 아직도 직관적이지 않다.

사실 파이썬 비동기를 최적화할 정도의 프로젝트면 다른 언어로 작성하는게 성능도 안정성도 훨씬 좋습니다.

컴파일 언어로 가는 게 아니라면, 성능이 크게 차이가 나나요? 멀티스레딩이라면야 GIL의 존재로 인해 큰 차이가 생기겠지만, 어차피 이벤트 루프가 작동하는 비동기 구조라면 언어에 따른 차이가 어떤 게 생기는지 궁금하네요.

jit compile 유무가 생각보다 큽니다. V8이 최적화가 잘되어있어요

4번에 eager_start=True 가 빠졌네요. create_task는 weakref를 만드니까 영원히 실행되지 않을 태스크가 될 코드....

https://rosettalens.com/s/ko/python-to-node

이 사람도 Python async 때문에 Node.js로 갈아탔다던데

출처 영상은 확인해 보지 않았지만, 실수 4에 대한 해결책 코드는 잘못된 코드입니다.

create_task()가 반환하는 태스크 인스턴스는 최소 하나의 변수에 할당돼야 하며, 변수는 태스크가 종료될 때까지 살아있어야 합니다. 그렇지 않으면 코루틴이 실행되고 있는 와중에 태스크 인스턴스가 가비지 컬렉트 당할 위험이 있습니다.

위와 같이 태스크를 생성하는 함수가 곧 종료되는 경우라면, 태스크 인스턴스를 리턴하거나, 전역 변수에 할당하거나, 인스턴스 변수에 할당하는 등의 방법을 사용해야 합니다.

P.S)
리턴값이 굳이 필요하지 않고, 코루틴이 단시간 내에 끝날 거라는 확신이 있더라도, 태스크 인스턴스에 대해서는 언젠가는 await을 걸어 주도록 짜는 게 좋습니다. 그게 싫다면 태스크로 작동할 코루틴마다 예외 처리를 빡세게 걸어서 로그 메시지를 빈틈없이 띄울 구조를 마련하거나요. 그러지 않으면 태스크가 아무리 큰 사고를 치더라도 Exception이 처리되지 않고 silently fail하는 경우가 생기게 됩니다.

제가 밥벌이로 개발/관리하는 프로젝트에서 수십 개의 모듈이 각자 while self.ok(): cmd = await self.cmd_queue.get(); await self.process(cmd); 이런 식의 태스크를 하나씩 생성해서 계속 돌리는 패턴을 설계했었거든요. 예외처리 패턴 확립하기 전까지는 문제 하나 터질때마다 제 멘탈도 같이 터져나가는 진귀한 경험을 했더랬죠 ㅎ

Async/Await 패턴의 원조(?)라 할 수 있는 C#을 쓰는 회사에 다니는 입장에서 봐도 1번 실수와 같이 await를 단순하게 순서대로 줄줄이 늘어놓는 형태의 잘못된 코드가 보일 때가 꽤 많더군요.

그런 코드를 보면 뭐랄까 공통적으로 async 메소드 호출 앞에서는 await 키워드를 써야 한다는 것만 알고 그 이상의 비동기 실행 순서에 대한 부분은 별로 생각하지 않아서 이런 코드가 나오는 것 같다는 느낌은 있습니다.
여러 개의 await가 나올 때 어떤 건 바로 아래에서 결과가 쓰이니까 그 앞에서 Task<T> 객체의 await 결과값을 받도록 하고, 어떤 건 꽤 뒤에서나 쓰일 테니까 Task<T>만 받아서 나중에 await하고 이런 식으로 비동기 흐름을 고려하여 코드를 작성하는 건 그만큼 머리를 쓰는 작업이니까요.

적어도 저는 비동기로 선언된 메소드에서는 이렇게 처리 흐름을 고려해서 코드를 작성하고 있긴 한데, 어떨 때 기존 유지보수 중인 퇴사자의 코드를 보면 ‘나는 그냥 단순하게 동기 코드를 작성하고 싶은데 중간에 써야 할 메소드가 비동기 타입밖에 없으니까 그냥 이렇게 작성한다’는 느낌이 들 때도 있더군요.

1번이 항상 독립적이면 저렇게 하는게 좋긴 한데,
코드 수정했다가 독립적이지 않게 되면 저 함수를 사용하는 곳을 전부 검수하며 수정해야하는 불편함도 있는 것 같네요.
시간이 엄청 오래걸리지 않는 작업이면 직렬로 await 하는게 코드관리면에서 더 나을지도

그것도 그렇죠.
제대로 된 비동기 코드라는 건 본질적으로 신경을 많이 써야만 하는 코드인 것 같습니다.

'멀티스레딩은 오버헤드가 부담스럽기 때문에 차선책으로 싱글스레드를 쪼개어 병렬처리를 해결한다'라는 개념으로 접근해야 하는 것 같아요. 그렇기 때문에, 기본적으로 멀티스레딩보다 때에 따라서는 더욱 신경을 써야 하는 게 맞는 것 같고요.