Python - 클로저 이해하기 (2) 클로저 개념, 특징

1
post-thumbnail

들어가기에 앞서

이번 시간에는 저번 시간에 이어서 본격적인 클로저에 대한 정의와 개념, 조건,
특징 등에 대해서 알아보려고 합니다.

이 글을 통해서 조금이나마 도움이 되시는 분이 계시다면 만족합니다 :)
자! 그러면 한번 클로저에 대해서 알아보도록 할까요?

클로저 기본 정의

먼저 다양한 클로저의 정의에 대해서 한번 알아보도록 하겠습니다.
사실 클로저의 정의만 보고 이해하기란 정말 쉽지 않습니다.
그래도 일단 정의에 대해서 보고 가보도록 하죠

다음은 MDN에서 정의한 Closure입니다.

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function’s scope from an inner function.

이를 직역하면,

클로저는 주변 상태(lexical environment)에 대한 참조와 함께 번들로 묶인(포함 된) 함수의 조합입니다.
즉, 클로저를 사용하면 내부 함수에서 외부 함수의 scope에 액세스 할 수 있습니다.

로 해석이 가능하겠네요.

이 문장만 봐서는 전혀 알수가 없습니다. 이해가 어려운 단어와 문장으로 가득합니다.
그러면 천천히 개념에 대해서 좀 더 이해하고 다음의 문장을 다시 이해해 보도록 목표를 삼아 보죠!

다른 클로저의 정의를 좀 더 알아볼까요?
다음과 같은 정의를 블로그에 찾아 볼 수 있었습니다.

자신을 둘러싼 스코프(네임스페이스)의 상태값을 기억하는 함수

즉, 클로저는 일단 '함수' 인 것 같습니다.
두 번째 정의에서는 함수 중에서 자신을 둘러싼 scope의 상태 값을 기억하는 함수라고 하네요.
자신을 둘러싼 scope의 상태값이라.. 다음과 같은 코드를 의미하는 것일까요?

msg = "안녕하세요!"
def main_func():
    global msg
    return msg

main_func()

위의 코드는 global scope에 있는 msg 변수를
main_func의 local scope에 가져다가 사용해서 해당 값을 그대로 반환하고 있습니다.

하지만 완벽히 위의 정의를 충족시키고 있진 못하고 있는 듯 합니다.
무엇보다도 위의 코드는 굉장히 위험합니다.

왜일까요?

첫 번째로, global scope에 있는 msg 변수가 변경되거나 삭제하면
이에 main_func이 에러가 발생하거나 값이 변할 수 있습니다.

msg = "안녕하세요!"
def main_func():
    global msg
    return msg

del msg

main_func()

# 결과
NameError: name 'msg' is not defined

두 번째로, scope의 상태 값을 기억한다고 보기 어렵습니다.
위의 코드는 기억하기 보다는 오히려 그냥 global scope의 msg 변수를 그대로 참조해서 반환하고 있는 것이 정확하겠네요.

그렇다면 저번 시간에 알아본 중첩 함수(nested function)와 nonlocal를 조금 활용해 보면 어떨까요?

다음의 코드를 한번 보시겠습니다.

def outer_func():
    msg = "안녕하세요 !"
    def inner_func():
        nonlocal msg
        return msg

    return inner_func

f = outer_func()
f()

위의 코드는 outer_func이라는 외부 함수 내에 inner_func 이라는 중첩 함수를 구현하고,
inner_func 은 nonlocal scope에 위치하는 msg를 읽어 반환하고 있습니다.
최종적으로 outer_func는 중첩 함수 자체(inner_func)를 반환하고 있습니다.
전 시간에 알아보았지만, 상위 scope의 변수를 읽는 행위는 따로 nonlocal로 선언해 주지 않아도 됩니다.

그렇다면 f 함수는 자신을 둘러싼 scope(msg)의 값을 기억하고 있을까요?

def outer_func():
    msg = "안녕하세요!"
    def inner_func():
        return msg

    return inner_func

f = outer_func()
del outer_func
f()

# 결과
'안녕하세요!'

오! 기억하고 있네요!
제가 del 명령어를 통해 outer_func 이라는 함수 객체를 지웠는데도 불구하고, msg의 값을 그대로 기억하고 있습니다.

점점 더 클로저에 가까워져 가고 있는 것 같습니다.

그렇다면 nonlocal이 아닌, global scope에 위치하는 변수는 어떨까요?

msg = "안녕하세요!"       #- global scope에 msg 선언
def outer_func():
    def inner_func():
        return msg

    return inner_func

f = outer_func()
del msg
f()

# 결과
NameError: name 'msg' is not defined

에러가 발생하네요. 즉, 자신의 둘러싼 scope는 nonlocal scope 일 때만 기억할 수 있는 듯 합니다.

그렇다면, 정확히 클로저란 무엇일까요?

클로저를 좀 더 정확히 이해하려면, 클로저의 생성 조건을 파악해야 합니다.

클로저 생성 조건

클로저의 생성 조건은 다음과 같습니다.

  • 해당 함수는 어떤 함수의 중첩된 함수(nested-function)이어야 합니다.
  • 해당 함수는 자신을 둘러싼(enclose) 함수 내에 상태를 반드시 참조해야 합니다.
  • 해당 함수를 둘러싼 함수는 이 함수를 반환해야 합니다.

위에서 추론해 보았던 예제 코드를 통해 클로저 생성 조건을 대입해 볼 수도 있지만,
특정 블로그에 굉장히 좋은 예시가 하나 있어 인용해 적용해 보려고 합니다.

우리는 processor(cpu)가 cache에 들러 원하는 데이터가 있는지를 확인하는 cache hit 검사 모듈을 구현한다고 가정하겠습니다.

