Salesforce와 HubSpot을 Postgres, 스프레드시트, 그리고 AI Agent로 교체하기
요약
Salesforce와 HubSpot 같은 무거운 CRM 대신 PostgreSQL, 스프레드시트, 그리고 AI Agent를 결합하여 맞춤형 미니 CRM을 구축한 사례를 소개합니다. Claude Cowork를 활용해 데이터 스키마 설계부터 검색 기능 통합까지 단 몇 시간 만에 완료하는 과정을 다룹니다.
핵심 포인트
- 기존 CRM의 과잉 기능 대신 필요한 데이터 스키마를 직접 설계
- AI Agent를 활용한 스프레드시트 데이터의 SQL 스키마 자동 생성
- 활동 이력을 변경 불가능한 추가 전용(append-only) 방식으로 설계하여 히스토리 보존
- SWIRL을 이용한 연합 검색(Federated Search) 환경 구축
우리는 두 개의 실제 CRM을 사용해 왔습니다.
첫 번째는 Salesforce였습니다. 우리 회사의 창립 멤버인 영업 담당자가 필수적이라고 말했기 때문입니다. 그다음은 HubSpot이었습니다. 우리 회사의 창립 멤버인 마케팅 담당자가 필수적이라고 말했기 때문입니다. 두 사람 모두 파이프라인 (pipeline)을 추적하는 것이 필수적이라는 점에서는 옳았습니다... 하지만 실제로 우리 규모에서는 두 시스템 모두 제 역할을 다하지 못했습니다.
데이터가 (시스템을) 벗어나 실제로 어떤 모습이었는지를 보면, 그것은 스프레드시트였습니다: Engaged Prospects (관심 잠재 고객), Legal (법무), 그리고 Partners/Channels (파트너/채널)라고 라벨링된 세 개의 회사 이름 열이 전부였습니다. 100개 이상의 조직이 있었지만, 상태(status)도, 활동 이력(activity history)도, 다음 단계(next steps)도 없었습니다.
완전한 CRM은 과잉(overkill)임이 두 번이나 증명되었고, 스프레드시트는 부족(underkill)했습니다.
그래서 저는 우리가 이미 운영 중인 도구들인 로컬 PostgreSQL, 스프레드시트 자체, 그리고 검색을 위한 SWIRL을 사용하여 Claude Cowork로 오후 한나절 만에 구축한 미니 CRM으로 두 시스템을 모두 교체했습니다. 우리가 배포하고 발견한 버그를 포함하여 전체 구축 과정을 소개합니다.
1단계: 세 개의 열에서 스키마 (schema)로
에이전트(agent)는 openpyxl을 사용하여 스프레드시트를 읽고, 단순히 이름 목록을 나열하는 것이 아니라 실제 작업인 활동 추적에 맞춘 스키마를 제안했습니다:
CREATE TABLE organizations (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
...
즉시 효과를 본 두 가지 설계 결정은 다음과 같습니다:
- 활동(Activities)은 변경 불가능한 추가 전용(append-only) 이력이며, 수정 가능한 "최근 접촉(last touched)" 필드가 아닙니다. 이를 통해 계정의 히스토리를 절대 놓치지 않습니다.
- 상태(Status)는 자유 형식의 텍스트(free-form text)입니다. 우리는 제안된 어휘 목록으로 시작했지만, 첫 번째 실제 업데이트는 목록에 없던 "dead"였습니다. 상태에 CHECK 제약 조건(constraint)을 걸었다면 단 한 단어의 지시사항이 스키마 마이그레이션(schema migration)으로 이어졌을 것입니다. 조인(join)을 깨뜨리는 것들(카테고리)은 제한하되, 어휘는 인간에게 맡기십시오.
두 개의 뷰(view)가 일상 업무의 대부분을 처리합니다:
-- 조직별 최신 활동
CREATE VIEW org_latest_activity AS
SELECT o.id, o.name, o.category, o.status,
...
멱등성(Idempotent)을 가진 로더 스크립트는 세 개의 스프레드시트 컬럼에서 모든 이름을 추출하여 적절한 카테고리와 함께 삽입했습니다. ON CONFLICT (name, category) DO NOTHING 구문 덕분에 스프레드시트가 확장된 후 스크립트를 다시 실행해도 항상 안전합니다.
2단계: SWIRL을 사용하여 검색 가능하게 만들기
우리는 연합 검색 (Federated Search)을 위해 SWIRL을 사용하므로, 당연한 다음 단계는 CRM을 다른 모든 데이터와 동일한 검색창으로 통합하는 SearchProvider를 만드는 것이었습니다.
먼저, 단일 ILIKE 스캔으로 매칭할 가치가 있는 모든 항목을 훑을 수 있도록 비정규화된 뷰 (Denormalized view)를 생성합니다:
CREATE VIEW crm_search AS
SELECT o.id, o.name, o.category, o.status,
COALESCE(o.notes, '') AS notes,
...
그 다음은 프로바이더 (Provider)입니다. SWIRL의 PostgreSQL 커넥터는 매핑된 필드를 포함한 쿼리 템플릿을 가져갑니다:
{
"name": "Mini-CRM - PostgreSQL (SQL)",
"connector": "PostgreSQL",
...
회사 이름, 상태 ("won"), 카테고리 ("partner"), 또는 통화 요약의 문구를 검색하면 SWIRL의 관련성 파이프라인 (Relevancy pipeline)에 따라 다른 모든 소스와 함께 순위가 매겨지며 즉시 작동합니다.
result_mappings 라인은 언급할 만한 한 차례의 반복 과정을 거쳤습니다. 일반적인 SQL 프로바이더 패턴은 result_mappings: "DATASET"인데, 이는 모든 행을 테이블 페이로드 (Table payload)를 가진 단일 결과로 압축합니다. 분석용으로는 괜찮지만, CRM용으로는 잘못된 방식입니다. CRM에서는 각 계정(Account)이 상태와 다음 단계가 보이는 개별 결과로 나타나야 하기 때문입니다. 명시적인 템플릿 매핑 (Explicit template mappings)으로 전환하면 각 조직(Org)이 고유한 카드 형태로 표시됩니다:
Acme Manufacturing (engaged_prospect / active)
최근 활동: 검색 POC 관련 소개 통화 (call, 2026-07-15).
다음 단계: 범위 정의 문서 송부 (기한: 2026-07-25). ...
date_published=updated로 매핑하면 날짜 정렬이 "알 수 없음"이 아닌 실제 날짜 기반으로 작동하게 됩니다.
LLM 시대를 위한 한 가지 세부 사항이 더 있습니다. SWIRL 제공업체는 설정(config)에 query_instructions를 포함할 수 있으며, 이는 SWIRL의 어시스턴트나 MCP 서버를 통해 소스를 쿼리할 때 LLM에 전달됩니다. 저희의 설정에는 전체 스키마(schema), 열거된 카테고리/상태(category/status) 값, 그리고 5가지 SQL 템플릿(활동 내역, 예정된 후속 조치, 파이프라인 수량)이 문서화되어 있습니다. 이를 통해 "이번 주에 주의가 필요한 사항은 무엇인가요?"라는 질문은 키워드 추측이 아닌, followups_due를 대상으로 하는 실제 SQL로 변환됩니다.
3단계: 아무도 싫어하지 않는 업데이트 루프
데이터 입력에 대한 첫 번째 계획은 대화형이었습니다. 에이전트가 각 계정을 돌며 무슨 일이 있었는지 묻는 방식이었죠. 하지만 단 한 번의 답변 이후 현실의 벽에 부딪혀 실패했습니다. 100개 이상의 업데이트를 하나씩 말로 불러주는 것은 고역입니다.
해결책: 스프레드시트를 편집 인터페이스로 유지하는 것입니다. 에이전트는 데이터베이스에서 미리 채워진, 조직당 한 행씩 구성된 "Tracking"이라는 두 번째 시트를 추가했습니다:
Name | Category | Status | Activity Date | Activity Type | Activity Summary | Next Step | Next Step Due | Notes
어떤 셀이든 편집하고, 저장한 뒤, 동기화(sync)를 실행하세요:
- 상태(Status)나 메모(Notes)가 DB와 다를 경우: 해당 조직(org) 정보가 업데이트됩니다.
- 활동 요약(Activity Summary)이 채워져 있고 조직의 최신 활동과 다를 경우: 새로운 활동 행이 삽입됩니다. 이력은 계속 누적되지만, 스프레드시트에는 항상 최신 정보만 표시됩니다.
- 카테고리와 함께 새로운 이름이 있는 경우: 조직이 생성됩니다.
- 스크립트는 절대 아무것도 삭제하지 않으며, 연속으로 두 번 실행해도 아무런 작업(no-op)이 일어나지 않습니다.
SQL에서 직접 대량 변경을 수행한 후에는 데이터베이스로부터 시트가 다시 생성되므로, 두 데이터가 충돌하는 일이 발생하지 않습니다.
대량 업데이트의 경우, 평범한 영어 문장이 두 인터페이스(DB 및 스프레드시트) 모두를 이기는 것으로 나타났습니다. "이 계정들을 제외하고 나머지는 모두 종료(dead)로 표시해줘. 파트너 X를 통해 이 새로운 딜(deal)을 수주(won)로 추가해줘"라는 문장은 하나의 검토된 트랜잭션(transaction)이 되었습니다. 에이전트는 이를 적용하고, 결과적인 파이프라인 수량을 출력하며, 시트를 새로고침합니다.
버그: 이름은 키(key)가 아니다
동기화 스크립트의 첫 번째 버전은 조직(organization)을 이름(name)으로 키(key)를 지정했습니다. 우리 데이터 중 한 회사는 실제로 두 가지 카테고리에 모두 존재하는데(서비스 기업이자 채널 파트너임), 이것이 바로 테이블의 유니크 제약 조건(unique constraint)이 (name, category)인 정확한 이유입니다.
스크립트의 인메모리 상태 딕셔너리(in-memory state dict)는 두 행 중 하나만을 조용히 유지했고, UPDATE ... WHERE name = X 구문은 두 행 모두에 영향을 미쳤습니다. 결과적으로, 시트를 동기화하면 대량 업데이트(bulk update)로 방금 삭제 처리된 행이 다시 살아나는 현상이 발생했습니다.
이 문제는 한 가지 이유로 즉시 드러났습니다. 동기화 과정에서 변경 보고서(change report)를 출력하는데, 우리는 변경 사항이 0(zero)일 것이라고 예상했기 때문입니다. 새로고침 후 동기화하는 사이클은 완벽한 no-op(아무 작업도 수행하지 않음) 상태여야 하지만, 대신 "1개의 조직이 업데이트됨"이라고 보고되었습니다. 그 예상치 못한 단 한 줄의 출력 결과가 전체 탐지 메커니즘이 되었습니다. 해결책은 기계적이었습니다(이름과 카테고리를 결합하여 키를 지정하고, 모든 UPDATE와 INSERT의 범위를 동일한 방식으로 설정함). 그리고 no-op이 실제로 구현될 때까지 재실행하여 검증했습니다.
만약 여러분의 로더(loader)가 멱등성(idempotent)을 갖추고 있다면, "다시 실행하고 변경 사항이 0임을 요구하라"는 것이 여러분이 작성할 수 있는 가장 저렴한 통합 테스트(integration test)가 될 것입니다.
최종 결과물
psql명령 한 번으로 접근 가능한, 전체 활동 이력이 포함된 Postgres 데이터베이스.- 이제 데이터베이스가 아닌 UI(사용자 인터페이스)로서 기능하는 스프레드시트.
- 연합 검색(federated search)에서 일급 객체(first-class) 결과로 나타나는 CRM 계정, 그리고 카드에 표시되는 다음 단계 및 상태.
- LLM 쿼리가 가능한 소스: SWIRL의 MCP 서버를 통한 스키마 인식(schema-aware) SQL.
- 실행 전 읽을 수 있는 SQL을 사용하는, 대량 작업을 위한 자연어 관리 루프(natural-language admin loop).
전체 스키마: 3개의 테이블, 3개의 뷰(view), ORM(Object-Relational Mapping) 없음, 그리고 취소된 2개의 CRM 구독.
Claude Cowork가 스키마, 로더, 프로바이더, 그리고 버그까지 작성했습니다. 변경 보고서가 버그를 잡아냈고, 인간은 그저 질문에 답하고 셀을 편집했을 뿐입니다.
이 시스템이 50인 규모의 영업 팀으로 확장될 수 있을까요? 아니요, 그리고 그럴 의도도 없습니다. 100개 계정 규모의 창업자 주도 파이프라인(founder-led pipeline)을 위해서는, 이 지루한 스택(boring stack)을 이기기 어렵습니다. 모든 구성 요소는 검사 가능하고, 모든 업데이트는 읽을 수 있는 SQL 문이며, 검색창은 이미 어디를 찾아봐야 할지 알고 있기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기