← 목록으로

Miro에 뺏긴 회고 10분, 이제 그만! ✨ 웹용 코르크보드 'PostItUp' 개발기

2026. 6. 5.

Miro에 뺏긴 회고 10분, 이제 그만! ✨ 웹용 코르크보드 'PostItUp' 개발기

제가 지금까지 몸담았던 모든 IT 팀에서 늘 겪는 일이 하나 있습니다.

스프린트가 끝나면 누군가 회고(Retro) 일정을 잡고, 또 다른 누군가가 슬랙에 Miro 링크를 공유합니다. 팀원 절반은 링크를 열자마자 벽에 부딪힙니다. "뷰어 플랜이라 편집이 안 돼요", "로딩이 안 되네요", "스티커 메모는 어떻게 추가하죠?" 한 사람은 실수로 프레임 전체를 삭제하고, 다른 한 사람은 보드의 엉뚱한 구석을 확대해놓고는 헤어나오지 못합니다.

결국 우리는 매 회고 시작 후 첫 10분에서 15분을 보드 세팅만 하다가 보냅니다.

저는 그 시간 내내 이런 생각을 했습니다. '이건 그냥 스티커 메모 보드잖아. 회의 역사상 가장 오래되고, 단순하고, 명확한 도구인데 왜 이걸 사용하려면 튜토리얼이 필요해?' 제가 예전에 다니던 회사에서도 정말 똑같은 경험을 했어요. 매번 회고 시작 전에 5분에서 10분은 'Miro 사용법 알려주세요', '확대 축소가 안 돼요' 같은 질문으로 흘려보냈죠.

그래서 저는 직접 만들었습니다. 이름은 PostItUp. 브라우저에서 실행되는 실시간 협업 스티커 메모 보드입니다. 실제 코르크보드처럼 생겼죠. 계정을 만들 필요 없이 누구나 메모를 붙일 수 있고, 이 모든 게 무료로 운영됩니다.

지금부터 PostItUp의 제품, 디자인 결정, 기술적 선택, 저를 거의 좌절시킬 뻔했던 버그들, 그리고 앞으로의 방향까지 모든 것을 상세히 이야기해 드릴게요. 내용이 좀 길어질 테니, 마실 것 하나 준비하고 편하게 읽어주세요!


PostItUp, 대체 어떤 놈이야?

PostItUp landing page

아이디어는 간단합니다. 보드를 만들고, 링크를 공유합니다. 사람들은 링크를 클릭하고 바로 메모를 붙이기 시작합니다. 회원가입도, 튜토리얼도, 온보딩 과정도 전혀 없습니다.

각 메모는 하나의 스티커입니다. 색상을 선택하고, 내용을 입력한 다음, 원한다면 이름을 추가하고 "Pin it" 버튼을 누릅니다. 메모는 캔버스에 나타나고, 보드를 보고 있는 모든 사람은 실시간으로 메모가 나타나는 것을 볼 수 있습니다.

이것이 핵심 루프입니다. 그 외의 모든 것은 이 위에 쌓아 올린 세부 사항들이죠.

시각적 디자인은 의도적이며, 생각보다 훨씬 중요합니다. 모든 카드에는 흔들리는 손글씨 테두리가 있고, 메모는 작은 마스킹 테이프 조각으로 보드에 고정된 것처럼 보입니다. 압정은 프로젝트 카드를 표시하고요. 배경은 선택에 따라 점선 그리드, 줄이 있는 공책 페이지, 또는 격자 패턴으로 바뀝니다. 폰트도 작은 크기에서도 읽기 쉬운 실제 손글씨 폰트를 사용했습니다.

이런 방식으로 보이도록 일부러 만들었습니다. 왜 그렇게 했는지는 잠시 후에 설명해 드릴게요.


왜 굳이 이런 모습으로 만들었을까?

대부분의 협업 도구는 SaaS 대시보드처럼 보입니다. 깔끔하고, 평평하고, 효율적이며, 조금은 차갑죠. 진지한 프로젝트 관리에는 전혀 문제가 없습니다.

