FastAPI의 비동기 동작 원리: 이벤트 루프, 코루틴, 그리고 동시성 처리

박병현·2025년 4월 4일
0

FastAPI의 비동기 동작 원리: 이벤트 루프, 코루틴, 그리고 동시성 처리

FastAPI의 핵심 동작 원리인 비동기(async) 처리에 대해 심층적으로 알아보려 합니다. 이벤트 루프, 코루틴, 그리고 동시성에 대한 개념을 명확히 이해함으로써 FastAPI를 더 효율적으로 활용할 수 있는 방법을 살펴보겠습니다.

실제 프로덕션 환경에서 FastAPI 애플리케이션을 개발하면서, 비동기 처리의 중요성을 잘 이해하지 못해 성능 저하 문제를 겪을 수 있으므로, 개념을 알고 개발하는것이 중요함.

WSGI와 ASGI: 파이썬 웹 서버 인터페이스의 진화

FastAPI를 이해하기 위해서는 먼저 WSGI와 ASGI의 차이를 알아야 합니다.

WSGI (Web Server Gateway Interface)

WSGI는 파이썬 웹 애플리케이션과 웹 서버 간의 표준 인터페이스로, PEP 333에서 정의되었습니다. 대표적인 구현체로는 Gunicorn, uWSGI 등이 있습니다.

주요 특징:

  • 동기식 처리: 요청이 들어오면 응답이 반환될 때까지 다른 요청을 처리하지 않음
  • HTTP 프로토콜만 지원: WebSocket 같은 새로운 프로토콜 지원 불가
  • 블로킹 I/O 모델: 한 번에 하나의 요청만 처리
# WSGI 애플리케이션 예시
def wsgi_app(environ, start_response):
    status = '200 OK'
    headers = [('Content-type', 'text/plain')]
    start_response(status, headers)
    return [b"Hello World"]

ASGI (Asynchronous Server Gateway Interface)

ASGI는 WSGI의 후속 표준으로, 비동기 웹 애플리케이션을 지원하기 위해 개발되었습니다. Uvicorn가 대표적인 ASGI입니다.

주요 특징:

  • 비동기식 처리: 여러 요청을 동시에 처리 가능
  • 다중 프로토콜 지원: HTTP, WebSocket, HTTP/2 등
  • 논블로킹 I/O 모델: I/O 작업 중에도 다른 요청 처리 가능
# 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

FastAPI는 ASGI 사양을 구현한 Starlette 프레임워크를 기반으로 합니다. 이를 통해 다음과 같은 이점을 제공합니다:

  1. 비동기 요청 처리: 동시에 많은 요청을 처리할 수 있어 성능 향상
  2. WebSocket 지원: 실시간 양방향 통신 가능
  3. 이벤트 스트리밍: Server-Sent Events와 같은 스트리밍 응답 지원
  4. 비동기 데이터베이스 지원: asyncpg, aiomysql 등과 함께 사용 가능

비동기 처리의 이해: 동기(Sync)와 비동기(Async)의 차이

Python의 async/await 문법은 비동기 프로그래밍의 핵심입니다. 동기와 비동기의 차이를 정확히 이해해 봅시다.

동기(Synchronous) 처리

def sync_function():
    # CPU 집약적인 작업
    total = 0
    for i in range(10000000):
        total += i
    return total

# 호출 예시
result = sync_function()  # 이 작업이 끝날 때까지 프로그램은 여기서 멈춤
print(f"계산 결과: {result}")

동기 함수는 호출 스택에서 직접 실행되며, 작업이 완료될 때까지 스레드를 점유합니다. 함수가 실행되는 동안 해당 스레드는 차단되어 다른 작업을 수행할 수 없습니다. 따라서 동시 작업 처리 능력이 제한됩니다.

비동기(Asynchronous) 처리

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)을 구현하는 방식으로, 스레드 컨텍스트 스위칭 오버헤드 없이 높은 처리량을 달성합니다.

이벤트 루프(Event Loop)의 핵심 개념

이벤트 루프는 비동기(async) 프로그래밍의 핵심 요소로, FastAPI 애플리케이션의 중앙 제어 시스템 역할을 합니다. 중요한 점은 이벤트 루프가 기본적으로 싱글 스레드에서 동작한다는 것입니다.

이벤트 루프의 역할

