closure(클로저)

teal·2023년 8월 11일
0

Python

목록 보기
4/8

지난 글에서 데코레이터 관련해서 클로저 이야기가 나왔다.

컴퓨터 언어에서 클로저(Closure)는 일급 객체 함수(first-class functions)의 개념을 이용하여 스코프(scope)에 묶인 변수를 바인딩 하기 위한 일종의 기술이다. 기능상으로, 클로저는 함수를 저장한 레코드(record)이며, 스코프(scope)의 인수(Factor)들은 클로저가 만들어질 때 정의(define)되며, 스코프 내의 영역이 소멸(remove)되었어도 그에 대한 접근(access)은 독립된 복사본인 클로저를 통해 이루어질 수 있다.
위키백과

뭔진 몰라도 일단 보면 복잡해보인다.

일단 나오는 개념들을 확인해보자
1. 일급 객체 함수
2. 스코프
3. 스코프내의 영역이 소멸되어도 클로저로 접근가능

일급 객체

일급 객체는 다음의 조건을 만족해야한다.

  1. 모든 요소는 함수의 실제 매개변수가 될 수 있다.
  2. 모든 요소는 함수의 반환 값이 될 수 있다.
  3. 모든 요소는 할당 명령문의 대상이 될 수 있다.
  4. 모든 요소는 동일 비교의 대상이 될 수 있다.

위키백과

첫번째 요소인 모든 요소는 함수의 실제 매개변수가 될 수 있다.
즉 클로저를 만들려면 함수에 함수를 매개변수로 넣을수 있는가?
가능하다. 애초에 데코레이터를 만들려면 함수에 매개변수로 함수를 받아야하고 파이썬에선 이것이 가능한 문법을 가지고있다.

두번째 요소는 함수의 반환 값이 될 수 있는가이다.
파이썬에서는 함수의 반환값으로 함수를 리턴해서 데코레이터를 동작시킨다. 즉 함수가 함수를 리턴하는것이 가능하다.

세번째 요소는 할당 명령문의 대상이 될 수 있는가이다.
이것도 가능하다. 파이썬에서는 fib = cache(fib)와 같이 함수를 특정 변수에 할당시켜서 동작시키는 것이 가능하다. 위와 같이 할당시킨 후 fib(10)과 같은 동작이 가능하다.

네번째 요소는 동일 비교의 대상이 될 수 있는가이다.
파이썬에서는 함수끼리의 비교가 가능하다.

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

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

if add == add_custom:
    print("같다")
else:
    print("다르다")
    
add2 = add

if add == add2:
    print("같다")
else:
    print("다르다")

위와같은 코드를 동작시키면

다르다
같다

라고 출력된다. 그 이유는 내부 구조가 완전히 동일하다고 하더라도 함수를 저장하고 있는 주소가 다르기 때문에 다르다고 출력되고 아래와 같은경우는 같은 주소를 가리키고 있는 경우이기 때문에 같다고 출력되는 것이다.

이를 통해 파이썬의 함수는 일급 객체의 조건을 만족한다는 것을 알 수 있다.

스코프

파이썬에서 과연 스코프라는 개념은 뭘까?

파이썬은 LEGB의 규칙을 따른다. 갑자기 LEGB는 뭔소리일까?
LEGB의 규칙은 파이썬에서 변수를 찾을때 아래의 순서를 지키면서 찾는다는 말이다.

Local, Enclosing, Global, Built-in

먼저 Local을 보자

Local은 말그대로 지역, 즉 파이썬은 지역변수를 가장 먼저 찾게 된다는 것이다.
일단 스코프라는 개념 자체를 보려면 어디를 기준으로 스코프를 볼지를 결정해야 한다. 즉 특정 변수는 어느 함수를 기준으로 볼지에따라 그 함수 기준에서는 지역 변수일수도 아닐수도 있다는 것이다.

간단한 함수를 작성해보자

e = 10
def outer(a: int, b: int) -> None:
    c = a + b
    def inner(n: int) -> int:
    	d = 10 + e
        print(d)
        return c + n
    print("결과값 :", inner(10))

outer(2, 3)

출력은 아래와 같다.

20
결과값 : 15

여기서 inner 함수의 기준으로 보면 지역변수는 d, n이다. 함수에서 지역변수는 인자로 들어온 값을 저장하는 매개변수들 + 함수 내에서 선언된 변수들이다.

그런데 inner 함수에서는 매개변수, 함수 내에서 선언된 변수도 아닌 c와 e를 참조하지 않았는가? 이는 LEGB중 E, G를 확인했기 때문이다.

E, 즉 Enclosing은 함수의 중첩 구조에서 내부 함수에서 외부 함수의 변수를 참조하는 것이다. 지역 변수를 다 확인했을때 c라는 변수를 찾지 못했기 때문에 파이썬은 외부 함수인 outer의 변수들을 확인하게 된다. 거기서 c를 찾았기 때문에 참조가 가능한 것이다.

변수 e는 그 어느 함수 안에서 선언된 것이 아닌 전역에 선언된 전역 변수이다. 그래서 G, Global 단계에서 확인해서 값을 참조하게 된다.

마지막으로 Built-in의 단계를 통해서 파이썬이 기본으로 제공하는 내장 함수를 사용해서 print와 같은 함수를 사용할 수 있게된다.

파이썬은 위와 같은 스코프를 가지고 동작한다.

그런데 파이썬은 스코프를 왜 관리하는 걸까?

만약 스코프라는 개념이 없다고 생각해보자.

def add():
    a = 10
    b = 5
    result = a + b

add()

print(result)

def minus():
    return a - b

print(minus())

