Intro
python에서 mutable 개념이 있다. 보통 reference 기반 언어에서 많이 사용된다.
python의 리스트는 mutable이며, 이는 곳 shallow copy를 한다는 뜻이다.
오늘은 mutable이 무엇인지도 알고, shallow copy도 무엇인지 개념은 알지만, 초보자가 쉽게 실수하는 부분에 대해서 이야기 한다.
Content
shallow copy를 잘 알고 있다면, 다음과 같은 상황에서 어떤 값이 출력 될지 잘 알 것이다.
class Activity:
tags = []
if __name__ == "__main__":
a1 = Activity()
a2 = Activity()
a1.tags.append("from a1")
print("a1.tags:", a1.tags)
print("a2.tags:", a2.tags)
print("Activity.tags:", Activity.tags)
print(a1.tags is a2.tags is Activity.tags)
# 결과
a1.tags: ['from a1']
a2.tags: ['from a1']
Activity.tags: ['from a1']
True
당연한 일이다. python의 []는 mutable이므로, a1.tags와 a2.tags의 주소가 같아서, a1에 append 했지만, a2에서도 값이 존재하는 것을 나온다.
위와 같은 실수를 안한다고 생각 할 수 있다.
그런데, pydantic과 dataclass를 사용할 때를 생각해보자. 무심코 [] 와 같이 초기화 하지 않았는가?
그러나, 다행이도, dataclass는 이와 같은 상황을 막아준다.
from dataclasses import dataclass
@dataclass
class UserActivityResponse:
user_id: int
activities: list[str] = []
def get_user_activity(user_id: int) -> UserActivityResponse:
response = UserActivityResponse(user_id=user_id)
if user_id == 1:
response.activities.append("login")
elif user_id == 2:
response.activities.append("purchase")
return response
if __name__ == "__main__":
r1 = get_user_activity(1)
print("User 1:", r1.activities)
r2 = get_user_activity(2)
print("User 2:", r2.activities)
print(id(r1.activities), id(r2.activities))
ValueError: mutable default <class 'list'> for field activities is not allowed: use default_factory
default로 mutable 을 넣었기 때문에, default_factory를 사용하라고 한다.
default_factory를 이용해서, 인터프리터가 list()를 return하도록 해서, 새로운 list 인스턴스를 만들도록 한다.
@dataclass
class UserActivityResponse:
user_id: int
activities: list[str] = field(default_factory=list)
자 그런데, pydantic은 좀 이상하다.
아래 코드를 실행하면, 우리의 예상대로라면, mutable로 인한 예상치 못한 동작이 진행되어야 한다.
from pydantic import BaseModel
class UserActivityResponse(BaseModel):
user_id: int
activities: list[str] = []
def get_user_activity(user_id: int) -> UserActivityResponse:
response = UserActivityResponse(user_id=user_id)
if user_id == 1:
response.activities.append("login")
elif user_id == 2:
response.activities.append("purchase")
return response
if __name__ == "__main__":
r1 = get_user_activity(1)
print("User 1:", r1.activities)
r2 = get_user_activity(2)
print("User 2:", r2.activities)
print(id(r1.activities), id(r2.activities))
# 예상
User 1: ['login']
User 2: ['login', 'purchase']
# 실제 결과
User 1: ['login']
User 2: ['purchase']
136320351170304 136320355851584
너무 자연스럽게, 정상적으로 동작한다.
이유는, pydantic이 mutable 초기화로 인해서 런타임에서 비정상 동작을 할 것을 예상하고, deepcopy를 하기 때문이다.
관련 링크: https://github.com/pydantic/pydantic/pull/6122/commits/cf18bf57878718cf5b98083aa6a314bd828bce57
Document the behavior of non-hashable default values by dmontagu · Pull Request #6122 · pydantic/pydantic
please review Selected Reviewer: @adriangb
github.com
하지만, 그렇다고 하더라도, 명시적으로 표시해주는 것이 좋다.
@dataclass 처럼 default_factory를 이용해서 초기화 하는 것이 가능하다.
from pydantic import BaseModel, Field
class UserActivityResponse(BaseModel):
user_id: int
activities: list[str] = Field(default_factory=list)
그러면, [] 대신에 list()로 사용하면 문제 없는 걸까?
아래와 같이 사용하면 문제가 없다고 생각할 수 있다.
그러나, 파이썬은 list()를 사용하면 결국 정의 시점에 한번 만들어진 객체를 의미한다.
즉, 인스턴스마다 새 리스트를 만들지 않고, 클래스 단 하나의 리스트를 공유한다.
class Activity:
tags = list()
그래서, factory pattern을 사용하는 것이고, 많은 라이브러리가 factory pattern으로 구현하였다.
내부적으로는 __init__을 통한 인스턴스 레벨 생성을 한다.
def __init__(self, tags=None):
if tags is None:
tags = list()
self.tags = tags
코멘트
일반 class, dataclass, pydantic 등, default 값이 필요하다고, = [] 로 초기화 하는 순간, 런타임에서 의도치 않은 동작을 하게 되는 것이다. 이는 곧, 유저가 봐서는 안되는 데이터(남의 데이터, 서버 데이터)를 볼 수도 있다는 뜻이다.
초기화가 필요한 순간, mutable 값이라면, factory pattern을 사용하자.
초모중겪시안(초보자는 모르고, 중급자는 겪고, 시니어는 안다) 시리즈를 만들어볼까 한다.
이 글은 초모중겪시안의 첫번째 글이다.
'IT' 카테고리의 다른 글
| 백엔드 설계 참고사항 1 (0) | 2026.01.31 |
|---|---|
| Token 수량 확인 해주는 앱(Claude Code, Codex, Gemini CLI) (0) | 2026.01.18 |
| [개발] AI로 짠 코드, 내가 리뷰하는 건 개발자인가 AI인가 (0) | 2025.12.29 |
| [TimescaleDB] hyper table에 indexing을 걸면? (0) | 2025.12.20 |
| [python] module level __getattr__ (__getattr__ 조금 더 파보기) (0) | 2025.12.20 |