파이썬의 클로져 에 대해 알아본다

클로져의 영단어 뜻은 '폐쇄'라는 뜻이다.

 

 

 

 

 

2. 배경지식

먼저 closure에 대해 바로 들어가기 전에 배경지식을 살펴본다.

함수의 중첩, 일급 객체, 파이썬의 nonlocal 스코프에 대해 알아보겠다.

 

 

 

2.1 함수 중첩

python에서 if 조건문 등은 중첩이 된다.

 

n = int(input("정수를 입력하세요"))

if n % 2 == 0:
    if n % 3 == 0:
        print("6의 배수군요?")
    else:
        print("짝수로소이다 ㅎㅎ")

인터프리터에 'import this'라고 입력하면 유명한 ‘Zen of Python‘[파이썬 계명]에서는 가급적 중첩을 피하라고 이야기하고 있다. 하지만 중첩을 해야만 하는 경우도 있기 때문에 중첩이 틀린 건 아니다.

그리고 파이썬에서는 함수도 중첩 선언이 된다 그러니까 함수 정의 안에 다른 함수를 정릐할 수 있는 것이다.

 

def greetings():
    def say_hi():
        print("Hi, everyone :)")

    say_hi()


>>> greetings()
    
Hi, everyone :)

인사를 건네는 greetings함수를 만들었다. 이 함수는 실제 인사를 건네는 코드를 다른 함수로 감사서 이 내부 함수를 호출하고 있다. 이 코드는 전혀 문제가 없다.

이러헤 내부 함수를 짜면 이게 다 Closure인줄 알았다. 하지만 그건 틀린 생각이었고, 함수 중첩은 Closure이기 위한 필요조건이지 충분조건이 아니다.

 

 

 

2.2 First Class Object

좀 생소한 개념이 나왔다. 그런데 생각보다 어렵지는 않다.

프로그래밍 언어에서 First Class Object(또는 First Class Citizen, 일급 객체)는 해당 언어 내에서 일반적으로 다른 모든 개체에 통용가능한 동작(Operation)이 지원되는 개체(entity)를 의미한다.

 

일급 객체(first-class object)란 다음 조건을 만족하는 객체를 뜻합니다.

  • 변수나 데이터 구조에 넣을 수 있어야 한다.
  • 매개변수에 전달할 수 있어야 한다.
  • 반환값으로 사용할 수 있어야 한다.

특히 일급 함수(first-class function)는 일급 객체의 조건을 만족하면서 실행 중(run-time)에 함수를 생성할 수 있어야 합니다. 파이썬에서는 def 안에서 def로 함수를 만들거나, lambda를 사용하여 실행 중에 함수를 생성할 수 있으므로 파이썬의 함수는 일급 함수입니다.

 

이 동작은 주로

  1. 함수의 인자로 전달되거나,
  2. 함수의 반환값이 되거나,
  3. 수정되고 할당되는 것들을 전제로 한다.

이게 무슨 뜻일까? 어렵지 않다. 파이썬에서 가장 많이 쓰이는 자료타입이 뭐가 있을까? list, str, int 등등이 쉽게 떠오란다.

이렇게 기본적이고 유명한 자료타입들은 함수의 인자로 전달되거나, 반환값이 되거나, 수정되고 할당될 수 있다.(적어도 개념적으로는)

이런 자료형은 파이썬의 1급 객체다.

반례) c언어세너는 함수의 인자로 함수의 포인터를전달할 수는 있어도, 함수의 이름을 전달할 수는 없다.

 

기술적으로 포인터가 일급 객체라는 표현은 맞아도 함수가 일급객체라는 표현은 적절하지 않은 것이다.

반면 파이썬에서는 함수도 1급객체다.

함수에는 위의 기본 자료타입들에 적용가능한 동작들이 똑같이 적용가능하다.

 

def add(a, b):
    return a + b

def execute(func, *args):
    return func(*args) # 2.

f = add # 3.

>>> execute(f, 3, 5) # 1.

8

Packing와 Unpacking을 활용한 간단한 예제이다.

두수의 합을 반환하는 함수 add를 정의 했는데 이를 직접 호출하지 않고 감싸는 execute함수를 만들었다.

