- Published on
빈 화면에 샘플을 심기 — PegNote 의 in-place 시딩과 정적 사이트 하이드레이션
- Authors

- Name
- Nostrss
- Github
- Github
페그보드 시리즈가 10편에서 한 번 매듭을 지었지만, 타공판은 계속 채워진다. 이번엔 시리즈 회고가 아니라 한 페그를 어떻게 구현했나를 코드 단위로 푸는 글이다. 대상은 PegNote — Workflowy 스타일의 미니멀 아웃라이너다. 불릿을 중첩하고, Tab으로 들여쓰고, 가지를 접고, 원하는 불릿으로 줌인하는 노트 앱.
이 글은 어떻게 쓰는지 가 아니라 어떻게 만들었는지 다. 특히 한 가지 기능 — 첫 방문자에게만 편집 가능한 샘플 노트를 미리 채워두는 온보딩 — 을 정적 사이트에서 안전하게 굴리는 일이 생각보다 까다로웠다. 서버가 없으니 함정이 전부 브라우저의 하이드레이션 경계에 숨어 있었다.
PegNote 는 왜 다른 페그들과 다른가
페그보드의 보통 페그(JSON 포매터, 인코더, 컨버터…)는 검색으로 한 번 들어와서 변환하고 나가는 도구다. 그래서 페이지마다 SEO·광고용 본문 블록(UtilityHero, learn 섹션)을 강제로 싣는다. 검색 트래픽 일회성 유입을 광고 노출로 바꾸는 게 수익 모델이니까.
PegNote 는 그 골격의 예외 다. ADR-0012 에서 apps 카테고리(Peg Apps)를 새로 긋고, 앱형 페그를 표준 유틸 골격에서 빼냈는데 — PegNote 가 그 첫 사례 다(이후 PegKanban·PegDiff 등이 같은 라인에 붙는다).
앱형 페그는 표준 유틸 페이지 골격(hero·learn)의 예외다. 변환기류 유틸이 공유하는
UtilityHero+learn본문 SEO/광고 블록을 강제하지 않는다. 앱형 페그는 검색 일회성 유입이 아니라 반복 방문·리텐션으로 수익화하므로, UI는 도구 본연의 사용성(예: PegNote의 Workflowy식 미니멀)을 최우선한다.— ADR-0012, Decision 4
수익 모델이 다르면 IA도 달라야 한다. 노트 앱은 다시 오는 사람으로 먹고산다. 그래서 페이지를 열면 hero 카피도, 설명 블록도 없다. 화면에 보이는 건 도구 그 자체뿐이다. 장르 키워드(outliner, nested bullet notes)는 화면에 안 보이는 곳에만 앵커한다.
// app/[locale]/peg-note/page.tsx
{/* Peg Apps exception (ADR-0012): no hero/learn body blocks. The page's
sole SEO heading lives here, visually hidden — the tool is the view. */}
<h1 className="sr-only">{t('srHeading')}</h1>
<section className="bg-canvas px-5 pt-8 pb-16 sm:px-8 sm:pt-10 sm:pb-20">
<div className="content-frame mx-auto">
<PegNoteTool />
</div>
</section>
metaTitle("PegNote — 중첩 불릿 아웃라이너 메모") 과 metaDescription, 그리고 sr-only H1 — 검색 앵커는 이 셋이 전부다. 화면은 도구에 양보한다.
문제는, hero·learn 본문을 안 싣기로 하면 기존 유틸이 설명 블록에서 하던 온보딩 도 같이 사라진다는 것이다. "이 도구 이렇게 쓰세요" 를 적을 자리가 없다. 그래서 그 자리를 도구 안에서 직접 채우는 방식으로 대체했다.
정적 사이트라서 — 모든 상태가 브라우저에 산다
페그보드는 1편에서 적었듯 정적 export(SSG) 사이트다. 빌드 타임에 HTML/JS/CSS 를 다 구워서 Cloudflare Pages 에 올리고, 런타임에는 서버가 없다. 데이터베이스도, API도, 세션도 없다.
그러니 PegNote 의 노트는 어디에 저장될까? 답은 하나뿐 — 브라우저의 localStorage. 계정도, 동기화도 없다. 키 하나에 아웃라인 전체를 JSON으로 직렬화해 넣는다.
const STORAGE_KEY = 'pegboard:peg-note';
그리고 변환·편집 로직 전부가 사용자 브라우저에서 돌아야 한다. 이건 페그보드에서 한 번 크게 데인 제약이다 — Node 전용 모듈을 모듈 스코프에서 import 하는 의존성은 빌드도 통과하고 Node 환경 단위 테스트도 통과하지만 브라우저에서 죽는다. 그래서 아웃라이너 코어(src/lib/outliner.ts)는 의존성 없는 순수 TS로 짰다.
/**
* Outliner core — pure, dependency-free tree-editing logic.
*
* Every function here runs in the browser of the static-exported site, so this
* module must stay free of Node-only imports (AGENTS.md). All operations are
* immutable: they return a new doc, or the SAME doc reference when the
* operation is a no-op so callers can skip history pushes and re-renders.
*/
문서는 불변(immutable) 트리다. 모든 편집 함수는 새 OutlineDoc 을 반환하거나, 변화가 없으면 들어온 것과 똑같은 참조 를 그대로 돌려준다. 이 "no-op이면 같은 참조" 규칙이 작은 디테일 같지만 호출부를 깔끔하게 만든다 — apply() 가 if (next === doc) return; 한 줄로 불필요한 히스토리 push와 리렌더를 걸러낸다.
function apply(next: OutlineDoc, editKey: string | null = null, focus?: ...) {
if (next === doc) return; // 참조가 같으면 아무 일도 일어나지 않은 것
setHistory((current) => pushHistory(current, next, editKey));
if (focus) pendingFocusRef.current = focus;
}
여기까지가 무대다. 이제 본론 — 첫 방문 온보딩.
in-place 시딩 — "설명" 대신 "진짜 노드"를 심는다
hero·learn 블록을 없앤 자리를 무엇으로 채울까. 선택은 in-place 시딩 이었다. 설명 텍스트를 보여주는 대신, 실제로 편집 가능한 샘플 노드 를 도구 안에 미리 심어 둔다. 첫 방문자는 빈 화면이 아니라 이미 몇 줄 적혀 있는 아웃라인을 본다 — 그리고 그 줄들을 바로 고쳐 쓰면서 도구를 익힌다.
샘플 자체가 사용법을 가르친다.
// messages/ko.json
"seedRoot": "여기에 첫 노트를 작성하세요",
"seedIndent": "들여쓰기는 Tab, 내어쓰기는 Shift+Tab",
"seedStorage": "모든 내용은 이 브라우저(내 컴퓨터)에 저장되어, 다시 접속해도 그대로 유지됩니다 — 계정도 필요 없어요",
규칙은 세 가지다. 말로 하면 간단하다.
- 첫 방문(localStorage가 비어 있을 때)에만 샘플을 보여준다.
- 저장된 데이터는 절대 덮어쓰지 않는다.
- 사용자가 한 번 지우면 다시 부활시키지 않는다.
말은 쉽다. 그런데 서버가 없는 정적 사이트에서 이걸 구현하면, 세 개의 서로 다른 실패가 기다리고 있다. 그리고 각 실패를 막는 메커니즘이 따로 있다.
실패 ① 하이드레이션 불일치 — 결정론적 시드 ID로 막는다
정적 사이트는 빌드 타임에 HTML을 미리 굽는다(prerender). 그리고 브라우저에서 React가 그 HTML 위에 다시 렌더(hydration)를 얹는다. 이 둘이 바이트 단위로 같아야 한다. 다르면 React가 하이드레이션 불일치를 토한다.
샘플 노드에 crypto.randomUUID() 로 ID를 박으면? prerender 때 만든 UUID와 브라우저 첫 렌더 때 만든 UUID가 다르다. 즉시 desync. 그래서 시드의 ID는 결정론적 상수 여야 한다.
// src/lib/outliner.ts
// Deterministic ids so the static prerender and the first client render produce
// identical HTML (a random UUID here would desync hydration ...).
export const SEED_ROOT_ID = 'outline-seed-root';
export function createSeedDoc(rootText: string, childTexts: readonly string[]): OutlineDoc {
return {
version: 1,
nodes: [{
id: SEED_ROOT_ID,
text: rootText,
collapsed: false,
children: childTexts.map((text, index) => ({
id: `outline-seed-${index}`, // outline-seed-0, outline-seed-1 ...
text,
collapsed: false,
children: [],
})),
}],
};
}
시드는 outline-seed-root, outline-seed-0, outline-seed-1 처럼 고정 ID를 쓴다. prerender와 첫 하이드레이션이 같은 ID를 보니 HTML이 일치한다. 사용자가 새 노드를 추가할 때부터는 createNodeId()(랜덤 UUID)를 쓴다 — 그때는 이미 클라이언트 단독이라 desync 걱정이 없다.
같은 제약을 페그보드의 다른 곳 — 드래그앤드롭에 쓴
@dnd-kit— 에서도 겪었다. 전역 카운터로 ID를 매기는 라이브러리가 정적 prerender와 하이드레이션 사이에서aria-describedby를 어긋나게 만들어,DndContext에 명시적id를 박아 고정해야 했다. 정적 사이트에서 "서버와 클라이언트가 같은 ID를 본다" 는 협상 불가능한 규칙이다.
실패 ② prerender엔 localStorage가 없다 — effect 복원으로 막는다
이제 돌아온 사용자 차례다. 저장된 아웃라인을 어떻게 불러올까. 가장 자연스러운 자리는 useState 의 lazy initializer 다.
// ❌ 이렇게 하면 안 된다
const [history, setHistory] = useState(() => {
const raw = localStorage.getItem(STORAGE_KEY); // prerender엔 localStorage가 없다
return createHistory(raw ? deserializeDoc(raw)! : createEmptyDoc());
});
이건 두 번 잘못된다. 첫째, prerender(Node 환경)엔 localStorage 자체가 없다. 둘째, 설령 가드를 둬도 렌더 도중 외부 저장소를 읽으면 서버 렌더(저장소 없음)와 클라이언트 첫 렌더(저장소 있음)가 달라져 또 하이드레이션이 깨진다.
그래서 복원은 렌더가 아니라 effect 에서 한다. 초기 상태는 언제나 시드 — prerender도, 첫 하이드레이션도 동일하게 시드를 그린다. 그 다음, 페인트가 끝난 뒤 effect가 localStorage를 읽어 저장본으로 갈아끼운다.
// 초기 상태는 항상 시드 (prerender == 첫 하이드레이션)
const [history, setHistory] = useState<OutlineHistory>(() =>
createHistory(createSeedDoc(t('seedRoot'), [t('seedIndent'), t('seedStorage')])),
);
// 복원은 effect에서 — 렌더 중에 외부 저장소를 읽으면 하이드레이션이 깨진다
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const saved = deserializeDoc(raw);
if (!saved) return;
setHistory(createHistory(saved));
} catch {
// 손상됐거나 접근 불가한 저장소는 무시
}
}, []);
여기에 정직하게 적어둘 트레이드오프가 하나 있다. 복원이 effect(페인트 이후)에서 일어나니까, 돌아온 사용자는 자기 데이터가 뜨기 직전 한 프레임 동안 시드를 본다. 시드 → (effect) → 저장본으로 스왑. 깜빡임이 인지될 만큼은 아니지만 0은 아니다. 정적 사이트에서 "렌더 중엔 저장소를 안 읽는다" 는 규칙을 지키는 대가다. 이건 페그보드 다른 유틸(json-formatter 등)이 쓰는 것과 같은 패턴이라, 일관성 쪽에 점수를 줬다.
deserializeDoc 은 절대 throw 하지 않고 손상된 데이터엔 null 을 돌려준다. localStorage는 사용자가 직접 만질 수 있는 신뢰 불가 입력이라, 한 글자만 깨져도 앱이 죽지 않도록 엄격하게 파싱한다.
실패 ③ 시드가 저장본을 덮어쓴다 — 첫 저장 스킵으로 막는다
이게 가장 미묘하다. PegNote 는 doc 이 바뀔 때마다 localStorage에 저장하는 effect가 있다.
useEffect(() => {
localStorage.setItem(STORAGE_KEY, serializeDoc(doc));
}, [doc]);
그런데 이 저장 effect는 마운트 시점에도 한 번 돈다. 그 순간 doc 은 아직 시드다(복원 effect는 아직 안 끝났거나 막 끝나는 중). 순서가 꼬이면 — 시드가 돌아온 사용자의 저장본을 덮어쓴다. 첫 방문자에게 친절하려던 기능이 재방문자의 데이터를 날리는 최악의 버그가 된다.
막는 방법은 마운트 직후의 첫 저장만 건너뛰는 것 이다.
// 마운트 커밋(아직 doc은 시드)의 저장을 한 번 스킵 —
// 시드가 저장본 위에 덮이는 걸 막는다.
const skipNextSaveRef = useRef(true);
useEffect(() => {
if (skipNextSaveRef.current) {
skipNextSaveRef.current = false;
return; // 첫 커밋은 저장하지 않는다
}
try {
localStorage.setItem(STORAGE_KEY, serializeDoc(doc));
} catch {
// 용량 초과·프라이버시 모드 실패는 무시
}
}, [doc]);
첫 커밋(시드 상태)의 저장은 버린다. 그 다음 doc 이 바뀌는 모든 순간 — 복원 effect의 스왑이든, 사용자의 실제 편집이든 — 은 정상적으로 저장된다. 복원이 일어나면 그 스왑이 doc 변경으로 잡혀 저장본이 저장본으로 다시 저장 되고, 복원이 안 일어나면(=진짜 첫 방문) 사용자가 첫 글자를 칠 때 시드를 고친 결과가 저장된다. 어느 쪽이든 시드 자체는 절대 디스크에 박히지 않는다.
"지우면 부활 안 함" — 빈 줄로 떨어뜨린다
규칙 ③(한 번 지우면 다시 안 보임)은 위 메커니즘에서 자연히 따라온다. "전체 지우기" 는 시드로 되돌리는 게 아니라 빈 한 줄 로 떨어뜨린다.
function handleClear() {
// ... 한 번 더 누르면 확정하는 확인 단계 생략
const fresh = createEmptyDoc(); // 시드가 아니라 빈 노드 하나
apply(fresh, null, { id: fresh.nodes[0].id, caret: 0 });
}
createEmptyDoc() 은 빈 노드 하나짜리 문서를 만든다. 이게 doc 변경으로 저장되면, 다음 방문 때 복원 effect가 빈 문서 를 읽어온다 — 시드가 아니다. 시드는 오직 "localStorage가 통째로 비어 있을 때"만 등장하므로, 한 번이라도 저장이 일어난 브라우저에는 다시 나타나지 않는다. 사용자가 "이 안내 그만 보고 싶다" 고 한 의사를 존중하는 것이다.
세 메커니즘을 한 줄로 정리하면 이렇다.
| 막는 실패 | 메커니즘 | 핵심 |
|---|---|---|
| 하이드레이션 불일치 | 결정론적 시드 ID (SEED_ROOT_ID) | prerender == 첫 하이드레이션 |
| prerender에 저장소 없음 | 복원을 lazy init이 아니라 effect에서 | 렌더 중엔 외부 저장소를 안 읽는다 |
| 시드가 저장본을 덮음 | skipNextSaveRef 로 첫 저장 스킵 | 시드는 절대 디스크에 박히지 않는다 |
세 개를 따로 보는 게 중요하다. ①과 ②는 둘 다 "하이드레이션" 이라 뭉뚱그리기 쉬운데, ①은 서버/클라 출력 일치, ②는 렌더 중 외부 읽기 금지 로 막는 실패가 다르다.
나머지 구현 디테일 몇 가지
세 메커니즘이 글의 척추지만, 코어를 짜면서 즐거웠던 작은 결정들도 적어둔다.
editKey로 undo 스텝을 합친다. 스냅샷 기반 undo/redo인데, 한 글자 칠 때마다 undo 스텝이 쌓이면 못 쓴다. 그래서 pushHistory 가 editKey 를 받아서, 연속된 같은 키의 편집을 하나의 스텝으로 합친다(coalesce). 한 노드에 타이핑하는 동안은 text:${id} 라는 같은 키를 쓰니 undo 한 번에 그 노드 편집이 통째로 되돌아간다. 문서는 불변이고 구조 공유되니 전체 스냅샷을 100개까지 들고 있어도 메모리는 싸다.
export function pushHistory(history, nextDoc, editKey = null) {
if (nextDoc === history.present) return history;
if (editKey !== null && editKey === history.lastEditKey) {
// 같은 editKey의 연속 편집은 한 스텝으로 합친다
return { ...history, present: nextDoc, future: [], lastEditKey: editKey };
}
const past = [...history.past, history.present].slice(-HISTORY_LIMIT);
return { past, present: nextDoc, future: [], lastEditKey: editKey };
}
IME 합성 중엔 split/merge를 안 한다. 한국어·일본어·중국어 입력은 조합 중(composition)에 Enter나 Backspace가 의미가 다르다. 조합 중인 Enter는 "한글 확정" 이지 "새 줄" 이 아니다. 그래서 키 핸들러는 합성 중이면 구조 편집을 건너뛴다.
// 합성(한/일/중 IME) 중엔 절대 split/merge 하지 않는다
if (event.nativeEvent.isComposing) return;
이건 한국어가 모국어인 사람이 만드는 도구라 처음부터 깔고 들어간 가드다. 영어만 테스트했으면 한참 뒤에 버그로 올라왔을 종류의 것이다.
구조 편집 후 포커스는 ref로 미뤄 적용한다. Enter로 노드를 쪼개면 새로 생긴 노드의 textarea 에 커서가 가 있어야 한다. 그런데 그 DOM 요소는 다음 렌더에야 존재한다. 그래서 편집 함수는 "어디에 포커스할지" 를 pendingFocusRef 에 적어두고, 렌더 후 effect가 그 요청을 집행한다.
// 마지막 구조 편집이 남긴 포커스 요청을 렌더 후 집행
useEffect(() => {
const pending = pendingFocusRef.current;
if (!pending) return;
pendingFocusRef.current = null;
focusRow(pending.id, pending.caret);
});
split/indent/merge가 전부 { id, caret } 를 같이 돌려주고, 한 곳에서 일관되게 커서를 옮긴다. 키보드만으로 아웃라인을 빠르게 편집하는 느낌은 이 디테일에서 나온다.
정리
- PegNote 는 페그보드의 첫 앱형 페그 다. 검색 일회성이 아니라 반복 방문으로 수익화하므로, hero·learn 같은 SEO/광고 본문 블록을 빼고(ADR-0012) 화면을 도구에 양보했다. 장르 키워드는
metaTitle·metaDescription·sr-onlyH1 에만 앵커한다. - hero·learn 을 빼서 비는 온보딩 자리는 in-place 시딩 으로 채웠다 — 설명 텍스트 대신 진짜 편집 가능한 샘플 노드 를 첫 방문자에게만 심는다.
- 서버 없는 정적 사이트에서 이걸 안전하게 하려면 세 개의 다른 실패 를 각각 막아야 한다:
- 결정론적 시드 ID(
SEED_ROOT_ID) → 하이드레이션 불일치 방지 - 복원을 lazy init이 아니라 effect 에서 → prerender에 없는 localStorage를 렌더 중 읽는 문제 방지 (대가: 재방문자가 한 프레임 시드를 봄)
skipNextSaveRef로 마운트 첫 저장 스킵 → 시드가 저장본을 덮는 문제 방지
- 결정론적 시드 ID(
- 지우기는 시드가 아니라 빈 줄 로 떨어뜨려, 사용자가 한 번 지우면 시드를 부활시키지 않는다.
- 코어(
outliner.ts)는 정적 export 제약에 맞춰 의존성 없는 순수 TS 이고, 불변 + no-op-같은-참조 규칙으로 리렌더를 절약한다. editKey 코얼레싱 undo, IME 합성 가드, ref 기반 포커스 같은 디테일이 "키보드로 빠른 아웃라이너" 의 손맛을 만든다.
"빈 타공판에 페그를 하나씩 건다" 는 게 페그보드의 컨셉인데, PegNote 는 그 안에서 또 한 번 빈 화면을 채우는 페그였다. 첫 방문자의 빈 화면에 샘플 세 줄을 심는 그 작은 친절이, 서버가 없다는 이유로 세 개의 하이드레이션 함정을 데리고 왔다. 그래도 다 막고 나니 — 계정도, 서버도 없이 다시 와도 그대로인 노트 한 장이 남았다.

