python - 클로저(Closure) 와 데코레이터(Decorator)

정현우·2023년 3월 9일
4
post-thumbnail

[ 글의 목적: python의 closure와 decorator의 정확한 정의와 활용법을 파악하고 제대로 사용하기 위해 ]

클로저(Closure) 와 데코레이터(Decorator)

흔히 아주 쉽게 "사용법" 만 익히고, @함수 로 무지성 사용만 하던 데코레이터와 그 근간을 이루는 클로저에 대해 조금 더 깊은 개념부터 정확하게 살펴보자. 그러기 위해 일급 객체와 내부 함수 그리고 변수의 scope에 대해 깊게 알아보자. 그리고 더 잘 데코레이터를 다뤄보자.

1. Closure 클로저

클로저의 진짜 간단하게 생각하면 "함수안에 함수를 구현하고, 런타임에 정의되는 그 내부를 반환하는 함수다." 하지만 이건 클로저의 결과론적인 설명이다. "왜" 클로저가 가능할까

1) 일급 객체 (First Class Object 또는 First Class Citizen)

  • 이 개념은 Christopher Strachey 라는 분의 강의 노트에서 처음으로 언급이 되었다고 한다. 해당 노트의 일부분을 발췌 해서 보자면 아래와 같다.

  • 일급 객체와 이급 객체. ALGOL에서 실수는 표현식에서도 나올 수 있고, 변수에 대입할 수도 있고, 프로시저 호출에 매개변수로 넘길 수도 있다. 하지만 프로시저는 다른 프로시저 호출에서 연산자나 실질적 매개변수로 밖에 사용할 수 없다. (실질적 매개변수에 대해서는 다음을 참조) 프로시저를 포함하거나 프로시저를 반환값으로 가지는 표현식은 존재하지 않는다. 따라서 ALGOL에서 프로시저는 이급 시민이다. 이는 다른 요소들 사이에서만 존재할 수 있고, 단일 변수나 표현식으로 나타날 수 없다는 것이다. (형식적 매개변수의 경우를 제외하고)

  • 위 노트에서는 명확하게 정의를 내리지 않고 있다. 이후 Robin Popplestone 가 아래와 같은 명확한 기준을 제시했다.

  1. 모든 일급 객체는 함수의 실질적인 매개변수가 될 수 있다.
  2. 모든 일급 객체는 함수의 반환값이 될 수 있다.
  3. 모든 일급 객체는 할당의 대상이 될 수 있다.
  4. 모든 일급 객체는 비교 연산(==, equal)을 적용할 수 있다.
  • python은 "순수 객체 지향 언어" 이다. 그리고 기본적으로 존재하는 데이터타입의 객체들은 일급 객체이다. 그리고 "함수 역시 일급 객체" 이다. 그리고 이 개념은 python에서만 국한되는 것이 아니다.

  • 가령 C를 예로 생각해보면, function의 argument로 pointer를 전달 할 수 있어도 function name 그 자체를 넘길 수는 없다. 이런 C를 생각해보면 포인터를 일급 객체라 생각할 수 는 있어도, 함수 자체가 일급 객체가 될 순 없다.

[출처: https://kukuta.tistory.com/321 ]

  • def add 으로 함수를 선언하면 파이썬은 내부적으로 위와 같은 함수를 관리하기 위한 PyFunctionObject 객체를 생성하게 된다. add 은 단순히 그 객체를 가르키는 "포인터" 일 뿐이다.

  • 함수 객체에는 다른 파이썬 객체들과 마찬가지로 PyObject에서 파생되어 나온 레퍼런스 카운트(ob_refcnt)와 객체 타입을 가리키는 포인터(ob_type)를 가지고 있고, 그외 함수 객체에 필요한 별도의 속성도 가지고 있다.

  • 즉 python은 함수도 일급 객체이기 때문에 함수에게 함수를 전달하는, 아래와 같은 형태가 가능한 것이다.

def multi(a, b):
	return a * b
    
def run_callback(callback, *args):
	print("run callback function")
	a = callback(*args)
    print("result >>", a)
    
>>> run_callback(multi, 5, 4)
run callback function
result >> 20

2) 내부 함수, 함수 중첩 (nested function)

  • python에서는 다양한 중첩이 허락이 된다. for안에 for, if안에 if, 기본적으로 제공하는 내부함수 sum안에 if, for 등 이런 면모들이 파이써닉(pythonic) 한 모습을 만들어 준다.

  • 그리고 함수안에 함수도 중첩이 된다. 위 (1)의 예시도 사실 일급 객체의 특성을 보여줬지만, 일급 객체이기에 가능한 함수 중첩의 모습도 같이 보인다.

