추가 모델
추가 모델 (Extra Models)
하나의 "엔티티(entity)"가 여러 가지 상태(state)를 가질 때가 있어요. 예를 들어 사용자를 저장할 땐 비밀번호가 필요하지만, 응답으로 내보낼 땐 비밀번호가 나가면 안 되죠. 그럴 때는 엔티티마다 모델을 하나만 두지 말고, 상황에 맞는 모델을 여러 개 만들어 쓰는 게 깔끔해요. Pydantic 모델은 자유롭게 여러 개 선언하고 상속할 수 있어요.
출처: 공식문서
여러 개의 모델 (Multiple models)
아래 예시를 볼게요. 입력받는 사용자(UserIn), 응답으로 내보내는 사용자(UserOut), 그리고 데이터베이스에 저장하는 사용자(UserInDB)를 각각 별도 모델로 선언했어요. UserOut에는 password 필드가 없고, UserInDB에는 해시된 비밀번호(hashed_password)가 있어요:
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserIn(BaseModel):
username: str
password: str
email: EmailStr
full_name: str | None = None
class UserOut(BaseModel):
username: str
email: EmailStr
full_name: str | None = None
class UserInDB(BaseModel):
username: str
hashed_password: str
email: EmailStr
full_name: str | None = None
def fake_password_hasher(raw_password: str):
return "supersecret" + raw_password
def fake_save_user(user_in: UserIn):
hashed_password = fake_password_hasher(user_in.password)
user_in_db = UserInDB(**user_in.model_dump(), hashed_password=hashed_password)
print("User saved! ..not really")
return user_in_db
@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
user_saved = fake_save_user(user_in)
return user_saved
여기에 몇 가지 짚어 볼 포인트가 있어요.
**user_in.model_dump()에 대하여 (About **user_in.model_dump())
UserInDB(**user_in.model_dump(), hashed_password=hashed_password)에서 일어나는 일을 풀어서 설명하면:
- Pydantic의
.model_dump():user_in모델을dict로 바꿔요. 즉{"username": ..., "password": ..., "email": ...}같은 형태예요. dict풀기 (unpacking):**user_in.model_dump()는 그dict의 키-값 쌍을 키워드 인자로 풀어 넣어요.UserInDB(username=..., password=..., email=..., full_name=...)처럼 되죠.- 다른 모델의 내용으로 새 모델 만들기:
UserIn의 데이터 복사본을UserInDB모델로 만들어요. dict풀기 + 추가 키워드: 거기에hashed_password=hashed_password를 추가로 넘겨서UserInDB의hashed_password필드를 채워요.
이렇게 하면 입력받은 필드에 해시된 비밀번호를 추가한 UserInDB 객체가 만들어져요. 그걸 데이터베이스에 저장하는 식으로 쓰는 거예요.
그리고 response_model=UserOut이기 때문에, FastAPI가 응답에서 UserOut에 없는 필드(여기선 hashed_password)를 제거해서 내보내요. 그래서 비밀번호 해시가 클라이언트에 노출되지 않아요.
중복 줄이기 (Reduce duplication)
이제 코드 중복을 줄여 볼 수 있어요. 공통 필드를 가진 UserBase 모델을 선언하고, 나머지 모델들이 이를 상속하게 만들면 돼요. 상속하면 타입 선언, 검증 등을 그대로 물려받고, 데이터 변환·검증·문서화도 평소처럼 잘 동작해요:
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserBase(BaseModel):
username: str
email: EmailStr
full_name: str | None = None
class UserIn(UserBase):
password: str
class UserOut(UserBase):
pass
class UserInDB(UserBase):
hashed_password: str
def fake_password_hasher(raw_password: str):
return "supersecret" + raw_password
def fake_save_user(user_in: UserIn):
hashed_password = fake_password_hasher(user_in.password)
user_in_db = UserInDB(**user_in.model_dump(), hashed_password=hashed_password)
print("User saved! ..not really")
return user_in_db
@app.post("/user/", response_model=UserOut)
async def create_user(user_in: UserIn):
user_saved = fake_save_user(user_in)
return user_saved
username, email, full_name은 UserBase에 한 번만 두고, 각 모델은 자기만의 필드(password, hashed_password)만 추가해요. 같은 로직(fake_password_hasher, fake_save_user, create_user)은 그대로 재사용돼요.
Union 또는 anyOf (Union or anyOf)
응답을 두 개 이상 타입 중 하나로 선언할 수도 있어요. 그 타입 중 어떤 것이라도 될 수 있다는 뜻이에요. OpenAPI에서는 anyOf로 정의돼요.
이러려면 표준 Python 타입 힌트 typing.Union을 쓰면 돼요:
참고:
Union을 정의할 때는 더 구체적인 타입을 먼저, 덜 구체적인 타입을 뒤에 두세요. 아래 예시에서Union[PlaneItem, CarItem]의PlaneItem이CarItem보다 앞에 와요.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class BaseItem(BaseModel):
description: str
type: str
class CarItem(BaseItem):
type: str = "car"
class PlaneItem(BaseItem):
type: str = "plane"
size: int
items = {
"item1": {"description": "All my friends drive a low rider", "type": "car"},
"item2": {
"description": "Music is my aeroplane, it's my aeroplane",
"type": "plane",
"size": 5,
},
}
@app.get("/items/{item_id}", response_model=PlaneItem | CarItem)
async def read_item(item_id: str):
return items[item_id]
response_model이 PlaneItem | CarItem이므로, FastAPI는 응답이 둘 중 하나의 형태여야 한다는 걸 알고 문서에 anyOf로 표시해요. item1은 CarItem처럼, item2는 PlaneItem처럼 해석돼요. PlaneItem에는 size 필드가 있으니, 응답 데이터에 따라 문서가 바뀌는 게 아니고 "둘 중 하나"를 나타내는 거예요.
Python 3.10에서의 Union
참고로 Python 3.10의 | 문법(PlaneItem | CarItem)은 타입 주석에서는 잘 동작해요. 만약 이걸 response_model=PlaneItem | CarItem처럼 값으로 대입하면, Python이 그걸 타입 주석으로 해석하는 대신 PlaneItem과 CarItem 사이에서 유효하지 않은 연산을 수행하려다가 오류를 내요. 우리 예시처럼 타입 주석 자리(함수 시그니처)에서 쓰는 건 문제없어요.
모델의 리스트 (List of models)
객체들의 리스트를 응답으로 선언하는 것도 같은 방식이에요. 표준 Python list를 쓰면 돼요:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
description: str
items = [
{"name": "Foo", "description": "There comes my hero"},
{"name": "Red", "description": "It's my aeroplane"},
]
@app.get("/items/", response_model=list[Item])
async def read_items():
return items
response_model=list[Item]은 응답이 Item 객체들의 리스트라는 뜻이에요. 각 원소가 Item으로 검증·문서화돼요.
임의의 dict 응답 (Response with arbitrary dict)
때로는 Pydantic 모델 없이 그냥 dict로 응답을 선언하고, 키와 값의 타입만 지정하고 싶을 때가 있어요. 유효한 필드/속성 이름을 미리 알 수 없을 때 유용해요(Pydantic 모델은 필드 이름을 알아야 하거든요).
이 경우 dict를 쓰면 돼요:
from fastapi import FastAPI
app = FastAPI()
@app.get("/keyword-weights/", response_model=dict[str, float])
async def read_keyword_weights():
return {"foo": 2.3, "bar": 3.4}
dict[str, float]는 문자열 키와 실수 값으로 이루어진 dict를 의미해요. 응답은 임의의 키를 가질 수 있지만 값은 float로 검증돼요.
요약 (Recap)
각 경우에 맞춰 Pydantic 모델을 여러 개 선언하고 자유롭게 상속 쓰면 돼요. 엔티티가 다양한 "상태"를 가질 수 있다면, 엔티티마다 모델을 하나만 두지 않아도 돼요. user "엔티티"가 좋은 예시인데, password, password_hash, 혹은 비밀번호가 없는 상태를 각각 가질 수 있죠.