이벤트 루프는 코루틴들의 생명주기를 관리하는 오케스트레이터입니다:

  1. 코루틴 등록 및 스케줄링: async def로 정의된 함수들을 실행 큐에 등록하고 실행 순서를 결정합니다.

  2. 코루틴 실행 관리: 등록된 코루틴을 실행하고, await 키워드를 만나면 해당 코루틴을 일시 중단합니다.

  3. I/O 이벤트 모니터링: 네트워크 요청, 파일 작업, 타이머 등의 완료를 감시하고, 완료되면 관련 코루틴을 재개합니다.

  4. 효율적인 리소스 활용: I/O 작업 대기 시간 동안 CPU가 유휴 상태로 있지 않도록 다른 코루틴을 실행합니다.

이벤트 루프의 가장 큰 특징은 단일 스레드에서 동시성을 구현한다는 점입니다. 여러 작업을 동시에 실행하는 것처럼 보이지만, 실제로는 한 번에 하나의 코루틴만 실행하며 효율적으로 전환합니다.

바리스타 카페 비유로 이해하는 이벤트 루프

비동기 이벤트 루프 시스템을 바리스타 카페 운영 방식에 비유해 볼 수 있습니다:

1. 단일 바리스타 모델 (이벤트 루프, asyncio.get_event_loop())

  • 바리스타 한 명(이벤트 루프)이 모든 주문(코루틴)을 관리합니다.
  • 이 바리스타는 한 번에 한 가지 작업만 직접 수행할 수 있습니다.

2. 주문 처리 과정 (비동기 처리, async/await)

  • 주문 접수: 고객이 주문하면 바리스타는 주문을 받습니다(요청 수신, async def 함수 호출).
  • 작업 위임: 에스프레소 추출이 필요하면 머신에 작업을 맡기고(await 키워드로 I/O 작업 시작), 주문 메모를 작업 보드에 붙입니다(작업 등록).
  • 다음 작업 전환: 바리스타는 즉시 다른 고객의 주문을 받거나 다른 음료를 준비합니다(이벤트 루프가 다른 코루틴 실행).
  • 완료된 작업 확인: 주기적으로 머신을 확인하여 완료된 에스프레소를 확인합니다(이벤트 루프가 I/O 작업 완료 체크).
  • 작업 완료 및 제공: 에스프레소가 준비되면 해당 음료를 완성하여 고객에게 제공합니다(await 완료 후 코루틴 재개 및 완료).

3. 무거운 작업 처리 (스레드풀 활용, asyncio.to_thread())

  • 복잡한 케이크 장식처럼 집중이 필요한 작업(CPU 집약적 작업)은 다른 직원(스레드풀)에게 위임합니다.
  • 바리스타는 이 작업의 완료를 기다리는 동안에도 계속해서 다른 주문을 처리할 수 있습니다(await asyncio.to_thread(heavy_function)).

주요 특징:
1. 싱글 스레드 모델: 기본적으로 이벤트 루프는 하나의 스레드(주로 메인 스레드)에서 실행됩니다.
2. Non-blocking 방식: I/O 작업이 발생하면 await를 통해 작업을 등록하고 다른 작업으로 전환합니다.
3. 스레드풀 활용: CPU 집약적 작업이나 블로킹 작업은 asyncio.to_thread()를 통해 별도의 스레드풀에 위임합니다.

이 모델의 장점은 스레드 간 컨텍스트 스위칭 비용이 발생하지 않고, 공유 자원에 대한 락(lock)이 필요 없어 효율적이라는 점입니다.

이벤트 루프의 효율성

바리스타 카페 모델의 효율성은 다음과 같습니다:

  1. 대기 시간 활용: 커피 머신이 에스프레소를 추출하는 동안(I/O 대기 시간) 바리스타는 다른 주문을 처리할 수 있습니다(await 이후 다른 코루틴 실행).
  2. 자원 최적화: 바리스타 한 명(단일 스레드)으로도 여러 주문을 효율적으로 처리할 수 있습니다(이벤트 루프의 효율적인 작업 관리).
  3. 병목 현상 방지: 무거운 작업(케이크 장식)은 다른 직원에게 위임하여 주문 흐름이 막히지 않게 합니다(asyncio.to_thread()로 CPU 작업 위임).

FastAPI에서도 마찬가지로, 이벤트 루프는 I/O 작업(데이터베이스 쿼리, API 호출 등) 대기 시간 동안 다른 요청을 처리함으로써 서버 리소스를 최대한 활용합니다.

