logo
Nostrss
Published on

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

Authors

Pegman brand retirement

pegboard.me

페그보드 시리즈가 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": "모든 내용은 이 브라우저(내 컴퓨터)에 저장되어, 다시 접속해도 그대로 유지됩니다 — 계정도 필요 없어요",

규칙은 세 가지다. 말로 하면 간단하다.

  1. 첫 방문(localStorage가 비어 있을 때)에만 샘플을 보여준다.
  2. 저장된 데이터는 절대 덮어쓰지 않는다.
  3. 사용자가 한 번 지우면 다시 부활시키지 않는다.

말은 쉽다. 그런데 서버가 없는 정적 사이트에서 이걸 구현하면, 세 개의 서로 다른 실패가 기다리고 있다. 그리고 각 실패를 막는 메커니즘이 따로 있다.

실패 ① 하이드레이션 불일치 — 결정론적 시드 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 스텝이 쌓이면 못 쓴다. 그래서 pushHistoryeditKey 를 받아서, 연속된 같은 키의 편집을 하나의 스텝으로 합친다(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-only H1 에만 앵커한다.
  • hero·learn 을 빼서 비는 온보딩 자리는 in-place 시딩 으로 채웠다 — 설명 텍스트 대신 진짜 편집 가능한 샘플 노드 를 첫 방문자에게만 심는다.
  • 서버 없는 정적 사이트에서 이걸 안전하게 하려면 세 개의 다른 실패 를 각각 막아야 한다:
    • 결정론적 시드 ID(SEED_ROOT_ID) → 하이드레이션 불일치 방지
    • 복원을 lazy init이 아니라 effect 에서 → prerender에 없는 localStorage를 렌더 중 읽는 문제 방지 (대가: 재방문자가 한 프레임 시드를 봄)
    • skipNextSaveRef 로 마운트 첫 저장 스킵 → 시드가 저장본을 덮는 문제 방지
  • 지우기는 시드가 아니라 빈 줄 로 떨어뜨려, 사용자가 한 번 지우면 시드를 부활시키지 않는다.
  • 코어(outliner.ts)는 정적 export 제약에 맞춰 의존성 없는 순수 TS 이고, 불변 + no-op-같은-참조 규칙으로 리렌더를 절약한다. editKey 코얼레싱 undo, IME 합성 가드, ref 기반 포커스 같은 디테일이 "키보드로 빠른 아웃라이너" 의 손맛을 만든다.

"빈 타공판에 페그를 하나씩 건다" 는 게 페그보드의 컨셉인데, PegNote 는 그 안에서 또 한 번 빈 화면을 채우는 페그였다. 첫 방문자의 빈 화면에 샘플 세 줄을 심는 그 작은 친절이, 서버가 없다는 이유로 세 개의 하이드레이션 함정을 데리고 왔다. 그래도 다 막고 나니 — 계정도, 서버도 없이 다시 와도 그대로인 노트 한 장이 남았다.