Profile picture

[Python] 캐싱(caching) 수행하여 성능 향상시키기

JaehyoJJAng2024년 05월 23일

개요

파이썬을 사용하다보면 성능에 대해 한 번쯤 고민을 해볼 때가 있을 것이다.

예를 들어, 특정 값을 리턴하는 함수를 여러번 실행해야 한다던지, 특정 값을 재사용 해야하는 상황 등

이러한 상황에서 캐싱 관련 기능을 활용하여 성능을 조금이나마 더 높일 수 있는 방법에 대해서 기록해보려고 한다.


Caching ?

일반적으로 '캐싱'은 접근하는데 비교적 시간이 오래 걸리는 데이터를 접근 속도가 빠른 저장소에 사본을 저장해두고 재사용하거나,

실행하는데 오래 걸리는 연산의 결과를 미리 계산해놓고 최초로 필요할 때 한번만 계산하여 저장해놓고 재사용하는 기법을 의미한다.


브라우저를 예를 들어보자.

대부분의 브라우저는 클라이언트 컴퓨터에 캐시를 두고 있다.

이 캐시에 이전에 방문 했었던 페이지의 내용을 저장해놓고 동일한 페이지를 재 방문시 저장해놓은 사본의 페이지를 보여주는 경우가 많다.

이렇게 함으로 얻을 수 있는 이점은, 동일한 내용을 요청하는 것에 대해서 불필요한 HTTP 통신을 줄이고, 사용자에게 페이지를 조금 더 빠르게 제공할 수 있다.


캐싱은 서버 단에서도 성능 최적화를 위한 핵심 도구로 자리잡고 있다.

예를 들어, 클라이언트로부터 받은 요청에 대한 연산 결과를 캐시에 미리 저장해두고, 나중에 동일한 요청이 들어왔을 때 저장한 값을 그대로 응답하는 것은 흔한 서버 단의 캐싱 패턴이다.


네트워크 쪽에서는 프록시(proxy) 서버나 CDN(Content Delivery Network)을 대표적인 캐싱 사례로 들 수 있다.

유저와 최대한 가까운 CDN 노드에 이미지나 비디오 같은 고용량의 데이터 사본을 저장해놓으면, 사용자가 멀리 있는 서버로부터 원본 데이터를 다운로드 받을 필요가 없어지기 때문이다.


메모이제이션

캐싱 관련 기능을 실습해보기 이전에 가장 원초적인 형태의 캐싱이라고도 불리우는 메모이제이션(Memoization)을 실습해보자.

이는 저장 공간에 별도의 상한선을 두지 않는 캐싱 방법이다.

메모이제이션을 구현할 때는 일반적으로 해시 테이블의 자료 구조를 사용하여 함수의 첫번째 호출 결과를 저장해놓고,

두번째 호출부터는 기존에 저장된 결과를 재사용한다.

간단한 예제로 이해해보자.

def update(user_id: str) -> dict[str,str]:
    print(f"[INFO] {user_id}를 DB에서 읽어오는 중 ...")
    return {'user_id': user_id, 'alias': 'komo'}

def get_user(user_id: str) -> dict[str,str]:
    return update(user_id)

두 개의 함수를 작성하였다.

첫번째 update() 함수는 사용자 아이디를 인자로 받아 해당 아이디에 해당하는 사용자 정보를 데이터베이스에서 읽어오는 로직이다.
(빠른 실습을 위해 하드 코딩으로 작성하였다.)


두번째 get_user() 함수는 넘어온 user_id 인자를 그대로 update() 함수에 넘겨 호출한 결과를 반환하는 함수이다.


그리고 get_user() 함수를 3개의 사용자 아이디 중 하나를 랜덤하게 인자로 넘겨 10회 호출해보자.

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

    for _ in range(10):
        user_ids :list[str] = ['user1', 'user2', 'user3']
        get_user(user_id=choice(user_ids))
[INFO] user1를 DB에서 읽어오는 중 ...
[INFO] user2를 DB에서 읽어오는 중 ...
[INFO] user2를 DB에서 읽어오는 중 ...
[INFO] user3를 DB에서 읽어오는 중 ...
[INFO] user3를 DB에서 읽어오는 중 ...
[INFO] user2를 DB에서 읽어오는 중 ...
[INFO] user1를 DB에서 읽어오는 중 ...
[INFO] user2를 DB에서 읽어오는 중 ...
[INFO] user3를 DB에서 읽어오는 중 ...
[INFO] user1를 DB에서 읽어오는 중 ...

콘솔에 출력된 내용을 확인해보면 3명의 사용자 정보가 여러번 불러와지고 있는 것을 볼 수 있다.

이러한 상황에서 메모이제이션을 활용하면 각 사용자에 대해서 한번씩만 호출할 수 있게되는데, 파이썬에서는 dictionary이라는 내장 자료구조를 사용하면 어렵지 않게 구현 가능하다.

cache :dict = dict()

def get_user(user_id: str) -> dict:
    if user_id not in cache:
        cache[user_id] = update(user_id=user_id)
    return cache[user_id]

