콘텐츠로 건너뛰기
Home » 파이썬의 데코레이터와 고차함수

파이썬의 데코레이터와 고차함수

데코레이터(decorator)는 말 그대로 어떤 객체에 대해서 부가적인 기능을 덧붙이는 것을 말한다. 어떤 클래스에 대해서 기능을 추가하려 할 때에 보통 그 클래스를 서브 클래싱하는데, 파이썬에서 데코레이터는 클래스가 아닌 임의의 함수에 대해서 기능을 추가한다. 언뜻 보기에 별 의미 없을 것 같은 이러한 패턴은 특정한 맥락에서는 코드를 간결하게 만들면서 문법적으로 깔끔하게 정리되는 코드를 만들 수 있는 장점이 있다. 오늘은 파이썬의 데코레이터는 어떻게 만들어지며, 어떤 상황에서 쓰일 수 있는지 알아보도록 하자.

일급 객체와 고차함수

파이썬에서는 모든 것이 객체이다

파이썬에서는 모든 것이 객체로 이루어져 있다. 함수라고 예외가 아니라서 파이썬에 함수도 객체로 취급된다.  프로그래밍 언어에서 어떠한 객체는 일반적으로 다음의 연산에 사용될 수 있다고 본다.

  1. 다른 변수에 대해 대입이나 바인딩이 가능하다.
  2. 어떤 함수에 전달되는 인자로 사용이 가능하다.
  3. 함수의 리턴값으로 사용이 가능하다.

이러한 세 조건을 모두 만족하는 객체를 1급 객체(first class citizen,  일급 시민/일급 엔티티라고도 한다)라고 부른다. 파이썬에서 “모든 것이 객체다”라고 말할 때, 이 객체는 사실상 1급 객체에 해당하며 함수 역시 파이썬에서는 1급 객체이며 위의 정의된 조건을 모두 만족한다.

바인딩

어떤 함수가 정의되어 있을 때, 그 함수의 이름은 실질적인 함수 객체에 대한 바인딩이며 다른 모든 객체와 마찬가지로 함수 타입 객체는 다른 이름을 추가적으로 바인딩하는 것이 가능하다.  따라서 아래와 같이 def를 사용하여 선언한 함수는 다른 변수에 대입하는 표현을 통해서 다른 이름을 부여하는 것이 가능하며, 이는 1급 객체의 첫번째 조건인 “다른 변수에 대입이 가능하다”를 만족한다.

def increase_by_one(x):
  return x + 1
add_one = increase_by_one
print(add_one(2))
# 3

익명함수라 할 수 있는 람다식 표현 역시 동일하다. 람다식은 단일 표현식으로 구성되는 함수를 특정한 이름에 고정하지 않고 사용하며, 따라서 해당 람다식이 들어간 컨텍스트 내에서만 함수 객체의 실체가 유지된다. 대신에 이름을 부여하는 경우에는 람다식을 계속 유지해서 쓸 수 있다.

double = lamdba x: x  * 2
print(double(3))
# 6

함수를 인자로 받는 함수

기본 내장 함수 중에서 map()filter()는 어떠한 연속열을 주어진 함수를 통해서 변형하는 동작을 수행한다. 따라서 이 함수들은 특정한 함수를 인자로 받아서, 연속열의 모든 원소에 대해 그 함수를 적용해보게 된다. mapfilter는 (결국 리스트 축약 리터럴로 대체하는게 좋다고 배우게 되지만) 대표적인 함수를 인자로 받는 함수이다.
이 케이스에서도 앞에서와 마찬가지로 이름을 가지고 정의된 함수나 람다식 모두를 수용할 수 있다.1

