- 작성일
DevBlog 1. 코드 Playground 만들기
- Authors

- Name
- seren-wib
Contents
상단 바 Playground에서 여섯 언어를 브라우저에서 바로 실행할 수 있게 만든 기록. 결과물은 Playground에서 볼 수 있음
1. 목표와 제약
- 상단 바에 Playground 메뉴. 마우스를 올리면 SQL, HTML, JavaScript, Python, C, C++ 목록이 펼쳐지고, 고르면 바로 연습 창으로 감
- 연습 창은 크게. 에디터와 결과를 나란히
- 제약: 이 블로그는 Vercel에 올린 정적 사이트. 코드를 받아서 돌려 줄 서버가 없고, 만들 생각도 없음
- 그래서 질문은 하나로 줄어듦. "이 언어는 어디서 실행할 수 있나"
2. 언어별로 실행할 곳 고르기
| 언어 | 실행 위치 | 이유 |
|---|---|---|
| SQL | 브라우저 (sql.js, SQLite WASM) | 이미 데이터베이스시스템 3장에 만들어 둔 것 재사용 |
| JavaScript | 브라우저 (Web Worker) | 브라우저가 원래 돌리는 언어 |
| HTML | 브라우저 (sandbox iframe) | 브라우저가 원래 그리는 언어 |
| Python | 브라우저 (Pyodide) | CPython을 WebAssembly로 옮긴 프로젝트. 약 10MB면 진짜 Python이 돎 |
| C, C++ | 외부 서버 (Compiler Explorer) | 컴파일러를 브라우저로 옮기면 수십 MB. 공개 API로 컴파일과 실행을 맡김 |
- C/C++는 다른 공개 실행 API도 확인했음. Piston 공개 API는 이제 키 없이 부르면
401이 옴 - Compiler Explorer(godbolt.org)는 API 하나로 컴파일과 실행이 되고, 다른 사이트에서 불러도 막지 않음(
Access-Control-Allow-Origin: *) - 대가: C/C++ 코드는 godbolt로 전송되고, 남의 공개 서버라 호출 제한이 있음. 연습용 블로그라 받아들임
3. 페이지 구조
- 언어 목록은
data/playground.ts한 곳에만 있음. 상단 메뉴, 카드 페이지, 연습 창 주소, sitemap이 전부 이 배열을 읽음
data/playground.ts
export const playgroundLanguages: PlaygroundLanguage[] = [
{ id: 'sql', name: 'SQL', runner: 'browser', engine: 'SQLite (sql.js)', ... },
{ id: 'html', name: 'HTML', runner: 'browser', engine: 'Sandboxed iframe', ... },
// javascript, python, c, cpp
]
/[locale]/playground/: 언어별 카드. 코드 몇 줄, 어디서 실행되는지(In browser / Compiler Explorer)/[locale]/playground/[lang]/: 연습 창.generateStaticParams로 언어 6개 × 한/영 2개 = 12페이지를 빌드 때 미리 만듦- 연습 창은 넓은 화면에서 에디터 3 : 결과 2로 나란히, 좁은 화면에서는 위아래로 쌓음. 에디터 높이는 화면의 72%
4. 상단 바 드롭다운
- JavaScript 없이 CSS만으로 만듦. 부모에
group, 목록에group-hover:visible,group-focus-within:visible focus-within덕분에 키보드 Tab으로 들어가도 열림- 링크와 목록 사이에
pt-2여백을 목록 쪽에 둠. 마우스가 링크에서 목록으로 내려가는 동안 hover가 끊기지 않게
components/PlaygroundMenu.tsx
<div className="group relative hidden sm:block">
<Link href={localePath(locale, '/playground')}>Playground</Link>
<div className="invisible absolute top-full pt-2 opacity-0 group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100">
<ul>{/* SQL, HTML, ... */}</ul>
</div>
</div>
- 함정 하나: 기존 메뉴 링크들은 좁은 화면에서 가로 스크롤되도록
overflow-x-auto상자 안에 있음. 거기에 넣으면 펼친 목록이 상자 밖으로 못 나가고 잘림. 그래서 Playground는 그 상자 밖, About과 언어 토글 사이에 따로 둠
5. 에디터: CodeMirror 6
- 문법 색, 줄 번호, 괄호 짝, Tab 들여쓰기가 다 필요해서 textarea 대신 CodeMirror 6
- 사이트 테마 따라가기: 하이라이트 스타일을
Compartment에 넣고, 다크 모드가 바뀌면 그 칸만 갈아 끼움. 에디터를 새로 만들지 않아서 커서와 내용이 그대로 - Ctrl/⌘+Enter 실행:
Prec.highest로 단축키를 기본 키맵보다 먼저 잡음 - 컴파일 에러 표시: godbolt 응답의 에러에는 줄·칸 번호가 붙어 옴. 이걸
@codemirror/lint의setDiagnostics로 넘기면 에디터 해당 줄에 빨간 밑줄이 생김 - 쓴 코드와 stdin은
localStorage에 저장. 새로고침해도 남아 있음
6. JavaScript: Web Worker
- 페이지에서 바로
eval하면while (true) {}한 줄에 탭 전체가 멈춤. 그래서 실행할 때마다 새 Worker를 만들고, 멈추면worker.terminate()로 통째로 버림 console.log를 덮어써서 출력을postMessage로 페이지에 보냄- 맨 바깥에서
await를 쓸 수 있게 코드를AsyncFunction으로 감쌈
public/static/playground/js-worker.js
console.log = function () { send('out', arguments) }
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
self.onmessage = (e) => {
new AsyncFunction(e.data.code)().then(
() => self.postMessage({ type: 'done' }),
(err) => { self.postMessage({ type: 'err', text: errorText(err) }); self.postMessage({ type: 'done' }) }
)
}
- 15초 안에 안 끝나면 Worker를 죽이고 "시간 제한" 줄을 찍음
- 본문이 끝나도 Worker를 바로 죽이지 않음.
setTimeout으로 나중에 찍히는 출력까지 받으려고. 다음 실행이나 Stop, 시간 제한 때 정리함
7. HTML: sandbox iframe
- 쓴 HTML을
<iframe srcdoc>에 넣고sandbox="allow-scripts allow-modals allow-forms allow-popups" allow-same-origin은 일부러 뺌. 그러면 iframe은 이름 없는 출처가 돼서, 안의 스크립트가 블로그 페이지나 저장소를 건드릴 수 없음- 콘솔:
<head>바로 뒤에 작은 스크립트를 끼워 넣어서console.*과 에러를parent.postMessage로 보냄. 페이지는 그 iframe에서 온 메시지만 받음 - 입력이 멈추고 0.6초 뒤 미리보기를 새로 그림
미리보기가 하얗게 비던 버그
- 처음엔
srcdoc값만 바꿨는데, 첫 화면이 가끔 빈 채로 남았음. 같은 HTML을 새 iframe에 넣으면 멀쩡히 그려짐 - 살아 있는 iframe의
srcdoc만 바꾸는 방식이 믿을 만하지 않다고 보고, 갱신할 때마다 Reactkey를 바꿔서 iframe을 새로 만들게 함. 그 뒤로 안 생김
8. Python: Pyodide
- npm
pyodide패키지(314.0.7, Python 3.14)를 씀. 런타임 파일 5개(약 12MB)를public/static/pyodide/로 복사해서 이 사이트에서 직접 내려줌 - 12MB를 git에 넣기 싫어서 복사본은
.gitignore에 넣고,predev·prebuild스크립트가 매번node_modules에서 복사함. Vercel 빌드도npm run build라서 같이 돌아감
package.json
"predev": "node ./scripts/copy-pyodide.mjs",
"prebuild": "node ./scripts/copy-pyodide.mjs",
- Pyodide는 Worker 안에서 돌림. 무한 루프를 죽일 수 있어야 해서
- 로딩에 몇 초 걸리니까 Worker를 실행 사이에 재사용함. 대신 실행마다 새 전역 dict를 만들어서 이전 실행의 변수가 안 남음. Stop이나 시간 제한으로 죽이면 다음 실행 때 새로 띄움
- Python 페이지를 열면 바로 로딩을 시작해서, 코드 읽는 동안 받아 둠
input(): 실행 전에 stdin 칸 내용을 줄 단위로 넘겨 둠 (setStdin)- numpy 같은 추가 패키지:
loadPackagesFromImports가 import를 보고 필요한 것만 jsDelivr에서 받음 (packageBaseUrl)
9. C, C++: Compiler Explorer API
components/playground/runners.ts
fetch(`https://godbolt.org/api/compiler/${compiler.id}/compile`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
source: code,
options: {
userArguments: '-O2 -Wall -std=c++20 -fdiagnostics-color=never',
executeParameters: { stdin },
compilerOptions: { executorRequest: true },
filters: { execute: true },
},
}),
})
- 컴파일러 id는
/api/compilers/c로 목록을 보고 골랐음. C는cg152(GCC 15.2), C++은g152 -fdiagnostics-color=never: 안 넣으면 에러 메시지에 터미널 색 코드가 섞여 옴- 응답에서 쓰는 것:
buildResult(컴파일 에러·경고와 줄 번호),stdout·stderr,code(종료 코드),execTime,timedOut - 무한 루프는 godbolt가 약 20초 뒤에 죽이고
timedOut: true를 줌 - 입력은 실행 전에 stdin 칸에 미리 적는 방식. 터미널처럼 중간에 주고받지는 못함
10. CSP는 안 바꿨음
- 이 블로그는 CSP로 스크립트 출처를
'self'위주로 막아 둠. 새 호스트를 열지 않고 끝냄 - Worker와 Pyodide 파일: 이 사이트(
'self')에서 내려줌 - WebAssembly 컴파일: 기존
'unsafe-eval'로 허용됨 - godbolt와 jsDelivr 패키지:
fetch라서connect-src에 해당하고, 이미 열려 있음 - iframe
srcdoc: 부모의 CSP를 물려받음. 인라인 스크립트는 되고 외부 스크립트는 막힘. 미리보기 안내에 "외부 스크립트는 불러오지 않습니다"라고 적어 둔 이유
11. 출력이 폭주할 때
while True: print(1)은 1초에 수만 줄을 보냄. 줄마다 React 상태를 바꾸면 페이지가 굳음- 들어온 줄은 버퍼에 쌓았다가
requestAnimationFrame에서 한 번에 반영. 2000줄이 넘으면 나머지는 버리고 한 줄로 알림
12. 정리
- 서버 없는 블로그라도 실행할 곳만 언어별로 나누면 됨. 브라우저가 못 하는 C/C++만 밖으로 보냄
- 멈출 수 있어야 하는 코드는 전부 Worker 안에서 돌림
- 다음 글은 이 실행기 위에 얹은 정렬 시각화 이야기