logo
작성일

DevBlog 1. 코드 Playground 만들기

Authors
  • avatar
    Name
    seren-wib
    Twitter
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만 바꾸는 방식이 믿을 만하지 않다고 보고, 갱신할 때마다 React key를 바꿔서 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 안에서 돌림
  • 다음 글은 이 실행기 위에 얹은 정렬 시각화 이야기