청구서가 도착합니다: Monday.com $48/월. 3개 시트, Pro 플랜. 그리고 매 스프린트 검토마다 누군가 같은 말을 중얼거립니다 — "이거 정말 싫어." 소프트웨어가 고장 났기 때문이 아닙니다. Monday.com은 정말 인상적입니다. 단지 파이프라인에 필요 없는 200개 기능을 할 뿐 실제로 운영하는 4개 워크플로우에는 부족합니다. 컬럼이 영업 프로세스 방식으로 정렬되지 않습니다. 자동화가 두 번 실행됩니다. 모바일 뷰는 계정 관리자가 휴대폰을 던지고 싶게 만듭니다. 그래서 우리는 Supabase 크레딧이 있고 의견이 많은 모든 개발 팀이 하는 일을 했습니다: 재구축했습니다. 11일, 1,200줄의 Astro, 후회 없음. 여기 스키마, 드래그 컨트롤러, 그리고 CFO를 미소 짓게 만든 청구서가 있습니다.

이것은 "우리가 Monday.com보다 똑똑하다"는 포스트가 아닙니다. "우리는 매우 구체적인 요구사항, 이미 알고 있는 스택, 그리고 주말을 가지고 있었다"는 포스트입니다. Astro, Supabase, 그리고 SaaS 비대화에 대한 건강한 원망을 사용하여 커스텀 CRM 칸반 보드를 구축한 전체 이야기입니다.

목차

우리의 CRM 칸반 내부: Astro + Supabase에서 Monday.com을 재구축한 이유

Monday.com이 우리에게 작동을 멈춘 이유

우리의 불만을 구체적으로 설명하겠습니다. SaaS 도구에 대한 모호한 불평은 쓸모가 없기 때문입니다.

문제 1: 데이터 모델이 우리에게 저항했습니다. Monday.com은 "보드"와 "항목" 그리고 "컬럼"으로 생각합니다. 우리 에이전시는 거래, 연락처, 프로젝트 — 그 사이의 관계가 있는 세 개의 별개 엔티티로 생각합니다. 거래가 연락처를 참조해야 하고 프로젝트가 거래를 참조해야 했습니다. Monday.com은 링크된 컬럼으로 이것을 어느 정도 할 수 있지만 어색합니다. 새 거래를 생성할 때마다 올바른 연락처에 수동으로 링크해야 했습니다. 사람들이 잊었습니다. 데이터가 지저분해졌습니다.

문제 2: 칸반 뷰가 우리가 원하는 것을 할 수 없었습니다. 우리는 거래가 단계별로 그룹화되고 출처(추천, 유기, 아웃바운드)별로 색상으로 코드화된 것을 보고 싶었습니다. Monday.com의 칸반 뷰는 한 상태 컬럼으로 그룹화할 수 있습니다. 그게 전부입니다. 명명 규칙으로 해킹하지 않고는 두 번째 시각적 차원을 레이어할 수 없습니다.

문제 3: 속도. 이건 주관적이지만 Monday.com은 우리가 하고 있는 것에 비해 느렸습니다. 거래를 클릭하고 사이드 패널이 로드될 때까지 기다리고 사용하지 않는 필드를 스크롤 지나 필요한 메모를 찾으세요. 모든 상호작용에는 마찰처럼 느껴지는 충분한 지연이 있었습니다.

문제 4: 비용 궤적. 3명에게 $48/월은 비싸지 않습니다. 하지만 우리는 네 번째 팀 멤버를 고려하고 있었고 Monday.com의 가격은 5개 시트의 Pro 플랜에서 $60/월로 뛰어올랐습니다 (4개를 구매할 수 없음). 이것은 적극적으로 불평하던 도구에 대한 연간 $720입니다.

전환점

실제 트리거는 부끄럽게도 평범했습니다. 잠재 고객이 우리에게 이메일을 보냈고 두 팀 멤버가 모두 답장했습니다. 왜냐하면 아무도 Monday.com에서 누가 리드를 "청구"했는지 알 수 없었기 때문입니다. 알림 시스템이 충분히 명확하게 표시하지 않았고 자신을 "People" 컬럼에 추가하는 것의 해킹 우회법은 신뢰할 수 없었습니다. 그것이 나가 VS Code를 대신 Monday.com을 열었을 때입니다.

