몇 주 전 sqlite-utils 4.0rc1 릴리스에 대해 글을 쓴 바 있습니다. Max 구독으로 Claude Fable을 사용할 수 있는 기간이 며칠 남지 않은 상황에서, 이 기회를 활용해 안정 버전인 4.0 출시까지 마무리해 보기로 했습니다. SemVer(시맨틱 버전 관리)를 원칙으로 삼고 있는 만큼, 하위 호환성을 깨는 메이저 버전 업그레이드는 최대한 드물게 가져가고 싶었기 때문입니다.
아이폰의 웹용 Claude Code에서 다음과 같은 프롬프트로 작업을 시작했습니다:
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)은 DELETE를 실행할 때atomic()래퍼 없이self.db.execute()만 사용합니다.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의 독립기념일 퍼레이드를 즐기면서, 틈틈이 폰으로 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() requireddb.execute()로 실행하는 raw 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가 어떻게 반응하는지는 사실 미처 생각하지 못했습니다. "동작 방식이 달라진다"는 표현이 사실상 테스트 스위트 전체가 실패하는 수준이었고, 결국 이 차이가 라이브러리 동작을 깨뜨리지 않도록 모델과 함께 대응 방안을 마련했습니다.
한 모델이 다른 모델의 작업을 검토하는 방식은 다소 이상하게 느껴질 수 있고, 저도 예전에는 왠지 미신적인 발상처럼 느껴진다고 생각했습니다. 그런데 실제로 효과가 있습니다. 충분히 유의미한 결과가 나온 경험이 쌓이다 보니, 이제는 Anthropic의 최신 모델로 OpenAI 작업 결과를 검토하거나, 반대로 OpenAI 모델로 Anthropic 작업을 검토하는 것이 습관이 되었습니다.
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 세션에 붙여넣었더니, 문제를 직접 실험으로 확인해 주었습니다:
두 문제 모두 확인되었습니다.
db.query()가self.execute()을 먼저 호출하면서 쓰기가 자동 커밋되었고, 그 이후에cursor.description를 검사하는 구조였습니다. 결국db.query("update ...")는ValueError를 발생시키기 전에 업데이트를 이미 커밋해 버린 것입니다. 또한INSERT ... RETURNING의 커밋이 반환된 제너레이터 끝에 위치해 있어, 이터레이터를 끝까지 소진하지 않으면 실행되지 않았습니다.next(db.query(...))처럼 반복하지 않거나 호출 후 사용하지 않으면 트랜잭션이 열린 채로 남아, 체인지로그와 문서의 내용을 어기게 됩니다.
수정 사항이 담긴 PR과 전체 Claude Code 트랜스크립트도 확인하실 수 있습니다. 이 코드를 검토하면서 SQLite 트랜잭션 시맨틱의 엣지 케이스에 대한 이해가 한층 깊어졌습니다.
7월 7일 Fablepocalypse 전까지 남은 기간 동안 Fable 사용량을 늘리기 위해 Claude Max 플랜을 월 $100에서 $200으로 업그레이드했습니다. 이 날부터는 Claude Max 구독자도 해당 모델을 API 전체 비용으로 사용해야 합니다.
직접 API 비용을 지불했다면 얼마나 들었을지 궁금했습니다. 처음에는 웹용 Claude Code로 원격 작업을 진행했기 때문에 해당 수치를 확인할 수 없다고 생각했는데, 기존 세션 내에서 AgentsView를 실행하면 비용을 추산할 수 있다는 걸 뒤늦게 깨달았습니다.
Run "uvx agentsview --help" and then use that tool to calculate the cost of this session
Claude는 session list --include-children 명령어 사용법을 파악해 다음 결과를 도출했습니다:
| 트랜스크립트 | 모델 | 비용 |
|---|---|---|
| 메인 세션 | claude-fable-5 | $141.02 |
| API 표면 스윕 에이전트 | claude-fable-5 | $2.40 |
| 트랜잭션/atomic 검토 에이전트 | claude-fable-5 | $2.39 |
| rc1 이후 커밋 검토 에이전트 | claude-fable-5 | $1.72 |
| 마이그레이션 검토 에이전트 | claude-fable-5 | $1.40 |
| 프롬프트 카운팅 에이전트 | claude-opus-4-8 | $0.32 |
| 합계 | $149.25 |
구독 플랜을 유지하고 있어 정말 다행입니다! 돌이켜 보면 제가 직접 조언한 대로, 더 저렴한 모델을 활용한 서브에이전트를 적극적으로 썼어야 했습니다.
현재 claude.ai/settings/usage에 표시되는 사용량은 다음과 같습니다:

