로컬 해킹에서 프로덕션 준비 단계까지: BrainGrid의 MCP 멀티 테넌트 인증 문제를 해결한 방법
요약
로컬에서 작동하던 MCP(Model Context Protocol) 서버를 멀티 테넌트 환경의 프로덕션 서비스로 확장하는 방법을 다룹니다. 서버리스 환경의 세션 유지 문제를 해결하기 위해 Redis 세션 스토어를 활용한 아키텍처와 인증 최적화 전략을 소개합니다.
핵심 포인트
- 서버리스 플랫폼의 세션 비유지 특성으로 인한 인증 문제 해결
- Redis 세션 스토어를 활용한 멀티 테넌트 인증 아키텍처 구축
- WorkOS, Auth0 등 기존 OAuth 제공업체와의 통합 방법
- 확장 가능하고 운영 비용이 저렴한 MCP 서버 배포 가이드
당신은 멋진 MCP 서버를 구축했습니다. 당신의 노트북에서는 완벽하게 작동합니다. 당신의 AI 어시스턴트는 Jira 티켓을 생성하고, 데이터베이스를 쿼리하며, 프로덕션에 배포할 수 있습니다. 삶은 즐겁습니다. 그때 팀원이 묻습니다: "저기, 저도 이것 좀 써도 될까요?"
더 나아가, 당신은 당신의 MCP를 고객을 위한 제품으로 출시하고 싶어 합니다. 이제 각기 다른 API 키와 인증을 가진 여러 테넌트 (tenants)를 지원해야 합니다.
갑자기, 당신은 지옥을 맛보게 됩니다.
아무도 말하지 않는 문제
당신이 고객과 MCP 서버를 공유하려고 할 때 발생하는 상황은 다음과 같습니다:
옵션 1: "그냥 설치하세요" 방식
## 팀원들에게 주는 지침:
1. 레포지토리 (repo) 클론
2. 의존성 (dependencies) 설치
...
결과: 3시간 후, 고객의 절반은 포기했고, 나머지 절반은 npm 문제를 디버깅하고 있습니다.
옵션 2: "호스팅해 봅시다"라는 악몽
당신은 Cloud Run이나 Vercel 같은 서버리스 (Serverless) 플랫폼에 배포합니다. 5분 후:
고객: "또 인증하라고 나오는데요..."
당신: "네, 그냥 새로고침하고 다시 로그인하세요."
고객: "방금 했어요. 또 나오는데요."
...
핵심 문제는 무엇일까요? 서버리스 플랫폼은 세션 (sessions)을 유지하지 않습니다. 모든 요청이 서로 다른 인스턴스 (instance)에 도달할 수 있습니다. 당신이 정성스럽게 만든 인증 흐름은 인증을 잡으려 애쓰지만 계속 튀어나오는 '두더지 잡기' 게임이 되어버립니다.
이것이 생각보다 중요한 이유
이것은 단순한 번거로움이 아닙니다. 이것은 다음과 같은 차이를 만듭니다:
- 당신만 사용하는 도구 vs 전체 고객층이 채택하는 도구
- "멋진 프로토타입" vs "핵심 인프라 (infrastructure)"
- 주말 프로젝트 vs 고객이 실제로 사용하는 프로덕션 준비 완료된 제품
우리는 BrainGrid에서 이를 고통스럽게 배웠습니다. 우리의 MCP 서버는 우리 팀이 AI와 협업하는 방식을 변화시켰습니다. 하지만 이는 우리가 고객에게 출시할 준비가 된 인증 퍼즐을 해결한 후에야 가능했습니다.
당신이 배우게 될 내용
이 가이드는 우리가 어떻게 MCP 서버를 로컬 개발 도구에서 다음과 같은 프로덕션 준비 완료된 서비스로 변모시켰는지 정확히 보여줍니다:
- 한 번의 인증으로 어디서든 작동 (Authenticates once, works everywhere) - 더 이상의 로그인 피로감은 없습니다.
- 1명에서 1,000명까지 확장 가능 (Scales from 1 to 1000 users) - 혼자 사용하든 회사 전체가 사용하든 동일한 성능을 보장합니다.
- 매우 저렴한 운영 비용 (Costs pennies to run) - 효율적인 캐싱 (Caching)을 통해 클라우드 비용을 최소화합니다.
- 기존 인증 시스템과 호환 (Works with existing auth) - WorkOS, Auth0 또는 모든 OAuth 제공업체와 통합됩니다.
- 몇 분 만에 배포 가능 (Deploys in minutes) - 명령어 하나로 로컬에서 원격 환경으로 전환할 수 있습니다.
우리는 정확한 아키텍처 (Architecture), 우리가 발견한 주의 사항 (Gotchas), 그리고 이 모든 것을 작동하게 만드는 코드를 다룰 것입니다. 이론이나 군더더기 없이, 수백 명의 개발자에게 서비스를 제공하는 실제 프로덕션 배포에서 검증된 솔루션만을 제공합니다.
여러분의 MCP 서버를 고객들이 실제로 사용하고 싶어 하는 서비스로 만들 준비가 되셨나요? 시작해 봅시다.
우리가 도달한 과정
- 초기 설정: 로컬에서 원격으로
- 서버리스 (Serverless)의 과제
- 기술적 솔루션: Redis 세션 스토어 (Session Store)
- 프로덕션 배포 전략
- 모니터링 및 디버깅 (Debugging)
- 성능 최적화 (Performance Optimization)
- 패러다임의 전환
초기 설정: 로컬에서 원격으로
1단계: 기본적인 MCP 서버 구성
FastMCP를 사용하여 표준 MCP 서버 설정을 시작합니다. 핵심은 MCP 서버의 이중적 특성을 이해하는 것입니다. 즉, 개발을 위한 로컬 환경과 고객이 사용하는 원격 환경 모두에서 작동해야 합니다.
import { FastMCP } from 'fastmcp';
import { z } from 'zod';
...
2단계: 원격 호스팅을 위한 httpStream 전환
Cloud Run 또는 Vercel에 배포하려면 httpStream 전송 (Transport) 방식으로 전환해야 합니다. 이때 도구가 인증 (Authentication)을 어떻게 처리할지에 대한 신중한 고려가 필요합니다.
// 환경 변수에서 전송 유형 감지
const transportType = process.env.MCP_TRANSPORT || 'stdio';
...
3단계: WorkOS를 이용한 OAuth 구현
MCP는 특정 OAuth 구현 패턴을 요구합니다. 핵심적인 통찰은 MCP 클라이언트가 특정한 디스커버리 플로우 (Discovery flow)를 기대한다는 점입니다.
const serverOptions = {
name: 'braingrid-server',
version: '1.0.0',
...
핵심 구현 세부 사항: WWW-Authenticate 헤더는 MCP 클라이언트가 인식할 수 있도록 올바른 형식으로 구성되어야 합니다:
// MCP 세션 구조 - 도구(tools)로 전달되는 데이터
interface MCPSession {
userId: string;
...
4단계: 이중 전송 모드 (Dual Transport Modes) 처리
MCP 서버는 로컬 및 원격 인증 패턴을 모두 지원해야 합니다:
export class BrainGridApiClient {
private auth?: AuthHandler;
private session?: MCPSession;
...
서버리스(Serverless)의 도전 과제
Cloud Run 및 Vercel과 같은 서버리스 플랫폼은 상태 유지 애플리케이션(stateful applications)에 독특한 과제를 안겨주는 근본적인 특성을 공유합니다:
1. 인스턴스 생명주기 관리 (Instance Lifecycle Management)
서버리스 인스턴스는 예측 불가능한 생명주기를 가집니다:
- 콜드 스타트 (Cold starts): 수요에 따라 새로운 인스턴스가 생성됨
- 스케일 투 제로 (Scale to zero): 비활성 상태 이후 인스턴스가 종료됨
- 수평 확장 (Horizontal scaling): 여러 인스턴스가 동시 요청을 처리함
- 스티키 세션 없음 (No sticky sessions): 요청이 어떤 인스턴스로든 전달될 수 있음
이는 MCP 서버에 다음과 같은 구체적인 문제를 야기합니다:
// 이 방식은 서버리스 환경에서 실패합니다:
class NaiveMCPServer {
private sessions = new Map<string, MCPSession>(); // ❌ 인스턴스 재시작 시 데이터 유실
...
2. JWT 검증 오버헤드 (JWT Validation Overhead)
세션 지속성(session persistence)이 없으면, MCP 서버는 모든 요청마다 전체 JWT 검증을 수행해야 합니다:
async function validateJWT(token: string): Promise<MCPSession> {
// 1단계: JWKS 가져오기 (네트워크 호출 ~50ms)
const jwks = await fetchJWKS('https://auth.workos.com/oauth2/jwks');
...
이는 모든 요청에 50-100ms의 지연 시간을 추가하며 비용을 크게 증가시킵니다.
3. 재인증 피로도 (Re-authentication Fatigue)
세션 지속성이 없을 때의 사용자 경험:
좌절한 개발자의 타임라인:
0:00 - MCP 서버에 연결 ✓
0:01 - WorkOS를 통해 인증 ✓
...
기술적 솔루션: 암호화가 적용된 Redis 세션 저장소
아키텍처 개요
이 솔루션은 보안을 핵심으로 하는 다층 캐싱 전략(multi-tier caching strategy)을 구현합니다:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Request │────▶│ Memory │────▶│ Redis │
│ │ │ Cache │ │ Cache │
...
구현 세부 사항 (Implementation Details)
AES-256-GCM 암호화를 적용한 세션 저장소 (Session Store with AES-256-GCM Encryption)
세션 저장소는 민감한 세션 데이터에 대해 군사 등급의 암호화 (military-grade encryption)를 구현합니다:
import { Redis } from 'ioredis';
import crypto from 'crypto';
import { MCPSession } from './types.js';
...
최적화된 인증 미들웨어 (Optimized Authentication Middleware)
인증 미들웨어는 패스트 패스/슬로우 패스 (fast-path/slow-path) 패턴을 구현합니다:
import { IncomingMessage } from 'http';
import crypto from 'crypto';
import { decodeJwt, createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';
import { sessionStore } from './session-store.js';
import { logger } from './logger.js';
import { MCPSession } from './types.js';
export async function authenticateRequest(request: IncomingMessage): Promise<MCPSession> {
const requestId = crypto.randomUUID();
const startTime = Date.now();
logger.debug({
requestId,
method: request.method,
url: request.url
}, 'Authentication request started');
try {
// Bearer 토큰 추출
const token = extractBearerToken(request);
if (!token) {
throw new UnauthorizedError('No bearer token provided');
}
// Fast path: userId를 위해 JWT 디코딩 시도
let userId: string | null = null;
let tokenExp: number | null = null;
try {
const decoded = decodeJwt(token);
userId = decoded.sub || null;
tokenExp = decoded.exp || null;
// 빠른 만료 확인
if (tokenExp && tokenExp < Date.now() / 1000) {
logger.debug({ requestId, userId }, 'Token expired, skipping cache');
userId = null; // 검증 강제 수행
}
} catch (error) {
logger.debug({ requestId }, 'Failed to decode JWT for cache lookup');
}
// userId가 있고 세션Store를 사용할 수 있다면 캐시를 시도합니다
if (userId && sessionStore.isAvailable()) {
const cached = await sessionStore.getSession(userId);
if (cached && cached.token === token) {
const elapsed = Date.now() - startTime;
logger.info({
requestId,
userId,
elapsed,
source: 'cache'
}, '인증 성공 (캐시됨)');
return cached;
}
}
// 느린 경로(Slow path): 전체 JWT 유효성 검사
logger.debug({ requestId }, '캐시 미스, JWT 유효성 검사를 수행합니다');
const session = await validateJWTWithWorkOS(token);
// 다음 사용을 위해 저장
if (sessionStore.isAvailable()) {
await sessionStore.storeSession(session);
}
const elapsed = Date.now() - startTime;
logger.info({
requestId,
userId: session.userId,
elapsed,
source: 'jwt'
}, '인증 성공 (유효성 검사됨)');
return session;
} catch (error) {
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기