Published on

고쳤는데 목록은 옛날 값 — 정본과 파생 캐시를 시그널로 다시 붙인 회고

Authors
  • avatar
    Name
    Hyo814
    Twitter

고쳤는데 목록은 옛날 값 — 정본과 파생 캐시를 시그널로 다시 붙인 회고

표준데이터를 인스턴스 기반으로 옮기는 과정에서, 데이터가 두 곳에 살게 됐습니다.

  • MetaClassInstanceMetadata — 인스턴스의 리터럴 값. 정본(source of truth)
  • DatasetMetadata — 데이터셋 쪽 값. 파생 캐시

그리고 이런 제보가 왔습니다.

인스턴스에서 값을 고쳤는데, 목록이랑 다운로드는 그대로예요.

파생 캐시가 정본을 안 따라가고 있었습니다.


1. 왜 데이터가 두 곳에 있었나

먼저 이 구조가 왜 생겼는지 짚어야 합니다. 설계 실수라기보다 전환기의 산물이었어요.

표준데이터를 인스턴스 기반으로 옮기는 중이었는데, 상세·편집·RDF 방출·통합검색이 아직 기존 DatasetMetaClass/DatasetMetadata를 읽고 있었습니다. 이걸 한 번에 다 바꾸면 전환이 빅뱅이 돼요. 그래서 인스턴스를 데이터셋에 연결할 때 값을 복사해서 기존 소비처가 계속 동작하게 했습니다. 이른바 브리지죠.

def link_message_instance(self, instance, slot=None):
    """
    add 화면에서 고른 인스턴스를 데이터셋에 연결한다.
    - dataset_dataset_instance에 (dataset, instance, slot=술어) 링크 저장
    - 브리지: 인스턴스 클래스로 DatasetMetaClass 생성 + 인스턴스 값 동기화
      (edit/상세/RDF/검색이 아직 DatasetMetaClass/DatasetMetadata를 읽으므로 유지)
    """

문제는 이 복사가 링크 시점에 딱 한 번 일어난다는 거였습니다. 그 뒤 인스턴스를 아무리 고쳐도 파생 캐시는 그 시점의 스냅샷으로 남아 있었어요.

전형적인 캐시 무효화 문제인데, 고약한 건 틀린 값이 조용히 서비스된다는 점입니다. 에러도 안 나고 로그도 없어요. 사용자가 알아채고 말해줘야 압니다.


2. 세 가지 안과 고른 이유

방법장점포기하는 것판단
① 파생 캐시를 없애고 전부 정본에서 조회근본 해결. 동기화 문제가 사라짐상세·편집·RDF·통합검색을 동시에 다 바꿔야 함. 전환을 빅뱅으로 되돌리는 셈최종 목표, 지금은 ✗
② 조회 시 재계산 (lazy)쓰기 경로 무변경읽기가 쓰기보다 압도적으로 많음. 목록 한 번 여는 데 전 행 재계산
③ 쓰기 시 시그널로 재동기화읽기 경로 무변경, 쓰기는 드묾시그널 의존, 벌크 경로에서 안 걸림✓ 채택

②를 접은 기준은 단순했습니다. 이 데이터는 읽기가 쓰기보다 훨씬 많습니다. 표준데이터는 등록·수정보다 조회·검색·다운로드가 압도적이에요. 비싼 계산을 읽기에 붙일지 쓰기에 붙일지는 그 비율로 정하면 됐습니다.

①이 정답이라는 건 알고 있었습니다. 캐시를 안 두는 게 캐시를 잘 관리하는 것보다 항상 낫죠. 다만 그건 전환이 끝난 뒤의 이야기이고, 지금 틀린 값이 나가고 있는 걸 먼저 멈춰야 했습니다. ③은 ①로 가는 길목에 있는 임시 구조고, ①이 완료되면 통째로 지워집니다.


3. 구현 — 멱등 재동기화 + fan-out

먼저 "정본에서 파생 캐시를 다시 도출하는" 함수를 만들었습니다. 델타를 계산하지 않고 항상 전체를 다시 씁니다. 멱등이라 몇 번 호출해도 결과가 같아요.

