ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Tiny Flow Board: 무한 캔버스 메모 보드
    Projects 2026. 3. 13. 13:21
    반응형
    링크, 메모, 이미지를 자유롭게 배치하는 Mini Canvas Board

     

    브라우저 탭 30개를 열어두고 "나중에 봐야지" 라며 북마크에 던져놓거나, 혹은 탭으로 계속 열어두는 경우가 있다.
    Notion은 이렇게 사용하기에 너무 무겁고, 북마크는 리스트형이라 맥락이 사라지고, 탭을 열어두면 인터넷이 많이 잡아먹는다.

     

    "캔버스 위에 자유롭게 올려두기만 하면 되는 가벼운 도구"를 만들자라는 아이디어에서 시작되었다.

    tiny flow board

     


     

    코어 기술 스택

    기술 선택 이유
    Next.js 16 (App Router) RSC + Route Handler로 API까지 한 프로젝트에서 해결
    React Flow 노드 기반 무한 캔버스의 사실상 표준
    Framer Motion 선언적 애니메이션으로 UX 디테일 보강
    LocalStorage 인증 없이 즉시 사용 가능한 Zero-config storage

     

    의도적으로 제외한 것들

    1. 인증 시스템 : 온보딩 마찰을 0으로 만들기 위해 로그인 없이 즉시 보드를 만듭니다. 
    2. 유로 서비스 : Vercel 무료 티어 + localStorage만으로 MVP 구현
    3. 무거운 상태관리 : React 19의 useState + useCallback으로 충분해서 전역상태관리 라이브러리 없이 깔끔하게 사용

     

    핵심 구현

    1. 무한 캔버스 - Konva에서 React Flow로 마이그레이션

    처음에는 HTML Canvas 위에 직접 그리는 방식으로 하려고 react-konva로 캔버스를 구현했다. 하지만 드래그 후 카드가 원래 위치로 돌아가는 스냅백 버그가 발생했다.

    사용자가 카드 드래그  →  상태 업데이트  →  리렌더링  →  props로 전달된 이전 좌표로 복원 

     

    React의 선언적 렌더링과 Canvas의 명령적 좌표 시스템이 충돌한다.

    useRef로 position을 관리하고, DOM에서 직접 좌표를 읽는 등 여러 우회책을 시도했지만 근본적인 아키텍처 불일치를 패치로 해결하는 것은 기술 부채를 쌓는 것 같아 React Flow로 마이그레이션 했습니다.

     

    React Flow는 노드의 위치 상태를 내부적으로 관리하면서, 변경 사항을 NodeChange 이벤트로 전달한다.

    드래그 중에는 React Flow가 DOM을 직접 조작하고, 드래그가 끝난 시점에만 Storage에 반영하는 구조이다.

    이 방식은 React 단방향 데이터 흐름과 Canvas의 실시간 인터랙션을 자연스럽게 분리시킨다.

     

    2. 커스텀 노드 시스템

    React Flow의 NodeTypes를 활용해 텍스트, 링크, 이미지 세 가지 커스텀 노드를 구현했다.

    const nodeTypes: NodeTypes = {
      textCard: TextCardNode,
      linkCard: LinkCardNode,
      imageCard: ImageCardNode,
    }
    
    // Card 데이터 → React Flow Node 변환
    function cardToNode(card: Card): Node {
      return {
        id: card.id,
        type: card.type === "text" ? "textCard"
            : card.type === "link" ? "linkCard"
            : "imageCard",
        position: { x: card.x, y: card.y },
        data: { card },
        style: { width: card.width },
      }
    }

     

    각 노드 컴포넌트는 memo로 감싸 불필요한 리렌더링을 방지하고, nodrag CSS 클래스로 입력 필드 내부에서의 드래그를 차단한다.

    export const TextCardNode = memo(function TextCardNode({ data }: NodeProps) {
      // ...
      return (
        <div onDoubleClick={() => setIsEditing(true)}>
          {isEditing ? (
            <div className="nodrag">  {/* 이 영역은 드래그 비활성화 */}
              <input autoFocus />
              <textarea onBlur={handleSave} />
            </div>
          ) : (
            <div>{content || "더블클릭하여 수정"}</div>
          )}
        </div>
      )
    })

     

    부모-자식 간 통신에 CustomEvent를 사용한다. 노드에서 카드를 삭제하면 window.dispatchEvent(new CustomEvent("tinyboard:refresh")) 를 발행하고, 캔버스가 이를 수신해 전체 상태를 갱신하도록 했다.

    React Flow 노드는 React Tree에서 격리되어 있어 props drilling이 어렵기 때문에 이런 패턴을 선택했다.

     

    3. 서버사이드 OG 메타데이터 파싱 - Link Preview API

    링크 카드의 미리보기를 위해 Next.js Route Handler로 server-side OG 파서를 구현했다.

    // app/api/link-preview/route.ts
    
    export async function GET(req: NextRequest) {
      const url = req.nextUrl.searchParams.get("url")
    
      const res = await fetch(url, {
        headers: { "User-Agent": "TinyBoard/1.0 (link-preview)" },
        signal: AbortSignal.timeout(5000),  // 5초 타임아웃
      })
      const html = await res.text()
    
      const preview = {
        title: extract(html, [
          /property="og:title"\s+content="([^"]*)"/,
          /name="twitter:title"\s+content="([^"]*)"/,
          /<title[^>]*>([^<]*)<\/title>/,    // fallback
        ]),
        image: resolveUrl(url, extract(html, [
          /property="og:image"\s+content="([^"]*)"/,
        ])),
        // ...
      }
      return NextResponse.json(preview)
    }

     

    브라우저에서 직접 외부 URL을 fetch하면 CORS에 막히게 된다. 그래서 Route Handler가 서버에서 대신 요청하고, 정규식으로 OG 메타태그를 파싱해서 JSON으로 반환하는데 이 때 라이브러리 없이 정규식만으로 처리해 번들 사이즈를 0으로 유지하도록 했다

     

    파싱 전략은 우선 순위 기반의 Fallback Chain이다.

    1.  og:title  →   2.  twitter:title   →   3.  <title>  태그

     

    4. localStorage 추상화

     

    localStorage는 단순해 보이지만 프로덕션에서는 여러 엣지 케이스가 있을 수 있다.

    // SSR 환경 체크
    function get<T>(key: string, fallback: T): T {
      if (typeof window === 'undefined') return fallback  // SSR 안전
      try {
        const raw = localStorage.getItem(key)
        return raw ? JSON.parse(raw) : fallback
      } catch {
        return fallback  // 파싱 실패 시 기본값
      }
    }
    
    // 용량 초과 감지
    export type StorageError = 'quota_exceeded' | null
    let lastError: StorageError = null
    
    function set(key: string, value: unknown): void {
      try {
        localStorage.setItem(key, JSON.stringify(value))
        lastError = null
      } catch {
        lastError = 'quota_exceeded'
      }
    }

     

    이미지를 Base64 Data URL로 저장하기 때문에 몇 장의 이미지만으로도 localStorage의 5MB 한도에 근접할 수 있다.

    그래서 함수를 통해서 카드 생성 직후 용량 초과 여부를 체크하고 사용자에게 토스트로 경고하도록 했다.

    ( 무료 버전을 만들려고 해서 그런데 나중에는 더 좋은 방법으로 옮길 예정이다 )

     

     

    마무리

    Tiny Flow Board는 "최소한의 스택으로 최대한의 인터랙션"을 목표로 만든 토이 프로젝트이다.

    인증 없이, 서버 DB 없이, 무거운 라이브러리 없이 자연스러운 UX를 만들고자 했다.

     

    다음 단계로는 Supabase를 이용한 공유기능과 테마 확장 기능, 카드 리사이즈 기능, Undo/Redo 기능을 진행해보려고 한다.

    반응형
Designed by Tistory.