본문 바로가기

파이썬

데코레이터 (Decorator) in python

파이썬 데코레이터를 공부해 보자.

 

우선 아래와 같이 연습용 함수를 두개 만든다.

def 데코레이터 (함수) :
	def 감싸는함수 () :
		결과 = 함수()
		return 결과
	return 감싸는함수

def 제곱 (입력) :
	결과 = 입력**2
	return 결과


입력 = 3
결과 = 제곱(입력)
print(결과)

위 코드 블럭을 실행시키면 "변수: 입력 = 3"이 제곱 함수의 인자로 들어가서 결과로 9 반환, "변수: 결과 = 9"로 저장하고 이를 출력한다.

일반적으로 많이 사용하는 기초적인 함수 구조이다.

 

데코레이터 함수는 인자(argument)로 함수(function)를 입력받는다.

 

함수가 인자로 함수를 입력받는다는게 어떤 의미일까?

이를 설명하기 위해서 콜백함수(callback) 함수에 대해서 알아야 한다.


콜백함수

콜백함수는 다른 함수의 인자로 사용되는 함수를 의미한다.

아래와 같은 콜백받는함수를 만들어 보자.

def 콜백받는함수 (콜백함수) :
	콜백함수()
	return None

콜백받는함수(제곱)

위와 같이 콜백받는함수의 인자인 콜백함수로 제곱함수를 넣으면 어떻게 될까?

위와 같이 "함수: 제곱""인자: 입력"이 없다고 나온다.

 

우선 제곱 이라는 변수를 출력해서 어떤 결과를 확인할 수 있는지 보자.

제곱이라는 변수는 함수 제곱이고 현재 RAM 주소를 알려준다.

즉, "변수: 제곱"은 "함수: 제곱"의 인스턴스(instance)를 저장한 상태라는 것을 알 수 있다.

 

기본적으로 def로 함수를 정의하면, 함수명과 똑같은 변수에 함수 instance를 저장한다.

그 후 해당 변수에 ()를 붙임으로서 해당 함수를 실행시킨다.

 

그렇다면 "변수: 제곱"에 다른 값을 넣으면 어떻게 될까?

제곱 = 3
제곱(3)

위와 같이 "변수: 제곱"에 int 3을 저장하고 "함수: 제곱"을 실행시키면 'int' 객체는 "호출이 가능(callable)"하지 않다는 에러가 난다.

"변수: 제곱"에 int를 덮어 씌워서 "함수: 제곱"의 인스턴스를 지워버렸기 때문에 앞으로 해당 함수는 사용할 수 없게 된다.

이런식으로 다시 "함수: 제곱"을 정의하면 "변수: 제곱"에 함수의 인스턴스가 저장되서 다시 해당 힘수를 사용할 수 있게 된다.

 

다시 콜백함수로 돌아가서, 입력으로 함수를 받는다는 의미를 생각해보자.

def 콜백받는함수 (콜백함수) :
	콜백함수()
	return None

콜백받는함수(제곱)

입력인자로 함수를 받는다는 것은 함수의 인스턴스를 받는다는 것이고, ()를 붙임으로서 해당 인스턴스를 실행할 수 있다는 말이다.

다시 실행시켜서 위 에러가 발생한 이유를 생각해 보자.

"함수: 제곱"은 "인자: 입력"을 받아야지 실행하는 함수다.

즉, 입력을 주어주지 않았기 때문에 위 에러가 발생한 것이다.

 

그렇다면 어떻게 콜백함수를 통해 입력된 "함수: 제곱"을 실행시킬 수 있을까?

콜백받는함수를 조금 수정해서 외부에서 입력 값을 전달하거나 내부적으로 값을 생성해서 입력을 전달하면 된다.

## 외부에서 인자를 입력하는 경우
def 콜백받는함수 (콜백함수, 입력) :
	결과 = 콜백함수(입력)
	return 결과

입력 = 3
결과 = 콜백받는함수(제곱, 입력)
print(결과)

## 내부에서 인자를 입력하는 경우
def 콜백받는함수 (콜백함수) :
    결과 = 0
    for 입력 in range(1,11) :
	    결과 += 콜백함수(입력)
    return 결과 ## 1의 제곱부터 10의 제곱까지의 합

결과 = 콜백받는함수(제곱)
print(결과)

이렇게 위와 같이 실행시킬 수 있다.


여기까지 정리하면

  1. 콜백함수는 어떤 함수에 인자로 들어가는 함수이다.
  2. 함수를 생성하면 함수명과 똑같은 변수에 해당 함수 인스턴스가 저장된다.
  3. 함수의 인스턴스가 저장된 변수에 ()를 붙이면 해당 함수를 call(실행/호출)한다.

