기술 블로그에 자기 시스템의 숫자를 쓰기 시작하면 확인해야 하는 게 둘 생깁니다. 원고의 숫자가 데이터베이스와 맞는지, 그리고 맞아서 곤란한 값이 섞여 있는지. 뒤쪽은 운용 중인 진입 조건이나 지금 들고 있는 종목처럼 틀려서가 아니라 맞아서 문제가 되는 값입니다.
그래서 발행 파이프라인을 이 순서로 묶었습니다.
원고 앞머리의 SQL 실행 → 숫자 대조 → 검증 블록 제거
→ 제거된 결과물을 스캔 → 위반 0건일 때만 발행본을 쓴다
마지막 두 단계의 순서가 이 글의 핵심입니다. 스캔한 것과 발행하는 것이 달라지면 스캐너는 무의미해집니다. 그리고 이 글에는 제가 보여줄 수 없는 파일이 하나 등장합니다. 그 이유가 결론입니다.
틀린 숫자는 어떻게 막나?
원고 앞머리(frontmatter)에 검증 블록을 답니다.
verify:
- label: 가격을 실제로 수집하는 종목 수
sql: SELECT COUNT(DISTINCT ticker) FROM price_history
expect: "608"
검증기를 돌리면 SQL을 실행해서 원고에 쓴 값과 대조하고, 마크다운 표로 뱉습니다.
| 항목 | 원고 값 | DB 값 | 일치 |
|---|---|---|---|
| 가격을 실제로 수집하는 종목 수 | `608` | `608` | ✅ |
이 표를 그대로 PR 본문에 붙입니다.
그게 이 도구의 진짜 목적입니다. 저는 이 블로그에 주당 15분만 쓰기로 예산을 잡았는데, 검수자가 숫자를 하나씩 DB에서 확인해야 한다면 그 예산이 성립하지 않습니다.
표가 대신 확인합니다.
값 비교는 관대하게 했습니다. 쉼표, 퍼센트 기호, 공백을 제거하고 양쪽이 숫자로 읽히면 숫자로 비교합니다. 1,234와 1234가 다르다고 보고하는 도구는 아무도 안 씁니다.
이 장치가 필요한 이유는 단순합니다.
기억으로 쓰면 틀립니다. 저는 이 시리즈를 쓰면서 기억하고 있던 수치를 세 번 다시 재봤고, 세 번 다 달랐습니다. 로컬 LLM 속도는 10 tok/s로 기억했는데 실측은 8.85였고, 크론 63개라고 적어둔 것은 스케줄 트리거 18개였고, 어떤 편향 사례는 2건으로 기억했는데 다시 세니 0건이었습니다.
셋 다 제가 직접 적어둔 메모였습니다.
방금 지난 10편의 숫자 82개를 전부 다시 실행했습니다.
benchmark-fit-alpha-flip ✅12 ❌0
benchmark-mismatch ✅ 7 ❌0
bollinger-bandwidth-prereg ✅ 3 ❌0
calendar-vs-trading-day ✅10 ❌0
llm-cost-routing ✅17 ❌0
mlx-kv-cache-memory ✅ 2 ❌0
n8n-import-deactivates ✅ 4 ❌0
rag-ablation ✅10 ❌0
rsi-misconceptions ✅ 6 ❌0
universe-vs-measurement ✅11 ❌0
82개 중 82개가 원본 데이터와 일치합니다.
대조는 이렇게 끝나는데, 원고에 SQL을 적는 방식에는 구멍이 하나 있습니다. 제가 실수로 쓰기 쿼리를 적을 수 있다는 것입니다. 그 판정은 정규식에서 데이터베이스로 옮겼습니다. PostgreSQL의 default_transaction_read_only를 켜면 그 세션의 모든 트랜잭션이 읽기 전용으로 시작하고, 무엇이 쓰기인지는 제가 아니라 데이터베이스가 압니다.
왜 옮겼는지는 부록 1에 있습니다.
맞아서 곤란한 숫자는 어떻게 막나?
두 번째 도구는 반대 방향입니다.
원고를 훑어서 나가면 안 되는 값이 있는지 봅니다. 정적 규칙과 동적 목록을 봅니다. 현행 진입·청산 파라미터나 실측 엣지 크기는 정규식 목록으로 관리하고, 지금 들고 있는 종목은 매일 바뀌니 스캔할 때마다 DB에 물어봅니다.
동적 쪽에는 의도적인 부정확을 하나 넣었습니다. 보유 종목을 묻는 쿼리가 이렇게 생겼습니다.
SELECT DISTINCT ticker FROM paper_position WHERE status='OPEN'
paper_position은 일별 스냅샷 테이블이라, 지금 보유 중을 정확히 구하려면 최신 날짜로 좁혀야 합니다. 그러면 31종목입니다. 그런데 저는 안 좁힙니다. 과거 스냅샷의 OPEN 행까지 전부 긁어서 32종목을 대조합니다. 한 종목이 과잉 차단됩니다.
지금은 안 들고 있는데 못 쓰게 막힙니다.
의도한 겁니다. 오탐은 문구를 고치면 되지만 미탐은 되돌릴 수 없습니다. 발행된 글은 회수해도 캐시와 인덱스에 남습니다. 비대칭인 손실 앞에서는 판정 기준도 비대칭이어야 합니다.
걸리는 모습입니다. 테스트용 원고를 만들어 돌렸고, 종목 코드는 가렸습니다.
leak-test.md:5 [진입 RSI 임계값] 현행 진입 필터 파라미터
leak-test.md:5 [손절률] 현행 청산 파라미터
leak-test.md:6 [보유기간] 현행 청산 파라미터
leak-test.md:6 [실측 Sharpe] 실측 엣지 크기
leak-test.md:7 [현재 보유 티커 XXXXXX] 실시간 포지션 노출
값 스캐너 위반 5건 — PR을 생성하지 않는다.
종료 코드 1, 그리고 발행본 파일은 만들어지지 않습니다. 위반이 있으면 쓰지 않고 죽습니다. 부분적으로 쓰고 경고하는 선택지는 없습니다.
여기까지 읽고 자연스럽게 드는 생각이 있을 겁니다.
그 금지값 규칙 파일을 한번 보여달라. 못 보여줍니다. 그 파일이 곧 비밀이기 때문입니다. 금지값 목록은 이 값들을 공개하지 마라는 규칙이고, 규칙에는 그 값이 적혀 있습니다. 목록을 공개하는 것과 값을 공개하는 것이 같은 일입니다. 이 글에 규칙 파일을 붙이는 순간, 이 글이 막으려는 유출을 이 글이 저지릅니다.
같은 이유로 파일의 위치도 중요해집니다.
처음에는 스캐너를 블로그 레포에 두는 게 자연스러워 보였습니다. 검사 대상이 거기 있으니까요. 그런데 그렇게 하면 발행 대상 원고와 금지값 목록이 같은 저장소에 있게 됩니다.
숨기려는 값이, 공개를 목적으로 하는 저장소 안에 들어갑니다.
| 비공개 리서치 레포 | 블로그 레포 | |
|---|---|---|
| 하는 일 | 실제 값 대조, 위반 시 발행 차단 | 빌드·면책조항 전수 검사·명암비 검사 |
| 금지값 목록 | 있음 | 없음 |
| 성격 | 사전 블로킹 | 구조 검사 |
블로그 레포의 CI에는 구체적인 값이 하나도 없습니다.
확인차 전체를 훑어봤고, 금지값 관련 참조는 0건입니다. 규칙 파일은 리서치 레포 한 곳에만 있습니다. 두 층을 합치면 검사가 한 군데 모여 편할 겁니다. 합치면 안 됩니다. 격리 자체가 방어선입니다. 두 레포가 지금은 둘 다 비공개지만, 한쪽이 언젠가 실수로 공개 전환돼도 값은 넘어가지 않습니다.
왜 원고가 아니라 산출물을 스캔하나?
여기서 걸려 넘어질 뻔한 게 있었습니다.
초안의 검증 블록에는 SQL이 들어 있고, SQL에는 종목 코드가 박혀 있습니다. WHERE ticker='069500' 같은 식으로요.
이 블록은 발행 전에 제거됩니다.
그러니까 처음 절차는 이랬습니다.
스캔한다 → 통과 → 검증 블록을 지운다 → 발행한다. 스캔한 파일과 발행한 파일이 다릅니다. 이 순서에서는 검증 블록을 지우는 걸 잊으면 SQL이 통째로 발행되고, 스캐너는 이미 통과 도장을 찍은 뒤입니다.
스캐너를 둔 이유와 정면으로 모순됩니다.
그래서 변환과 검사를 한 명령으로 묶었습니다.
초안 → (검증 블록 제거) → 변환 결과물을 스캔 → 통과할 때만 파일로 쓴다
스캔 대상은 변환 결과물 그 자체입니다. 지우는 걸 기억하기에 의존하는 절차가 사라졌습니다.
일반화하면 이렇습니다.
검사는 배포 산출물에 해야 하고 소스에 하면 안 됩니다. 소스와 산출물 사이에 변환이 하나라도 끼면 그 변환이 검사를 무력화할 수 있습니다. 빌드 후에 주입되는 환경변수, 번들러가 인라인하는 설정, 템플릿이 렌더링하는 값이 전부 같은 함정입니다.
빌드에도 같은 함정이 있었습니다.
Astro의 일반 빌드는 콘텐츠 캐시를 재사용해서, 삭제했거나 초안 처리한 글이 산출물에 남을 수 있습니다. 그래서 CI는 캐시를 지우고 빌드합니다.
검사 대상이 실제 발행본과 다르면 같은 문제가 여기서 다시 나옵니다.
자주 묻는 질문
왜 CI 가 아니라 발행 스크립트에 붙였나요
CI 는 이미 레포에 들어온 것을 검사합니다. 유출은 들어오기 전에 막아야 해서 발행의 정문에 붙였습니다. 이 레포 CI 의 검사들은 유출과 무관한 구조 검사예요.
금지값 목록 자체가 유출 아닌가요
그래서 그 파일은 리서치 레포에만 둡니다. “무엇을 숨길지 적어둔 문서”는 그 자체로 숨겨야 할 문서입니다.
스캐너를 통과하면 안전한가요
아뇨. 스캐너는 알려진 값을 대조하므로 숫자를 안 쓰고 말로 설명한 서술은 못 잡습니다. 통과가 공개 경계 통과는 아니에요.
발행 뒤에 패턴을 추가하면 옛 글은요
다시 검사되지 않습니다. 스캐너는 발행 시점에 한 번만 돌아요. 그래서 패턴을 추가하는 작업은 같은 자리에서 초안 전체를 재스캔합니다.
남는 것
열거로 막는 방어는 열거가 끝나지 않습니다. 금지 키워드 목록은 제가 아는 것까지만 막습니다. 판정을 그 일을 실제로 아는 계층에 넘기면 제가 모르는 경우까지 막힙니다. 목록을 늘리고 싶어질 때가 신호입니다.
늘릴 게 아니라 옮길 때입니다.
검사는 산출물에 하세요. 소스와 배포본 사이에 변환이 하나라도 있으면, 소스를 검사한 결과는 배포본에 대해 아무것도 보장하지 않습니다. 저는 검증 블록 지우기라는 한 단계 때문에 이 함정에 빠질 뻔했습니다.
되돌릴 수 없는 쪽으로 편향하세요. 스캐너가 한 종목을 과잉 차단합니다. 고칠 수 있었지만 안 고쳤습니다. 오탐은 문장을 다시 쓰면 되고, 미탐은 인터넷에 남습니다. 손실이 비대칭이면 기준도 비대칭이어야 합니다.
그리고 규칙 파일을 어디에 두는지가 규칙 내용만큼 중요합니다. 무엇을 숨길지 적어둔 문서는 그 자체로 숨겨야 할 문서입니다. 이 글이 자기 설정 파일을 못 싣는 이유이고, 검사를 한 군데로 합치지 않은 이유입니다.
숫자를 82개 썼고 82개가 맞는다고 말할 수 있는 건 제가 꼼꼼해서가 아닙니다. 꼼꼼함에 의존하지 않기로 해서입니다.
부록 1. 제 가드는 가드가 아니었다
읽기 전용 검사를 처음에는 정규식 하나로 했습니다.
const WRITE_KEYWORDS =
/\b(insert|update|delete|drop|truncate|alter|create|grant|revoke|copy)\b/i;
SELECT나 WITH로 시작하고, 위 키워드가 없고, 세미콜론이 하나뿐이면 통과. 충분해 보였습니다. 시험해 보니 아니었습니다.
통과 SELECT setval('signal_candidate_id_seq', 1)
통과 SELECT nextval('signal_candidate_id_seq')
차단 DELETE FROM signal_candidate
setval()은 시퀀스 값을 바꿔 쓰는 함수인데 금지 키워드가 하나도 안 들어 있습니다. SELECT로 시작하고 INSERT도 UPDATE도 DELETE도 없으니, 정규식 입장에서는 완벽한 읽기 쿼리입니다. SELECT 1 INTO __t__도 통과했습니다.
CREATE 없이 테이블을 만듭니다.
블랙리스트에 setval, nextval, into를 추가할 수도 있었습니다. 그런데 다음에 뭐가 나올지 제가 모르는데, 모르는 걸 목록에 넣을 수는 없습니다.
그래서 세션을 읽기 전용으로 열고 DB가 거부하게 했습니다.
execFileSync('docker', [
'compose', 'exec',
'-e', 'PGOPTIONS=-c default_transaction_read_only=on',
'-T', 'postgres', 'psql', ...
]);
이 글을 쓰면서 다시 확인했습니다. 정규식은 지금도 setval을 통과시킵니다. 1차 필터로 남겨뒀으니 당연합니다. 그리고 그다음에 이렇게 됩니다.
ERROR: cannot execute setval() in a read-only transaction
시퀀스 값은 시도 전후로 276 그대로입니다. 이 글의 검증 표 자체가 그 증거입니다. 위 두 줄은 이 글의 verify 블록에 들어 있고 발행 전에 실행됐습니다. 검증기가 자기 자신의 가드를 검증합니다.
정규식을 지우지는 않았습니다. 명백한 것을 값싸게 걸러 DB까지 안 가게 하는 1차 필터로는 여전히 쓸모가 있습니다. 다만 그게 방어선이라고 믿는 걸 그만뒀습니다. 방어선은 DB입니다.
한 가지는 분명히 해두는 게 맞습니다.
이건 실수를 막는 장치이고 공격을 막는 장치가 못 됩니다. 원고의 SQL은 제가 씁니다. 적대적 입력을 상정하면 pg_read_file() 같은 읽기이지만 위험한 함수까지 막아야 하는데, 그건 이 도구의 범위 밖입니다. 범위를 코드 주석에 적어뒀습니다.
방어 도구에서 가장 위험한 건 자기 범위를 모르는 것이라서요.
부록 2. 검사기도 코드다
이 도구들에는 테스트가 붙어 있습니다.
세 파일에 17개입니다. 과해 보일 수 있는데 이유가 있습니다. 검사기가 조용히 고장 나면 아무 일도 안 일어납니다. 애플리케이션 버그는 사용자가 알려주지만, 통과 도장만 찍는 스캐너는 아무도 신고하지 않습니다. 초록불이 계속 켜지고, 그게 정상으로 보입니다.
두 번 겪었습니다.
정규식 g 플래그. RegExp.prototype.test()는 g 플래그가 붙으면 lastIndex를 기억합니다. 같은 정규식으로 여러 줄을 검사하면 결과가 번갈아 나옵니다. 첫 줄에서 걸리고, 둘째 줄은 통과하고, 셋째 줄에서 다시 걸립니다. 유출 스캐너에서 이러면 절반을 놓칩니다.
그래서 g를 안 씁니다.
process.argv[1]이 undefined인 경우. 직접 실행됐을 때만 main()을 부르는 관용구를 쓰는데, 테스트에서 모듈로 불러오면 argv[1]이 없어서 경로 변환 함수가 그냥 죽습니다. 모듈을 불러오는 것만으로 크래시하니 테스트를 한 줄도 못 씁니다.
둘 다 로직이 아니라 도구가 자기 자신을 실행하는 방식에서 나온 버그입니다. 이런 건 테스트 없이는 안 보입니다.
부록 3. 공개 레포 CI가 하는 일
값을 못 두는 대신 블로그 레포 CI는 구조를 검사합니다. 검사 세 종류가 워크플로에 박혀 있습니다.
- name: 빌드 (캐시 purge)
run: npm run build:clean
- name: Disclaimer 전수 검증
run: node scripts/check-disclaimer.mjs
- name: 명암비 검증 (WCAG AA)
run: node scripts/check-contrast.mjs
면책조항 검사는 빌드 산출물의 모든 포스트 HTML을 열어서 문구가 들어 있는지 봅니다. 면책조항은 공통 레이아웃에서 강제되니 구조적으로 빠질 수 없는데, 그래도 검사합니다. 강제되는 구조도 조용히 깨집니다. 레이아웃을 갈아엎거나, 컴포넌트를 리팩터링하거나, 포스트 하나가 다른 레이아웃을 쓰기 시작하면 됩니다.
어느 것도 빌드를 실패시키지 않습니다.
이 사이트의 수치는 주 1회 DB와 다시 대조하고, 판단이 바뀌면 지우지 않고 글 안에 덧붙입니다. 갱신은 RSS로 받을 수 있습니다.