/* * ★ 저널을 통째로 읽는 자리는 **코드에 그 이유가 적혀 있어야 한다** — 소스 규율 검사. * * ── 왜 이 검사가 필요한가 (2026-08-20) ────────────────────────────────────── * `TwinEngine.timeRange` 는 min/max **두 수**를 구하려고 공간의 인스턴스마다 저널을 `find()` 로 전부 * 실어 왔다. 개발 서버에서 한 트윈이 275,882행 · `payload` 131MB 였고, 화면 상단의 컨텍스트 띠가 그 * 함수를 **4초마다** 불렀다. 프로세스 CPU 81~152%, 아무 일도 하지 않는 질의가 8~14초였다. * * **사람 리뷰가 이것을 잡지 못한 이유는 코드가 맞았기 때문이다.** min/max 는 정확히 나왔다. 틀린 것은 * 답이 아니라 비용이고, 비용은 읽어서는 보이지 않는다. 그래서 사람에게 다시 맡기지 않고 기계에 맡긴다. * * ── 규율 ──────────────────────────────────────────────────────────────────── * 저널(`TwinEvent`)을 `find()` 로 읽는 자리는 둘 중 하나여야 한다. * ① **상한이 있다**(`take:`) — 목록·표본처럼 화면이 쓰는 만큼만 읽는 경우. * ② **폴드다** — 사실을 하나씩 계산하는 것이 목적이라 그 구간의 행이 필요한 경우. 이때는 바로 앞에 * `journal-fold:` 로 시작하는 주석을 두어 **왜 필요한지**, 그리고 상한이 없다면 **없어질 조건**을 * 함께 적는다. * * 둘 다 아니면 이 검사가 빨개진다. 「두 수만 필요한데 표를 다 읽는」 코드가 다시 들어오는 것을 막는 * 자리가 여기다. 집계(MIN/MAX/COUNT)는 DB 가 하고 행은 실어 오지 않는다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import { guardRoots } from './guard-roots.ts' /** 훑는 곳 — 저널을 읽을 수 있는 층 둘. */ const ROOTS = guardRoots(import.meta.url, ['../server']) const READ = 'getRepository(TwinEvent)' /** 폴드라고 밝히는 표식. 이유를 함께 적는 것이 이 표식의 값이다. */ const FOLD_PRAGMA = 'journal-fold:' /** 표식을 찾아 볼 범위 — 바로 앞 주석 블록. 멀리 두면 무엇에 붙은 표식인지 알 수 없다. */ const PRAGMA_LOOKBACK = 900 /** 주석을 지운 소스 — 규율을 **설명하는 글**이 규율 위반으로 잡히지 않게. */ function stripComments(src: string): string { return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '') } function sources(dir: string): string[] { let entries: string[] try { entries = readdirSync(dir) } catch { return [] } return entries.flatMap(f => { const p = join(dir, f) return statSync(p).isDirectory() ? sources(p) : p.endsWith('.ts') && !p.endsWith('.d.ts') ? [p] : [] }) } interface Site { file: string line: number bounded: boolean fold: boolean } /** 저널을 `find()` 로 읽는 자리들. 쓰기(`insert`/`save`)와 집계(`createQueryBuilder`)는 대상이 아니다. */ function journalFindSites(): Site[] { const out: Site[] = [] for (const root of ROOTS) { for (const f of sources(root)) { const src = readFileSync(f, 'utf-8') let at = src.indexOf(READ) while (at >= 0) { const tail = src.slice(at + READ.length, at + READ.length + 600) /* 이 자리가 어떤 호출인가 — 첫 메서드만 본다. */ const call = /^\s*\.?\s*(find|findOne|count|countBy|createQueryBuilder|save|insert|delete|create)\b/.exec(tail) if (call && call[1] === 'find') { /* 옵션 객체 안에 상한이 있나 — 괄호 균형까지 보지 않고 넉넉히 잘라 본다(실패 시 폴드 표식이 받는다). */ const opts = tail.slice(0, 500) const before = src.slice(Math.max(0, at - PRAGMA_LOOKBACK), at) out.push({ file: f.slice(root.length + 1), line: src.slice(0, at).split('\n').length, bounded: /\btake\s*:/.test(opts), fold: before.includes(FOLD_PRAGMA) }) } at = src.indexOf(READ, at + 1) } } } return out } test('저널을 통째로 읽는 자리는 폴드라고 밝히고 이유를 적는다', () => { const bare = journalFindSites() .filter(s => !s.bounded && !s.fold) .map(s => `${s.file}:${s.line}`) assert.deepEqual( bare, [], `이유 없이 저널 전체를 읽는 자리 ${bare.length}곳:\n ${bare.join('\n ')}\n\n` + `둘 중 하나로 만든다.\n` + ` ① 화면이 쓰는 만큼만 읽는다면 \`take:\` 를 준다.\n` + ` ② 사실을 계산하는 것이 목적이라면 바로 앞에 \`journal-fold:\` 주석으로 왜 행이 필요한지,\n` + ` 상한이 없다면 그 예외가 없어질 조건까지 적는다.\n\n` + `수 몇 개만 필요하다면 셋째 길이 맞다: 집계는 DB 가 한다(MIN/MAX/COUNT + createQueryBuilder).\n` + `\`timeRange\` 가 min/max 두 수를 구하려고 275,882행·payload 131MB 를 4초마다 파싱했던 자리다.` ) }) test('검사가 실제로 저널 읽기를 보고 있다 — 대상이 사라지면 초록이 거짓이 된다', () => { const sites = journalFindSites() /* * ── 문턱을 셋에서 둘로 내렸다 (2026-08-28) ───────────────────────────────── * 자리 하나를 **질의 빌더로 옮겨서** 줄었다(계보 조회 — 관계로 물어 두 단계 질의가 되고 `take` 가 * 있어도 안쪽에 상한이 없던 자리다). 그 방향이 이 검사가 바라는 방향이므로 문턱을 따라 내린다. * * 이 단정의 뜻은 「자리가 많아야 한다」가 아니라 **「검사가 무언가를 보고 있어야 한다」**다. 남은 둘도 * 옮겨지면 이 시험이 빨개지는데, 그때가 바로 사람이 알아야 하는 순간이다 — 검사가 아무것도 보지 않는 * 채로 초록이면 그 초록이 거짓이다. 그때는 검사의 패턴을 질의 빌더 쪽으로 옮긴다. */ assert.ok( sites.length >= 2, `저널 \`find()\` 를 ${sites.length}곳 찾았다 — 둘보다 적으면 이 검사가 보는 대상이 없어졌다는 뜻이다. ` + `호출을 질의 빌더로 옮겼다면 이 검사의 패턴도 그쪽으로 옮긴다(리포지토리를 변수에 담아 부르는 ` + `형태도 함께 본다).` ) }) test('시각축은 집계로 답한다 — 행을 실어 오는 방식으로 되돌아가지 않는다', () => { /* * `timeRange` 만은 이름으로 못 박는다. 이 함수가 이 결함의 출처이고, 되돌아가도 **답은 맞기 때문에** * 결과를 보는 시험으로는 잡히지 않는다. */ const src = readFileSync(new URL('../server/engine/twin-engine.ts', import.meta.url).pathname, 'utf-8') const at = src.indexOf('static async timeRange(') assert.ok(at > 0, 'timeRange 를 찾지 못했다 — 이름이 바뀌면 이 검사도 함께 옮긴다') const body = src.slice(at, at + 2500) assert.ok(body.includes('MIN(e.eventTime)'), '가장 이른 시각은 DB 가 고른다') assert.ok(body.includes('MAX(e.eventTime)'), '가장 늦은 시각도 그렇다') assert.ok(!body.includes('getRepository(TwinEvent).find('), '저널을 행 단위로 실어 오지 않는다') }) /* * ── 세는 것도 읽는 것이다 (2026-08-22 실측) ───────────────────────────────── * 위 규율은 **행을 실어 오는 것**을 봤다. 그런데 `getManyAndCount()` 는 행을 싣지 않으면서도 조건에 * 맞는 행을 **전부 센다**. 실측: 저널 358만 행인 트윈에서 한 번에 5.32초, 상한 1만이면 0.04초. * 이력 화면을 열 때마다 그것을 물었고 그 5초 동안 이벤트 루프가 다른 요청을 처리하지 못했다. * * 그래서 저널에는 **상한 없는 세기를 두지 않는다.** 정확한 수를 모르는 것은 사실이므로 그때는 * 「모른다」로 낸다(목록 규약의 `capped`) — 상한값을 전체로 적으면 화면이 거짓을 말한다. */ test('저널을 상한 없이 세지 않는다 — 답은 맞지만 비용이 틀린 자리', () => { /* * **주석과 남의 표를 세지 않는다.** 처음 만든 검사는 「파일이 `TwinEvent` 를 언급하고 어딘가에 * `.count(` 가 있으면」 이었다. 그래서 공간·구조·대표표현을 세는 자리와 **이 규율을 설명하는 주석** * 까지 잡았다 — 거짓 경보는 참 경보를 죽인다. 규칙은 하나다: **저널 저장소에서 세는 자리**만 본다. */ const offenders: string[] = [] for (const file of ROOTS.flatMap(r => sources(r))) { const src = stripComments(readFileSync(file, 'utf-8')) for (const m of src.matchAll(/getRepository\(TwinEvent\)|repository: getRepository\(TwinEvent\)/g)) { const after = src.slice(m.index ?? 0, (m.index ?? 0) + 900) const counted = after.match(/getManyAndCount\(|getCount\(|\.count\(/) if (!counted) continue /* 상한이 함께 적혀 있으면 규율을 지킨 것이다(상한까지만 센다). */ if (/take\(|COUNT_CAP/.test(after.slice(0, (counted.index ?? 0) + 80))) continue offenders.push(`${file.split('/packages/')[1]} ${counted[0]}`) } } assert.deepEqual( offenders, [], `저널을 상한 없이 세는 자리가 있다 — 상한까지만 세고 넘으면 「모른다」로 낸다:\n ${offenders.join('\n ')}` ) }) test('건수 상한이 값으로 선언돼 있고, 넘으면 전체를 비운다', () => { const src = readFileSync(new URL('../server/service/twin-journal/twin-journal-query.ts', import.meta.url).pathname, 'utf-8') assert.match(src, /const COUNT_CAP = JOURNAL_COUNT_CAP/, '상한은 저널 세기의 문에서 온다 — 값이 두 곳에 흩어지지 않게') assert.match(src, /take\(COUNT_CAP \+ 1\)/, '상한을 넘었는지 알려면 하나 더 읽어야 한다') /* 상한을 넘으면 **전체를 비운다** — 상한값을 전체로 적으면 화면이 거짓을 말한다. */ assert.match(src, /capped \? undefined : counted\.length/) assert.match(src, /capped/, '넘었다는 사실을 함께 낸다(알리지 않고 자르지 않는다)') })