하지만 빠른 피드백 세션이나 회고, 브레인스토밍에는 이런 시각적 언어가 오히려 방해가 됩니다. 이는 "전문적인 맥락"을 암시하여 사람들이 글을 쓸 때 더 신중하고 조심스럽게 만들죠. 물리적인 보드에 솔직하고 직설적인 포스트잇을 붙일 사람들도 Jira 티켓에는 훨씬 더 외교적인 내용을 작성할 겁니다.

물리적인 스티커 메모는 일회용 같고, 즉흥적이며, 솔직해도 안전하다는 느낌을 줍니다. 저는 디지털 버전도 그와 같은 느낌을 전달하길 원했습니다. 사실 저도 개발자로서 깔끔하고 효율적인 UI를 선호했지만, 브레인스토밍 같은 세션에서는 이런 '인간적인' 인터페이스가 훨씬 더 창의적인 에너지를 불어넣는다는 걸 실무에서 여러 번 느꼈습니다.

그리고 그냥 '다르게 보이는' 도구에 대한 장점도 있습니다. 일주일 내내 같은 Notion 문서나 같은 Miro 보드를 보고 있으면, 어느새 무감각해집니다. 화면에 코르크보드처럼 보이는 무언가를 열면, 작은 정신적 맥락 전환이 일어납니다. 사소한 것 같지만, 저는 이게 중요하다고 생각합니다.


세 가지 캔버스 모드: 하나로는 모두를 만족시킬 수 없었으니까

New board creation page

보드를 만들 때 세 가지 캔버스 모드 중 하나를 선택합니다.

**자유 캔버스(Free canvas)**는 열린 점선 그리드입니다. 메모는 어디에 놓든 그 자리에 고정됩니다. Alt+드래그로 화면 이동, Ctrl+스크롤로 확대/축소할 수 있습니다. 메모를 어디든 배치할 수 있어 가장 유연하지만, 가장 혼란스러울 수도 있습니다.

**그리드 모드(Grid mode)**는 모든 것을 32픽셀 그리드에 자동으로 맞춰줍니다. 캔버스는 여전히 완전히 열려 있지만, 직접 정렬하려 애쓰지 않아도 메모가 깔끔하게 정렬됩니다. 지금은 제가 기본으로 사용하는 모드죠. 읽기 좋게 구조화되어 있으면서도, 너무 억압적인 느낌은 없습니다.

**줄이 있는 페이지(Ruled lines)**는 배경에 공책 줄을 깔아줍니다. 보드의 분위기 자체가 달라지죠. 순차적인 피드백이나 자유로운 아이디어 대신 정렬된 목록을 수집할 때 아주 유용합니다.

보드 소유자가 생성 시 모드를 설정하며, 이 모드는 전체 캔버스에 적용됩니다. 동일한 코드베이스, 동일한 컴포넌트를 사용하지만, 세 가지 완전히 다른 느낌을 줍니다.

이 결정에 예상보다 많은 시간을 할애했습니다. 처음에는 "그냥 자유 캔버스로 하면 되지, 그게 가장 명확한 선택이잖아"라고 생각했습니다. 하지만 몇 번의 빠른 피드백 세션을 진행해보니, 열린 캔버스에 익숙하지 않은 사람들이 메모를 어색하게 추가하며 어디에 놓아야 할지 몰라 하는 것을 계속 발견했습니다. 그리드 모드가 그 문제를 완전히 해결해 주었죠. 그리고 줄이 있는 페이지 모드는 누군가 설문지 같은 느낌을 원한다고 말해서 추가하게 된 기능입니다.

기능은 사람들이 사용하는 모습을 관찰하는 것에서 나와야지, 그들이 무엇을 원할지 상상하는 것에서 나오면 안 된다는 것을 다시 한번 깨달았습니다.


메모 작성하기

캔버스 아무 곳이나 더블 클릭하면 모달이 나타납니다. 내용을 입력하고, 색상을 선택하고, 원한다면 이름을 추가합니다(이름은 localStorage에 저장되어 한 번만 입력하면 됩니다). "Pin it"을 누릅니다.

