Eighty Percent Done Is Not a Real Number
This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry. Why this...
개요 #
"이 프로젝트가 가장 완성에 가깝다." 개발자 Pascal CESCATO는 여러 개의 사이드 프로젝트를 앞에 두고 이렇게 판단했다. UI도 필요 없고, 아키텍처를 다시 짤 일도 없는 SQL 감사 도구. .sql 파일 두 개와 bash 스크립트, PDF 생성기가 전부인 그 도구는 이미 끝난 것처럼 보였다.
그런데 진짜 데이터베이스에 처음 돌린 순간, 결과는 딱 세 줄이었다. 나머지 아홉 개 지표는 실행조차 되지 못했다. 이 글은 코드 리뷰 네 번으로도 못 잡은 버그들이 어떻게 실행 한 번에 무너졌는지, 그리고 "80% 완료"라는 추정이 왜 검증되기 전까지는 아무 의미가 없는 숫자였는지에 대한 기록이다.
완성에 가장 가까워 보였던 프로젝트 #
여러 프로젝트가 대기줄에 있었다. 난방 기술자용 CRM은 필수 기능만 따져도 몇 주가 더 필요했고, 실제 현장 피드백으로 워크플로를 검증해야 했다. 레스토랑 플랫폼은 완성품이라기보단 아키텍처 실험에 가까웠다. 뉴스레터는 기다리는 고객조차 없었다.
반면 SQL 감사 도구는 완성돼 보였다. 엔진별로 SELECT 열 개를 UNION ALL로 묶고, bash 스크립트가 이를 psql이나 mysql에 넘겨 JSON으로 변환하고, PDF 생성기가 전문가용 리포트를 뽑아냈다. 결정을 내린 건 기술적 흥미가 아니었다. 결승선까지의 거리가 가장 짧아 보인다는 착각, 그 하나였다.
정확히 말하면 그건 잊혀진 프로젝트였다. 완성된 애플리케이션처럼 보이는 PoC였을 뿐, 애플리케이션은 아니었다.
돌려보기 전엔 아무도 몰랐다 #
실제 데이터베이스를 건드리기 전, 저자는 네 개의 모델(DeepSeek, Big Pickle, GLM, Qwen)에 코드 리뷰를 맡겼다. 각 모델은 버그와 의존성, 유령 LLM 호출까지 짚어낸 상세 리포트를 내놨다. 유용했다. 하지만 네 모델 모두 똑같은 맹점을 공유했다.
넷 중 누구도 이 스크립트를 실제 MySQL이나 PostgreSQL에 돌려보지 않았다. 그들은 SQL을 읽었을 뿐, SQL의 동작을 읽지 않았다. 네 개의 독립된 모델이 공유한 그 맹점은 정확히 잘못된 결론을 확정해 줬다. 이 프로젝트가 실제보다 완성에 가깝다는 결론을.
실제 데이터베이스가 세 줄로 보여준 것 #
pg_stat_statements 확장이 없는 기본 PostgreSQL 16에 처음 돌린 결과는 이랬다.
[{"metric":"psql:...audit_all_postgres.sql:193: ERROR: relation \"pg_stat_statements\" does not exist"...}]세 줄. 그게 전부였다. 없는 확장 하나를 조회한 쿼리가 UNION ALL 전체를 죽였고, 나머지 아홉 개 지표는 실행할 기회조차 얻지 못했다.
같은 패턴이 테스트하는 데이터베이스마다 반복됐다. 시스템 테이블이 없거나 권한이 부족하면, 문제가 된 지표 하나만 건너뛰는 게 아니라 스크립트 전체가 멈췄다. 슈퍼유저 없이 접근 불가한 pg_authid, 적절한 GRANT 없는 mysql.user, MariaDB에 없는 column_statistics — 증상은 매번 똑같았다.
해법도 매번 같았다. 조회 전에 접근 가능한지 먼저 확인하고, 실패하면 UNAVAILABLE: <이유>를 반환한 뒤 스크립트를 계속 진행시키는 것. 이론상으로는 간단했다.
몰랐던 함정 #
수정 하나는 사소해 보였다.
CASE WHEN NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements')
THEN NULL
ELSE (SELECT COUNT(*) FROM pg_stat_statements)
END맞아 보이지만 틀렸다. PostgreSQL은 pg_stat_statements의 존재를 CASE 분기가 실행될 때가 아니라 쿼리 파싱 시점에 검증한다. 조건 검사는 아무것도 막지 못한다. 어느 분기를 탈지 알기도 전에 쿼리가 먼저 실패한다.
결국 이 로직은 정적 SQL에서 빼내 bash 스크립트로 옮겨야 했다. 별도의 사전 감지 쿼리로. 문법상 완벽하지만 의미상 쓸모없는 CASE였다. 네 번의 리뷰 어느 것도 이걸 못 잡았다. 실제 엔진에 돌려야만 실패했으니까.
권한과 무관했던 버그 #
가장 오래 붙잡고 있었던 건 roles_without_password였다. GRANT가 확인됐는데도, 두 개의 다른 계정에서 SHOW GRANTS 출력을 손에 쥐고 있는데도, 계속 UNAVAILABLE이 돌아왔다.
권한 문제가 아니었다. 문자열 포맷 문제였다. 검사는 information_schema.SCHEMA_PRIVILEGES에 'user'@'host'(따옴표 두 쌍) 형식으로 저장된 GRANTEE를, CONCAT("'", USER(), "'")가 만든 'user@host'(전체를 감싼 따옴표 한 쌍)와 비교했다. 두 문자열은 절대 일치할 수 없었고, 실패는 감사 도구에서 가장 두려운 형태로 나타났다. 보안 항목의 거짓 음성(false negative)으로.
두 번째 함정은 더 멍청했다. MySQL 스크립트는 PostgreSQL과 달리 사용자에게 데이터베이스 이름을 물어본 적이 없었다. 데이터베이스가 선택되지 않으면 DATABASE()는 NULL을 반환하고, TABLE_SCHEMA = DATABASE() 조건은 아무것도 매칭하지 않는다.
결과적으로 bloated_tables는, 스크립트 밖에서 독립적으로 23%로 확인된 테이블에 대해 0을 반환했다. 그 0은 "블로트 없음"이 아니었다. "던진 질문이 아무것에도 해당하지 않아 행을 못 찾음"이었다. 감사 도구가 절대 조용히 내면 안 되는 종류의 오류. 아무것도 측정하지 않은 안심용 0.
이건 더 이상 결승선까지의 거리 문제가 아니었다. 방향의 문제였다. 완성된 스크립트를 향해 간다고 믿었지만, 실은 한 번도 본 적 없는 땅을 향해 걷고 있었다.
이름이 약속한 걸 측정하지 않은 지표 #
buffer_pool_ratio(innodb_buffer_pool_size / max_connections)는 실제 데이터베이스에서 1,789,569.7067을 반환했다. 비율(ratio)이 1을 넘을 이유는 없다.
계산에 버그가 있는 게 아니었다. 애초에 말이 안 되는 계산이었다. 바이트 단위 메모리 크기를 이론상 최대 연결 수로 나눠봐야 이해 가능한 단위가 나오지 않는다. 비율도 아니고, 연결당 할당량도 아니고, 포화 지표도 아니다. 네 번의 코드 리뷰가 여기에 아무 말도 못 한 건, 문법적으로는 아무것도 틀리지 않았기 때문이다.
이 지표는 버퍼 풀 히트율(1 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests))로 교체했다. 디스크가 아닌 메모리 캐시에서 처리된 읽기의 비율이라는, 실제로 의미 있는 값이다. 같은 데이터베이스에서 99.75%. 뭔가를 뜻하는 숫자였다.
열 개 지표가 놓친 것 #
원래의 열 개 지표로 파이프라인이 안정되고 n8n 운영 데이터베이스에 대한 리포트도 일관되게 나왔을 때, 결과에는 모순이 없어 보였다. 그런데 그 리포트를 다른 LLM에 다시 검토받는 과정에서 구멍이 드러났다. 열 개 지표 중 어느 것도 연결 암호화를 측정하지 않았던 것이다.
구멍을 드러낸 건 테스트가 아니라 재검토였다. LLM은 딱 하나의 지표 ssl_enforced를 제안했고, 그걸 추가하는 것만으로 위험 매트릭스의 우선순위와 최종 점수가 바뀌었다. ssl_enforced가 들어가자 n8n 데이터베이스의 암호화 부재가 위험 매트릭스 최상단으로 뛰어올랐다. 원래 열 개 지표가 찾은 그 무엇보다 위로.
이후 Kimi가 구현 난이도로 정렬한 후보 목록에서 세 개(connection_count_ratio, unused_indexes, long_running_transactions)를 더 골랐다. 기존 모델을 복잡하게 만들지 않고 딱 맞아떨어져서였다. 쿼리 하나, 값 하나, 나머지와 같은 방어 패턴. 반면 disk_usage_growth_rate는 시점 감사 도구의 성격을 바꿔버릴 히스토리 스냅샷이 필요해 제외됐고, table_count나 database_size는 위험 신호가 아닌 그저 표시용 맥락이라 빠졌다.
Mistral이 진행한 세 번째 검토는 내용이 아니라 리포트의 제시 방식에 초점을 맞췄다. duplicate_primary_keys = 0이라는 긍정적 결과가 평가 중간 산문 문단에 묻혀 있을 뿐, 이슈 없는 지표까지 포함한 체계적인 체크리스트 표에 한 번도 등장하지 않는다는 지적이었다. 여기서 duplicate_indexes와 large_tables_count가 추가됐다.
결산 #
| 지표 | 이전 | 이후 |
|---|---|---|
| 엔진당 지표 수 | 10 | 16 |
| 권한 부족 시 동작 | 스크립트 전체 중단 | UNAVAILABLE: <이유>, 나머지 계속 진행 |
| 점수 산정 | 없음 | 결정론적, 카테고리별 분류(보안/성능/무결성) |
| SSL/TLS 감지 | 없음 | 있음 — 운영 DB의 1순위 위험을 드러냄 |
| 리포트 언어 | 1개 | 다국어, 안정적인 섹션 식별자 |
| 실제 실행 전 코드 리뷰 | 모델 4개 | 모델 4개, 동작 버그 0건 발견 |
버그를 설명하는 리포트를 디버깅하다 #
거의 아이러니한 마지막 디테일 하나. 최종 렌더링 시점에 위험 매트릭스의 색깔 점(점수별 빨강/주황/회색)이 PDF에 나타나지 않았다. Copilot은 원인을 끝내 못 찾았다. 세 시간을 붙잡고 20달러가 넘는 크레딧(Haiku 4.5)을 태웠지만 점 하나 못 찍었다. OpenCode의 Big Pickle이 1분 안에, 무료로 해결했다. Copilot의 모든 가설이 틀렸고, 진짜 문제는 점을 삽입하려던 컬럼 하나였다.
교훈은 수정 자체가 아니라 방법에 있었다. 세 번째 맹목적 구현을 시도하지 말고, 추측 대신 실제 생성된 코드를 확인하고 다시 읽어라. 이 프로젝트 전체를 관통하는 원칙과 같다. 실제 환경에서 한 번 돌아가기 전까지는 아무것도 믿지 마라.
결정론이 덮지 못하는 것 #
같은 데이터베이스에 대한 같은 리포트 두 개를, 서사 부분을 서로 다른 LLM으로 생성했다. 전역 점수, 카테고리 점수, 위험 매트릭스는 소수점까지 동일했다. 그 숫자들은 애초에 LLM에서 나오지 않기 때문이다. 결정론적 점수 산정이 보장하려던 게 정확히 이것이다. 모델은 서술할 뿐, 채점하지 않는다.
그런데 두 리포트 중 하나가 권고 사항의 비즈니스 영향에 SOC 2를 언급했다. 프랑스 데이터베이스, 프랑스 LLM(Mistral Large), 프랑스어 리포트에 SOC 2가 등장할 이유는 없었다. 점수가 거짓말한 게 아니라 그 주변 문장이 거짓말한 것이다. 다만 순수한 날조는 아니었다. SOC 2는 실재하는 프레임워크이고, 리포트의 컨설팅 톤과도 일관되며, 그 자체로는 정확하게 정의됐다. 문제는 모델이 개념을 환각한 게 아니라, 실제로 알지 못하는 가상의 자리에 그 개념을 적용했다는 점이다. 발명이 아니라 오용이었다.
MySQL 쪽에서 DATABASE()가 NULL을 반환한 것과 같은 계열의 버그다. 물어본 적 없는 필드가 그럴듯해 보이는 무언가로 조용히 채워진 것. 다만 이번엔 허술한 쿼리가 아니라, 고객의 국가만큼 핵심적인 정보가 빠질 수 있다는 걸 한 번도 예상하지 못한 프롬프트가 문제였다. 사실 국가는 그렇게까지 중요하지 않다. SOC 2든 GDPR이든 ISO 27001이든, 취약한 보안은 취약한 보안이다.
지금은 고쳐졌다. 모든 리포트 생성 앞에 관할권 플래그(--jurisdiction FR, --jurisdiction US 등 ISO 3166 값)가 붙고, 서술 프롬프트는 해당 관할권이 정당화하지 않는 컴플라이언스 프레임워크를 명명하는 것이 명시적으로 금지됐다.
지금의 모습 #
이 모든 걸 넘긴 뒤 남은 것. MariaDB나 PostgreSQL 데이터베이스에 명령줄로 돌리면 몇 분 안에 끝나고, 전역 점수·위험 매트릭스·우선순위 권고·기술 부록을 담은 완전한 PDF 리포트를 뽑아내는 도구. 실행을 지켜볼 필요도, 결과를 믿어도 되는지 추측할 필요도 없이.
엔진당 지표는 열 개에서 열여섯 개로 늘었다. 카테고리별로 분류된 결정론적 점수. 섹션 제목이 내부적으로 안정적인 식별자를 유지하면서 라벨만 번역돼 표시되는 다국어 지원. 영향받은 테이블과 제약을 개수만이 아니라 이름으로 짚어주는 부록.
이 중 어느 것도 시작 시점엔 계획에 없었다. 두 파일짜리 SQL PoC는 자기가 하겠다고 말한 걸 정확히 해냈다. 그걸 작성한 사람의 상상 속을 벗어나 실제 어딘가에 돌리지만 않는다면. 이 프로젝트가 완성에 가장 가까워 보여서 선택됐다. 실제로 남은 거리는 다른 프로젝트보다 빨리 좁혀졌으니 어떤 의미에선 맞았다. 하지만 그 근접함은 buffer_pool_ratio처럼 한 번도 검증된 적 없이 추정만 됐을 뿐이다. 컴파일되고 아직 크래시하지 않은 코드로부터.
크래시하지 않는 스크립트는 아무것도 증명하지 못한다. buffer_pool_ratio는 아무것도 실패시키지 않았다. 돌아갔고, 숫자를 만들었고, 그 숫자는 아무 의미도 없었다. 아무리 다시 돌려도 그건 안 드러났을 것이다. 다른 방법으로 다른 곳에서 측정한 값과 비교해야만 그 계산이 공허하다는 걸 볼 수 있었다.
종이 위에서 돌아가는 도구와 실제 데이터베이스 앞에서 버티는 도구의 차이는 코드의 정교함이 아니다. 몇 번이나 크래시시키는 데 성공했느냐도 아니다. 그 도구가 내놓는 모든 결과가 — 프로젝트를 시작하게 만든 그 추정치까지 포함해서 — 단지 그럴듯한 게 아니라 실제로 검증된 무언가에 대응하느냐다.
기술 노트 — 도구와 방법 #
최초 코드 리뷰(실제 실행 전): DeepSeek, Big Pickle, GLM, Qwen — 네 모델, 네 리포트, 하나의 공유된 맹점(실제 DB 접근 없음).
테스트한 엔진: PostgreSQL 16(기본, 그리고 확장 포함), MariaDB.
실제 조건에서만 드러난 버그들:
pg_extension이CASE분기 실행 시점이 아니라 파싱 시점에 검증됨information_schema와CONCAT(USER())사이의GRANTEE포맷 불일치- MySQL 쪽 데이터베이스 선택 프롬프트 부재로
DATABASE()가NULL반환 - 근거 없는
buffer_pool_ratio지표(실제 관계가 없는 나눗셈) - 원래 지표 목록에서 SSL/TLS 감지의 완전한 부재
- 관할권이 제공·검증되지 않은 채 인용된 컴플라이언스 프레임워크(SOC 2)
리포트 생성: Markdown → WeasyPrint로 PDF 변환. 결정론적 점수 산정을 LLM 서술과 분리. 위험 점은 순수 CSS(<span> + border-radius), 3단계 팔레트(빨강/주황/회색 — 위험 매트릭스의 어느 줄도 좋은 소식이 아니므로 초록은 없음).
유지한 디버그 방법: 구현을 다시 시도하기 전에, 증상 설명에 기반한 맹목적 수정 대신 코드와 그것이 실제로 만들어내는 결과를 확인한다.
이 글은 위 출처를 바탕으로 한국 독자를 위해 재작성한 기사입니다. 원문의 사실과 수치에 근거하며, 별도의 견해를 포함하지 않습니다.