one_to_ten = list(map(increase_by_one, range(10))
print(one_to_ten)
# [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

함수를 리턴하는 함수

함수를 리턴하는 함수는 사실 흔하게 접할 수 있는 패턴이 아닐 수 있다.  따라서 실용적이기 보다는 개념적인 예에서 출발해보자.
앞에서 정의했던 increase_by_one 함수를 다시 들여다 보자. 이 함수는 특정한 값을 받아서 여기에 1을 더한 값을 리턴해주는 단순한 함수이다. 그렇다면 이 함수의 보다 추상화된 버전을 생각해보자. 바로 1이 아닌 어떤 값이라도 받아서 더해주는 함수를 생각할 수 있다. 그리고 이건 그냥 더하기를 구현한 함수에 지나지 않을 수 있다.

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

그래서 이 더하기 함수를 위와 같이 구현했다. 그리고 이 함수를 다시 호출하는 방식으로 increase_by_one 함수를 작성한다고 하면 아래와 같이 구현할 것이다.

def increase_by_one(x):
    return increase(x, 1)

어떤 값에 1을 더할 것이기 때문에 x + 1 을 더하는 대신에 increase 함수를 이용해서 두 값을 더해주었을 뿐이다.  그렇다면 이 increase_by_one 외에도 increase_by_two, increase_by_three 등도 여기의 상수값을 변경해서 추가적으로 정의할 수 있을 것이다.

여기서 잠깐

여기서 절대로 헷갈리지 말아야 할 부분이 있다. return increase(x, 1) 이 함수를 리턴한다고 생각하면 안된다.  increase 라는 이름 자체는 함수 객체를 가리키고 있지만, 이에 대한 호출 리터럴인 increase(x, 1) 은 함수 increase에 x와 1을 전달한 후에 받는 리턴값을 말한다. 따라서 이 표현은 함수가 아닌 함수를 호출한 결과를 의미하는 것이다.

하지만 내부 구조가 거의 비슷한 함수라면 매번 소스 코드 상에서 def 문을 이용해서 정의하는 것 외에 런타임에 동적으로 함수를 만들 수 있다. 이 때 사용하는 테크닉이 바로 함수를 리턴하는 함수이다. 여기에는 다음 두 가지의 파이썬 언어 기능이 활용된다.

  1. def 문 내에서 다시 def 문을 써서 해당 함수 내에서만 참조하는 내부 함수를 정의할 수 있다.
  2. 1에서 만든 함수는 함수의 내부 객체이며, return 문을 통해서 함수 외부로 전달될 수 있다.

따라서 다음과 같은 식의 함수를 생각해 볼 수 있다는 것이다.

def increase_by(n):
  def function_without_name(x):
    return increase(x, n)
  return functino_without_name

이 함수의 정의 부분은 다음과 같이 설명된다.

  1. increase_by() 함수는 주어진 파라미터 값 만큼 증가시켜주는 함수를 생성하는 함수이다.
  2. 함수 내부에는 이름이 중요하지 않은 function_without_name()이라는 함수가 있다. 이 함수는 미리 정해진 n과 매번 실행할 때마다 달라지는 x를 더해서 리턴한다.
  3. 그리고 increase_byfunction_without_name 함수 객체 자체를 리턴한다.

그리고 이 함수는 다음과 같이 활용할 수 있다.

increase_by_one = increase_by(1)
# lambda x: increase(x, 1) 과 동일한 함수를 리턴했다.
increase_by_two = increase_by(2)
increase_by_three = increase_by(3)
increase_by_one(10) # -> 11
increase_by_two(100) # -> 102
increase_by_three(1000) # -> 1003

함수 자체를 인자로 받고, 리턴하는 패턴이 예전에는 매우 낯선것으로 취급받았으나, 함수형 언어의 여러 패턴들이 광범위하게 다른 타입의 언어들에게 퍼지게 되고 특히 자바 스크립트가 비동기식으로 처리하는 코드를 사용하면서 ‘콜백’이라는 이름으로 함수 간에 함수를 주고 받는 패턴이 조금씩 대중화 되기 시작했다.
함수를 리턴하는 함수의 개념이 받아들여질 법 하다면, 이제 데코레이터에 대해서 계속 이야기해볼만 하겠다.

데코레이터

 

시간을 측정하는 함수

어떤 함수를 실행하고, 그 실행에 걸린 시간을 출력하는 방법을 생각해보자. 가장 간단한 방법은 작업을 시작할 때와 끝날 때에 각각 time.time()을 호출하고, 그 두 시간 값의 차이를 구하는 것이다.
여기에는 시간이 제법 많이 걸리는 함수가 하나 필요하다. 소수를 판별하는 함수를 매우 비효율적으로 동작하게끔 하나 작성해보자.

def is_prime(n):
  if n < 2:
    return False
  for k in range(2, n):
    if n % k == 0:
      return False
  return True

이 코드를 시간을 출력하도록 수정하려면 다음과 같이 좀 귀찮게 변경해야 한다.

import time
def is_prime_with_duration(n):
  a = time.time()
  result = True
  if n < 2:
    result = False
  else:
    for k in range(2, n):
      if n % k == 0:
        result = False
        break
  b = time.time()
  print(b - a)
  return result
isPrime_with_duration(9973)
## 0.0009996891021728516

걸린 시간을 측정하는데 있어서 기존 함수 코드를 수정해야 하는 불편함을 없애기 위해서는 시간을 측정하는 함수를 하나 만들면 된다.  아래 함수는 함수 하나를 받아서, 이를 호출하고 그에 걸린 시간을 위의 방법으로 측정한다.

def check_time(f):
  a = time.time()
  result = f()
  b = t ime.time()
  print(b - a)
  return result

인자 개수의 제한을 없애는 방법

그런데 문제가 있다. 우리가 전달하고자 하는 is_prime 함수는 인자를 하나 받아야 한다. 그런데 위의 코드에서 f는 인자가 없는 함수이다. 인자 개수 패턴이 맞지 않는 문제는 functools.partial을 이용하면 되긴 하는데, 여기서는 f를 보다 범용적으로 쓸 수 있도록 다음과 같이 함수를 다시 수정한다.

def check_time(f, *args, **kwds):
  a = time.time()
  result = f(*args, **kwds)
  b = time.time()
  print(b - a)
  return result
check_time(is_prime, 9973)
# 0.0009982585906982422
# -> True

인자에 *args, **kwds 를 쓰는 것은 뒤에 추가적인 인자와 레이블이 붙은 키워드인자를 더 받을 수 있다는 의미이며, 이들은 함수 내에서 튜플과 사전으로 기능한다. 그리고 이들은 다시 *args, **kwds를 통해서 desctruct 되어 f로 들아가게 된다.
이제 함수의 실행 시간을 측정할 수 있는 방법이 생겼다. 그런데 어떤 함수들은 항상 수행 시간을 알아야하기 때문에, check_time(is_prime, 9973)과 같은 형식으로 써야하는 것은 조금 불편할 것이다.
그래서 사용시의 편의를 위해서 이런식으로 코드를 약간 꼬는 것도 생각해 볼 수 있다.

def is_prime_behind(n):
  if n < 2:
    ....
    # 소수 판별 코드를 쓴다.
is_prime = lambda x: check_time(is_prime_behind, x)

하지만 이렇게 함수를 이중으로 만드는 것은 더 이상해 보인다.  그렇다면 관점을 바꿔서 check_time 함수가 특정 함수를 받아 실행하고 실행시간을 출력하는 함수가 아니라, 바로 위처럼 시간을 수행해주는 기능으로 그 함수를 감싼 익명함수를 리턴하는 함수라면 어떨까?

def check_time(f):
  def timed_func(*args, **kwds):
    a = time.time()
    result = f(*args, **kwds)
    b = time.time()
    print(b - a)
    return result
  return timed_func

이 코드의 구성은 다음과 같다.  먼저 기존의 check_time이 하던 일은 내부의 timed_func라는 함수로 이동했다. 그리고 timed_func는 함수를 인자로 받는 것이 아니라, 함수 f 가 받아야 할 인자를 받아서 시간을 측정하고 함수 f를 해당 인자에 대해서 실행한다.
이제 이를 사용하는 법을 보자.  여기서 언급되는 is_prime 은 맨 처음에 작성한 순수한 (그리고 느린) 소수 판별 함수이다.

## 1)
is_prime(9973)
# True
## 2)
is_prime = check_time(is_prime)
## 3)
is_prime(9973)
# 0.003000020980834961
# True

이 부분을 잘 살펴보자. 먼저 첫번째 코드에서 is_prime 을 호출했고, 그 결과는 참이냐 거짓이냐의 값만 리턴된다.
두번째 코드에서는 check_timeis_prime을 전달하고, 그 결과로 생성된 함수를 다시 is_prime이라는 이름에 바인딩한다. 이 시점에서 is_prime이라는 이름이 가리키는 함수 객체가 바뀌었다.  그리고 새롭게 바뀐 is_prime 을 사용해보면 결과를 리턴하기에 앞서서 수행 시간을 한 번 프린트해준다.

함수를 인자로 받고 함수를 리턴하는 함수

여기서 check_time 함수가 하는 일을 잘 살펴보자. 이것은 매우 주의깊게 설계된 함정과 비슷하다. 함수를 인자로 받고, 다시 그 함수를 캡쳐하여 자신이 받은 인자와 결합시켜 실행해주는 내부 함수를 하나 정의한 후, 그 내부 함수 객체를 리턴한다.
그리고 이 기묘함은 원래의 함수 이름에 이렇게 조작된 함수를 다시 연결해줌으로써 완성된다. 그리하여 원래의 함수는 숨겨지고 자신의 수행시간을 출력해주는 함수가 되었다.
여기서 사용된 두 가지 트릭, 1) 기묘하게 생긴 check_time 이라는 함수와 2) 같은 이름에서의 함수를 바꿔치기는 익숙해진다면 매우 유용하게 사용될 수 있고, 파이썬에는 이 트릭을 데코레이터라는 문법으로 정의한다.