위와 같은 경우에서 스코프가 없다고 생각하면 저 add함수를 실행하면 a, b, result 변수가 생성되고 함수 외부에서 result를 출력하면 결과값인 15가 출력될 것이고 minus()의 결과값을 출력하면 5가 출력되는 끔찍한 상황이 발생할 것이다.

즉 프로그램의 어디선가 변수를 선언하면 그 변수가 동일한 이름을 공유하는 다른 함수의 변수와 같게 되고 이를 방지하기 위해서는 수없이 많은 변수명을 사용해야만 관리할 수 있다.

그리고 스코프가 끝나도 사라지지 않는 값들이 생긴다. 즉 add함수가 끝나도 a, b, result의 값은 프로그램이 끝나기전까지 어디서 좀비처럼 계속 살아있다는 말이된다.

이처럼 끔찍한 상황들을 막기위해 스코프를 두고 해당 스코프가 끝나면 알아서 할당 해제되는 것이다.

위의 과정을 통해 파이썬에서 스코프가 무엇 인지를 알 수 있었다.

그런데 위의 개념을 알게되면 다음 3번인 스코프내의 영역이 소멸되어도 클로저로 접근가능이라는 말이 이상하게 느껴진다.

스코프내의 영역이 소멸되어도 클로저로 접근가능

아니 이게 대체 무슨 소리일까? 스코프를 벗어나면 내부에 선언된 값들이 할당 해제 된다고 위에서 그랬는데 갑자기 접근가능하다니?

이를 이해하기 위해서는 먼저 파이썬에서 가비지 컬렉터에의해 할당 해제되는 과정을 알아야 한다.

파이썬에서는 객체를 할당 해제하는 매커니즘은 레퍼런스 카운트가 0이 되는 객체를 가비지 컬렉터가 할당 해제를 진행한다.

아래의 간단한 코드를 보자.

import sys

x = [1, 2, 3]
y = x

ref_count = sys.getrefcount(x)
print(ref_count)

del x

ref_count = sys.getrefcount(y)
print(ref_count)

print(y)

결과값

3
2
[1, 2, 3]

파이썬에서 현재 객체의 레퍼런스 카운트를 확인하는 방법은 sys.getrefcount를 통해 가능하다. 첫번째 레퍼런스 카운트는 [1, 2, 3] 이라는 리스트를 x가 참조하고 있으므로 +1, y = x를 통해서 y가 리스트를 참조하므로 +1, sys.getrefcount(x)를 통해서 리스트를 참조하므로 +1 이므로 3이 출력된다.

del x 를 한 경우 리스트를 참조하고 있는 변수 x가 사라지므로 -1을 통해 다음 출력은 2가 된다. del x를 했더라도 y가 리스트를 참조하고 있으므로 레퍼런스 카운트가 0이되는 상황은 발생하지 않아서 마지막에 리스트가 정상 출력되는 것을 볼 수 있다.

즉 객체는 참조하고 있는 변수가 있는한 할당 해제되지 않는다는 것을 알 수 있다. 그런데 스코프내의 영역이 소멸되어도 클로저로 접근가능은 대체 어떤 소리일까?

저번 글에서 작성된 데코레이터와 함수를 가져와보자

def cache(f: Callable) -> Callable:
    cache = {}

    def inner(*arg, **kwargs):
        n = arg[0]

        if cache.get(n):
            return cache[n]

        cache[n] = f(*arg, **kwargs)
        return cache[n]
    return inner


@cache
def fib(n: int) -> int:
    if n < 3:
        return 1
    return fib(n-2) + fib(n-1)

아래에서 @cache를 통해서 fib 함수를 작성한 경우는 즉
fib = cache(fib)처럼 동작한다는 것을 저번 포스트에서 확인했다.
그래서 fib의 함수명을 출력하면 inner라는 것도 확인했다.

그런데 결국 cache함수의 호출을 통해서 inner 함수가 리턴되고 cache 함수를 지역변수로 가지고있던 cache함수는 스코프를 벗어나서 삭제되었을텐데 inner함수에서 어떻게 Enclosing 스코프인 cache 딕셔너리를 참조해서 사용할 수 있는 것일까?

코드를 잘 보면 inner 함수에서 cache를 참조에서 사용하고 있기 때문에 레퍼런스 카운트가 0이 안된것이고 그래서 fib 함수가 계속 호출될때마다 cache 딕셔너리가 유지되고 캐시로 활용할 수 있게되는 것이다. 만약 inner함수에서 참조를 안한 경우 cache 함수의 스코프를 벗어난 경우 그대로 해제된다.

즉, cache 데코레이터를 사용하면 내부에서 cache 딕셔너리를 참조하고있는 inner가 클로저가 되어 cache 함수의 스코프가 종료되어도 cache 딕셔너리는 레퍼런스 카운트의 값이 0이 되지않아 살아있게 되므로 위에서 작성한 cache 데코레이터를 통해서 캐싱을 사용할 수 있게 된다.

실제 fib의 클로저를 확인해보면 아래와 같이 출력된다.

print("fuction name", fib.__name__)
print("__closure__ 1", fib.__closure__[0].cell_contents)
fib(10)
print("__closure__ 2", fib.__closure__[0].cell_contents)
fuction name inner
__closure__ 1 {}
__closure__ 2 {2: 1, 1: 1, 3: 2, 4: 3, 5: 5, 6: 8, 7: 13, 8: 21, 9: 34, 10: 55}

결과

위의 과정을 통해 데코레이터에서 클로저가 동작하는 원리를 알게 되었다. 간단하게 끝날줄 알았는데 생각보다 엮여있는 개념이 많아서 글이 너무 길어졌다..

profile
고양이를 키우는 백엔드 개발자

2개의 댓글

comment-user-thumbnail
2023년 8월 11일

정보 감사합니다.

1개의 답글