메모가 캔버스에 나타납니다.

다른 사람이 같은 보드를 보고 있다면, 그 순간 바로 나타나는 것을 볼 수 있습니다. 새로 고침할 필요 없이 보드는 실시간으로 업데이트됩니다.

Board canvas with notes

메모는 착지할 때 약간 무작위적인 회전 값을 가집니다. -3도에서 +3도 사이의 무작위 값으로, 삽입 시점에 선택되어 데이터베이스에 저장됩니다. 이는 아주 작은 디테일이지만, 보드를 소프트웨어가 아닌 사람이 직접 메모를 붙인 것처럼 보이게 합니다. 코르크보드에 일관되게 똑바로 붙은 스티커 메모는 어색할 테니까요.

메모를 캔버스 위에서 드래그할 수 있습니다. 손을 떼는 순간 위치가 데이터베이스에 저장됩니다. 보고 있는 모든 사람이 움직임을 실시간으로 볼 수 있습니다.

동의하는 메모에는 '찬성' 투표를 할 수 있습니다. 메모당 장치당 한 표입니다. 투표 수는 메모에 영구적으로 남습니다.

보드 소유자는 어떤 메모든 삭제할 수 있고, 작성자는 자신이 작성한 메모를 삭제할 수 있습니다. 소유자는 설정에서 전체 보드를 비울 수도 있습니다.


개발자들이 실제로 유용하다고 느낄 부분

Embed panel in board settings

모든 보드에는 임베드(Embed) 패널이 있습니다. 툴바에서 링크 아이콘 버튼을 클릭하여 엽니다. 세 가지 옵션이 있습니다.

iFrame은 가장 일반적인 방식입니다. 한 줄의 코드로, 어떤 웹페이지든 보드를 임베드할 수 있습니다.

<iframe
  src="https://postitup.varshithvhegde.in/embed/your-board-slug"
  width="100%"
  height="600"
  frameborder="0">
</iframe>

별도의 /embed/[slug] 라우트가 존재하여, 내비게이션이나 앱 크롬 없이 간소화된 캔버스 버전을 렌더링합니다. 오직 보드만 보이죠. Supabase Realtime은 여전히 내부에서 실행되므로, iFrame 안에서도 실시간으로 업데이트됩니다.

**스크립트 태그(Script tag)**는 제가 실제로 유용하다고 생각하는 부분입니다. 다음 스크립트를 어떤 웹페이지든 붙여넣으세요.

<script
  src="https://postitup.varshithvhegde.in/embed.js"
  data-board="your-board-slug"
  data-url="https://postitup.varshithvhegde.in">
</script>

이 코드는 페이지 모서리에 떠다니는 "메모 남기기" 버튼을 삽입합니다. 이 버튼을 클릭하면 슬라이드 아웃 드로어(slide-out drawer)가 열리면서 전체 보드가 나타납니다. iFrame은 누군가 버튼을 클릭할 때만 로드됩니다. 드로어를 아무도 열지 않으면 보드 로딩에 비용이 들지 않습니다. 네트워크 요청도, 레이아웃 변화도 없습니다.

이 스크립트는 완전히 자체 포함되어 있습니다. 사용자 측에서 어떤 프레임워크도 필요하지 않습니다. 정적 HTML 페이지, 워드프레스 블로그, Next.js 앱 등 무엇이든 작동합니다. 저는 이미 몇몇 페이지에 적용해봤고, 전체 설정에 약 90초밖에 걸리지 않았습니다.

React 컴포넌트는 다음으로 출시될 예정입니다.

현재는 스니펫이 게시되었을 때 어떻게 사용하는지 보여줍니다. npm 패키지는 아직 출시되지 않았지만, 현재 개발 중입니다. 목표는 다음과 같습니다.

import { PostItBoard } from "postitup"

<PostItBoard
  board="your-board-slug"
  baseUrl="https://postitup.varshithvhegde.in"
  height={500}
/>