데코레이터

데코레이터는 바로 앞 장에서 정의한 “함수를 인자로 받고 함수를 리턴하는” 형식의 check_time 과 같은 함수를 작성하고, 이를 @check_time 과 같은 꼴로 써서 번거로워보이는 is_prime = check_time(is_prime) 행을 자동으로 처리해주는 일종의 문법적 장식이라 볼 수 있다.
즉 앞 장의 예는 결국 다음과 같이 사용할 수 있다는 것이다.  (헷갈릴까봐 새로 정의한다.)

"""데코레이터 함수를 정의한다
입력 - f: 데코레이팅할 원래 함수
출력 - g: f를 실행하는 코드 앞/뒤로 부가적인 액션이 붙은 함수
"""
def check_time(f):
 def g(*a, **k):
 x = time.time()
 r = f(*a, **k)
 y = time.time()
 print(y - x)
 return r
 return g
@check_time
def is_prime(n):
 if n < 2:
 return False
 for k in range(2, n):
 if n % k == 0:
 return False
 return True
is_prime(9973)
# 0.003000020980834961
# True

데코레이터가 활용되는 곳들

Flask

flask는 URL별 요청 핸들러를 개별 함수로 정의하는데, 이 때 프레임워크 내장 데코레이터를 이용하여 함수를 작성하는 것만으로도 서버 앱에 자동으로 연결되게 한다.