다시 동일한 방법으로 get_user() 함수를 10번 호출하면

[INFO] user1를 DB에서 읽어오는 중 ...
[INFO] user2를 DB에서 읽어오는 중 ...
[INFO] user3를 DB에서 읽어오는 중 ...

이번에는 각 유저를 1번씩만 조회하고 있는 것을 확인할 수 있다.


@cache Decorator

메모이제이션을 구현하는 것이 위 예제처럼 어렵지는 않지만 파이썬의 @cache 데코레이터를 활용하면 더 깔끔하게 처리가 가능하다.

메모이제이션에서 진행했던 예제 코드를 기반으로 get_user() 함수에 cache 데코레이터를 적용해보자.

def update(user_id: str) -> dict[str,str]:
    print(f"[INFO] {user_id}를 DB에서 읽어오는 중 ...")
    return {'user_id': user_id, 'alias': 'komo'}

@cache
def get_user(user_id: str) -> dict[str,str]:
    return update(user_id)

그리고 다시 10번 호출해보면

[INFO] user3를 DB에서 읽어오는 중 ...
[INFO] user1를 DB에서 읽어오는 중 ...
[INFO] user2를 DB에서 읽어오는 중 ...

각 유저를 1번씩만 호출하고 있는 것을 확인할 수 있다.

@cache 데코레이터의 경우 파이썬 3.9에 추가 된 기능이기 때문에 현재 사용 중인 파이썬 버전에 따라 적용이 불가능 할 수 도 있다.


@cache의 한계

그러면 @cache 데코레이터를 아무 걱정 없이 서비스에 적용해도 되는걸까?

@cache에는 몇 가지 한계가 존재한다. 바로 메모리 관련 문제인데 아래 예시를 한 번 봐보자.


1. 사용자 정보를 DB로부터 가져오는 함수 정의 2. 사용자를 무작위로 호출하는 함수 정의

from functools import cache
import random
import string
import tracemalloc

def update(user_id: str) -> dict[str,str]:
    print(f"[INFO] {user_id}를 DB에서 읽어오는 중 ...")
    return {'user_id': user_id, 'alias': f'komo_{user_id}', 'description': ''.join(random.choices(string.ascii_letters, k=1000))}

@cache
def get_user(user_id: str) -> dict[str,str]:
    return update(user_id)

위 코드에서는 random 모듈을 사용하여 이 사용자의 정보를 생성하였다.


이제 이 함수를 호출한다고 가정하고, get_user 함수 호출 시뮬레이션 함수를 작성해보자.

def simulate(n: int) -> None:
    tracemalloc.start()
    _ = [get_user(user_id=i) for i in range(n)]
    current, peak = tracemalloc.get_traced_memory()
    print(f"현재 메모리 사용량(MB): {current / (1024**2):.3f} MB")
    tracemalloc.stop()

if __name__ == '__main__':
    n :int = 10000
    simulate(n=n)

시뮬레이터를 10,000번 호출했을 때의 결과는 아래와 같다.

현재 메모리 사용량(MB): 13.427 MB

시뮬레이터를 100,000번 호출했을 때의 결과는 아래와 같다.

현재 메모리 사용량(MB): 136.589 MB

시뮬레이션과 메모리 사용량은 1:1 비율 정도로 증가하는 것을 확인할 수 있다.


중요한 점은 캐싱에 사용되는 메모리는 Process가 중지될 때까지 사라지지 않는다.

실제 서비스에 @cache가 적용된다면 캐싱에 얼마나 많은 메모리가 사용될지 예상이 상당히 어려울 것이다.

무턱대고 캐싱을 적용하게되면 서비스 장애까지 발생할 수 있는 극단적인 상황이 발생할 수도 있다.


@lru_cache

lru_cache에서 lru는 "Least Recently Used`의 약어이다.

이는 캐싱 매커니즘에서 사용하는 알고리즘으로, 자주 사용된 데이터를 보관하고 가장 최근에 사용하지 않은 데이터를 삭제하여 메모리를 관리한다.

@lru_cache의 최대 장점은 cache와 다르게 lru_cache가 제공하는 유연성이다.

@cache에서 작성한 함수를 동일하게 작성하여 시뮬레이션 해보자.

from functools import lru_cache
import random
import string
import tracemalloc

def update(user_id: str) -> dict[str,str]:
    return {'user_id': user_id, 'alias': f'komo_{user_id}', 'description': ''.join(random.choices(string.ascii_letters, k=1000))}

@lru_cache(maxsize=1000)
def get_user(user_id: str) -> dict[str,str]:
    return update(user_id)
def simulate(n: int) -> None:
    tracemalloc.start()
    for i in range(n):
        _ = get_user(user_id=i)
        if i % 200 == 0:
            current, peak = tracemalloc.get_traced_memory()
            print(f"Iteration {i}: 현재 메모리 사용량(MB): {current/(1024**2):.3f} MB")
    tracemalloc.stop()

if __name__ == '__main__':
    n :int = 10000
    simulate(n=n)