이벤트 루프의 역할

  1. 작업 관리: 모든 비동기 작업(코루틴)을 등록하고 관리
  2. 제어 흐름 조정: await 지점에서 코루틴 간의 전환을 결정
  3. I/O 이벤트 처리: 네트워크 요청, 파일 작업 등의 완료를 감지
  4. 결과 전달: 완료된 작업의 결과를 해당 코루틴에 반환

이벤트 루프 동작 방식 간단 설명

  1. 클라이언트 요청 수신: FastAPI가 HTTP 요청을 받습니다.
  2. 핸들러 실행: 이벤트 루프가 해당 엔드포인트 핸들러(라우트 함수, async def)를 실행합니다.
  3. await 지점: 핸들러에서 await를 만나면 해당 코루틴이 일시 중단됩니다.
  4. 다른 작업 처리: 이벤트 루프는 다른 요청이나 작업으로 전환합니다.
  5. 작업 완료 알림: I/O 작업이 완료되면 이벤트 루프에 알립니다.
  6. 코루틴 재개: 이벤트 루프가 일시 중단된 코루틴을 재개합니다.

이 모든 과정이 단일 스레드 내에서 이루어진다는 점이 중요합니다. 동시성(Concurrency)은 있지만 병렬성(Parallelism)은 없습니다.

이벤트 루프는 단일 스레드에서 여러 작업을 전환하며 처리하는 "교통정리 담당자"와 같습니다. I/O 작업처럼 "기다림"이 필요한 작업이 많은 웹 애플리케이션에서 이 방식은 매우 효율적입니다.

코루틴(Coroutine)과 일시 중단 메커니즘

코루틴은 일시 중단되고 재개될 수 있는 함수입니다. 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 내부 처리 방식을 이해하는 것이 중요합니다:

위 다이어그램은 FastAPI에서 비동기(async/await) 요청이 처리되는 전체 생명주기를 보여줍니다. 각 단계별로 살펴보겠습니다:

  1. 요청 수신과 라우팅: 클라이언트의 요청이 ASGI 서버(Uvicorn)에 도달하면, 해당 요청은 적절한 async def 엔드포인트 핸들러로 라우팅됩니다.

  2. 코루틴 실행: async def로 정의된 함수는 호출 시 즉시 실행되지 않고 코루틴 객체를 반환합니다. 이 코루틴은 이벤트 루프에 의해 실행됩니다.

  3. await 지점 처리: 코루틴 실행 중 await 키워드를 만나면(예: await db.fetch_one(query)) 다음과 같은 일이 발생합니다:

    • 코루틴의 실행이 일시 중단됩니다(현재 상태와 실행 위치 저장)
    • 해당 작업이 이벤트 루프에 등록되고 콜백이 설정됩니다
    • 일시 중단된 코루틴의 제어권이 이벤트 루프로 반환됩니다
  4. I/O 작업 시작: 이벤트 루프에 등록된 작업에 따라 비동기 I/O 작업(데이터베이스 쿼리, HTTP 요청 등)이 시작됩니다. 이 작업들은 비차단(non-blocking) 방식으로 운영 체제 또는 하위 시스템에 위임됩니다.

  5. 이벤트 루프의 동시성 처리: I/O 작업이 진행되는 동안, 이벤트 루프는 다른 코루틴들을 계속해서 처리합니다:

    • 새로운 요청에 대한 핸들러 실행
    • 다른 await 지점 처리
    • 완료된 I/O 작업 확인
  6. I/O 완료 감지: 이벤트 루프는 각 순환마다 완료된 I/O 작업이 있는지 확인합니다. 모든 이벤트 루프 반복에서 이 확인 작업이 이루어집니다.

  7. 코루틴 재개: I/O 작업이 완료되면 해당 결과와 함께 일시 중단되었던a 코루틴이 재개됩니다. 코루틴은 await 표현식의 결과를 받아 실행을 계속합니다.

  8. 응답 반환: 코루틴 실행이 완료되면 응답이 생성되어 클라이언트에게 반환됩니다.