from app import app # app은 flask 앱 인스턴스이다.
from flask import render_template
@app.route('/')
def index():
  return render_template('index.html')

python 3.4에서 비동기 코루틴을 정의하기
파이썬3.4까지는 async 구문이 따로 정의되지 않았고, asyncio.coroutine 을 이용해서 비동기 코루틴을 생성했다. 이 역시 데코레이터로 만들어진다.

import asyncio
@asyncio.coroutine
def greet():
  asyncio.sleep(1)
  print("hello world")

이처럼 데코레이터는 함수를 정의만 하고, 특정한 공통된 전/후 처리를 데코레이터 내에 숨기는 것으로 반복적으로 작성해야 하는 많은 코드를 줄여 줄 수 있어서, 사실 알고 보면 꽤나 유용하게 (그리고 재미있게) 사용할 수 있는 테크닉이다.

더 알아보기

아래의 토픽들은 본 글에서 소개된 내용에서 추가적으로 파고드는 부분인데, 관련해서 구글링이나 파이썬 표준 문서를 체크해보는 것도 좋겠다. 관련 내용에 대해서 추가 포스트를 발행하게되면 관련글 링크를 넣을 예정이다.

  • 변수와 바인딩
  • 파라미터에 별표와 이중별표를 쓰는 이유
  • wrap 과 partial 함수 – functools 모듈

보너스 – 클래스를 사용한 데코레이터 만들기

데코레이터를 사용하는데 있어서 함수만 사용할 수 있는게 아니라 클래스를 사용할 수도 있다. 파이썬에서는 클래스 역시 객체이며, 모든 객체들은 __call__ 이라는 메소드를 가지고 있다면, 그 자체가 callable 한 것으로 간주된다. 따라서 다음과 같이 클래스를 작성하면, 데코레이터 함수와 동일한 방식으로 데코레이터로 쓸 수 있다.

  1. 이니셜라이저는 하나의 함수 타입 인자를 받는다.
  2. __call__ 메소드를 정의한다. 이 메소드는 생성시 인자로 받은 함수를 실행하면서 앞/뒤로 특정한 동작을 수행할 수 있다. 데코레이터 함수와 달리 변형된 함수를 리턴할 필요가 없다.
import time
class TimeChecker:
  """함수 실행 시간을 출력하는 데코레이터 클래스"""
  def __init__(self, f):
    self.f = f
  def __call__(self, *args, **kwds):
    a = time.time()
    result = self.f(*args, **kwds)
    b = time.time()
    print(b-a)
    return result
@TimeChecker
def is_prime(n):
  if n < 2:
    ....

  1. 람다식을 변수에 바인딩한 경우는 사실상 def 를 통해서 정의한 함수와 아무런 차이가 없다. 결국 메모리 어딘가에 위치하고 있는 실제 계산을 수행하는 함수 객체와 그 함수 객체를 가리키는 이름의 조합이 되는 것이다.