TypeScript 타입, SSR(서버 사이드 렌더링) 안전성, Next.js에서 하이드레이션 문제 없이 작동, 그리고 앱 내에서 이질적으로 보이지 않도록 테마 props도 제공할 예정입니다. 만약 이 기능이 필요하시다면, 레포지토리를 주시해주세요. 곧 나옵니다!


이제 기술적인 부분입니다

기술 스택은 Next.js 16 (App Router), Supabase (데이터베이스, 실시간 통신, 인증), 모든 곳에 TypeScript, 레이아웃 유틸리티는 Tailwind CSS를 사용했습니다. 컴포넌트 라이브러리는 전혀 쓰지 않았습니다. 시각적인 모든 요소는 직접 작성한 CSS로 만들었죠.

흔들리는 테두리(wobbly borders)는 어떻게 작동할까?

카드에 적용된 손글씨 같은 느낌의 테두리는 페이지 레이아웃에 정의된 단일 SVG 필터에서 나옵니다.

<filter id="roughen">
  <feTurbulence
    type="fractalNoise"
    baseFrequency="0.04"
    numOctaves="4"
    seed="3"
    result="noise" />
  <feDisplacementMap
    in="SourceGraphic"
    in2="noise"
    scale="2.5"
    xChannelSelector="R"
    yChannelSelector="G" />
</filter>