이 예제에서 앞서 이야기한 1급 객체에 적용가능한 동작이 모두 적용되었다.

  1. f라는 함수가 execute의 함수의 인자로 전달되었고
  2. 함수 내부에서 인자로 받은 함수 func를 문제없이 사용하고 있으며,
  3. add라는 원 함수의 이름이 마음에 안들었는지 f라는 새이름에 할당했다.

즉, 파이썬에서 함수는 1급 객체이다. 이 특성이 있어야 Closure가 성립될 수 있다.

 

 

 

2.3 nonlocal

이 개념은 중요하다. 꼭 Closure가 아니라도 의미가 있기에 조금 길게 설명한다.

먼저 간단한 예시이다.

 

z = 3

def outer(x):
    y = 10
    def inner():
        x = 1000
        return x

    return inner()

print(outer(10))

일단 파이썬에서 함수 중첩이 가능하다는 것을 확인했다.

그건 그런데, 위의 함수에는 x값에 대한 코드가 두 번 제시됐다.

처음은 함수 실행 시에 받는 임의의 x이고, 다음은 inner함수 내에서 1000으로 초기화하는 변수 x이다.

예시처럼 함수 호출 시 인자를 10으로 줬을 때 반환되는 값은 몇이어야 하는가??

위와 같은 이슈는 결국 scope에 대한 이야기다. inner함수 입장에서 바라보겠다.

 

  • inner함수 블록 안에 있는 영역은 local스코프라고 불린다. 로컬 영역 안의 모든 개체들은 inner의 제어 아래에 있다.
  • outer의 안에 있되, inner의 밖에 있는 영역은 nonlocal 스코프라고 불린다. outer의 y변수는 inner입장에서는 nonlocal스코프의 변수이다.
  • outer함수 밖의 영역은 global스코프이다. z변수는 global에 선언된 변수로 outer함수 뿐 아니라 다른 코드나 함수에서도 참조 가능할 것이다.

이런 global, nonlocal, local의 구분, 다시 말해 스코프의 구분은 어떤 의미가 있는가?

각 스코프는 자신의 영역에 최대한의 관심을 가지며, 다른 영역의 변수나 객체에 대해서는 제한적인 제어를 가지게 된다. → 객체의 이름이 같다면 local부터 우선순위를 둔다.

 

def greetings(x):
    def say_hi():
        print(x)

    say_hi()


greetings('안녕하세요?')

안녕하세요?

아까의 예와 비슷하다.

say_hi() 함수는 nonlocal영역의 x의 변수를 그대로 반환하는 기능을 갖고있다.

그리고 greetings()가 받은 문자열을 문제없이 참조할 수 있었다.

그러니까 외부 스코프의 변수에 대해 '읽기'가 문제없이 가능하다고 이해하면 된다.

하지만 자신의 영역이 아닌 영역에 대해 '쓰기'는 제한적이다.

 

def count(x):
    def increment():
        x += 1
        print(x)

    increment()


>>> count(5)

UnboundLocalError: local variable 'x' referenced before assignment

음 문제가 뭘까?

UnboundLocalError로 local변수 x가 할당되기 전에 참조됐다는 의미이다.

하지만 우리는 nonlocal의 x에 1을 더한건데?

함정이 있다.

 

local영역에서 밖의 영역에 대한 값을 찹조하는, 또는 읽는 것은 항상 문제가 없는데, 값을 수정하거나 새로 할당하는 것은,쓰는 것(write)은 안 된다.

 

위의 예시 코드에서는 'x+=1'가 수정하는 역할을 하고 있다.

파이썬에서는 값을 수정하는, 쓰는 코드가 나오면 따로 언급이 없는한, increment함수는 x가 자신이 제어할 수 있는 local변수라고 무조건 가정한다.

함부로 외부의 변수를 건드리는 어려운 버그를 막기 위해서다.

선언하지도 않는 변수에 값을 더하는건 당~연히 말이 안되는 거니깐.

그래서 로컬 스코프에 변수가 할당이 되지 않았다는 에러가 출력된 것이다.

 

왜 위와 같이 헷갈리게 만들어놨을까?

그것은 코드 영역의 책임과 권한을 명확히 나누는 것이 좋기 때문이다.

