FastAPI — 타입이 곧 계약이다 · 계약과 수명 · 이론
타입 힌트 하나가 만드는 네 가지
한 줄 요약
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 과 다르다
- 400 Bad Request — 내가 정한 규칙을 어겼다 (잔액 부족, 중복 이메일)
- 422 Unprocessable Entity — 모양이 틀렸다 (문자열이 와야 하는데 숫자)
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 연산 전부 해당한다.
고치는 방법은 둘이다.
- 그냥
def로 선언한다 → FastAPI 가 알아서 스레드풀로 보낸다 - 비동기 라이브러리를 쓴다 →
httpx.AsyncClient,asyncpg
초보자가 "빠르라고" 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 는 만능이 아니라 완충일 뿐이다.