우리의 CRM 칸반 내부: Monday.com을 Astro + Supabase로 다시 구축한 이유
우리는 Monday.com에 월 $48을 지불하고 있었습니다. 3석, Pro 플랜. 그리고 매주 팀의 누군가는 "이거 정말 싫어"라는 말을 할 텐데요. Monday.com이 나쁜 것이 아니기 때문입니다 — 정말 인상적인 소프트웨어입니다. 하지만 우리가 필요하지 않은 약 200가지를 하고 있었고 우리가 실제로 원하는 4가지를 거의 할 수 없었습니다. 그래서 우리는 의견이 많은 개발자로 가득한 에이전시가 할 만한 일을 했습니다: 우리 스스로 만들었습니다.
이것은 "우리가 Monday.com보다 똑똑하다"는 글이 아닙니다. "우리는 매우 구체적인 필요성, 이미 알고 있는 스택, 그리고 주말을 가지고 있었다"는 글입니다. Astro, Supabase, 그리고 SaaS 비대화에 대한 건전한 분노를 사용하여 맞춤형 CRM 칸반 보드를 구축한 전체 이야기입니다.
목차
- Monday.com이 우리에게 더 이상 작동하지 않은 이유
- 우리가 실제로 필요한 것
- 스택 선택: Astro + Supabase
- 데이터베이스 설계: 단순하게 유지하기
- 칸반 보드 구축
- Supabase Realtime으로 실시간 업데이트
- 인증 및 행 수준 보안
- 배포 및 호스팅 비용
- 다르게 할 것들
- 자주 묻는 질문

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에서 알 수 없었기 때문에 모두 답장했습니다. 알림 시스템이 충분히 명확하게 표시하지 않았고 "사람" 열에 자신을 추가하는 우리의 어설픈 해결 방법은 신뢰할 수 없었습니다. 그때 저는 Monday.com 대신 VS Code를 열었습니다.
우리가 실제로 필요한 것
코드를 작성하기 전에 우리는 약 1시간 동안 우리의 CRM이 정확히 무엇을 해야 하는지를 나열하는 데 보냈습니다. 좋을 것이 아니라. 실제로 필요한 것입니다.
목록은 다음과 같습니다:
- 거래 단계에 대한 열이 있는 칸반 보드: Lead → Contacted → Proposal → Negotiation → Won → Lost
- 다음을 표시하는 거래 카드: 연락처 이름, 거래 가치, 출처 태그(색상 코딩), 할당된 팀 멤버, 현재 단계에 머문 일수
- 순간적인 지속성으로 열 간 드래그 앤 드롭
- 거래 상세 보기 - 메모(마크다운), 연락처 정보, 간단한 활동 로그
- 실시간 동기화 - 보드를 보는 두 사람이 같은 상태를 봅니다
- 연락처 데이터베이스 - 기본 정보(이름, 이메일, 회사, 메모)
- 간단한 인증 — 팀만, 공개 액세스 없음
그것뿐입니다. Gantt 차트가 아닙니다. 시간 추적이 아닙니다. 자동화 엔진이 아닙니다. 47가지 다른 열 유형이 아닙니다. 실제 데이터베이스와 실제 관계에 의해 지원되는 칸반 보드일 뿐입니다.
스택 선택: Astro + Supabase
우리는 Astro 개발 가게이므로 Astro는 분명한 시작점이었습니다. 하지만 여기서 작동하는 이유를 설명할 가치가 있습니다. Astro의 "정적 사이트 생성기"라는 평판은 상당히 과소평가됩니다.
Astro 4.x 이후(그리고 지금 2025년에 5.x), 온디맨드 경로를 사용한 서버 측 렌더링이 일급 기능입니다. 당신은 전체 동적 애플리케이션을 만들 수 있습니다. 우리는 Astro의 하이브리드 렌더링 모드를 사용합니다: 대부분의 페이지는 요청 시 서버 렌더링되지만 로그인 페이지와 같은 것을 사전 렌더링할 수 있습니다.
칸반 보드 자체의 경우 React 아일랜드를 사용합니다. 이것은 이와 같은 앱에 대한 Astro의 킬러 기능입니다 — 애플리케이션의 셸(네브, 레이아웃, 인증 검사)은 JS가 0으로 서버 렌더링되고 칸반 보드는 client:load로 단일 대화형 아일랜드로 마운트됩니다.
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의 아일랜드 아키텍처는 비대화형 부분에 대해 더 적은 클라이언트 측 JS를 의미했습니다. |