feDisplacementMap은 프랙탈 노이즈를 기반으로 픽셀을 이동시킵니다. 그 결과 마치 누군가 손으로 테두리를 그린 것처럼 보이죠. 어떤 요소든 filter: url(#roughen)을 적용하면 이 흔들리는 모습을 얻을 수 있습니다. 한 번 정의된 필터가 모든 곳에서 참조되는 방식입니다.

마스킹 테이프(washi tape) 조각은 텍스처를 위한 반복 선형 그라데이션이 적용된 반투명 div입니다.

background-image: repeating-linear-gradient(
  90deg,
  transparent 0, transparent 3px,
  rgba(255,255,255,0.18) 3px, rgba(255,255,255,0.18) 4px
);
mix-blend-mode: multiply;

mix-blend-mode: multiply는 실제 테이프가 종이 위에서 작동하는 것처럼, 뒤에 있는 어떤 것과도 반투명하게 섞여 보이도록 만듭니다. 이런 작은 디테일들이 모여 전체적인 완성도를 높입니다.

Supabase의 실시간 통신

Supabase Realtime은 Postgres 위에 구축된 WebSocket 레이어입니다. 필터를 이용해 테이블의 변경 이벤트를 구독하면, 행이 변경될 때 Supabase가 페이로드를 보내줍니다.

const channel = supabase
  .channel(`board:${board.id}`)
  .on("postgres_changes", {
    event: "INSERT",
    schema: "public",
    table: "notes",
    filter: `board_id=eq.${board.id}`
  }, (payload) => {
    setNotes(n =>
      n.find(x => x.id === payload.new.id)
        ? n  // 이미 낙관적 업데이트로 추가됨
        : [...n, payload.new as Note]
    )
  })
  .on("postgres_changes", { event: "UPDATE", ... }, (payload) => {
    setNotes(n => n.map(x =>
      x.id === payload.new.id ? { ...x, ...payload.new } : x
    ))
  })
  .on("postgres_changes", { event: "DELETE", ... }, (payload) => {
    setNotes(n => n.filter(x => x.id !== payload.old.id))
  })
  .subscribe()

INSERT 핸들러는 메모를 추가하기 전에 이미 존재하는지 확인합니다. 메모를 게시하면 사용자 자신의 UI는 즉시 업데이트됩니다(낙관적 업데이트, optimistic update). 실시간 이벤트는 잠시 후에 도착하죠. 이 확인 로직이 없으면 메모가 두 번 보이는 문제가 발생합니다.

오후 내내 날려버린 드래그 버그

메모 드래그는 시각적으로는 잘 작동했습니다. 하지만 위치가 제대로 저장되지 않아 페이지를 새로 고치면 메모가 원래 있던 자리로 돌아갔습니다.

문제는 **오래된 클로저(stale closure)**였습니다. mouseup 핸들러가 메모의 위치를 콜백이 처음 생성될 때 존재했던 React 상태 스냅샷에서 읽고 있었던 거죠. 드래그 후의 현재 위치가 아니라요.

React 상태 업데이트는 비동기적입니다. mouseup이 발생할 때쯤에는 핸들러를 생성할 때 클로저로 캡처된 상태는 이미 여러 렌더링 뒤쳐져 있을 수 있습니다. 메모는 화면에서 움직인 것처럼 보이지만, 데이터베이스에 저장하는 값은 이전 값이었던 겁니다.

저도 예전에 비슷한 프로젝트에서 겪어본 적이 있는데, 그땐 정말 답을 찾기까지 꽤 오랜 시간을 헤맸습니다. React의 비동기 상태 업데이트 특성을 제대로 이해하지 못하면 종종 마주치는 함정이죠.

해결책은 mousemove 이벤트마다 업데이트되는 ref에 실시간 위치를 저장하는 것이었습니다.

const dragging = useRef<{
  id: string
  ox: number      // 원래 위치
  oy: number
  startX: number  // 마우스 시작 위치
  startY: number
  finalX: number  // mousemove마다 업데이트됨
  finalY: number
} | null>(null)

// onMouseMove 함수 내에서:
const nx = snap(dragging.current.ox + dx, board.mode)
const ny = snap(dragging.current.oy + dy, board.mode)
dragging.current.finalX = nx
dragging.current.finalY = ny
setNotes(ns => ns.map(n =>
  n.id === dragging.current?.id ? { ...n, x: nx, y: ny } : n
))

// onMouseUp 함수 내에서:
const { id, finalX, finalY } = dragging.current
dragging.current = null  // 비동기 호출 전에 초기화
await supabase.from("notes").update({ x: finalX, y: finalY }).eq("id", id)

ref는 변경 가능하며, 클로저가 언제 생성되었는지와 상관없이 항상 현재 값을 제공합니다. ref에 있는 메모 위치는 mouseup이 발생할 때의 실제 최종 위치입니다. 문제 해결!

Row Level Security와 업보트(upvote) 문제

Supabase는 Postgres의 RLS(Row Level Security)를 사용합니다. 각 테이블의 정책은 각 사용자가 무엇을 읽고, 삽입하고, 업데이트하고, 삭제할 수 있는지 제어합니다. 이걸 건너뛰면 데이터베이스는 프런트엔드 번들에 내장되어 완전히 공개되는 익명 키를 얻는 누구에게나 열려버립니다.

대부분의 정책은 직관적입니다. 하지만 업보트 정책은 그렇지 않았습니다.

저는 클라이언트가 upvotes 열을 임의의 숫자로 직접 설정하는 것을 막아야 했습니다. 첫 시도는 with check 제약 조건으로, 열 값을 notes 테이블로 다시 서브쿼리하여 비교하는 것이었습니다.

create policy "update notes" on notes
  for update using (...)
  with check (
    upvotes = (select upvotes from notes where id = notes.id)
  )

이것은 무한 재귀를 일으켰습니다. Postgres는 정책을 평가하려 했고, 정책은 notes 테이블을 읽었습니다. notes 테이블을 읽는 것은 정책을 다시 트리거했고, 이는 다시 notes 테이블을 읽으려 했습니다. 데이터베이스 수준에서 스택 오버플로우가 발생했죠.

처음 Supabase RLS를 접했을 때, 단순히 CRUD만 막으면 되는 줄 알았는데, 이렇게 '업보트' 같은 특정 로직은 RLS만으로는 부족하다는 걸 깨달았습니다. 결국 SECURITY DEFINER 함수로 안전망을 한 겹 더 치는 게 정답이더군요. 실무에서 보안 정책을 설계할 때도 이런 섬세한 접근이 정말 중요하죠.

실제 해결책은 전체 업보트 작업을 소유하는 SECURITY DEFINER 함수였습니다.

create or replace function increment_upvote(note_id uuid, voter_fp text)
returns json
language plpgsql
security definer
as $$
declare
  already_voted boolean;
  new_count integer;
begin
  select exists(
    select 1 from note_votes
    where note_votes.note_id = increment_upvote.note_id
    and voter_fingerprint = voter_fp
  ) into already_voted;

  if already_voted then
    return json_build_object('success', false, 'reason', 'already_voted');
  end if;

  insert into note_votes (note_id, voter_fingerprint)
  values (increment_upvote.note_id, voter_fp);

  update notes set upvotes = upvotes + 1
  where id = increment_upvote.note_id
  returning upvotes into new_count;

  return json_build_object('success', true, 'upvotes', new_count);
end;
$$;

SECURITY DEFINER는 함수가 호출자의 권한이 아닌 데이터베이스 소유자의 권한으로 실행됨을 의미합니다. note_votes로의 직접 삽입은 정책 수준에서 차단됩니다. 투표를 등록하는 유일한 방법은 이 함수를 호출하는 것입니다. 이 함수는 중복 투표를 확인하고 원자적으로(atomically) 증가시킵니다. 클라이언트에서 upvotes 열을 직접 조작할 수 없습니다.

데이터베이스에 위치가 저장되는 방식

모든 메모는 x, y, width, rotation 열을 가집니다. 이들은 모두 부동 소수점(float) 값입니다. 메모를 드래그하고 놓으면, 새로운 좌표로 데이터베이스 업데이트가 한 번 발생합니다. 다른 사람이 보드를 보고 있다면, Supabase Realtime이 UPDATE 이벤트를 전달하고 메모는 그들의 화면에서 움직입니다.

rotation은 삽입 시점에 한 번 설정됩니다. -3도에서 +3도 사이의 무작위 값으로, 영구적으로 저장됩니다. 그 이후로는 변경되지 않습니다. 이것이 보드를 그리드가 아닌 실제 코르크보드처럼 보이게 하는 핵심입니다.

그리드 모드의 경우, 좌표는 저장하기 전에 가장 가까운 32픽셀 증분으로 스냅됩니다.

function snap(v: number, mode: Board["mode"]) {
  return mode === "grid" ? Math.round(v / GRID) * GRID : v
}

자유 모드와 줄이 있는 모드는 스냅 기능을 완전히 건너뜁니다. 이 함수는 순수 함수로, 라이브 미리보기를 위해 mousemove 핸들러에서 호출되고, 데이터베이스 쓰기 전에 다시 한 번 호출되어 화면에 보이는 것이 그대로 저장되도록 합니다.

양쪽에서의 입력 유효성 검사

모든 입력은 데이터베이스에 닿기 전에 새니타이저(sanitizer)를 거칩니다.

export function sanitizeText(input: string): string {
  return input
    .replace(/<[^>]*>/g, "")       // HTML 태그 제거
    .replace(/javascript:/gi, "")  // JS URI 차단
    .trim()
}

onChange 핸들러에서는 길이 제한이 적용되어 너무 긴 내용은 제출 시도조차 할 수 없습니다. 그리고 마지막 안전망으로, 데이터베이스에는 열 수준 제약 조건이 있습니다.

alter table notes
  add constraint notes_content_length
    check (char_length(content) between 1 and 500),
  add constraint notes_upvotes_nonneg
    check (upvotes >= 0)

누군가 프런트엔드를 완전히 우회하고 원시 API 요청을 보내더라도, 데이터베이스는 이 제약 조건을 위반하는 모든 것을 거부합니다. 두 개의 레이어가 독립적으로 강제됩니다.

GDPR 관련 사항

저는 사람들이 신뢰할 수 있는 서비스를 만들고 싶었습니다. 그래서 나중에 번거롭게 추가하는 것보다 처음부터 적절한 데이터 권리를 구축했습니다.

두 개의 Postgres 함수가 이 작업을 수행합니다.

데이터 내보내기는 사용자에 대한 모든 데이터를 JSON으로 반환합니다. 계정 페이지에서 한 번의 클릭으로 파일로 다운로드할 수 있습니다. 이것은 GDPR 20조를 충족합니다.

계정 삭제는 좀 더 복잡합니다. 사용자의 모든 보드를 삭제해야 하고(외래 키를 통해 메모가 연쇄 삭제됨), 다른 사람의 보드에 게시한 메모는 익명화해야 하며(내용은 유지되지만 작성자 정보는 제거됨), 프로필을 삭제한 다음, 인증 기록을 삭제해야 합니다. 마지막 단계는 권한 상승이 필요하므로, 이 함수는 SECURITY DEFINER로 실행됩니다. 데이터베이스 작업 후, 클라이언트는 localStorage를 지우고 로그아웃합니다.

완전한 삭제입니다. 아무것도 남지 않습니다.


인증(Auth)

Supabase를 통한 GitHub OAuth와, 원한다면 이메일 및 비밀번호 방식도 지원합니다.

Postgres 트리거는 누군가 가입할 때 자동으로 프로필 레코드를 생성합니다.

create or replace function handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, email, display_name)
  values (
    new.id,
    coalesce(new.email, ''),
    coalesce(
      new.raw_user_meta_data->>'display_name',
      split_part(coalesce(new.email, 'anonymous'), '@', 1)
    )
  )
  on conflict (id) do nothing;
  return new;
