Logo

파이썬에서 캐시 적용하기 (@cache, @lru_cache)

하드웨어와 소프트웨어를 불문하고 Caching(캐싱)은 정말 광범위하게 다양한 형태로 활용되고 있는 성능 최적화 기법입니다. 이번 포스팅에서는 파이썬으로 프로그래밍할 때는 어떻게 캐시를 적용할 수 있는지 알아보도록 하겠습니다.

캐싱이란?

먼저 프로그래밍 입문자 분들을 위해서 캐싱에 대해서 간단히 개념은 짚고 넘어가는 게 좋을 것 같습니다.

일반적으로 캐싱은 접근하는데 시간이 오래 걸리는 데이터를 접근 속도가 빠른 저장소에 사본을 저장해두고 재사용하거나, 실행하는데 오래 걸리는 연산의 결과를 미리 계산해놓고 최초로 필요할 때 한번만 계산하여 저장해놓고 재사용하는 기법을 의미합니다.

예를 들어, 대부분의 웹브라우저는 클라이언트 컴퓨터에 캐시를 두고 있는데요. 이 캐시에 이 전에 방문했던 웹페이지의 내용을 저장해놓고 동일한 페이지를 재방문 시 이 저장해놓은 사본의 페이지를 보여주는 경우가 많습니다. 이렇게 함으로써 불필요한 HTTP 통신을 줄이고, 좀 더 기민한 웹 브라우징 경험을 제공할 수 있는 것이지요.

캐싱은 서버 단에서도 성능 최적화를 위한 핵심 도구로 사용되고 있습니다. 예를 들어, 클라이언트로 부터 받은 요청에 대한 처리 결과를 캐시에 저장두고, 나중에 동일한 요청이 들어왔을 때 저장해둔 결과를 그대로 응답하는 것은 매우 흔한 서버 단의 캐싱 패턴입니다.

뿐만 아니라 캐싱은 데이터베이스와 같은 핵심적인 서버 자원을 과부하로 부터 보호하기 위해서도 사용할 수 있습니다. 애플리케이션에서 데이터베이스로 부터 불러온 데이터를 캐시에 저장해놓고 재사용해준다면 중복 쿼리가 줄어 데이터베이스 입장에서 동시에 처리해야하는 부담이 현저히 줄어들 것입니다.

하드웨어 쪽에서는 캐싱이 고성능 저장 매체와 저성능 저장 매체 사이의 속도 차이로 인한 성능 손실을 최소화 하기 위해서 많이 사용됩니다. 대표적인 예로, CPU와 RAM 사이에 있는 CPU 캐시를 들 수 있는데요. 하드 디스크(HDD, SSD)의 일부 용량을 마치 메모리처럼 사용하는 가상 메모리 전략도 비슷한 맥략으로 볼 수가 있겠습니다.

네트워크 쪽에서는 프록시(Proxy) 서버나 CDN(Content Delivery Network)을 대표적인 캐싱 사례로 들 수 있겠네요. 유저와 최대한 가까운 CDN 노드(node)에 이미지나 비디오 같이 고용량 데이터의 사본을 저장해놓으면, 굳이 지리적으로 멀리 있는 서버로 부터 원본 데이터를 다운로드를 받을 필요가 없을 것입니다.

메모이제이션

가장 원초적인 형태의 캐싱으로 소위 메모이제이션(memoization)이라고 일컽는 저장 공간에 별도의 상한선을 두지 않는 캐싱 방법을 생각할 수 있습니다. 메모이제이션은 특히 코딩 테스트에서 재귀 알고리즘의 성능을 최적화하기 위해서 자주 사용되곤 합니다.

메모이제이션을 구현할 때는 일반적으로 해시 테이블 자료구조를 사용하여 함수의 첫번째 호출 결과를 저장해놓고 두번째 호출부터는 기존에 저장된 결과를 재사용합니다.

그럼 간단한 예제 코드를 통해서 메모이제이션 기법이 어떤 느낌인지 살짝 맛만 볼까요? 😏

def fetch_user(user_id):
    print(f"DB에서 아이디가 {user_id}인 사용자 정보를 읽어오고 있습니다...")
    return {
        "userId": user_id,
        "email": f"{user_id}@test.com",
        "password": "test1234"
    }

