
Claude Fable이 주로 작성한 sqlite-utils 4.0rc2
요약
Claude Code를 활용하여 sqlite-utils 4.0rc2의 안정화 버전을 개발하고 심각한 버그를 찾아낸 사례를 소개합니다. AI 코딩 에이전트가 데이터 손실을 유발할 수 있는 트랜잭션 오류를 식별하고 수정하는 과정을 다룹니다.
핵심 포인트
- Claude Code를 이용해 라이브러리의 치명적인 버그(데이터 손실)를 사전에 발견
- AI 에이전트가 복잡한 코드 리뷰와 대규모 리팩토링을 수행할 수 있음을 증명
- 에이전트 작업 시간 동안 개발자가 다른 업무를 병행하는 효율적인 워크플로우 제시
Claude Fable이 주로 작성한 sqlite-utils 4.0rc2
2026년 7월 5일
몇 주 전에 sqlite-utils 4.0rc1 출시를 것에 대해 글을 썼습니다. Max 구독에서 Claude Fable을 사용할 수 있는 기간이 며칠 남지 않았기 때문에, 저는 제가 진정으로 안심할 수 있는 4.0 안정화 버전(stable release)을 만드는 데 Claude Fable이 도움을 줄 수 있을지 확인해 보기로 했습니다. 저는 유의적 버전(SemVer)을 준수하려고 노력하며, 호환되지 않는 메이저 버전(major version) 업데이트가 가능한 한 드물게 발생하기를 원하기 때문입니다.
저는 제 iPhone의 웹용 Claude Code에서 다음과 같은 프롬프트로 시작했습니다:
안정적인 4.0 버전을 출시하기 전 최종 검토 - 나중에 수정할 경우 파괴적 변경(breaking change)이 될 수 있는 마지막 순간의 문제들을 찾아내는 것이 매우 중요함
그것이 Claude Fable이 저를 위해 생성한 초기 보고서입니다. 제가 아직 직접 경험하지 못했던 중대한 문제들이 있었으며, 그중 5개는 Fable이 "출시 차단 요소(release blockers)"로 분류했습니다. 그중 가장 심각한 것은 다음과 같습니다:
1.delete_where()
절대 커밋(commit)되지 않으며 연결을 오염시킴 (데이터 손실)
Table.delete_where()
(sqlite_utils/db.py:2948)
은 atomic() 래퍼(wrapper) 없이 생(bare) self.db.execute()를 통해 DELETE를 실행합니다. db.py:2944에 있는 Table.delete()가 올바르게 래핑되어 있는 것과 비교해 보십시오. 연결이 in_transaction=True 상태로 남게 되어, 이후의 모든 atomic() 호출은 세이브포인트(savepoint) 분기(db.py:430-440)를 타게 되고 이 역시 커밋되지 않습니다. 전체 과정을 재현하면 다음과 같습니다:
db = sqlite_utils.Database("dw.db") db["t"].insert_all([{"id": i} for i in range(3)], pk="id") db["t"].delete_where("id = ?", [0]) # 이제 conn.in_transaction은 True입니다 db["t"].insert({"id": 50}) db["u"].insert({"a": 1}) db.close() # 다시 열기: 행(rows)은 [0, 1, 2]입니다 — 삭제된 행, 50번 행, 그리고 u 테이블이 모두 사라졌습니다.
이것은 정말 심각한 버그입니다! 이것을 출시하지 않아서 정말 다행입니다. 적어도 이것은 5.0 버전을 강제해야 하는 설계 결함이 아니라, 4.0.1 포인트 릴리스에서 수정할 수 있는 버그였을 것입니다.
37번의 프롬프트, 34번의 커밋, 그리고 30개의 별도 파일에 걸쳐 +1,321개, -190개의 코드 변경을 거치며, 우리는 피드백 세트 전체를 차례대로 처리했고 그 과정에서 몇 가지 다른 설계 개선도 이루었습니다.
코딩 에이전트 (coding agents)의 기묘한 점은, 이와 같이 어려운 작업일수록 오히려 다른 일들을 동시에 할 수 있는 더 많은 기회를 제공한다는 것입니다. 에이전트가 새로운 작업을 처리하는 데 때때로 10~15분이 걸리기 때문입니다. 저는 Half Moon Bay의 7월 4일 퍼레이드를 즐기러 나갔고, 가끔씩 휴대폰으로 확인하며 Fable에게 다음 단계를 지시했습니다.
자세한 내용은 PR (Pull Request)과 공유된 트랜스크립트 (transcript)에서 확인할 수 있습니다. 저는 최종 검토를 위해 노트북으로 전환하였으며, GitHub의 PR 인터페이스를 통해 검토를 진행했습니다.
가장 중요한 변경 사항은 트랜잭션 처리 (transaction handling)와 관련되어 있으며, 이는 이전 RC (Release Candidate) 버전의 핵심적인 신기능이었습니다. 새로운 RC에는 새로운 트랜잭션 모델에 대한 포괄적인 문서가 포함되어 있으며, 그 도입부를 여기에 전문 인용하겠습니다:
데이터베이스에 쓰기 작업을 수행하는 이 라이브러리의 모든 메서드—
insert()
, upsert()
, update()
, delete()
, delete_where()
, transform()
, create_table()
, create_index()
, enable_fts()
및 그 외의 메서드들은—각자의 트랜잭션 내부에서 실행되며 반환되기 전에 이를 커밋 (commit)합니다. 메서드 호출이 완료되는 즉시 변경 사항이 디스크에 저장됩니다:
db = Database("data.db")
db.table("news").insert({"headline": "Dog wins award"})
새 행이 이미 저장되었습니다 - commit()이 필요하지 않습니다
db.execute()로 실행되는 raw SQL에도 동일하게 적용됩니다. 쓰기 문은 실행되는 즉시 커밋됩니다.
commit()을 호출할 필요가 없으며, 변경 사항을 영구적으로 저장하기 위해 데이터베이스를 닫을 필요도 없습니다. 트랜잭션에 대해 고민해야 하는 상황은 정확히 두 가지입니다:
-
여러 쓰기 작업을 하나로 묶어 모두 성공하거나 모두 실패하게 만들고 싶은 경우 —
db.atomic()을 사용하세요. -
db.begin()을 사용하여 직접 트랜잭션을 관리하는 경우. 이 경우 커밋하기 전까지는 아무것도 커밋되지 않습니다. 라이브러리는 사용자가 직접 연 트랜잭션을 절대로 대신 커밋하지 않습니다.
Fable의 문서를 검토하면서—문서 수정 사항을 먼저 검토하는 것은 무엇이 바뀌었는지 초기 이해를 구축하는 데 매우 훌륭한 방법입니다—저는 이 세부 사항을 발견했습니다:
db.atomic()
그리고 메서드별 자동 트랜잭션 (automatic per-method transactions)은 Python의 기본 트랜잭션 처리 모드에서의 연결 (connections)을 위해 설계되었습니다. Python 3.12+의 sqlite3.connect(..., autocommit=True)
또는 autocommit=False
옵션으로 생성된 연결은 지원되지 않는데, 이는 해당 연결에서 commit()
과 rollback()
이 다르게 동작하기 때문입니다.
저는 sqlite-utils가 Python 3.12에서 추가된 최신 자동 커밋 (autocommit) 설정에 어떻게 반응할지 생각하지 못했음을 인정합니다. 결과적으로 “해당 연결에서 다르게 동작한다”는 점은 거의 모든 테스트 스위트 (test suite)가 실패하는 것과 같았고, 그래서 저는 이 차이점이 라이브러리의 작동 방식을 망가뜨리지 않도록 모델과 함께 작업했습니다.
그리고 GPT-5.5의 최종 검토
저는 한 모델이 다른 모델의 작업물을 검토한다는 아이디어가 다소 황당하다고 생각하곤 했습니다—묘하게 미신처럼 느껴졌기 때문입니다. 문제는 그것이 정말로 효과가 있다는 점입니다. 저는 Anthropic의 최고 모델이 OpenAI의 작업물을 검토하게 하고, 그 반대로도 하는 것을 습관적으로 시작했습니다. 왜냐하면 그 과정에서 가치 있다고 느낄 만큼 흥미로운 결과가 자주 나왔기 때문입니다.
저는 Codex Desktop과 GPT-5.5 xhigh에 다음과 같이 프롬프트 (prompt)를 입력했습니다:
Review changes since the last RC. Also confirm that the changelog is up-to-date. (지난 RC 이후의 변경 사항을 검토하세요. 또한 변경 로그 (changelog)가 최신 상태인지 확인하세요.)
이것만으로도 조사할 가치가 있는 두 가지 문제를 찾아내기에 충분했습니다:
발견 사항 (Findings)
- [P1] sqlite_utils/db.py:663
db.query()
는 이제 db.execute()
를 호출한 후에만 행(row)을 반환하지 않는 문(statement)을 거부하며, sqlite_utils/db.py:705는 해당 쓰기 작업을 먼저 자동 커밋 (auto-commits)합니다. 따라서 db.query("update ...")
는 ValueError
를 발생시키지만 업데이트는 이미 커밋된 상태입니다. 이는 “행을 반환하는 SQL에만 사용할 수 있음”이라고 문서화된 메서드로서 놀라운 부작용입니다.
- [P1] sqlite_utils/db.py:672
db.query()
를 통한 INSERT ... RETURNING
은 반환된 제너레이터 (generator)가 완전히 소진된 후에만 커밋됩니다. 반복 (iteration) 없는 db.query("insert ... returning ...")
나 일반적인 next(db.query(...))
의 경우를 말합니다.
사용법의 경우, 트랜잭션(transaction)을 열어둔 상태로 남겨두며, 닫을 때 쓰기 작업이 롤백(roll back)될 수 있습니다. 이는 반복(iteration) 없이도 효과가 적용된다고 명시된 docs/changelog.rst:15 및 docs/python-api.rst:232의 내용과 모순됩니다.
저는 이 내용을 새로운 Fable 세션에 붙여넣어 문제를 확인하기 위한 몇 가지 실험을 실행했습니다:
두 가지 발견 사항 모두 확인되었습니다.
db.query()가 먼저 self.execute()를 호출하여 쓰기 작업을 자동 커밋(auto-commit)한 다음, 그제서야 cursor.description을 확인했습니다. 즉, db.query("update ...")는 ValueError를 발생시키기 전에 업데이트를 커밋해 버렸습니다. 그리고 INSERT ... RETURNING 커밋은 반환된 제너레이터(generator)의 끝에 위치했기 때문에, 이터레이터(iterator)를 소진하지 않는 한—즉 next(db.query(...))를 호출하거나 반복되지 않은 호출을 통해—트랜잭션을 열린 상태로 남겨두게 되어, 변경 로그(changelog)와 문서(docs)의 약속과 모순되는 결과를 낳았습니다.
수정 사항이 포함된 PR(Pull Request)과 Claude Code의 전체 트랜스크립트(transcript)는 여기 있습니다. 이 코드를 검토하면서 SQLite 트랜잭션 의미론(semantics)의 예외 케이스(edge cases)에 대해 더 나은 멘탈 모델(mental model)을 구축하는 데 큰 도움이 되었습니다!
예상 비용 (보조금 미적용) $149.25
만약 제가 이 비용을 직접 지불했다면 얼마가 들었을지 궁금했습니다. 처음에는 웹용 Claude Code를 사용하여 원격으로 작업을 수행했기 때문에 해당 수치를 확인할 수 없을 것이라고 생각했지만, 기존 세션 내에서 AgentsView를 실행하여 해당 비용 추정치를 얻을 수 있다는 것을 깨달았습니다!
"uvx agentsview --help"를 실행한 다음, 해당 도구를 사용하여 이 세션의 비용을 계산하세요
Claude는 session list --include-children 명령어를 사용하는 방법을 찾아내어 다음과 같은 결과를 내놓았습니다:
| Transcript | Model | Cost |
|---|---|---|
| Main session | claude-fable-5 | $141.02 |
| ... | Total | $149.25 |
제가 이 구독 서비스를 이용하고 있어서 정말 다행입니다! 제 조언대로 더 저렴한 모델을 사용하는 하위 에이전트(subagents)를 더 적극적으로 활용했어야 했습니다.
현재 claude.ai/settings/usage에 표시되는 내용은 다음과 같습니다:

