환경
- python==3.14
- pydantic==2.13.4
배경
unset attribute
개발을 하다보면 설정 안 됨을 뜻하는 값이 필요할 때가 있다. python에선 이 값을 None으로 표현하곤 한다.
class UpdateImagePayload(BaseModel):
name: str | None = None
tag_id: UUID | None = None이렇게 명시적으로 tag_id가 설정이 안 된 경우(unset) 기본값을 None으로 하고 이를 “설정 안 됨”으로 취급한다.
“설정 안 됨”이 필요한 대표적인 경우는 DB 업데이트다.
async def update_image(db: AsyncSession, image_id: UUID, payload: UpdateImagePayload):
_ = await db.execute(
update(Image)
.where(Image.id == image_id)
.values(payload.model_dump(exclude_unset=True))
)설정이 안 됐다는 건 해당 칼럼을 수정하지 않는다는 뜻이기 때문에 그 값은 빼고 업데이트 해야 한다. 이때 pydantic을 사용하는 경우 exclude_unset=True을 사용한다. 말 그대로 설정 안 된 값은 제외한다는 뜻이다. 만약 유저가 다음과 같은 json을 request body로 보냈다고 하자.
{
"name": "foo"
}이 json으로 생성된 UpdateImagePayload instance는 json에 tag_id가 없음에도 기본값 None으로 그 속성을 가지고 있다. 따라서 exclude_unset=True 없이 model_dump를 하면 다음과 같은 python dict를 반환한다.
{
"name": "foo",
"tag_id": None
}이건 우리가 원하는 결과가 아니다. 왜냐하면 유저는 name만 수정하길 원했기 때문이다. 그러나 현재 python(3.14)에는 “정의됐지만 설정되지 않을 수 있는 속성”은 없다. 따라서 그런 동작은 유저가 직접 구현해야 한다. pydantic은 “설정된 속성 목록”을 기록하는 식으로 그런 동작을 구현했다. 그래서 나온 게 exclude_unset 옵션인 것이다. model_dump를 exclude_unset=True로 실행하면 결과는 다음과 같다.
{
"name": "foo",
}우리가 원하는 대로 tag_id는 빠졌다.
문제
두 가지를 뜻하는 None
만약 None이 유저가 의도한 값이라고 해도 함수 update_image는 잘 작동할 것이다. 만약 유저가 tag를 떼고 싶다면 tag_id를 null로 설정할 것이다.
유저가 보낸 json
{
"tag_id": null
}이 값은 unset이 아니므로 model_dump(exclude_unset=True)에서 제외되지 않을 것이다.
model_dump(exclude_unset=True)
{
"tag_id": None,
}따라서 런타임에서는 의도한대로 동작한다. 문제는 set, unset 모두 같은 값(None)이라서 맥락 없이 이 값만 보면 무엇을 뜻하는지 알 수가 없다는 것이다.
if payload.tag_id == None: # 이 값은 unset일까 아닐까?
...즉, JS에서 null과 undefined로 구분된 것이 python에선 None 하나로 표현되기 때문에 문제가 생기는 것이다.
해결
custom singleton
undefined와 같은 것을 python에서 구현하는 건 어려운 일이 아니다. python은 기본적으로 nominal typing이기 때문에, 새로운 class를 만들고 그 class로 singleton을 만들면 그만이다. 예를 들어 enum으로 구현하면
class MissingType(enum.Enum):
MISSING = "MISSING"
MISSING = MissingType.MISSING이렇게 구현할 수 있고
class UpdateImagePayload(BaseModel):
- name: str | None = None
+ name: str | None | MissingType = MISSING
- tag_id: UUID | None = None
+ tag_id: UUID | None | MissingType = MISSING이렇게 사용할 수 있다. 실제로 msgspec에선 이런 식으로 UNSET이란 값을 구현했다1.
그러나 이렇게 정의한 값은 두 가지 문제가 있다.
- 타입과 값의 이름이 다르다.
None가 타입이자 값인 것과는 달리 MISSING의 타입은 MissingType이고 값은 MISSING이다.
- 여러 방식으로 구현할 수 있다.
enum을 사용하지 않고 그냥 빈 class를 정의해서 사용해도 된다.
sentinel values
이런 문제를 해결하기 위해서 python 3.15에 sentinel이 추가됐다2. sentinel을 사용하면 MISSING을 이렇게 정의할 수 있다.
MISSING = sentinel('MISSING')enum을 쓰는 것보다 훨씬 간결하다. 이 값을 이렇게 사용할 수 있다.
class UpdateImagePayload(BaseModel):
- name: str | None = None
+ name: str | None | MISSING = MISSING
- tag_id: UUID | None = None
+ tag_id: UUID | None | MISSING = MISSINGenum으로 정의한 MISSING과 달리, sentinel value는 이름을 그대로 타입으로 사용할 수 있다.
실제로 pydantic은 이 sentinel을 이용해 실험적으로 MISSING sentinel을 구현했다34.
from pydantic.experimental.missing_sentinel import MISSING
class UpdateImagePayload(BaseModel):
name: str | None | MISSING = MISSING
tag_id: UUID | None | MISSING = MISSING이 MISSING 값은 pydantic이 이해하는 값이라서 exclude_unset=True 옵션을 사용하지 않아도 속성값이 MISSING이면 직렬화 과정에서 제외된다.
참고
https://github.com/pydantic/pydantic/issues/5326