end;
$$ language plpgsql security definer;

on conflict do nothing은 트리거가 동일한 사용자에 대해 두 번 발생할 경우 오류를 방지합니다. 테스트 중에 이런 일이 여러 번 발생해서 이 구문이 있어서 다행이라고 생각합니다.

경로 보호는 Next.js 미들웨어로, 보호된 페이지를 제공하기 전에 Supabase 세션을 확인합니다. 세션이 없으면 의도한 목적지를 쿼리 파라미터로 포함하여 로그인 페이지로 리디렉션됩니다. 로그인 후에는 원래 가려던 페이지로 이동합니다.

익명 사용자도 공개 및 링크 전용 보드에 계정 없이 게시할 수 있습니다. 그들의 메모는 localStorage의 작성자 이름을 표시합니다. 투표는 역시 localStorage의 무작위 지문(fingerprint)으로 추적됩니다. 어떤 신원에도 연결되지 않습니다. 데이터베이스에는 메모 자체 외에 아무것도 남지 않습니다.


보드: 전체 흐름은 어떻게 작동할까?

/new에서 보드를 생성합니다. 제목, 설명, 기여자들을 위한 안내 문구, 캔버스 모드, 공개 여부 등을 설정합니다. 제출하면 /board/your-slug로 리디렉션됩니다.

