파이썬 일급 함수와 클로저: 함수가 상태를 기억하는 원리

반응형

Python에서 함수는 변수에 담고, 다른 함수에 인자로 넘기고, 함수에서 반환할 수 있는 일급 객체다. 클로저(closure)는 이 성질을 이용해 바깥 scope의 변수를 기억하는 함수다.

두 개념은 연결되지만 같은 말은 아니다. 함수를 값처럼 다룬다는 것이 일급 함수의 성질이고, 바깥 함수 호출이 끝난 뒤에도 필요한 binding을 안쪽 함수가 유지하는 것이 클로저다.

함수가 일급 객체라는 뜻

다음 코드는 함수를 변수에 담고 다른 함수에 전달한다.

def add(left, right):
    return left + right


def calculate(operation, left, right):
    return operation(left, right)


sum_operation = add

assert sum_operation(2, 3) == 5
assert calculate(add, 10, 20) == 30

add 뒤에 괄호가 없으면 함수를 호출한 결과가 아니라 function object 자체를 가리킨다. calculate()는 구체적인 덧셈에 고정되지 않고 전달받은 callable을 실행한다.

함수를 list나 dictionary에 저장하거나 다른 함수의 반환값으로 돌려주는 것도 같은 성질에서 나온다. callback, decorator와 전략 선택 code가 가능한 기반이다.

클로저는 바깥 binding을 기억한다

호출할 때마다 상태가 바뀌는 counter를 함수로 만들어 보자.

def make_counter(start=0):
    count = start

    def increment(step=1):
        nonlocal count
        count += step
        return count

    return increment


counter = make_counter(10)

assert counter() == 11
assert counter(3) == 14
assert counter.__code__.co_freevars == ("count",)
assert counter.__closure__[0].cell_contents == 14

make_counter() 호출은 이미 끝났지만 반환된 increment()count binding을 계속 참조한다. 이때 count는 안쪽 함수 입장에서 closure variable, 즉 free variable이다. Python function object의 __closure__에는 이 값을 연결하는 cell이 들어 있다.

함수가 값을 복사해 얼려 둔다고만 이해하면 부족하다. 여러 안쪽 함수가 같은 cell을 공유할 수도 있고, cell이 가리키는 object의 수명은 closure가 참조하는 동안 이어진다.

중첩 함수가 모두 클로저는 아니다

함수 안에서 함수를 정의했다고 자동으로 closure가 생기지는 않는다.

def outer():
    def nested():
        return "no captured variable"

    return nested


function = outer()

assert function.__code__.co_freevars == ()
assert function.__closure__ is None

nested()는 enclosing scope의 local binding을 하나도 참조하지 않는다. 구조상 중첩 함수지만 기억할 closure variable이 없으므로 __closure__도 없다.

nonlocal은 언제 필요한가

바깥 변수를 읽기만 할 때는 nonlocal 선언이 필요 없다.

def make_power(exponent):
    def power(number):
        return number**exponent

    return power


square = make_power(2)
assert square(5) == 25

반면 count += step처럼 바깥 name에 새 값을 다시 대입하면 nonlocal count가 필요하다. 선언이 없으면 Python은 count를 안쪽 함수의 local name으로 판단하고, 대입 전에 읽으려는 순간 UnboundLocalError가 발생한다.

list에 append()하는 경우는 name을 다른 list로 재바인딩하는 것이 아니라 기존 object를 변경하므로 동작이 다르다. 이 경계는 Python nonlocal과 재바인딩에서 더 자세히 다룬다.

클로저를 쓰기 좋은 경우

closure는 설정 하나와 작은 상태를 함수에 묶을 때 간결하다.

  • 입력받은 설정을 기억하는 function factory
  • callback에 필요한 context 보관
  • decorator의 option과 원래 함수 참조
  • 호출 횟수처럼 operation 하나로 관리되는 작은 상태

상태 field와 operation이 늘어나고, serialization·validation·typing contract가 중요해지면 class가 더 읽기 쉬울 수 있다. 여러 thread나 async task가 같은 closure state를 수정한다고 자동으로 안전해지는 것도 아니다. 필요한 경우 lock, queue 또는 single-owner 구조를 따로 설계해야 한다.

late binding과 decorator까지 확장한 예시는 Python 클로저와 데코레이터에서 이어서 볼 수 있다.

클로저는 보안 경계가 아니다

closure를 ‘data hiding’이라고 설명할 수는 있지만 언어가 접근을 금지하는 private storage는 아니다. 위 예제처럼 __closure__를 통해 cell을 관찰할 수 있다.

token이나 password를 closure에 넣었다고 안전하게 숨겨지는 것은 아니다. closure는 public interface를 작게 만드는 lexical encapsulation 도구이지 secret 관리 장치가 아니다.

또한 큰 object를 capture하면 함수가 살아 있는 동안 그 object도 memory에 남을 수 있다. 필요하지 않은 request, model, cache 전체를 무심코 capture하지 않는지 확인한다.

자주 묻는 질문

lambda만 클로저를 만들 수 있나?

아니다. def로 정의한 함수와 lambda 모두 enclosing scope의 변수를 capture하면 closure가 될 수 있다. lambda 여부보다 free variable이 있는지가 기준이다.

global과 nonlocal의 차이는 무엇인가?

global은 module scope의 binding을 대상으로 하고, nonlocal은 가장 가까운 enclosing function scope의 binding을 대상으로 한다.

클로저와 객체 중 무엇이 더 빠른가?

일반적인 설계에서는 미세한 호출 비용보다 상태와 operation이 얼마나 명확하게 드러나는지가 중요하다. 작은 단일 동작은 closure, 여러 상태와 method가 협력하면 class를 먼저 검토한다.

참고 자료

반응형
KEEP READING
카테고리 전체 보기 →

댓글