웹집사 MCP 개발기: 코드베이스를 자연어로 검색하는 AI 집사 만들기
신규 입사자가 "Button 컴포넌트 어떻게 써?"라고 물으면, 코드를 직접 찾아서 답해주는 AI 집사를 만들었습니다. 이 글에서는 MCP 서버를 개발하면서 겪은 고민과 해결 과정을 공유하고자 합니다.
문제 인식
저희 웹팀은 여러 개의 레포지토리를 운영하고 있습니다. 신규 입사자나 타 팀 개발자분들이 웹 코드베이스를 파악하려면 담당자에게 직접 질문해야 했고, 이로 인해 양쪽 모두 업무 흐름이 끊기는 문제가 있었습니다.
- "이 컴포넌트 어디 있어요?"
- "앱 내 웹뷰에서 네이티브 통신 어떻게 해요?"
- "예약 관련 API 연동 코드 좀 보여주세요"
이런 질문이 슬랙에 반복적으로 올라왔습니다. 답변하는 쪽도 받는 쪽도 컨텍스트 스위칭 비용을 지불해야 했습니다.
웹 관련 질문에 답해주는 AI 도구가 있으면 좋겠다는 생각에서 웹집사 프로젝트를 시작하게 되었습니다.
해결 방법: 웹집사 MCP
MCP란?
MCP(Model Context Protocol)는 AI 모델이 외부 도구와 통신하는 표준 프로토콜입니다. 쉽게 말해, Claude 같은 AI가 데이터베이스, API, 파일 시스템 등 외부 리소스에 접근할 수 있게 해주는 다리 역할을 합니다.
기존에는 AI에게 코드를 직접 복사해서 붙여넣기 해야 했다면, MCP를 사용하면 AI가 직접 필요한 코드를 검색해서 가져올 수 있습니다.
웹집사는 이 MCP를 활용해서 만들었습니다. Claude Code에서 웹집사를 호출하면 여러 레포의 코드를 검색하고, 관련 코드 조각을 컨텍스트로 제공해줍니다.
시스템 구조
웹집사는 크게 두 가지 프로세스로 동작합니다.
[인덱싱] 코드를 검색 가능한 형태로 변환
Git Pull → 파일 수집 → AST 파싱 → 청킹 → 임베딩 → DB 저장
[검색] 자연어 질문에 맞는 코드 찾기
쿼리 → 동의어 확장 → 하이브리드 검색 → 점수 합산 → 결과 반환핵심 기술 구현
1. AST 기반 코드 청킹
AST(Abstract Syntax Tree) 는 코드를 트리 구조로 표현한 것입니다.
단순히 파일을 통째로 저장하면 검색 품질이 떨어집니다. 파일 하나에 여러 함수와 컴포넌트가 섞여 있기 때문입니다. tree-sitter라는 파서를 사용해서 코드를 AST로 변환하고, 함수/컴포넌트/타입 단위로 분리했습니다.
청킹 대상 노드 타입
어떤 코드 단위로 분리할지 정의했습니다.
# 의미 있는 코드 단위로 분리하기 위한 노드 타입 정의
CHUNK_NODE_TYPES = [
"function_declaration", # function foo() {}
"arrow_function", # const foo = () => {}
"export_statement", # export const Component
"interface_declaration", # interface Props {}
"type_alias_declaration", # type MyType = ...
"class_declaration", # class MyClass {}
]청킹 구현
tree-sitter로 파일을 파싱하고, 위에서 정의한 노드 타입에 해당하는 코드 블록을 추출합니다.
def chunk_file_with_ast(file_path, repo_name):
# 파일 확장자에 따라 적절한 파서 선택
if file_path.suffix in [".tsx", ".jsx"]:
parser = tsx_parser
else:
parser = ts_parser
# 파일 내용을 바이트로 읽어서 파싱
content = file_path.read_bytes()
tree = parser.parse(content)
chunks = []
# AST를 순회하며 청킹 대상 노드 수집
def collect_declarations(node, depth=0):
if node.type in CHUNK_NODE_TYPES:
chunk = {
"content": node.text.decode("utf-8"),
"node_type": node.type,
"node_name": extract_node_name(node),
"start_line": node.start_point[0] + 1,
"end_line": node.end_point[0] + 1,
"file_path": str(file_path),
"repo": repo_name
}
chunks.append(chunk)
# 깊이 2까지만 탐색 (너무 깊이 들어가면 불필요한 노드까지 수집)
if depth < 2:
for child in node.children:
collect_declarations(child, depth + 1)
collect_declarations(tree.root_node)
return chunks이렇게 하면 "Button 컴포넌트" 검색 시 Button 관련 코드만 정확히 반환됩니다.
2. 하이브리드 검색
왜 여러 검색 방식을 조합했을까요?
처음에는 벡터 검색만 사용했는데, 몇 가지 문제가 있었습니다.
- 벡터 검색만 사용하면: 의미는 비슷하지만 정확한 키워드가 없는 결과가 나옵니다. "useAuth 훅"을 검색했는데 인증과 관련 없는 다른 훅이 나오기도 했습니다.
- 키워드 검색만 사용하면: 동의어나 유사 표현을 놓칩니다. "로그인"을 검색했는데 "signIn"이라고 작성된 코드를 못 찾는 경우가 있었습니다.
그래서 세 가지 검색을 조합했습니다.
| 검색 방식 | 가중치 | 역할 |
|---|---|---|
| 벡터 검색 | 50% | "로그인 기능"과 "인증 처리"가 비슷한 의미임을 이해 |
| BM25 키워드 검색 | 30% | 정확한 함수명, 변수명 매칭 |
| 패턴 매칭 | 20% | 특정 코드 패턴(예: 훅, API 호출) 인식 |
하이브리드 검색 구현
def search_code(query, top_k=5, repo_filter=None):
# 1. 쿼리 전처리 - 동의어 확장
expanded_query = expand_query_with_synonyms(query)
# 2. 벡터 검색 - 의미 기반 유사도
query_embedding = get_embedding(query)
vector_results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k * 3, # 후보를 넉넉하게 가져옴
where={"repo": repo_filter} if repo_filter else None
)
# 벡터 거리를 점수로 변환 (거리가 가까울수록 높은 점수)
for i, distance in enumerate(vector_results["distances"][0]):
vector_scores[i] = max(0, min(1, 1 - distance / 10))
# 3. BM25 키워드 검색 - 정확한 토큰 매칭
keyword_scores = bm25_search(expanded_query, candidates)
# 4. 패턴 매칭 - 특수 패턴 보너스
pattern_scores = pattern_search(query, candidates)
# 5. 최종 점수 계산
final_results = []
for i, candidate in enumerate(candidates):
final_score = (
vector_scores.get(i, 0) * 0.5 +
keyword_scores.get(i, 0) * 0.3 +
pattern_scores.get(i, 0) * 0.2
)
candidate["score"] = final_score
final_results.append(candidate)
# 점수 순으로 정렬 후 상위 k개 반환
final_results.sort(key=lambda x: -x["score"])
return final_results[:top_k]3. BM25 키워드 검색
BM25는 전통적인 정보 검색 알고리즘입니다. TF-IDF의 발전된 버전이라고 생각하시면 됩니다.
def tokenize(text):
"""텍스트를 토큰으로 분리"""
# 영문 식별자와 한글을 각각 토큰으로 분리
tokens = re.findall(r'[a-zA-Z_][a-zA-Z0-9_]*|[가-힣]+', text.lower())
return tokens
def calculate_bm25_score(query_tokens, doc_tokens, avg_doc_len, k1=1.5, b=0.75):
"""BM25 점수 계산"""
doc_len = len(doc_tokens)
doc_freq = Counter(doc_tokens)
score = 0
for token in query_tokens:
if token in doc_freq:
tf = doc_freq[token]
# BM25 공식 적용
numerator = tf * (k1 + 1)
denominator = tf + k1 * (1 - b + b * (doc_len / avg_doc_len))
score += numerator / denominator
return score
def bm25_search(query, candidates):
"""모든 후보에 대해 BM25 점수 계산"""
query_tokens = tokenize(query)
# 평균 문서 길이 계산
all_doc_lengths = [len(tokenize(c["content"])) for c in candidates]
avg_doc_len = sum(all_doc_lengths) / len(all_doc_lengths)
scores = {}
for i, candidate in enumerate(candidates):
doc_tokens = tokenize(candidate["content"])
scores[i] = calculate_bm25_score(query_tokens, doc_tokens, avg_doc_len)
# 정규화 (0~1 범위로)
max_score = max(scores.values()) if scores else 1
return {i: s / max_score for i, s in scores.items()}4. 도메인 특화 동의어 사전
팀마다 고유한 용어가 있습니다. 저희 팀에서 "브릿지"라고 하면 네이티브 앱과 웹뷰 간 통신 코드를 의미합니다. 이런 도메인 지식을 동의어 사전으로 구축해서, 팀 용어로 검색해도 관련 코드를 잘 찾을 수 있게 했습니다.
# 도메인 특화 동의어 사전
SYNONYM_DICT = {
# 팀 용어 -> 실제 코드에서 사용되는 키워드들
"브릿지": ["bridge", "postMessage", "native"],
"디자인시스템": ["design-system", "Button", "Input", "Modal"],
"인증": ["auth", "login", "signIn", "token"],
"예약": ["reservation", "booking", "schedule"],
}
# 패턴 기반 검색을 위한 정규식
PATTERN_KEYWORDS = {
"훅": [r"use[A-Z]\w+"], # useAuth, useState 등
"API": [r"fetch\(", r"axios"], # API 호출 패턴
"컴포넌트": [r"export.*function", r"export.*const.*=.*\("],
}
def expand_query_with_synonyms(query):
"""쿼리를 동의어로 확장"""
expanded_terms = query.split()
for key, synonyms in SYNONYM_DICT.items():
if key in query:
expanded_terms.extend(synonyms)
# 중복 제거 후 반환
return " ".join(list(dict.fromkeys(expanded_terms)))API 호출 없이 무료로 동작하면서도 검색 품질을 크게 향상시켰습니다.
5. 임베딩과 캐싱
임베딩(Embedding) 은 텍스트를 숫자 벡터로 변환하는 기술입니다.
예를 들어 "로그인"이라는 단어를 [0.1, 0.3, -0.2, ...] 같은 숫자 배열로 변환합니다. 비슷한 의미의 텍스트는 비슷한 벡터를 가지게 되어서, 벡터 간 거리를 계산하면 의미적 유사도를 알 수 있습니다.
OpenAI의 text-embedding-3-large 모델을 사용했고, 동일한 쿼리가 반복될 때 API 비용을 줄이기 위해 캐시도 구현했습니다.
# 임베딩 캐시 (LRU 방식)
_embedding_cache = {}
MAX_CACHE_SIZE = 100
def get_embedding(text):
"""텍스트의 임베딩 벡터를 반환 (캐시 적용)"""
# 캐시 키는 텍스트의 앞 500자로 제한
cache_key = text[:500]
# 캐시 히트
if cache_key in _embedding_cache:
return _embedding_cache[cache_key]
# 캐시가 가득 차면 오래된 항목 삭제
if len(_embedding_cache) >= MAX_CACHE_SIZE:
# 앞쪽 절반 삭제 (간단한 LRU 구현)
keys_to_remove = list(_embedding_cache.keys())[:MAX_CACHE_SIZE // 2]
for key in keys_to_remove:
del _embedding_cache[key]
# OpenAI API 호출
response = openai_client.embeddings.create(
input=[text],
model="text-embedding-3-large"
)
embedding = response.data[0].embedding
# 캐시에 저장
_embedding_cache[cache_key] = embedding
return embedding6. MCP 서버 구현
FastMCP를 사용해서 Claude Code에서 호출할 수 있는 도구를 정의했습니다.
from fastmcp import FastMCP
mcp = FastMCP("web-butler")
@mcp.tool()
def search_web_code(
query: str,
repo: str = None,
node_type: str = None,
top_k: int = 5
) -> str:
"""
웹팀 코드베이스를 자연어로 검색합니다.
Args:
query: 검색 쿼리 (예: "Button 컴포넌트 사용법")
repo: 특정 레포로 필터링 (선택사항)
node_type: 노드 타입 필터 - function, component 등 (선택사항)
top_k: 반환할 결과 수 (기본값: 5)
Returns:
검색된 코드 조각들을 마크다운 형식으로 반환
"""
results = search_code(query, top_k, repo, node_type)
# 마크다운 형식으로 결과 포매팅
output = f"## '{query}' 검색 결과\n\n"
for i, result in enumerate(results, 1):
output += f"### {i}. {result['node_name']} ({result['node_type']})\n"
output += f"**파일**: `{result['file_path']}`\n"
output += f"**라인**: {result['start_line']}-{result['end_line']}\n\n"
output += f"```typescript\n{result['content']}\n```\n\n"
return output
@mcp.tool()
def ask_web_code(question: str, top_k: int = 5) -> str:
"""
코드베이스에 대해 자연어로 질문합니다.
관련 코드를 검색하고 컨텍스트와 함께 반환합니다.
Args:
question: 질문 (예: "로그인 로직이 어떻게 구현되어 있어?")
top_k: 참고할 코드 조각 수 (기본값: 5)
Returns:
관련 코드와 함께 질문에 답변할 수 있는 컨텍스트
"""
results = search_code(question, top_k)
context = "다음 코드를 참고하여 질문에 답변해주세요:\n\n"
for result in results:
context += f"### {result['file_path']}\n"
context += f"```typescript\n{result['content']}\n```\n\n"
context += f"\n**질문**: {question}"
return context
# 서버 실행
if __name__ == "__main__":
mcp.run()개발팀 MCP 연동
웹집사 단독으로도 유용하지만, 진정한 가치는 개발팀 연동에서 나옵니다. 회사에는 이미 iOS 집사, Server 집사가 구축되어 있었습니다.
"웹에서 결제 API 호출하는 부분이랑 서버 엔드포인트 둘 다 보여줘"이런 질문에 웹-서버 간 코드를 한 번에 파악할 수 있게 되었습니다. 각 팀의 MCP가 독립적으로 동작하면서도 Claude를 통해 자연스럽게 연결됩니다.
기술 스택 정리
| 역할 | 기술 | 선택 이유 |
|---|---|---|
| 코드 파싱 | tree-sitter | 다양한 언어 지원, 빠른 파싱 속도 |
| 임베딩 | OpenAI text-embedding-3-large | 높은 품질, 다국어 지원 |
| 벡터 DB | ChromaDB | 설치 간편, Python 친화적 |
| MCP 프레임워크 | FastMCP | 간결한 API, 빠른 개발 |
| 키워드 검색 | BM25 알고리즘 | 검증된 알고리즘, 구현 간단 |
결과
웹집사 도입 후 체감한 변화는 다음과 같습니다.
- 온보딩 시간 단축: 신규 입사자가 코드베이스를 스스로 탐색할 수 있게 됨
- 커뮤니케이션 비용 감소: "이거 어디 있어요?" 질문이 현저히 줄어듦
- 개발팀 협업 개선: 웹-앱-서버 간 기술적 맥락을 한 번에 파악 가능
어려웠던 점
개발 과정에서 가장 어려웠던 부분은 검색 품질 튜닝이었습니다.
벡터 검색, BM25, 패턴 매칭의 가중치를 어떻게 설정해야 할지 정답이 없었습니다. 실제 팀원들이 자주 하는 질문을 수집하고, 각 질문에 대해 기대하는 답변이 나오는지 반복적으로 테스트했습니다.
처음에는 벡터 검색 70%, 키워드 30%로 시작했는데, 정확한 함수명 검색이 잘 안 됐습니다. 여러 번의 실험 끝에 현재의 50:30:20 비율에 도달했습니다.
결국 가장 효과적이었던 건 도메인 용어를 동의어 사전에 추가하는 것이었습니다. 기술적으로 복잡한 알고리즘보다, 우리 팀만의 맥락을 반영하는 게 더 중요했습니다.
앞으로 시도해보고 싶은 것들
1. 더 정교한 청킹 전략
현재는 함수/컴포넌트 단위로 청킹하고 있는데, 관련된 코드끼리 묶어서 청킹하면 더 좋은 결과가 나올 것 같습니다. 예를 들어 컴포넌트와 그 컴포넌트가 사용하는 훅을 함께 묶는 방식입니다.
2. 검색 품질 대시보드
어떤 질문이 잘 동작하고 어떤 질문이 실패하는지 모니터링하는 대시보드가 있으면 지속적으로 품질을 개선할 수 있을 것 같습니다.
마무리
이번 웹집사 MCP 개발을 통해 웹팀 그리고 개발팀 전체의 생산성을 높일 수 있는 좋은 경험을 했다고 생각합니다. 무엇보다 AI를 활용해 더 나은 가치를 만들기 위한 노력이 생각보다 재미있다는 사실을 알게 되어서, 앞으로도 이러한 비효율적인 부분을 찾아 해결하고 싶습니다.