슬러그는 보드 제목과 4자리 무작위 접미사를 결합하여 생성됩니다. 충돌을 방지하기 위함이죠. sprint-retro-3a7f처럼, sprint-retro만 쓰는 대신입니다. 간단하고 사람이 읽기 쉽습니다.

보드 페이지는 Next.js 서버 컴포넌트이며, 보드 데이터와 초기 메모를 서버 사이드에서 가져옵니다. 이것은 성능에 중요합니다. 페이지가 로드될 때 캔버스는 이미 채워져 있습니다. 로딩 스피너도, 잠시 후 채워지는 빈 보드도 없습니다. 메모들은 HTML에 이미 포함되어 있습니다.

초기 로드 후에는 Supabase Realtime이 모든 후속 업데이트를 처리합니다. 두 가지 다른 시스템이 각자의 장점을 최대한 활용하는 방식이죠.

보드 설정은 소유자가 제목, 설명, 안내 문구, 캔버스 모드, 공개 여부를 업데이트할 수 있도록 합니다. 모드를 자유 모드에서 그리드 모드로 변경해도 기존 메모는 이동하지 않으며, 새로운 메모만 스냅됩니다. 공개 여부 변경은 즉시 적용됩니다.

보드를 삭제하려면 보드 제목을 입력하여 확인해야 합니다. 모든 메모는 연쇄 삭제됩니다. 되돌릴 수 없죠. 확인 절차가 귀찮게 느껴지도록 의도적으로 만들었습니다.