def get_user(user_id):
    return fetch_user(user_id)

두 개의 함수를 작성하였습니다. 첫번째 fetch_user() 함수는 사용자 아이디를 인자로 받아 해당 아이디에 해당하는 사용자 정보를 데이터베이스에서 읽어오는 척(?)을 하는 함수이고, 두번째 get_user() 함수는 단순히 넘어온 인자를 그대로 첫번째 함수로 넘겨 호출한 결과를 반환하는 함수입니다.

그 다음, 이 get_user() 함수를 3개 사용자 아이디 중 하나를 랜덤하게 인자로 넘겨서 10회 호출해보겠습니다.

if __name__ == "__main__":
    from random import choice

    for _ in range(10):
        get_user(user_id = choice(["A01", "B02", "C03"]))

파이썬의 random 모듈로 무작위 데이터 다루는 방법에 대해서는 관련 포스팅을 참고 바랍니다.

그러면 아래와 같이 fetch_user() 함수도 동일하게 10회 되는 것을 볼 수 있습니다.

DB에서 아이디가 B02인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 A01인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 A01인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 C03인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 A01인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 C03인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 C03인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 B02인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 B02인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 A01인 사용자 정보를 읽어오고 있습니다...

콘솔에 출력된 내용을 보면 3명의 사용자의 정보를 3~4번 가상의(?) 데이터베이스를 통해서 불러오고 있는데요.

이러한 상황에서 메모이제이션을 활용하면 각 사용자에 대해서 딱 한번씩만 데이터베이스를 조회할 수 있는데요. 파이썬에서 메모이제이션은 사전(dictionary)이라는 내장 자료구조를 이용하면 어렵지 않게 구현할 수 있습니다.

cache = {}
def get_user(user_id):
    if user_id not in cache:        cache[user_id] = fetch_user(user_id)    return cache[user_id]

다시 동일한 방법으로 get_user() 함수를 10회 호출을 해보면, 이번에는 딱 3번만 데이터베이스에 접근하는 것을 알 수 있습니다.

if __name__ == "__main__":
    from random import choice

    for _ in range(10):
        get_user(user_id = choice(["A01", "B02", "C03"]))
DB에서 아이디가 B02인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 C03인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 A01인 사용자 정보를 읽어오고 있습니다...

@cache 데코레이터

메모이제이션을 직접 구현하는 것이 위에서 보여드릴 것처럼 그닥 어렵지는 않지만 파이썬의 @cache 데코레이터를 활용하면 더 깔끔하게 처리할 수 있습니다.

@cache라는 데코레이터는 파이썬에 내장된 functools 모듈로 부터 불러올 수 있으며 함수를 대상으로 사용합니다. @cache 데코레이터를 어떤 함수 위에 선언하면, 그 함수에 넘어온 인자를 키(key)로 그리고 함수의 호출 결과를 값(value)으로 메모이제이션이 적용됩니다.

예를 들어, 맨 처음에 작성했던 get_user() 함수에 @cache 데코레이터를 적용해보겠습니다.

from functools import cache
def fetch_user(user_id):
    print(f"DB에서 아이디가 {user_id}인 사용자 정보를 읽어오고 있습니다...")
    return {
        "userId": user_id,
        "email": f"{user_id}@test.com",
        "password": "test1234"
    }

@cachedef get_user(user_id):
    return fetch_user(user_id)

그리고 다시 get_user() 함수를 10회 호출을 해보면, 메모이제이션 효과로 데이터베이스에 3번만 다녀오는 것을 볼 수 있습니다.

if __name__ == "__main__":
    from random import choice

    for _ in range(10):
        get_user(user_id = choice(["A01", "B02", "C03"]))
DB에서 아이디가 A01인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 C03인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 B02인 사용자 정보를 읽어오고 있습니다...

단, @cache 데코레이터는 비교적 최신 버전인 파이썬 3.9에 추가가 된 기능이기 때문에 현재 사용 중이신 파이썬 버전에 따라 지원이 안 될 수도 있으므로 주의바랍니다.

해싱 전략

