sqlite-utils 4.0rc2, 대부분 Claude Fable이 작성(약 $149.25)
원문은 Simon Willison님이 에 게재했습니다. 이 블로그 구독하기
몇 주 전 sqlite-utils 4.0rc1 릴리스에 대해 썼다. Max 구독으로 Claude Fable을 사용할 수 있는 기간이 며칠 남지 않아, 정말 만족할 수 있는 4.0 정식 릴리스를 만드는 데 도움을 받을 수 있을지 시험해 보기로 했다. SemVer를 지키려 노력하는 데다 호환성이 깨지는 메이저 버전은 최대한 드물게 내고 싶기 때문이다.
다음 프롬프트로 시작했다. iPhone의 Claude Code for web에서였다:
Final review before shipping a stable 4.0 release - very important to spot any last minute things that would be a breaking change if we fix them later
그 초기 리포트는 Fable이 만들어 준 것이다. 내가 아직 직접 마주치지 못했던 심각한 문제들이 있었는데, Fable이 ‘릴리스 차단’으로 분류한 것만 5개였다. 그중 가장 심각한 문제는 다음과 같다:
1.
delete_where()가 커밋되지 않고 커넥션을 오염시킴 (데이터 손실)
Table.delete_where()(sqlite_utils/db.py:2948)는atomic()래퍼 없이self.db.execute()로 바로DELETE를 실행한다 — 올바르게 래핑된db.py:2944의Table.delete()와 비교해 보라. 커넥션은in_transaction=True상태로 남고, 이후의 모든atomic()호출은 세이브포인트 분기(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 is now True db["t"].insert({"id": 50}) db["u"].insert({"a": 1}) db.close() # Reopen: rows are [0, 1, 2] — the delete, row 50, AND table u are all gone.
정말 심각한 버그다! 이 상태로 배포하지 않아서 다행이다. 그나마 다행인 점은 4.0.1 포인트 릴리스에서 고칠 수 있는 버그였지, 5.0을 강제할 설계 결함은 아니었다는 것이다.
총 37개의 프롬프트, 34개의 커밋, 30개 파일에 걸친 +1,321 -190줄의 코드 변경을 거치며 피드백을 하나씩 모두 처리했고, 그 과정에서 몇 가지 추가적인 설계 개선도 함께 진행했다.
코딩 에이전트의 묘한 점은 이렇게 어려운 작업일수록 오히려 다른 일을 병행할 여지가 더 생긴다는 것이다. 에이전트가 새 작업을 처리하는 데 10~15분이 걸리기도 하기 때문이다. 나는 Half Moon Bay의 7월 4일 퍼레이드를 즐기러 나간 사이 틈틈이 휴대폰으로 확인하며 Fable에 다음 단계를 지시했다.
전체 내용은 PR과 이 공유 트랜스크립트에 있다. 최종 리뷰는 노트북으로 옮겨 GitHub PR 인터페이스에서 진행했다.
가장 중요한 변경은 트랜잭션 처리와 관련된다. 이는 이전 RC의 핵심 신기능이었다. 새 RC에는 새로운 트랜잭션 모델에 대한 종합 문서가 포함됐는데, 그 서두를 여기 전문 인용한다:
이 라이브러리에서 데이터베이스에 쓰는 모든 메서드—
insert(),upsert(),update(),delete(),delete_where(),transform(),create_table(),create_index(),enable_fts()등—는 각자 자체 트랜잭션 안에서 실행되며 반환 전에 커밋된다. 메서드 호출이 끝나는 즉시 변경 사항이 디스크에 저장된다:db = Database("data.db") db.table("news").insert({"headline": "Dog wins award"}) # The new row is already saved - no commit() required이는 db.execute()로 실행하는 로우 SQL에도 동일하게 적용된다—쓰기 문은 실행 즉시 커밋된다.
commit()을 호출할 필요가 전혀 없으며, 변경 사항을 유지하기 위해 데이터베이스를 닫을 필요도 없다. 트랜잭션을 의식해야 하는 경우는 정확히 두 가지뿐이다:
여러 쓰기 작업을 하나로 묶어 모두 성공하거나 모두 실패하게 하고 싶을 때—db.atomic()을 사용한다.
트랜잭션을 직접 관리할 때,
db.begin()으로 시작하면 커밋할 때까지 아무 것도 커밋되지 않는다—라이브러리가 사용자가 연 트랜잭션을 임의로 커밋하지 않는다.
Fable의 문서를 검토하면서—문서 수정부터 살펴보는 것이 무엇이 바뀌었는지 초기에 파악하는 아주 좋은 방법이라고 생각한다—이 세부 내용을 발견했다:
db.atomic()과 메서드별 자동 트랜잭션은 Python 기본 트랜잭션 처리 모드의 커넥션을 전제로 설계됐다. Python 3.12+의sqlite3.connect(..., autocommit=True)또는autocommit=False옵션으로 생성된 커넥션은 지원되지 않는다. 해당 커넥션에서는commit()과rollback()의 동작이 다르기 때문이다.
솔직히 Python 3.12에 추가된 최신 autocommit 설정에 sqlite-utils가 어떻게 반응할지는 생각해 본 적이 없었다. 알고 보니 “해당 커넥션에서 다르게 동작한다”는 말은 거의 전체 테스트 스위트가 실패한다는 뜻이었고, 그래서 모델과 함께 이 차이가 라이브러리 동작을 망가뜨리지 않도록 작업했다.
그리고 GPT-5.5의 최종 리뷰
한 모델이 다른 모델의 작업을 리뷰하게 한다는 발상은 예전엔 다소 터무니없게 느껴졌다—묘하게 미신처럼 보였다. 문제는 정말 효과가 있다는 것이다—이제는 습관적으로 Anthropic의 최고 모델에 OpenAI의 작업을, 그리고 그 반대로도 리뷰를 시킨다. 흥미로운 결과를 꽤 자주 얻을 수 있어 충분히 가치가 있기 때문이다.
Codex Desktop과 GPT-5.5 xhigh에 다음과 같이 프롬프트했다:
Review changes since the last RC. Also confirm that the changelog is up-to-date.
그것만으로도 조사할 만한 두 가지 이슈가 드러났다:
발견 사항
- [P1] sqlite_utils/db.py:663의
db.query()는db.execute()를 호출한 뒤에야 행을 반환하지 않는 문을 거부하고, sqlite_utils/db.py:705에서는 그 쓰기를 먼저 자동 커밋한다. 그래서db.query("update ...")는ValueError를 일으키지만 업데이트는 이미 커밋된 상태다. “행을 반환하는 SQL에만 사용할 수 있다”고 문서화된 메서드로서는 놀라운 부작용이다.- [P1] sqlite_utils/db.py:672의
INSERT ... RETURNING을db.query()로 실행하면 반환된 제너레이터를 완전히 소진한 뒤에야 커밋된다. 이터레이션 없이db.query("insert ... returning ...")를 호출하거나 흔한next(db.query(...))패턴을 사용하면 트랜잭션이 열린 채로 남아 닫힐 때 롤백될 수 있다. 이는 이터레이션 없이도 즉시 반영된다고 한 docs/changelog.rst:15와 docs/python-api.rst:232의 설명과 모순된다.
그 내용을 새로운 Fable 세션에 붙여 넣었고, Fable이 몇 가지 실험을 돌려 문제를 확인했다:
두 발견 모두 확인됐다.
db.query()는 먼저self.execute()를 호출해 쓰기를 자동 커밋한 뒤에야cursor.description을 검사하므로,db.query("update ...")는ValueError를 일으키기 전에 이미 업데이트를 커밋했다. 그리고INSERT ... RETURNING의 커밋은 반환된 제너레이터 끝에 있어 이터레이터를 끝까지 소진하지 않으면 실행되지 않았다 —next(db.query(...))나 이터레이션 없이 호출하면 트랜잭션이 열린 채 남아 변경 로그와 문서가 약속한 내용과 모순된다.
수정이 담긴 PR과 전체 Claude Code 트랜스크립트는 여기 있다. 이 코드를 리뷰하면서 SQLite 트랜잭션 의미론의 엣지 케이스에 대해 더 명확한 멘탈 모델을 갖게 됐다!
예상 (보조금 미적용) 비용 $149.25
Claude Max $200/월 플랜으로 업그레이드했다(이전에는 $100/월이었다). 7월 7일 Fablepocalypse까지 남은 기간 동안 Fable 사용량을 늘리기 위해서였다. 그날부터는 Claude Max 구독자도 모델에 대한 전체 API 비용을 지불해야 한다.
비용을 직접 지불했다면 얼마나 들었을지 궁금했다. 처음에는 Claude Code for web으로 원격으로 작업을 실행했기에 그런 수치를 확인할 수 없을 거라 생각했는데, 기존 세션 안에서 AgentsView를 실행하면 비용 추정치를 얻을 수 있다는 걸 깨달았다!
Run "uvx agentsview --help" and then use that tool to calculate the cost of this session
Claude는 session list --include-children 명령어 사용법을 파악해 다음과 같은 결과를 내놓았다:
| 트랜스크립트 | 모델 | 비용 |
|---|---|---|
| Main session | claude-fable-5 | $141.02 |
| API-surface sweep agent | claude-fable-5 | $2.40 |
| Transactions/atomic review agent | claude-fable-5 | $2.39 |
| Post-rc1 commits review agent | claude-fable-5 | $1.72 |
| Migrations review agent | claude-fable-5 | $1.40 |
| Prompt-counting agent | claude-opus-4-8 | $0.32 |
| 합계 | $149.25 |
그 구독을 하고 있어서 정말 다행이다! 내 조언을 따라 더 저렴한 모델의 서브에이전트를 더 적극적으로 활용했어야 했다.
현재 claude.ai/settings/usage에 표시되는 내용은 다음과 같다:

다른 Fable 기반 대형 프로젝트도 여러 개 진행 중이며, 가격 인상 직전에 저 Fable 바를 100%까지 채우는 것이 목표다.
sqlite-utils 4.0rc2 전체 릴리스 노트
전체 릴리스 노트는 여기 있다. 각 변경이 적용될 때마다 Fable이 이를 변경 로그의 “Unreleased” 섹션에 추가하도록 했고, 그때마다 검토했다. 덕분에 변경 로그의 커밋 히스토리가 이번 릴리스에 포함된 각 변경을 간결하게 요약하게 됐다는 깔끔한 부수 효과도 있다.
예전에는 릴리스 노트를 직접 쓰는 것을 원칙으로 했지만, 솔직히 이번 노트는 내가 직접 썼을 것보다 낫다. 릴리스 노트는 지루하고 예측 가능하며 정확해야 하기 때문에 에이전트에 맡겨도 괜찮다고 생각하는 글쓰기의 좋은 예다.
호환성이 깨지는 변경:
db.execute()로 실행된 쓰기 문은 이제 트랜잭션이 이미 열려 있는 경우가 아니라면 자동으로 커밋되며, 열려 있다면 그 트랜잭션에 참여한다. 이전에는 무언가 커밋할 때까지 열린 채 남는 암시적 트랜잭션을 열었기 때문에, 같은 커넥션에서 읽을 때는 쓰기가 된 것처럼 보여도 커넥션을 닫으면 조용히 롤백됐다. 커밋되지 않은db.execute()쓰기의 롤백에 의존하던 코드는 먼저 명시적 트랜잭션을 열기 위해 새로운db.begin()메서드를 사용해야 한다. 트랜잭션 모델 전체는 Transactions and saving your changes에 문서화되어 있다.db.query()는 이제 반환된 제너레이터가 처음 이터레이션될 때까지 기다리지 않고 호출되는 즉시 SQL을 실행한다. 행은 여전히 이터레이션 중에 지연 로드된다. SQL 오류는 이제 호출 지점에서 발생하고,INSERT ... RETURNING같은 문은 결과를 이터레이션하지 않아도 즉시 실행·커밋되며, 행을 반환하지 않는 문을 전달하는 경우—이전에는 조용히 아무 일도 일어나지 않았다—이제db.execute()사용을 권하는ValueError를 일으킨다. 이렇게 거부된 문은 오류가 발생하기 전에 롤백되므로 데이터베이스에 아무 영향도 주지 않는다.- Python API 유효성 검사 오류는 이제
AssertionError대신ValueError를 일으킨다. 이전에는 열 없이create_table()을 호출하거나 존재하지 않는 테이블에transform()을 적용하거나ignore=True와replace=True를 동시에 전달하는 등의 잘못된 인자가 단순assert문으로 거부됐는데, 이는 Python을-O플래그로 실행하면 조용히 건너뛰어졌다. 이런 경우에AssertionError를 잡던 코드는 대신ValueError를 잡아야 한다.table.upsert()와table.upsert_all()은 이제 레코드에 기본 키 컬럼 중 하나에 대한 값이 없거나 값이None인 경우PrimaryKeyRequired를 일으킨다. 이전에는—기존 행과 절대 매치될 수 없는—이런 레코드가 조용히 새 행으로 삽입되거나, 삽입이 이미 이뤄진 뒤 혼란스러운KeyError를 일으켰다.db.enable_wal()과db.disable_wal()은 이제 트랜잭션이 열려 있는 동안 호출되면sqlite_utils.db.TransactionError를 일으킨다. 이전에는 저널 모드 변경의 부작용으로 열린 트랜잭션을 조용히 커밋해db.atomic()과 사용자 관리 트랜잭션의 롤백 보장을 깨뜨렸다.View클래스는 더 이상enable_fts()메서드를 갖지 않는다. 뷰에서는 전문 검색이 지원되지 않으므로NotImplementedError를 일으키기 위해서만 존재하던 메서드였는데, 이제 호출하면 대신AttributeError가 발생하고 API 레퍼런스에서도 사라졌다.sqlite-utils enable-fts명령은 뷰를 가리킬 때 깔끔한 오류를 표시한다.insert와upsert명령에서 아무 동작도 하지 않던-d/--detect-types플래그가 제거됐다. 타입 감지는 4.0a1부터 CSV/TSV 데이터의 기본값이었으므로 이 플래그는 아무 효과가 없었다—이를 사용하던 호출은 단순히 플래그를 빼면 된다. 감지를 비활성화하는--no-detect-types는 그대로 사용할 수 있다.Database()는 이제 Python 3.12+의sqlite3.connect(..., autocommit=True)또는autocommit=False옵션으로 생성된 커넥션을 전달받으면sqlite_utils.db.TransactionError를 일으킨다. 해당 커넥션에서는commit()과rollback()의 동작이 달라서, 이전에는 라이브러리가 만든 모든 쓰기가 커넥션을 닫을 때 조용히 버려지는 문제가 있었다.그 밖의 변경:
table.delete_where(),table.optimize()와table.rebuild_fts()가 변경 사항을 커밋하지 않아 커넥션을 열린 트랜잭션 안에 남겨 두던 버그를 수정했다. 그 작업과 이후의 모든 쓰기는 커넥션을 닫을 때 조용히 롤백될 수 있었다. 세 메서드는 모두 이제 다른 쓰기 메서드와 일관되게db.atomic()을 사용한다.sqlite-utils drop-table명령은 이제 뷰 삭제를 거부하고,drop-view는 테이블 삭제를 거부한다. 이전에는 이름이 일치하면 각각 잘못된 유형의 객체를 조용히 삭제했다. 이제 둘 다 올바른 명령을 사용하라는 오류와 함께 종료된다.- 새로운 마이그레이션 시스템이 적용하는 마이그레이션은 이제 마이그레이션 적용 기록과 함께 트랜잭션 안에서 실행된다. 마이그레이션이 예외를 일으키면 변경 사항은 롤백되고 보류 상태로 남아, 오류를 수정한 뒤 안전하게 다시 적용할 수 있다.
VACUUM을 실행하는 것처럼 트랜잭션 안에서 실행할 수 없는 마이그레이션은@migrations(transactional=False)로 제외할 수 있다—Migrations and transactions를 참조하라.table.upsert()와table.upsert_all()은 이제 기존 테이블의 기본 키 또는 복합 기본 키를 감지하므로, 이미 기본 키가 있는 테이블에 upsert할 때는pk=인자가 더 이상 필요하지 않다.- 이제
db.table(table_name).insert({})를 사용해INSERT INTO ... DEFAULT VALUES로 기존 테이블에 기본값으로만 이루어진 행을 삽입할 수 있다. (#759)sqlite-utils migrate명령 개선: 알려진 마이그레이션과 일치하지 않는--stop-before값은 이제 조용히 무시되는 대신 오류가 되고,--stop-before는 여전히 이전sqlite_migrate.Migrations클래스를 사용하는 마이그레이션 파일에서도 올바르게 동작하며,--list는 이제 더 이상 데이터베이스 파일이나 마이그레이션 추적 테이블을 생성하지 않는 읽기 전용 동작이 됐다.migrations.applied()는 이제 적용된 순서대로 마이그레이션을 반환한다.- 트랜잭션을 수동으로 제어하기 위한 새로운
db.begin(),db.commit()및db.rollback()메서드.db.atomic()컨텍스트 매니저의 대안이다.- 새 문서: Transactions and saving your changes에서 트랜잭션이 어떻게 동작하고 변경 사항이 언제 커밋되는지 설명하고, 새로운 Upgrading 페이지에서 메이저 버전 간 이동에 필요한 변경 사항을 자세히 다룬다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기