예제의 count함수는 하나의 내부함수를 갖고 있지만 어떤 경우에는 수십여개의 내부 함수를 가질 수도 있다.

이때 내부 함수마다 count의 상태값을 건드리고 수정한다면 예상하지 못한 결과를 초래할 수 있다.

 

그래서 읽기는 가능하지만 쓰기는 제한하고 있는 것이다.

리눅스에서 모든 파일에 유저마다, 그룹마다 쓰기와 읽기, 실행에 대한 권한을 따로 제어하는 것은 같은 이치다.

 

만약 위의 예제에서 의도적으로 nonlocal스코프의 값을 수정하고 싶으면 어떻게 할까? 이때 nonlocal statement를 쓰면 된다.

 

def count(x):
    def increment():
        nonlocal x  # x가 로컬이 아닌 nonlocal의 변수임을 확인한다.
        x += 1
        print(x)

    increment()

count(5)

>>> 6

increment 함수 정의 바로 아랫줄에 'nonlocal x'라고 선언했다. 이 코드는 x가 local변수가 아닌, nonlocal, 여기서는 count스코프 내의 변수라는 것을 개발자가 명시적으로 선언하는 것으로 nonlocal영역의 상태에 대해 읽는 것뿐 아니라 쓰는 것도 가능하게 된다.

같은 식으로 global 스코프의 값을 제어하고 싶다면 'global x'와 같이 사용할 수도 있을 것이다.

 

z = 3

def outer(x):
    y = 10
    def inner():
        x = 1000
        return x

    return inner()

>>> print(outer(10))

자, 멀리 돌아왔다. 우리의 원 질문은, 저 함수를 실행했을 때 값이 outer의 10이 나올지, inner의 1000이 나올지 이다. 결과는~~??

 

명심할점 . 함수는 자신이 제어권을 가장 많이 확보하고 있는, 자신에게 가장 가까운 스코프부터 찾아나아간다.

inner함수는 자신의 local스코프에서 x를 찾았기 때문에 밖의 스코프를 더 이상 찾아볼 이유가 없다. 그렇기 때문에 outer의 인자로 몇을 주든지 간에 무조건 local x의 값 1000이 나올 것이다.

 

outer(1)
outer(10)
outer(100)

>>> 1000
>>> 1000
>>> 1000

자, 생각보다 길었지만 Cloure을 이해하기 위한 세 가지 선행개념을 살펴 보았다. 이제 Closure로 넘어가자

 

 

 

 

 

3. Closure에 대한 이해

이제 기본지식을 바탕으로 closure에 대해 알아보자.

먼저 아리송한 Closure의 단어의 뜻을 잡고 가자.

기술 용어의 뜻을 제대로 파악하지 못하고 쓰는 것은 절대 지양해야 한다.

이 단어의 개념을 이해하면 클로저의 정의를 파악하고, 툭징과 사용 시의 장점에 대해 알아본다.

 

 

3.1 Closure단어의 의미는?

Closure는 여기서 무슨 의미일까? 이 영어 단어의 의미가 파이썬의 클로저에 던지는 메시지는 무엇일까?

Closure라는 단어에 집찾하기 전에 관련된 단어로 enclose로 우회해서 이해해보자.

enclose는 close에 대해 en접두사가 붙어 (담,울타리 등으로)'두르다', '둘러치다'란 의미를 갖고 있다. 이 뜻 그대로 이해해보자.

 

방대한 초원을 공유지로 하여 많은 사람들이 각자의 가축을 기른다고 하자. 누구는 소를 키우고 누구는 양을 키우고... 공유지에서 풀을 먹일 땐 같이 풀어놓더라고, 향후 분명 각자의 소유지에 담을 둘러(enclose) 가축들을 관리할 것이다.

 

김가네와 박가네가 서로의 우리에 가축동물을 기르고 있다. 이렇게 각자의 영역을 구축하여 동물들을 관리하면 어떤 장점이 있을까?

 

당연한 얘기지만, 각자의 동물에 대한 관리와 책임을 명확히 할수 있고, 서로 다른 재산끼리의 불필요한 충돌을 방지할 수 있으면, 각자가 원하는 대로 재산을 관리할 수 있다.

 