코딩 테스트가 아닌 실전 코딩에서는 메모이제이션을 사용할 수 있는 경우는 극히 제한적인데요. 일회성으로 실행되는 스크립트에서는 사용해봄직 하겠지만 상용 소프트웨어에서 무제한으로 늘어날 수 있는 캐시를 쓸 수 있는 경우는 많지 않을 것입니다.

일반적으로 캐싱을 위해 사용되는 저장 매체는 접근 속도가 빨라야 하므로 가격이 자연스럽게 비싸질 수 밖에 없는데요. 따라서 용량이 제한된 고가의 저장 매체를 최대한 효과적으로 사용하기 위해서 캐싱 전략에 대해서 생각을 해봐야합니다.

캐싱 전략이란 쉽게 말해 캐시 용량이 꽉 찼을 때 어떤 데이터는 케시에 남겨두고 어떤 데이터는 지워야할지에 대한 접근 방법을 뜻합니다.

많은 캐싱 전략이 있지만 그 중에서 가장 많이 알려진 것은 LRU(Least Recently Used)일 것입니다. LRU는 “최근에 사용된 데이터일수록 앞으로도 사용될 가능성이 높다”라는 가설을 바탕으로 고안된 캐싱 전략입니다. 따라서, LRU 캐싱 전략에서는 가장 오랫동안 사용되지 않은 데이터를 우선적으로 캐시에서 삭제하여 여유 공간을 확보합니다.

이와 정반대의 접근 방법을 택하고 있는 MRU(Most Recently Used) 또는 사용 빈도를 고려한 LFU(Least Frequently Used) 등 그 밖에도 다양한 캐싱 전략이 있지만 본 포스팅에서 다루고자 하는 수준을 벗어나는 내용이므로 넘어가겠습니다.

@lru_cache 데코레이터

LRU 캐시를 직접 구현해보신 분은 아시겠지만 사실 LRU 캐싱 전략을 사용하는 캐시를 직접 구현하는 것은 그리 만만한 일이 아닙니다. 다행히도 파이썬에서는 메모리 기반 LRU 캐시를 좀 더 손쉽게 사용할 수 있도록 @lru_cache라는 데코레이터를 제공해주고 있습니다. 😌

@lru_cache 데코레이터는 @cache와 마찬가지로 functools 내장 모듈로 부터 불러올 수 있습니다. @lru_cache 데코레이터를 어떤 함수 위에 선언하면 사용하면, 그 함수애 넘어온 인자를 키(key)로 그리고 함수의 호출 결과를 값(value)으로 LRU 캐싱이 적용됩니다.

예를 들어, 위에서 작성했던 get_user() 함수에 @lru_cache 데코레이터를 적용해보겠습니다.

from functools import lru_cache
def fetch_user(user_id):
    print(f"DB에서 아이디가 {user_id}인 사용자 정보를 읽어오고 있습니다...")
    return {
        "userId": user_id,
        "email": f"{user_id}@test.com",
        "password": "test1234"
    }

@lru_cachedef get_user(user_id):
    return fetch_user(user_id)

이제 다시 동일한 방법으로 get_user() 함수를 10회 호출을 해보겠습니다. 추가로 @lru_cache 데코레이터에서 제공하는 cache_info() 함수도 호출하여 캐시에 관련 정보를 확인해보겠습니다.

if __name__ == "__main__":
    from random import choice

    for _ in range(10):
        get_user(user_id = choice(["A01", "B02", "C03"]))

    print(get_user.cache_info())

역시 기대했던대로 데이터베이스에 접근이 3회만 일어났고요. 뿐만 아니라 캐시가 7번 hit되었고, 3번 miss되었으며, 최대 캐시 크기는 128이며, 현재 캐시 크기는 3인 것도 확인되네요.

DB에서 아이디가 B02인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 A01인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 C03인 사용자 정보를 읽어오고 있습니다...
CacheInfo(hits=7, misses=3, maxsize=128, currsize=3)

위의 캐시 관련 정보를 통해 @lru_cache 데코레이터는 디폴트로 최대 128개의 호출 결과를 저장할 수 있다는 것을 알 수 있는데요. 이 캐시의 최대 크기를 변경하고 싶다면 maxsize 옵션을 설정해주면 됩니다.

