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

- Name
- Hyo814
고쳤는데 목록은 옛날 값 — 정본과 파생 캐시를 시그널로 다시 붙인 회고
표준데이터를 인스턴스 기반으로 옮기는 과정에서, 데이터가 두 곳에 살게 됐습니다.
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_values가 bulk_update를 쓰게 했습니다. Django의 bulk_update는 save()를 거치지 않아서 post_save 시그널이 안 뜹니다.
이건 시그널을 우회하는 코드입니다. 보통은 냄새나는 패턴이에요. 그래서 판단 근거를 주석에 남겼습니다 — "파생 캐시라 AuditLog 시그널을 유발하지 않는다". 정본이 아니라 파생이니까 감사 대상이 아니라는 논리입니다.
시그널로 고친 문제를, 다른 시그널이 안 터지게 막으면서 해결한 셈이에요. 시그널을 쓰기 시작하면 이런 상호작용을 계속 신경 써야 한다는 걸 이때 알았습니다.
5. 검증
이 버그의 특징이 "조용히 틀린 값"이라, 검증도 읽는 쪽에서 해야 했습니다. 저장이 성공하는 건 원래도 성공했거든요.
| 시나리오 | 기대 | 결과 |
|---|---|---|
| 인스턴스 값 수정 후 목록 조회 | 새 값 | ✓ (수정 전엔 옛 값) |
| 인스턴스 값 수정 후 다운로드 | 새 값 | ✓ |
| 인스턴스 값 수정 후 RDF 방출 | 새 값 | ✓ |
| 인스턴스 값 삭제 후 조회 | 항목 사라짐 | ✓ (post_delete) |
| 인스턴스를 두 데이터셋이 공유 → 값 수정 | 양쪽 다 갱신 | ✓ (fan-out) |
| 같은 값으로 재저장 | 결과 동일 (멱등) | ✓ |
| 재동기화 후 AuditLog | 캐시 갱신분은 미기록 | ✓ (의도) |
전용 테스트도 붙였습니다(test_sync_bridge, 5케이스). 시그널로 도는 로직은 테스트가 없으면 조용히 끊깁니다. 나중에 누가 bulk_update를 save() 루프로 바꾸거나, 시그널 연결을 옮기면 아무 에러 없이 캐시가 다시 낡을 거예요.
6. 한계 — 정직하게
- 인스턴스 하나 저장할 때 시그널이 속성 수만큼 뜹니다. 값이 20개면
post_save가 20번 뜨고, 각각 전체 재동기화를 돌려요. 지금 규모에서는 체감이 없지만 명백한 낭비입니다. 저장 단위를 인스턴스로 묶어 트랜잭션 커밋 시점에 한 번만 돌리는 게 맞습니다(transaction.on_commit). - 벌크 경로는 여전히 안 걸립니다. 정본을
bulk_create/bulk_update로 건드리면 시그널이 안 뜨고 캐시는 낡습니다. 적재 커맨드가 정본을 직접 손대면 뒤에sync_bridge를 명시적으로 불러줘야 해요. - 시그널은 호출 지점이 코드에 안 보입니다. 값을 저장했을 뿐인데 데이터셋 여러 개가 갱신되는 걸, 저장 코드만 읽어서는 알 수 없어요. 주석과 테스트가 그걸 대신하고 있지만 완전하진 않습니다.
- 감사 로그에 구멍이 있습니다. 의도한 구멍이지만, 나중에 "이 값이 언제 이렇게 됐지"를 파생 캐시 기준으로 추적하려 하면 안 나옵니다. 정본 쪽 로그를 봐야 한다는 걸 알고 있어야 해요.
그리고 가장 큰 한계는, 이 구조 자체가 임시라는 것입니다. 소비처를 전부 정본 조회로 옮기면 이 시그널과 sync_bridge는 통째로 지워집니다. 그때 지워지도록 주석에 그 사실을 적어뒀어요.
7. 회고
- "값이 두 곳에 있다"는 걸 발견하면, 그다음 질문은 항상 "어느 쪽이 정본인가"입니다. 그게 정해져야 동기화 방향이 한쪽으로 고정돼요. 양방향 동기화로 갔으면 훨씬 어려워졌을 겁니다.
- 읽기가 많은지 쓰기가 많은지로 캐시 갱신 시점을 정하면 됐습니다. lazy냐 eager냐를 취향으로 고르려니 답이 안 나왔는데, 비율을 보니 바로 정해졌어요.
- 델타 계산 대신 전체 재도출을 고른 건 지금도 잘한 선택 같습니다. 무엇이 바뀌었는지 추적하는 코드는 그 자체가 버그의 온상이에요. 멱등이면 최악의 경우 좀 느릴 뿐입니다.
- 시그널을 쓰면 다른 시그널과의 상호작용까지 설계 대상이 됩니다. 감사 로그 폭발은 예상 못 했다가 중간에 걸린 문제였어요. 시그널은 "여기서 끝"이 없습니다.
- 조용히 틀리는 버그는 읽는 쪽에서 검증해야 합니다. 쓰기는 원래도 성공했으니까요. 이런 종류는 테스트를 남겨두지 않으면 반드시 재발합니다.
이 글에 나온 브리지 구조가 왜 생겼는지는 표준데이터를 인스턴스 기반으로 전환한 회고에 적어뒀습니다. 임시 구조를 임시인 채로 잘 굴리는 것도 일의 일부더라고요.