데이터베이스 설계: 단순하게 유지하기
스키마에는 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()
);
-- Activity log: 간단한 추가 전용 로그
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()
);
거래의 stage_entered_at 필드는 제가 좋아하는 작은 결정 중 하나입니다. 거래가 새 단계로 이동할 때마다 이 타임스탬프를 업데이트합니다. 이렇게 하면 활동 로그를 쿼리하지 않고도 "현재 단계에 있는 일수"를 계산할 수 있습니다. 간단하고, 빠르고, 유용합니다.
position 필드는 칸반 열 내의 순서 지정을 처리합니다. 카드를 다른 카드 사이로 드래그할 때 새 위치 값을 계산합니다. 우리는 정수 간격(위치는 1000씩 증가)을 사용하므로 거의 재조정할 필요가 없습니다.
칸반 보드 구축
칸반 보드는 Astro 아일랜드로 마운트된 React 구성 요소입니다. 우리는 2025년 기준으로 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('Failed to move deal');
}
// 활동 로그
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입니다. 칸반 보드 자체는 클라이언트에서 수화됩니다. 이는 초기 페이지 로드가 빠르다는 것을 의미합니다 — 브라우저는 즉시 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를 사용합니다. 세 가지 계정. 가입 흐름이 없습니다 — 우리는 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 아키텍처를 적용했습니다.
다르게 할 것들
v1을 배포한 지 약 4개월이 지났습니다. 여기 내가 바꿀 것들입니다:
처음부터 Zustand 사용
우리는 상태 관리를 위해 React의 내장 useState와 useContext로 시작했습니다. realtime 동기화, 낙관적 업데이트, 롤백 로직을 추가했을 때 상태 관리 코드가 엉켰습니다. 2주 후 Zustand로 마이그레이션했습니다. 처음부터 시작했어야 했습니다.
검색 추가 더 일찍
우리는 3주차까지 검색을 구축하지 않았고 특정 거래를 위해 열을 수동으로 스캔하는 그 3주는 짜증났습니다. Supabase에 대한 간단한 ilike 쿼리는 30분이 걸렸을 것입니다.
키보드 단축키
여전히 추가하지 않았지만 원합니다. N을 눌러 새 거래를 만들고 /를 눌러 검색하고 1-6을 눌러 단계별로 필터링합니다. 하루에 여러 번 도구에 있을 때 합산되는 작은 것들입니다.
더 나은 모바일 보기
칸반 보드는 기술적으로 모바일에서 작동합니다. 하지만 6개의 열은 휴대전화 화면에 맞지 않습니다. 모바일을 위한 목록 보기가 필요합니다. 우리는 거의 휴대전화에서 CRM을 확인하지 않기 때문에 우선 순위를 정하지 않았지만 좋을 것 같습니다.
자주 묻는 질문
CRM 칸반 보드를 구축하는 데 얼마나 걸렸습니까? 처음 사용 가능한 버전은 주말과 저녁에 걸쳐 약 20시간이 걸렸습니다. 칸반 보드, 거래 세부 정보, 드래그 앤 드롭, 기본 인증을 얻었습니다. 우리는 그 이후로 검색, 더 나은 모바일 스타일, 버그 수정과 같은 개선 사항에 약 10시간을 더 보냈습니다.
동적 앱에 Next.js 대신 Astro를 사용한 이유는 무엇입니까? Astro의 아일랜드 아키텍처는 우리 앱의 비대화형 부분(레이아웃, 네브, 정적 페이지)이 JavaScript를 0으로 배송한다는 것을 의미합니다. 칸반 보드 자체는 로드 시 수화되는 React 아일랜드입니다. 대화형 표면 영역이 하나의 구성 요소에 초점을 맞춘 내부 도구의 경우 이것은 훌륭한 선택입니다. 우리는 페이지 전체에 대화형 작업이 더 분산되어 있는 클라이언트 프로젝트에 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은 WebSockets를 기반으로 하며 우리의 사용 사례(3명의 동시 사용자, 낮은 빈도의 업데이트)에서 본질적으로 즉각적입니다. 우리는 한 사용자가 카드를 드래그한 후 다른 사용자가 업데이트를 보는 종단 간 지연을 측정했습니다: 일반적으로 80-150ms입니다. 우리가 감지할 수 있는 것보다 빠릅니다.
Twenty나 Folk과 같은 오픈 소스 CRM 대안을 고려했습니까? 우리는 Twenty(2024년에 출시된 오픈 소스 CRM)를 살펴봤고 인상적입니다. 하지만 우리가 필요한 것보다 훨씬 더 많은 기능을 갖춘 전체 CRM이며 자체 호스팅하려면 더 많은 인프라가 필요합니다. 우리의 목표는 정확히 필요한 것을 구축하고 더 이상 구축하지 않는 것이었습니다. Twenty가 우리가 시작했을 때 존재했고 더 간단한 칸반 포커스 모드가 있었다면 그 경로를 가셨을 수도 있습니다.
클라이언트를 위해 커스텀 내부 도구를 구축하시겠습니까? 우리는 실제로 했습니다. 여러 클라이언트가 Monday.com, Notion 또는 Airtable의 특정 워크플로우를 위해 해킹되었을 때 우리에게 왔습니다. 우리는 일반적으로 프론트엔드를 위해 Astro 또는 Next.js와 백엔드를 위해 Supabase 또는 헤드리스 CMS로 이들을 구축합니다. 이것이 필요한 것처럼 들린다면 우리는 이야기해야 합니다.