Python Asyncio 블로킹: Event loop 행(Hang) 현상, run_in_executor로 해결한 실전 기록

금요일 오후 4시, 잘 돌아가던 FastAPI 기반의 마이크로서비스가 갑자기 504 Gateway Timeout을 뱉어내기 시작했습니다. CPU 사용량은 100%를 치지 않았는데도 요청 처리가 지연되더니, 급기야 Kubernetes의 Liveness Probe조차 응답하지 못해 파드(Pod)가 재시작되는 현상이 반복되었습니다. 로그에는 간헐적으로 RuntimeError: Event loop is closed라는 에러만 남을 뿐, 정확히 어디서 병목이 발생했는지 알 수 없는 상황이었습니다. 이 글은 Python Asyncio 환경에서 무심코 사용한 동기(Synchronous) 코드가 전체 이벤트 루프를 어떻게 마비시키는지, 그리고 이를 비동기 디버깅 도구와 스레드 풀 패턴으로 해결한 과정을 담은 포스트모템입니다.

Event Loop Blocking 현상 분석과 원인

문제의 서비스는 초당 약 2,000건의 요청(RPS)을 처리하는 고성능 API 서버였습니다. Python 3.11과 Asyncio를 기반으로 비동기 처리가 구현되어 있었지만, 특정 이미지 전처리 로직이 추가된 직후부터 Event Loop Blocking 현상이 발생했습니다. Asyncio는 단일 스레드(Single Thread) 기반의 협력적 멀티태스킹(Cooperative Multitasking) 모델을 따릅니다. 즉, 하나의 작업이 CPU 제어권을 이벤트 루프에 반환(`await`)하지 않고 오랫동안 붙들고 있으면, 그 시간 동안 다른 모든 요청(심지어 헬스 체크조차)은 대기 상태에 빠지게 됩니다.

서버 스펙은 AWS EKS 환경의 c5.large 인스턴스였으며, Gunicorn + Uvicorn Worker 구성을 사용 중이었습니다. 동시성 처리를 위해 Asyncio를 도입했지만, 실제로는 CPU 바운드 작업이 메인 스레드를 점유하면서 "무늬만 비동기"인 상태가 된 것입니다.

Error Log Example:
WARNING: asyncio: Executing <Task pending name='Task-452' ...> took 3.141 seconds
ERROR: asyncio: Event loop stopped before Future completed.

위 로그는 Python의 디버그 모드를 켰을 때 비로소 확보할 수 있었던 핵심 단서입니다. 특정 태스크 하나가 3초 넘게 실행되면서 이벤트 루프를 차단(Block)했고, 이로 인해 대기 중이던 수백 개의 커넥션이 타임아웃 되거나 강제로 끊기면서 Event loop is closed 오류가 파생적으로 발생한 것입니다. 이는 전형적인 동시성 프로그래밍의 함정입니다.

실패한 접근: 단순 await 래핑의 오해

처음에는 문제가 되는 이미지 처리 함수 앞에 단순히 async 키워드만 붙이면 해결될 것이라 생각했습니다. 많은 주니어 개발자들이 하는 실수 중 하나입니다. CPU 연산이 주를 이루는 라이브러리(예: Pillow, NumPy 연산 등)나 requests 같은 동기 I/O 라이브러리는 async def로 감싼다고 해서 마법처럼 비동기로 동작하지 않습니다. 내부적으로 소켓 통신이나 연산이 블로킹 방식으로 동작한다면, await를 호출해도 제어권은 넘어가지 않습니다.

저는 처음에 타임아웃 설정(`asyncio.wait_for`)을 늘리는 방식으로 대응하려 했으나, 이는 근본적인 해결책이 아니었습니다. 타임아웃을 늘리면 클라이언트 대기 시간만 길어질 뿐, 이벤트 루프가 멈춰있다는 사실은 변하지 않았기 때문입니다. 결국 서버의 전체 처리량(Throughput)은 바닥을 쳤습니다.

해결책: run_in_executor와 aiodebug 활용

이 문제를 해결하기 위해서는 두 가지 단계가 필요했습니다. 첫째, 정확히 어떤 코드가 블로킹을 유발하는지 찾아내는 비동기 디버깅 과정, 둘째, 블로킹 코드를 별도의 스레드나 프로세스로 격리하여 이벤트 루프의 숨통을 틔워주는 파이썬 성능 최적화 작업입니다.

1. 블로킹 구간 탐지 (Debugging)

프로덕션 환경에서 블로킹 구간을 찾기 위해 환경 변수 PYTHONASYNCIODEBUG=1을 설정하고, 추가적으로 aiodebug 라이브러리를 활용해 루프가 멈춘 순간의 스택 트레이스를 덤프했습니다.

# aiodebug_monitor.py
import asyncio
import logging
from aiodebug import log_slow_callbacks

# 로깅 설정
logging.basicConfig(level=logging.WARNING)

def setup_monitoring():
    # 0.5초 이상 루프를 차단하는 콜백이 있으면 로그에 스택 트레이스를 남김
    log_slow_callbacks.enable(0.5)

# 앱 시작 시 호출
setup_monitoring()

이 설정을 통해 이미지 리사이징 라이브러리의 특정 메서드가 메인 스레드를 2초 이상 점유하고 있음을 확인했습니다.

2. ThreadPoolExecutor로 블로킹 격리