박가네는 그래프에 대한 깊은 이해가 있어 양들의 배치를 2차원 행렬 그래프에 가깝게 배치했지만, 김가네 목장은 소를 단순히 우리 안에서 방목하고 있다. 각자의 필요나 기호에 맞게 관리한 것이다.

 

이렇게 어떤 재산이나 속성을 Enclose하여 자신만의 영역을 구축하는 것, 그것의 장점을 이해함으로써 클로저를 더 쉽게 이해할 수 있다. 이게 본격적인 정의와 특징을 살펴보면서, 클로저의 장점을 다루며 위의 예시를 상기할 것이다.

 

 

 

3.2 기본 정의

이제 진짜 클로저에 대해 살펴보자면.

파이썬에서 클로저는 '자신을 둘러싼 스코프(네임스페이서)의 상태값을 기억하는 함수'이다.

그리고 어떤 함수가 클로저이기 위해서는 다음의 세 가지 조건을 만족해야 한다.

  1. 해당 함수는 어떤 함수 내의 중첩된 함수여야 한다.
  2. 해당 함수는 자신을 둘러싼(enclose) 함수 내의 상태값을 반드시 참조해야 한다.
  3. 해당 함수를 둘러싼 함수는 이 함수를 반환해야 한다.

 

이해를 위해 예시를 들자. 몇 주 전 파이썬에서 팩토리얼을 구하는 5가지 방법에 대한 포스트(링크)를 작성했다. 해당 포스트의 말미에서는 효율을 높이기 위한 Memoization을 제시했는데 이 방법은 5가지 알고리즘에 모두 적용가능하다. 그러면 Memoization 코드를 따로 분리시키고 각 알고리즘에서 그 모듈을 사용한다면 코드를 5번 하드코딩한 것보다 유지보수도 쉽고 가독성도 올라갈 것이 자명하다.(Modularity, that’s what matters.)

해당 포스트에서 캐시 히트 여부를 검사하는 분리된 모듈 코드는 다음과 같았다.

 

def in_cache(func):
    cache = {}
    def wrapper(n):
        if n in cache:
            return cache[n]
        else:
            cache[n] = func(n)
            return cache[n]
    return wrapper

 

in_cache함수는 cache라는 dict자료구조와 wrapper라는 내장 중첩함수를 가지고 있다.

이 중첩함수는 정수 n를 받아서 n의 key에 해당하는 value가 cache에 담겨 있으면 반환하고, 아니면 func를 실행해서 값을 저장한 뒤 반환한다.

이 함수를 실제로 적용해보기 전에, wrapper함수에 대해 살펴보자.

위에서 어떤 함수의 클로저이기 위한 특징을 살폈는데 공교롭게도 이 함수는 클로저이기 위한 조건을 모두 충족한다.

 

  1. in_cache 함수 내의 중첩된 함수이고,
  2. Enclosing하는 in_cache 스코프의 cache 라는 상태값을 참조하고 있으며,
  3. 자신을 둘러싼 함수는 자신(wrapper)을 반환하고 있다!

 

일단 wrapper 는 정의상 클로저라는 얘기이고, 이 함수를 제대로 사용해보자.

위 포스트의 팩토리얼 함수를 하나 가져와서 in_cache 를 적용해보겠다

 

def factorial(n):
    ret = 1
    for i in range(1, n+1):
        ret *= i
    return ret

 

주어진 정수에 대해 팩토리얼을 구하는 초보적인 코드다.

Memoization과 실제 팩토리얼을 구하는 코드가 분리된 상태로 Memoization을 기능에 추가하려면 다음과 같이 작동시킨다.

factorial = in_cache(factorial)

그리고 이해를 위해 wrapper 함수에 코드를 한 줄 추가한다.

 

def in_cache(func):
    cache = {}
    def wrapper(n):
        print(cache) ## !!!!
        if n in cache:
            return cache[n]
        else:
            cache[n] = func(n)
            return cache[n]
    return wrapper


def factorial(n):
    ret = 1
    for i in range(1, n+1):
        ret *= i
    return ret

factorial = in_cache(factorial)

 

이제 진짜 핵심으로 들어간다.

그 클로저라는 wrapper함수 안에 cache의 상태 값을 출력하는 코드를 넣었다.

