타입 힌트 하나가 만드는 네 가지
한 줄 요약
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: ...
이 한 줄이 하는 일.
- 요청 검증 — 본문이
Item모양이 아니면 핸들러에 들어오기 전에 422 로 거절한다 - 직렬화 — 반환값을
ItemOut으로 걸러서 JSON 으로 만든다 - 문서 —
/openapi.json과/docs가 저절로 생긴다 - 타입 검사 — 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 는 이제 옛말이다
@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 을 쓴다. 커넥션 풀·캐시 클라이언트를 여는 자리다.
두 선언 모두 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 는 만능이 아니라 완충일 뿐이다.