이를 실행하면 아래와 같은 결과를 볼 수 있다.


@lru_cache(maxsize=1000)의 maxsize를 통해 첫 1000회 실행에서는 메모리 사용량이 계속 증가하는 것을 볼 수 있다.

하지만 이후 1001 ~ 10000회 실행에 대해선 메모리 사용량이 아주 미세하게 증가하며 안정화 되는 것을 볼 수 있을 것이다.
image


cached_property Decorator

파이썬 3.8 버전부터 __dict__를 활용한 cached_property 기능이 추가되었다.

특정 property를 캐싱할 수 있는 기능인데, 이를 이용하면 속성에 처음 접근할 때 계산을 수행하고, 이후에는 동일한 값을 반환하여 성능을 향상 시킬 수 있다.

cached_property 기능의 경우 주로 클래스의 인스턴스 속성이나, 메서드의 결과를 캐시할 때 매우 유용하다.


예제

아래의 첫 번째 예시는 연산이 오래 걸리는 property를 다시 계산하지 않고 재사용하기 위해 cached_property를 도입해본 예시이다.

from functools import cached_property

class Example:
    def __init__(self, peoples: list[dict[str, str|int]]) -> None:
        self.peoples = peoples

    @cached_property
    def average_age(self):
        print("나이 평균 계산 중...")
        total_age = sum(person['age'] for person in self.peoples)
        return total_age / len(self.peoples)

# 사용 예시
people_data = [
    {'name': 'Alice', 'age': 30},
    {'name': 'Bob', 'age': 25},
    {'name': 'Charlie', 'age': 35}
]

# 인스턴스 생성
example_instance = Example(people_data)

# 처음 average_age에 접근하면 계산이 수행됨
print(example_instance.average_age)  # 출력: 나이 평균 계산 중... 30.0

# 두 번째부터는 캐시된 결과를 반환
print(example_instance.average_age)  # 출력: 30.0

# __dict__ 값 조회
print(vars(example_instance))

# __dict__ 값 삭제하여 캐싱 초기화
example_instance.pop('peoples')

# 다시 연산 수행
example_instance.averate_age

위 예제에서는 Example 클래스의 average_age 속성을 @cached_property로 데코레이트 하였다.

해당 속성은 주어진 사람들의 나이 평균을 계산하는데, 이 때 처음 접근할 때에는 계산 로직을 수행하고, 이후에는 캐시된 결과를 반환한다.


이런 기능이 가능한 이유는 첫 호출 이후 객체의 __dict__에 결과 값이 저장되기 때문이다.

print(vars(example_instance))
# output
{'peoples': [{'name': 'Alice', 'age': 30}, {'name': 'Bob', 'age': 25}, {'name': 'Charlie', 'age': 35}], 'average_age': 30.0}

따라서 다음과 같이 __dict__의 값을 지우게 되면 캐싱된 값이 무효화되고 다시 연산을 수행하게 된다.

# __dict__ 값 삭제하여 캐싱 초기화
example_instance.pop('peoples')

# 다시 연산 수행
example_instance.averate_age

property의 호출 방법?

위에서 작성한 샘플 코드를 다시 살펴봐보자.

# ...

# 인스턴스 생성
example_instance = Example(people_data)

# 처음 average_age에 접근하면 계산이 수행됨
print(example_instance.average_age)  # 출력: 나이 평균 계산 중... 30.0

# 두 번째부터는 캐시된 결과를 반환
print(example_instance.average_age)  # 출력: 30.0

# ...

여기서 example_instance 인스턴스에서 average_age를 호출할 때 ()를 붙이지 않고 호출을 한다.


보통 메소드를 호출할 때에는 다음과 같이 호출해야 하는데

average_age()

예제에서는 왜 average_age를 속성처럼 호출해서 사용하는 걸까?

그 이유는 프로퍼티(property)가 메소드에 데코레이트 된 경우 일반적인 메서드처럼 호출하지 않고 속성처럼 접근할 수 있기 때문이다.

@cached_property를 데코레이터를 사용하면 메서드를 속성으로 변환하는데, 이렇게 하면 메서드를 호출하는 것이 아니라 속성처럼 접근할 수 있게된다. 따라서 괄호를 사용하지 않는다.

이와 대조적으로, 만약에 @property를 사용하여 메서드를 속성으로 변환하지 않았다면, 해당 메서드를 호출할 때에는 괄호를 사용해야 한다.


마무리

캐싱 전략에 대해서 실습을 통해 알아보았다.

캐싱을 통해 성능을 최적화할 때는 반드시 데이터의 접근 패턴을 완벽하게 파악하는 것이 중요하다.

수시로 갱신되는 데이터를 다루는 서비스의 경우 캐싱을 사용하게 되면 기존의 사본 데이터가 제공되면서, 실시간 업데이트가 중요한 사용자에게는 큰 문제의 소지가 될 수 있기 때문이다.

이러한 부분을 잘 파악하여 캐싱을 적재적소에 맞게 적용하면 우수한 프로그램을 만들 수 있을 것이다.


Loading script...