def wrapper_function():
	def print_test():
		print("inner function")
        
	print("run inner function")
    print_test()
    return True
    
>>> result = wrapper_function()
run inner function
inner function
>>> print(result)
True
  • return True 의 역할을 하는 wrapper_function 내부에 def print_test 로 함수 자체를 하나 더 선언해버렸다. 그리고 선언 후 바로 excute 했다. 그리고 원래 목적대로 return True 를 했다.

  • 그리고 호출한 결과가 위와 같다. 이게 "closure" 가 되기 위한 "충분 조건" 이다.

3) Scoping Rule, nonlocal scope

[출처: https://blog.hexabrain.net/347]
  • (scope가 무엇인지 짚고 넘어가진 않겠다.) python은 기본적으로 위의 범위로 scope area를 지원한다. 즉 built-in > global > local 이 가장 기본적인 scope 범위다.

  • 근데 여기엔 enclosed 라는 친구가 껴있다. 정확하게는 "자신을 둘러싸는 상위 범위를 말하는 것" 이다. 이 범위는 중첩 함수나 람다에서 나타나는데, 자신을 둘러싸는 상위 범위는 내부 함수 기준으로 외부 함수의 범위를 말한다고 볼 수 있다. 다시 위 예제를 살펴보자!

def wrapper_function():
	c = "variable"

	def print_test():
    	cc = "inner variable"
		print("inner function")
        
	print("run inner function")
    print_test()
    return True
  • c 라는 변수는 wrapper_function 기준으로 local scope, print_test 기준으로 enclosed scope가 된다. 그리고 cc 라는 변수는 print_test 기준 local scope 이다. scope는 주체가 누구냐에 따라 당연히, 달라진다.

  • 그러면 print_test 기준으로 enclosed scopec 라는 변수에 접근하면 어떻게 될까, 접근할 수 있을까?

UnboundLocalError: local variable 'c' referenced before assignment
  • 없다! 이런 문제를 해결하기 위해, c 변수를 사용하고 접근하기 위해 nonlocal 을 선언한다.
def wrapper_function():
	c = "variable"

	def print_test():
    	nonlocal c
        c = "updated variable"
    	cc = "inner variable"
		print(f"inner function, c >> {c}, cc >> {cc}")

4) 다시, 클로저(Closure)

  • 파이썬에서 클로저는 자신을 둘러싼 scope의 상태값, 즉 enclosed scope를 기억하는 함수: 일급객체 라고 정의할 수 있다. 그리고 기본적으로 클로저로 사용하기 위해 아래 조건들이 충족되어야 한다.
  1. 해당 함수는 어떤 함수 내의 중첩된 함수여야 한다.
  2. 해당 함수는 자신을 둘러싼(enclose) 함수 내의 상태값을 반드시 참조해야 한다.
  3. 해당 함수를 둘러싼 함수는 이 함수를 반환해야 한다.
def mul(m, n):
    def wrapper(n):
        return m * n
    return wrapper
  • 이 점을 좀 더 확장해 class에 __call__을 활용해 closure를 만들어 보자!
class Mul:
    def __init__(self, m):
        self.m = m

    def __call__(self, n):
        return self.m * n


if __name__ == "__main__":
    mul3 = Mul(3)
    mul5 = Mul(5)
    print(mul3(10))  # 30 출력
    print(mul5(10))  # 50 출력
  • python 은 순수 객체 지향언어 이며 class "ddunder method"가 있다. 특히 __call__ 은 class자체를 호출할때 excute되는 ddunder method이다.

  • 그래서 mul3 = Mul(3) 를 통해 Mul object instance를 생성하고, 변수 mul3(__call__의 파라미터) 를 통해 excute 가능하다!

  • closure에 의해 정의된 내부 함수, 중첩 함수는 호출 해서 메모리에 할당될 때, 즉 런타임에서 정확하게 행위가 정의 된다! 그래서 재미있는 점은 "함수가 메모리에서 사라져도 값이 유지가 된다는 점" 이다.

def mul(m):
    def wrapper(n):
        return m * n
    return wrapper
    
mul3 = mul(3)
mul5 = mul(5)

