추가 모델

추가 모델 (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를 추가로 넘겨서 UserInDBhashed_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_nameUserBase에 한 번만 두고, 각 모델은 자기만의 필드(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]PlaneItemCarItem보다 앞에 와요.

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_modelPlaneItem | CarItem이므로, FastAPI는 응답이 둘 중 하나의 형태여야 한다는 걸 알고 문서에 anyOf로 표시해요. item1CarItem처럼, item2PlaneItem처럼 해석돼요. PlaneItem에는 size 필드가 있으니, 응답 데이터에 따라 문서가 바뀌는 게 아니고 "둘 중 하나"를 나타내는 거예요.

Python 3.10에서의 Union

참고로 Python 3.10의 | 문법(PlaneItem | CarItem)은 타입 주석에서는 잘 동작해요. 만약 이걸 response_model=PlaneItem | CarItem처럼 값으로 대입하면, Python이 그걸 타입 주석으로 해석하는 대신 PlaneItemCarItem 사이에서 유효하지 않은 연산을 수행하려다가 오류를 내요. 우리 예시처럼 타입 주석 자리(함수 시그니처)에서 쓰는 건 문제없어요.

모델의 리스트 (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, 혹은 비밀번호가 없는 상태를 각각 가질 수 있죠.

더 알아보기 (Learn more)