RicoCheesethe studio log · v2.0
Live · KRRead posts
목록으로
뉴스PUBLISHED · 2026년 7월 15일·22 MIN READ

80% 완료는 진짜 숫자가 아니다 — 돌려보기 전까진 아무것도 검증되지 않았다

완성에 가장 가까워 보였던 SQL 감사 도구를 실제 데이터베이스에 돌린 순간, 열 개의 지표 대부분이 무너졌다. 코드 리뷰 네 번으로도 못 찾은 버그들이 왜 실행 한 번에 드러났는지에 대한 기록.

#devchallenge#bugsmash#postgresql#database
Eighty Percent Done Is Not a Real Number

개요 #

"이 프로젝트가 가장 완성에 가깝다." 개발자 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에 처음 돌린 결과는 이랬다.

untitled
json
[{"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: <이유>를 반환한 뒤 스크립트를 계속 진행시키는 것. 이론상으로는 간단했다.

몰랐던 함정 #

수정 하나는 사소해 보였다.

untitled
sql
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_countdatabase_size는 위험 신호가 아닌 그저 표시용 맥락이라 빠졌다.

Mistral이 진행한 세 번째 검토는 내용이 아니라 리포트의 제시 방식에 초점을 맞췄다. duplicate_primary_keys = 0이라는 긍정적 결과가 평가 중간 산문 문단에 묻혀 있을 뿐, 이슈 없는 지표까지 포함한 체계적인 체크리스트 표에 한 번도 등장하지 않는다는 지적이었다. 여기서 duplicate_indexeslarge_tables_count가 추가됐다.

결산 #

지표이전이후
엔진당 지표 수1016
권한 부족 시 동작스크립트 전체 중단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 리포트를 뽑아내는 도구. 실행을 지켜볼 필요도, 결과를 믿어도 되는지 추측할 필요도 없이.

dbgrade.tech 보기 →

엔진당 지표는 열 개에서 열여섯 개로 늘었다. 카테고리별로 분류된 결정론적 점수. 섹션 제목이 내부적으로 안정적인 식별자를 유지하면서 라벨만 번역돼 표시되는 다국어 지원. 영향받은 테이블과 제약을 개수만이 아니라 이름으로 짚어주는 부록.

SlideShare에서 실제 리포트 보기 →

이 중 어느 것도 시작 시점엔 계획에 없었다. 두 파일짜리 SQL PoC는 자기가 하겠다고 말한 걸 정확히 해냈다. 그걸 작성한 사람의 상상 속을 벗어나 실제 어딘가에 돌리지만 않는다면. 이 프로젝트가 완성에 가장 가까워 보여서 선택됐다. 실제로 남은 거리는 다른 프로젝트보다 빨리 좁혀졌으니 어떤 의미에선 맞았다. 하지만 그 근접함은 buffer_pool_ratio처럼 한 번도 검증된 적 없이 추정만 됐을 뿐이다. 컴파일되고 아직 크래시하지 않은 코드로부터.

크래시하지 않는 스크립트는 아무것도 증명하지 못한다. buffer_pool_ratio는 아무것도 실패시키지 않았다. 돌아갔고, 숫자를 만들었고, 그 숫자는 아무 의미도 없었다. 아무리 다시 돌려도 그건 안 드러났을 것이다. 다른 방법으로 다른 곳에서 측정한 값과 비교해야만 그 계산이 공허하다는 걸 볼 수 있었다.

종이 위에서 돌아가는 도구와 실제 데이터베이스 앞에서 버티는 도구의 차이는 코드의 정교함이 아니다. 몇 번이나 크래시시키는 데 성공했느냐도 아니다. 그 도구가 내놓는 모든 결과가 — 프로젝트를 시작하게 만든 그 추정치까지 포함해서 — 단지 그럴듯한 게 아니라 실제로 검증된 무언가에 대응하느냐다.

기술 노트 — 도구와 방법 #

최초 코드 리뷰(실제 실행 전): DeepSeek, Big Pickle, GLM, Qwen — 네 모델, 네 리포트, 하나의 공유된 맹점(실제 DB 접근 없음).

테스트한 엔진: PostgreSQL 16(기본, 그리고 확장 포함), MariaDB.

실제 조건에서만 드러난 버그들:

  1. pg_extensionCASE 분기 실행 시점이 아니라 파싱 시점에 검증됨
  2. information_schemaCONCAT(USER()) 사이의 GRANTEE 포맷 불일치
  3. MySQL 쪽 데이터베이스 선택 프롬프트 부재로 DATABASE()NULL 반환
  4. 근거 없는 buffer_pool_ratio 지표(실제 관계가 없는 나눗셈)
  5. 원래 지표 목록에서 SSL/TLS 감지의 완전한 부재
  6. 관할권이 제공·검증되지 않은 채 인용된 컴플라이언스 프레임워크(SOC 2)

리포트 생성: Markdown → WeasyPrint로 PDF 변환. 결정론적 점수 산정을 LLM 서술과 분리. 위험 점은 순수 CSS(<span> + border-radius), 3단계 팔레트(빨강/주황/회색 — 위험 매트릭스의 어느 줄도 좋은 소식이 아니므로 초록은 없음).

유지한 디버그 방법: 구현을 다시 시도하기 전에, 증상 설명에 기반한 맹목적 수정 대신 코드와 그것이 실제로 만들어내는 결과를 확인한다.


이 글은 위 출처를 바탕으로 한국 독자를 위해 재작성한 기사입니다. 원문의 사실과 수치에 근거하며, 별도의 견해를 포함하지 않습니다.