logo
Nostrss
Published on

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

Authors

Pegman brand retirement

pegboard.me

페그보드 시리즈가 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 하이드레이션 함정: DndContextid를 안 박으면 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 },
});

handleDragEndover가 무엇이냐에 따라 목적지 리스트와 인덱스를 푼다 — 카드 위면 그 카드의 리스트·인덱스, 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는 카드 열기로 분기).
  • 함정: DndContextid를 박아라. 전역 카운터 기반 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 기반 영속화, 그리고 키 하나로 라우트를 잇는 데이터 계약. 셋 다 "서버가 있었으면 안 했을 고민"이고, 셋 다 코드를 읽지 않으면 안 보이는 디테일이다.

읽어줘서 고맙다.