저 코드는 wrapper을 enclosing하는 스코프의 cache변수를 추적한다.

이제 함수를 몇 번 실행해보자.

 

factorial(3)
factorial(5)
factorial(10)

{}
6

{3: 6}
120


{3: 6, 5: 120}
3628800

 

위의 {}결과가 cache의 상태고, 그 아래는 n에 따른 팩토리얼 값이다. 이상한 것을 발견했는가?

{}가 함수가 실행됨에 따라 업데이트 되고있다.

이것을 그림으로 표현하면

 

전역 스코프에는 수많은 변수들이 저장되어 있다. 그중에는 factorial함수도 있다.

이 함수는 원래 정의한 팩토리얼 함수가 아닌, 'in_cache(dactorial)'의 실행결과 반환된 함수로, 이게 곧 클로저다.(클로저의 조건 3번)

 

이 클로저에 다시 한 번 factorial이라는 이름을 할당해서 사용하는 것뿐, cached_factorial등의 이름을 붙여도 상관은 없다.

 

이 클로저는 자체 스코프를 가지고 있어, cache라는 상태는 매번 실행할 때마다 초기화되는 것이 아니라 값이 유지되고있다. 마치 전역 공간에 선언이라도 했듯이 말이다.

 

신기하게도 원래 함수 정의에서 cache 는 wrapper 함수 스코프의 밖에 선언되어 있었다. 그럼에도 wrapper(여기서는 factorial)는 cache 에 접근이 되고, 그 상태를 자신의 스코프 내에서 저장하고 제어할 수 있다.

 

아까 살펴본 클로저의 정의, **‘클로저는 자신을 둘러싼 스코프(네임스페이스)의 상태값을 기억하는 함수’**는 이런 뜻인 것이다. 이 예에서 factorial 스코프에서는 자신을 둘러싼(enclosing) 스코프의 상태값을(cache) 기억하고 제어할 수 있다.

이를 통해 자신만의 스코프 내에서 함수 실행시마다 초기화되는 것이 아닌, 지속적으로 관리되고 유지되는 cache 를 두고 소기의 목적을 달성할 수 있다.

 

여기서 궁금한점 wrapper에서 cache[n]=func(n)으로 nonlocal 객체인 cache의 값을 수정했는데 오류가 발생하지 않는 이유는??

 

 

 

3.3. Closure의 특징

클로저를 사용할 때의 특징과 장점을 살펴보도록 하자.

일단 눈에 띠는 특징으로 클로저는 자신을 둘러싼 함수 스코프의 상태값을 참조하는데, 이 값은 함수가 메모리에서 사라져도 값이 유지가 된다.

def times_multiply(n):
    def multiply(x):
        return n * x
    return multiply


times_3 = times_multiply(3)
times_4 = times_multiply(4)

times_3(5)
times_4(5)


15
20

 

이제는 다른 예제다. times_multiply 는 n 을 받아서 multiply 라는 함수를 반환한다.

이 multiply 내장 중첩함수는 클로저로 상위 스코프의 n 을 참조하고 있다. 결과 times_3, times_4 는 반환된 클로저로 들어온 숫자에 각각 3, 4를 곱해 반환한다.

 

이때 times_multiply 를 메모리 상에서 삭제하자.

 

del(times_multiply)

그러면 이 함수는 더 이상 호출할 수 없는데, 이게 기존에 생성된 클로저에도 영향을 미칠까?

 

times_3(5)


15

문제없이 호출가능하다. 클로저는 원 함수에 어떤 변화(심지어는 삭제)가 발생되어도 자신의 스코프는 지킨다.

 

 

두 번째 특징으로 클로저에서 자신 안에 정의된 내부 변수가 아닌, Enclosing하고 있는 변수에 접근하는 것을 파이썬에서 지원하고 있다.

 

times_4.__closure__[0].cell_contents

4

파이썬 3 기준으로, 클로저 함수는 **closure 변수를 자동으로 갖고 있다.

이 변수는 튜플 타입으로서 클로저가 enclosing 스코프에서 참조하는 변수들을 담고 있다. 그리고 각 원소의 cell_contents 는 그 값 자체를 갖고 있다.

위에서는 4라는 값을 감싸는 함수의 인자로 주었기 때문에 저 값은 클로저가 참조하는 값으로 유지된다.

