불변식이랑 스키마가 서로 딴소리를 했어
아키텍처 문서는 대놓고 적어놨어. 저장한 기사는 영구고, 기사를 지우는 건 보존 함수뿐이라고. 보존 함수도 거기 맞춰 쓰였고. 조심스럽고, 명시적이고, 정확히 맞는 행들을 봐주는. 스키마는 딴소리를 했지. 기사 표가 자기 출처를 가리키는 참조에 ON DELETE CASCADE 가 달려 있었어.
선반을 지우면 무슨 일이 나냐고 누가 묻기 전까진 저 두 사실이 연결이 안 됐어. 선반을 지우면 다른 선반이 안 들고 있는 출처들이 지워져. 합리적이고, 설정 화면이 독자 대신 해주는 일이지. 그다음 캐스케이드가 그 출처들의 기사를 데려갔어. 저장한 것까지. 조심스러운 보존 함수는 호출조차 안 됐고.
고치기 전에 증명해
이런 버그의 유혹은 제약을 바로 바꾸는 거야. 고치는 법이 뻔하니까. 먼저 재현하는 게 십 분 값어치를 하는데, 이유가 진단에 대한 자신감 때문은 아냐. 재현은 문제의 크기를 알려주고, 크기가 뭘 더 바꿔야 하는지를 정하거든.
DB 임시 사본에서 출처 하나를 지우니까 보관함 선반이 비었어. 그 결과가 버그의 나머지 절반을 드러냈지. 기사가 출처 없이 살아남을 수 있게 되면 그걸 그리는 질의도 그 상황을 견뎌야 하니까.
나머지 절반 — 이너 조인이 구조를 숨겨
제약을 SET NULL 로 바꾼다는 건 살아남은 기사한테 출처가 아예 없을 수 있다는 뜻이야. 선반 질의는 매체 이름을 얻으려고 기사랑 출처를 조인하고 있었어. 이너 조인, 즉 매칭 없는 행을 버리는 쪽으로. 그래서 캐스케이드에서 구해낸 기사들이 어차피 모든 선반에서 사라졌을 거야. 똑같은 실패인데 한 층 위고, 훨씬 알아채기 어려워. 행은 표에 멀쩡히 앉아 있을 테니까.
조인은 대체 이름을 단 레프트 조인이 돼야 했어. 제약이랑 질의가 한 변경이야. 둘 중 하나만 고치면 약속은 깨진 채로 고쳐진 것처럼 보이고, 그게 원래 버그보다 나빠.
플래그 말고 세상한테 물어
SQLite 는 제약을 제자리에서 못 바꾸니까 수리는 문서에 나온 표 재구축이야. 만들고, 복사하고, 버리고, 이름 바꾸고. 재미있는 결정은 돌아가는 설치본이 재구축이 필요한지 어떻게 아느냐야. 스키마 버전 플래그도 되긴 해. 누군가의 플래그가 실제 스키마랑 어긋나기 전까진. 살아 있는 외래 키 정의를 읽고 캐스케이드가 진짜로 있을 때만 재구축하면 마이그레이션이 멱등하고 자기 검증적이 돼. 숫자한테 뭐가 맞아야 하는지 묻는 대신 DB 한테 뭐가 참인지 묻는 거지.