파이썬 frozendict, 딕셔너리를 캐시 키로 쓰려면

설정 딕셔너리를 frozendict로 바꿨는데, 캐시 함수에 넘기자 다시 해시 오류가 난다. 바꿀 수 없는 딕셔너리인데 무엇이 해시를 막고 있을까?

파이썬 frozendict, 딕셔너리를 캐시 키로 쓰려면
세 줄 요약
  • Python 3.15의 frozendict는 키와 값이 모두 해시 가능할 때 캐시 함수의 인자로 쓸 수 있다.
  • 값으로 넣은 리스트는 여전히 수정할 수 있고, 해당 frozendict의 해시 계산도 실패한다.
  • MappingProxyType은 원본의 변경을 보여주는 뷰이므로 고정된 설정 사본과 용도가 다르다.

dict 대신, 해시 가능한 설정을 넘긴다

lru_cache를 붙인 함수에 설정 딕셔너리를 넘기면 unhashable type: 'dict' 오류가 날 수 있다. 캐시가 호출 인자를 키로 사용하기 때문이다. Python 3.15에 추가된 frozendict는 매핑을 유지하면서 이 문제를 해결할 수 있다. 조건은 모든 키와 값이 해시 가능해야 한다는 것이다.

가령 보고서에 표시할 열 이름을 설정으로 받는 함수라면, 열 목록을 문자열의 튜플로 두고 전체 설정을 frozendict로 만들 수 있다. 다음은 같은 설정의 결과를 재사용하는 설명용 예제다.

from functools import lru_cache

@lru_cache(maxsize=128)
def make_header(options):
    return options["separator"].join(options["columns"])

options = frozendict(columns=("name", "total"), separator=",")
make_header(options)
make_header(options)
print(make_header.cache_info())

두 호출 사이에 캐시를 비우지 않았다면 두 번째 호출은 저장한 결과를 이용한다. 실제 도입에서는 cache_info()의 hits·misses로 재사용 여부를 볼 수 있다. maxsize=128은 최근 호출 결과를 최대 128개 보관하도록 제한한다.

겉을 고정해도 안쪽 리스트는 바뀐다

frozendict는 얕게 복사한다. 키에 다른 값을 다시 대입하는 것은 막지만, 값으로 저장한 객체의 내부 동작까지 바꾸지는 않는다.

options = frozendict(columns=["name", "total"])
options["columns"].append("tax")  # The list can still change.
hash(options)                   # TypeError: the list is unhashable.

생성이 성공했다는 사실과 캐시 키로 사용할 수 있다는 사실은 다르다. 위 예제에서는 리스트를 값으로 담는 것까지는 가능하고, 해시를 요구할 때 오류가 난다. 열 순서를 보존하려면 문자열의 튜플처럼 그 의미에 맞는 해시 가능한 값을 사용한다. 튜플 안에 다시 리스트가 들어 있다면 바깥을 튜플로 바꾸는 것만으로는 해결되지 않는다.

설정의 순서도 구분해야 한다. frozendict의 항목을 순회할 때는 삽입 순서가 유지되지만, 같은 키·값 쌍을 가진 두 frozendict의 동등성은 항목 순서에 좌우되지 않는다. 반면 값으로 넣은 열 이름 튜플의 순서는 보고서 결과를 바꾸므로 그대로 보존해야 한다.

MappingProxyType은 원본을 계속 바라본다

MappingProxyType은 기존 매핑을 읽기 전용으로 보여준다. 원본 딕셔너리에 새 값을 넣으면 뷰에서도 새 값이 보인다. 생성 당시의 항목을 담는 frozendict와 선택 기준이 갈리는 지점이다.

필요한 동작선택
소유자가 바꾼 설정을 읽는 쪽에도 반영한다.MappingProxyType으로 원본의 뷰를 제공한다.
생성 당시 설정의 키·값 연결을 유지한다.frozendict로 매핑을 만든다.
설정을 캐시 함수에 전달한다.키·값 모두 해시 가능한 frozendict를 사용한다.

일반 dict를 MappingProxyType으로 감싸도 캐시 키가 되지는 않는다. 프록시의 해시 계산은 원본에 위임되기 때문에 원본 dict의 해시 불가 문제는 남는다. 어느 방식을 고르든 중첩 리스트까지 자동으로 고정되는 것은 아니다.

캐시할 함수와 타입 검사를 함께 본다

frozendict는 dict를 상속하지 않는다. 기존 함수가 isinstance(options, dict)만 허용한다면 거부될 수 있다. 읽기만 하는 함수는 필요한 계약에 맞춰 collections.abc.Mapping을 받을 수 있는지 검토한다. 값을 수정하는 함수까지 타입 검사만 넓혀서는 안 된다.

캐시 역시 결과를 재사용해도 되는 함수에 붙여야 한다. 호출할 때마다 외부 작업을 수행해야 하는 함수라면 같은 설정이라는 이유로 실행을 건너뛸 수 없다. 먼저 설정 값들을 해시 가능한 구조로 만들고, 함수가 읽기만 하는지 확인한 뒤 캐시 적중을 측정하는 순서가 맞다.