del(mul)
print(mul3(3))
print(mul5(5))
  • 그래서 위와 같이 del(mul) 를 통해 아예 함수 메모리 할당 해제를 해도 mul3(3)mul5(5) 는 아주 잘 살아있다!
mul3.__closure__[0].cell_contents
mul5.__closure__[0].cell_contents
  • 파이썬 3 기준으로, 클로저 함수는 __closure__ 변수를 자동으로 갖고 있다. 이 변수는 튜플 타입으로서 클로저가 enclosing 스코프에서 참조하는 변수들을 담고 있다. 그리고 각 원소의 cell_contents 는 그 값 자체를 갖고 있다. 즉, 런타임에서 함수가 정의가 되기 위해 __closure__ 변수가 같이 생성되고 유지되기 된다. 그래서 기존 함수가 삭제되어도 문제없이 클로저를 실행할 수 있다.

  • 그리고 이 closure 의 특성을 바탕으로 우리는 "데코레이터" 를 만들 수 있는 것이다.


2. Decorator 데코레이터

1) 데코레이터란

  • 이름 자체에서 데코레이터는 장식하다, 꾸미다라는 뜻의 decorate에 er(or)을 붙인 말인데 장식하는 도구 정도를 표현하고 있다. 위 클로저에서 계속 봤듯이, 클로저를 활용해 이 데코레이터를 만든다.

  • 목적은 "데코레이터는 기존 함수를 변경하지 않으면서 함수에 기능을 추가 할 때" 를 위해서 사용한다.

import time

def deco(fun):
    def wrapper(*args):
    	"""우리 이쁜 데코레이터"""
        start_time = time.time()
        fun(*args)
        end_time = time.time()
        print(f"함수 실행 시간 : {end_time - start_time}")
    return wrapper

@deco
def print_something(a):
    print(a)
    
>>> print_something("데코레이터 사용을 통해 출력해 보자!")
데코레이터 사용을 통해 출력해 보자!
함수 실행 시간 : 0.00024819374084472656
  • 앞서 본 클로저의 개념을 활용해서 내부 함수, 중첩 함수를 만들고, *args 을 통해 원래 함수의 인자를 packing 해서, 내부 함수에서 fun(*args) 로 excute 한다. 그리고 @deco를 통해 print_something 정의하고 unpacking 을 통해 인자를 전달해서 excute한다.

  • 그러면 이렇게 간단하게, @deco 만 추가해서 기본적으로 수행되는 함수의 로직이 excute되고 우리가 원한대로 "함수 실행 시간" 이 출력된다. 이미 다 아는 개념으로 데코레이터를 만들었다!

2) syntax sugar, syntactic sugar @

  • 여기서 가장 의문인건 어떻게 @만으로 decorator를 처리하느냐 이다! 해당 시리즈의 python 실행되기 까지 글에서 언급한 "AST(abstract syntax tree)" 를 살펴봐야 이 "@" 를 알 수 있다.

  • 이 @는 Cpython의 Python parser 를 통해 AST로 바뀔때 처리된다. 아래 코드 예시를 보자! 들여쓰기 수준이 굉장히 심하니 조심,,

@my_decorator
def my_function():
    print("Hello, world!")

# 아래가 AST
FunctionDef(
    name='my_function',
    args=arguments(
        args=[],
        vararg=None,
        kwarg=None,
        defaults=[]
    ),
    body=[
        Expr(
            value=Call(
                func=Name(
                        id='my_decorator',
                        ctx=Load()
                    ),
                args=[
                    FunctionDef(
                        name='my_function',
                        args=arguments(
                            args=[],
                            vararg=None,
                            kwarg=None,
                            defaults=[]
                        ),
                        body=[
                            Expr(
                                value=Call(
                                    func=Name(
                                        id='print',
                                        ctx=Load()
                                    ),
                                    args=[Str(s='Hello, world!')],
                                    keywords=[]
                                ),
                                lineno=3,
                                col_offset=4
                            )
                        ],
                        decorator_list=[],
                        lineno=2,
                        col_offset=0
                )],
                keywords=[]
                ),
            lineno=2,
            col_offset=0
        )
    ],
    decorator_list=[],
    lineno=2,
    col_offset=0
)
  • function을 정의하면 FunctionDef 라는 저수준의 object 정의로 변경되는데, @를 만나면 또 body라는 내부 부분에 FunctionDef 들어간다. 이렇게 sytax sugar를 python parser가 처리해서 handling 해준다!

