시작하기 전
Django 프레임워크의 어드민 기능을 활용하면 간단한 대시보드를 구현할 수 있다.
데이터셋을 쉽게 조회할 수 있지만, 여러 사용자가 동시에 접속하면 예상치 못하게 데이터베이스(DB)에 부하가 발생할 수 있다.
이 문제는 Django-cachalot 같은 캐싱 라이브러리로 부분적으로 완화할 수 있다.
Django-cachalot은 SQL 쿼리를 키로 사용해 쿼리 결과를 캐싱하고, 데이터 변경을 감지하여 테이블 단위로 캐시를 무효화(invalidation)하는 방식을 사용한다.
이 때, 코드 레벨에서 어떻게 Django 쿼리 실행을 추적하고 데이터 변경을 감지하는지 궁금해서 찾아본 내용을 정리했다.
실제로 Monkey patching과 데코레이터를 활용해 기존 프레임워크 동작에 개입하면서도 방해하지 않고, 실행 전후로 필요한 로직을 삽입하는 방식을 확인할 수 있었다.
런타임 중에 기존의 클래스나 모듈의 함수, 메서드, 속성 등을 동적으로 수정하거나 덮어쓰는 기법을 말한다.
데코레이터로 실행 전후 커스텀 로직 삽입하기
Python은 함수도 일급 객체(first-class object)이기 때문에 함수를 파라미터로 전달하거나 반환값으로 활용할 수 있다.
이러한 특성을 활용하여 어떤 함수의 실행 전후에 공통 로직을 넣고 싶을 때 데코레이터 패턴을 사용할 수 있다.
아래 예시는 함수 실행 전후에 메시지를 출력하는 간단한 데코레이터이다.
def my_decorator(func): def wrapper(*args, **kwargs): print("Before function execution") result = func(*args, **kwargs) print("After function execution") return result return wrapper
# 아래 문법은 add = my_decorator(add)와 동일하다.@my_decoratordef add(a, b): return a + b
데이터 변경 감지 로직 구현하기
자, 이제 Django-cachalot이 데이터 변경을 감지하는 방식에 대해 살펴보자.
아래 코드처럼 정규식을 활용하여, SQL 쿼리에 조회(SELECT
)가 아닌 UPDATE
, INSERT
와 같은 데이터 변경 명령어가 포함되어 있는지를 검사한다.
SQL_DATA_CHANGE_RE = re.compile( '|'.join([ fr'(\W|\A){re.escape(keyword)}(\W|\Z)' for keyword in ['update', 'insert', 'delete', 'alter', 'create', 'drop'] ]), flags=re.IGNORECASE,)
if SQL_DATA_CHANGE_RE.search(sql): tables = filter_cachable( _get_tables_from_sql(connection, sql)) if tables: # 데이터 변경이 일어나면 캐시를 무효화한다. invalidate( *tables, db_alias=connection.alias, cache_alias=cachalot_settings.CACHALOT_CACHE)
Monkey patching으로 프레임워크 메서드 확장하기
라이브러리에서는 데이터 변경을 감지하기 위해서 객체가 SQL 쿼리를 실행한 후 위의 로직을 넣어야 할 것이다.
Django ORM이 쿼리를 실행할 때는 CursorWrapper 객체를 사용한다.
DB의 쿼리 결과를 가리키는 포인터. 대량의 데이터 중 한줄씩 순차적으로 접근하거나 일부만 가져올 때 사용됨
따라서 Monkey patching을 이용해 프레임워크의 메서드를 쿼리 실행 후에 데이터 변경 감지 로직을 실행하는 메서드로 수정했다.
from django.db.backends.utils import CursorWrapper
# 런타임에 메서드의 내용을 수정한다. (Monkey patching)CursorWrapper.execute = _patch_cursor_execute(CursorWrapper.execute)CursorWrapper.executemany = _patch_cursor_execute(CursorWrapper.executemany)
기존 동작 전후에 코드 추가하기 (데코레이터 활용)
위 코드에서 나온 _patch_cursor_execute는 데코레이터로 특정 동작 전후에 로직을 넣을 수 있다.
def _patch_cursor_execute(original): @wraps(original) def inner(cursor, sql, *args, **kwargs): try: return original(cursor, sql, *args, **kwargs) # DB에 SQL 실행 finally: # original 호출 -> finally 블록 실행 -> 리턴값 반환 connection = cursor.db if getattr(connection, 'raw', True): if isinstance(sql, bytes): sql = sql.decode('utf-8') sql = sql.lower() if SQL_DATA_CHANGE_RE.search(sql): # 데이터 변경 감지 로직 ... return inner
위 코드는 기존 메서드가 하는 일(DB에 SQL 실행)을 한 후, SQL 문을 이용해 데이터 변경을 감지한다. inner의 파라미터인 cursor는 CursorWrapper 인스턴스이고, python 메서드 문법에서 self에 해당한다고 생각하면 된다. (아래 코드 참고)
def _patch_cursor_execute(original): @wraps(original) def inner(cursor, sql, *args, **kwargs): ...
class CursorWrapper: def execute(self, sql, *args, **kwargs)
캐싱하지 않고 DB에 요청되어야만 하는 SQL 쿼리
그런데 위 데코레이터는 변경을 감지해서 무효화하는 로직만 있지, 이후 캐시 저장하는 로직이 없다.
왜 바로 캐시를 저장하지 않고 있고, 또 어떤 코드에서 저장하고 있을까
그걸 위해서는 Django ORM에서 쿼리가 실행되는 과정에 관한 정보를 미리 알아야 한다.
QuerySet -> Query -> Compiler -> execute_sql() -> CursorWrapper.execute() -> DB
- QuerySet : ORM 코드 (ex. Book.objects.all())
- Compiler : ORM -> SQL 문자열 반환
- execute_sql : SQLCompiler 내부 메서드 -> SQL 실행 준비
- CursorWrapper.execute() : 실제 DB 커서에 SQL 전달 -> 결과 받아옴
DB에 무조건 요청되어야지, 캐싱하면 안되는 SQL 쿼리문들이 있다.
예를 들어, NOW 관련 정보를 요청하는 쿼리나, select_for_update 같이 실제로 DB에 락이 걸려야 하는 쿼리는 캐싱하지 않고 DB에 요청이 되어야 한다.
NOW, select_for_update 같은 키워드는 raw sql 보다는 ORM에서 가져오기 쉽다.
(ex. query.select_for_update
)
따라서 CursorWrapper 단계가 아닌 ORM 내용을 알고 있는 Compiler에서 캐시를 저장하는 것이다.
만약 캐싱하지 않아야 되는 쿼리라면 캐시 키를 만들지 않는다.
def _get_tables(db_alias, query, compiler=False): ... if query.select_for_update or ( not cachalot_settings.CACHALOT_CACHE_RANDOM and '?' in query.order_by): raise UncachableQuery ...
try: cache_key = cachalot_settings.CACHALOT_QUERY_KEYGEN(compiler) table_cache_keys = _get_table_cache_keys(compiler)except (EmptyResultSet, UncachableQuery): # UncachableQuery 에러가 일어나면 캐싱하지 않는다. return execute_query_func()
참고로 캐시 키는 DB 정보, SQL문, SQL문에 사용된 param을 이용해 만들어진다.
cache_key = '%s:%s:%s' % (compiler.using, sql, [str(p) for p in params])
정리: Monkey patching과 데코레이터의 실전 활용
이 글에서는 Python의 monkey patching과 decorator를 활용해 프레임워크 동작의 전후에 원하는 코드를 삽입하는 방법과, Django ORM에서 쿼리 실행 흐름을 살펴보았다.
실제로 DataDog과 같은 모니터링 툴도 monkey patch를 이용해 주요 이벤트를 가로채어 로그를 기록하고 있었다.
따라서, 다양한 상황에서 유연하게 시스템 동작을 확장하거나 모니터링할 때 이 기법들을 실전에서 적극적으로 적용할 수 있을 것 같다.