TT Lab
시작하기
배우기 러닝패스 코스

FastAPI — 타입이 곧 계약이다

타입 힌트 하나가 만드는 네 가지

TT Lab 에서 이어서 보기

한 줄 요약

FastAPI 에서 타입 힌트는 주석이 아니라 실행되는 계약이다. 하나를 적으면 검증·직렬화·문서·에디터 자동완성이 한꺼번에 생긴다.

왜 계약을 코드에 적는가

문서로만 있는 API 명세는 반드시 코드와 어긋난다. 필드 하나를 옵션으로 바꾸면서 문서를 같이 고치는 사람은 없기 때문이다.

그래서 프런트엔드는 "이 필드가 null 로 올 수도 있나요" 를 채팅으로 묻게 되고, 서버는 예상 못 한 본문을 받아 500 으로 죽는다. 로그에는 KeyError 한 줄만 남아서 누가 무엇을 잘못 보냈는지도 알 수 없다. 검증을 핸들러 안에 손으로 적기 시작하면 이번에는 그 코드가 엔드포인트마다 조금씩 달라진다.

타입 힌트로 계약을 코드 안에 적어 두면 문서·검증·에러 응답·클라이언트 타입이 전부 한 곳에서 나온다. 어긋날 자리 자체가 없어진다.

문서에만 적힌 계약과 코드 안에 적은 계약

본문은 문서로만 있는 API 명세가 반드시 코드와 어긋난다고 말한다. 필드 하나를 옵션으로 바꾸면서 문서를 같이 고치는 사람은 없기 때문이다. 어긋남이 어디서 사고가 되는지를 두 방식으로 견준다.

  • 문서로만 있는 명세프런트엔드는 이 필드가 null 로 올 수도 있나요를 채팅으로 묻게 된다. 서버는 예상 못 한 본문을 받아 500 으로 죽고, 로그에는 KeyError 한 줄만 남아서 누가 무엇을 잘못 보냈는지 알 수 없다. 검증을 핸들러에 손으로 적으면 엔드포인트마다 조금씩 달라진다.
  • 타입 힌트로 코드 안에 적은 계약핸들러의 타입 힌트 하나가 요청 검증, 응답 직렬화, openapi.json 과 docs 문서, 에디터와 mypy 의 타입 검사를 한꺼번에 만든다. 요청 본문이 선언한 모양이 아니면 핸들러에 들어오기 전에 422 로 거절한다.

여기서 구분할 것 422 는 400 과 다르다. 본문의 구분은 400 이 내가 정한 규칙을 어겼을 때(잔액 부족, 중복 이메일)이고 422 는 모양이 틀렸을 때이다. 스키마 위반의 422 는 자동으로 나가고 규칙 위반의 400 은 직접 정한다.

잠깐, 예측해 보세요 422 응답 본문의 loc 가 body 와 qty 로 되어 있다. 프런트엔드는 이 값으로 무엇을 할 수 있을까?

설명 확인 · 채점 없는 자가 점검

본문은 loc 를 클라이언트에 그대로 넘기면 폼 필드별 오류 표시가 공짜로 된다고 한다. 응답 본문에 어느 필드가 왜 틀렸는지가 들어 있기 때문이다.

근거 문서

그래서 뭐가 다른가

@app.post("/items")
def create(item: Item) -> ItemOut: ...

이 한 줄이 하는 일.

  1. 요청 검증 — 본문이 Item 모양이 아니면 핸들러에 들어오기 전에 422 로 거절한다
  2. 직렬화 — 반환값을 ItemOut 으로 걸러서 JSON 으로 만든다
  3. 문서 — /openapi.json 과 /docs 가 저절로 생긴다
  4. 타입 검사 — mypy 와 에디터가 실제로 검사한다

Flask 였다면 request.json 을 꺼내 if "name" not in body: 를 손으로 쓰고, 그걸 문서에도 따로 적고, 둘이 어긋나기 시작한다.

422 는 400 과 다르다

FastAPI 는 스키마 위반에 자동으로 422 를 준다. 본문에 어느 필드가 왜 틀렸는지가 들어 있다.

{"detail":[{"type":"int_parsing","loc":["body","qty"],
            "msg":"Input should be a valid integer","input":"many"}]}

loc 을 클라이언트에 그대로 넘기면 폼 필드별 오류 표시가 공짜로 된다.

response_model 은 '빼는' 장치다

가장 실무적인 기능이면서 가장 자주 놓친다.

class User(BaseModel):
    email: str
    hashed_password: str      # DB 모델에는 있다

class UserOut(BaseModel):
    email: str                # 나가는 쪽에는 없다

@app.get("/me", response_model=UserOut)
def me() -> User: ...         # User 를 돌려줘도 UserOut 으로 걸러진다

핸들러가 실수로 전체 객체를 반환해도 응답에는 hashed_password 가 없다. 이게 없으면 언젠가 누군가 return user 를 쓰고, 해시가 API 로 나간다. 실제로 자주 일어나는 사고다.