현재 Fable을 활용한 대형 프로젝트를 여러 개 동시에 진행 중입니다. 가격 인상 전까지 Fable 사용량을 100%로 채우는 것이 목표입니다.
RC의 전체 릴리스 노트입니다. 변경 사항이 반영될 때마다 Fable에게 체인지로그의 "Unreleased" 섹션에 추가하도록 하고 직접 검토했습니다. 덕분에 체인지로그의 커밋 히스토리가 각 변경 사항을 간결하게 요약하는 역할도 하게 되었습니다.
지금까지는 릴리스 노트를 직접 손으로 작성해 왔는데, 솔직히 말하면 이번에 Fable이 작성한 결과물이 제가 썼을 것보다 낫습니다. 릴리스 노트는 단조롭고 예측 가능하며 정확해야 하는 글이라는 점에서, 에이전트에게 맡기기에 딱 좋은 작업입니다.
호환성 변경 사항:
db.execute()로 실행하는 쓰기 구문은 이제 자동으로 커밋됩니다. 단, 트랜잭션이 이미 열려 있는 경우에는 해당 트랜잭션에 합류합니다. 이전에는 암묵적 트랜잭션이 열린 채로 유지되어, 같은 커넥션에서 읽으면 쓰기가 반영된 것처럼 보였지만 커넥션이 닫히면 조용히 롤백되었습니다.db.execute()의 미커밋 쓰기를 롤백하는 로직에 의존하던 코드는 새로운db.begin()메서드로 먼저 명시적 트랜잭션을 열어야 합니다. 트랜잭션 모델에 대한 자세한 내용은 트랜잭션 및 변경 사항 저장 문서를 참고하세요.db.query()는 이제 반환된 제너레이터가 처음 반복될 때까지 기다리지 않고, 호출 즉시 SQL을 실행합니다. 행은 반복 중에 지연 페치됩니다. SQL 오류는 이제 호출 지점에서 발생하며,INSERT ... RETURNING와 같은 구문은 반복 없이 즉시 실행 및 커밋됩니다. 행을 반환하지 않는 구문을 전달하면—이전에는 조용히 무시되었던—ValueError가 발생하며db.execute()사용을 권장합니다. 이렇게 거부된 구문은 오류 발생 전에 롤백되므로 데이터베이스에 영향을 미치지 않습니다.- 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)로 제외할 수 있습니다. 자세한 내용은 마이그레이션과 트랜잭션을 참고하세요.table.upsert()과table.upsert_all()는 이제 기존 테이블의 기본 키 또는 복합 기본 키를 자동으로 감지합니다. 기본 키가 이미 정의된 테이블에 업서트할 때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.atomic()컨텍스트 매니저의 대안으로 트랜잭션을 직접 제어할 수 있는db.begin(),db.commit(),db.rollback()메서드가 새로 추가되었습니다.- 새 문서 추가: 트랜잭션 및 변경 사항 저장에서는 트랜잭션 동작 방식과 변경 사항이 커밋되는 시점을 설명하며, 새로 추가된 업그레이드 페이지에서는 메이저 버전 간 마이그레이션에 필요한 변경 사항을 상세히 안내합니다.
현재 제 블로그의 장문 아티클만 표시되고 있습니다. 모든 포스트를 받아보시려면 /atom/everything/을 구독하시거나, 다른 구독 옵션을 확인해 보세요.