또한 가격 인상 시점에 맞춰 Fable 지표 100% 달성을 목표로, 현재 진행 중인 다른 주요 Fable 기반 프로젝트들도 여러 개 있습니다.
sqlite-utils 4.0rc2 전체 릴리스 노트 (Release Notes)
다음은 이번 RC(Release Candidate)의 전체 릴리스 노트입니다. 저는 각 변경 사항이 반영될 때마다 Fable이 이를 변경 로그(changelog)의 "미출시 (Unreleased)" 섹션에 추가하고 검토하도록 했습니다. 이 방식은 변경 로그의 커밋 히스토리가 이번 릴리스에 포함된 각 변경 사항의 간결한 요약 역할을 하게 되는 멋진 부수 효과를 가져왔습니다.
과거에는 릴리스 노트를 직접 작성하는 정책을 유지해 왔으나, 솔직히 말해서 이것들은 제가 직접 만든 것보다 더 낫습니다. 릴리스 노트는 지루하고, 예측 가능하며, 정확해야 하기 때문에 에이전트(agent)에게 외주를 주어도 괜찮은 글쓰기의 아주 좋은 예시입니다.
중대한 변경 사항 (Breaking changes):
db.execute()로 실행되는 쓰기 문(Write statements)은 이제 자동으로 커밋됩니다. 단, 이미 트랜잭션 (transaction)이 열려 있는 경우에는 해당 트랜잭션에 참여합니다. 이전에는 암시적 트랜잭션 (implicit transaction)을 열어 무언가가 커밋될 때까지 열린 상태로 유지되었습니다. 이로 인해 동일한 연결 (connection)에서 읽을 때는 쓰기가 작동하는 것처럼 보였으나, 연결이 닫힐 때 조용히 롤백 (rollback)되었습니다. 커밋되지 않은db.execute()쓰기의 롤백에 의존하던 코드는 먼저 명시적 트랜잭션 (explicit transaction)을 열기 위해 새로운db.begin()메서드를 사용해야 합니다. 트랜잭션 모델에 대한 전체 문서는 "Transactions and saving your changes"에서 확인할 수 있습니다.db.query()는 이제 반환된 제너레이터 (generator)가 처음 반복(iterate)될 때까지 기다리지 않고, 호출되는 즉시 SQL을 실행합니다. 행 (rows)은 여전히 반복 중에 지연 로딩 (lazily) 방식으로 가져옵니다. SQL 오류는 이제 호출 지점에서 발생합니다.INSERT ... RETURNING과 같은 문은 결과를 반복할 필요 없이 즉시 실행되고 커밋됩니다. 또한, 이전에는 아무 작업도 수행하지 않고 조용히 넘어갔던(silent no-op) '행을 반환하지 않는 문'을 전달할 경우, 이제는db.execute()사용을 권장하는ValueError가 발생합니다.
대신에 사용해야 합니다. 이렇게 거부된 문(statement)은 에러가 발생하기 전에 롤백(rollback)되므로 데이터베이스에 아무런 영향을 미치지 않습니다.
-
Python API 검증 에러가 이제
AssertionError대신ValueError를 발생시킵니다. 이전에는 컬럼이 없는create_table()호출, 존재하지 않는 테이블에 대한transform()호출, 또는ignore=True와replace=True를 동시에 전달하는 것과 같은 잘못된 인자(arguments)들이 단순한assert문을 통해 거부되었습니다. 하지만assert문은 Python이-O플래그와 함께 실행될 때 조용히 무시됩니다. 이러한 경우를 위해AssertionError를 잡도록 작성된 코드는 이제 대신ValueError를 잡아야 합니다. -
table.upsert()및table.upsert_all()은 이제 레코드에 기본 키(primary key) 컬럼 값이 누락되었거나None값을 가지고 있는 경우PrimaryKeyRequired를 발생시킵니다. 이전에는 기존 행과 절대 일치할 수 없는 이러한 레코드들이 조용히 새로운 행으로 삽입되었거나, 삽입이 이미 완료된 후에 혼란스러운KeyError를 발생시켰습니다. -
db.enable_wal()및db.disable_wal()은 트랜잭션(transaction)이 열려 있는 동안 호출될 경우 이제sqlite_utils.db.TransactionError를 발생시킵니다. 이전에는 저널 모드(journal mode)를 변경하는 부작용(side effect)으로 열려 있는 트랜잭션이 조용히 커밋(commit)되어,db.atomic()및 사용자가 관리하는 트랜잭션의 롤백(rollback) 보장을 깨뜨렸습니다. -
View클래스에 더 이상enable_fts()메서드가 없습니다. 이 메서드는 뷰(view)에 대한 전체 텍스트 검색(full-text search)이 지원되지 않기 때문에 단순히NotImplementedError를 발생시키기 위해서만 존재했습니다. 이제 이를 호출하면 대신AttributeError가 발생하며, 해당 메서드는 더 이상 API 레퍼런스에 나타나지 않습니다.sqlite-utils enable-fts명령어를 뷰에 지정할 경우 깔끔한 에러 메시지를 보여줍니다. -
아무런 동작도 하지 않는(no-op)
-d/--detect-types플래그가insert및upsert명령어에서 제거되었습니다. 타입 감지(type detection)는 4.0a1 버전부터 CSV/TSV 데이터에 대해 기본값으로 설정되었으므로, 해당 플래그는 아무런 역할도 하지 않았습니다. 이를 사용하는 호출에서는 단순히 플래그를 제거하면 됩니다. 감지를 비활성화하려면--no-detect-types를 계속 사용할 수 있습니다. -
Database()는 이제sqlite_utils.db.TransactionError를 발생시킵니다.
Python 3.12+의 sqlite3.connect(..., autocommit=True)로 생성된 연결이 전달되거나
또는 autocommit=False 옵션이 전달될 경우,
commit() 및 rollback() 메서드가 해당 연결에서 다르게 동작합니다. 이는 이전 버전에서 연결이 닫힐 때 라이브러리에 의해 수행된 모든 쓰기 작업이 조용히 폐기되는 문제를 야기했습니다. 그 외 변경 사항:
table.delete_where(),table.optimize()및table.rebuild_fts()에서 발생하던 버그를 수정했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Simon Willison Blog의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기