본문 바로가기

동기와 비동기

1. def와 async def

지금까지 어떤 함수는 def로, 어떤 함수는 async def로 썼습니다. 둘 다 잘 동작해서 별생각 없이 넘어갔을 수 있습니다. 그런데 이 선택은 서버의 성능을 수십 배 차이 나게 만들 수 있습니다.

이번 절은 이 책에서 가장 중요한 내용 중 하나입니다. FastAPI 코드가 실행은 잘 되는데 이상하게 느린 경우, 원인의 대부분이 여기에 있습니다.

1.1 실습 세팅

직접 측정해보면서 진행하겠습니다.

앞 실습의 서버가 실행 중이면 Ctrl + C로 멈추고, 가상환경이 켜져 있다면 deactivate로 빠져나옵니다. 아래 명령은 실습 폴더들을 모아둔 상위 폴더에서 실행하세요.

mkdir 06_4_async
cd 06_4_async
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install "fastapi[standard]" httpx

macOS/Linux에서는 python -m venv venv 대신 python3 -m venv venv를, 활성화 명령 대신 source ./venv/bin/activate를 사용합니다. 이후 명령은 가상환경이 활성화된 상태에서 실행합니다.

2. 직접 측정해보기

main.py 파일에 아래 코드를 작성합니다. 세 엔드포인트 모두 1초를 기다렸다가 응답합니다.

import asyncio
import time

from fastapi import FastAPI

app = FastAPI()


@app.get("/sync-sleep")
def sync_sleep():
    """일반 함수 안에서 1초 대기"""
    time.sleep(1)
    return {"ok": True}


@app.get("/async-block")
async def async_block():
    """비동기 함수 안에서 일반 대기 (잘못된 코드입니다)"""
    time.sleep(1)
    return {"ok": True}


@app.get("/async-ok")
async def async_ok():
    """비동기 함수 안에서 비동기 대기"""
    await asyncio.sleep(1)
    return {"ok": True}

이번에는 fastapi dev 대신 fastapi run으로 실행합니다. 개발 서버는 파일 감시 때문에 측정에 영향을 줄 수 있기 때문입니다.

fastapi run main.py

측정용 코드를 bench.py 파일에 작성합니다. 요청 10개를 동시에 보내고 전체 시간을 재는 코드입니다.

import asyncio
import time

import httpx

BASE = "http://127.0.0.1:8000"


async def run(path: str, n: int = 10) -> float:
    async with httpx.AsyncClient(timeout=60) as client:
        start = time.perf_counter()
        await asyncio.gather(*[client.get(f"{BASE}{path}") for _ in range(n)])
        return time.perf_counter() - start


async def main() -> None:
    for path in ("/sync-sleep", "/async-block", "/async-ok"):
        elapsed = await run(path)
        print(f"{path:15} 요청 10개 -> {elapsed:.2f}초")


asyncio.run(main())

httpx는 측정 코드에서 직접 사용하므로 실습 세팅에서 함께 설치했습니다. 서버를 켜둔 채로 다른 터미널을 열고, 06_4_async 폴더로 이동해 같은 venv를 활성화한 뒤 실행합니다.

python bench.py

2.1 결과

/sync-sleep     요청 10개 -> 1.03초
/async-block    요청 10개 -> 10.02초
/async-ok       요청 10개 -> 1.03초

같은 1초 대기인데 가운데 것만 10배 느립니다. async def 안에서 time.sleep을 썼기 때문입니다.

엔드포인트10개 요청왜
/sync-sleep1초FastAPI가 별도 스레드에서 실행해 동시에 처리됩니다
/async-block10초서버 전체가 멈춰서 하나씩 순서대로 처리됩니다
/async-ok1초기다리는 동안 다른 요청을 처리합니다

/async-block은 틀린 코드가 아니라 느린 코드라는 점이 무섭습니다. 에러가 나지 않고 결과도 맞습니다. 사용자가 한 명일 때는 전혀 티가 나지 않다가, 사용자가 늘어나면 서버가 멈춘 것처럼 보이기 시작합니다.

3. 이런 차이가 생기는 이유

3.1 FastAPI가 두 함수를 다루는 방식

FastAPI는 함수를 보고 실행 방식을 정합니다.

  • async def 함수는 이벤트 루프에서 직접 실행합니다. 이벤트 루프는 하나뿐이며, 여기서 실행되는 코드가 멈추면 서버 전체가 멈춥니다.
  • def 함수는 별도의 스레드 풀에서 실행합니다. 그 함수가 멈춰도 이벤트 루프는 계속 다른 요청을 받습니다.

이벤트 루프를 식당의 홀 서빙 직원 한 명이라고 생각해보세요. 이 직원은 여러 테이블을 오가며 주문을 받습니다. 주방에서 음식이 나오기를 기다리는 동안 다른 테이블의 주문을 받습니다. 이것이 await입니다.

그런데 이 직원이 한 테이블 앞에 서서 음식이 나올 때까지 가만히 기다린다면, 나머지 테이블은 전부 방치됩니다. 이것이 async def 안에서 time.sleep을 쓴 상황입니다.

3.2 await가 붙지 않는 것이 신호입니다

async def 함수 안에서 시간이 걸리는 작업을 할 때, 그 앞에 await가 없다면 의심해야 합니다.

@app.get("/bad")
async def bad():
    time.sleep(1)                    # await 없음, 서버가 멈춥니다
    response = requests.get(url)     # await 없음, 서버가 멈춥니다
    data = pd.read_csv("big.csv")    # await 없음, 서버가 멈춥니다
    return data
@app.get("/good")
async def good():
    await asyncio.sleep(1)           # 괜찮습니다
    async with httpx.AsyncClient() as client:
        response = await client.get(url)  # 괜찮습니다
    return response.json()