앞으로의 계획

지금 가장 집중하고 있는 것은 npm 패키지입니다.

iFrame 임베드는 잘 작동하지만, 이미 React 코드베이스를 사용 중이라면 컴포넌트를 직접 앱에 삽입하는 것이 훨씬 깔끔한 경험을 제공합니다. 목표는 TypeScript API와 테마 props를 제공하여 UI에서 이질적으로 보이지 않게 하고, Next.js에서 하이드레이션 경고 없이 작동하는 <PostItBoard /> 컴포넌트입니다.

import { PostItBoard } from "postitup"

<PostItBoard
  board="your-board-slug"
  baseUrl="https://postitup.varshithvhegde.in"
  height={500}
  theme="paper"
/>

이런 형태가 될 겁니다. 곧 npm에 게시될 예정이니, 필요하시다면 GitHub 레포지토리를 주시해주세요.

그 다음으로는 **레인 모드(lane mode)**입니다. 칸반 스타일 레이아웃으로 열을 추가하여 제품 피드백 세션을 위한 '좋아요/보통/싫어요'나, 회고를 위한 '잘된 점/아쉬운 점/실행 항목' 같은 구성을 만들 수 있습니다. 동일한 실시간 동기화, 동일한 익명 게시 기능이 제공되지만, 자유 캔버스 대신 열로 정리됩니다.

그리고 매번 회고를 시작할 때 처음부터 만들 필요 없이 보드 템플릿 기능도 추가할 예정입니다.


무료. 진짜 무료입니다.

이 모든 서비스는 Supabase 무료 티어와 Vercel 취미 플랜으로 운영됩니다.

  • Supabase 무료: 500MB 데이터베이스, 월간 활성 사용자 50,000명, 무제한 API 요청.
  • Vercel 취미: 무제한 배포, 무료 도메인, 빠른 엣지 네트워크.

팀원들과 회고를 진행하면서 이 제한에 도달할 일은 없을 겁니다.

이것은 제가 예전에 FormRelay에 대해 썼던 것과 같은 교훈을 줍니다. "직접 서버를 운영하는 것"과 "사실상 데이터베이스 삽입에 불과한 것에 한 달에 20달러를 지불하는 것" 사이에는 거대한 간극이 있습니다. 매달 실제 돈이 나가는 수많은 문제들이 사실은 주말 프로젝트로 해결할 수 있는 것들이죠.

{% embed https://dev.to/varshithvhegde/i-built-a-form-backend-in-a-weekend-because-paying-20month-for-contact-forms-is-stupid-1o34 %}

실시간으로 업데이트되는 스티커 메모 보드라고 하면 복잡하게 들립니다. 하지만 그렇지 않습니다. 데이터베이스 테이블 하나, WebSocket 구독, 그리고 드래그 기능을 아는 캔버스일 뿐입니다. 의미 있는 파일들의 총 코드량은 아마 2천 줄 정도일 겁니다. 레포지토리 전체를 오후 한나절이면 읽을 수 있습니다.


직접 사용해보세요

라이브 데모: postitup.varshithvhegde.in

소스 코드: github.com/Varshithvhegde/postitup {% github Varshithvhegde/postitup %}

보드를 만들고, 링크를 누군가와 공유해보세요. 실시간으로 메모가 나타나는 것을 지켜보세요.

뭔가 깨지거나 기능 아이디어가 있다면 이슈를 열어주세요. 기여하고 싶다면 PR(Pull Request)은 언제나 환영입니다. MIT 라이선스이므로 원하는 대로 사용하셔도 좋습니다.

만약 이 임베드를 어딘가에 사용하게 되신다면, 정말 보고 싶습니다. 댓글로 알려주시거나 varshithvh@gmail.com으로 이메일 보내주세요.


원문: https://dev.to/varshithvhegde/i-got-sick-of-miro-eating-10-minutes-of-every-retro-so-i-built-a-corkboard-for-the-web-41n9 수집일: 2026-06-05 02:04:14