가변 기본 인자의 함정

def f(x, lst=[])가 호출마다 같은 리스트를 공유해 버그가 되는 이유를 함수 정의 시점의 동작으로 이해하고, None 센티넬 패턴으로 안전하게 푸는 법을 정리합니다.

· 6 min read · PALDYN Team

지난 글에서 문자열과 바이트의 함정을 봤다면, 이번에는 파이썬 입문자뿐 아니라 경험자도 종종 당하는 가장 유명한 함정 하나를 다룬다. 바로 함수의 가변 기본 인자(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인지 확인해 그때마다 새 객체를 만드는 것이다.

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으로 검사하면, 사용자가 일부러 빈 리스트를 넘긴 경우를 구분하지 못한다. 정확히 “인자를 안 넘겼다”는 신호인 Noneis로 걸러야 한다.

의도적으로 활용하는 경우

이 동작이 항상 버그인 것은 아니다. 함수 정의 시점에 한 번만 평가된다는 성질을 이용해, 간단한 캐시나 호출 횟수 카운터를 만드는 데 쓰이기도 한다.

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)도 이 패턴을 경고로 잡아 주니, 경고를 켜 두는 것도 좋은 방어책이다. 다음 글에서는 비슷하게 “평가 시점” 때문에 헷갈리는 또 다른 함정 — 클로저의 늦은 바인딩을 다룬다.


지난 글: 인코딩·디코딩 에러 다루기

다음 글: 클로저의 늦은 바인딩 함정


읽어주셔서 감사합니다. 😊