FastAPI 비동기 처리 중 블로킹 작업 분리하기

문제 상황

사내 이미지 업로드 API에서 간헐적으로 타임아웃이 발생했다. 로그를 확인해보니 PIL을 사용한 리사이징 작업이 수행되는 동안 다른 요청들이 대기하고 있었다.

@app.post("/upload")
async def upload_image(file: UploadFile):
    image = Image.open(file.file)
    resized = image.resize((800, 600))  # 블로킹 발생
    # ...

FastAPI는 비동기 프레임워크지만, async 함수 안에서 동기 작업을 실행하면 이벤트 루프가 블로킹된다.

해결 방법

CPU 집약적 작업은 별도 스레드에서 실행하도록 수정했다.

import asyncio
from concurrent.futures import ThreadPoolExecutor

executor = ThreadPoolExecutor(max_workers=4)

def resize_image_sync(image_bytes: bytes):
    image = Image.open(io.BytesIO(image_bytes))
    resized = image.resize((800, 600))
    output = io.BytesIO()
    resized.save(output, format='JPEG')
    return output.getvalue()

@app.post("/upload")
async def upload_image(file: UploadFile):
    image_bytes = await file.read()
    loop = asyncio.get_event_loop()
    resized_bytes = await loop.run_in_executor(
        executor, resize_image_sync, image_bytes
    )
    # S3 업로드 등 후속 처리

Python 3.9 이상이라면 asyncio.to_thread()를 사용할 수도 있다.

결과

평균 응답 시간이 1.2초에서 0.4초로 감소했다. 이미지 처리 중에도 다른 요청들이 정상적으로 처리되는 것을 확인했다.

교훈

  • async 함수라고 해서 모든 작업이 논블로킹인 것은 아니다
  • CPU 집약적 작업은 명시적으로 스레드/프로세스 분리가 필요하다
  • 운영 환경에서는 worker 수와 executor 설정의 밸런스가 중요하다