실제로 필요한 것

코드를 작성하기 전에 우리의 CRM이 정확히 무엇을 해야 하는지 나열하는 데 약 1시간을 보냈습니다. 좋을 것이 아니라. 실제로 필요한 것.

목록은 다음과 같습니다:

  1. 칸반 보드 - 거래 단계에 대한 컬럼: Lead → Contacted → Proposal → Negotiation → Won → Lost
  2. 거래 카드 표시: 연락처 이름, 거래 가치, 출처 태그 (색상 코드), 할당된 팀 멤버, 현재 단계의 일 수
  3. 드래그 앤 드롭 - 컬럼 간 이동 및 즉시 지속성
  4. 거래 상세 뷰 - 메모 (마크다운), 연락처 정보, 간단한 활동 로그
  5. 실시간 동기화 - 보드를 보고 있는 두 사람이 같은 상태를 봅니다
  6. 연락처 데이터베이스 - 기본 정보 (이름, 이메일, 회사, 메모)
  7. 간단한 인증 — 우리 팀만, 공개 접근 없음

그게 다입니다. Gantt 차트 없음. 시간 추적 없음. 자동화 엔진 없음. 47개의 다른 컬럼 타입 없음. 실제 데이터베이스와 실제 관계를 지원하는 칸반 보드만.

스택 선택: Astro + Supabase

우리는 Astro 개발 상점이므로 Astro는 명백한 시작점이었습니다. 하지만 Astro의 명성이 "정적 사이트 생성기"로 실질적으로 과소평가되기 때문에 실제로 왜 의미가 있는지 설명할 가치가 있습니다.

Astro 4.x 이후 (그리고 2026년 현재 5.x), 서버 측 렌더링과 온디맨드 라우트는 일류 기능입니다. 완전한 동적 애플리케이션을 구축할 수 있습니다. 우리는 Astro의 하이브리드 렌더링 모드를 사용합니다: 대부분 페이지는 요청 시 서버 렌더링되지만 로그인 페이지와 같은 것은 여전히 미리 렌더링할 수 있습니다.

대화형 칸반 보드 자체의 경우 React island를 사용합니다. 이것이 Astro의 이 같은 앱에 대한 킬러 기능입니다 — 애플리케이션 셸 (네비게이션, 레이아웃, 인증 확인)은 0 JS로 서버 렌더링되고 칸반 보드는 client:load를 가진 단일 대화형 island로 마운트됩니다.

Supabase는 여러 가지 이유로 데이터베이스 선택이었습니다:

기능 중요한 이유
Postgres 기반 실제 관계형 데이터베이스, 실제 외래 키, 실제 쿼리
Realtime 구독 라이브 업데이트를 위한 빌트인 WebSocket 지원
행 수준 보안 (RLS) 앱 수준이 아닌 데이터베이스 수준의 인증 규칙
JS 클라이언트 라이브러리 깔끔한 API, 좋은 TypeScript 지원
무료 계획 우리 사용량은 Supabase의 무료 계획에 편하게 맞습니다
자체 호스팅 옵션 무료 계획을 초과하면 자체 실행할 수 있습니다

우리는 다른 옵션을 잠깐 고려했습니다:

옵션 거부한 이유
Firebase / Firestore NoSQL은 관계형 데이터를 어색하게 만듭니다. 이전에 화상을 입었습니다.
PlanetScale 좋지만 빌트인 realtime이 없습니다. 별도 WebSocket 솔루션이 필요합니다.
Neon + Prisma 견고한 조합이지만 Supabase는 하나에서 인증 + realtime + DB를 제공합니다.
Next.js에서 구축 우리는 Next.js를 잘 알고 있습니다 (정기적으로 구축), 하지만 내부 도구의 경우 Astro의 island 아키텍처는 대화형이 아닌 부분을 위해 더 적은 클라이언트 측 JS를 의미했습니다.

우리의 CRM 칸반 내부: Astro + Supabase에서 Monday.com을 재구축한 이유 - 아키텍처

데이터베이스 설계: 단순하게 유지

스키마에는 4개의 테이블이 있습니다. 그게 전부입니다.

