본문 바로가기

IT

[python] 리스트 잘못 쓰면, 오늘은 정상인데 내일은 망가진다.

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을 사용하자.

 

초모중겪시안(초보자는 모르고, 중급자는 겪고, 시니어는 안다) 시리즈를 만들어볼까 한다.

이 글은 초모중겪시안의 첫번째 글이다.