가변 기본 인자의 함정
def f(x, lst=[])가 호출마다 같은 리스트를 공유해 버그가 되는 이유를 함수 정의 시점의 동작으로 이해하고, None 센티넬 패턴으로 안전하게 푸는 법을 정리합니다.
지난 글에서 문자열과 바이트의 함정을 봤다면, 이번에는 파이썬 입문자뿐 아니라 경험자도 종종 당하는 가장 유명한 함정 하나를 다룬다. 바로 함수의 가변 기본 인자(mutable default argument)다. 리스트나 딕셔너리를 기본값으로 둔 함수가 왜 “이전 호출의 결과를 기억하는” 이상한 동작을 하는지, 원인과 해결을 살펴본다.
문제의 코드
다음 함수는 “아이템을 받아 리스트에 담아 돌려준다”는 평범해 보이는 코드다.
def add(item, bag=[]):
bag.append(item)
return bag
print(add("a")) # ['a'] — 기대대로
print(add("b")) # ['a', 'b'] — ?!
print(add("c")) # ['a', 'b', 'c'] — ?!
add("b")를 호출하면 ['b']가 나오리라 기대하지만, 실제로는 이전 호출의 'a'가 그대로 남아 있다. 호출할 때마다 리스트가 비워지지 않고 계속 쌓인다.
왜 이런 일이 — 기본값은 “정의 시점”에 단 한 번 만들어진다
원인은 파이썬의 동작 방식에 있다. 함수의 기본값은 함수가 호출될 때마다가 아니라, 함수가 정의될 때 단 한 번 평가되어 함수 객체에 저장된다. 즉 bag=[]의 그 빈 리스트는 def 문이 실행되는 순간 딱 하나 만들어지고, 이후 모든 호출이 같은 리스트 객체를 공유한다.
리스트는 가변(mutable) 객체이므로 append로 내용을 바꾸면 그 변화가 그대로 남는다. 다음 호출은 비어 있는 새 리스트가 아니라, 이미 내용이 채워진 그 리스트를 다시 쓰는 것이다. 함수 객체의 __defaults__ 속성을 보면 이 공유되는 객체를 직접 확인할 수 있다.
print(add.__defaults__) # (['a', 'b', 'c'],) — 기본값이 누적되어 있다
왜 정수나 문자열은 괜찮은가
def f(x=0)이나 def f(s="")은 같은 함정에 빠지지 않는다. 정수와 문자열은 불변(immutable) 객체라서, 값을 “바꾸는” 연산이 사실은 새 객체를 만들어 이름에 다시 묶을 뿐 원래 기본값 객체를 건드리지 않기 때문이다. 함정은 리스트, 딕셔너리, 세트처럼 제자리에서 수정 가능한 가변 객체를 기본값으로 둘 때만 생긴다.
해결: None 센티넬 패턴
표준 해법은 기본값을 가변 객체가 아니라 None으로 두고, 함수 안에서 None인지 확인해 그때마다 새 객체를 만드는 것이다.
def add(item, bag=None):
if bag is None:
bag = [] # 호출될 때마다 새 리스트 생성
bag.append(item)
return bag
print(add("a")) # ['a']
print(add("b")) # ['b'] — 기대대로!
이제 기본값(None)은 불변이라 공유되어도 문제가 없고, 실제 리스트는 bag is None이 참일 때마다 함수 본문 안에서 새로 만들어진다. if bag is None을 쓰는 이유도 분명하다. 빈 리스트나 0도 거짓으로 평가되므로 if not bag으로 검사하면, 사용자가 일부러 빈 리스트를 넘긴 경우를 구분하지 못한다. 정확히 “인자를 안 넘겼다”는 신호인 None만 is로 걸러야 한다.
의도적으로 활용하는 경우
이 동작이 항상 버그인 것은 아니다. 함수 정의 시점에 한 번만 평가된다는 성질을 이용해, 간단한 캐시나 호출 횟수 카운터를 만드는 데 쓰이기도 한다.
def fib(n, _cache={0: 0, 1: 1}): # 의도적으로 공유 — 메모이제이션
if n not in _cache:
_cache[n] = fib(n - 1) + fib(n - 2)
return _cache[n]
다만 이런 용도라면 functools.lru_cache 같은 명시적 도구를 쓰는 편이 의도가 분명해 더 낫다. 가변 기본 인자를 일부러 쓸 때는 “이게 의도된 공유다”라는 점이 읽는 사람에게 분명히 드러나야 한다.
정리
가변 기본 인자의 함정은 “기본값은 호출 시점이 아니라 정의 시점에 한 번 평가된다”는 한 가지 사실에서 비롯된다. 리스트·딕셔너리·세트를 기본값으로 두지 말고, None을 기본값으로 한 뒤 함수 안에서 새로 만드는 센티넬 패턴을 습관처럼 쓰자. 린터(pylint, ruff)도 이 패턴을 경고로 잡아 주니, 경고를 켜 두는 것도 좋은 방어책이다. 다음 글에서는 비슷하게 “평가 시점” 때문에 헷갈리는 또 다른 함정 — 클로저의 늦은 바인딩을 다룬다.
지난 글: 인코딩·디코딩 에러 다루기
다음 글: 클로저의 늦은 바인딩 함정
읽어주셔서 감사합니다. 😊