혼자 만들어서 혼자 쓰던 MCP 서비스를 남에게 열어준다는 건, 자취방에 손님을 부르는 것과 비슷하다. 지금까지는 아무 데나 양말을 던져도 괜찮았지만, 누군가 문을 열고 들어오는 순간 "이건 좀..." 싶은 것들이 보이기 시작한다.
이 글은 그 "이건 좀..." 목록이다.
배경: 뭘 만들었길래
Contents Hub는 RSS, YouTube, LinkedIn 등 여러 소스에서 콘텐츠를 크롤링하고, Gemini AI로 매일 다이제스트를 만들어 이메일과 Slack으로 보내주는 서비스다. MCP(Model Context Protocol) 도구로도 연결되어 있어서, Claude Code에서 "오늘 다이제스트 보여줘", "이 URL 크롤해줘" 같은 명령을 바로 쓸 수 있다.
문제는 이 서비스가 철저히 나 혼자 쓸 용도로 만들어졌다는 것.
user_id=2가 코드 곳곳에 하드코딩되어 있다- MCP는 stdio transport로 로컬에서만 돌아간다
- 인증은 JWT 수동 발급이 전부다
- 스케줄러는 글로벌 하나, 다이제스트도 하나
이걸 다른 사람이 쓸 수 있게 만드는 여정이 시작됐다.
1장. 가장 먼저 부딪히는 벽: "너의 데이터와 나의 데이터"
멀티테넌시라는 이름의 대공사
1인 서비스에서 멀티유저로 가는 건, 단순히 user_id 필터를 추가하는 게 아니다. 생각해야 할 게 줄줄이 딸려온다.
구독: A가 구독한 Hacker News와 B가 구독한 Hacker News는 같은 RSS 피드다. 크롤을 두 번 할 건가, 한 번 하고 공유할 건가? 한 번 크롤 + 공유 참조가 효율적이지만, 설계 복잡도가 확 올라간다. 초기엔 각자 독립 크롤로 시작하고, 사용자 수가 늘면 공유 풀로 전환하는 게 현실적이다.
콘텐츠: Subscription을 통해 간접 격리할 수 있다. 하지만 구독 없이 1회성으로 크롤한 콘텐츠(adhoc crawl)는? 별도로 user_id 컬럼이 필요하다.
검색: 시맨틱 검색에서 pgvector 쿼리에 user_id 필터를 넣어야 한다. 벡터 인덱스 성능에 영향이 있을 수 있다. HNSW 인덱스 + WHERE 절 조합의 성능 테스트가 필요하다.
다이제스트: 사용자마다 다른 시간에, 다른 구독 기반으로, 다른 언어로 만들어야 한다. 글로벌 스케줄러 하나로는 안 된다.
핵심은 **"모든 쿼리에 WHERE user_id = :current_user가 빠지면 사고"**라는 것. 한 곳이라도 놓치면 남의 다이제스트가 보인다. 테스트에서 이걸 검증하는 게 Phase 1의 가장 중요한 작업이다.
인증: API Key가 자연스럽다
MCP 서비스의 인증은 웹 서비스와 다르다. 사용자가 .mcp.json에 설정을 넣고 끝이어야 한다. OAuth 리다이렉트 플로우? MCP에선 의미 없다.
그래서 API key 방식이 딱 맞는다.
chub_sk_a1b2c3d4e5f6...
chub_ 접두사를 붙이는 건 단순한 미학이 아니다. GitHub 코드 검색이나 시크릿 스캐너가 이 패턴을 잡아낼 수 있다. Stripe(sk_live_), OpenAI(sk-) 모두 같은 이유로 접두사를 쓴다.
가입 플로우도 단순하게:
초대 코드 수신 → API 호출로 등록 → key 발급 → .mcp.json에 설정 → 끝
2장. "연결은 어떻게?" — Transport의 선택
stdio에서 SSE로
현재 MCP 서버는 stdio transport다. 로컬에서 Python 프로세스를 띄워서 stdin/stdout으로 통신한다. 당연히 원격 접속은 불가능하다.
공개 서비스가 되려면 사용자의 .mcp.json이 이렇게 바뀌어야 한다:
{
"mcpServers": {
"contents-hub": {
"type": "sse",
"url": "https://api.contentshub.dev/mcp/sse",
"headers": {
"Authorization": "Bearer chub_sk_a1b2c3..."
}
}
}
}
URL 하나, key 하나. 이게 전부여야 한다.
FastMCP는 SSE transport를 내장 지원하므로, 코드 변경은 상대적으로 적다. 하지만 인프라 쪽 준비가 따른다:
- HTTPS 필수 — MCP 클라이언트가 평문 HTTP를 허용할 수도 있지만, API key가 오가는 이상 암호화는 기본이다
- 도메인 필요 — IP 주소를
.mcp.json에 넣는 건 불안정하다 - 리버스 프록시 — Caddy나 Nginx가 앞에서 HTTPS를 처리하고, 뒤에서 MCP 서버로 포워딩
Streamable HTTP는 아직 이르다
MCP의 최신 스펙에 Streamable HTTP transport가 있다. 양방향 통신이 가능하고 미래 지향적이지만, 생태계가 아직 초기다. 초대제 서비스에서 bleeding edge를 쓸 이유가 없다. SSE로 시작하고, 생태계가 성숙하면 전환하면 된다.
3장. 크롤링의 회색지대
"긁어도 되는 것 vs 안 되는 것"
이건 기술이 아니라 정책의 문제다.
| 플랫폼 | 공개 서비스에서 | 이유 |
|---|---|---|
| RSS | O | 공개 데이터, 구독 목적 자체가 소비 |
| YouTube RSS | O | 공개 피드 |
| 일반 웹 | O | robots.txt 준수 전제 |
| X | 쿠키 필요, ToS 위반 가능성 | |
| Twitter/X | X | 쿠키 필요, 법적 리스크 |
혼자 쓸 때는 내 LinkedIn 쿠키로 내 피드를 긁는 거라 괜찮았다. 하지만 남의 쿠키를 서버에 저장해서 크롤한다? 법적으로도, 윤리적으로도 선을 넘는다.
현실적 대안:
- 초기엔 RSS / YouTube / Web만 지원
- 소셜 크롤링은 "사용자 본인 쿠키 입력 + 본인 책임" 옵션으로 나중에 열되, 법적 검토 후
- 또는 공식 API 연동 (Twitter API는 유료, LinkedIn API는 제한적)
이 결정이 서비스의 성격을 규정한다. "무엇을 안 하느냐"가 "무엇을 하느냐"만큼 중요하다.
4장. 돈 이야기: Gemini API 비용의 그림자
사용자가 늘면 비용은 어떻게 되나
Contents Hub의 AI 기능은 전부 Gemini API에 의존한다:
- 다이제스트 생성 (gemini-2.5-flash)
- 시맨틱 검색용 임베딩 (gemini-embedding-001)
- 콘텐츠 스니펫 생성
혼자 쓸 때 Gemini 비용은 거의 무시할 수준이었다. 하지만 사용자 10명이면? 각자 매일 다이제스트를 받고, 검색을 하고, 크롤할 때마다 임베딩을 생성한다.
대략적으로:
- 다이제스트 1회 생성: ~20개 콘텐츠 요약 → 입출력 토큰 비용
- 임베딩: 크롤된 콘텐츠당 1회 API 호출
- 스니펫: 콘텐츠당 1회 (300자 초과 시)
초대제에서 당장 과금할 필요는 없지만, 사용자별 비용 추적은 처음부터 넣어야 한다. 나중에 "이 서비스 유지비가 얼마야?"라는 질문에 답할 수 없으면 곤란하다.
Rate limiting도 필수다:
| 리소스 | 무료 티어 제한 |
|---|---|
| 구독 수 | 20개 |
| 다이제스트 | 1회/일 |
| 시맨틱 검색 | 100회/일 |
| adhoc 크롤 | 10회/일 |
제한이 없으면, 누군가 검색을 1만 번 돌리는 날 Gemini 청구서가 날아온다.
5장. 인프라: VM 한 대로 얼마나 버틸 수 있을까
현실적인 스케일링 계획
| 사용자 수 | 서버 | 크롤러 | 스케줄러 |
|---|---|---|---|
| 1~5명 | VM 1대 | in-process | APScheduler |
| 5~20명 | VM 1대 (스펙 업) | in-process | APScheduler + 사용자별 job |
| 20~50명 | 크롤 워커 분리 | ARQ (Redis 큐) | 전용 스케줄러 |
| 50명+ | 서비스 분리 | Celery + 멀티 워커 | 독립 프로세스 |
핵심 포인트: 초대제 5~10명이면 VM 한 대로 충분하다. 과도한 인프라 설계는 1인 개발자의 적이다. "지금 필요한 것"과 "나중에 필요할 것"을 구분하고, 전환 지점만 알고 있으면 된다.
APScheduler에서 ARQ로 전환하는 시점은 "크롤 작업이 스케줄러를 블로킹하기 시작할 때"다. 다이제스트 생성(Gemini API 호출 ~90초)이 다른 사용자의 크롤을 지연시키면, 그때가 분리 시점이다.
6장. 온보딩: 첫인상이 전부다
가입에서 첫 다이제스트까지
초대제라도, 아니 초대제이기 때문에 첫 경험이 중요하다. 초대한 사람이 설정하다 포기하면 피드백을 받을 기회조차 없다.
이상적인 플로우:
1. 초대 코드 DM으로 수신
2. 한 번의 API 호출로 가입 + key 발급
3. .mcp.json 복붙 가이드 (3줄)
4. Claude Code에서 "구독 추가해줘" → 자연어로 구독 설정
5. 다음 날 아침, 첫 다이제스트 수신
"기본 구독 세트"도 준비하면 좋다. 가입하자마자 Hacker News, TechCrunch 같은 인기 RSS가 세팅되어 있으면 빈 화면에서 시작하는 불안감이 줄어든다.
문서는 세 가지만:
- Quick Start:
.mcp.json설정, 3분 가이드 - 도구 목록: 19개 MCP 도구 + 실제 사용 예시
- FAQ: "다이제스트 시간 바꿀 수 있나요?", "LinkedIn 지원하나요?"
정리: Phase별 체크리스트
Phase 1 — 멀티테넌시 + 인증 (먼저)
- invite_codes, api_keys, user_preferences 테이블
- API key 인증 미들웨어
- 모든 쿼리에 user_id 필터
- 사용자별 다이제스트 스케줄
- 격리 검증 테스트
Phase 2 — MCP Transport
- SSE transport 전환
- HTTPS + 도메인 설정
-
.mcp.json설정 가이드
Phase 3 — 사용량 관리
- Rate limiting
- 사용자별 비용 추적
- 관리자 모니터링
Phase 4 — 온보딩
- 초대 코드 → 가입 플로우
- 기본 구독 세트
- Quick Start 문서
남은 질문들
이 글을 쓰면서도 결정하지 못한 것들이 있다:
- 도메인:
contentshub.dev? 기존 VM 서브도메인으로 시작? - 소셜 크롤링: 언제, 어떤 방식으로 열 것인가
- 유료화: 당분간 무료로 가되, 비용 구조는 처음부터 파악
- 공유 콘텐츠 풀: 같은 피드 중복 크롤 문제를 언제 해결할 것인가
이 질문들에 대한 답은 실제로 사용자를 받아보면서 나올 것이다. 완벽한 설계를 기다리기보다, Phase 1을 끝내고 첫 초대 사용자에게 열어보는 게 낫다.
혼자 쓰던 서비스를 누군가에게 여는 건, 코드의 문제가 아니라 책임의 문제다. 그 사람의 데이터를 안전하게 지킬 수 있는가, 그 사람의 시간을 낭비하지 않을 수 있는가. 기술적 준비 목록이 곧 그 책임감의 체크리스트다.