이 레포가 무엇을 하는 물건인가.
고객은 웹사이트 채팅 위젯에서, 누구는 인스타 DM에서, 누구는 왓츠앱에서, 누구는 이메일로 문의한다. chatwoot은 이 제각각인 입력 통로(채널)를 전부 받아서, 상담원에게는 "대화(conversation) → 메시지(message)"라는 통일된 모델로 보여 준다. 상담원이 답을 쓰면, chatwoot이 다시 알아서 그 고객이 쓰던 원래 채널의 형식으로 변환해 돌려보낸다.
즉 chatwoot의 본질은 "N개의 서로 다른 메시징 API를 하나의 받은편지함으로 정규화(normalize)하는 어댑터 계층 + 실시간 협업 대시보드"다. 자체호스팅이라 고객 데이터를 내 서버에 둔다는 점이 SaaS인 Intercom과 갈라지는 결정적 지점이다.
chatwoot은 옴니채널(omnichannel) 헬프데스크다. 라이브챗 위젯, 이메일, Facebook/Instagram, WhatsApp, Telegram, Line, SMS(Twilio), 보이스(Twilio Voice) 등 채널을 단일 인박스로 통합하고, 자동 배정·라벨·캔드응답(canned response)·자동화 규칙·매크로·CSAT 설문·헬프센터 포털·리포팅까지 고객지원에 필요한 거의 모든 기능을 갖췄다. 최근 버전은 Captain(AI 에이전트)으로 자주 묻는 질문을 자동 응대하고, Copilot으로 상담원을 보조한다.
트렌딩 이유 · Intercom/Zendesk 등 경쟁 대비 장점.
고객지원 SaaS(Intercom·Zendesk)는 강력하지만 좌석당 월 구독료가 비싸고, 고객 데이터가 외부 서버에 쌓인다. chatwoot이 꾸준히 별을 모으는 이유는 ① 자체호스팅으로 데이터 주권을 가져오면서 ② 기능은 상용에 근접하고 ③ 최근 Captain AI 에이전트로 "AI 고객지원" 흐름에 올라탔기 때문이다. MIT 라이선스 코어 + 엔터프라이즈 모듈(별도 라이선스)의 오픈코어 모델이다.
| 비교 항목 | Intercom / Zendesk (SaaS) | chatwoot |
|---|---|---|
| 호스팅 | 벤더 클라우드 전용 | 자체호스팅(Docker/Helm/Heroku/DO) 또는 클라우드 |
| 데이터 주권 | 외부 서버에 저장 | 내 서버에 저장 — GDPR·국내법 대응 용이 |
| 비용 | 좌석당 월 구독(고가) | 코어 무료(MIT) · 인프라 비용만 |
| 채널 | 다수 지원 | 웹챗·이메일·FB·IG·WA·TG·Line·SMS·Voice 등 옴니채널 |
| AI | Fin 등 자체 AI | Captain(에이전트)·Copilot — 자체 RAG/LLM, 모델 교체 가능 |
| 확장 | 제한된 API/앱 | 오픈소스 코드 직접 수정·웹훅·대시보드 앱·에이전트 봇 |
| 커스터마이즈 | 설정 범위 내 | Rails+Vue 코드 풀 액세스 · 자체 채널 추가 가능 |
SNS 메시지는 SNS 앱에서, 이메일은 메일함에서, 채팅은 또 다른 도구에서 각각 응대하면 고객 한 명의 맥락이 흩어진다. 어느 상담원이 무슨 답을 했는지 추적이 안 되고, 같은 질문에 매번 새로 답한다. 게다가 상용 SaaS에 모든 대화를 맡기면 데이터가 벤더에 종속되고, 좌석이 늘수록 비용이 가파르게 오른다.
chatwoot은 모든 채널을 Inbox라는 추상 위로 정규화해 단일 대화 타임라인으로 만든다. 상담원은 한 화면에서 모든 채널을 응대하고, 자동 배정·라벨·자동화로 워크플로를 스케일한다. 그리고 이 모든 것을 내가 통제하는 서버에서 돌리므로 데이터 주권과 비용 통제를 동시에 얻는다. 여기에 Captain AI가 반복 질문을 흡수해 상담원은 복잡한 건만 다룬다.
백엔드(Rails)·프론트엔드(Vue 3)·인프라(Postgres·Redis·Sidekiq) 각각.
코어는 Ruby 3.4 + Rails 7.1로 짜인 모놀리식 애플리케이션이다. API(REST)는 Vue 대시보드와 위젯·외부 통합을 모두 먹여 살린다. 채널 연동·실시간·백그라운드 처리가 핵심이라 관련 젬이 두텁게 깔려 있다.
| 요소 | 역할 |
|---|---|
| Rails 7.1 / Ruby 3.4 | 웹 프레임워크 본체. API 컨트롤러·모델·서비스 객체 중심. |
devise + devise_token_auth + devise-two-factor | 인증. 세션·토큰 인증·2FA(OTP)·SAML(엔터프라이즈)까지. |
pundit | 인가(authorization). 정책 객체(policy)로 "누가 무엇을 할 수 있나" 분리. |
sidekiq + sidekiq-cron | 백그라운드 잡 처리(Redis 기반). 메시지 전송·웹훅·스케줄 잡. |
wisper | 발행/구독(pub-sub) 이벤트 버스. 도메인 이벤트를 리스너로 분배. |
| ActionCable | 웹소켓 실시간 — 새 메시지·타이핑·상태를 대시보드로 push. |
liquid | 템플릿 엔진 — 캔드응답·자동화 메시지에 변수 치환. |
opensearch-ruby | 전문(full-text) 검색 — 대화·연락처·메시지 검색 인덱싱. |
administrate | 슈퍼어드민(설치 관리자) 백오피스 UI 자동 생성. |
| Active Storage (S3/GCS/Azure) | 첨부파일 저장 — 클라우드 스토리지 어댑터 추상화. |
프론트는 하나가 아니다. 상담원 대시보드, 고객이 웹사이트에 다는 채팅 위젯, 헬프센터 포털, 설문(survey) 등 여러 진입점(entrypoint)이 같은 빌드 파이프라인(Vite) 위에 올라간다.
| 요소 | 역할 |
|---|---|
| Vue 3 (Composition API) | 전체 UI 프레임워크. app/javascript/dashboard 등. |
pinia | 상태 관리(Vuex 후계). 대화·연락처·UI 상태 스토어. |
vue-router | 대시보드 SPA 라우팅. |
vue-i18n + Crowdin | 다국어. 50+ 언어를 Crowdin으로 번역 관리. |
| Tailwind CSS | 유틸리티 우선 스타일. 디자인 시스템(design-system). |
ProseMirror (@chatwoot/prosemirror-schema) | 메시지 입력기 — 리치텍스트·멘션·서식. |
Chart.js (vue-chartjs) | 리포팅 대시보드 차트. |
ActionCable JS / @rails/actioncable | 웹소켓 클라이언트 — 실시간 수신. |
@chatwoot/ninja-keys (커맨드바) | 키보드 단축 커맨드 팔레트. |
SDK (app/javascript/sdk/sdk.js) | 고객 사이트에 삽입하는 위젯 로더(iframe 임베드). |
| 요소 | 역할 |
|---|---|
| PostgreSQL | 주 데이터베이스. 계정·대화·메시지·연락처. pg 젬. |
| Redis | Sidekiq 큐·ActionCable 펍섭·캐시. redis-namespace. |
| OpenSearch | 검색 인덱스(선택). 대규모 대화/연락처 검색. |
| Docker / docker-compose | 웹·worker(sidekiq)·postgres·redis 컨테이너 구성. |
| Helm 차트 | Kubernetes 배포(ArtifactHub 공식 차트). |
| Procfile (web / worker) | 웹 서버 + Sidekiq 워커 프로세스 분리 실행. |
| Vite | 프론트 번들러. 위젯·대시보드·SDK 각각 빌드 설정. |
| Sentry / New Relic | 에러·성능 모니터링(선택). |
| 외부 API | Twilio(SMS/Voice)·Meta Graph(FB/IG)·WhatsApp·Telegram·Slack·OpenAI/LLM·Firecrawl(크롤링). |
채널 어댑터 → 통합 대화 모델 → 이벤트 버스 → 실시간/백그라운드.
외부 채널(왓츠앱·인스타·웹챗 등)에서 들어온 메시지는 채널별 Inbox를 거쳐 공통 Conversation / Message 모델로 정규화된다. 메시지가 생성되면 wisper 이벤트가 발행되고, 여러 리스너가 이를 받아 자동화 규칙 실행·웹훅·알림·실시간 브로드캐스트(ActionCable)·검색 인덱싱 등을 각자 비동기로 처리한다. 상담원이 답을 쓰면 역방향으로 채널 어댑터가 원래 플랫폼 API 형식으로 변환해 Sidekiq 잡으로 송신한다.
chatwoot의 심장부는 Inbox 모델이 다형적 연관(polymorphic association)으로 실제 채널을 가리킨다는 점이다. Inbox는 channel_type + channel_id를 들고, 그 채널은 Channel::WebWidget·Channel::Whatsapp·Channel::Email·Channel::FacebookPage 등 11종 중 하나다. 상위 로직(대화·메시지)은 채널 종류를 몰라도 되고, 채널별 차이는 각 Channel 모델과 대응 서비스에 캡슐화된다. 새 채널을 추가해도 Conversation/Message 코드는 손대지 않는다 — 이것이 옴니채널 확장성의 비결이다.
Channel::WebWidget # 웹사이트 임베드 채팅
Channel::Email # IMAP/SMTP 이메일
Channel::FacebookPage # 페이스북 페이지 메신저
Channel::Instagram # 인스타그램 DM
Channel::Whatsapp # WhatsApp Cloud/온프레미스
Channel::Telegram # 텔레그램 봇
Channel::Line # LINE
Channel::Sms / TwilioSms# SMS
Channel::Api # 커스텀(직접 API로 메시지 주입)
Channel::TwitterProfile # X/트위터
Channel::Tiktok # 틱톡
각 채널은 has_one :inbox, as: :channel 관계다. Inbox 입장에서는 belongs_to :channel, polymorphic: true — 무엇이 붙든 똑같이 다룬다.
"메시지가 생기면 해야 할 일"이 너무 많다(알림·웹훅·자동화·실시간·검색·리포팅). 이걸 모델 콜백에 다 욱여넣으면 결합이 폭발한다. chatwoot은 wisper pub/sub 이벤트 버스로 분리했다. 도메인 동작이 일어나면 이벤트를 broadcast하고, app/listeners/의 각 리스너가 관심 있는 이벤트만 구독해 처리한다. 발신자는 누가 듣는지 모르고, 수신자는 누가 보냈는지 신경 쓰지 않는다 — 느슨한 결합(loose coupling)의 교과서다.
| 패턴 | chatwoot에서의 구현 |
|---|---|
| 다형적 채널 | Inbox belongs_to :channel, polymorphic — 채널 11종을 균일 취급. |
| Builder 객체 | app/builders/ — 인바운드 메시지를 대화/메시지로 조립하는 책임 분리. |
| Service 객체 | app/services/{facebook,whatsapp,...} — 채널별 외부 API 송수신 캡슐화. |
| pub/sub 이벤트 | wisper + app/listeners/ + app/dispatchers/ — 도메인 이벤트 분배. |
| 백그라운드 잡 | app/jobs/ + Sidekiq — 외부 송신·웹훅·집계를 비동기로. |
| 정책(policy) 인가 | app/policies/ + Pundit — 멀티테넌트 권한 분리. |
| 멀티테넌시 | Account 단위로 모든 데이터 스코핑 — 한 인스턴스에 여러 조직. |
| 오픈코어 | enterprise/ 디렉터리에 유료 기능(Captain·SLA·SAML 등) 격리. |
chatwoot의 이벤트 시스템을 "회사 사내 방송(공지 방송)"으로 생각하자. "3번 창구에 새 고객 도착!"이라고 한 번 방송(broadcast)하면, 그 방송을 듣고 경비팀(알림)·기록팀(리포팅)·안내데스크(실시간 화면 갱신)·외부협력사(웹훅)가 각자 알아서 움직인다. 방송하는 사람은 누가 듣는지 일일이 호출할 필요가 없고, 새 부서(리스너)가 생겨도 방송 로직은 그대로다.
고객이 위젯에서 타이핑하는 순간 상담원 화면에 점이 깜빡이고, 새 메시지가 즉시 뜬다. 이건 ActionCable 웹소켓으로 처리한다. app/channels/room_channel.rb가 클라이언트 구독을 받고, 서버는 ActionCableBroadcastJob으로 해당 계정/대화 구독자에게만 이벤트를 push한다. Redis가 펍섭 백엔드라 웹 서버를 여러 대로 늘려도 실시간이 깨지지 않는다(수평 확장 가능).
Account로 스코핑해, A회사의 상담원이 B회사 대화를 절대 못 보게 격리한다. Pundit 정책과 쿼리 스코프가 이 경계를 강제한다.최근 트렌딩의 동력인 Captain은 enterprise/app/services/captain/에 격리돼 있다. 헬프센터 문서·과거 대화를 임베딩(embedding)해 벡터로 저장(ArticleEmbedding 모델)하고, 고객 질문이 오면 유사 문서를 검색(RAG) → LLM에 컨텍스트로 주입 → 답변 생성한다. tool_registry_service로 LLM이 호출할 수 있는 도구(예: 대화 이관, 정보 조회)를 등록하는 툴 호출(function calling) 구조도 갖췄다. Copilot은 같은 엔진을 상담원 보조(답변 초안 제안)로 쓴다. 모델은 OpenAI 호환이라 교체 가능하다.
전형적 Rails 레이아웃 + 옴니채널 특화 디렉터리 + enterprise 분리.
① app/models/inbox.rb + app/models/channel/로 "채널 다형성"의 실물 → ② app/models/conversation.rb·message.rb로 통합 대화 모델 → ③ app/builders/로 인바운드 조립 → ④ app/listeners/ + wisper로 이벤트 분배 → ⑤ app/services/whatsapp(또는 facebook)로 채널별 송수신 → ⑥ app/javascript/dashboard로 프론트. 옴니채널의 핵심 통찰은 ①~⑤에 거의 다 있다.
대형 Rails 모놀리스 + 옴니채널 + 실시간에서 무엇을 배우나.
chatwoot 최대의 교훈은 "서로 다른 11개 메시징 API를 하나의 모델로 정규화하는 법"이다. 핵심은 ① 공통 인터페이스(Inbox/Conversation/Message)를 먼저 정의하고, ② 채널별 차이를 Channel::* 모델 + 대응 Service로 캡슐화하며, ③ 다형적 연관으로 상위 로직을 채널 무지(channel-agnostic) 상태로 유지하는 것. 새 채널 추가가 "코어 수정"이 아니라 "어댑터 하나 추가"가 되도록 설계하는 패턴은 결제·알림·스토리지 등 모든 통합에 그대로 적용된다.
Channel::WebWidget과 Channel::Whatsapp이 각각 어떤 칼럼/메서드를 갖고, Inbox가 둘을 어떻게 균일하게 다루는지 코드로 추적하라. "공통" vs "채널 고유"의 경계선을 정리해 본다.모델 콜백에 모든 부수효과를 넣는 대신, wisper로 도메인 이벤트를 발행하고 리스너가 구독하는 구조를 배운다. app/listeners/를 읽으면 "메시지 생성 → 알림/웹훅/자동화/실시간"이 어떻게 독립적으로 갈라지는지 보인다. 이 패턴은 마이크로서비스로 쪼개지 않고도 모놀리스 안에서 책임을 분리하는 강력한 도구다.
message.created 이벤트를 구독하는 리스너를 전부 찾아 표로 정리하라(이벤트명 → 리스너 → 무슨 잡). 새 리스너 하나(예: "긴 메시지면 슬랙 알림")를 추가하는 코드를 머릿속으로 설계해 본다.웹 요청은 빨라야 하고(실시간 push), 무거운 일은 미뤄야 한다(백그라운드). chatwoot은 ActionCable(즉시 push) + Sidekiq(지연 처리) 두 축으로 이를 분리한다. "사용자에게 즉시 보여줄 것"과 "나중에 처리해도 될 것"을 가르는 기준을 코드로 체득할 수 있다. Redis가 양쪽(펍섭 백엔드 + 잡 큐)을 받치는 구조도 핵심.
한 인스턴스에 여러 조직이 공존하는 SaaS의 기본기를 배운다. Account 스코핑으로 데이터를 격리하고, Pundit 정책 객체로 "이 사용자가 이 리소스에 무엇을 할 수 있나"를 컨트롤러 밖으로 분리한다. app/policies/를 읽으면 권한 로직이 모델·컨트롤러에 흩어지지 않고 한곳에 모이는 이점이 보인다.
ConversationPolicy(또는 유사 정책)를 읽고, 관리자/상담원/봇이 각각 무엇을 할 수 있는지 권한 매트릭스를 그려라. 멀티테넌트 누수(다른 Account 데이터 접근)를 막는 코드 지점을 찾아본다.enterprise/ 디렉터리로 유료 기능을 코어와 물리적으로 분리하는 오픈코어 운영 방식, 그리고 captain/의 RAG 파이프라인(임베딩 → 벡터검색 → LLM → 툴 호출)을 실서비스 코드로 학습한다. "AI 기능을 기존 대형 앱에 어떻게 깔끔히 얹는가"의 좋은 레퍼런스다.
enterprise/app/services/captain/에서 "문서 임베딩 → 질문 시 검색 → LLM 호출"의 흐름을 함수 단위로 따라가라. tool_registry_service가 LLM에 어떤 도구를 노출하는지 목록화해 본다.단일 바이너리가 아니라 "여러 프로세스 + 데이터 저장소"의 조합이다.
| 항목 | 요구사항 |
|---|---|
| 런타임 | Ruby 3.4.x + Node 24.x(빌드용). 프로덕션은 보통 Docker 이미지 사용. |
| 데이터베이스 | PostgreSQL(필수). 메시지·대화·연락처 등 핵심 데이터. |
| 캐시·큐 | Redis(필수) — Sidekiq 큐 + ActionCable 펍섭. |
| 검색(선택) | OpenSearch — 대규모 검색 인덱싱. 없으면 DB 검색으로 폴백. |
| 프로세스 구성 | web(Puma) + worker(Sidekiq) 최소 2종 프로세스. 실시간 위해 ActionCable. |
| 스토리지 | 로컬 또는 S3/GCS/Azure(첨부파일). 프로덕션은 클라우드 스토리지 권장. |
| 외부 자격증명 | 채널별 API 키(Twilio·Meta·WhatsApp·Telegram), 메일 SMTP/IMAP, (선택) OpenAI 키. |
| 배포 옵션 | docker-compose(소규모) · Helm/Kubernetes(대규모) · Heroku/DigitalOcean 원클릭 · 매니지드 클라우드(Chatwoot Cloud). |
| 리버스 프록시 | 웹소켓(ActionCable) 통과를 위한 Nginx/Caddy 설정 필요(WS upgrade). |
chatwoot 설치의 90%는 .env 채우기다. FRONTEND_URL·POSTGRES_*·REDIS_URL·메일 설정·채널 API 키가 전부 환경변수다(저장소 .env.example 약 270줄). 처음엔 docker-compose로 web+worker+postgres+redis를 한 번에 띄우는 게 가장 빠르며, 채널은 하나씩(웹챗→이메일→SNS) 붙여 가며 검증하는 것을 권장한다.
난이도별 5단계 — 로컬 docker-compose로 띄워 두고 진행.
docker-compose로 chatwoot을 띄운 뒤, 새 계정을 만들고 웹사이트 라이브챗 인박스를 생성한다. 발급된 위젯 스크립트(sdk.js 임베드)를 빈 HTML 페이지에 붙여, 그 페이지에서 채팅을 보내고 대시보드에서 받아 응대해 본다. 옴니채널의 가장 단순한 한 채널을 끝까지 경험하는 것이 목적.
메시지를 하나 보내고, 코드에서 message.created 류 이벤트가 발행되는 지점과 그것을 구독하는 app/listeners/ 리스너들을 모두 찾아 "이벤트 → 리스너 → 잡" 매핑 표를 만든다. Sidekiq 웹 UI(/sidekiq)로 실제 어떤 잡이 큐에 쌓이는지 눈으로 확인한다.
"새 대화에 특정 키워드가 있으면 자동으로 라벨 붙이고 팀에 배정"하는 자동화 규칙(AutomationRule)을 UI로 만들고, 코드(app/models/automation_rule.rb + 관련 서비스)에서 조건·액션이 어떻게 평가·실행되는지 따라간다. Liquid 변수가 들어간 캔드응답도 하나 작성해 본다.
Channel::Api(커스텀 채널)를 이용해, 외부에서 REST API로 메시지를 주입하고 웹훅으로 답장을 받아 처리하는 나만의 채널 어댑터(예: 사내 챗봇 연동)를 작은 스크립트로 구현한다. 다형적 채널 추상화가 "코어 수정 없이 새 채널"을 어떻게 가능케 하는지 체감한다.
enterprise/app/services/captain/를 정독해 "헬프센터 글 임베딩 → 질문 시 벡터검색 → LLM 컨텍스트 주입 → 답변"의 파이프라인을 함수 단위로 정리한다. 그다음 별도 작은 스크립트로 "문서 몇 개를 임베딩해 저장하고, 질문에 유사 문서를 붙여 LLM에 보내는" 미니 RAG를 직접 재현해 본다.
6주 코스 — Rails 도메인 설계부터 실시간·AI 통합까지.
| 주차 | 주제 | 실습 · 참고 |
|---|---|---|
| 1주차 | Rails 도메인 모델링 — Account·Inbox·Conversation·Message 관계 | app/models/ · ERD 직접 그리기 · 다형적 연관 학습 |
| 2주차 | 채널 다형성 & 어댑터 패턴 — 외부 API 통합 정규화 | app/models/channel/ · app/services/{facebook,whatsapp} |
| 3주차 | 이벤트 기반 설계 — wisper pub/sub, 리스너/디스패처 | app/listeners/ · app/dispatchers/ · 느슨한 결합 원리 |
| 4주차 | 백그라운드 처리 — Sidekiq 잡·큐·재시도·스케줄(cron) | app/jobs/ · Sidekiq 웹 UI · Redis 모니터링 |
| 5주차 | 실시간 & 멀티테넌시 — ActionCable·Pundit 인가·Account 스코핑 | app/channels/ · app/policies/ · WS 수평 확장 |
| 6주차 | AI 통합(RAG) & 프론트(Vue 3) — Captain·Copilot·Pinia 스토어 | enterprise/.../captain/ · app/javascript/dashboard/ |
본문·소스에 나온 용어 빠른 참조.
| 용어 | 의미 |
|---|---|
| 옴니채널 | 웹챗·이메일·SNS·전화 등 모든 창구를 하나의 연속 대화로 응대. |
| Inbox | chatwoot의 채널 추상 단위. 다형적으로 실제 채널(WebWidget/Whatsapp...)을 가리킴. |
| Conversation / Message | 채널 무관 통합 대화·메시지 모델. 모든 채널이 이리로 정규화됨. |
| Contact / ContactInbox | 고객(연락처)과 그가 어느 채널로 들어왔는지 매핑. |
| Account | 멀티테넌시 단위(조직). 모든 데이터가 이 스코프로 격리. |
| 다형적 연관 | 한 모델이 여러 종류의 다른 모델을 균일하게 가리키는 Rails 패턴. |
| wisper | pub/sub 이벤트 버스 젬. 도메인 이벤트를 리스너로 분배(느슨한 결합). |
| Listener / Dispatcher | 이벤트 구독자 / 이벤트 분배기(app/listeners, app/dispatchers). |
| Builder | 인바운드 페이로드를 대화/메시지로 조립하는 객체(app/builders). |
| Sidekiq | Redis 기반 백그라운드 잡 처리 엔진. 외부 송신·웹훅·집계 담당. |
| ActionCable | Rails 웹소켓. 새 메시지·타이핑을 실시간 push(Redis 펍섭). |
| Pundit | 정책 객체 기반 인가 라이브러리. 멀티테넌트 권한 분리. |
| AutomationRule / Macro | 조건-액션 자동화 / 일괄 작업 매크로. |
| Canned Response | 자주 쓰는 답변 템플릿(Liquid 변수 치환 가능). |
| CSAT | 고객 만족도 설문(대화 종료 후 평가). |
| Captain / Copilot | RAG 기반 AI 상담 에이전트 / 상담원 보조(엔터프라이즈). |
| RAG | 검색으로 끌어온 문서를 LLM 프롬프트에 주입해 근거 있는 답 생성. |
| 오픈코어 | 코어는 오픈소스(MIT), 일부 고급 기능(enterprise/)은 별도 라이선스. |
| 자체호스팅 | 벤더 서버가 아닌 내 서버에 직접 설치·운영(데이터 주권). |
chatwoot/chatwoot) · Heroku/DigitalOcean 원클릭