클로저의 늦은 바인딩 함정

반복문 안에서 만든 람다들이 모두 같은 마지막 값을 돌려주는 이유를 클로저의 늦은 바인딩으로 이해하고, 기본 인자와 partial로 해결하는 법을 정리합니다.

· 6 min read · PALDYN Team

지난 글에서 “평가 시점” 때문에 생기는 함정을 봤다면, 이번에는 비슷하게 시점을 오해하기 쉬운 또 하나의 고전적인 버그 — 클로저의 늦은 바인딩(late binding)을 다룬다. 반복문 안에서 함수를 여러 개 만들었는데 전부 똑같이 동작한다면, 거의 틀림없이 이 함정에 빠진 것이다.

문제의 코드

반복문을 돌며 람다를 하나씩 만들어 리스트에 담는, 자주 보이는 패턴이다.

funcs = [lambda: i for i in range(3)]

print(funcs[0]())   # 기대: 0 → 실제: 2
print(funcs[1]())   # 기대: 1 → 실제: 2
print(funcs[2]())   # 기대: 2 → 실제: 2

각 람다가 자기 차례의 i(0, 1, 2)를 기억하리라 기대하지만, 셋 다 마지막 값인 2를 돌려준다. 이벤트 핸들러를 반복문으로 등록하거나, 설정값마다 콜백을 만들 때 흔히 터지는 버그다.

모든 람다가 마지막 값을 돌려준다

왜 이런 일이 — 클로저는 “값”이 아니라 “변수”를 캡처한다

핵심은 클로저가 변수의 값을 복사해 가지는 게 아니라, 변수 자체를 참조한다는 점이다. lambda: i는 “지금의 i 값”을 저장하는 게 아니라, “i라는 이름을 나중에 찾아보겠다”는 약속이다. 이름을 실제로 찾아보는(바인딩하는) 시점은 람다를 정의할 때가 아니라 호출할 때다 — 그래서 “늦은” 바인딩이다.

문제는 세 람다가 모두 같은 i를 공유한다는 것이다. 반복문이 끝나면 i는 마지막 값인 2에 머물러 있다. 그 뒤 어떤 람다를 호출하든, 그제야 i를 찾아보니 모두 2다. 람다를 만든 순간의 i가 아니라, 호출하는 순간의 i를 보는 것이다.

이것은 버그가 아니라 파이썬의 일관된 동작이다. 같은 이유로, 클로저가 바깥 변수의 “현재” 값을 항상 반영해 주는 유용한 성질도 여기서 나온다. 다만 반복문과 만나면 의도와 어긋날 뿐이다.

해결: 만드는 순간 값을 묶어 둔다

해결의 원리는 하나다. 람다를 만드는 그 시점에 i의 현재 값을 따로 붙들어 둔다. 가장 흔한 방법은 기본 인자를 쓰는 것이다.

늦은 바인딩 해결법

① 기본 인자로 즉시 캡처

기본 인자는 (지난 글에서 봤듯) 함수가 정의되는 시점에 평가된다. 바로 이 성질을 역으로 활용한다.

funcs = [lambda i=i: i for i in range(3)]

print(funcs[0]())   # 0
print(funcs[1]())   # 1
print(funcs[2]())   # 2

lambda i=i: i에서 오른쪽 i(기본값)는 람다가 만들어지는 순간의 값으로 즉시 평가되어 매개변수 i에 박힌다. 더 이상 바깥 변수를 늦게 찾아보지 않으므로 각자 다른 값을 기억한다.

② functools.partial로 값 고정

partial은 함수에 인자를 미리 채워 새 함수를 만든다. 이때 채워지는 값도 호출 시점에 즉시 평가되므로 같은 효과를 낸다.

from functools import partial

funcs = [partial(lambda x: x, i) for i in range(3)]
print([f() for f in funcs])   # [0, 1, 2]

③ 별도 함수로 감싸기

반복문 본문을 별도 함수로 빼면, 매 호출마다 새로운 지역 스코프(와 새로운 i)가 생겨 자연스럽게 분리된다.

def make(i):
    return lambda: i        # 이 i는 make 호출마다 별개

funcs = [make(i) for i in range(3)]
print([f() for f in funcs])  # [0, 1, 2]

정리

늦은 바인딩 함정은 “클로저는 값이 아니라 변수를 캡처하고, 그 변수를 찾아보는 시점은 호출할 때”라는 사실에서 비롯된다. 반복문 안에서 함수를 만들 때는, 만드는 순간의 값을 기본 인자나 partial, 혹은 감싸는 함수로 붙들어 두면 된다. 지난 글의 가변 기본 인자와 함께 “파이썬이 무엇을 언제 평가하는가”를 이해하면 두 함정 모두 자연스럽게 풀린다. 다음 글에서는 컴퓨터의 숫자 표현에서 비롯되는 부동소수점의 함정을 다룬다.


지난 글: 가변 기본 인자의 함정

다음 글: 부동소수점의 함정


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