FastAPI에서 비동기 함수 vs. 동기 함수: 언제 무엇을 선택해야 할까
Olivia Novak
Dev Intern · Leapcell

소개
효율적이고 확장 가능한 웹 애플리케이션을 구축하는 것은 개발자들의 끊임없는 추구입니다. 파이썬 생태계에서 FastAPI는 주로 비동기 기능을 통해 고성능 API를 구축하는 강력한 도구로 부상했습니다. 하지만 FastAPI를 처음 접하는 사용자나 이를 처음 사용하는 숙련된 개발자들에게 흔히 혼란을 주는 지점은 async def를 언제 사용해야 하고 언제 기존의 def 함수를 사용해야 하는지 이해하는 것입니다. 이 결정은 단순한 스타일 문제가 아니라 애플리케이션의 응답성, 리소스 활용 및 전반적인 성능에 심오한 영향을 미칩니다. 이 글은 FastAPI에서 비동기 함수 정의와 동기 함수 정의의 차이점을 명확히 밝히고, 각각을 언제 사용해야 하는지에 대한 명확한 지침을 제공하여 궁극적으로 더 강력하고 효율적인 웹 서비스를 구축하는 데 도움을 줄 것입니다.
핵심 개념 이해하기
FastAPI의 구체적인 내용으로 들어가기 전에 파이썬에서 동기 및 비동기 프로그래밍의 기본 개념을 파악하는 것이 중요합니다.
동기 함수 (def)
def를 사용하여 함수를 정의하면 동기 함수로 간주됩니다. 이는 함수가 호출될 때 일련의 순서대로, 즉 하나씩 작업을 실행한다는 의미입니다. 동기 함수가 완료하는 데 시간이 오래 걸리는 작업(예: 데이터베이스 쿼리, 외부 API 호출 또는 파일 I/O 대기)을 만나면, 다음 줄의 코드로 진행하기 전에 해당 작업이 완료될 때까지 전체 프로그램이 해당 지점에서 차단됩니다. 웹 서버의 맥락에서 이는 하나의 요청이 차단 I/O 작업을 수행하는 동안 서버는 해당 워커 스레드에서 다른 들어오는 요청을 처리할 수 없다는 것을 의미합니다.
import time def synchronous_task(task_id: int): print(f"Synchronous Task {task_id}: Starting CPU-bound work...") # CPU 집약적인 작업 시뮬레이션 count = 0 for _ in range(1_000_000_000): count += 1 print(f"Synchronous Task {task_id}: CPU-bound work finished.") print(f"Synchronous Task {task_id}: Starting I/O-bound wait...") # 차단 I/O 작업 시뮬레이션 time.sleep(2) # 스레드를 차단합니다 print(f"Synchronous Task {task_id}: I/O-bound wait finished.") return f"Result from synchronous task {task_id}" # 실행 시 synchronous_task(1)은 synchronous_task(2)가 시작되기 전에 완전히 완료됩니다.
비동기 함수 (async def)
async def로 정의된 함수는 비동기 함수이며, 이는 메인 실행 스레드를 차단하지 않고 동시에 작업을 수행하도록 설계되었다는 것을 의미합니다. await 키워드는 async def 함수 내에서 중요합니다. async def 함수가 'awaitable' 객체(예: asyncio.sleep 또는 httpx를 사용한 비동기 HTTP 요청, 또는 비동기 데이터베이스 드라이버 호출)에 대한 await 표현식을 만나면, 해당 지점에서 실행을 일시 중지하고 이벤트 루프로 제어권을 반환합니다. 이벤트 루프는 실행 준비가 된 다른 작업으로 전환할 수 있습니다. await된 작업이 완료되면, async def 함수는 중단된 지점에서 실행을 재개할 수 있습니다. 이 비차단 동작은 I/O 집약적인 작업에 특히 유리합니다.
import asyncio async def asynchronous_task(task_id: int): print(f"Asynchronous Task {task_id}: Starting I/O-bound wait...") # 비차단 I/O 작업 시뮬레이션 await asyncio.sleep(2) # 이벤트 루프로 제어권을 양보합니다 print(f"Asynchronous Task {task_id}: I/O-bound wait finished.") print(f"Asynchronous Task {task_id}: Starting CPU-bound work...") # CPU 집약적인 작업 시뮬레이션 (오프로딩되지 않으면 여전히 차단됨) count = 0 for _ in range(1_000_000_000): count += 1 print(f"Asynchronous Task {task_id}: CPU-bound work finished.") return f"Result from asynchronous task {task_id}" # 비동기 컨텍스트에서, await 기간 동안 asynchronous_task의 여러 호출이 '나란히' 실행될 수 있습니다.
FastAPI와 함수 실행
Starlette와 Pydantic을 기반으로 구축된 FastAPI는 파이썬의 asyncio 라이브러리를 활용하여 비동기 요청 처리를 가능하게 합니다. 이를 통해 비교적 적은 수의 워커 프로세스로 높은 동시성을 달성할 수 있습니다.
FastAPI가 async def를 처리하는 방법
FastAPI는 요청을 수신하고 async def 엔드포인트 함수로 라우팅할 때, 이 함수를 이벤트 루프 내에서 직접 실행합니다. async def 함수가 await I/O 집약적인 작업을 만나면, 제어권을 양보하여 이벤트 루프가 다른 들어오는 요청이나 실행 준비가 된 다른 작업을 처리할 수 있도록 합니다. 이것이 async def의 강력함이 빛나는 지점입니다. I/O 집약적인 작업(데이터베이스 호출, 외부 API 호출, 파일 읽기/쓰기, 네트워크 요청)의 경우, 애플리케이션은 각 대기 작업에 전용 스레드를 갖지 않고도 많은 동시 클라이언트를 효율적으로 처리할 수 있습니다.
예제: I/O 집약적 작업에 대한 async def
외부 서비스에서 데이터를 가져오는 API 엔드포인트를 고려해 보세요.
from fastapi import FastAPI import httpx # 비동기 HTTP 클라이언트 import asyncio app = FastAPI() @app.get("/items_async/{item_id}") async def get_item_async(item_id: int): print(f"Request for item {item_id}: Starting external API call asynchronously...") async with httpx.AsyncClient() as client: # 시간이 걸리는 외부 API 호출 시뮬레이션 response = await client.get(f"https://jsonplaceholder.typicode.com/todos/{item_id}") data = response.json() print(f"Request for item {item_id}: External API call finished.") return {"item_id": item_id, "data": data} # 이를 테스트하려면 /items_async/1, /items_async/2 등에 대해 여러 동시 요청을 보낼 수 있습니다. # 이러한 요청들이 엄격하게 순차적으로가 아니라 상호 교차하며 완료되는 것을 관찰할 수 있습니다.
이 예제에서 await client.get(...)은 메인 이벤트 루프를 차단하지 않고 get_item_async의 실행을 일시 중지합니다. 그러면 FastAPI는 다른 들어오는 요청을 처리하거나 다른 작업을 수행할 수 있습니다.
FastAPI가 def를 처리하는 방법
FastAPI는 def 엔드포인트 함수를 만나면 이를 동기 함수로 지능적으로 인식합니다. 동기 함수가 메인 비동기 이벤트 루프를 차단하는 것을 방지하기 위해, FastAPI는 자동으로 동기 엔드포인트 함수를 별도의 스레드 풀에서 실행합니다. 이는 def 함수가 차단 I/O 작업이나 긴 CPU 집약적인 계산을 수행하는 경우, 해당 스레드 풀의 스레드를 차단하지만 메인 이벤트 루프 자체는 차단하지 않는다는 것을 의미합니다.
예제: 동기적이며 잠재적으로 차단되는 작업에 대한 def
비동기화하기 어려운 복잡하고 CPU 집약적인 계산을 수행하는 엔드포인트를 상상해 보세요.
from fastapi import FastAPI import time app = FastAPI() def perform_heavy_computation(number: int): print(f"Synchronous Computation for {number}: Starting CPU-bound work...") # CPU 집약적인 작업 시뮬레이션 result = 0 for i in range(number * 10_000_000): # 큰 루프 result += i print(f"Synchronous Computation for {number}: CPU-bound work finished.") return result @app.get("/compute_sync/{number}") def compute_sync(number: int): print(f"Request for computation {number}: Received.") computation_result = perform_heavy_computation(number) return {"input_number": number, "result": computation_result} # 여러 요청이 /compute_sync를 동시에 처리하는 경우, 각 요청은 FastAPI의 스레드 풀에서 별도의 스레드에서 실행됩니다. # 동기 작업의 동시성은 이 스레드 풀의 크기로 제한됩니다.
이 경우 perform_heavy_computation은 차단 함수입니다. FastAPI는 이를 백그라운드 스레드에서 실행하여 메인 이벤트 루프를 차단하지 못하도록 합니다. 그러나 이러한 동시 차단 작업의 수는 스레드 풀의 크기(기본값은 uvicorn의 경우 종종 약 40개의 스레드)로 제한되며, 스레드를 생성하고 관리하는 데는 오버헤드가 발생합니다.
async def vs. def 사용 시기
async def와 def 선택은 주로 엔드포인트가 수행하는 작업의 특성에 따라 달라집니다.
async def를 사용하는 경우:
- 함수에 await 가능한 I/O 집약적 작업이 포함될 때. 이것이 주요 사용 사례입니다. 예시는 다음과 같습니다:
httpx를 사용하여 외부 API에 HTTP 요청 보내기.- 비동기 데이터베이스 드라이버(예: PostgreSQL용
asyncpg,aioodbc,asyncio를 사용한SQLModel)와 상호 작용. - 비동기적으로 파일 읽기/쓰기(예:
aiofiles). - 비동기 큐에서 메시지 대기.
- 강력한 CPU 사용 없이 외부 리소스를 기다리는 작업.
- 다른 awaitable 유틸리티 또는 라이브러리를 활용해야 할 때. 본질적으로 비동기적인 라이브러리와 통합하는 경우, 해당 작업을 await하려면
async def가 필요합니다. - I/O 집약적 작업의 동시성을 극대화하려는 경우.
async def를 사용하면 요청의 대부분이 I/O 대기에 소비되는 한, 애플리케이션이 효율적으로 많은 수의 동시 요청을 처리할 수 있습니다.
간단한 규칙: 함수에 await 키워드가 포함되어 있으면 async def여야 합니다.
def를 사용하는 경우:
- 함수가 순수하게 CPU 집약적인 작업을 수행할 때. 함수가 대부분의 시간을 계산, 메모리 내 데이터 처리 또는 외부 리소스를 기다리지 않고 반복하는 데 소비한다면, 이는 CPU 집약적입니다.
async def로 만들면 CPU 계산이 자동으로 차단되지 않는 것은 아닙니다. 실제 계산은 이벤트 루프를 차단하거나(await되지 않은 경우) 또는 백그라운드 스레드를 차단합니다(FastAPI가def함수를 오프로딩하는 경우).- 예시: 복잡한 수학 계산, 대규모 데이터 변환, 이미지 처리, 비디오 인코딩.
- 동기 전용 라이브러리 또는 드라이버와 상호 작용할 때. 많은 오래된 파이썬 라이브러리, 특히 데이터베이스 드라이버(예: PostgreSQL용
psycopg2또는 전통적인 형태의SQLAlchemyORM)는 동기적입니다. 엔드포인트에서 이러한 라이브러리를 사용해야 하는 경우,def로 정의하면 FastAPI가 스레드 풀에서 실행하여 차단 특성을 처리할 수 있습니다. - 단순성과 친숙함. I/O나 복잡한 로직이 없는 매우 간단한 엔드포인트의 경우, 특히
asyncio모범 사례에 익숙하지 않다면def함수가 작성하고 이해하기에 약간 더 간단할 수 있습니다. 그러나 항상 미래의 확장성을 고려해야 합니다.
CPU 집약적 작업에 대한 중요 참고 사항 (async def에서): async def 함수에 await하는 것이 없는 CPU 집약적인 코드가 포함되어 있으면, 해당 CPU 집약적인 코드는 여전히 이벤트 루프를 차단합니다. 이벤트 루프를 차단하지 않고 async def 엔드포인트 내에서 CPU 집약적인 작업을 처리하려면, 일반적으로 loop.run_in_executor()(또는 starlette.concurrency.run_in_threadpool과 같이 이를 기반으로 구축된 라이브러리)를 사용하여 별도의 프로세스 또는 스레드 풀로 오프로딩해야 합니다. FastAPI는 def 함수에 대해 자동으로 이 작업을 수행하지만, async def 함수는 장기 실행 CPU 코드가 있는 경우 명시적으로 관리해야 합니다.
from concurrent.futures import ThreadPoolExecutor from functools import partial # ... (app = FastAPI()와 위의 perform_heavy_computation 함수가 있다고 가정) executor = ThreadPoolExecutor(max_workers=4) # CPU 집약적 작업을 위한 스레드 풀 @app.get("/compute_async_offloaded/{number}") async def compute_async_offloaded(number: int): print(f"Request for computation {number}: Received, offloading CPU work...") # CPU 집약적인 계산을 스레드 풀로 오프로딩 loop = asyncio.get_event_loop() computation_result = await loop.run_in_executor( executor, partial(perform_heavy_computation, number) ) return {"input_number": number, "result": computation_result}
이는 더 고급 패턴이며, 때로는 async def 함수도 CPU 집약적인 작업을 명시적으로 관리해야 함을 시사합니다.
결론
FastAPI에서 async def와 def 사이를 선택하는 것은 애플리케이션의 성능 특성에 영향을 미치는 중요한 결정입니다. I/O 집약적 작업의 경우, async def는 거의 항상 더 나은 선택이며, 파이썬의 asyncio 이벤트 루프를 활용하여 높은 동시성과 효율적인 리소스 활용을 가능하게 합니다. 반대로, CPU 집약적인 작업이나 동기 라이브러리와의 상호 작용의 경우, def가 적합하며, FastAPI는 지능적으로 이를 스레드 풀로 오프로딩하여 메인 이벤트 루프를 차단하지 않도록 합니다. 기본 메커니즘을 이해하고 작업의 특성을 고려함으로써, 고성능 및 확장 가능한 FastAPI 애플리케이션을 효과적으로 구축할 수 있습니다. 의심스러울 때는 함수의 일부라도 비동기적으로 await할 수 있는 I/O 집약적인 부분이 있다면 async def를 선호하세요.