본문 바로가기
C.W.K.
Stream
Lesson 04 of 05 · published

약속을 깨뜨린 외래 키

~14 min · sqlite, constraints, bugs, invariants

Level 0미분류
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

불변식이랑 스키마가 서로 딴소리를 했어

아키텍처 문서는 대놓고 적어놨어. 저장한 기사는 영구고, 기사를 지우는 건 보존 함수뿐이라고. 보존 함수도 거기 맞춰 쓰였고. 조심스럽고, 명시적이고, 정확히 맞는 행들을 봐주는. 스키마는 딴소리를 했지. 기사 표가 자기 출처를 가리키는 참조에 ON DELETE CASCADE 가 달려 있었어.

선반을 지우면 무슨 일이 나냐고 누가 묻기 전까진 저 두 사실이 연결이 안 됐어. 선반을 지우면 다른 선반이 안 들고 있는 출처들이 지워져. 합리적이고, 설정 화면이 독자 대신 해주는 일이지. 그다음 캐스케이드가 그 출처들의 기사를 데려갔어. 저장한 것까지. 조심스러운 보존 함수는 호출조차 안 됐고.

고치기 전에 증명해

이런 버그의 유혹은 제약을 바로 바꾸는 거야. 고치는 법이 뻔하니까. 먼저 재현하는 게 십 분 값어치를 하는데, 이유가 진단에 대한 자신감 때문은 아냐. 재현은 문제의 크기를 알려주고, 크기가 뭘 더 바꿔야 하는지를 정하거든.

DB 임시 사본에서 출처 하나를 지우니까 보관함 선반이 비었어. 그 결과가 버그의 나머지 절반을 드러냈지. 기사가 출처 없이 살아남을 수 있게 되면 그걸 그리는 질의도 그 상황을 견뎌야 하니까.

나머지 절반 — 이너 조인이 구조를 숨겨

제약을 SET NULL 로 바꾼다는 건 살아남은 기사한테 출처가 아예 없을 수 있다는 뜻이야. 선반 질의는 매체 이름을 얻으려고 기사랑 출처를 조인하고 있었어. 이너 조인, 즉 매칭 없는 행을 버리는 쪽으로. 그래서 캐스케이드에서 구해낸 기사들이 어차피 모든 선반에서 사라졌을 거야. 똑같은 실패인데 한 층 위고, 훨씬 알아채기 어려워. 행은 표에 멀쩡히 앉아 있을 테니까.

조인은 대체 이름을 단 레프트 조인이 돼야 했어. 제약이랑 질의가 한 변경이야. 둘 중 하나만 고치면 약속은 깨진 채로 고쳐진 것처럼 보이고, 그게 원래 버그보다 나빠.

플래그 말고 세상한테 물어

SQLite 는 제약을 제자리에서 못 바꾸니까 수리는 문서에 나온 표 재구축이야. 만들고, 복사하고, 버리고, 이름 바꾸고. 재미있는 결정은 돌아가는 설치본이 재구축이 필요한지 어떻게 아느냐야. 스키마 버전 플래그도 되긴 해. 누군가의 플래그가 실제 스키마랑 어긋나기 전까진. 살아 있는 외래 키 정의를 읽고 캐스케이드가 진짜로 있을 때만 재구축하면 마이그레이션이 멱등하고 자기 검증적이 돼. 숫자한테 뭐가 맞아야 하는지 묻는 대신 DB 한테 뭐가 참인지 묻는 거지.

코드 경로 하나에서 강제되는 불변식은 강제되는 게 아냐. 질문은 절대 "내 함수가 이 규칙을 지키나?"가 아니라 "내 함수를 안 거치고 이 행들에 닿을 수 있는 게 있나?"야. 그리고 제약이랑 캐스케이드랑 트리거는 delete 라는 단어 검색에 절대 안 잡히는 코드 경로고.

Code

제약이랑 질의는 수정 둘이 아니라 하나야·python
def _detach_articles_from_source_cascade(con) -> None:
    """Rebuild `articles` so deleting a source can never delete one.

    The original FK was ON DELETE CASCADE, which quietly made the
    permanence invariant a lie: deleting a shelf deletes the sources no
    other shelf holds, and the cascade took the saved articles with them
    -- the one thing the product promises never to lose.
    """
    # Detect from the LIVE definition, not from a version flag: the
    # database is asked what is true rather than told what should be.
    fks = con.execute("PRAGMA foreign_key_list(articles)").fetchall()
    if not any(r["from"] == "source_id"
               and str(r["on_delete"]).upper() == "CASCADE"
               for r in fks):
        return                      # already correct; idempotent

    con.commit()                    # PRAGMA is a no-op inside a txn
    con.execute("PRAGMA foreign_keys=OFF")
    try:
        con.execute("BEGIN")
        con.execute("""CREATE TABLE articles_rebuild (
            id        INTEGER PRIMARY KEY,
            -- SET NULL, never CASCADE: an article's permanence must not
            -- depend on the plumbing that delivered it.
            source_id INTEGER REFERENCES sources(id) ON DELETE SET NULL,
            ...
        )""")
        con.execute("INSERT INTO articles_rebuild SELECT * FROM articles")
        con.execute("DROP TABLE articles")
        con.execute("ALTER TABLE articles_rebuild RENAME TO articles")
        con.commit()
    finally:
        con.execute("PRAGMA foreign_keys=ON")


# The other half of the same fix. An INNER join here would drop exactly
# the orphans the rebuild just rescued -- the same disappearance, one
# layer up, and far harder to see.
_ARTICLE_SELECT = """
    SELECT a.*, COALESCE(s.name, '(source removed)') AS source_name
      FROM articles a
      LEFT JOIN sources s ON s.id = a.source_id
"""

External links

Exercise

네 시스템이 하는 내구성 약속을 하나 적어봐. 이어서 보호 대상 행이 든 표를 가리키는 외래 키를 전부 나열하고 각각의 ON DELETE 절을 읽어. 캐스케이드를 찾으면, 네 보호 대상 행에 닿을 부모 삭제를 만들어서 버려도 되는 사본에 실행해봐. 마지막으로, 사람들이 건너뛰는 단계가 이건데, 부모가 널일 때 읽기 경로가 그 행을 여전히 돌려주는지 확인해.
Hint
두 번째 버그가 사는 데가 마지막 단계야. 캐스케이드를 SET NULL 로 바꾸면 질의가 한 번도 못 본 행 모양이 생겨. 부모 없는 자식. 모든 이너 조인, 애플리케이션의 모든 NOT NULL 가정, 가드 없이 parent.name 하는 모든 코드가 이제 네가 방금 지킨 바로 그 행에서 떨구거나 터져.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.