4. 무엇을 쓸지 고르는 법

4.1 판단 기준

함수 안에서 하는 일선택
계산만 하고 바로 반환아무거나 (def 권장)
SQLAlchemy로 데이터베이스 조회 (동기)def
requests로 외부 API 호출def
파일 읽기와 쓰기def
Pandas, 이미지 처리 등 무거운 계산def
httpx.AsyncClient로 외부 API 호출async def
비동기 데이터베이스 라이브러리 사용async def
다른 async 함수를 awaitasync def

확신이 서지 않으면 def를 쓰세요. def는 느려질 수는 있어도 서버 전체를 멈추게 하지는 않습니다. async def를 잘못 쓰면 서버 전체가 멈춥니다.

이 책의 4장과 5장에서 데이터베이스를 다루는 엔드포인트를 전부 def로 쓴 이유가 여기에 있습니다. SQLAlchemy의 동기 세션은 await할 수 없는 코드이므로 def가 맞습니다.

AI가 만들어 준 코드에서 가장 자주 보이는 실수

AI에게 FastAPI 코드를 요청하면 거의 모든 엔드포인트를 async def로 만들어 줍니다. "FastAPI는 비동기 프레임워크"라는 인상이 강하기 때문으로 보입니다.

그런데 그 안에서 SQLAlchemy 동기 세션을 쓰거나 requests를 호출하는 코드를 함께 만들어 줍니다. 이 조합이 바로 /async-block입니다.

받은 코드를 확인하는 방법은 간단합니다. async def인데 함수 안에 await가 하나도 없다면 def로 바꾸세요. 그것만으로 대부분의 경우가 해결됩니다.

4.2 반드시 async def 안에서 무거운 일을 해야 한다면

이미 async def로 되어 있는데 그 안에서 동기 코드를 호출해야 하는 상황이 있습니다. 이럴 때는 그 부분만 스레드로 넘깁니다.

import asyncio
import time

from fastapi import FastAPI

app = FastAPI()


def heavy_work() -> str:
    """시간이 오래 걸리는 동기 함수"""
    time.sleep(1)
    return "완료"


@app.get("/offload")
async def offload():
    # 이 함수만 별도 스레드에서 실행하고, 그동안 이벤트 루프는 자유롭습니다
    result = await asyncio.to_thread(heavy_work)
    return {"result": result}

asyncio.to_thread로 감싸면 /sync-sleep과 같은 성능이 나옵니다. 직접 측정해서 확인해보세요.

Starlette이 제공하는 함수를 써도 됩니다.

from starlette.concurrency import run_in_threadpool

result = await run_in_threadpool(heavy_work)

5. 의존성도 같은 규칙을 따릅니다

4장에서 만든 get_db 함수를 다시 보겠습니다.

def get_db() -> Generator[Session, None, None]:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

async def가 아니라 def입니다. 의존성 함수도 엔드포인트와 같은 규칙으로 처리되기 때문입니다. SessionLocal()은 동기 코드이므로 def가 맞습니다.

async def로 바꿔서 실험해보면, 데이터베이스 연결이 이벤트 루프를 막게 되어 동시 처리 성능이 떨어집니다.

6. 스레드 풀에도 한계가 있습니다

def를 쓰면 안전하다고 했지만, 무한정 안전한 것은 아닙니다. FastAPI가 사용하는 스레드 풀에는 정해진 개수가 있습니다. 기본값은 40개입니다.

1초 걸리는 def 엔드포인트에 요청 100개가 동시에 들어오면, 앞의 40개가 처리되는 동안 나머지 60개는 대기합니다. 전체는 약 3초가 걸립니다.

그래서 진짜 트래픽이 많은 서비스는 결국 비동기 라이브러리로 넘어갑니다. 데이터베이스도 asyncpg 같은 비동기 드라이버와 SQLAlchemy의 비동기 세션을 쓰게 됩니다.

다만 이것은 처음부터 할 일은 아닙니다. 동기 코드로 먼저 정확하게 만들고, 측정해서 느린 곳을 찾은 뒤에 바꾸는 것이 순서입니다. 비동기 코드는 디버깅이 훨씬 어렵기 때문에, 필요하지 않은데 미리 도입하면 개발 속도만 느려집니다.

7. 남의 코드를 볼 때

FastAPI 코드를 받았을 때, 또는 예전에 쓴 코드를 다시 열었을 때 아래 순서로 훑어보면 이 절의 문제를 빠르게 찾을 수 있습니다.

  1. async def로 선언된 함수를 찾습니다.
  2. 그 함수 안에 await가 하나라도 있는지 봅니다.
  3. 없다면 def로 바꿉니다.
  4. 있더라도 time.sleep, requests.get, 무거운 계산처럼 await가 붙지 않은 줄이 함께 있는지 확인합니다.
  5. 그런 줄이 있으면 asyncio.to_thread로 감싸거나 함수 전체를 def로 되돌립니다.

연습문제

  1. /offload 엔드포인트를 추가하고 bench.py로 측정해보세요. /async-block과 얼마나 차이 나는지 확인해보세요.
  2. bench.py의 요청 개수를 100개로 늘려 세 엔드포인트를 다시 측정해보세요. /sync-sleep이 몇 초가 걸리는지, 왜 그런지 설명해보세요.
  3. 5장에서 만든 블로그의 엔드포인트를 전부 async def로 바꾸고 목록 조회를 여러 번 동시에 요청해보세요. def일 때와 비교해보세요.
  4. requests와 httpx.AsyncClient로 각각 외부 API를 10번 호출하는 엔드포인트를 만들고 측정해보세요. 외부 API로는 https://dev.wenivops.co.kr/services/fastapi-crud/1/blog를 쓰시면 됩니다.