개발 블로그를 위한 MCP 서버 구축기 (3): Cold Start 1초 미만을 위한 캐싱과 에러 복구
· 약 7분
Git Commit Hash 기반 캐싱으로 서버 시작 시간을 1초 미만으로 줄이고, 에러 복구 전략을 구현합니다.
🎯 들어가며
1편에서 Git 기반 아키텍처를, 2편에서 역인덱스 검색 기능을 구현했습니다. 하지만 실제로 사용해보면 한 가지 불편함이 있습니다.
"서버 시작이 너무 느려요"
매번 Claude Desktop을 열 때마다 Git clone과 인덱스 빌드가 발생합니다. 콘텐츠가 늘어날수록 점점 더 오래 걸리죠.
이번 편에서는 캐싱 시스템을 구현해서 Cold Start를 1초 미만으로 줄이고, 에러 복구 전략으로 안정성을 높입니다.
📚 문제 분석
현재 시작 프로세스
서버 시작
↓
Git Clone (첫 실행) 또는 Git Pull (5-10초)
↓
인덱스 빌드: 모든 파일 파싱 (1-3초)
↓
서비스 준비 완료
문제점
- 매번 인덱스 재빌드: 콘텐츠가 변경되지 않아도 전체 인덱스를 다시 빌드
- 네트워크 의존성: Git 작업 실패 시 서버 시작 불가
- 느린 Cold Start: 초기 시작에 5-15초 소요
목표
| 항목 | 현재 | 목표 |
|---|---|---|
| Cold Start (캐시 있음) | ~10초 | < 1초 |
| Cold Start (캐시 없음) | ~10초 | ~5초 |
| 네트워크 실패 시 | 서버 시작 실패 | 재시도 후 복구 |
🏗️ 캐싱 전략 설계
Git Commit Hash 기반 검증
캐시 무효화 전략에서 가장 중요한 건 "언제 캐시를 버릴 것인가?" 입니다.
여러 방법이 있습니다:
| 방식 | 장점 | 단점 |
|---|---|---|
| 시간 기반 (TTL) | 구현 간단 | 변경 없어도 갱신, 변경 있어도 캐시 사용 가능 |
| 파일 수정 시간 | 정확한 감지 | 모든 파일 검사 필요, clone 시 시간 변경됨 |
| Git Commit Hash | 정확하고 빠름 | Git 저장소 필요 |
Git Commit Hash를 선택했습니다. 저장소가 업데이트될 때만 commit hash가 바뀌므로 완벽한 무효화 키가 됩니다.
캐시 흐름
서버 시작
↓
Git Pull (저장소 동기화)
↓
현재 Commit Hash 조회
↓
캐시 파일에서 저장된 Commit Hash 비교
↓
[일치] 캐시 로드 → 서비스 준비 (< 1초)
[불일치] 인덱스 재빌드 → 캐시 저장 → 서비스 준비
🔧 CacheManager 구현
캐시 파일 구조
{
"commitHash": "b07fdb6abc123...",
"timestamp": "2025-12-03T15:30:00Z",
"index": {
"posts": { ... },
"docs": { ... },
"tags": { ... },
"keywords": { ... }
}
}
CacheManager 클래스
src/cache-manager.js
import { promises as fs } from 'fs';
import path from 'path';
export class CacheManager {
constructor(options = {}) {
this.cacheDir = options.cacheDir || path.join(__dirname, '..', '.mcp-cache');
this.cacheFile = path.join(this.cacheDir, 'index.json');
}
// 인덱스 저장
async saveIndex(index, commitHash) {
try {
await fs.mkdir(this.cacheDir, { recursive: true });
const cacheData = {
commitHash,
timestamp: new Date().toISOString(),
index,
};
await fs.writeFile(
this.cacheFile,
JSON.stringify(cacheData, null, 2),
'utf-8'
);
} catch (error) {
console.error('[CacheManager] Failed to save cache:', error);
// 캐시 저장 실패는 치명적이지 않으므로 에러를 던지지 않음
}
}
// 인덱스 로드
async loadIndex() {
try {
const content = await fs.readFile(this.cacheFile, 'utf-8');
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') {
return null; // 캐시 없음
}
throw error;
}
}
// 캐시 유효성 확인
async isValid(currentCommitHash) {
const cacheData = await this.loadIndex();
if (!cacheData) return false;
return cacheData.commitHash === currentCommitHash;
}
}
핵심 포인트:
- 캐시 저장 실패 허용: 캐시 저장이 실패해도 서버는 정상 동작
- Commit Hash 비교: 단 한 번의 문자열 비교로 유효성 검증
- JSON 직렬화: Map 객체는 JSON으로 직접 변환 불가, 별도 처리 필요
📊 인덱스 직렬화와 복원
JavaScript의 Map 객체는 JSON.stringify로 직접 변환할 수 없습니다:
const map = new Map([['key', 'value']]);
JSON.stringify(map); // "{}" ← 빈 객체!
직렬화 (Map → Object)
src/search-engine.js
exportIndex() {
return {
posts: Object.fromEntries(this.index.posts),
docs: Object.fromEntries(this.index.docs),
tags: Object.fromEntries(this.index.tags),
keywords: Object.fromEntries(this.index.keywords),
};
}
Object.fromEntries()로 Map을 일반 객체로 변환합니다.
복원 (Object → Map)
src/search-engine.js
loadFromCache(cachedData) {
if (!cachedData || !cachedData.index) {
throw new Error('Invalid cache data');
}
const { index } = cachedData;
// Object를 Map으로 변환
this.index.posts = new Map(Object.entries(index.posts));
this.index.docs = new Map(Object.entries(index.docs));
this.index.tags = new Map(Object.entries(index.tags));
this.index.keywords = new Map(Object.entries(index.keywords));
}
Object.entries()와 new Map()으로 다시 Map 객체로 복원합니다.
