MCP 멀티테넌트 보안: Cloudflare Durable Objects로 테넌트 간 데이터 유출을 구조적으로 차단하기
2025년 초, MCP SDK 1.26.0 패치 노트에 조용히 등장한 한 줄이 SaaS 개발자들 사이에서 화제가 됐습니다. 무상태 MCP 서버에서 단일 McpServer 인스턴스를 전역으로 공유했을 때, 응답이 다른 클라이언트에게 유출되는 버그가 발견됐다는 내용이었습니다. 실제 고객 데이터가 타 고객에게 노출됐다면, 그것은 단순한 버그가 아니라 법적 책임과 서비스 신뢰를 한꺼번에 무너뜨리는 보안 인시던트입니다. 이 사건은 "소프트웨어적 격리"에 의존하는 멀티테넌트 설계가, MCP처럼 지속적인 세션 상태를 가진 프로토콜에서는 얼마나 위태로운지를 단적으로 보여줍니다.
이 글은 Cloudflare Durable Objects(DO)가 MCP 서버의 세션 격리 문제를 구조 설계 단계에서 어떻게 해결하는지 심층 분석합니다. 단순한 "방법론 소개"를 넘어, 왜 기존 tenant_id 필터링 방식이 MCP에 취약한지, DO의 런타임 격리 모델이 어떻게 이 취약점을 구조적으로 제거하는지를 실제 코드와 함께 살펴봅니다. 이 글을 읽고 나면 Cloudflare Workers 위에서 테넌트 간 데이터 유출 위험을 구조 자체에서 제거한 MCP 서버를 직접 설계하고 구현하는 데 필요한 판단 기준을 갖출 수 있습니다.
사전 지식: 이 글은 Cloudflare Workers와 TypeScript 기본 문법에 익숙하고, MCP 또는 SaaS 보안에 관심 있는 백엔드·풀스택 개발자를 주요 독자로 합니다. MCP를 처음 접하는 경우에도 핵심 개념 섹션에서 충분한 맥락을 제공합니다.
크로스 테넌트 데이터 유출(Cross-Tenant Data Leak): 멀티테넌트 시스템에서 한 테넌트(사용자/조직)의 데이터가 의도치 않게 다른 테넌트에게 노출되는 보안 취약점. 소프트웨어 버그, 잘못된 캐싱, 전역 상태 공유 등 다양한 원인으로 발생하며, SaaS 서비스에서 법적 책임과 신뢰 손실로 이어집니다.
Durable Objects의 "테넌트당 전용 인스턴스" 모델은 소프트웨어 관례가 아닌 런타임 수준의 격리를 제공하여, 잘못된 쿼리 한 줄로 인한 데이터 유출 가능성을 구조 자체에서 제거합니다.
목차
핵심 개념
MCP와 멀티테넌트 환경의 긴장 관계
MCP(Model Context Protocol)는 Anthropic이 설계한 AI 에이전트-도구 통합 표준으로, 클라이언트(LLM 런타임)와 서버(도구·데이터 제공) 간의 JSON-RPC 기반 통신을 정의합니다. 문제는 MCP 서버가 세션 상태를 유지한다는 점입니다. 단일 서버 인스턴스가 전역 상태를 가지면, 여러 테넌트의 세션이 동일한 메모리 공간을 공유하게 됩니다.
전통적인 멀티테넌트 방식은 단일 DB에 tenant_id 컬럼을 두고 모든 쿼리에서 필터링하는 접근입니다. 이 방식은 REST API처럼 요청이 독립적(stateless)일 때는 어느 정도 통하지만, MCP처럼 세션 컨텍스트가 요청 간에 누적·공유되는 프로토콜에서는 단 하나의 버그로도 테넌트 경계가 무너질 수 있습니다. 앞서 언급한 MCP SDK 1.26.0 버그가 바로 이 케이스입니다.
Cloudflare Durable Objects: 격리의 런타임 단위
Durable Objects(DO)는 Cloudflare Workers 위에서 동작하는 컴퓨트 + 스토리지 일체형 스테이트풀 단위입니다. 각 DO 인스턴스는 V8 Isolate(Google V8 엔진의 독립적 실행 컨텍스트로, 각 isolate는 별도의 힙 메모리를 가짐)를 기반으로 동작하며, 인스턴스 간 메모리 접근은 근본적으로 차단됩니다. Cloudflare는 이를 공식적으로 "logical isolation"으로 표현합니다. V8 isolate 기반 격리이므로 OS 프로세스 격리와는 모델이 다르지만, 인스턴스 간 데이터 접근을 코드 경로 수준에서 원천 차단한다는 점에서 멀티테넌트 격리에 실질적인 효과를 발휘합니다.
멀티테넌트 격리 관점에서 핵심 속성은 다음 세 가지입니다.
| 속성 | 설명 |
|---|---|
| 싱글 인스턴스 보장 | 동일한 ID로 생성된 DO는 전 세계에서 단 하나만 존재. 동시성 충돌 없이 직렬화 처리 |
| 격리된 SQLite 스토리지 | 2025년 4월 GA 기준, 인스턴스당 최대 10GB 전용 SQLite. 다른 인스턴스에서 접근할 방법이 없음 |
| McpAgent 1:1 대응 | Cloudflare Agents SDK의 McpAgent는 MCP 클라이언트 연결 하나당 DO 인스턴스 하나를 생성 |
McpAgent와 다중 세션:
idFromName("tenant:tenantId")패턴에서는 동일 테넌트의 여러 탭이나 연결이 모두 같은 DO 인스턴스로 라우팅됩니다. DO의 직렬 처리 특성상 이 요청들은 순차적으로 처리됩니다. 세션 수준의 격리가 필요한 경우(예: 각 대화 세션을 독립적으로 분리), DO 이름에 세션 ID를 추가하는 설계가 필요합니다. 단, 이 경우 동일 테넌트의 세션 간 상태 공유가 불가능해지므로 설계 의도를 명확히 하는 것이 중요합니다.
DO vs. Redis + DB 분리 방식: 전통적인 멀티테넌트는 단일 DB에
tenant_id컬럼을 두고 쿼리마다 필터링합니다. DO 방식은 테넌트마다 별도의 SQLite가 존재하므로,WHERE tenant_id = ?조건을 빠뜨리는 실수가 구조적으로 불가능합니다.
McpAgent와 DO의 연결 구조
격리 원리를 한 줄로 요약하면, idFromName("tenant:A")로 라우팅된 요청은 해당 DO 인스턴스에서만 처리되며, 어떤 코드 경로로도 "tenant:B" 인스턴스의 메모리나 스토리지에 도달할 수 없습니다.
클라이언트 세션 (테넌트 A)
│
▼
Cloudflare Worker (진입점)
extractTenantId() → "tenant-a"
│
▼
idFromName("tenant:tenant-a")
│
▼
DO 인스턴스 A ← McpAgent 실행
[전용 SQLite 10GB]
[격리된 V8 Isolate 메모리]
클라이언트 세션 (테넌트 B) → DO 인스턴스 B (완전히 분리)Worker는 얇은 라우터 역할만 하고, 실제 MCP 로직과 상태는 모두 DO 인스턴스 안에 캡슐화됩니다. DO 인스턴스 간에는 명시적인 RPC 호출 없이는 어떤 데이터도 공유되지 않습니다.
실전 적용
아래 세 예시는 기본 구조 → ORM 통합 → 인증 통합 순서로 점진적으로 발전합니다. 예시 1에서 extractTenantId()의 간략한 형태를 소개하고, 예시 3에서 JWT 기반 실제 구현을 다룹니다.
예시 1: 테넌트 ID 기반 DO 라우팅 (기본 구조)
가장 기본적인 패턴입니다. Worker 진입점에서 요청의 인증 정보로부터 테넌트 ID를 추출하고, 이를 DO 인스턴스 이름으로 사용합니다. extractTenantId()의 JWT 기반 실제 구현은 예시 3에서 다룹니다.
// worker.ts — 얇은 라우터 역할
export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
// 1. 헤더 또는 JWT에서 테넌트 ID 추출
// 구체적인 JWT 구현은 예시 3 참고
const tenantId = await extractTenantId(request);
if (!tenantId) {
return new Response('Unauthorized', { status: 401 });
}
// 2. 동일 tenantId → 항상 동일 DO 인스턴스
// 다른 tenantId → 런타임 격리된 DO 인스턴스
const id = env.MCP_AGENT.idFromName(`tenant:${tenantId}`);
const stub = env.MCP_AGENT.get(id);
// 3. 모든 처리를 DO에 위임
return stub.fetch(request);
} catch (error) {
console.error('Routing error:', error);
return new Response('Internal Server Error', { status: 500 });
}
}
};
// agent.ts — DO 인스턴스 = 세션 격리 단위
export class TenantMcpAgent extends McpAgent<Env, {}, { tenantId: string }> {
server = new McpServer({ name: 'tenant-mcp', version: '1.0.0' });
async init() {
this.server.tool('getTenantData', {}, async () => {
// this.ctx.storage는 이 DO 인스턴스만의 전용 SQLite
// 어떤 코드도 다른 테넌트의 storage에 접근할 수 없음
const data = await this.ctx.storage.get('sensitive-data');
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
});
}
}| 코드 포인트 | 역할 |
|---|---|
idFromName(\tenant:${tenantId}`)` |
테넌트 ID를 DO 고유 식별자로 변환. 동일 ID → 동일 인스턴스 보장 |
stub.fetch(request) |
Worker는 라우팅만, 비즈니스 로직은 DO 내부로 캡슐화 |
this.ctx.storage |
이 DO 인스턴스 전용 SQLite. 타 인스턴스 접근 불가 |
try/catch (Worker 레벨) |
라우팅 단계의 예외를 500으로 처리. 미인증 요청은 401로 차단 |
예시 2: Drizzle ORM으로 타입 안전한 테넌트 스키마 관리
DO 내장 SQLite를 직접 사용하는 것도 가능하지만, Drizzle ORM을 사용하면 두 가지 이점이 있습니다. 첫째, TypeScript 타입 추론으로 컬럼명 오타나 타입 불일치를 컴파일 단계에서 잡을 수 있습니다. 둘째, 스키마 마이그레이션을 코드로 관리할 수 있어 테넌트별 DB 구조 변경이 체계적으로 이루어집니다.
// pnpm add drizzle-orm
// drizzle-orm/durable-sqlite는 drizzle-orm 패키지 내 경로로 별도 설치가 불필요합니다
import { drizzle } from 'drizzle-orm/durable-sqlite';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
// 스키마 정의 — tenant_id 컬럼이 필요 없음 (DO 자체가 테넌트 경계)
const userRecords = sqliteTable('user_records', {
id: text('id').primaryKey(),
payload: text('payload').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }),
});
export class TenantDataAgent extends McpAgent {
private db!: ReturnType<typeof drizzle>;
async init() {
// DO의 내장 SQLite를 Drizzle로 래핑
// 이 db 인스턴스는 이 테넌트의 DO에만 존재
this.db = drizzle(this.ctx.storage, { schema: { userRecords } });
this.server.tool('queryRecords', {}, async () => {
// WHERE tenant_id = ? 없이도 안전: 런타임 수준에서 이 테넌트 데이터만 존재
const records = await this.db.select().from(userRecords).all();
return {
content: [{ type: 'text', text: JSON.stringify(records) }]
};
});
this.server.tool('insertRecord', {
id: z.string(),
payload: z.string(),
}, async ({ id, payload }) => {
await this.db.insert(userRecords).values({
id,
payload,
createdAt: new Date(),
});
return { content: [{ type: 'text', text: 'inserted' }] };
});
}
}기존 멀티테넌트 DB와의 결정적 차이: 전통적 방식에서는
SELECT * FROM user_records WHERE tenant_id = 'A'처럼 필터 조건이 필수입니다. 개발자가 실수로 이 조건을 빠뜨리면 전체 테넌트 데이터가 노출됩니다. DO 기반에서는 이 스키마에tenant_id컬럼 자체가 없어도 됩니다. 해당 DO의 SQLite에는 그 테넌트 데이터만 런타임 수준에서 격리되어 존재하기 때문입니다.
예시 3: Cloudflare Zero Trust와 Access for SaaS 통합
기업 환경에서는 Okta, Azure AD 같은 IdP(Identity Provider)를 Cloudflare Access와 연동하고, Access가 발급한 JWT의 클레임으로 DO 인스턴스를 결정합니다. 아래 예시는 예시 1의 extractTenantId()를 실제로 구현합니다.
// auth.ts
// getCloudflareAccessJWT: Cloudflare Access JWT 검증 유틸리티
// 직접 구현하거나 커뮤니티 라이브러리를 활용할 수 있습니다.
// 서명 검증 방법: https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/
async function getCloudflareAccessJWT(
jwt: string
): Promise<Record<string, unknown>> {
// 실제 구현 시 JWKS 엔드포인트에서 공개키를 가져와 서명 검증이 필요합니다
// 아래는 구조 설명을 위한 단순화된 버전입니다
const [, payloadBase64] = jwt.split('.');
return JSON.parse(atob(payloadBase64));
}
export async function extractTenantId(
request: Request
): Promise<string | null> {
try {
// Cloudflare Access가 모든 요청에 자동으로 JWT를 주입
const jwt = request.headers.get('Cf-Access-Jwt-Assertion');
if (!jwt) return null;
const payload = await getCloudflareAccessJWT(jwt);
// JWT 클레임에서 조직 ID 추출 (Okta/Azure AD의 org_id 클레임)
const tenantId =
(payload.org_id as string) ?? (payload.sub as string) ?? null;
return tenantId;
} catch (error) {
// JWT 검증 실패 시 null 반환 → Worker에서 401로 처리
console.error('JWT validation failed:', error);
return null;
}
}# wrangler.toml — 테넌트별 서브도메인 라우팅 설정
# 각 테넌트는 tenant-a.mcp.company.com → DO 인스턴스 "tenant-a"에만 도달합니다
routes = [
{ pattern = "*.mcp.company.com/*", zone_name = "company.com" }
]
[[durable_objects.bindings]]
name = "MCP_AGENT"
class_name = "TenantMcpAgent"이 구조에 MCP Server Portals(2025년 8월 오픈 베타)를 추가하면, Cloudflare Zero Trust 대시보드에서 모든 MCP 서버를 단일 게이트웨이로 통합 관리하고, 테넌트별 SSO, 도구 접근 제어, 요청 로깅을 중앙화할 수 있습니다.
CVE-2025-6514와 DO 격리의 관계: CVE-2025-6514는 mcp-remote OAuth 프록시에서 OAuth 메타데이터 조작이 가능한 취약점입니다. 이 CVE는 DO의 런타임 격리와 별개의 공격 벡터입니다. DO로 테넌트 간 데이터 유출을 차단했더라도, OAuth 핸드셰이크 과정의 이 취약점은 SDK 업그레이드를 통해 별도로 패치해야 합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 강한 격리 보장 | V8 Isolate 기반 런타임 격리로 인스턴스 간 메모리·스토리지 공유 불가 |
| 크로스 테넌트 쿼리 실수 원천 차단 | tenant_id 필터 누락 실수가 구조적으로 불가능. 테넌트 경계가 DB 레벨에서 격리됨 |
| 글로벌 엣지 자동 배치 | DO 인스턴스가 클라이언트와 가장 가까운 Cloudflare PoP(전 세계 분산 데이터센터)에 자동 배치 |
| 서버리스 비용 모델 | 활성 처리 시간만 과금. Hibernation으로 유휴 세션 비용 제로 |
| 내장 세션 스토어 | 별도 Redis나 외부 세션 스토어 불필요. DO 자체가 영속적 세션 스토어 역할 |
| SQLite ACID 트랜잭션 | 부분 업데이트로 인한 데이터 불일치 방지. 롤백 보장 |
단점 및 주의사항
아키텍처·성능 고려사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 싱글 스레드 처리 | 하나의 DO 인스턴스는 동시 요청을 직렬 처리. 단일 세션 내 높은 동시성 불가 | 세션 단위 DO 설계로 테넌트 내 동시성은 Worker 수준에서 처리 |
| 콜드 스타트 지연 | Hibernation 후 첫 요청 시 수십 ms 수준의 깨어나기 지연 발생 | SQLite 영속화로 상태는 보존. UX 영향 미미한 수준 |
| 리전 고정 레이턴시 | DO는 특정 리전에 고정. 테넌트가 해당 리전과 물리적으로 멀 경우 레이턴시 증가 | Cloudflare Smart Placement(트래픽 패턴에 따라 최적 리전에 DO를 자동 배치하는 기능) 활용 |
| DO 간 집계 비효율 | 테넌트를 넘나드는 집계 연산 시 Worker 경유가 필요하여 오버헤드 발생 | 크로스 테넌트 집계는 별도 Analytics Engine이나 R2 기반 파이프라인 구성을 권장 |
보안 취약점 및 패치 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| CVE-2025-6514 | mcp-remote OAuth 프록시의 OAuth 메타데이터 조작 취약점. DO 격리와 별개의 공격 벡터 | Agents SDK 최신 버전으로 즉시 업그레이드 |
| MCP SDK 레거시 안티패턴 | 1.26.0 이전의 전역 McpServer 인스턴스 공유 패턴은 응답 유출 위험 |
요청마다 새 인스턴스를 생성하는 Cloudflare 공식 가이드라인 적용 |
WebSockets Hibernation: DO가 비활성 세션을 절전 상태로 전환하는 기능입니다. 상태는 SQLite에 영속화되어 있어 다음 요청이 오면 즉시 세션 컨텍스트가 복원됩니다.
McpAgent에서 기본값으로 활성화되어 있으며, 유휴 세션에 대한 과금이 발생하지 않습니다.
실무에서 가장 흔한 실수
- Worker 진입점에서 인증 없이 DO에 직접 라우팅:
extractTenantId()가null을 반환할 때 처리 없이 통과시키면, 미인증 요청이 임의의 DO 인스턴스에 접근할 수 있습니다. 예시 1의if (!tenantId) return new Response('Unauthorized', { status: 401 })패턴처럼 반드시 401 응답으로 차단하는 것이 중요합니다. - DO 이름에 테넌트 ID 외의 정보 혼합:
idFromName("tenant:A:session:123")처럼 세션 ID까지 포함하면 동일 테넌트에서 수백 개의 DO가 생성되어 상태 관리가 복잡해집니다. 기본적으로는idFromName("tenant:tenantId")패턴을 사용하고, 세션 수준의 격리가 명확히 필요한 경우에만 세션 ID를 포함하는 설계가 적절합니다. 어떤 경우든 DO 이름 설계 의도를 코드 주석으로 명시해두는 것이 유지보수에 도움이 됩니다. - MCP SDK 버전을 고정하지 않아 보안 패치 누락: CVE-2025-6514나 크로스 클라이언트 응답 유출 패치는 SDK 버전 업그레이드 없이는 적용되지 않습니다.
package.json에 최소 버전 하한을 명시하고, Dependabot(GitHub의 자동 의존성 업데이트 도구)이나 Renovate Bot을 활용해 보안 패치를 자동으로 알림받는 구성을 갖추는 것을 권장합니다.
마치며
Cloudflare Durable Objects는 MCP 멀티테넌트 환경에서 소프트웨어 관례가 아닌 런타임 격리 경계로 데이터를 분리하여, 크로스 테넌트 유출 위험을 구조 설계 단계에서 제거합니다. 이 격리 모델을 도입하면 tenant_id 필터 누락 같은 실수가 구조적으로 불가능해지고, 보안 감사 시 "격리 로직이 제대로 적용됐는가"를 일일이 검증하는 비용이 크게 줄어듭니다. 멀티테넌트 관련 인시던트가 발생하더라도 영향 범위가 해당 테넌트의 DO 인스턴스로 한정되어, 사고 대응 범위 자체가 좁아지는 효과도 기대할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
- Cloudflare Agents SDK로 기본 McpAgent 구성:
pnpm create cloudflare@latest -- --template=cloudflare/agents-starter로 스타터 프로젝트를 생성한 뒤,wrangler.toml에[[durable_objects.bindings]]설정을 추가하여 DO 바인딩을 확인해볼 수 있습니다. - 테넌트 라우팅 로직 적용: Worker 진입점에
extractTenantId()함수를 작성하고idFromName(\tenant:${tenantId}`)` 패턴으로 라우팅을 구현해보시면, 테넌트별 DO 인스턴스가 별도로 생성되는 것을 Cloudflare 대시보드에서 직접 확인할 수 있습니다. - Drizzle ORM으로 SQLite 스키마 타입 안전화:
pnpm add drizzle-orm을 설치하고drizzle(this.ctx.storage, { schema })로 DO 내장 SQLite를 Drizzle로 래핑하면, 타입 안전한 쿼리와 스키마 마이그레이션 관리가 가능해집니다.
다음 글: Cloudflare Gateway DLP와 OPA(Open Policy Agent)를 결합하여 MCP 도구별 세밀한 RBAC 정책을 코드로 관리하고, 실시간 민감 데이터 유출 탐지를 구현하는 방법을 다룰 예정입니다.
참고 자료
Cloudflare 공식 문서
- Piecing together the Agent puzzle: MCP, authentication & authorization, and Durable Objects free tier | Cloudflare Blog
- Build and deploy Remote MCP servers to Cloudflare | Cloudflare Blog
- McpAgent API Reference | Cloudflare Agents docs
- Build a Remote MCP server | Cloudflare Agents docs
- Securing MCP servers | Cloudflare Agents docs
- Zero-latency SQLite storage in every Durable Object | Cloudflare Blog
- SQLite in Durable Objects GA with 10GB storage per object | Cloudflare Changelog
- Securing the AI Revolution: Introducing Cloudflare MCP Server Portals | Cloudflare Blog
- Secure MCP servers with Access for SaaS | Cloudflare One docs
- Durable Objects in Dynamic Workers: Give each AI-generated app its own database | Cloudflare Blog
- Rules of Durable Objects | Cloudflare Durable Objects docs
- Agents SDK v0.4.0 release notes | Cloudflare Community
커뮤니티 & 사례 연구