배경
성능·동작 분석 글을 쓰다 보면 "제가 재봤더니 이랬습니다"로 끝나는 숫자가 생깁니다. 독자는 그 숫자를 검증할 수 없고, 저자도 나중에 재현하기 어렵습니다.
getByRole 성능 글을 검증하면서 이게 구체적으로 드러났습니다. 글이 "재봐야 안다"고 가르치는데, 정작 독자에게 건네는 4구간 벤치마크 수치(9.7 / 463.4 / 8.8 / 266.5ms)에는 머신 사양도 Node·jsdom 버전도 코드도 없습니다. 같은 조건으로 다시 재보니 절대값이 2.5배 차이 났습니다(배율 구조는 동일). 측정을 설교하는 글이 재현 불가능한 숫자를 제시하는 자기모순입니다.
정적 코드 블록 대신 독자가 직접 돌려보는 위젯이 있으면 이 문제가 사라집니다.
핵심 아이디어 — 무엇을 라이브로 돌릴 수 있는가
먼저 짚어야 할 구분이 있습니다. 이게 설계 전체를 결정합니다.
|
환경 의존성 |
브라우저에서 라이브 가능? |
| 호출 횟수·구조적 사실 |
없음 |
✅ 그대로 재현됨 |
| 실행 시간(ms) |
큼 (머신·엔진·jsdom 버전) |
⚠️ 브라우저 값만 |
getByRole 글의 핵심 주장 세 개는 전부 호출 횟수와 구조로 증명됩니다.
- 후보 86개 전부의 이름이 계산된다 (
.filter()가 첫 매치에서 안 멈춤)
- 숨은 노드 85개도 예외 없이 계산된다
- 후보당
getComputedStyle이 약 3회 불린다 (총 261회)
이 숫자들은 jsdom이든 브라우저든 똑같이 나옵니다. 그래서 @testing-library/dom과 dom-accessibility-api를 브라우저에 그대로 띄우고 window.getComputedStyle을 감싸서 세면, 독자가 자기 브라우저에서 직접 확인할 수 있습니다.
시간만 다릅니다. 그리고 그 차이 자체가 글의 결론입니다 — "브라우저는 렌더할 때 스타일을 계산해두고, jsdom은 물어볼 때 계산한다". 즉 독자 브라우저의 측정값 옆에 기록된 jsdom 값을 나란히 놓는 것이 곧 논지의 증명이 됩니다.
결론: 구조적 사실은 라이브로 증명하고, jsdom 타이밍은 기록값으로 대조한다.
현재 구조에서의 구현 방법
마크다운 확장 지점은 이미 있습니다
apps/blog/web/src/app/posts/[...slug]/PostClient.tsx:318 근처에서 raw HTML 태그를 React 컴포넌트로 매핑하고 있습니다.
rehypePlugins={[rehypeRaw, rehypeSlug]}
components={{
// ...
callout: Callout,
'file-tree': FileTree,
}}
rehype-raw가 이미 붙어 있으므로 새 문법이나 MDX 전환 없이 태그 하나만 추가하면 됩니다.
글에서는 이렇게 씁니다.
<playground experiment="role-query-calls" buttons="85" rules="2000"></playground>
왜 MDX로 가지 않는가
react-markdown + rehype-raw 조합이 이미 callout / file-tree / figure로 검증돼 있습니다. MDX 전환은 콘텐츠 파이프라인(validate-posts.ts, generate-search-index.ts, generate-rss.ts, generate-llms-full.ts)을 전부 건드리는 큰 변경이고, 얻는 건 임의 JSX인데 우리에게 필요한 건 등록된 실험 몇 개뿐입니다. 비용 대비 이득이 안 맞습니다.
실험은 저자가 쓰는 코드가 아니라 등록된 모듈
독자가 임의 코드를 입력하는 REPL이 아니라, 저자가 미리 작성해 번들에 포함시킨 실험을 파라미터만 바꿔 돌리는 형태입니다. 임의 코드 실행은 보안·번들·유지보수 비용이 크고, 이 블로그가 필요한 건 그게 아닙니다.
src/components/post/markdown/Playground/
├── Playground.tsx # <playground> 진입점, 컨트롤 + 결과 렌더
├── registry.ts # experiment id → 모듈 매핑 (lazy import)
├── types.ts # ExperimentDef, RunResult
├── baselines/
│ └── role-query-calls.json # 기록된 jsdom 측정값 + 환경 메타
└── experiments/
├── accessibleName.ts # textContent vs 접근성 이름 vs 스텁
├── roleQueryCalls.ts # getComputedStyle 호출 횟수 계측
└── styleCache.ts # 콜드/웜/무효화 4구간
실험 모듈 인터페이스 스케치:
export type RunResult = {
rows: Array<{ label: string; ms?: number; calls?: number; note?: string }>;
facts?: Array<{ claim: string; passed: boolean }>; // 구조적 사실 검증
};
export type ExperimentDef = {
id: string;
params: Array<{ key: string; label: string; min: number; max: number; default: number }>;
run: (params: Record<string, number>, signal: AbortSignal) => Promise<RunResult>;
};
facts가 핵심입니다. "후보 86개 전부 이름 계산됨 ✅" 같은 통과/실패 판정을 독자 브라우저에서 직접 보여주면, 시간 수치보다 훨씬 강한 증거가 됩니다.
SSG 제약 대응
next.config.ts가 output: 'export'라 서버가 없습니다. 전부 클라이언트에서 끝나야 합니다.
- 빌드 시에는 플레이스홀더만 렌더하고 하이드레이션 후 활성화
@testing-library/dom은 버튼을 누른 뒤 동적 import (초기 번들에 안 실림)
- 선례:
MermaidChart.tsx가 이미 클라이언트 전용 무거운 라이브러리를 다루고 있음. next/dynamic + ssr: false는 AdminLayoutClient.tsx:10에 이미 사용 중
@testing-library/dom은 이미 devDependencies에 있으니 dependencies로 승격이 필요합니다. dom-accessibility-api는 신규 추가입니다.
측정 신뢰성
브라우저 타이밍은 노이즈가 큽니다. 최소한 이 정도는 해야 글의 주장(측정 규율)과 앞뒤가 맞습니다.
- 워밍업 1회 후 N회 반복 → 중앙값 + 최소/최대 표기 (단일 측정 금지)
performance.now() 해상도가 스펙터 완화로 제한될 수 있음을 명시
- 실행 중 UI 블로킹 방지 — 무거운 실험은 청크로 쪼개고
AbortSignal로 중단 가능하게
- 결과 옆에 독자 환경 자동 표기(userAgent, 코어 수, 측정 시각)
접근성·UX
- 실행은 명시적 버튼으로만 (자동 실행 금지 — 저사양 기기 배려)
prefers-reduced-motion 존중
- JS 비활성 / 실행 실패 시 기록된 결과 테이블로 폴백 (글이 깨지지 않아야 함)
- 결과 테이블은
overflow-x: auto (기존 모바일 대응 방침)
검토했으나 채택하지 않은 안
| 안 |
왜 안 되는가 |
| 브라우저에서 jsdom 구동 |
jsdom은 Node 지향이라 브라우저 번들이 비현실적(무겁고 Node API 의존). 애초에 브라우저엔 진짜 DOM이 있어서 목적에도 안 맞음 |
| WebContainer로 실제 vitest 실행 |
COOP/COEP 헤더가 필수인데 GitHub Pages는 응답 헤더를 설정할 수 없음. 정적 호스팅과 근본적으로 충돌 |
| StackBlitz/CodeSandbox iframe 임베드 |
헤더 문제는 없지만 무겁고 오프사이트 의존. 다만 "전체 재현"용 보조 링크로는 유효 |
| 결과 JSON만 시각화 |
구현은 제일 싸지만 "직접 돌려본다"는 핵심 가치가 사라짐. 폴백으로만 사용 |
단계
1단계 — 뼈대 + 실험 1개
<playground> 매핑, registry, accessibleName 실험. 타이밍 없이 문자열 비교만이라 노이즈가 없고 결정론적이라 첫 실험으로 적합. (textContent = "지난달 15일" vs 접근성 이름 = "15일" vs 스텁 = "지난달 15일")
2단계 — 호출 횟수 계측
roleQueryCalls 실험. window.getComputedStyle을 감싸 후보별 호출을 세고, 구조적 사실 3개를 통과/실패로 판정. 이게 이 이슈의 핵심 가치입니다.
3단계 — 파라미터 + 타이밍
버튼 수·CSS 규칙 수 슬라이더, 4구간 스타일 캐시 실험, 반복 측정 + 중앙값, 기록된 jsdom 기준선과 나란히 표기.
4단계 (선택)
전체 재현용 StackBlitz 링크, 결과 공유용 permalink(쿼리스트링에 파라미터 인코딩).
완료 기준
관련
배경
성능·동작 분석 글을 쓰다 보면 "제가 재봤더니 이랬습니다"로 끝나는 숫자가 생깁니다. 독자는 그 숫자를 검증할 수 없고, 저자도 나중에 재현하기 어렵습니다.
getByRole 성능 글을 검증하면서 이게 구체적으로 드러났습니다. 글이 "재봐야 안다"고 가르치는데, 정작 독자에게 건네는 4구간 벤치마크 수치(
9.7 / 463.4 / 8.8 / 266.5ms)에는 머신 사양도 Node·jsdom 버전도 코드도 없습니다. 같은 조건으로 다시 재보니 절대값이 2.5배 차이 났습니다(배율 구조는 동일). 측정을 설교하는 글이 재현 불가능한 숫자를 제시하는 자기모순입니다.정적 코드 블록 대신 독자가 직접 돌려보는 위젯이 있으면 이 문제가 사라집니다.
핵심 아이디어 — 무엇을 라이브로 돌릴 수 있는가
먼저 짚어야 할 구분이 있습니다. 이게 설계 전체를 결정합니다.
getByRole 글의 핵심 주장 세 개는 전부 호출 횟수와 구조로 증명됩니다.
.filter()가 첫 매치에서 안 멈춤)getComputedStyle이 약 3회 불린다 (총 261회)이 숫자들은 jsdom이든 브라우저든 똑같이 나옵니다. 그래서
@testing-library/dom과dom-accessibility-api를 브라우저에 그대로 띄우고window.getComputedStyle을 감싸서 세면, 독자가 자기 브라우저에서 직접 확인할 수 있습니다.시간만 다릅니다. 그리고 그 차이 자체가 글의 결론입니다 — "브라우저는 렌더할 때 스타일을 계산해두고, jsdom은 물어볼 때 계산한다". 즉 독자 브라우저의 측정값 옆에 기록된 jsdom 값을 나란히 놓는 것이 곧 논지의 증명이 됩니다.
현재 구조에서의 구현 방법
마크다운 확장 지점은 이미 있습니다
apps/blog/web/src/app/posts/[...slug]/PostClient.tsx:318근처에서 raw HTML 태그를 React 컴포넌트로 매핑하고 있습니다.rehype-raw가 이미 붙어 있으므로 새 문법이나 MDX 전환 없이 태그 하나만 추가하면 됩니다.글에서는 이렇게 씁니다.
왜 MDX로 가지 않는가
react-markdown+rehype-raw조합이 이미callout/file-tree/figure로 검증돼 있습니다. MDX 전환은 콘텐츠 파이프라인(validate-posts.ts,generate-search-index.ts,generate-rss.ts,generate-llms-full.ts)을 전부 건드리는 큰 변경이고, 얻는 건 임의 JSX인데 우리에게 필요한 건 등록된 실험 몇 개뿐입니다. 비용 대비 이득이 안 맞습니다.실험은 저자가 쓰는 코드가 아니라 등록된 모듈
독자가 임의 코드를 입력하는 REPL이 아니라, 저자가 미리 작성해 번들에 포함시킨 실험을 파라미터만 바꿔 돌리는 형태입니다. 임의 코드 실행은 보안·번들·유지보수 비용이 크고, 이 블로그가 필요한 건 그게 아닙니다.
실험 모듈 인터페이스 스케치:
facts가 핵심입니다. "후보 86개 전부 이름 계산됨 ✅" 같은 통과/실패 판정을 독자 브라우저에서 직접 보여주면, 시간 수치보다 훨씬 강한 증거가 됩니다.SSG 제약 대응
next.config.ts가output: 'export'라 서버가 없습니다. 전부 클라이언트에서 끝나야 합니다.@testing-library/dom은 버튼을 누른 뒤 동적 import (초기 번들에 안 실림)MermaidChart.tsx가 이미 클라이언트 전용 무거운 라이브러리를 다루고 있음.next/dynamic+ssr: false는AdminLayoutClient.tsx:10에 이미 사용 중@testing-library/dom은 이미devDependencies에 있으니dependencies로 승격이 필요합니다.dom-accessibility-api는 신규 추가입니다.측정 신뢰성
브라우저 타이밍은 노이즈가 큽니다. 최소한 이 정도는 해야 글의 주장(측정 규율)과 앞뒤가 맞습니다.
performance.now()해상도가 스펙터 완화로 제한될 수 있음을 명시AbortSignal로 중단 가능하게접근성·UX
prefers-reduced-motion존중overflow-x: auto(기존 모바일 대응 방침)검토했으나 채택하지 않은 안
단계
1단계 — 뼈대 + 실험 1개
<playground>매핑, registry,accessibleName실험. 타이밍 없이 문자열 비교만이라 노이즈가 없고 결정론적이라 첫 실험으로 적합. (textContent= "지난달 15일" vs 접근성 이름 = "15일" vs 스텁 = "지난달 15일")2단계 — 호출 횟수 계측
roleQueryCalls실험.window.getComputedStyle을 감싸 후보별 호출을 세고, 구조적 사실 3개를 통과/실패로 판정. 이게 이 이슈의 핵심 가치입니다.3단계 — 파라미터 + 타이밍
버튼 수·CSS 규칙 수 슬라이더, 4구간 스타일 캐시 실험, 반복 측정 + 중앙값, 기록된 jsdom 기준선과 나란히 표기.
4단계 (선택)
전체 재현용 StackBlitz 링크, 결과 공유용 permalink(쿼리스트링에 파라미터 인코딩).
완료 기준
<playground experiment="...">한 줄로 삽입 가능pnpm build --filter=@blog/web정적 export 성공관련
apps/blog/web/src/app/posts/[...slug]/PostClient.tsx— 컴포넌트 매핑 지점apps/blog/web/src/components/post/MermaidChart.tsx— 클라이언트 전용 렌더링 선례