FastAPI의 핵심 동작 원리인 비동기(async) 처리에 대해 심층적으로 알아보려 합니다. 이벤트 루프, 코루틴, 그리고 동시성에 대한 개념을 명확히 이해함으로써 FastAPI를 더 효율적으로 활용할 수 있는 방법을 살펴보겠습니다.
실제 프로덕션 환경에서 FastAPI 애플리케이션을 개발하면서, 비동기 처리의 중요성을 잘 이해하지 못해 성능 저하 문제를 겪을 수 있으므로, 개념을 알고 개발하는것이 중요함.
FastAPI를 이해하기 위해서는 먼저 WSGI와 ASGI의 차이를 알아야 합니다.
WSGI는 파이썬 웹 애플리케이션과 웹 서버 간의 표준 인터페이스로, PEP 333에서 정의되었습니다. 대표적인 구현체로는 Gunicorn, uWSGI 등이 있습니다.
주요 특징:
# WSGI 애플리케이션 예시
def wsgi_app(environ, start_response):
status = '200 OK'
headers = [('Content-type', 'text/plain')]
start_response(status, headers)
return [b"Hello World"]
ASGI는 WSGI의 후속 표준으로, 비동기 웹 애플리케이션을 지원하기 위해 개발되었습니다. Uvicorn가 대표적인 ASGI입니다.
주요 특징:
# ASGI 애플리케이션 예시
async def asgi_app(scope, receive, send):
if scope['type'] == 'http':
await send({
'type': 'http.response.start',
'status': 200,
'headers': [
[b'content-type', b'text/plain'],
],
})
await send({
'type': 'http.response.body',
'body': b'Hello, world!',
})
FastAPI는 ASGI 사양을 구현한 Starlette 프레임워크를 기반으로 합니다. 이를 통해 다음과 같은 이점을 제공합니다:
Python의 async
/await
문법은 비동기 프로그래밍의 핵심입니다. 동기와 비동기의 차이를 정확히 이해해 봅시다.
def sync_function():
# CPU 집약적인 작업
total = 0
for i in range(10000000):
total += i
return total
# 호출 예시
result = sync_function() # 이 작업이 끝날 때까지 프로그램은 여기서 멈춤
print(f"계산 결과: {result}")
동기 함수는 호출 스택에서 직접 실행되며, 작업이 완료될 때까지 스레드를 점유합니다. 함수가 실행되는 동안 해당 스레드는 차단되어 다른 작업을 수행할 수 없습니다. 따라서 동시 작업 처리 능력이 제한됩니다.
import asyncio
async def async_function():
# I/O 작업 (데이터베이스 쿼리, API 호출 등)
await asyncio.sleep(1) # I/O 작업 시뮬레이션
return "작업 완료"
# 호출 예시
async def main():
result = await async_function() # 대기 중에 다른 작업 가능
print(f"비동기 결과: {result}")
asyncio.run(main())
비동기 함수는 이벤트 루프에 의해 관리됩니다. await
지점에서 해당 코루틴은 실행 상태(스택 프레임, 로컬 변수, 실행 위치)를 보존한 채 일시 중단되고, 제어권이 이벤트 루프로 반환됩니다. 이벤트 루프는 I/O 작업의 완료 여부를 확인하고, 완료된 작업의 코루틴을 재개시킵니다.
핵심 포인트: 비동기 처리의 효율성은 I/O 대기 시간 동안 CPU가 다른 작업을 수행할 수 있다는 점에 있습니다. 이는 단일 스레드에서 동시성(concurrency)을 구현하는 방식으로, 스레드 컨텍스트 스위칭 오버헤드 없이 높은 처리량을 달성합니다.
이벤트 루프는 비동기(async
) 프로그래밍의 핵심 요소로, FastAPI 애플리케이션의 중앙 제어 시스템 역할을 합니다. 중요한 점은 이벤트 루프가 기본적으로 싱글 스레드에서 동작한다는 것입니다.
이벤트 루프는 코루틴들의 생명주기를 관리하는 오케스트레이터입니다:
코루틴 등록 및 스케줄링: async def
로 정의된 함수들을 실행 큐에 등록하고 실행 순서를 결정합니다.
코루틴 실행 관리: 등록된 코루틴을 실행하고, await
키워드를 만나면 해당 코루틴을 일시 중단합니다.
I/O 이벤트 모니터링: 네트워크 요청, 파일 작업, 타이머 등의 완료를 감시하고, 완료되면 관련 코루틴을 재개합니다.
효율적인 리소스 활용: I/O 작업 대기 시간 동안 CPU가 유휴 상태로 있지 않도록 다른 코루틴을 실행합니다.
이벤트 루프의 가장 큰 특징은 단일 스레드에서 동시성을 구현한다는 점입니다. 여러 작업을 동시에 실행하는 것처럼 보이지만, 실제로는 한 번에 하나의 코루틴만 실행하며 효율적으로 전환합니다.
비동기 이벤트 루프 시스템을 바리스타 카페 운영 방식에 비유해 볼 수 있습니다:
1. 단일 바리스타 모델 (이벤트 루프, asyncio.get_event_loop()
)
2. 주문 처리 과정 (비동기 처리, async
/await
)
async def
함수 호출).await
키워드로 I/O 작업 시작), 주문 메모를 작업 보드에 붙입니다(작업 등록).await
완료 후 코루틴 재개 및 완료).3. 무거운 작업 처리 (스레드풀 활용, asyncio.to_thread()
)
await asyncio.to_thread(heavy_function)
).주요 특징:
1. 싱글 스레드 모델: 기본적으로 이벤트 루프는 하나의 스레드(주로 메인 스레드)에서 실행됩니다.
2. Non-blocking 방식: I/O 작업이 발생하면 await
를 통해 작업을 등록하고 다른 작업으로 전환합니다.
3. 스레드풀 활용: CPU 집약적 작업이나 블로킹 작업은 asyncio.to_thread()
를 통해 별도의 스레드풀에 위임합니다.
이 모델의 장점은 스레드 간 컨텍스트 스위칭 비용이 발생하지 않고, 공유 자원에 대한 락(lock)이 필요 없어 효율적이라는 점입니다.
바리스타 카페 모델의 효율성은 다음과 같습니다:
await
이후 다른 코루틴 실행).asyncio.to_thread()
로 CPU 작업 위임).FastAPI에서도 마찬가지로, 이벤트 루프는 I/O 작업(데이터베이스 쿼리, API 호출 등) 대기 시간 동안 다른 요청을 처리함으로써 서버 리소스를 최대한 활용합니다.
await
지점에서 코루틴 간의 전환을 결정async def
)를 실행합니다.await
를 만나면 해당 코루틴이 일시 중단됩니다.이 모든 과정이 단일 스레드 내에서 이루어진다는 점이 중요합니다. 동시성(Concurrency)은 있지만 병렬성(Parallelism)은 없습니다.
이벤트 루프는 단일 스레드에서 여러 작업을 전환하며 처리하는 "교통정리 담당자"와 같습니다. I/O 작업처럼 "기다림"이 필요한 작업이 많은 웹 애플리케이션에서 이 방식은 매우 효율적입니다.
코루틴은 일시 중단되고 재개될 수 있는 함수입니다. Python에서는 async def
로 정의됩니다.
async def fetch_data():
print("데이터 요청 시작")
await asyncio.sleep(2) # 여기서 코루틴이 일시 중단됨
print("데이터 수신 완료")
return {"data": "결과값"}
위 코드에서 await asyncio.sleep(2)
라인에서:
1. fetch_data()
코루틴 전체가 일시 중단됩니다.
2. 제어권이 이벤트 루프로 돌아갑니다.
3. 이벤트 루프는 그동안 다른 코루틴을 실행합니다.
4. 2초 후, 이벤트 루프는 fetch_data()
를 재개합니다.
중요한 이해: 코루틴이 일시 중단될 때, 그 코루틴의 상태(변수, 실행 위치 등)는 보존됩니다. 이것이 FastAPI가 많은 동시 요청을 효율적으로 처리할 수 있는 이유입니다.
FastAPI의 async 내부 처리 방식을 이해하는 것이 중요합니다:
위 다이어그램은 FastAPI에서 비동기(async/await
) 요청이 처리되는 전체 생명주기를 보여줍니다. 각 단계별로 살펴보겠습니다:
요청 수신과 라우팅: 클라이언트의 요청이 ASGI 서버(Uvicorn)에 도달하면, 해당 요청은 적절한 async def
엔드포인트 핸들러로 라우팅됩니다.
코루틴 실행: async def
로 정의된 함수는 호출 시 즉시 실행되지 않고 코루틴 객체를 반환합니다. 이 코루틴은 이벤트 루프에 의해 실행됩니다.
await 지점 처리: 코루틴 실행 중 await
키워드를 만나면(예: await db.fetch_one(query)
) 다음과 같은 일이 발생합니다:
I/O 작업 시작: 이벤트 루프에 등록된 작업에 따라 비동기 I/O 작업(데이터베이스 쿼리, HTTP 요청 등)이 시작됩니다. 이 작업들은 비차단(non-blocking) 방식으로 운영 체제 또는 하위 시스템에 위임됩니다.
이벤트 루프의 동시성 처리: I/O 작업이 진행되는 동안, 이벤트 루프는 다른 코루틴들을 계속해서 처리합니다:
I/O 완료 감지: 이벤트 루프는 각 순환마다 완료된 I/O 작업이 있는지 확인합니다. 모든 이벤트 루프 반복에서 이 확인 작업이 이루어집니다.
코루틴 재개: I/O 작업이 완료되면 해당 결과와 함께 일시 중단되었던a 코루틴이 재개됩니다. 코루틴은 await
표현식의 결과를 받아 실행을 계속합니다.
응답 반환: 코루틴 실행이 완료되면 응답이 생성되어 클라이언트에게 반환됩니다.
싱글 스레드 동시성: FastAPI의 비동기 처리는 기본적으로 단일 스레드에서 이루어집니다. 다중 스레드 없이도 많은 요청을 동시에 처리할 수 있는 것은 이벤트 루프의 효율적인 작업 전환 덕분입니다.
이벤트 루프의 역할: 이벤트 루프는 모든 비동기 작업의 중앙 관리자입니다. 작업을 등록하고, 실행하고, 완료를 감지하며, 코루틴을 재개하는 역할을 담당합니다.
I/O 대기 시간 활용: 비동기 처리의 핵심 이점은 I/O 작업의 대기 시간 동안 CPU가 다른 작업을 처리할 수 있다는 점입니다. 데이터베이스나 네트워크 응답을 기다리는 동안 다른 요청을 처리함으로써 서버 리소스를 최대한 활용합니다.
논블로킹 I/O: 비동기 I/O 작업은 완료될 때까지 스레드를 차단하지 않습니다. 대신, 작업이 시작된 후 제어권은 즉시 이벤트 루프로 반환되고, 완료 시 콜백을 통해 알림을 받습니다.
비동기 라이브러리 사용: FastAPI의 비동기 성능을 최대화하려면 비동기 데이터베이스 드라이버(예: asyncpg, aiomysql)와 비동기 HTTP 클라이언트(예: httpx)를 사용해야 합니다.
이벤트 루프 차단 방지: async def
함수 내에서 CPU 집약적인 동기 작업을 직접 실행하면 전체 이벤트 루프가 차단됩니다. 이런 작업은 asyncio.to_thread()
를 사용하여 별도의 스레드로 위임해야 합니다.
코루틴 체인: 여러 비동기 작업을 순차적으로 실행해야 할 경우, 각 작업에 await
를 사용하면 됩니다. 병렬 실행이 필요한 경우 asyncio.gather()
를 활용할 수 있습니다.
async def sequential_operations():
# 순차적 실행
result1 = await operation1()
result2 = await operation2(result1)
return result2
async def parallel_operations():
# 병렬 실행
result1, result2 = await asyncio.gather(
operation1(),
operation2()
)
return combine_results(result1, result2)
FastAPI의 비동기 처리 메커니즘을 이해하면 효율적인 웹 API를 설계하고 구현할 수 있습니다. 특히 동시 요청이 많거나 I/O 작업이 빈번한 애플리케이션에서 큰 성능 향상을 기대할 수 있습니다.
async
함수 내에서 동기(blocking) 작업을 직접 호출하는 것은 매우 위험합니다. 이것은 "늑대를 양의 탈을 쓴" 것과 같은 상황으로, 비동기로 보이지만 실제로는 전체 이벤트 루프를 차단합니다.
RAG(Retrieval Augmented Generation) 시스템을 구현할 때 흔히 발생하는 문제를 살펴보겠습니다:
@app.post("/query")
async def process_query(query: str):
# 🚨 위험한 패턴! 🚨
# 비동기 함수 내에서 무거운 동기 작업 직접 호출
embeddings = embedding_model.embedding(query) # 시간이 오래 소요될 수 있음
# 여기서부터 비동기 작업
similar_docs = await vector_db.search(embeddings)
response = await llm.generate_response(query, similar_docs)
return {"response": response}
위 코드의 문제점:
1. embedding_model.embedding(query)
는 CPU 집약적인 동기 작업입니다.
2. async
함수 내부에서 직접 호출되고 있습니다.
3. 임베딩 생성 중에는 전체 서버의 모든 요청이 차단됩니다.
4. 사용자는 비동기 엔드포인트의 성능 이점을 전혀 얻지 못합니다.
@app.post("/query")
async def process_query(query: str):
# 올바른 패턴: 동기 작업을 별도 스레드로 위임
embeddings = await asyncio.to_thread(embedding_model.embedding, query)
# 계속해서 비동기 작업
similar_docs = await vector_db.search(embeddings)
response = await llm.generate_response(query, similar_docs)
return {"response": response}
FastAPI에서는 동시성(Concurrency)과 병렬성(Parallelism)을 모두 활용할 수 있습니다.
--workers
옵션이나 별도 프로세스를 통해 구현FastAPI 애플리케이션의 성능을 최적화하기 위한 핵심 가이드라인입니다:
async def
로 API 정의await
사용: 데이터베이스, 외부 API 등result = await asyncio.to_thread(heavy_calculation, data)
FastAPI는 비동기 처리를 효율적으로 활용할 수 있도록 설계되었습니다. 이벤트 루프, 코루틴, 그리고 비동기 패턴을 이해하고 적절히 활용함으로써 고성능 웹 API를 개발할 수 있습니다.
모든 엔드포인트가 반드시 비동기일 필요는 없지만, 이벤트 루프를 차단하지 않도록 주의해야 합니다. CPU 집약적인 작업은 별도의 스레드나 프로세스로 위임하고, I/O 바운드 작업은 비동기로 처리하는 것이 좋습니다.
특히 주의해야 할 것은 async 함수 내부에 동기 로직이 숨어 있는 경우입니다. 이는 마치 트로이 목마처럼 비동기 시스템 내부에 숨어들어 전체 성능을 저하시킬 수 있습니다. RAG 시스템의 임베딩 생성이나 ML 모델 추론과 같은 무거운 작업은 항상 별도 스레드나 백그라운드 작업으로 처리해야 합니다.
마지막 조언: FastAPI에서 성능 문제가 발생한다면, 가장 먼저 확인할 것은 "이벤트 루프를 차단하는 코드가 있는지"입니다. 특히 async 함수 내에 숨어 있는 동기 로직을 찾아내는 것이 중요합니다. 대부분의 성능 이슈는 이 문제에서 비롯됩니다.