핵심 메커니즘 이해

  1. 싱글 스레드 동시성: FastAPI의 비동기 처리는 기본적으로 단일 스레드에서 이루어집니다. 다중 스레드 없이도 많은 요청을 동시에 처리할 수 있는 것은 이벤트 루프의 효율적인 작업 전환 덕분입니다.

  2. 이벤트 루프의 역할: 이벤트 루프는 모든 비동기 작업의 중앙 관리자입니다. 작업을 등록하고, 실행하고, 완료를 감지하며, 코루틴을 재개하는 역할을 담당합니다.

  3. I/O 대기 시간 활용: 비동기 처리의 핵심 이점은 I/O 작업의 대기 시간 동안 CPU가 다른 작업을 처리할 수 있다는 점입니다. 데이터베이스나 네트워크 응답을 기다리는 동안 다른 요청을 처리함으로써 서버 리소스를 최대한 활용합니다.

  4. 논블로킹 I/O: 비동기 I/O 작업은 완료될 때까지 스레드를 차단하지 않습니다. 대신, 작업이 시작된 후 제어권은 즉시 이벤트 루프로 반환되고, 완료 시 콜백을 통해 알림을 받습니다.

실제 적용 시 고려사항

  1. 비동기 라이브러리 사용: FastAPI의 비동기 성능을 최대화하려면 비동기 데이터베이스 드라이버(예: asyncpg, aiomysql)와 비동기 HTTP 클라이언트(예: httpx)를 사용해야 합니다.

  2. 이벤트 루프 차단 방지: async def 함수 내에서 CPU 집약적인 동기 작업을 직접 실행하면 전체 이벤트 루프가 차단됩니다. 이런 작업은 asyncio.to_thread()를 사용하여 별도의 스레드로 위임해야 합니다.

  3. 코루틴 체인: 여러 비동기 작업을 순차적으로 실행해야 할 경우, 각 작업에 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 함수 내의 동기 로직

async 함수 내에서 동기(blocking) 작업을 직접 호출하는 것은 매우 위험합니다. 이것은 "늑대를 양의 탈을 쓴" 것과 같은 상황으로, 비동기로 보이지만 실제로는 전체 이벤트 루프를 차단합니다.

실제 사례: RAG 시스템에서의 임베딩 처리

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)을 모두 활용할 수 있습니다.

동시성 (Concurrency)

  • 단일 스레드에서 여러 작업을 번갈아가며 실행
  • I/O 바운드 작업에 효과적 (네트워크 요청, DB 쿼리 등)
  • FastAPI의 비동기 기능은 주로 동시성에 중점

병렬성 (Parallelism)

  • 여러 CPU 코어를 활용하여 작업을 실제로 동시에 실행
  • CPU 바운드 작업에 효과적 (데이터 처리, 계산 등)
  • --workers 옵션이나 별도 프로세스를 통해 구현

성능 최적화 가이드라인

FastAPI 애플리케이션의 성능을 최적화하기 위한 핵심 가이드라인입니다:

  1. 기본적으로 비동기 엔드포인트 사용: async def로 API 정의
  2. I/O 작업은 항상 await 사용: 데이터베이스, 외부 API 등
  3. CPU 집약적인 작업은 위임:
    result = await asyncio.to_thread(heavy_calculation, data)
  4. 적절한 워커 수 설정: 일반적으로 CPU 코어 수에 맞게 설정
  5. 비동기 데이터베이스 드라이버 활용: 가능하다면 asyncpg, aiomysql 등 사용

결론

FastAPI는 비동기 처리를 효율적으로 활용할 수 있도록 설계되었습니다. 이벤트 루프, 코루틴, 그리고 비동기 패턴을 이해하고 적절히 활용함으로써 고성능 웹 API를 개발할 수 있습니다.

모든 엔드포인트가 반드시 비동기일 필요는 없지만, 이벤트 루프를 차단하지 않도록 주의해야 합니다. CPU 집약적인 작업은 별도의 스레드나 프로세스로 위임하고, I/O 바운드 작업은 비동기로 처리하는 것이 좋습니다.

특히 주의해야 할 것은 async 함수 내부에 동기 로직이 숨어 있는 경우입니다. 이는 마치 트로이 목마처럼 비동기 시스템 내부에 숨어들어 전체 성능을 저하시킬 수 있습니다. RAG 시스템의 임베딩 생성이나 ML 모델 추론과 같은 무거운 작업은 항상 별도 스레드나 백그라운드 작업으로 처리해야 합니다.

마지막 조언: FastAPI에서 성능 문제가 발생한다면, 가장 먼저 확인할 것은 "이벤트 루프를 차단하는 코드가 있는지"입니다. 특히 async 함수 내에 숨어 있는 동기 로직을 찾아내는 것이 중요합니다. 대부분의 성능 이슈는 이 문제에서 비롯됩니다.

참고 자료

profile
AI Application Engineer

0개의 댓글