Typst WASM와 React로 프라이버시 중심 이력서 편집기 만들기
출처: Dev.to
문제
대부분의 온라인 이력서 작성 도구는 두 가지 진영으로 나뉩니다.
- SaaS 도구 – 이력서를 서버에 업로드해 PDF를 생성합니다. → 가장 민감한 개인 데이터가 내 컴퓨터를 떠납니다.
- LaTeX/Typst 템플릿 – 훌륭한 출력물을 만들지만 로컬 툴체인, 패키지 매니저, CLI 사용 능력이 필요합니다.
비기술 사용자에게는 두 번째 옵션이 접근하기 어렵고, 프라이버시를 중시하는 사용자에게는 첫 번째 옵션이 받아들일 수 없습니다. SmartResume는 두 문제를 동시에 해결합니다. 전문적인 조판 품질을 브라우저 안에서만 제공하죠.
시도해 보려면 resume.kakuti.site에 접속하세요. 소스 코드는 GitHub에 공개되어 있습니다.
┌─────────────────────────────────────────────────┐
│ Browser (SPA) │
│ │
│ ┌──────────┐ ┌───────────┐ ┌─────────────┐ │
│ │ React │──▶│ Editor │──▶│ IndexedDB │ │
│ │ Pages │ │ State │ │ (localforage)│ │
│ └──────────┘ └───────────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Web Worker │ │
│ │ ┌────────────┐ │ │
│ │ │ Typst WASM │ │ │
│ │ │ Compiler + │ │ │
│ │ │ Renderer │ │ │
│ │ └────────────┘ │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────┘
│
┌─────▼──────┐
│ Vercel │
│ /api/ │ ← Discord webhook
│ feedback │ (optional)
└────────────┘
이 애플리케이션은 단일 페이지 앱(React 18 + Vite 5 + TypeScript)이며, 선택적인 피드백을 받기 위한 서버리스 함수 하나만 존재하고 나머지는 모두 클라이언트에서 실행됩니다.
Typst는 LaTeX와 비슷하지만 문법이 더 깔끔하고 컴파일 속도가 빠른 최신 조판 언어입니다. 핵심 아이디어는 Typst의 컴파일러와 렌더러를 @myriaddreamin/typst.ts를 통해 WebAssembly로 변환한다는 점입니다. 파이프라인을 담당하는 두 개의 WASM 바이너리는 다음과 같습니다.
| 바이너리 | 목적 | 크기 |
|---|---|---|
typst_ts_web_compiler_bg.wasm | .typ 소스를 파싱해 문서 AST 생성 | 약 8 MB |
typst_ts_renderer_bg.wasm | AST를 PDF 바이트와 SVG 요소로 렌더링 | 약 5 MB |
두 바이너리 모두 Web Worker 안에서 실행돼 메인 스레드가 차단되는 일을 방지합니다. 이는 매우 중요한데, Typst 컴파일은 단일 페이지 이력서라도 200~400 ms가 소요될 수 있기 때문에 UI 스레드에서 실행하면 안 됩니다.
// frontend/src/features/template-renderer/hooks/useTypstCompiler.ts
const workerRef = useRef();
useEffect(() => {
const worker = new Worker(
new URL('../worker/typst.worker.ts', import.meta.url),
{ type: 'module' }
);
worker.postMessage({ type: 'init' });
workerRef.current = worker;
return () => worker.terminate();
}, []);
워커는 WASM 바이너리를 로드하고 CDN(Roboto, NotoSansCJK, Font Awesome)에서 폰트 파일을 가져오며, /public/templates/ 디렉터리의 Typst 템플릿 파일들을 미리 불러옵니다.
메인 스레드와 워커는 간단한 메시지 프로토콜을 통해 통신합니다.
Main Thread Web Worker
│ │
│──── set_source ────────────▶ │ (.typ 소스 업데이트)
│──── compile (id: 7) ───────▶ │ (컴파일 트리거)
│ │
│ ... 사용자가 입력하고 또다른 컴파일을 트리거 ... │
│──── compile (id: 8) ───────▶ │
│ │
│◀─── compile_done (id: 7) ─── │ ← 오래된 결과, 무시
│◀─── compile_done (id: 8) ─── │ ← 최신 결과, 렌더링
각 compile 메시지는 단조롭게 증가하는 ID를 포함합니다. 워커가 작업을 마치면 해당 ID를 다시 반환하고, 최신 요청과 ID가 일치하지 않으면 결과를 버립니다. 이는 AbortController 없이도 오래된 결과를 자동으로 거부하는 간단한 메커니즘입니다.
Typst 템플릿은 보통 #import 지시문을 사용해 다른 파일을 참조합니다. 하지만 WASM 샌드박스에서는 파일 시스템 접근이 불가능하므로, 워커는 이러한 import를 제거하고 대신 모의 구현을 삽입합니다.
// 컴파일 전에 각 템플릿 소스에 삽입되는 코드:
#let fa-icon(name, fill: black) = {
// 유니코드 문자 매핑 – 외부 폰트가 필요 없음
let icons = (
"github": "\u{f09b}",
"linkedin": "\u{f08c}",
"envelope": "\u{f0e0}",
// ...
)
text(fill: fill, raw(icons.at(name, default: "")))
}
#let linguify(key, default: none, ..args) = { default }
편집 경험은 Notion과 유사한 블록 기반 UI입니다. 각 블록은 제목, 리스트 아이템, 혹은 단락이며, 두 가지 편집 모드를 지원합니다.
- contenteditable
div를 이용한 서식 편집(굵게, 색상, 폰트 크기 등).contentEditable을 사용할 때 가장 큰 난관은 React 재렌더링 시 선택 영역이 사라지는 점인데, 이를 해결하기 위해 아래와 같은 로직을 구현했습니다.
// frontend/src/features/editor/utils/domUtils.ts
function saveSelection(container: HTMLElement): SelectionState | null {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
// 텍스트 노드와 오프셋을 기준으로 캐럿 위치를 찾기 위해 DOM을 순회
const nodeStack: Node[] = [];
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null
);
// ... 현재 노드와 오프셋까지의 경로를 만든다
return { nodePath, offset };
}
function restoreSelection(
container: HTMLElement,
state: SelectionState
): void {
// 저장된 경로를 따라가서 목표 노드에 도달한 뒤,
// 저장된 오프셋 위치에 캐럿을 설정한다
}
- 블록 옆 여백 영역을 클릭하거나 Enter 키를 누르면 나타나는 숨겨진 입력창. 여기서는 순수 마크다운을 입력하고 Enter 로 확정, Escape 로 취소합니다.
# Experience | 2020 - Present
**Senior Engineer** at Acme Corp
- Led a team of **5 engineers**
- Built [the platform]{#0075de}
이 포맷은 커스텀 확장자를 사용합니다. [text]{#color}는 색상 텍스트, [text]{size:14pt}는 폰트 크기 지정에 쓰이며, 렌더링 시 Tailwind‑style 인라인 스타일과 Typst 마크업으로 각각 매핑됩니다.
블록 앞에 #, ##, ###, 혹은 - 를 입력하면 자동으로 블록 타입이 전환됩니다. 별도의 툴바 클릭이 필요 없습니다.
에디터 상태(블록 트리)는 템플릿별 제너레이터를 통해 Typst 소스 코드로 변환됩니다.
// frontend/src/features/template-renderer/generators/westernResume.ts
export function generateWesternResume(state: EditorState): string {
const lines: string[] = [];
// 개인 정보 헤더
lines.push(`#set page(margin: (top: 1.5cm, bottom: 1.5cm))`);
lines.push(`#align(center)[`);
lines.push(` = ${escape(state.personalInfo.name)}`);
lines.push(` ${escape(state.personalInfo.email)} | ${escape(state.personalInfo.phone)}`);
lines.push(`]`);