본문으로 건너뛰기

개발 블로그를 위한 MCP 서버 구축기 (3): Cold Start 1초 미만을 위한 캐싱과 에러 복구

· 약 7분
Nam Young Kim
Data Fullstack Engineer

Git Commit Hash 기반 캐싱으로 서버 시작 시간을 1초 미만으로 줄이고, 에러 복구 전략을 구현합니다.

🎯 들어가며​

1편에서 Git 기반 아키텍처를, 2편에서 역인덱스 검색 기능을 구현했습니다. 하지만 실제로 사용해보면 한 가지 불편함이 있습니다.

"서버 시작이 너무 느려요"

매번 Claude Desktop을 열 때마다 Git clone과 인덱스 빌드가 발생합니다. 콘텐츠가 늘어날수록 점점 더 오래 걸리죠.

이번 편에서는 캐싱 시스템을 구현해서 Cold Start를 1초 미만으로 줄이고, 에러 복구 전략으로 안정성을 높입니다.


📚 문제 분석​

현재 시작 프로세스​

서버 시작
↓
Git Clone (첫 실행) 또는 Git Pull (5-10초)
↓
인덱스 빌드: 모든 파일 파싱 (1-3초)
↓
서비스 준비 완료

문제점​

  1. 매번 인덱스 재빌드: 콘텐츠가 변경되지 않아도 전체 인덱스를 다시 빌드
  2. 네트워크 의존성: Git 작업 실패 시 서버 시작 불가
  3. 느린 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 객체로 복원합니다.


🔄 서버 초기화 흐름​

이제 CacheManager를 서버 초기화에 통합합니다:

index.js
async function initialize() {
// 1. Git 저장소 동기화
await gitManager.sync();
const commitHash = await gitManager.getCurrentCommitHash();

// 2. 캐시 확인 및 로드
const cached = await cacheManager.loadIndex();

if (cached && await cacheManager.isValid(commitHash)) {
// 캐시 유효 → 로드 (< 1초)
searchEngine.loadFromCache(cached);
console.error('[MCP Server] Index loaded from cache');
} else {
// 캐시 없음 또는 무효 → 재빌드
console.error('[MCP Server] Building search index...');
const repoPath = gitManager.getRepoPath();
await searchEngine.buildIndex(repoPath);

// 캐시 저장 (다음 시작을 위해)
await cacheManager.saveIndex(searchEngine.exportIndex(), commitHash);
}

console.error(`[MCP Server] Index: ${searchEngine.getStats()}`);
}

🛡️ 에러 복구: Git Pull 재시도​

네트워크 작업은 언제든 실패할 수 있습니다. 일시적인 네트워크 문제로 서버 시작이 실패하면 안 됩니다.

재시도 로직 구현​

src/git-manager.js
async pull(retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const git = simpleGit(this.cacheDir);
await git.pull('origin', this.branch);
return; // 성공
} catch (error) {
if (i === retries - 1) {
// 마지막 시도 실패
throw new Error(
`Failed to pull repository after ${retries} attempts: ${error.message}`
);
}

console.error(`[GitManager] Pull failed (attempt ${i + 1}/${retries}), retrying...`);

// 지수 백오프: 1초, 2초, 3초...
await new Promise(resolve =>
setTimeout(resolve, 1000 * (i + 1))
);
}
}
}

지수 백오프(Exponential Backoff)​

재시도 간격을 점점 늘립니다:

시도대기 시간
1회 실패1초
2회 실패2초
3회 실패에러 발생

네트워크가 일시적으로 불안정할 때 복구 확률을 높입니다.


🔄 수동 동기화: refresh_content​

사용자가 새 콘텐츠를 발행한 후 즉시 반영하고 싶을 때를 위한 도구입니다.

src/tools/refresh-content.js
export const refreshContentTool = {
name: 'refresh_content',
description: 'Manually sync repository and rebuild search index.',
inputSchema: {
type: 'object',
properties: {
force: {
type: 'boolean',
description: 'Force rebuild even if no changes (default: false)',
},
},
},
};

export async function handleRefreshContent(args, gitManager, searchEngine, cacheManager) {
const { force = false } = args;

// 현재 commit hash 저장
const oldCommitHash = await gitManager.getCurrentCommitHash();

// Git pull 실행
await gitManager.pull();

// 새 commit hash 확인
const newCommitHash = await gitManager.getCurrentCommitHash();
const hasChanges = oldCommitHash !== newCommitHash;

// 변경이 있거나 force=true인 경우 인덱스 재빌드
if (hasChanges || force) {
const repoPath = gitManager.getRepoPath();
await searchEngine.buildIndex(repoPath);
await cacheManager.saveIndex(searchEngine.exportIndex(), newCommitHash);

return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
updated: true,
oldCommit: oldCommitHash.substring(0, 7),
newCommit: newCommitHash.substring(0, 7),
hasChanges,
message: 'Index rebuilt successfully',
}, null, 2),
}],
};
}

return {
content: [{
type: 'text',
text: JSON.stringify({
success: true,
updated: false,
message: 'Already up to date. Use force=true to rebuild anyway.',
}, null, 2),
}],
};
}

사용 예시​

사용자: "블로그에 새 글을 올렸는데 반영해줘"

Claude: refresh_content 호출

{
"success": true,
"updated": true,
"oldCommit": "abc1234",
"newCommit": "def5678",
"hasChanges": true,
"message": "Index rebuilt successfully"
}

📊 성능 측정 결과​

실제 측정 (5개 포스트, 3개 문서 기준)​

시나리오시간
첫 실행 (Clone + Build)~8초
재시작 (캐시 없음, Pull + Build)~3초
재시작 (캐시 유효)< 1초
검색 쿼리< 10ms

캐시 파일 크기​

$ ls -lh .mcp-cache/index.json
-rw-r--r-- 1 user staff 335K Dec 3 15:30 index.json

약 335KB로 메모리 부담이 거의 없습니다.


📊 Phase 3 결과​

구현된 도구 (1개 추가)​

도구설명
refresh_content수동 저장소 동기화 및 인덱스 재빌드

성능 개선​

항목개선 전개선 후
Cold Start (캐시)~10초< 1초
메모리 사용-~335KB
네트워크 에러서버 실패자동 재시도

최종 MCP 도구 목록 (8개)​

Phase도구
Phase 1list_blog_posts, get_blog_post, list_docs, get_doc
Phase 2search_content, get_recent_posts, get_tags
Phase 3refresh_content

💡 개발 회고​

잘된 점​

  1. Git 기반 설계: 인증 없이 사용 가능하고, commit hash로 캐시 무효화가 깔끔
  2. 단순한 검색: 복잡한 알고리즘 없이 가중치 기반 점수만으로 충분한 품질
  3. 점진적 구현: Phase별로 나눠서 각 단계를 확실히 완성

배운 점​

  1. Map 직렬화: JSON으로 직접 변환 안 되는 문제, Object.entries/fromEntries 활용
  2. 캐시 설계: 무효화 전략이 핵심, Commit hash가 완벽한 키
  3. 에러 복구: 재시도 로직은 선택이 아닌 필수

향후 개선 아이디어​

  • 시맨틱 검색: 벡터 임베딩 기반 유사도 검색
  • 관련 포스트 추천: 태그와 키워드 기반 추천
  • 통계 대시보드: 자주 조회되는 콘텐츠 분석

🔗 전체 소스 코드​

이 시리즈에서 구현한 MCP 서버의 전체 소스 코드는 GitHub에서 확인할 수 있습니다:


참고 자료​