PYTHON / FIELD NOTES

Python 함수를 다시 불렀는데 왜 지난번 값이 남아 있을까

Python 함수의 기본값은 호출할 때가 아니라 def가 실행될 때 한 번 만들어집니다. tags=[]가 호출마다 쌓이고 datetime.now() 기본값이 멈춰 있는 이유를 Python 3.12.14 실행 결과로 보여 주고, None 기본값, dataclass의 default_factory, 일부러 남기는 캐시 중 무엇을 쓸지 정리합니다.

태그를 붙이는 작은 함수 def add_tag(tag, tags=[])를 만들었다고 해 보겠습니다. 첫 호출은 ['python']을 돌려줍니다. 그런데 두 번째로 add_tag('web')을 부르면 ['web']이 아니라 ['python', 'web']이 나옵니다. 아무것도 넘기지 않았는데 지난번 값이 남아 있는 것입니다. 이 글을 읽고 나면 왜 이런 일이 생기는지 설명할 수 있고, 보통의 함수, 호출 시각 같은 값, dataclass 필드, 일부러 만든 캐시마다 어떻게 써야 하는지 고를 수 있습니다.

예제는 2026-10-04에 macOS 27.0.1에서 Python 3.12.14(uv로 설치한 빌드)로 실행했고, 첫 예제는 macOS에 들어 있는 Python 3.9.6에서도 똑같이 실행해 같은 출력을 얻었습니다. 태그 값은 모두 설명을 위해 만든 가짜 데이터입니다.

기본값은 호출할 때가 아니라 def가 실행될 때 만들어진다

Python에서 def는 선언이 아니라 실행되는 문장입니다. 모듈을 불러오면 def 줄이 한 번 실행되어 함수 객체가 만들어지고, 이때 기본값 자리의 식도 함께 계산됩니다. tags=[]라면 빈 리스트 하나가 이 순간에 만들어집니다. Python 언어 레퍼런스의 함수 정의 절은 기본 매개변수 값이 함수 정의가 실행될 때 왼쪽부터 한 번 계산되고, 이후 모든 호출이 미리 계산된 같은 값을 쓴다고 적고 있습니다.

그렇게 만든 리스트는 함수 객체의 __defaults__ 속성에 보관됩니다. tags를 넘기지 않은 호출은 새 리스트를 만드는 대신 이 객체를 매개변수에 연결합니다. 함수 안에서 tags.append(tag)를 하면 지역 변수에 값을 넣는 것처럼 보이지만, 실제로는 함수에 붙어 있는 그 하나뿐인 리스트를 바꾸는 것입니다.

세 단계. 1 def add_tag(tag, tags=[])가 모듈을 불러올 때 한 번 실행되며 기본값 []를 계산한다. 2 그 리스트 객체 하나를 함수가 __defaults__에 보관한다. 3 tags를 생략한 호출은 모두 그 객체를 쓰므로 append가 기본값 자체를 바꾸고, 두 호출의 결과 a is b는 True다.
기본값 리스트는 def가 실행될 때 하나만 만들어져 함수에 붙어 있고, 인자를 생략한 호출은 모두 그 객체를 함께 씁니다.

실제로 네 번 불러 보면

인자를 생략한 호출과 리스트를 직접 넘긴 호출을 섞어 네 번 불렀습니다. 첫 호출은 ['python'], 두 번째는 ['python', 'web']이었습니다. 세 번째로 add_tag('ai', ['own'])처럼 리스트를 넘기면 넘긴 리스트만 ['own', 'ai']가 되고 기본값은 건드리지 않습니다. 네 번째 호출에서 다시 인자를 생략하자 ['python', 'web', 'git']이 나왔습니다. 호출이 끝난 뒤 add_tag.__defaults__를 찍어 보니 처음의 ([],)가 (['python', 'web', 'git'],)로 바뀌어 있었습니다.

네 번의 호출. 호출 1 add_tag('python')은 ['python']. 호출 2 add_tag('web')은 지난번 값이 남아 ['python', 'web']. 호출 3 add_tag('ai', ['own'])은 넘긴 리스트만 바뀌어 ['own', 'ai']. 호출 4 add_tag('git')은 다시 기본값에 쌓여 ['python', 'web', 'git']. 끝난 뒤 __defaults__는 (['python', 'web', 'git'],).
인자를 생략한 호출만 같은 리스트에 쌓였고, 리스트를 직접 넘긴 호출 3은 기본값에 영향을 주지 않았습니다.

같은 객체라는 것도 확인할 수 있습니다. 인자를 생략하고 두 번 부른 결과를 a, b에 담아 a is b를 보면 True이고, a is add_tag.__defaults__[0]도 True입니다. 그래서 첫 번째 결과를 받아 둔 변수 a도 두 번째 호출 뒤에는 ['x', 'y']로 바뀌어 있습니다. 결과를 받아 다른 곳에 저장해 두었다면 그 값까지 나중 호출에 따라 바뀐다는 뜻입니다.

리스트만의 문제도 아닙니다. 로그 함수에 at=datetime.now().strftime("%H:%M:%S")를 기본값으로 두고 2초 간격으로 두 번 불렀더니 두 줄 모두 같은 시각 22:04:54가 찍혔습니다. 시각은 바뀌지 않는 문자열이라 누적되지는 않지만, 모듈을 불러온 순간의 값으로 고정되는 원리는 같습니다. 기본값 자리에 함수 호출을 쓰면 그 호출은 정의할 때 한 번만 일어납니다.