-- Contacts: 우리가 대화하는 사람과 회사
create table contacts (
  id uuid default gen_random_uuid() primary key,
  name text not null,
  email text,
  company text,
  phone text,
  notes text,
  created_at timestamptz default now()
);

-- Deals: 우리 칸반 보드의 파이프라인 항목
create table deals (
  id uuid default gen_random_uuid() primary key,
  contact_id uuid references contacts(id) on delete set null,
  title text not null,
  value integer, -- 센트 단위로 저장됨
  stage text not null default 'lead'
    check (stage in ('lead', 'contacted', 'proposal', 'negotiation', 'won', 'lost')),
  source text
    check (source in ('referral', 'organic', 'outbound', 'repeat', 'other')),
  assigned_to uuid references auth.users(id),
  position integer not null default 0, -- 컬럼 내 순서
  stage_entered_at timestamptz default now(),
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Activities: 간단한 추가 전용 로그
create table activities (
  id uuid default gen_random_uuid() primary key,
  deal_id uuid references deals(id) on delete cascade,
  user_id uuid references auth.users(id),
  action text not null, -- 'stage_change', 'note', 'created' 등.
  details jsonb,
  created_at timestamptz default now()
);

-- Deal notes: 거래에 첨부된 마크다운 메모
create table deal_notes (
  id uuid default gen_random_uuid() primary key,
  deal_id uuid references deals(id) on delete cascade,
  user_id uuid references auth.users(id),
  content text not null,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

deals의 stage_entered_at 필드는 내가 좋아하는 작은 결정 중 하나입니다. 거래가 새 단계로 이동할 때마다 이 타임스탠프를 업데이트합니다. 그것은 활동 로그를 쿼리하지 않고 "현재 단계의 일 수"를 계산하게 합니다. 단순하고, 빠르며, 유용합니다.

position 필드는 칸반 컬럼 내 순서를 처리합니다. 카드를 두 카드 사이로 드래그할 때 새 위치 값을 계산합니다. 우리는 정수 간격 (위치는 1000으로 증가)을 사용하므로 재조정할 필요가 거의 없습니다.

칸반 보드 구축

칸반 보드는 Astro island로 마운트된 React 구성 요소입니다. 2026년 기준 React 생태계에서 가장 접근 가능하고 잘 유지되는 DnD 라이브러리이기 때문에 드래그 앤 드롭에 @dnd-kit/core를 사용했습니다.

여기가 단순화된 구조입니다:

// src/components/KanbanBoard.tsx
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { useState } from 'react';
import { KanbanColumn } from './KanbanColumn';
import { DealCard } from './DealCard';
import { useDeals } from '../hooks/useDeals';
import { useRealtimeDeals } from '../hooks/useRealtimeDeals';

const STAGES = ['lead', 'contacted', 'proposal', 'negotiation', 'won', 'lost'];

const SOURCE_COLORS: Record<string, string> = {
  referral: '#10b981',
  organic: '#3b82f6',
  outbound: '#f59e0b',
  repeat: '#8b5cf6',
  other: '#6b7280',
};

export function KanbanBoard() {
  const { deals, moveDeal } = useDeals();
  const [activeId, setActiveId] = useState<string | null>(null);

  // Realtime 변경에 구독
  useRealtimeDeals();

  const handleDragEnd = async (event) => {
    const { active, over } = event;
    if (!over) return;

    const dealId = active.id;
    const newStage = over.data.current?.stage;
    const newPosition = over.data.current?.position;

    if (newStage) {
      await moveDeal(dealId, newStage, newPosition);
    }
    setActiveId(null);
  };

  return (
    <DndContext onDragStart={({ active }) => setActiveId(active.id)} onDragEnd={handleDragEnd}>
      <div className="kanban-grid">
        {STAGES.map((stage) => (
          <KanbanColumn
            key={stage}
            stage={stage}
            deals={deals.filter((d) => d.stage === stage)}
            sourceColors={SOURCE_COLORS}
          />
        ))}
      </div>
      <DragOverlay>
        {activeId ? <DealCard deal={deals.find((d) => d.id === activeId)} overlay /> : null}
      </DragOverlay>
    </DndContext>
  );
}

moveDeal 함수는 낙관적 업데이트를 수행합니다 — 즉시 로컬 상태를 업데이트한 다음 Supabase로 업데이트를 보냅니다. 데이터베이스 업데이트가 실패하면 롤백됩니다. 이것이 보드를 즉시 느끼게 합니다.

const moveDeal = async (dealId: string, newStage: string, newPosition: number) => {
  // 낙관적 업데이트
  setDeals((prev) =>
    prev.map((d) =>
      d.id === dealId
        ? { ...d, stage: newStage, position: newPosition, stage_entered_at: new Date().toISOString() }
        : d
    )
  );

  const { error } = await supabase
    .from('deals')
    .update({
      stage: newStage,
      position: newPosition,
      stage_entered_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
    })
    .eq('id', dealId);

  if (error) {
    // 롤백 — 서버에서 다시 가져오기
    await refreshDeals();
    toast.error('거래를 이동하지 못했습니다');
  }

  // 활동 로그
  await supabase.from('activities').insert({
    deal_id: dealId,
    user_id: currentUser.id,
    action: 'stage_change',
    details: { from: previousStage, to: newStage },
  });
};

이를 호스팅하는 Astro 페이지는 최소한입니다:

---
// src/pages/board.astro
import Layout from '../layouts/App.astro';
import { KanbanBoard } from '../components/KanbanBoard';
import { getSession } from '../lib/auth';

const session = await getSession(Astro.request);
if (!session) return Astro.redirect('/login');
---

<Layout title="Deal Board">
  <KanbanBoard client:load />
</Layout>

client:load 지시문이 무거운 리프팅을 하고 있습니다. 레이아웃, 네비게이션, 페이지 셸은 모두 서버 렌더링된 HTML입니다. 칸반 보드 자체는 로드 시 하이드레이트되는 React island입니다. 이는 초기 페이지 로드가 빠름을 의미합니다 — 브라우저는 즉시 HTML을 받으면 대화형 보드가 바로 부팅됩니다.

Supabase Realtime으로 실시간 업데이트

이것이 Supabase를 이 프로젝트에 명확한 승자로 만든 기능입니다. 한 팀 멤버가 거래를 이동하면 다른 팀 멤버는 실시간으로 움직이는 것을 봅니다. 새로고침 필요 없음.

// src/hooks/useRealtimeDeals.ts
import { useEffect } from 'react';
import { supabase } from '../lib/supabase';
import { useDealsStore } from '../stores/deals';

export function useRealtimeDeals() {
  const { updateDealLocally, addDealLocally, removeDealLocally } = useDealsStore();

  useEffect(() => {
    const channel = supabase
      .channel('deals-changes')
      .on(
        'postgres_changes',
        { event: '*', schema: 'public', table: 'deals' },
        (payload) => {
          switch (payload.eventType) {
            case 'UPDATE':
              updateDealLocally(payload.new);
              break;
            case 'INSERT':
              addDealLocally(payload.new);
              break;
            case 'DELETE':
              removeDealLocally(payload.old.id);
              break;
          }
        }
      )
      .subscribe();

    return () => {
      supabase.removeChannel(channel);
    };
  }, []);
}

한 가지 함정: 당신이 거래를 이동하면 realtime 구독을 통해 자신의 변경 사항을 다시 받습니다. 조심하지 않으면 카드가 점프하는 시각적 결함이 발생합니다. 우리는 낙관적 업데이트에 타임스탠프를 태그하고 최근 로컬 변경과 일치하는 realtime 이벤트를 무시함으로써 이를 처리합니다. 코드 몇 줄이 더 있지만 UX가 견고하게 느껴집니다.

인증 및 행 수준 보안

이것이 내부 도구이므로 인증은 간단합니다. Supabase Auth를 이메일/비밀번호와 함께 사용합니다. 3개 계정. 가입 흐름이 없음 — Supabase 대시보드에서 수동으로 계정을 생성했습니다.

행 수준 보안은 흥미로워집니다. 이것이 내부 도구이지만 RLS는 애플리케이션 코드를 엉망으로 만들어도 데이터베이스가 실수로 데이터를 유출하지 않음을 의미합니다.

-- 인증된 사용자만 거래를 볼 수 있음
alter table deals enable row level security;

create policy "Authenticated users can read all deals"
  on deals for select
  to authenticated
  using (true);

create policy "Authenticated users can insert deals"
  on deals for insert
  to authenticated
  with check (true);

create policy "Authenticated users can update deals"
  on deals for update
  to authenticated
  using (true);

create policy "Authenticated users can delete deals"
  on deals for delete
  to authenticated
  using (true);

네, 이 정책들은 허용적입니다 — 인증된 모든 사용자가 뭔든 할 수 있습니다. 3인 팀의 경우 괜찮습니다. 역할 기반 권한이 필요한 크기로 성장하면 RLS 인프라가 이미 있습니다. 우리는 정책을 조이기만 하면 됩니다.

배포 및 호스팅 비용

재미있는 부분입니다. 돈을 이야기해봅시다.

서비스 플랜 월간 비용
Supabase 무료 계획 $0
Vercel (Astro SSR 호스팅) Pro 플랜 (이미 있음) $0 증분
도메인 기존 도메인의 서브도메인 $0
합계 $0/월

우리는 이미 클라이언트 프로젝트를 위해 Vercel Pro 플랜을 사용하고 있으므로 하나 더 SSR 앱을 배포하면 우리에게 추가 비용이 들지 않습니다. Supabase의 무료 계획은 500MB 데이터베이스 스토리지, 월 50,000 활성 사용자 (우리는 3명), 그리고 realtime 연결을 제공합니다. 우리는 무료 계획 용량의 약 1%를 사용하고 있습니다.

Monday.com과 비교하세요:

Monday.com 우리의 커스텀 CRM
월간 비용 $48 (3 시트, Pro) $0
연간 비용 $576 $0
구축 시간 0 시간 ~20 시간
유지 보수 0 시간/월 ~1 시간/월

우리의 내부 시간당 요금으로 20시간의 개발 시간은 연간 $576보다 훨씬 더 가치가 있습니다. 하지만 그 수학은 요점을 놓칩니다. 우리는 부분적으로 원해서, 부분적으로 우리의 특정 워크플로우에 더 나은 도구이기 때문에, 부분적으로 그 20시간이 우리가 이후 클라이언트 프로젝트에서 사용한 것들을 배웠기 때문에 이것을 구축했습니다. 우리는 이후 헤드리스 CMS 지원 애플리케이션을 위해 비슷한 Astro + Supabase 아키텍처를 클라이언트를 위해 구축했습니다.

다르게 할 것들

배포 이후 약 4개월입니다. 여기가 바꿀 것들입니다:

처음부터 Zustand 사용

우리는 React의 빌트인 useState와 useContext로 상태 관리를 시작했습니다. realtime 동기화, 낙관적 업데이트, 롤백 로직을 추가할 때쯤에는 상태 관리 코드가 엉켜 있었습니다. 우리는 2주 후 Zustand로 마이그레이션했습니다. 처음부터 여기서 시작했어야 했습니다.

검색을 더 일찍 추가

우리는 3주까지 검색을 구축하지 않았고 특정 거래를 위해 수동으로 컬럼을 스캔하는 그 3주는 짜증났습니다. Supabase의 간단한 ilike 쿼리는 구현하는 데 30분이 걸렸을 것입니다.

키보드 단축키

여전히 추가하지 않았지만 원합니다. N을 눌러 새 거래를 만들고, /를 눌러 검색하고, 1-6을 눌러 단계별로 필터링합니다. 하루에 여러 번 도구를 사용할 때 더해지는 작은 것들.

더 나은 모바일 뷰

칸반 보드는 기술적으로 모바일에서 작동합니다. 하지만 6개 컬럼은 휴대폰 화면에 맞지 않습니다. 모바일을 위한 리스트 뷰가 필요합니다. 우리가 거의 휴대폰에서 CRM을 확인하지 않기 때문에 우선순위를 지정하지 않았지만 좋을 것입니다.

FAQ

CRM 칸반 보드를 구축하는 데 얼마나 걸렸나요?

첫 번째 사용 가능한 버전은 주말과 저녁 시간 동안 약 20시간이 걸렸습니다. 그것이 우리에게 칸반 보드, 거래 상세 정보, 드래그 앤 드롭, 기본 인증을 주었습니다. 그 이후로 검색, 더 나은 모바일 스타일, 버그 수정과 같은 개선 사항에 아마도 10시간을 더 보냈습니다.

동적 앱을 위해 Next.js 대신 Astro를 선택한 이유는 무엇입니까?

Astro의 island 아키텍처는 우리 앱의 비대화형 부분 (레이아웃, 네비게이션, 정적 페이지)이 0 JavaScript를 배송함을 의미합니다. 칸반 보드 자체는 로드 시 하이드레이트되는 React island입니다. 대화형 표면적이 한 구성 요소에 집중된 내부 도구의 경우 이것이 훌륭한 맞춤입니다. 우리는 클라이언트 프로젝트를 위해 Next.js를 사용합니다 — 페이지 전체에 대화형이 더 분산되어 있습니다.

Supabase의 무료 계획은 정말 CRM에 충분합니까?

작은 팀의 경우, 절대적으로. 우리는 약 200개의 거래, 150개의 연락처, 수천 개의 활동 로그 항목이 있습니다. 그것은 킬로바이트의 데이터입니다. Supabase의 무료 계획은 500MB의 스토리지를 제공하는데 우리는 몇 년 동안 도달하지 않을 것입니다. realtime 연결 한계도 관대합니다 — 무료 계획에서 최대 200개의 동시 연결을 얻습니다.

백업은 어떻습니까?

Supabase는 Pro 플랜에서 일일 백업 ($25/월)을 포함하지만 우리는 무료 계획에 있습니다. 우리는 이미 있던 $5/월 VPS에서 cron 작업을 통해 주간 pg_dump를 실행합니다. 화려하지 않지만 작동합니다. 또한 잘못된 것이 발생하면 복원할 수 있는 Supabase 프로젝트 클론이 있습니다.

이 접근 방식이 3명보다 큰 팀에 대해 작동할 수 있습니까?

아마도 10-15명 정도까지 이것이 더 타이트한 RLS 정책과 일부 역할 기반 UI 로직으로 작동할 것이라고 생각합니다. 그 이상으로 자동화, 커스텀 워크플로우, 보고와 같은 기능을 원하기 시작할 것이고 그것은 심각한 엔지니어링 노력이 걸릴 것입니다. 그 지점에서 전용 CRM 도구가 더 의미가 있습니다 — 그냥 아마도 Monday.com이 아닙니다.

실시간 동기화는 어떻게 작동합니까?

Supabase Realtime은 내부적으로 WebSocket을 사용하며 우리 사용 사례 (3명의 동시 사용자, 낮은 빈도 업데이트)에서 본질적으로 즉시입니다. 우리는 한 사용자가 카드를 드래그하는 것에서 다른 사용자가 업데이트를 보는 것까지 엔드투엔드 지연을 측정했습니다: 일반적으로 80-150ms. 그것은 우리가 인지할 수 있는 것보다 빠릅니다.

Twenty나 Folk과 같은 오픈 소스 CRM 대안을 고려했습니까?

우리는 2024년에 출시된 오픈 소스 CRM인 Twenty를 살펴봤고 인상적입니다. 하지만 우리가 필요한 것보다 훨씬 더 많은 기능이 있는 전체 CRM이고 자체 호스팅하려면 더 많은 인프라가 필요합니다. 우리의 목표는 정확히 우리가 필요한 것을 구축하고 그 이상은 아무것도 하지 않는 것이었습니다. Twenty가 더 단순한 칸반 중심 모드를 가진 우리가 시작할 때 존재했다면 우리가 그 길을 갔을 수도 있습니다.

클라이언트를 위해서도 커스텀 내부 도구를 구축합니까?

We have, actually. 여러 클라이언트가 Monday.com, Notion, 또는 Airtable을 특정 워크플로우에 대해 초과하는 것을 멈춘 후 우리에게 왔습니다. 우리는 일반적으로 프론트엔드의 Astro 또는 Next.js와 백엔드의 Supabase 또는 헤드리스 CMS로 이들을 구축합니다. 그것이 당신이 필요한 것 같으면 우리가 대화해야합니다.