- Published on
플레이그라운드를 다시 짜기 — PegCode, 광고·GA4·타입 진단이 바꾼 설계
- Authors

- Name
- Nostrss
- Github
- Github
사실 브라우저 플레이그라운드는 처음이 아니다. 예전에 JavaScript Playground를 한 번 짠 적이 있고, 거기에 URL 해시로 코드를 공유하는 기능까지 붙였었다. 그래서 Pegboard에 JS/TS 플레이그라운드 peg를 넣기로 했을 때, "그거 그대로 옮기면 되겠네" 하고 가볍게 봤다.
그런데 옮기는 게 아니라 거의 다시 짜게 됐다. 뼈대(sandbox iframe + postMessage)는 같은데, 제약이 달라지니 절반의 결정이 바뀌었다. 이번 peg가 사는 곳에는 (i) 디스플레이 광고가 붙고, (ii) 다른 peg와 같은 origin의 localStorage를 공유하고, (iii) console 에러 하나면 빨개지는 레지스트리 스모크 게이트가 있고, (iv) GA4가 모든 URL을 빨아들이고, (v) 운영자가 "VSCode식 빨간 줄"을 v1에 박으라고 했다. 결정 기록은 docs/adr/0014-pegrun-playground-monaco-sandboxed-iframe.md에 있다. (도구 이름은 처음 PegRun이었다가 PegCode로 개명됐다 — ADR 헤더에 그 흔적이 남아 있다.)
이 글은 그 다섯 개 제약이 코드를 어떻게 다시 그었는지에 대한 기록이다.
같은 iframe, 이번엔 선택이 아니라 의무
예전 글에서는 eval과 new Function이 왜 위험한지를 설명한 다음 iframe을 골랐다. 이번엔 고른 게 아니라 다른 길이 전부 막혔다.
eval(userCode)나 new Function(userCode)()는 host 페이지와 같은 실행 컨텍스트에서 돈다. Pegboard에서는 그게 곧:
- 사용자 코드가 페이지에 붙은 광고 스크립트·DOM을 만질 수 있고,
- 같은 origin의 다른 peg가 저장해 둔 localStorage를 읽고 지울 수 있고,
- 사용자 코드가 던진 console 에러 하나가 host의 콘솔에 찍혀 스모크 게이트(
tests/e2e/utility-smoke.spec.ts)를 빨갛게 만든다.
세 번째가 특히 결정적이다. 이 게이트는 모든 유틸 페이지를 로드해서 콘솔/페이지 에러가 하나라도 나면 실패한다. host 컨텍스트에서 사용자 코드를 돌리는 순간, 사용자가 짠 throw가 곧 내 배포 게이트의 실패가 된다. 그래서 eval/new Function은 전부 탈락하고, 사용자 코드의 console·예외·타이머를 host 바깥으로 밀어내는 <iframe sandbox="allow-scripts">만 남는다.
allow-same-origin을 일부러 빼면 브라우저가 iframe을 opaque origin(null origin)으로 취급해서, host의 localStorage·쿠키·DOM 어디에도 닿지 못한다. host와 iframe은 오직 postMessage로만 대화한다. 여기까지는 예전 글과 같은 결론이고, 이번엔 그 결론이 유일한 선택지라는 게 다르다.
실행 엔진 — 가짜 console 대신 진짜 console을 갈아끼우기
예전 버전은 iframe 안에서 new Function('console', code)로 가짜 console 객체를 함수 인자로 주입했다. 이번엔 접근을 바꿨다. iframe의 전역 console.* 자체를 갈아끼우고, 트랜스파일된 코드를 indirect eval로 전역 스코프에서 실행한다 (src/lib/peg-code/runner.ts).
['log', 'info', 'warn', 'error', 'debug'].forEach(function (level) {
console[level] = function () {
post({ type: 'console', level: level, text: serializeArgs(arguments) });
};
});
// ...
window.addEventListener('message', function (event) {
var data = event.data;
if (!data || typeof data.code !== 'string' || data.run !== true) return;
post({ type: 'start' });
try {
(0, eval)(data.code);
} catch (error) {
post({ type: 'console', level: 'error', text: serialize(error) });
}
post({ type: 'done' });
});
(0, eval)(...)은 indirect eval이라 iframe의 전역 스코프에서 실행된다. 코드가 참조하는 console이 곧 우리가 바꿔치기한 그 console이라, 인자로 주입할 필요가 없어졌다. 동기 에러는 try/catch로, 비동기 에러는 window.onerror와 unhandledrejection 리스너로 잡아 같은 console 메시지 채널로 흘려보낸다.
솔직하게 짚자면, 격리를 만드는 건 iframe 경계지 eval이냐 new Function이냐가 아니다. opaque-origin iframe 안에서는 둘 다 host에 닿지 못한다. indirect eval로 바꾼 건 보안 때문이 아니라, 전역 console을 덮어쓰는 방식과 더 자연스럽게 맞물리기 때문이다.
직렬화는 iframe 안에서 끝낸다
postMessage는 구조적 클론으로 데이터를 복사하는데, 함수와 순환 참조 객체는 복사하지 못한다. 그래서 값을 host로 보내기 전에, iframe 안에서 문자열로 만들어 버린다. 문제는 직렬화 함수를 어떻게 iframe 안에 넣느냐다.
해법은 직렬화 함수의 소스를 .toString()으로 떠서 srcdoc에 그대로 박는 것이다 (runner.ts가 serializeConsoleArg.toString()을 인라인한다). 그래서 serializeConsoleArg는 모듈 스코프를 일절 참조하지 않는 완전 자기완결 함수여야 한다 — import도, 바깥 변수도 안 된다. 같은 함수를 Node에서 그대로 단위 테스트할 수 있다는 게 덤이다 (src/lib/peg-code/console-format.ts).
export function serializeConsoleArg(value: unknown): string {
function safeStringify(input: unknown): string | undefined {
const seen: unknown[] = [];
try {
return JSON.stringify(input, function (_key, val) {
if (typeof val === 'bigint') return `${String(val)}n`;
if (typeof val === 'function') {
return `[Function: ${(val as { name?: string }).name || 'anonymous'}]`;
}
if (typeof val === 'undefined') return undefined;
if (typeof val === 'object' && val !== null) {
if (seen.indexOf(val) !== -1) return '[Circular]';
seen.push(val);
}
return val;
}, 2);
} catch {
try { return String(input); } catch { return '[Unserializable]'; }
}
}
// 원시값은 빠르게 통과, 그 외는 safeStringify로
// ...
}
seen 배열로 순환 참조를 잡아 [Circular]로 끊고, BigInt는 42n처럼 접미사를 붙이고, 함수는 [Function: name]으로, undefined는 객체 안에서 키를 지운다. JSON.stringify가 끝까지 실패하면 String()으로, 그것도 던지면 [Unserializable]로 떨어진다. console에 뭘 던져도 빨간 페이지 대신 그럴듯한 문자열 하나가 나오는 게 목표다.
매 실행 = 새 iframe
run()이 불릴 때마다 기존 iframe을 떼어내고 새로 만든다 (use-code-runner.ts).
const run = useCallback((js: string) => {
teardown(); // 이전 프레임 제거
setEntries([]);
setStatus('running');
const iframe = document.createElement('iframe');
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.style.display = 'none';
iframe.srcdoc = srcdoc; // srcdoc은 한 번만 만들어 재사용
pendingCodeRef.current = js;
iframeRef.current = iframe;
container.appendChild(iframe);
}, [teardown, srcdoc]);
iframe을 재사용하지 않는 이유는 예전 글에서 정리한 그대로다 — 전역 상태 오염(window.x = 1이 다음 실행에 남음), 잔존 타이머·리스너, 메모리 누수. 매번 새로 만드는 비용보다 깨끗한 전역에서 시작하는 이점이 크다.
이 구조가 공짜로 주는 게 하나 더 있다. Stop = 프레임 teardown이다. 실행 중인 iframe을 통째로 떼면 그 안의 타이머·pending Promise·리스너가 프레임과 함께 사라진다. host와 iframe이 서로 다른 window라 가능한 하드 스톱이다. 거기에 stale 프레임 방어로, host는 event.source가 현재 iframe의 contentWindow일 때만 메시지를 받아들여서, 떼어낸 옛 프레임이 뒤늦게 보낸 메시지를 버린다.
다만 정직하게 한 줄. Stop이 격리는 줘도 자원 독점까지 막진 못한다. allow-scripts iframe은 사용자 코드의 CPU 독점·fetch·정밀 타이머를 막아주지 않는다는 예전 글의 한계가 그대로 남아 있다. while(true){} 같은 동기 무한 루프가 도는 동안 Stop이 즉시 먹히는지는 브라우저의 iframe 프로세스 격리(사이트 아이솔레이션)에 달려 있어서, 코드 레벨에서 보장한다고 말하기 어렵다 — 확실히 막으려면 Web Worker + 실행 시간 워치독이 필요한데, v1 범위는 아니다.
"빨간 줄" 한 줄이 에디터 엔진을 갈랐다
원래 결과창은 "컴파일 결과만 보여주는 뷰"로 가려다, 운영자가 RUN + console로 못 박았다. peg-app의 수익 모델이 반복 방문이라, 돌려보는 스크래치패드라야 사람이 돌아온다는 논리였다. 여기까진 실행 엔진 얘기와 맞물린다.
진짜 분기점은 다른 한 줄이었다. 운영자가 VSCode식 인라인 타입 진단(빨간 줄·호버) 을 v1에 넣기로 번복한 순간, 에디터 선택이 갈렸다. 타입 진단은 TypeScript 언어 서비스(tsserver)가 만들어내고, 그걸 통째로 내장한 에디터가 Monaco다. 문제는 ADR-0008이 공통 코드 표면을 CodeMirror 6로 표준화하면서 Monaco를 명시적으로 거부했다는 점이다 — ≈100개 광고 페이지가 공유하는 번들을 수 MB짜리 에디터로 오염시킬 수 없으니까.
ADR-0014의 정리는 이 거부 사유가 "전 페이지 공유 에디터" 맥락의 판단이라는 것이다. PegCode는 단일 peg-app 라우트에 next/dynamic(ssr: false)로 lazy-load되어 격리되므로, 다른 99개 페이지를 오염시키지 않는다. 그래서 이 한 라우트에 한해 Monaco를 허용하고, 공유 코드 표면(변환기 등)은 CM6 그대로 둔다. Monaco는 CDN 로더가 아니라 self-host한다 — 제3자 CDN 의존은 "코드가 기기를 안 떠난다"는 프라이버시 서사를 깨니까.
Monaco를 들인 덕에 트랜스파일러를 따로 안 붙여도 된다. Monaco의 TS 워커가 타입체크와 TS→JS 방출을 둘 다 한다.
async function transpile(lang: Language, code: string): Promise<string> {
if (lang === 'javascript' || !monaco || !editor) return code;
const model = editor.getModel();
if (!model) return code;
try {
const getWorker = await monaco.languages.typescript.getTypeScriptWorker();
const client = await getWorker(model.uri);
const output = await client.getEmitOutput(model.uri.toString());
const jsFile = output.outputFiles.find((f) => f.name.endsWith('.js'));
return jsFile ? jsFile.text : code;
} catch {
return code; // 진짜 문법 에러는 실행 시 console에서 드러난다
}
}
JS면 그대로 통과, TS면 워커가 방출한 .js를 꺼내 sandboxed iframe으로 보낸다. 워커가 실패하면 원본을 그대로 넘긴다 — 어차피 진짜 에러는 iframe console에 찍히니까. sucrase 같은 별도 트랜스파일러를 들이는 길도 있었지만, 그건 타입 스트립만 하고 "빨간 줄"을 못 그린다. 운영자의 번복이 그 길을 닫았다.
공유는 query가 아니라 hash여야 했다
코드 공유 자체는 예전 글에서 LZ-String + URL로 풀어둔 그대로다. 그런데 이번엔 query param이 아니라 반드시 URL hash여야 하는 이유가 새로 생겼다. 이 사이트엔 GA4가 붙어 있고, query param에 코드를 실으면 공유 링크를 열 때마다 사용자 코드 전체가 page_location으로 구글에 전송된다. hash 프래그먼트는 서버로도, 애널리틱스로도 안 간다.
interface WirePayload {
v: 1;
l: 't' | 'j'; // language, 't'=typescript / 'j'=javascript
c: string; // code
}
export function encodeSnippet(snippet: SharedSnippet): string {
const payload: WirePayload = {
v: 1,
l: snippet.language === 'typescript' ? 't' : 'j',
c: snippet.code,
};
return 'code=' + compressToEncodedURIComponent(JSON.stringify(payload));
}
페이로드에 버전(v)을 박아둔 게 작지만 중요하다. 나중에 스키마가 바뀌어도 옛 링크를 식별해서 처리하거나, 못 읽으면 깔끔히 null로 떨어뜨릴 수 있다. 디코드는 hash가 code=로 시작하는지 보고, 압축을 풀고, v !== 1이거나 c가 문자열이 아니면 null을 반환한다. PegCode 스니펫이 아닌 hash는 전부 null로 떨어져서, 호출부가 자연스럽게 localStorage나 seed로 폴백한다.
시작 상태 — hash > storage > seed, 그리고 in-place 시딩
플레이그라운드가 열릴 때 보여줄 코드는 세 출처에서 온다. 우선순위가 있고, 그 규칙을 순수 함수로 떼어내 단위 테스트한다 (src/lib/peg-code/initial-state.ts).
export function resolveInitialState({ shared, stored, seed }: ResolveArgs): ResolvedState {
// 공유 링크는 자기 언어 버퍼를 차지, 나머지/설정은 저장값(없으면 seed)
if (shared) {
const base = stored?.buffers ?? seed;
return {
language: shared.language,
buffers: { ...base, [shared.language]: shared.code },
autorun: stored?.autorun ?? false,
};
}
// 재방문: 저장 버퍼 복원(빠진 버퍼는 seed로 채움)
if (stored) {
return { language: stored.language, buffers: { ...seed, ...stored.buffers }, autorun: stored.autorun };
}
// 첫 방문: 두 버퍼 모두 seed, 기본 TypeScript
return { language: 'typescript', buffers: { ...seed }, autorun: false };
}
공유 링크가 이기되 자기 언어 버퍼만 차지하고, 다른 언어 버퍼와 autorun 설정은 저장값에서 가져온다. 재방문이면 저장한 두 버퍼를 복원한다. localStorage 저장은 pegboard:peg-code:v1 키에 디바운스로 best-effort 저장하고(시크릿 모드·쿼터 에러는 조용히 삼킨다), 읽을 때 모양을 검증해 깨진 값은 seed로 흘려보낸다.
세 번째 분기 — 첫 방문 seed가 peg-app의 온보딩이다. Peg Apps는 ADR-0012에 따라 hero·learn 같은 본문 SEO/광고 블록을 안 싣는다(보이는 H1조차 sr-only다). 대신 도구 안에 직접 샘플을 심는 in-place 시딩으로 온보딩을 대신한다. PegCode의 seed는 인사·사용법·"서버에 아무것도 저장 안 됨" 안내를 주석 블록으로 얹은, 진짜 편집 가능한 코드다. 저장값이나 공유 코드가 있으면 절대 안 뜬다 — 사용자의 작업을 덮지 않는다.
로드 순간엔 아무 일도 일어나면 안 된다
마지막 제약 하나가 자동실행 동작을 갈랐다. 스모크 게이트는 페이지를 page.goto로 로드만 한다. 그러니 첫 로드에 자동실행이 돌면 안 된다 — seed 코드를 실행하다 콘솔에 뭐라도 찍히거나 에러가 나면 게이트가 빨개진다.
그래서 규칙은 이렇다. 첫 방문 seed는 표시만 하고, 처음 실행은 오직 Run 버튼이나 Cmd/Ctrl+Enter로만. autorun 체크박스를 켜도 편집(타이핑)이 일어날 때만 디바운스 라이브 실행이 돈다. 스모크는 타이핑을 하지 않으니 이 라이브 실행과 충돌하지 않는다. 에디터의 onChange도 ev.isFlush(프로그램적 setValue)를 걸러서, 초기 로드나 언어 전환 같은 비-편집 변경엔 안 돈다.
작아 보이지만, "편집 시에만 자동실행"이라는 한 줄은 UX 선택이 아니라 배포 게이트를 통과하기 위한 제약에서 나왔다.
정리
- 브라우저 JS/TS 플레이그라운드를 peg-app으로 다시 짰다. 뼈대(
sandboxiframe + postMessage)는 예전 JS Playground와 같지만, 제약이 절반의 결정을 다시 그었다(ADR-0014). - iframe 격리는 이제 선택이 아니라 의무 — 광고, 공유 origin localStorage, console 에러 하나면 깨지는 스모크 게이트 때문에
eval/new Function(host 컨텍스트)이 전부 탈락. - 실행 엔진은 iframe 전역
console.*을 덮어쓰고 트랜스파일된 코드를 indirect eval로 돌린다. 격리는 iframe 경계가 만들지 eval 선택이 만드는 게 아니다. - console 값은 postMessage 전에 iframe 안에서 직렬화한다 — 직렬화 함수 소스를
.toString()으로 srcdoc에 박으므로 그 함수는 완전 자기완결이어야 한다. 순환참조·BigInt·함수·undefined를 안전하게 문자열화. - 매 실행 = 새 iframe → 깨끗한 전역 +
Stop=teardown 하드 스톱. 단, 동기 무한 루프는 여전히 못 막는다(정직한 한계). - "빨간 줄" 요구 한 줄이 에디터를 갈랐다 — 격리된 단일 라우트라 ADR-0008의 Monaco 거부가 적용 안 되어 Monaco를 self-host로 허용. TS 워커가 타입체크 + TS→JS 방출을 둘 다 해서 별도 트랜스파일러 불필요.
- 공유는 query가 아니라 URL hash — GA4가 query를
page_location으로 구글에 보내니까. LZ-String 압축 + 버전 박은 페이로드. - 시작 상태는 hash > storage > seed 순수 함수로 결정, 첫 방문 seed가 곧 ADR-0012의 in-place 온보딩.
- 로드 순간 무반응 + 편집 시에만 자동실행 — UX가 아니라 스모크 게이트를 통과하기 위한 제약.
예전에 "내가 쓰려고" 짠 플레이그라운드를, 이번엔 광고·게이트·애널리틱스·다국어가 둘러싼 시스템 안에서 다시 짰다. 같은 문제를 두 번 풀면서 매번 제약이 설계를 끌고 다닌다는 걸 또 한 번 확인했다. ffmpeg 편에서 비즈니스 모델이 기술 선택을 끌고 다녔던 것과 정확히 같은 모양이다.

