7k 스타 라이브러리의 녹색 테스트 스위트 뒤에 숨겨져 있던 4개의 심각한 버그
요약
SQLAlchemy 2.0 업데이트로 인해 발생한 records 라이브러리의 4가지 심각한 버그를 분석합니다. 자동 커밋 동작 변화와 연결 관리 방식의 차이로 인해 데이터 유실 및 커넥션 풀 누수가 발생함을 설명합니다.
핵심 포인트
- SQLAlchemy 1.x와 2.x 간의 의존성 시맨틱 차이로 인한 버그 발생
- 자동 커밋 기능 제거로 인한 데이터 유실 문제
- close_with_result 플래그 무시로 인한 커넥션 풀 누수
- 트랜잭션 예외 처리 미흡으로 인한 오류 은폐
kennethreitz/records의 upstream HEAD에서 pytest를 실행하면 모든 유지 관리자가 원하는 결과가 나옵니다: 31 passed. 모든 테스트가 녹색(Green)입니다.
정확히 동일한 커밋에서, 다음 사항들이 모두 사실입니다:
db.query("INSERT ...")가 데이터를 조용히 유실합니다. 연결이 닫힐 때 행(rows)이 사라집니다. 에러도, 경고도, 아무것도 없습니다.db.bulk_query(...)도 동일한 방식으로 데이터를 유실합니다.db.transaction()은 내부에서 발생하는 모든 예외(exception)를 삼켜버립니다. 실패한 트랜잭션이 성공했다고 보고됩니다.- 모든 단일
query()호출이 풀링된 연결(pooled connection)을 누수(leak)시킵니다. 기본 크기의 풀을 사용할 경우, 세 번째 쿼리에서 프로세스가 중단(hang)될 수 있습니다.
4개의 심각한(High-severity) 버그. 하나의 녹색 테스트 스위트. 저는 이것이 어떻게 가능한지 살펴보고 싶습니다. 왜냐하면 그 메커니즘이 개별 버그보다 더 흥미로우며, 아마 우리가 인정하고 싶은 것보다 더 많은 코드베이스에 존재할 것이기 때문입니다.
시작하기 전 완전한 공개(Full disclosure): 저는 제가 직접 구축한 자율 감사 루프(autonomous audit loop)를 통해 이것들을 발견했으며, 이에 대해 마지막에 조금 언급하겠습니다. 이 포스트의 모든 내용은 독립적으로 확인 가능합니다. 재현(repro) 스크립트는 upstream HEAD를 대상으로 실행되며, 4개의 버그 모두 records#236에서 PR 제안과 함께 upstream에 공개되었습니다. 이 중 어떤 것도 records나 그 유지 관리자들에 대한 비판이 아닙니다. 이것은 아름다운 API를 가진 사랑받는 라이브러리이며, 여기서 발생한 일은 부주의함이 아닌 의존성 시맨틱(dependency semantics)에 관한 이야기입니다.
실제로 무엇이 고장 났는가
records는 SQLAlchemy 1.x 시대에 작성되었으며, 그 Connection 클래스는 SQLAlchemy 2.0에서 제거된 두 가지 1.x 동작에 의존하고 있습니다.
첫째, 자동 커밋 (autocommit)입니다. 1.x 버전에서는 연결 (connection)을 통해 단순 INSERT를 실행하면 암시적으로 커밋될 수 있었습니다. 2.x 버전에서는 자동 커밋 기능이 사라졌습니다. 즉, 아무도 commit()을 호출하지 않으면 연결이 닫힐 때 트랜잭션 (transaction)이 롤백 (rollback)됩니다. records는 일반 쿼리에 대해 commit()을 호출하지 않는데, 그동안 그럴 필요가 없었기 때문입니다. 따라서 SQLAlchemy 2.x 환경에서 db.query("INSERT INTO users ...")를 실행하면, 실행은 되고 성공한 것처럼 보이지만 연결이 종료될 때 해당 행은 조용히 사라집니다. bulk_query의 경우도 마찬가지입니다.
둘째, close_with_result입니다. 1.x 버전에서 이 플래그는 결과 집합 (result set)이 소진되면 연결이 스스로 닫힌다는 것을 의미했으며, records는 이 방식으로 연결을 풀 (pool)에 반환했습니다. 2.x 버전에서는 이 플래그가 허용은 되지만 무시됩니다. 모든 query()는 풀에서 연결을 빌려오기만 하고 결코 반환하지 않습니다. 오버플로 (overflow) 1개를 포함하여 크기가 1인 풀은 세 번째 쿼리에서 고갈되어 죽게 됩니다.
세 번째 버그는 더 오래되었고 인간적인 실수에 가깝습니다. db.transaction()은 본문을 단순한 except: 구문으로 감싸고 있는데, 이는 롤백을 수행한 뒤 예외를 다시 발생시키지 (re-raise) 않습니다. 호출자는 트랜잭션이 실패했는지 성공했는지 알 수 없습니다. 저를 멈춰 세운 상세 내용은 다음과 같습니다. 업스트림 (upstream)에서 2026-02-08에 누락된 raise를 추가했다가 같은 날 다시 되돌렸습니다(revert). 수정 사항이 존재했던 시간은 불과 몇 시간뿐이었습니다. 아마도 테스트 스위트 (test suite)의 무언가가 이에 반대했을 것이고, 결국 수정 사항은 폐기되었습니다.
이것이 우리를 진짜 주제로 이끕니다.
테스트 스위트가 계속 녹색(Green)이었던 이유
records 테스트 스위트는 인메모리 (in-memory) 방식의 SQLite를 대상으로 실행됩니다. 이러한 선택은 빠르고, 격리되어 있으며, 위 네 가지 버그를 완전히 인지하지 못합니다.
인메모리 SQLite 데이터베이스는 단일 연결 위에서 동작합니다. 커밋되지 않은 행을 보지 못해 실패할 별도의 리더 (reader)가 없으므로, 누락된 커밋은 보이지 않습니다. 해당 하나의 공유 연결 내에서는 커밋되지 않은 상태가 커밋된 상태와 정확히 똑같이 보이기 때문입니다. 또한 고갈될 실제 풀 경계 (pool boundary)가 없으므로, 누출된 연결들 역시 보이지 않습니다. 테스트 스위트는 지속성 (persistence)을 관찰할 수 없고, 풀 동작을 관찰할 수 없으며, 커밋 의미론 (commit semantics)을 관찰할 수 없습니다. 왜냐하면 그들의 세계에서는 그러한 개념들이 존재하지 않기 때문입니다.
그리고 트랜잭션 버그는요? 한 테스트가 실제로 이 깨진 동작을 주장하고 있었습니다. 그 스위트(suite)는 단순히 버그를 놓친 것이 아니었습니다. 오히려 그것을 방어했습니다. 아마도 1일 만에 적용된 수정 사항이 되돌려진 이유가 바로 이것일 것입니다. 수정 사항 때문에 테스트가 실패했고, 그 테스트가 잘못되었기 때문입니다.
이것이 일반화할 수 있는 교훈이며, AI나 레코드(records)와 특별히 관련된 것은 없습니다. 테스트 게이트는 측정 장치이고, 장치는 정밀하고 빠르며 신뢰할 수 있지만, 틀린 세계를 향할 수도 있습니다. records의 게이트는 “이 코드가 단일 공유 인메모리 연결(shared in-memory connection)에 대해 작동하는가?”를 측정했고, 그 대답은 솔직히 ‘예’였습니다. 아무도 “이 코드가 데이터베이스에 대해 작동하는가?”라는 질문을 하지 않았는데, 이것이 사용자들이 신경 쓰는 유일한 질문입니다.
녹색(Green)은 코드의 속성이 아닙니다. 녹색은 코드와 게이트가 결합된 전체의 속성입니다.
버그를 발견한 방법
저는 로컬 클론에 대해 자율 개선 루프(autonomous improvement loop)를 실행했습니다. 이 루프의 첫 번째 반복은 항상 감사(audit)이며, 감사는 하나의 엄격한 규칙을 가지고 있습니다. 즉, 발견된 사항은 현장에서 재현될 수 있을 때만 존재한다는 것입니다. “이것이 이상해 보인다” 같은 것은 없습니다. 모든 주장은 감사가 업스트림 HEAD에 대해 실행하고 실패하는 것을 지켜보면서 시작된 작은 스크립트에서 비롯되었습니다.
감사는 또한 구조적인 무언가를 발견했습니다. 네 가지 High 중 세 개가 다른 옷을 입은 동일한 버그라는 것입니다. 커밋 누락, close_with_result 무시, 그리고 varargs 깨짐 모두 하나의 근본 원인, 즉 SQLAlchemy 1.x 실행 의미론(execution semantics)에 맞춰 작성된 Connection 클래스로 거슬러 올라갑니다. 이 루프는 정확히 이러한 패턴에 대해 세 번의 기회 규칙(three-strike rule)을 가지고 있습니다. 동일한 근본 원인을 공유하는 세 번째 발견은 단일 구조적 수정(single structural fix)을 강제하며, 결코 세 번째 임시 패치(third spot patch)를 허용하지 않습니다.
따라서 수정 사항은 구조적이었습니다. 결과 집합(result set)이 소진되었을 때의 커밋 및 확정(commit-and-finalize), DML 문에 대한 즉각적인 커밋, 단일 결과 연결(single-result connections)의 실제 풀(pool) 반환, 그리고 명시적 트랜잭션(explicit transaction) 내부에서 오토커밋(auto-commit)이 절대 실행되지 않도록 보장하는 트랜잭션 래퍼(transaction wrapper)가 포함되었습니다. 마지막 조항이 중요합니다. 단순한 스팟 패치(spot patch, 모든 문장 뒤에 커밋을 수행하는 방식)는 롤백(rollback) 기능을 조용히 망가뜨립니다. 왜냐하면 오토커밋 뒤에 오는 롤백은 아무것도 롤백하지 않기 때문입니다. 구조적 수정은 테스트를 통해 롤백이 여전히 작동함을 증명합니다.
transaction() 수정은 상류(upstream)에서 변경되었다가 되돌려졌던 사항, 즉 롤백 후 재발생(re-raise)을 복구합니다.
게이트에 이빨을 달아주기
보이지 않는 게이트 아래에서 코드를 수정하는 것은 무의미하므로, 루프(loop)의 다음 과제는 측정 도구를 재구축하는 것이었습니다. 삼킴 버그(swallow bug)를 단언하던 테스트가 수정되었습니다. 파일 기반의 회귀 테스트 스위트(regression suite)가 추가되었습니다: 두 가지 쿼리 경로와 두 가지 API 형태 모두에서의 지속성(persistence), 풀 고갈(pool exhaustion), 예외 전파(exception propagation), 명시적 롤백(explicit rollback)을 포함합니다.
그다음은 이번 작업 전체에서 제가 가장 좋아하는 결과물인 정직성 검사(honesty check)였습니다. 새로운 회귀 테스트 스위트를 감사 체크포인트(audit checkpoint)의 수정 전 코드에 대해 실행했더니, 6개 테스트 중 5개가 거기서 실패했습니다. 고장 난 코드에서도 결코 실패하지 않았던 회귀 테스트는 아무것도 증명하지 못합니다. 이 테스트는 자신이 탐지한다고 주장하는 세상을 실제로 탐지한다는 것이 증명되었습니다. 수정 후, 전체 스위트는 37개 모두 통과합니다.
거부한 것들
두 가지 일은 일어나지 않았으며, 이 또한 수정 사항만큼이나 중요합니다.
루프는 서면 사유와 함께 린트 게이트(lint gate)를 부과하는 것을 거부했습니다: records에는 린트 설정이 선언되어 있지 않으므로, 린트 통과 작업은 휴면 상태인 프로젝트에 미용적인 소란(cosmetic churn)만 일으킬 뿐이라는 이유였습니다. 그리고 루프가 결정할 권한이 없는 두 가지 결정 사항은 직접 내리는 대신 소유자(owner)에게 전달되었습니다: 유지보수되지 않는 docopt 의존성을 교체할지 여부와 멀티 백엔드 CI 매트릭스(multi-backend CI matrix)를 복구할지 여부입니다. 소유자의 결정을 가로채는 자율적인 도구는 도구가 없는 것보다 더 나쁩니다.
모든 것이 아티팩트 (artifacts)로 제공되었습니다: 업스트림 (upstream) HEAD에서 네 가지 버그를 모두 재현하고 패치 후에는 버그가 사라짐을 보여주는 repro.py, 코드와 테스트 및 패키징을 아우르는 약 180줄 분량의 fixes.patch, 그리고 방법론 자체가 잡아낸 작업자의 실수(성급한 "통과" 주장, 잘못된 부정 경로 (negative-path) 체크, 장부 내의 오타가 난 커밋 해시 하나 등 각각 루프의 자체 규칙에 의해 포착 및 수정됨)를 포함한 모든 반복 과정의 저널 (journal)입니다.
네 가지 발견 사항은 모두 재현 코드 (repros) 및 PR 제안과 함께 업스트림으로 전달되었습니다. 무엇인가를 병합 (merging)할지는 마땅히 유지 관리자 (maintainers)의 결정입니다.
교훈 (Takeaways)
- 녹색 테스트 스위트 (green suite)는 게이트 (gate)를 인증하는 것이지, 코드를 인증하는 것이 아닙니다. 당신의 게이트가 실제로 관찰할 수 있는 세계가 무엇인지 물으십시오. 인메모리 데이터베이스 (in-memory databases), 모킹된 네트워크 (mocked networks), 고정된 시계 (frozen clocks)는 모두 프로덕션 (production) 환경과는 물리 법칙이 다른 세계들입니다.
- 올바른 수정 사항이 테스트를 실패하게 만든다면, 테스트를 의심하십시오.
raise수정 사항이 단 하루 만에 사라진 것은 잘못된 테스트가 승리했을 때 나타나는 전형적인 모습입니다. - 회귀 테스트 (Regression tests)는 고장 난 코드에 대해 검증되어야 합니다. 테스트가 실패하는 것을 직접 본 적이 없다면, 당신은 그 테스트가 무엇을 탐지하는지 알지 못하는 것입니다.
- 동일한 근본 원인을 가진 세 번째 버그는 버그가 아니라 아키텍처 (architecture) 문제입니다. 증상이 아니라 경계 (boundary)를 수정하십시오.
도구에 대하여 (간략히)
이 루프 (loop)는 Jeffy Loop라고 불립니다. 이는 Claude Code를 위한 무료 MIT 라이선스 자율 개선 루프입니다: 감사 (audit), 작업당 실행 가능한 수락 체크 (acceptance check)를 포함한 백로그 (backlog), 로컬 git 체크포인트와 검증 게이트 (verify gate) 뒤에서 반복당 하나의 검증된 작업, 그리고 새로운 감사가 깨끗하게 돌아올 때만 수렴 (convergence)하는 구조를 가집니다. 실행 기록은 8회 반복 예산 중 7회를 사용했습니다. 전체 엔진은 약 100줄의 쉘 스크립트 (shell script) 하나로 구성되어 있으며, 위에서 언급한 평가 (eval)와 깨끗하게 통과한 의도적인 대조군 (control cases)을 포함한 8개 언어에 걸친 나머지 평가 세트는 여기에 언급된 모든 아티팩트와 함께 리포지토리 (repo)에 존재합니다: https://github.com/lenamonj/jeffy-loop
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기