구현된 코드는 다음과 같습니다.

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

즉, cache dict 객체 안에 n이 있으면 그대로 반환하며(cache hit), 없으면(cache miss) 값을 생성해서 cache에 저장 후, 해당 값을 반환하는 코드입니다.

아마 눈치 채셨겠지만, 위의 코드에서 wrapper는 클로저입니다.

  • in_cache 안에 내장 중첩 함수이며,
  • 자신을 둘러싼(nonlocal) scope에 cache라는 객체를 참조하고 있으며
  • 자신을 둘러싼 in_cache 라는 함수에서는 자신을 반환합니다.

그렇다면 왜 이런 클로저를 생성해서 사용하는 것일까요?

다음의 특징을 보면 이해할 수 있습니다.

숫자가 입력되면 1부터 해당 숫자까지의 합을 계산하는 함수를 하나 가져와 in_cache와 같이 사용해 보겠습니다.

이 때 wrapper 중첩 함수 안에서 print문을 통해 cache를 확인해 보도록 하죠.

코드는 다음과 같습니다.

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 list_sum(n):
    return sum(list(range(n)))
    
list_sum = in_cache(list_sum)

해당 코드는 wrapper의 nonlocal scope(enclosing)에 있는 cache라는 변수를 추적합니다.
다음 코드를 통해 실행하면 결과가 어떻게 될까요?

list_sum(5)  #- 결과?
list_sum(10) #- 결과?
list_sum(8)  #- 결과?

결과는 다음과 같습니다.

{}
Out[56]: 10
{5: 10}
Out[57]: 45
{5: 10, 10: 45}
Out[58]: 28

이상함을 눈치 채셨나요?
nonlocal scope에 위치하는 cache 변수는 전역 변수처럼 행동하고 있습니다!

즉, 우리가 실행한 list_sum = in_cache(list_sum) 구문에서, 원래 정의한 list_sum 함수가 아닌, in_cache(list_sum)를 반환한 함수를 사용함으로서, 우린 클로저를 사용한 것입니다.

즉, 다시 정리하면 클로저란, 외부함수 in_cache에 둘러쌓인 nonlocal scope의 객체의 값을 기억하는 함수인 것입니다!

list_sum = in_cache(list_sum) 구문에서 우리는 in_cache 함수의 중첩 함수인
wrapper 함수를 반환 받아 사용했는데요,
해당 함수 밖에 존재하던 cache 변수를 마치 전역 변수처럼 사용할 수 있게 된 것입니다.

외부 함수는 이미 종료됐는데도 말이죠.

다시 말하면, in_cache 함수를 삭제해도 cache 변수가 살아있게 됩니다.

del in_cache
list_sum(5)

#- 결과
{5: 10, 10: 45, 8: 28}
Out[67]: 10

클로저의 특징과 장점

위의 예제를 계속적으로 탐구해 보도록 하겠습니다.
in_cache 함수를 지웠는데도 cache 변수는 전역변수 처럼 그대로 살아있음을 확인했습니다.

그렇다면 해당 변수는 어디에 있는 것일까요?

__closure__ 변수에 가지고 있습니다.

파이썬 3을 기준으로 클로저 함수는 자동으로 해당 변수를 가지고 있습니다. 해당 변수에는 클로저가 참조하는 eclosing에 위치하는 변수들의 값을 저장하고 있습니다.

다음과 같이 코드를 실행시켜 보면, 저장된 cache 변수의 값을 확인할 수 있습니다.

list_sum.__closure__[0].cell_contents

# 결과
Out[68]: {5: 10, 10: 45, 8: 28}

이러한 클로저의 장점은 전역변수의 단점을 해소시켜 줄 수 있습니다.

  • 특정 scope 안에 변수를 활용함으로서 변수간의 충돌을 방지할 수 있으며,
  • 독립적으로 책임의 소재를 물을 수 있고,
  • 해당 스코프의 내부를 더 쉽게 바꿀 수 있습니다.

만약 전역 변수를 사용했다면, 다른 영향도를 고려해야 하므로, 위의 행동들이 쉽지 않을 것입니다.

또한 스코프 별로 동일한 변수명(ex) cache) 를 사용함으로써 좀 더 깨끗한 코드를 작성할 수도 있겠죠.

마치며

다시 가장 위로 올라가 클로저의 정의를 살펴볼까요?

클로저는 주변 상태(lexical environment)에 대한 참조와 함께 번들로 묶인(포함 된) 함수의 조합입니다.
즉, 클로저를 사용하면 내부 함수에서 외부 함수의 scope에 액세스 할 수 있습니다.

자신을 둘러싼 스코프(네임스페이스)의 상태값을 기억하는 함수

여기서 말하는 lexical environment는 내부 함수가 선언됐을 때의 scope를 말합니다.

즉, 위의 예제로 대비해서 다시 작성해보면, 클로저는 cache와 같은 객체에 대한 참조와 함께 클로저의 고유 스코프로 묶인 함수의 조합! 으로 다시 이해할 수 있습니다.

이때 cache는 외부 함수가 사라져도 계속 기억되고 있으므로, 상태값을 기억하고 있죠.

자! 드디어 클로저의 정의까지 완벽히 이해해 보았습니다!

꼭 클로저에 대한 개념을 실제 프로그램 코드에 활용해 보시길 바랍니다 ^^

감사합니다!

참조 블로그

이 글을 작성하기에 참조한 블로그는 다음과 같습니다.
Python의 Closure에 대해 알아보자
1급 객체(first-class object)란?

profile
재빅의 빅데이터 여행 기록 저장소

0개의 댓글

관련 채용 정보