Python 3.12.14 실행 결과. defaults.py list: 처음 기본값 ([],), 호출 1 ['python'], 호출 2 ['python', 'web'], 호출 3 ['own', 'ai'], 호출 4 ['python', 'web', 'git'], 끝난 뒤 기본값 (['python', 'web', 'git'],). ids: a is b True, a is __defaults__[0] True, a는 ['x', 'y']. time: 22:04:54 first, 2초 뒤에도 22:04:54 two seconds later.
기본값을 그대로 쓴 실제 출력입니다. 리스트는 호출마다 쌓였고, datetime.now() 기본값은 2초 뒤에도 같은 시각을 돌려줬습니다.

보통은 None을 기본값으로 둔다

호출할 때마다 새 리스트가 필요하다면 기본값을 None으로 두고 함수 본문에서 만듭니다. Python 튜토리얼과 프로그래밍 FAQ가 모두 이 방식을 권합니다.

def add_tag(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags

이렇게 고친 함수는 첫 호출에 ['python'], 두 번째에 ['web']을 돌려줬고, __defaults__는 끝까지 (None,)이었습니다. 본문의 tags = []는 호출될 때마다 실행되므로 매번 새 리스트가 생깁니다. 호출 시각도 같은 방식으로 at=None을 받고 본문에서 datetime.now()를 부르면 됩니다.

if not tags:보다 if tags is None:이 정확합니다. not tags는 호출하는 쪽이 일부러 넘긴 빈 리스트도 새 리스트로 바꿔 버립니다. 빈 리스트 mine을 넘겨 확인해 보니 if not tags: 버전을 부른 뒤 mine은 여전히 []였고, is None 버전에서는 ['python']이 들어 있었습니다.

한 가지는 남습니다. None 패턴은 기본값 공유를 막을 뿐, 호출하는 쪽이 넘긴 리스트는 그대로 바꿉니다. 실행해 보니 mine = ['own']을 넘기고 난 뒤 mine도 ['own', 'ai']가 되어 있었고 돌려받은 값과 같은 객체였습니다. 원본을 건드리면 안 되는 함수라면 본문에서 tags = list(tags)처럼 복사한 뒤 추가합니다.

dataclass와 일부러 남기는 캐시

dataclass 필드에서는 같은 실수를 Python이 미리 막습니다. tags: list = []로 필드를 선언하자 클래스를 정의하는 순간 ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory가 났습니다. dataclasses 문서에 따르면 3.11부터는 list, dict, set이라는 타입 대신 해시할 수 없는 값을 기본값으로 쓰지 못하게 하는데, 해시 불가능을 변경 가능의 근사치로 쓰는 부분적인 보호라고 스스로 밝힙니다. 대신 field(default_factory=list)를 쓰면 인스턴스마다 새 리스트가 만들어지고, 실행 결과도 p.tags는 ['python'], q.tags는 []로 서로 다른 객체였습니다.

Python 3.12.14 실행 결과. none: 호출 1 ['python'], 호출 2 ['web'], 기본값 (None,). copy: 돌려받은 값 ['own', 'ai'], 호출한 쪽 리스트도 ['own', 'ai'], 같은 객체 True. dataclass: ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory. default_factory 사용 시 p.tags ['python'], q.tags [], 같은 객체 False.
고친 코드의 실제 출력입니다. None 기본값과 default_factory는 호출·인스턴스마다 새 리스트를 만들었지만, 넘겨받은 리스트는 여전히 바뀌었습니다.

반대로 이 동작을 일부러 쓰는 경우도 있습니다. 프로그래밍 FAQ는 계산이 오래 걸리는 함수의 결과를 기억해 두는 메모이제이션 예로 def expensive(arg1, arg2, *, _cache={})를 보여 줍니다. 키워드 전용 매개변수와 밑줄로 시작하는 이름이 "호출하는 쪽이 건드리는 값이 아니다"라는 표시 역할을 합니다. 같은 목적이라면 표준 라이브러리의 functools.lru_cache가 의도를 더 분명히 드러냅니다.

네 칸. 보통의 함수는 기본값을 None으로 두고 본문에서 if tags is None: tags = []로 새 리스트를 만든다. 호출 시각처럼 매번 달라야 하는 값도 기본값에 datetime.now()를 두면 고정되므로 at=None으로 받고 본문에서 계산한다. dataclass 필드에 list 기본값을 두면 ValueError가 나므로 field(default_factory=list)를 쓴다. 일부러 남기는 캐시는 공식 FAQ의 키워드 전용 _cache나 functools.lru_cache를 쓴다. None 패턴도 호출자가 넘긴 리스트는 바꾸므로 원본을 지키려면 list(tags)로 복사한다.
대부분은 None 기본값으로 충분하고, dataclass는 default_factory, 의도한 캐시는 이름으로 드러나게 둡니다.

코드 리뷰나 린터로 미리 잡을 수도 있습니다. Ruff는 이 패턴을 B006(mutable-argument-default) 규칙으로 표시하고, None으로 바꾸는 자동 수정을 제공하되 원래 의도가 캐시였을 수 있어 "unsafe"로 분류합니다. 이 내용은 Ruff 문서에서 확인한 것이고, 이번 실행 환경에는 Ruff와 Pylint를 설치하지 않아 직접 돌려 보지는 않았습니다.

함수 시그니처에서 =[], ={}, =set(), =datetime.now()처럼 기본값 자리에 새로 만들어지는 값이나 호출이 보이면, 그 값이 정의할 때 한 번만 만들어진다는 점을 떠올리고 None으로 바꿀지, 캐시라면 이름으로 드러낼지 정하면 됩니다.

END OF NOTE목록으로
COMMENTS BOX

이 기록에 대화를 더해 주세요.

궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.

최신순

로그인 상태 확인 중…

댓글을 불러오는 중…