좀 의미있는 변화를 보기위해서 get_user() 함수에 적용되어 있는 @lru_cache 데코레이터의 maxsize 옵션을 극단적으로 2로 설정해볼까요?

from functools import lru_cache

def fetch_user(user_id):
    print(f"DB에서 아이디가 {user_id}인 사용자 정보를 읽어오고 있습니다...")
    return {
        "userId": user_id,
        "email": f"{user_id}@test.com",
        "password": "test1234"
    }

@lru_cache(maxsize=2)def get_user(user_id):
    return fetch_user(user_id)

이 번에는 get_user() 함수를 10회 호출을 하는 것을 여러 차례 진행해보면 재미있을 것 같습니다. 😁

  • 첫번째 시도 (캐시 히트률: 60%)
DB에서 아이디가 A01인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 B02인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 C03인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 A01인 사용자 정보를 읽어오고 있습니다...
CacheInfo(hits=6, misses=4, maxsize=2, currsize=2)
  • 두번째 시도 (캐시 히트률: 70%)
DB에서 아이디가 A01인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 C03인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 B02인 사용자 정보를 읽어오고 있습니다...
CacheInfo(hits=7, misses=3, maxsize=2, currsize=2)
  • 세번째 시도 (캐시 히트률: 40%)
DB에서 아이디가 A01인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 B02인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 C03인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 B02인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 C03인 사용자 정보를 읽어오고 있습니다...
DB에서 아이디가 A01인 사용자 정보를 읽어오고 있습니다...
CacheInfo(hits=4, misses=6, maxsize=2, currsize=2)

위와 같이 실행할 때 마다 캐시 히트율이 조금씩 달라지며 그에 따라 데이터베이스 접근 회수도 달라지는 것을 볼 수 있습니다. 이것은 줄어든 캐시 사이즈로 인해서 가장 오랫동안 사용되지 않은 데이터가 캐시에서 제거되기 때문입니다.

[보너스] LRU 캐시 구현

제가 위에서 LRU 캐시를 직접 구현하는 것이 만만치 않은 일이라고 언급했었는데요. 사실 파이썬의 collections 내장 모듈에서 제공하는 OrderedDict라는 자료구조를 이용하면 그나마 비교적 쉽게 LRU 캐시를 흉내 내볼 수는 있을 것 같습니다.

예를 들어, 최대 2개의 아이템만 저장할 수 있는 캐시를 구현해보겠습니다.

from collections import OrderedDict

MAX_SIZE = 2
cache = OrderedDict()

def get_user(user_id):
    if user_id in cache:
        cache.move_to_end(user_id) # 가장 최근에 사용된 아이템은 캐쉬의 맨 뒤로 이동
        return cache[user_id]

    if len(cache) == MAX_SIZE:
        cache.popitem(last=False) # 캐시 용량이 꽉 찾을 때는 캐쉬에 맨 앞에 있는 아이템 삭제

    cache[user_id] = fetch_user(user_id)
    return cache[user_id]

절대 상용 시스템에서 이와 같이 직접 구현한 LRU 캐시를 사용하면 안 될 것입니다. ⚠️ 순수하게 학습 용으로만 참고 부탁드립니다. 🙏

전체 코드

본 포스팅에서 제가 작성한 전체 코드는 아래에서 직접 확인하고 실행해보실 수 있습니다.

https://dales.link/uwr

마치면서

지금까지 실습을 통해서 파이썬에서 어떤 방법을 통해 캐싱을 할 수 있는지 살펴보았습니다.

마지막으로, 캐싱을 통해 성능 최적화를 하실 때는 반드시 데이터 접근 패턴을 충분히 파악하는 것이 우선되야 할 것입니다. 특히, 수시로 갱신되는 데이터를 다루는 서비스의 경우 캐싱을 통해 얻는 것보다 잃는 것이 더 많을 수도 있거든요. 캐시에 기존에 저장되어 있는 사본의 데이터가 제공되면 실시간 업데이트가 중요한 사용자에게는 큰 문제의 소지가 될 수 있기 때문입니다.

이러한 부분을 잘 고려 후에 신중히 캐싱을 적지적소에 잘 적용하셔서 성능이 우수한 파이썬 소프트웨어를 작성하실 수 있으셨으면 좋겠습니다.