개요
이전에 api 요청 부담을 줄이기 위한 캐시 구현에 관한 글을 썼다. 원래 함수를 해치지 않고도 효율적으로 캐시를 구현하면서 외부에 저장도 할 수 있는 방법이었다.
그런데, Youtube API 관련한 작업을 하면서 이 캐시를 적용했을 때 오류가 발생했다.
원인은 리스폰스의 유효기한 때문이었다.
분석
streamingData{
expiresInSeconds: 21540,
formats: [
{
itag: 17
url: "https://rr3---sn-3u-20nr.googlevideo.com/videoplayback?expire=1641911009..."
},
]
}
대충 이런 식으로 expiresInSeconds
는 요청 시각 기준 남은 시간이, url 안에는 쿼리스트링으로 expire값에 만료 일자가 적혀 있었다. 일단 쿼리스트링을 분석하는 것보단 expiresInSeconds
를 참조하는 게 더 쉬우므로 저 값을 이용해 만료일자를 계산하면 된다.
즉,
import time
expire = time.time() + int(data['streamingData']['expiresInSeconds'])-30
같은 식이다. 30초는 매너상 빼 줬다.
고민
생각해봐야 할 게 있다. 원함수의 리턴값으로부터 expire를 계산하는 식 은 함수에 의존하므로 cache
데코레이터를 정의하는 부분에 들어가면 안 된다. Nested Function
으로 정의하는 건 어떨까? 예를 들면,
#원함수
@cached
def getInfo():
def expire(raw):
return raw['expireTime']
return {'expireTime':100}
이고,
#캐싱
def cached(func):
func.cache = {}
@wraps(func)
def wrapper(*args):
tmp = func.cache.get(args)
if tmp != None and tmp[0]>time.time():
print("유효한 캐시가 존재합니다.")
return tmp[1]
else:
print("캐시 정보를 불러오지 못했습니다. api를 요청합니다.")
ret = func(*args)
expire = func.expire(ret) #Here!
func.cache[args] = (expire, ret)
saveCache(func)
return ret
return wrapper
같은 식이다. (가독성을 위해 캐시를 외부에 저장하는 부분은 뺐다)
그러나 중첩 함수를 이와 같은 방식으로 사용할 수 없다는 사실을 깨닫고 큰 좌절에 빠졌다.
#원함수
@cached
def getInfo():
def expire(raw):
return raw['expireTime']
getInfo.expire = expire #내부 함수를 스태틱 변수와 연결하는 코드
return {'expireTime':100}
getInfo() #1회 호출을 통해 실제로 연결
같은 식으로 가능하긴 하지만…편법에 불과하고 또 매우 지저분하다.
해결
결국 생각해낸 것은 데코레이터 인자로 람다 함수를 전달 하는 방법이었다. 만료 기한을 계산하는 함수가 람다 함수로 표현되어야 한다는 조건이 붙는다. 자바스크립트가 생각나는 대목이다.
백 마디 설명보다 결과 코드를 보자.
#원함수
@cached(lambda raw: time.time() + int(raw['streamingData']['expiresInSeconds'])-30)
def getInfo():
pass
def cached(expireFunc): #인자를 전달하기 위해 한 번 더 감싼다
def decorator(func):
func.cache = {}
@wraps(func)
def wrapper(*args):
tmp = func.cache.get(args)
if tmp != None and tmp[0]>time.time():
print("유효한 캐시가 존재합니다.")
return tmp[1]
else:
print("캐시 정보를 불러오지 못했습니다. api를 요청합니다.")
ret = func(*args)
expire = expireFunc(ret) #Here!
func.cache[args] = (expire, ret)
return ret
return wrapper
return decorator
좀 난해할 수도 있겠다. 데코레이터 부분을 이렇게 분리해서 생각해 보면 쉽게 이해할 수 있다.
#원함수
decorator = cached(lambda raw: time.time() + int(raw['streamingData']['expiresInSeconds'])-30)
@decorator
def getInfo():
pass
람다 함수가 getInfo()
위에 있는 바로 저 decorator
함수에만 귀속되어 있는 느낌이다. 짱 신기하다.
개선
생각해 보니 캐시를 저장할 때 만료 기간을 함께 저장하지 않아도 될 것 같다. 괜히 복잡하게 짰네.
#캐싱
def cached(expireFunc):
def decorator(func):
func.cache = loadCache(func)
@wraps(func)
def wrapper(*args):
tmp = func.cache.get(args)
if tmp != None and expireFunc(tmp)>time.time(): #유효기간은 여기서 바로 구해서 비교
print("유효한 캐시가 존재합니다.")
return tmp
else:
print("캐시 정보가 존재하지 않거나 유효하지 않습니다. api를 요청합니다.")
func.cache[args] = ret = func(*args)
saveCache(func)
return ret #원함수 리턴값 그대로 저장
return wrapper
return decorator
이쪽이 훨씬 단순하자. 진짜 왜 저렇게 했었을까.
여담
아주 머리 아픈 문제였다. 과정에 비해 결과는 놀라울 정도로 아름답다. 짱 기쁘다. 아이 좋아.
참고로 클래스 내부 함수에 사용할 때 self
를 캐시 키값으로 저장하면 큰일 난다. 이때는
#캐싱
def cached(expireFunc):
def decorator(func):
func.cache = loadCache(func)
@wraps(func)
def wrapper(self, *args): #self는 따로
tmp = func.cache.get(args)
if tmp != None and expireFunc(tmp)>time.time():
print("유효한 캐시가 존재합니다.")
return tmp
else:
print("캐시 정보가 존재하지 않거나 유효하지 않습니다. api를 요청합니다.")
func.cache[args] = ret = func(self, *args) #self는 따로
saveCache(func)
return ret
return wrapper
return decorator
이렇게 *args
로부터 self
를 분리해 주면 된다. 실제로 사용할 땐 이렇게 했다. 한큐에 해결하는 방법은 아직 못 찾았다.
여담 2
나중에 클래스 데코레이터를 이용해서 구현하는 방법도 생각해 봐야겠다. 다들 잘 자요.
그럼 다음에,
Gabriel-Dropout
at 17:23