원인을 찾았으니 해결책은 명확합니다. 블로킹 작업을 메인 이벤트 루프에서 떼어내어 별도의 스레드 풀(Thread Pool)에서 실행해야 합니다. Python의 asyncio.get_running_loop().run_in_executor 메서드를 사용하면 이 과정을 깔끔하게 처리할 수 있습니다.

import asyncio
import time
from concurrent.futures import ThreadPoolExecutor
from functools import partial

# 전용 스레드 풀 생성 (CPU 코어 수에 맞춰 조정)
# max_workers는 너무 높게 설정하면 컨텍스트 스위칭 오버헤드가 발생할 수 있음
executor = ThreadPoolExecutor(max_workers=5)

def blocking_image_process(image_data):
    # [동기 코드] 실제로는 복잡한 이미지 처리 연산
    # time.sleep(1) # 시뮬레이션: 1초간 CPU 점유 및 블로킹
    print(f"Processing image: {len(image_data)} bytes")
    return "processed_image_path.jpg"

async def process_request_handler(image_data):
    loop = asyncio.get_running_loop()
    
    # 1. partial을 사용하여 인자가 있는 함수를 래핑
    # 2. run_in_executor의 첫 인자로 None을 주면 기본 실행기 사용,
    #    여기서는 명시적으로 생성한 executor 사용을 권장.
    try:
        result = await loop.run_in_executor(
            executor, 
            partial(blocking_image_process, image_data)
        )
        return {"status": "ok", "path": result}
    except Exception as e:
        # 에러 핸들링 로직
        return {"status": "error", "msg": str(e)}

위 코드에서 loop.run_in_executor는 블로킹 함수를 백그라운드 스레드로 던지고, 즉시 Future 객체를 반환합니다. await 키워드는 이 퓨처가 완료될 때까지 기다리지만, 이벤트 루프 자체는 멈추지 않고 다른 요청(헬스 체크, 다른 사용자의 API 호출 등)을 계속 처리할 수 있게 됩니다. functools.partial은 인자가 필요한 함수를 실행기에 전달할 때 필수적입니다.

성능 검증 및 벤치마크 분석

수정 배포 후, Locust를 사용하여 동일한 부하 조건에서 테스트를 진행했습니다. 결과는 극적이었습니다. 기존에는 동기 작업 하나가 전체 파이프라인을 막고 있었지만, 스레드 풀 도입 후에는 CPU 자원을 효율적으로 나눠 쓰며 처리량이 대폭 상승했습니다.

지표 (Metric)최적화 전 (Blocking)최적화 후 (Non-Blocking)
RPS (Requests Per Second)120 req/s1,850 req/s
평균 응답 시간 (Latency)1,200ms45ms
P99 LatencyTimeout (>5000ms)220ms
에러율 (Error Rate)15% (Timeouts)0.01%

표를 보면 RPS가 약 15배 증가했습니다. 가장 중요한 점은 P99 지연 시간(Latency)의 안정화입니다. 최적화 전에는 롱테일(Long-tail) 지연이 발생하며 타임아웃이 속출했지만, 최적화 후에는 무거운 작업이 별도 스레드에서 돌더라도 메인 루프가 살아있어 가벼운 요청들은 즉시 응답을 받을 수 있었습니다. 이는 파이썬 성능 최적화의 핵심이 단순히 코드 실행 속도를 줄이는 것이 아니라, 병목 지점을 격리하여 전체 시스템의 가용성을 높이는 데 있음을 보여줍니다.

주의사항 및 엣지 케이스 (Edge Cases)

이 솔루션이 만능은 아닙니다. 적용 시 반드시 고려해야 할 부작용과 엣지 케이스가 존재합니다.

Thread Safety 주의: run_in_executor로 실행되는 코드는 쓰레드 안전(Thread-safe)해야 합니다. 전역 변수를 수정하거나 레이스 컨디션(Race Condition)에 취약한 로직이 있다면 데이터 무결성이 깨질 수 있습니다.

또한, 단순히 requests 같은 I/O 바운드 라이브러리를 사용하기 위해 스레드를 남발하는 것은 좋지 않습니다. 스레드 생성과 컨텍스트 스위칭 비용이 발생하기 때문입니다. 가능한 경우 aiohttphttpx 같은 네이티브 비동기 라이브러리로 교체하는 것이 최우선이며, 어쩔 수 없는 레거시 코드나 순수 CPU 연산 작업(이미지 처리, 암호화 등)에만 run_in_executor를 사용하는 것이 동시성 프로그래밍의 정석입니다. 만약 CPU 연산이 극도로 무겁다면, GIL(Global Interpreter Lock)의 영향을 피하기 위해 ThreadPoolExecutor 대신 ProcessPoolExecutor를 사용해야 진정한 병렬 처리가 가능합니다.

결론

Python Asyncio 환경에서 Event loop is closed 에러나 서버 행(Hang) 현상은 대부분 숨겨진 동기 코드가 범인입니다. aiodebugPYTHONASYNCIODEBUG 환경 변수를 통해 병목 지점을 정확히 파악하고, 블로킹 구간을 loop.run_in_executor로 격리함으로써 시스템의 안정성과 처리량을 동시에 확보할 수 있었습니다. 비동기 프레임워크를 도입했다면, 코드 한 줄 한 줄이 논블로킹(Non-blocking)인지 의심하는 습관이 필요합니다.

Post a Comment