그리고 이 클로저는 값을 하나만 참조했기 때문에 closure 는 길이가 1인 튜틀이다.

 

times_4.__closure__[1].cell_contents


IndexError: tuple index out of range

만약 enclosing 스코프에서 값을 여러 개 참조한다면 그 개수만큼 접근가능할 것이다.

클로저가 생성될 때(times_3 = times_multiply(3)) 이 closure 변수가 같이 생성되고 유지되기 때문에 기존 함수가 삭제되어도 문제없이 클로저를 실행할 수 있다.

 

그리고 내가 좋아하는 decorator는 결국 클로저를 만드는 문법이다.

 

factorial = in_cache(factorial)

in_cache 는 함수를 받아 Memoization 코드를 추가해 다시 함수를 반환하는 함수로, 위에서는 반환된 클로저를 다시 factorial 이라는 이름으로 재할당했다.

이 작업은 데코레이터가 정확히 하는 일이기도 하다.

@in_cache
def factorial(n):
    ...

 

 

 

 

4. Closure 사용시의 장점


그럼 클로저를 사용하면 대체 뭐가 좋을까? 아까의 factorial 예제를 보자.

캐시를 자신의 스코프 안에 저장하는 대신 그냥 전역변수로 두고 쓰면 되잖아? 왜 귀찮게 클로저를 만들어서 해야 하는거야?

충분히 던질 수 있는 질문이다.

아까 살펴 본 이미지다. 우리는 공유지에 각 개인의 소유 가축을 모두 풀어놓지 않고 울타리를 둘러쳐 자신의 영역을 구축한다.

결과적으로 이렇게 하는 이유와 Closure 사용의 장점이 일맥상통한다.

  • 관리와 책임을 명확히 할 수 있고
  • 각 변수가 섞여 불필요한 충돌을 방지할 수 있으며
  • 사용환경(context)에 맞게 임의대로 내부구조를 조정할 수 있다.

이 장점을 현실적인 예로 살펴보자. 앞서 우리는 factorial 함수를 정의했다. 추가로 1부터 N까지의 합을 구해 반환하는 sum_to_N 함수를 정의해서 같이 사용해야 한다고 하자.

 

def sum_to_N(N):
    s = 0
    for n in range(N+1):
        s += n
    return s


sum_to_N(10)

55

 

이 함수 또한 빈번하게 호출된다면 Memoization을 통해 효율성을 높일 수 있다.

 

그러면, 이런 비슷한 함수가 있을 때마다 전역공간에 cache를 두고 사용할 수 있는가?

10!은 3628800이고 1부터 10까지의 합은 55다.

 

전혀 다른 숫자기 때문에 같은 이름의 캐시를 쓸 수 없고, 굳이 전역공간에 캐시를 둔다면 cache_factorial, cache_sum_n과 같은 딱 보기에도 안쓰러운 이름의 변수를 써야 한다.

 

하지만 스코프를 분리해주고 자신의 스코프 안에서만 관리되는 캐시를 쓴다면 두 캐시가 충돌하지 않게 되서 사용성이 훨씬 커진다.

이걸 이렇게 그림으로 표현할 수도 있을까?

각 함수가 자신만의 스코프 안에서 고유한 cache를 유지하고 이 값을 문제없이 사용한다. 저 캐시들을 전역변수에 할당하고 사용해야 한다면 정말 끔찍할 것이다.

 

여기서 Closure라는 단어의 의미를 이렇게 정의해볼 수도 있겠다.

Closure는 폐쇄라는 뜻을 갖는데, 어떤 굳게 닫힌 문을 연상시킨다.

굳게 닫힌 문이라도 언젠가는 열리기 마련이고, 우리는 이 문을 통해 해당 스코프에 대한 간접적인 접근이 가능하다.

 

클로저가 하는 일이 바로 그런 것이 아닌가? 클로저를 호출해서 그 스코프 안의 변수에 접근하고 있으니… 그 이외의 방법으로 접근하는 것은 불가능하다.

 

 

출처:

클로저에 대해 아주 잘 설명되어있다.

https://shoark7.github.io/programming/python/closure-in-python#2b

반응형

+ Recent posts