def sync_bridge(dataset):
    """데이터셋의 브리지 인스턴스(들)의 현재 리터럴 값으로 DatasetMetadata를 멱등 재동기화.

    링크 시점 1회 복사(copy_instance_values)의 stale를 해소 — 인스턴스 편집 후에도
    파생 캐시를 정본(MetaClassInstanceMetadata)에서 재도출해 최신으로 유지한다.
    파생 캐시라 copy_instance_values의 bulk_update가 AuditLog 시그널을 유발하지 않는다.
    인스턴스 공유(다중 데이터셋)면 참조 데이터셋마다 호출된다(시그널 fan-out).
    """
    if dataset is None:
        return
    links = DatasetMetaClassInstance.objects.filter(
        dataset=dataset
    ).select_related("metaclass_instance")
    for link in links:
        instance = link.metaclass_instance
        if instance is None:
            continue
        dataset_metaclass = DatasetMetaClass.objects.filter(
            dataset=dataset, metaclass_id=instance.metaclass_id
        ).first()
        if dataset_metaclass is None:
            continue
        DatasetManager.copy_instance_values(dataset_metaclass, instance)

델타 계산을 안 한 건 의도적입니다. 어떤 값이 바뀌었는지 추적하는 코드가 곧 다음 버그거든요. 전체 재도출은 비싸지만 틀릴 수가 없습니다. 지금 데이터 규모에서는 이 교환이 맞다고 봤어요.

그리고 정본이 바뀔 때 이걸 부르도록 시그널을 걸었습니다.

def _resync_bridged_datasets(sender, instance, **kwargs):
    metaclass_instance_id = getattr(instance, "metaclass_instance_id", None)
    if not metaclass_instance_id:
        return
    dataset_ids = list(
        DatasetMetaClassInstance.objects.filter(
            metaclass_instance_id=metaclass_instance_id
        ).values_list("dataset_id", flat=True).distinct()
    )
    for dataset in Dataset.objects.filter(id__in=dataset_ids):
        DatasetManager.sync_bridge(dataset)


post_save.connect(_resync_bridged_datasets, sender=MetaClassInstanceMetadata)
post_delete.connect(_resync_bridged_datasets, sender=MetaClassInstanceMetadata)

fan-out이 필요한 이유가 여기 있습니다. 인스턴스 하나를 여러 데이터셋이 공유할 수 있어요. 그러면 그 인스턴스를 참조하는 모든 데이터셋의 캐시가 같이 낡습니다. 하나만 갱신하면 나머지는 조용히 틀린 채로 남아요. post_delete까지 건 것도 같은 이유입니다. 값을 지우는 것도 변경이니까요.


4. 트레이드오프 — 감사 로그를 일부러 안 남긴 것

이 작업에서 제일 조심스러웠던 판단입니다.

DatasetMetadata에는 감사 로그(AuditLog) 시그널이 걸려 있습니다. 누가 언제 뭘 바꿨는지 남기는 용도예요. 그런데 재동기화가 이 테이블을 통째로 다시 쓰니, 그대로 두면 인스턴스 값 하나 고칠 때마다 감사 로그가 수십 건씩 쌓입니다. 게다가 그 로그의 내용은 "시스템이 캐시를 갱신했다"뿐이라 감사 가치도 없어요. 진짜 감사 대상은 사용자가 정본을 고친 그 행위인데, 그건 이미 정본 쪽에 남고 있고요.

그래서 copy_instance_valuesbulk_update를 쓰게 했습니다. Django의 bulk_updatesave()를 거치지 않아서 post_save 시그널이 안 뜹니다.

이건 시그널을 우회하는 코드입니다. 보통은 냄새나는 패턴이에요. 그래서 판단 근거를 주석에 남겼습니다 — "파생 캐시라 AuditLog 시그널을 유발하지 않는다". 정본이 아니라 파생이니까 감사 대상이 아니라는 논리입니다.

시그널로 고친 문제를, 다른 시그널이 안 터지게 막으면서 해결한 셈이에요. 시그널을 쓰기 시작하면 이런 상호작용을 계속 신경 써야 한다는 걸 이때 알았습니다.


5. 검증

