- Published on
PegKanban 구현기 — dnd-kit 하이드레이션 함정과 localStorage 한 줄로 AI에 보드를 연결하기
- Authors

- Name
- Nostrss
- Github
- Github
페그보드 시리즈가 10편에서 한 번 매듭을 지었지만, 시스템은 계속 자란다. 이번엔 시리즈 회고가 아니라 한 페그를 어떻게 구현했나를 코드 단위로 푸는 글이다. 대상은 PegKanban — Trello류 드래그앤드롭 칸반 보드다.
PegKanban은 apps 카테고리의 앱형 페그다. ADR-0012에서 박은 Peg Apps 예외 — 변환기류 유틸이 지는 UtilityHero·learn 본문 SEO 블록을 강제하지 않고, 도구 본연의 사용성을 최우선하는 라인 — 의 일원이다. 그래서 페이지(page.tsx)는 거의 비어 있다. 보이는 H1조차 sr-only로 숨기고, 보드 자체가 곧 화면이다.
// app/[locale]/peg-kanban/page.tsx
return (
<PageShell utilitySlug="peg-kanban">
{/* Peg Apps exception (ADR-0012): no hero/learn body blocks. The page's
sole SEO heading lives here, visually hidden — the board is the view. */}
<h1 className="sr-only">{t('srHeading')}</h1>
<PegKanbanTool />
</PageShell>
);
이 글에서 다룰 건 사용 설명이 아니다. 정적 export라는 제약 위에서 드래그앤드롭과 영속화·AI 연동을 붙이면서 마주친, 코드를 읽지 않으면 안 보이는 두 가지 디테일이다.
- dnd-kit 하이드레이션 함정:
DndContext에id를 안 박으면next dev에서만 경고가 뜨고 정적 빌드에선 잠복한다. - localStorage 데이터 계약: 키 하나(
pegboard:peg-kanban)를 두 곳에서 정의해 묶어두면, 별도 라우트인 오프라인 AI 채팅이 자연어로 같은 보드를 조작할 수 있다.
보드 모델 — 순수·불변, no-op은 같은 참조
먼저 토대. 보드 로직은 전부 src/lib/kanban.ts에 모여 있고, 의존성 0의 순수 TS다. 이건 취향이 아니라 제약이다 — 정적 export 사이트라 모든 변환 로직은 브라우저에서 돈다. Node 전용 모듈을 모듈 스코프에서 import하면 빌드는 통과해도 브라우저에서 빈 화면으로 죽는다(페그보드가 quicktype-core로 한 번 데인 그 패턴). 그래서 보드 코어는 의존성 없는 순수 함수로 못 박았다.
// src/lib/kanban.ts
export interface Card {
readonly id: string;
readonly title: string;
readonly description: string;
readonly labels: readonly LabelColor[];
}
export interface List {
readonly id: string;
readonly title: string;
readonly cards: readonly Card[];
}
export interface Board {
readonly version: 1;
readonly title: string;
readonly lists: readonly List[];
}
모든 편집 연산(addCard, moveCard, renameList, …)은 불변이다. 새 보드를 반환하거나, 연산이 no-op이면 같은 보드 참조를 그대로 반환한다. 모듈 주석에 이 계약이 박혀 있다.
All operations are immutable: they return a new board, or the SAME board reference when the operation is a no-op so callers can skip re-renders and persistence writes.
같은 참조를 돌려주는 게 왜 중요하냐면 — 뒤에 나올 보드가 바뀔 때마다 localStorage에 저장하는 effect가 [board] 의존성으로 걸려 있기 때문이다. no-op이 새 객체를 만들면 의미 없는 저장이 매번 돈다. 같은 참조면 React가 effect를 건너뛴다.
version: 1이 리터럴 타입인 것도 의도다. deserializeBoard는 저장본의 version이 1이 아니면 그냥 null을 돌려 거부한다 — 미래에 모델이 바뀌면 여기에 마이그레이션 분기를 걸 자리를 미리 비워둔 셈이다.
드래그앤드롭 — 중첩 SortableContext + dropzone
드래그앤드롭은 @dnd-kit으로 짰다. 칸반은 두 축의 정렬이 동시에 필요하다 — 컬럼끼리 가로로, 카드끼리 세로로. 그래서 SortableContext를 중첩한다.
// 바깥: 컬럼 가로 정렬
<SortableContext items={board.lists.map((l) => l.id)}
strategy={horizontalListSortingStrategy}>
{board.lists.map((list) => (
<ListColumn ... />
))}
</SortableContext>
// 각 컬럼 안: 카드 세로 정렬
<SortableContext items={list.cards.map((c) => c.id)}
strategy={verticalListSortingStrategy}>
{list.cards.map((card) => <SortableCard ... />)}
</SortableContext>
여기서 비-자명한 부분 하나 — 빈 컬럼에는 떨어뜨릴 카드가 없다. SortableContext는 기존 아이템 위에 떨어질 때만 위치를 잡아준다. 카드가 0개인 컬럼으로 카드를 옮기려면 컬럼 본체가 별도의 드롭 타깃이어야 한다. 그래서 각 컬럼의 카드 영역에 useDroppable로 dropzone을 하나 더 깐다.
const { setNodeRef: setDropRef } = useDroppable({
id: `${DROPZONE_PREFIX}${list.id}`,
data: { type: 'dropzone', listId: list.id },
});
handleDragEnd는 over가 무엇이냐에 따라 목적지 리스트와 인덱스를 푼다 — 카드 위면 그 카드의 리스트·인덱스, dropzone이면 그 리스트의 끝, 컬럼 자체면 역시 끝.
if (overData?.type === 'card') {
toListId = overData.listId as string;
toIndex = findCardLocation(board, String(over.id))?.index ?? 0;
} else if (overData?.type === 'dropzone') {
toListId = overData.listId as string;
const list = board.lists.find((l) => l.id === toListId);
toIndex = list ? list.cards.length : 0; // 빈 컬럼: 끝에 붙인다
} else if (overData?.type === 'list') {
toListId = String(over.id);
const list = board.lists.find((l) => l.id === toListId);
toIndex = list ? list.cards.length : 0;
}
if (!toListId) return;
setBoard((current) => moveCard(current, cardId, toListId!, toIndex));
세 센서를 붙였는데, 각 활성화 조건이 사용성과 직결된다.
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
- 마우스: 5px 움직여야 드래그 시작 → 클릭(카드 열기)과 드래그가 안 섞인다.
- 터치: 200ms 롱프레스 후 드래그. 이게 중요한 디테일인데 — 카드에
touch-action: none을 걸면 안 된다. 걸면 일반 터치 스크롤이 죽어서 모바일에서 컬럼을 못 내린다. 롱프레스 지연이 "스크롤 vs 드래그"를 구분하는 장치라, 카드는 평소처럼 스크롤되고 꾹 눌렀을 때만 들린다. (반대로 컬럼의 드래그 핸들 버튼에는touch-none을 박는다 — 거긴 잡으면 무조건 드래그니까.) - 키보드: Space로 집고 화살표로 옮긴다. 그런데 카드는 Enter로 열리기도 해야 한다. dnd-kit의 키보드 리스너에서
onKeyDown을 떼어내 Enter는 카드 열기로, 나머지는 dnd로 흘려보낸다.
const { onKeyDown: dndKeyDown, ...dragListeners } = listeners ?? {};
// ...
onKeyDown={(event) => {
if (event.key === 'Enter') { event.preventDefault(); onOpen(); return; }
dndKeyDown?.(event); // Space 집기 + 화살표 이동은 dnd로
}}
드래그 중 따라다니는 미리보기는 DragOverlay로 그린다. 카드 앞면(CardFace)을 컬럼과 오버레이에서 같이 재사용해서, 들고 있는 카드가 원래 카드와 똑같이 생기게 했다.
함정 — DndContext에 id를 박지 않으면
여기가 이 글의 본론이다. 정적 export에서 dnd-kit을 쓸 때 조용히 터지는 게 하나 있다.
dnd-kit은 접근성을 위해 드래그 안내 텍스트를 aria-describedby로 연결한다. 그 id를 DndContext에 명시적 id를 주지 않으면 모듈 전역 카운터에서 자동 생성한다. 문제는 이 카운터가 서버 렌더와 클라이언트 렌더에서 같은 값이라는 보장이 없다는 것이다.
- 정적 export(prerender)는 빌드 시 한 번 도는 오래 산 Node 프로세스에서 카운터가 증가한다.
- 브라우저는 갓 시작한 카운터에서 다시 센다.
둘이 어긋나면 서버가 박은 aria-describedby id와 클라이언트가 만든 id가 달라 하이드레이션 불일치가 난다. 더 고약한 건 언제 보이느냐다.
A fixed id makes the ids deterministic. @dnd-kit derives its aria-describedby from a module-global counter when no id is given, which drifts between a long-lived SSR process and a fresh client → hydration mismatch (visible in
next dev, latent in static export).
즉 next dev에선 경고가 뜨고, 정적 빌드(output: 'export')에선 잠복한다. 정적 export는 서버 프로세스가 빌드 타임에만 살고 런타임엔 없으니, 프로덕션에서 증상이 안 드러나서 더 위험하다. "빌드는 멀쩡한데 dev에서만 콘솔이 빨갛네?"로 끝내고 넘기기 딱 좋은 함정이다.
해결은 한 줄이다. id를 고정해 양쪽이 같은 결정론적 id를 쓰게 한다.
<DndContext
id="peg-kanban"
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
이건 dnd-kit만의 문제가 아니라 전역 카운터로 id를 만드는 모든 라이브러리의 SSR 공통 함정이다. 페그보드에선 @dnd-kit을 쓰는 컴포넌트가 늘어날 때마다 이 한 줄을 잊지 않도록 코드 주석에 함정의 이유까지 박아뒀다. 6개월 뒤에 카피만 보고 "이 id 뭐지, 지워도 되나?" 싶을 때 멈춰 세우려고.
시드 온보딩 — 결정론적 id, 그리고 저장 순서
Peg Apps는 hero·learn 본문이 없는 대신 in-place 시딩으로 온보딩한다(ADR-0012). 첫 방문에만 실제 편집 가능한 샘플 보드를 미리 깔아두는 방식이다. 사용법 설명을 본문 카피로 적는 대신, 진짜 카드로 보여준다 — 할 일·진행 중·완료 세 컬럼에 "진행 중으로 드래그해 보세요 →", "카드를 클릭하면 세부 내용이 열립니다" 같은 안내가 담긴 카드가 들어 있다. 안내가 곧 데이터다.
그런데 시드도 하이드레이션 제약에 걸린다. 만약 시드 카드 id를 crypto.randomUUID()로 만들면, 서버 prerender와 첫 클라이언트 렌더가 서로 다른 id를 만들어 또 불일치가 난다. 그래서 시드는 인덱스 기반 결정론적 id를 쓴다.
// src/lib/kanban.ts — createSeedBoard
lists: seed.lists.map((list, listIndex) => ({
id: `seed-list-${listIndex}`,
title: list.title,
cards: list.cards.map((card, cardIndex) => ({
id: `seed-card-${listIndex}-${cardIndex}`, // 결정론적
...
})),
})),
반면 사용자가 실제로 추가하는 카드의 id는 createId() → crypto.randomUUID()다. 랜덤이어도 런타임에 한 번 생성되고 즉시 상태에 들어가니 하이드레이션과 무관하다. 시드만 prerender 시점에 이미 DOM에 있어야 해서 결정론적이어야 한다는 차이다.
더 미묘한 건 저장과 복원의 순서다. 보드 상태는 두 effect로 관리된다 — 마운트 시 localStorage에서 복원하는 effect, 그리고 보드가 바뀔 때마다 저장하는 effect. 순진하게 짜면 이 순서가 꼬인다.
마운트
→ 초기 상태 = 시드 보드
→ 저장 effect 실행 → 시드를 localStorage에 저장 (!) ← 돌아온 사용자의 저장본을 덮어씀
→ 복원 effect 실행 → 이미 늦음
그래서 마운트 직후 첫 저장을 한 번 건너뛰는 가드를 둔다.
const skipNextSaveRef = useRef(true);
useEffect(() => {
// 첫 실행은 마운트 커밋(board는 아직 시드) — 건너뛴다. 이후 변경부터 저장.
if (skipNextSaveRef.current) {
skipNextSaveRef.current = false;
return;
}
try {
localStorage.setItem(STORAGE_KEY, serializeBoard(board));
} catch { /* 용량·프라이버시 모드 실패는 무시 */ }
}, [board]);
복원은 lazy initializer가 아니라 effect에서 한다. prerender엔 localStorage가 없어서, 렌더 중에 읽으면 서버/클라가 또 어긋난다. 그래서 렌더는 항상 시드로 시작하고, effect에서 저장본이 있으면 갈아끼운다.
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return; // 첫 방문: 시드 유지
const saved = deserializeBoard(raw);
if (saved) setBoard(saved); // 돌아온 사용자: 저장본으로 교체
} catch { /* 손상/불가 저장소 무시 */ }
}, []);
세 조각 — 결정론적 시드 id, 첫 저장 건너뛰기, effect 기반 복원 — 이 맞물려야 "첫 방문엔 샘플, 두 번째부턴 내 보드, clear하면 빈 보드(시드 부활 없음)"가 정확히 굴러간다. 셋 중 하나만 빠져도 시드가 저장본을 덮거나, 하이드레이션이 깨지거나, 빈 화면이 된다.
localStorage 한 줄을 데이터 계약으로 — AI가 보드를 만진다
가장 재미있는 부분. 페그보드의 오프라인 AI 채팅(offline-ai-chat, 7편에서 다룬 WebGPU 온디바이스 Gemma)에는 Peg AI라는 기능이 있다 — 자연어로 PegNote·PegKanban을 조작하는 경로다. "마케팅 보드의 런칭 준비 카드를 완료로 옮겨줘" 같은 말이 실제 보드 편집으로 이어진다.
문제는 PegKanban과 offline-ai-chat이 완전히 다른 라우트라는 것이다. 정적 export엔 둘을 잇는 서버가 없다. 둘이 공유할 수 있는 유일한 채널은 — localStorage 키 하나다.
// app/[locale]/peg-kanban/peg-kanban-tool.tsx
// Must stay identical to peg-ai/storage.ts BOARD_KEY — the offline-AI writes
// cards under this key and PegKanban reads them (shared data contract).
const STORAGE_KEY = 'pegboard:peg-kanban';
// src/lib/peg-ai/storage.ts
// Must stay identical to PegKanban's own STORAGE_KEY (app/[locale]/peg-kanban):
// a data contract so the AI and the board read/write the same saved cards.
const BOARD_KEY = 'pegboard:peg-kanban';
pegboard:peg-kanban이라는 같은 문자열이 두 곳에 따로 정의돼 있고, 양쪽 주석이 서로를 가리킨다. 한쪽만 바꾸면 AI가 쓴 카드와 보드가 읽는 카드가 갈라진다 — 그래서 이건 코드의 우연이 아니라 명시적 계약이다. 더 중요한 건 Peg AI가 키만 공유하는 게 아니라 보드의 직렬화기까지 그대로 쓴다는 점이다.
Peg AI reads/writes those keys through the apps' own serializers, so what it writes is exactly what PegNote/PegKanban restore.
loadBoard/saveBoard가 PegKanban과 동일한 deserializeBoard/serializeBoard를 거치니, AI가 저장한 바이트는 보드가 마운트 시 복원하는 바이트와 정확히 같다. 한쪽이 자기 포맷으로 쓰고 다른 쪽이 다른 포맷으로 읽는 불일치가 구조적으로 불가능하다.
또 하나 — 이 storage 모듈의 모든 localStorage 접근은 함수 안에만 있다. 모듈 스코프에서 localStorage를 건드리면 정적 export 빌드가 죽는다(서버엔 localStorage가 없으니). 그래서 클라이언트 이벤트 핸들러에서만 호출되도록 가뒀다.
Every localStorage access lives INSIDE a function (never module scope) so this stays static-export safe.
LLM은 라우팅만, 편집은 코드가
그럼 자연어 → 보드 편집은 어떻게 안전하게 도나? 핵심 설계는 하이브리드다. LLM은 모호한 NL→tool 한 스텝만 하고, 검증·리스트 해석·실제 편집은 전부 결정론적 코드(src/lib/peg-ai/dispatch.ts)가 한다.
// dispatch.ts — interpret는 절대 쓰지 않는다. Plan만 반환한다.
case 'board_add_card': {
const text = argText(args);
if (!text) return { kind: 'clarify-empty', tool: 'board_add_card' };
return {
kind: 'confirm-add-board',
text,
description: descOf(args.description),
suggestedListId: resolveListId(board, args.list), // 모델이 부른 리스트명 → 실제 id
lists: listRefs(board),
};
}
interpret는 순수·전체 함수다. 어떤 모델 출력이든 정확히 하나의 Plan으로 매핑되고, 쓰기는 일어나지 않는다. 쓰기 Plan은 전부 confirm-* — 사용자가 확인 버튼을 눌러야 비로소 UI가 applyAddBoardCard 같은 apply 헬퍼를 호출한다(사용자가 쓰기 전 확인을 택했다).
모델이 리스트 이름을 틀리게 부르거나 없는 카드를 가리켜도 안전하게 실패한다. resolveListId는 정규화 후 정확 매칭 → 느슨한 포함 매칭 순으로 시도하고, 아무것도 안 맞으면 null을 돌려 UI가 리스트를 직접 고르게 한다. 카드 참조가 안 맞으면 no-match Plan으로 떨어져 아무 일도 안 일어난다.
// 모델의 자유 텍스트 리스트명을 기존 리스트 id로: 정확 → 느슨한 포함 → null
function resolveListId(board: Board, listName: unknown): string | null {
if (typeof listName !== 'string' || !listName.trim()) return null;
const target = normalize(listName);
const exact = board.lists.find((l) => normalize(l.title) === target);
if (exact) return exact.id;
const loose = board.lists.find((l) => {
const t = normalize(l.title);
return t.includes(target) || target.includes(t);
});
return loose?.id ?? null;
}
이 분리의 값은 명확하다 — LLM이 틀려도 데이터는 안 깨진다. 모델은 "어떤 카드를 어디로"라는 의도만 추출하고, 그 의도가 실제 보드의 어느 id에 닿는지는 코드가 결정론적으로 푼다. 잘못된 참조는 편집이 아니라 후보 선택지나 못 찾음으로 안전하게 귀결된다. 그리고 마지막 한 겹 — 사용자 확인 — 이 한 번 더 막는다.
결과적으로 PegKanban 입장에선 아무것도 특별하지 않다. AI는 그냥 pegboard:peg-kanban에 보드의 정식 포맷으로 카드를 하나 더 써둘 뿐이고, 보드는 다음에 마운트될 때(= 보드 페이지를 다시 열 때) 그걸 평소처럼 복원한다. 참고로 보드 쪽엔 storage 이벤트 리스너가 없어서, 두 라우트 사이의 동기화는 라이브가 아니라 마운트 시점에 일어난다 — 별도 탭에서 보드를 띄워두고 실시간 반영을 기대하는 모델은 아니다. 어쨌든 두 라우트를 잇는 건 서버도 전역 상태도 아닌, 합의된 키 하나다.
정리
- PegKanban은 Peg Apps 예외(ADR-0012) — hero·learn 본문 없이 보드가 곧 화면. SEO H1은
sr-only. - 보드 코어(
src/lib/kanban.ts)는 의존성 0 순수 TS(정적 export = 브라우저 실행 제약). 불변, no-op은 같은 참조 → 무의미한 저장·리렌더 차단. - 드래그앤드롭은 dnd-kit 중첩 SortableContext(컬럼 가로 + 카드 세로) + 빈 컬럼용 dropzone. 센서는 마우스 5px / 터치 200ms 롱프레스(카드에
touch-action:none금지) / 키보드 Space·화살표(Enter는 카드 열기로 분기). - 함정:
DndContext에id를 박아라. 전역 카운터 기반aria-describedby가 서버/클라 불일치를 내고,next dev에선 보이지만 정적 export에선 잠복한다.id="peg-kanban"한 줄로 결정론화. - 시드 온보딩 3종 세트: 결정론적 id(
seed-list-N/seed-card-N-M) + 첫 저장 건너뛰기(skipNextSaveRef) + effect 기반 복원. 셋이 맞물려야 "첫 방문 샘플 / 이후 내 보드 / clear 후 시드 부활 없음"이 성립. - localStorage 데이터 계약: 키
pegboard:peg-kanban이 PegKanban과 Peg AI 두 곳에 정의되고 반드시 동기화. 같은 직렬화기를 공유해 AI가 쓴 바이트 = 보드가 읽는 바이트. 모든 localStorage 접근은 함수 안(정적 export 안전). - AI 연동은 하이브리드: LLM은 NL→tool만, 검증·리스트 해석·편집은 결정론적 코드.
interpret는 쓰기 없는 순수 함수, 쓰기는 전부confirm-*→ 사용자 확인 후 apply. 모델이 틀려도no-match/후보 선택으로 안전하게 실패.
한 줄로 요약하면 — 정적 export에서 드래그앤드롭과 AI 연동을 붙이는 일의 어려움은 기능 자체가 아니라 서버가 없다는 제약에 있었다. 하이드레이션 결정론, effect 기반 영속화, 그리고 키 하나로 라우트를 잇는 데이터 계약. 셋 다 "서버가 있었으면 안 했을 고민"이고, 셋 다 코드를 읽지 않으면 안 보이는 디테일이다.
읽어줘서 고맙다.