데코레이터

이제 다시 데코레이터로 돌아가서 아래 함수를 보면,

def 데코레이터 (함수) :
	def 감싸는함수 () :
		결과 = 함수()
		return 결과
	return 감싸는함수

def 제곱 (입력) :
	결과 = 입력**2
	return 결과


입력 = 3
결과 = 제곱(입력)
print(결과)

데코레이터 함수는 위에서 만든 콜백함수를 인자로 받는 콜백받는함수와 똑같이 동작한다는 것을 알 수 있다.

다만, return으로 감싸는 함수를 반환하는데, 이게 어떤 의미가 있는지 알아보자.

제곱 = 데코레이터(제곱)
제곱

"변수: 제곱""변수: 데코레이터"에 넣고 결과를 "변수: 제곱"에 넣는다.

그러면 "변수: 제곱"에는 "함수: 데코레이터" 내부에 있는 "함수: 감싸는함수"의 인스턴스가 return되어 저장된다.

즉, 기존의 "함수: 제곱"의 인스턴스가 저장된 "변수: 제곱""함수: 감싸는함수"의 인스턴스로 덮어씌우는 것이다.

이제 제곱()을 하면 "함수: 감싸는함수"가 실행된다.

 

한번 실행시켜 보자.

감싸는 함수 내부에 콜백함수로 전달된 제곱함수는 인자로 입력을 필요로 한다는 것을 알 수 있다.

 

코드를 약간 수정하여 내부에 인자를 전달해 보자.

def 데코레이터 (함수) :
	def 감싸는함수 (입력 = 3) :
		print("감싸는 함수 시작 지점")
		결과 = 함수(입력)
		print("감싸는 함수 끝나는 지점")
		return 결과
	return 감싸는함수

def 제곱 (입력) :
	결과 = 입력**2
	return 결과

제곱 = 데코레이터(제곱)
제곱()

위와 같이 감싸는함수를 실행시키면 내부에 저장된 제곱 함수 인스턴스가 동작하게 된다.

데코레이터 함수는 어떤 함수를 실행시킬 때, 앞 뒤로 어떤 작업을 해주기 위해 사용하는 함수이다.

 

파이썬에서는 위와 같은 작업을 쉽게 해주기 위해 "@데코레이터"를 지원한다.

이를 함수 앞에 적어주면, 해당 함수명을 가진 변수는 데코레이터 내부의 감싸는함수 인스턴스를 저장하게 된다.

이때, 감싸는함수는 콜백함수를 실행하도록 설계되어 있다.

def 데코레이터 (함수) :
	def 감싸는함수 (입력 = 3) :
		print("감싸는 함수 시작 지점")
		결과 = 함수(입력)
		print("감싸는 함수 끝나는 지점")
		return 결과
	return 감싸는함수

@데코레이터
def 제곱 (입력) :
	결과 = 입력**2
	return 결과

제곱()

@데코레이터
def 나누기2 (입력) :
	결과 = 입력/2
	return 결과

나누기2()

위와 같이 어떤 함수가 들어오든 데코레이터를 사용할 수 있다.

 

만약 콜백함수의 인자의 개수가 다르다면 어떻게 할까?

이 경우 *args, **kwargs 를 감싸는함수의 인자로 사용하면 된다. (자세한 내용은 다음 포스트에)

def 데코레이터 (함수) :
	def 감싸는함수 (*args, **kwargs) :
		print("감싸는 함수 시작 지점")
		결과 = 함수(*args, **kwargs)
		print("감싸는 함수 끝나는 지점")
		return 결과
	return 감싸는함수

@데코레이터
def 제곱 (입력) :
	결과 = 입력**2
	return 결과

제곱(3)

@데코레이터
def 곱하기 (입력1, 입력2) :
	결과 = 입력1 * 입력2
	return 결과

곱하기(4,7)

@데코레이터
def 더하기 (입력1, 입력2, 입력3) :
    결과 = 입력1 + 입력2 + 입력3
    return 결과

더하기(5,7,10)


활용

데코레이터를 어떻게 활용할 수 있을까?

아직까지 활용에 대해서는 공부가 부족하다.

 

우선 가장 먼저 생각할 수 있는건 함수의 실행 속도를 매번 확인해주는 함수를 만드는 것이다.

import datetime
import time

def 타이머 (콜백함수) :
    def 감싸는함수 (*args, **kwargs) :
        시작 = datetime.datetime.now()
        결과 = 콜백함수(*args, **kwargs)
        time.sleep(0.001)
        끝 = datetime.datetime.now()
        걸린시간 = 끝-시작
        print(f"{콜백함수.__name__} 함수를 실행시키는데 걸린 시간은 {걸린시간} 입니다.")
        return 결과
    return 감싸는함수

