본문 바로가기
Artificial Intelligence/60. Python

[PYTHON] 비동기 루프를 멈추는 Blocking 함수 문제와 run_in_executor 활용 3가지 해결 방법

by Papa Martino V 2026. 2. 25.
728x90

Blocking 함수
Blocking 함수

 

파이썬의 asyncio를 활용한 비동기 프로그래밍은 단일 스레드 기반의 이벤트 루프(Event Loop) 위에서 동작합니다. 이 구조는 I/O 바운드 작업에서 매우 효율적이지만, 치명적인 약점이 있습니다. 바로 루프 내부에서 Blocking(차단) 함수가 실행되는 순간, 전체 서버가 마비된다는 점입니다. 본 가이드에서는 비동기 환경에서 동기 함수가 유발하는 성능 병목 현상을 진단하고, run_in_executor를 활용하여 이를 우아하게 해결하는 시니어급 전략을 제시합니다.


1. 이벤트 루프의 '질식' 현상: 왜 문제인가?

비동기 루프는 수많은 태스크를 번갈아 가며 처리하는 관리자 역할을 합니다. 만약 특정 태스크가 루프의 제어권을 반환(await)하지 않고 CPU를 점유하거나 동기적 I/O 대기에 빠지면, 관리자는 다음 작업을 수행할 수 없게 됩니다. 이를 보통 '루프가 블로킹되었다' 혹은 '질식했다'고 표현합니다.

  • 데이터 정체: 하나의 요청이 5초 동안 블로킹되면, 대기 중인 다른 수천 개의 요청도 함께 5초간 멈춥니다.
  • 타임아웃 발생: 클라이언트 측에서 연결 타임아웃이 발생하여 서비스 가용성이 급격히 떨어집니다.
  • 예측 불가능한 지연: 로그 상으로는 에러가 없지만 실제 서비스는 매우 느려지는 현상이 발생합니다.

2. 동기 vs 비동기 처리 구조의 결정적 차이

차단 문제를 해결하기 위해서는 우리가 작성하는 함수가 어떤 성격인지 명확히 구분해야 합니다. 아래 표는 실행 방식에 따른 차이를 정리한 것입니다.

구분 비동기 함수 (Non-blocking) 동기 함수 (Blocking)
호출 방식 await func() func()
루프 제어권 I/O 대기 시 루프에 제어권 반환 작업 완료 전까지 루프 점유
대표 예시 httpx, aiohttp, motor requests, time.sleep, OpenCV 연산
병목 해결 기본 구조에서 해결됨 run_in_executor 필요

3. 시니어의 해결책: run_in_executor의 3가지 활용 패턴

① ThreadPoolExecutor를 이용한 I/O 위임

requests 라이브러리처럼 비동기 버전을 지원하지 않는 레거시 라이브러리를 사용해야 할 때 유용합니다. 동기 함수를 별도의 스레드에서 실행하여 메인 이벤트 루프의 자유를 보장합니다.

② ProcessPoolExecutor를 이용한 CPU 연산 분리

이미지 처리(PIL, OpenCV)나 대규모 행렬 연산은 스레드로 처리해도 GIL(Global Interpreter Lock) 때문에 성능 향상이 미미할 수 있습니다. 이때는 별도의 프로세스로 작업을 넘겨 진정한 병렬 처리를 구현합니다.

③ 타임아웃과 결합된 안전한 실행

외부 리소스를 사용하는 블로킹 함수는 언제 끝날지 예측하기 어렵습니다. asyncio.wait_for와 결합하여 무한 대기를 방지하는 것이 실무적인 방법입니다.


4. Sample Example: 블로킹 문제를 해결하는 실제 코드

아래 예제는 동기 함수인 time.sleeprequests를 비동기 루프 내에서 안전하게 실행하는 방법을 보여줍니다.


import asyncio
import time
import requests
from concurrent.futures import ThreadPoolExecutor

# 1. 동기 블로킹 함수 (비동기 루프의 적)
def heavy_blocking_task(url):
    print(f"[Thread] {url} 데이터 요청 중...")
    # requests는 동기 라이브러리이므로 루프를 멈춤
    response = requests.get(url)
    time.sleep(1) # 인위적인 지연
    return f"결과: {response.status_code}"

async def main():
    loop = asyncio.get_running_loop()
    
    # 2. 전용 스레드 풀 생성 (시니어급 최적화)
    # CPU 코어 수 등에 맞춰 적절히 조절
    executor = ThreadPoolExecutor(max_workers=3)

    print("--- 비동기 메인 루프 시작 ---")
    
    # 3. run_in_executor를 통한 해결 방법
    # 함수 이름과 인자를 분리하여 전달함에 주의
    start_time = time.perf_counter()
    
    tasks = [
        loop.run_in_executor(executor, heavy_blocking_task, "https://www.google.com"),
        loop.run_in_executor(executor, heavy_blocking_task, "https://www.python.org"),
        loop.run_in_executor(executor, heavy_blocking_task, "https://github.com")
    ]
    
    # 다른 비동기 작업과 동시에 실행됨
    results = await asyncio.gather(*tasks)
    
    end_time = time.perf_counter()
    
    for res in results:
        print(res)
        
    print(f"총 소요 시간: {end_time - start_time:.2f}초")
    executor.shutdown()

if __name__ == "__main__":
    asyncio.run(main())

5. 결론: 비동기 환경의 무결성 유지

비동기 프로그래밍에서 가장 큰 실수는 '작동하니까 괜찮겠지'라는 안일함으로 블로킹 코드를 방치하는 것입니다. run_in_executor는 동기와 비동기 세계를 잇는 안전한 다리 역할을 합니다. 성능 최적화의 첫걸음은 현재 내 코드가 루프를 방해하고 있지는 않은지 진단하는 것임을 잊지 마십시오. 오늘 제시한 방법들을 통해 더 견고하고 빠른 파이썬 어플리케이션을 구축하시길 바랍니다.


6. 내용의 출처 및 참고 문헌 (Sources)

  • Python Documentation. "asyncio — Event Loop: run_in_executor."
  • Real Python. "Async IO in Python: A Complete Walkthrough."
  • Luciano Ramalho. "Fluent Python, 2nd Edition" (Concurrency Patterns).
  • Stack Overflow. "How to run blocking functions in asyncio."
728x90