3) function meta information

  • 계속 살펴보는 것이 함수도 객체, 일급 객체, 파이썬은 모든게 객체라는 개념이었다. 그리고 함수는 좀 더 특별한 것이 자체 "메타 정보"라는 것을 가지고 있다. 즉, 함수를 정의하면 python이 객체로 처리하면서 함수에 추가하는 속성, attribute 들이 존재한다. 기본 attribute 들이 무엇인지 궁금하면 정의한 함수를 dir(fun_name) 이렇게 출력해 보자!

  • 근데 decorator를 사용하면 이 function이 가지고 있는 기본 meta 정보가 다른 정보로 덮어져 버린다! 위 예시를 그대로 다시 살펴보자!

@deco
def print_something1(a):
    """내 함수야"""
    print(a)
    
def print_something2(a):
    """내 함수야"""
    print(a)

print(print_something1.__name__)
print(print_something1.__doc__)
print(print_something2.__name__)
print(print_something2.__doc__)

# 출력
wrapper
우리 이쁜 데코레이터
print_something2
내 함수야
  • 하나는 위에서 만든 데코레이터 (@deco)를 활용했고 하나는 그냥 순수 함수를 만들었다. 그리고 메타정보의 차이를 살펴보면, 데코레이터를 통해 만들어진 함수는 우리가 deco를 정의하면서 만든 내부 중첩 함수 wrapper 로 name 이 박혀버린다. 사실 진짜 이름은 그게 아닌데!! (doc 또한!)

  • 이를 해결하기 위해 python 내장 라이브러리 중 functool 라는 모듈이 있는데, 그 내부 함수중 wrap 를 사용한다.

import time
from functools import wraps

def deco(fun):
    @wraps(fun)
    def wrapper(*args):
        """우리 이쁜 데코레이터"""        
        start_time = time.time()
        fun(*args)
        end_time = time.time()
        print(f"함수 실행 시간 : {end_time - start_time}")
    return wrapper

@deco
def print_something1(a):
    """내 함수야"""
    print(a)
    
def print_something2(a):
    """내 함수야"""
    print(a)

print(print_something1.__name__)
print(print_something1.__doc__)
print(print_something2.__name__)
print(print_something2.__doc__)

# 출력
print_something1
내 함수야
print_something2
내 함수야

4) Class decorator, Method Decorating

  • 계속 말하는, "python은 순수 객체 지향 언어" 이다. function으로 decorator를 만들었고, function 역시 객체, 일급 객체임을 알았다. 그러면 당연히, class를 통해 정의한 method를 통해 decorator 도 만들 수 있을 것이라고 생각이 든다. 위에서 클로저를 class를 통해 정의한 mul을 떠올리면서 아래 예제를 체크해보자.
import time

class DecoratorClass:
    def __init__(self, fun):
        self.fun = fun

    def __call__(self, *args):
        start_time = time.time()
        self.fun(*args)
        end_time = time.time()
        print(f"함수 실행 시간 : {end_time - start_time}")

@DecoratorClass
def print_something1(a):
    print(a)
    
print_something1("드가자")

# 출력
드가자
함수 실행 시간 : 0.0002970695495605469
  • function based decorator랑은 형식은 조금 상이하지만 역시 __call__ ddunder method를 활용했다. 이렇게 class based decorator도 바로 만들 수 있다. 그리고 조금 어거지일 수 있으나, @staticmethod 를 통해 class 내부의 데코레이터도 만들 수 있다.
import time

class DecoratorClass:
    @staticmethod
    def method_deco(fun):
        def wrapper(*args):
            start_time = time.time()
            fun(*args)
            end_time = time.time()
            print(f"함수 실행 시간 : {end_time - start_time}")
        return wrapper

@DecoratorClass.method_deco
def print_something1(a):
    print(a)
    
print_something1("1 드가자")

마무리 & 출처

  • 해당 글에 나온 핵심 키워드만 조금 다시 추려보자, 다시 키워드를 체크하면서 위 내용을 리마인드 하려고 한다!
  1. 일급 객체
  2. nested function
  3. nonlocal scope - enclosed scope
  4. packing & unpacking - (positional argument & keyword argument)
  5. syntax sugar - @ decorator AST
  6. function meta information & ddunder method
  7. class based decorator & staticmethod
profile
도메인 중심의 개발, 깊이의 가치를 이해하고 “문제 해결” 에 몰두하는 개발자가 되고싶습니다. 그러기 위해 항상 새로운 것에 도전하고 노력하는 개발자가 되고 싶습니다!

0개의 댓글