FastAPI는 동기/비동기를 어떻게 구분할까? async def와 def 제대로 이해하기

fastapi

목록 보기
3/4
post-thumbnail

들어가며

FastAPI로 API를 만들다 보면 항상 마주치는 질문이 있습니다. "이 경로 처리 함수, def로 써야 할까 async def로 써야 할까?" 언뜻 사소해 보이지만 이 선택이 서버 성능에 꽤 큰 영향을 줍니다. 공식 문서의 Concurrency and async / await 페이지를 기반으로, FastAPI가 동기와 비동기를 어떻게 구분하고 처리하는지, 그리고 그게 왜 이렇게까지 편하게 느껴지는지 정리해봤습니다.

결론부터: FastAPI는 알아서 처리해준다

가장 인상 깊었던 부분은 이거였습니다.

어쨌든 위의 어떤 경우에서도 FastAPI는 여전히 비동기적으로 동작하며 매우 빠릅니다.

def로 짜든 async def로 짜든, 하나의 앱 안에서 두 스타일을 섞어 쓰든 FastAPI가 알아서 올바르게 처리합니다. 개발자가 굳이 이벤트 루프를 직접 관리하거나 콜백 지옥에 빠질 필요가 없다는 뜻입니다. 다만 상황에 맞게 고르면 성능을 더 끌어올릴 수 있죠.

판단 기준은 딱 하나: "await로 호출해야 하는가?"

FastAPI 문서가 제시하는 기준은 놀라울 만큼 단순합니다.

  1. 사용하는 라이브러리가 await로 호출하라고 안내한다면 → 경로 처리 함수를 async def로 선언한다.
@app.get("/")
async def read_results():
    results = await some_library()
    return results
  1. 라이브러리가 await를 지원하지 않는다면(대부분의 DB 드라이버가 아직 그렇습니다) → 그냥 일반 def로 선언한다.
@app.get("/")
def results():
    results = some_library()
    return results
  1. 정말 잘 모르겠으면 → 그냥 def를 쓰면 된다.

이게 전부입니다. 코루틴이 뭔지, 이벤트 루프가 어떻게 도는지 몰라도 이 세 줄만 기억하면 실무에서 막힐 일이 거의 없습니다.

왜 이런 구분이 필요할까: I/O 바운드 작업의 정체

비동기가 의미 있는 이유는 결국 "기다림" 때문입니다. 네트워크로 요청을 받고, DB 쿼리 결과를 기다리고, 외부 API 응답을 기다리는 작업들은 CPU 입장에서 보면 대부분 멍하니 대기하는 시간입니다. 이런 작업을 I/O 바운드 작업이라고 부릅니다.

동기 코드는 이 대기 시간 동안 아무것도 못 하고 멈춰 있습니다. 반면 비동기 코드는 "이 작업은 끝날 때까지 시간이 걸리니, 그동안 다른 요청을 처리하고 있다가 결과가 오면 알려줘"라는 식으로 동작합니다. 웹 API는 수많은 요청이 동시에 들어오고 각 요청마다 크고 작은 대기가 끼어 있는 전형적인 I/O 바운드 워크로드라서, 비동기 처리의 이점을 가장 크게 보는 영역입니다.

문서에서는 이걸 햄버거 가게 비유로 설명합니다. 점원 한 명이 주문만 받고 손님은 번호표를 들고 자리에 가서 대화하다가 번호가 뜨면 찾으러 가는 방식(동시성)이, 점원이 요리까지 끝낼 때까지 카운터 앞에서 멀뚱히 서서 기다리는 방식(동기)보다 훨씬 효율적이라는 거죠. 재미있는 비유지만 핵심을 정확히 찌릅니다.

async / await 문법이 편한 이유

파이썬의 async/await 문법이 좋은 건, 비동기 코드를 마치 순서대로 실행되는 일반 코드처럼 읽고 쓸 수 있게 해준다는 점입니다.

async def get_burgers(number: int):
    # 햄버거를 만들기 위한 비동기 처리
    return burgers

await get_burgers(2)라고 쓰면 "이 작업이 끝날 때까지 기다렸다가 결과를 받아라, 그동안 파이썬은 다른 일을 하러 가도 된다"는 뜻이 됩니다. 예전처럼 콜백을 겹겹이 쌓거나 스레드를 직접 관리하지 않아도, 코드는 위에서 아래로 읽히는 동기 코드와 똑같이 생겼는데 실제로는 논블로킹으로 동작합니다. 이게 콜백 지옥이나 스레드 동기화 문제를 겪어본 사람이라면 특히 크게 와닿는 편의성입니다.

FastAPI 내부에서는 무슨 일이 일어날까

기술적으로 좀 더 들어가면, FastAPI(정확히는 그 기반인 Starlette)는 경로 처리 함수가 어떻게 선언됐는지에 따라 실행 방식을 다르게 가져갑니다.

  • async def로 선언한 함수는 이벤트 루프 위에서 직접 실행됩니다.
  • 일반 def로 선언한 함수는 서버가 블로킹되지 않도록 별도의 외부 스레드풀에서 실행된 뒤 그 결과를 await 합니다.

의존성(Dependency)에도 동일한 규칙이 적용되고, 서로 다른 스타일의 의존성이 얽혀 있어도 FastAPI가 알아서 조율해줍니다. 즉 개발자는 "이 작업이 I/O를 기다리는가, 아닌가"만 판단하면 되고, 실행 위치를 어디로 보낼지는 프레임워크가 대신 결정해줍니다.

동시성과 병렬성을 헷갈리지 않기

문서는 동시성(concurrency)과 병렬성(parallelism)을 구분하는 데도 공을 들입니다. 둘 다 "여러 일이 비슷한 시간에 일어난다"는 점은 같지만,

  • 동시성은 대기 시간이 많은 작업(I/O 바운드)에서 대기하는 동안 다른 일을 처리하는 전략이고,
  • 병렬성은 여러 작업을 실제로 동시에 처리할 자원(CPU 코어 등)을 늘려서 처리하는 전략입니다(CPU 바운드 작업에 유리).

FastAPI는 웹 API처럼 대기가 많은 상황에서는 비동기(동시성)로, 머신러닝 추론처럼 순수 연산이 많은 CPU 바운드 상황에서는 멀티프로세싱(병렬성)으로 대응할 수 있어서 두 마리 토끼를 다 잡을 수 있다는 점도 인상적이었습니다.

정리

  • 판단 기준은 단순하다: 라이브러리가 await를 지원하면 async def, 아니면 그냥 def.
  • 확신이 없으면 def를 써도 FastAPI는 여전히 빠르게 동작한다.
  • defasync def는 한 프로젝트, 심지어 한 라우터 안에서도 섞어 쓸 수 있고 FastAPI가 알아서 처리한다.
  • 내부적으로 def는 스레드풀에서, async def는 이벤트 루프에서 실행되도록 FastAPI가 자동으로 라우팅해준다.
  • 이 모든 게 개발자에게 "복잡한 비동기 프로그래밍 지식"을 강요하지 않으면서도 높은 성능을 뽑아낼 수 있게 해주는, FastAPI가 편하다고 느껴지는 이유다.

결국 FastAPI의 async 처리 방식이 매력적인 이유는 어렵게 설계된 게 아니라, "몰라도 동작하고, 알면 더 잘 동작한다"는 점에 있는 것 같습니다.

참고: FastAPI 공식 문서 - Concurrency and async / await

profile
Asher입니다. 하지만 Joon이라고도 불리는

0개의 댓글