이 버그의 특징이 "조용히 틀린 값"이라, 검증도 읽는 쪽에서 해야 했습니다. 저장이 성공하는 건 원래도 성공했거든요.

시나리오기대결과
인스턴스 값 수정 후 목록 조회새 값✓ (수정 전엔 옛 값)
인스턴스 값 수정 후 다운로드새 값
인스턴스 값 수정 후 RDF 방출새 값
인스턴스 값 삭제 후 조회항목 사라짐✓ (post_delete)
인스턴스를 두 데이터셋이 공유 → 값 수정양쪽 다 갱신✓ (fan-out)
같은 값으로 재저장결과 동일 (멱등)
재동기화 후 AuditLog캐시 갱신분은 미기록✓ (의도)

전용 테스트도 붙였습니다(test_sync_bridge, 5케이스). 시그널로 도는 로직은 테스트가 없으면 조용히 끊깁니다. 나중에 누가 bulk_updatesave() 루프로 바꾸거나, 시그널 연결을 옮기면 아무 에러 없이 캐시가 다시 낡을 거예요.


6. 한계 — 정직하게

  • 인스턴스 하나 저장할 때 시그널이 속성 수만큼 뜹니다. 값이 20개면 post_save가 20번 뜨고, 각각 전체 재동기화를 돌려요. 지금 규모에서는 체감이 없지만 명백한 낭비입니다. 저장 단위를 인스턴스로 묶어 트랜잭션 커밋 시점에 한 번만 돌리는 게 맞습니다(transaction.on_commit).
  • 벌크 경로는 여전히 안 걸립니다. 정본을 bulk_create/bulk_update로 건드리면 시그널이 안 뜨고 캐시는 낡습니다. 적재 커맨드가 정본을 직접 손대면 뒤에 sync_bridge를 명시적으로 불러줘야 해요.
  • 시그널은 호출 지점이 코드에 안 보입니다. 값을 저장했을 뿐인데 데이터셋 여러 개가 갱신되는 걸, 저장 코드만 읽어서는 알 수 없어요. 주석과 테스트가 그걸 대신하고 있지만 완전하진 않습니다.
  • 감사 로그에 구멍이 있습니다. 의도한 구멍이지만, 나중에 "이 값이 언제 이렇게 됐지"를 파생 캐시 기준으로 추적하려 하면 안 나옵니다. 정본 쪽 로그를 봐야 한다는 걸 알고 있어야 해요.

그리고 가장 큰 한계는, 이 구조 자체가 임시라는 것입니다. 소비처를 전부 정본 조회로 옮기면 이 시그널과 sync_bridge는 통째로 지워집니다. 그때 지워지도록 주석에 그 사실을 적어뒀어요.


7. 회고

  • "값이 두 곳에 있다"는 걸 발견하면, 그다음 질문은 항상 "어느 쪽이 정본인가"입니다. 그게 정해져야 동기화 방향이 한쪽으로 고정돼요. 양방향 동기화로 갔으면 훨씬 어려워졌을 겁니다.
  • 읽기가 많은지 쓰기가 많은지로 캐시 갱신 시점을 정하면 됐습니다. lazy냐 eager냐를 취향으로 고르려니 답이 안 나왔는데, 비율을 보니 바로 정해졌어요.
  • 델타 계산 대신 전체 재도출을 고른 건 지금도 잘한 선택 같습니다. 무엇이 바뀌었는지 추적하는 코드는 그 자체가 버그의 온상이에요. 멱등이면 최악의 경우 좀 느릴 뿐입니다.
  • 시그널을 쓰면 다른 시그널과의 상호작용까지 설계 대상이 됩니다. 감사 로그 폭발은 예상 못 했다가 중간에 걸린 문제였어요. 시그널은 "여기서 끝"이 없습니다.
  • 조용히 틀리는 버그는 읽는 쪽에서 검증해야 합니다. 쓰기는 원래도 성공했으니까요. 이런 종류는 테스트를 남겨두지 않으면 반드시 재발합니다.

이 글에 나온 브리지 구조가 왜 생겼는지는 표준데이터를 인스턴스 기반으로 전환한 회고에 적어뒀습니다. 임시 구조를 임시인 채로 잘 굴리는 것도 일의 일부더라고요.