LabHub

FastAPI — 타입이 곧 계약이다 · 계약과 수명 · 이론

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

LabHub 에서 이어서 보기

한 줄 요약

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

왜 계약을 코드에 적는가

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

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

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

그래서 뭐가 다른가

@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 는 이제 옛말이다

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

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

실무에서 물리는 것들

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

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

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