규칙 하나만 지켜도 절반은 막는다 — 입력 모델과 출력 모델을 절대 같은 클래스로 쓰지 않는다.

async def 와 def — 여기서 서버가 멈춘다

FastAPI 는 두 가지를 다 받는다. 그런데 동작이 완전히 다르다.

선언 어디서 실행되나 안에서 블로킹하면
async def 이벤트 루프 위에서 직접 서버 전체가 멈춘다
def 스레드풀로 보내진다 그 스레드만 멈춘다
@app.get("/slow")
async def slow():
    time.sleep(1)        # ❌ 이 1초 동안 모든 요청이 대기한다

async def 안에서는 await 하지 않는 블로킹 호출을 하면 안 된다. time.sleep, requests.get, 동기 DB 드라이버, 무거운 CPU 연산 전부 해당한다.

고치는 방법은 둘이다.

초보자가 "빠르라고" async 를 붙였다가 오히려 서버를 직렬화시키는 것이 이 프레임워크의 1번 함정이다. 확신이 없으면 def 로 쓰는 편이 안전하다.

의존성 주입

Depends 는 "이 핸들러에는 이게 필요하다" 를 선언하는 장치다.

def get_db():
    con = connect()
    try:
        yield con          # 핸들러가 쓰는 동안
    finally:
        con.close()        # 응답을 보낸 뒤 정리된다

@app.get("/items")
def items(db = Depends(get_db)): ...

yield 를 쓰면 정리 코드가 응답 후에 반드시 실행된다. 그리고 진짜 값어치는 테스트에서 나온다.

app.dependency_overrides[get_db] = lambda: FakeDB()

프로덕션 코드를 한 줄도 안 고치고 갈아 끼운다. 몽키패치가 필요 없다.

lifespan — on_event 는 이제 옛말이다

@asynccontextmanager
async def lifespan(app):
    app.state.pool = await make_pool()   # 시작할 때
    yield
    await app.state.pool.close()         # 끝날 때

app = FastAPI(lifespan=lifespan)

@app.on_event("startup") 은 폐기 예정이다. 새 코드에는 lifespan 을 쓴다. 커넥션 풀·캐시 클라이언트를 여는 자리다.

async def 안의 블로킹과 def 의 스레드풀

두 선언 모두 FastAPI 가 받아 주지만 실행되는 곳이 다르다. 안에서 블로킹 호출을 했을 때 멈추는 범위를 본문의 표대로 견준다.

  • async def: 이벤트 루프 위에서 직접 실행await 하지 않는 블로킹 호출을 하면 서버 전체가 멈춘다. time.sleep, requests.get, 동기 DB 드라이버, 무거운 CPU 연산이 모두 해당한다. 빠르라고 async 를 붙였다가 서버를 직렬화시키는 것이 본문이 말하는 이 프레임워크의 첫째 함정이다.
  • def: 스레드풀로 보내져 실행블로킹하면 그 스레드만 멈춘다. 그런데 스레드풀의 크기는 유한하다. 본문은 기본 40 이라고 쓰고, 전부 블로킹 중이면 그다음 요청은 큐에서 기다린다고 한다. def 는 만능이 아니라 완충일 뿐이다.

여기서 구분할 것 async def 를 고치는 길은 둘이다. 그냥 def 로 선언하거나 비동기 라이브러리(httpx.AsyncClient, asyncpg)를 쓴다. 기본 40 은 레슨 본문이 적은 값이고 이 도식의 수치는 설명용이다.

잠깐, 예측해 보세요 1초 걸리는 블로킹 호출이 든 핸들러를 def 로 바꾸었다. 서버가 통째로 멈추는 일은 없어졌다. 그런데 이 요청이 한꺼번에 수백 개 들어오면 어떻게 될까?

설명 확인 · 채점 없는 자가 점검

스레드풀이 유한해서 전부 블로킹 중이면 그다음 요청은 큐에서 기다린다. 본문의 표현으로 def 는 만능이 아니라 완충일 뿐이다. 한 요청의 블로킹이 서버 전체를 세우는 것은 막아 주지만 동시에 받아 낼 수 있는 수에는 끝이 있다.

근거 문서

실무에서 물리는 것들

BackgroundTasks 는 큐가 아니다. 응답을 보낸 뒤 같은 프로세스에서 실행된다. 워커가 재시작되면 그 작업은 사라진다. 메일 발송처럼 잃어도 되는 것에만 쓰고, 잃으면 안 되는 것은 Redis/Kafka 로 보낸다.

워커 수. uvicorn --workers N 은 프로세스를 N 개 띄운다. 각각 메모리를 따로 쓰고 전역 변수를 공유하지 않는다. 인메모리 캐시를 전역에 두면 워커마다 다른 값을 갖는다.

동기 엔드포인트의 스레드풀 크기는 유한하다(기본 40). 전부 블로킹 중이면 그다음 요청은 큐에서 기다린다. def 는 만능이 아니라 완충일 뿐이다.