@타이머
def 제곱 (입력) :
	결과 = 입력**2
	return 결과

@타이머
def 곱하기 (입력1, 입력2) :
	결과 = 입력1 * 입력2
	return 결과

@타이머
def 더하기 (입력1, 입력2, 입력3) :
    결과 = 입력1 + 입력2 + 입력3
    return 결과

def main () :
    제곱(3)
    곱하기(4,7)
    더하기(5,7,10)

if __name__ == "__main__" :
    main()

중간에 time.sleep(0.001) 을 추가한 것은, 함수 실행속도가 너무 빨라서 0.001초를 더해 값을 확인하기 위함이다.

이처럼 @타이머 데코레이터를 사용하면 어떤 함수를 사용하던 실제 걸리는 시간에 +0.001초가 더해져서 나오게 된다.

 

다음은 데코레이터 함수를 통해 캐시메모리를 활용하는 것이다.

import datetime

def 타이머 (콜백함수) :
    def 감싸는함수 (*args, **kwargs) :
        시작 = datetime.datetime.now()
        결과 = 콜백함수(*args, **kwargs)
        끝 = datetime.datetime.now()
        걸린시간 = 끝-시작
        print(f"{콜백함수.__name__} 함수를 실행시키는데 걸린 시간은 {걸린시간} 입니다.")
        return 결과
    return 감싸는함수

def cacheable(func):
    cache = {}
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

def 재귀함수 (a, b) :
    if (a == 0) & (b==0) : return 0
    if a == 0 : 
        결과 = 재귀함수(a, b-1) + 1
        return 결과
    if b == 0 : 
        결과 = 1 + 재귀함수(a-1, b)
        return 결과
    결과 = 재귀함수(a,b-1)+재귀함수(a-1,b)
    return 결과

@타이머
def main() :
    print(재귀함수 (12,12))

if __name__ == "__main__" :
    main()

위 코드의 재귀함수는 cacheable 이라는 데코레이션 함수를 적용하기 전이다.

이 경우 결과는 아래와 같다.

대략적으로 2.2초가 걸린다.

 

재귀함수에 cacheable 데코레이션 함수를 적용한 후 결과를 보자.

import datetime

def 타이머 (콜백함수) :
    def 감싸는함수 (*args, **kwargs) :
        시작 = datetime.datetime.now()
        결과 = 콜백함수(*args, **kwargs)
        끝 = datetime.datetime.now()
        걸린시간 = 끝-시작
        print(f"{콜백함수.__name__} 함수를 실행시키는데 걸린 시간은 {걸린시간} 입니다.")
        return 결과
    return 감싸는함수

def cacheable(func):
    cache = {}
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@cacheable
def 재귀함수 (a, b) :
    if (a == 0) & (b==0) : return 0
    if a == 0 : 
        결과 = 재귀함수(a, b-1) + 1
        return 결과
    if b == 0 : 
        결과 = 1 + 재귀함수(a-1, b)
        return 결과
    결과 = 재귀함수(a,b-1)+재귀함수(a-1,b)
    return 결과

@타이머
def main() :
    print(재귀함수 (12,12))

if __name__ == "__main__" :
    main()

위와 같이 시간이 2.208487초 -> 0.001초로 엄청나게 줄어든 것을 확인할 수 있다.

이 cacheable 이라는 데코레이션은 내부에 cashe라는 사전 자료형을 만들고 인자를 키로 하는 재귀함수의 결과를 저장해 둔다.

그리고 같은 인자를 가진 재귀함수를 호출하게 되면 미리 저장해둔 값을 사전에서 꺼내어 전달하면서 재귀함수의 반복횟수를 획기적으로 줄여준다.

 

예를 들면

재귀함수(10,10)라는 함수를 실행시키면

{(10,10):결과}

라고 cache에 저장한다.

 

마찬가지로 재귀함수(10,9), 재귀함수(9,10) 같은 값을

{(10,10) : 재귀함수(10,10)의 결과, (10,9) : 재귀함수(10,9)의 결과, (9,10) : 재귀함수(9,10)의 결과}

이런식으로 저장한다.

 

이미 계산한 결과를 이렇게 캐시에 저장해 두면 O(1) 시간에 해당 결과를 불러올 수 있다.

대신 사전이 메모리를 차지하기 때문에 기본 계산보다 더 많은 메모리를 사용하게 된다.

이는 메모리와 속도의 trade-off 관계로 보면 될 것 같다.

'파이썬' 카테고리의 다른 글

type - Class에 대한 이해 1(Python)  (0) 2023.11.25
인터페이스 (interface) in python  (0) 2021.11.13
추상클래스 (Abstract class) in python  (0) 2021.11.13