웹집사 MCP 개발기

여러 레포를 자연어로 검색하는 AI 집사를 만들어 개발팀 협업을 개선한 경험을 공유합니다.

웹집사 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 embedding

6. 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높은 품질, 다국어 지원
벡터 DBChromaDB설치 간편, Python 친화적
MCP 프레임워크FastMCP간결한 API, 빠른 개발
키워드 검색BM25 알고리즘검증된 알고리즘, 구현 간단

결과

웹집사 도입 후 체감한 변화는 다음과 같습니다.

  • 온보딩 시간 단축: 신규 입사자가 코드베이스를 스스로 탐색할 수 있게 됨
  • 커뮤니케이션 비용 감소: "이거 어디 있어요?" 질문이 현저히 줄어듦
  • 개발팀 협업 개선: 웹-앱-서버 간 기술적 맥락을 한 번에 파악 가능

어려웠던 점

개발 과정에서 가장 어려웠던 부분은 검색 품질 튜닝이었습니다.

벡터 검색, BM25, 패턴 매칭의 가중치를 어떻게 설정해야 할지 정답이 없었습니다. 실제 팀원들이 자주 하는 질문을 수집하고, 각 질문에 대해 기대하는 답변이 나오는지 반복적으로 테스트했습니다.

처음에는 벡터 검색 70%, 키워드 30%로 시작했는데, 정확한 함수명 검색이 잘 안 됐습니다. 여러 번의 실험 끝에 현재의 50:30:20 비율에 도달했습니다.

결국 가장 효과적이었던 건 도메인 용어를 동의어 사전에 추가하는 것이었습니다. 기술적으로 복잡한 알고리즘보다, 우리 팀만의 맥락을 반영하는 게 더 중요했습니다.


앞으로 시도해보고 싶은 것들

1. 더 정교한 청킹 전략

현재는 함수/컴포넌트 단위로 청킹하고 있는데, 관련된 코드끼리 묶어서 청킹하면 더 좋은 결과가 나올 것 같습니다. 예를 들어 컴포넌트와 그 컴포넌트가 사용하는 훅을 함께 묶는 방식입니다.

2. 검색 품질 대시보드

어떤 질문이 잘 동작하고 어떤 질문이 실패하는지 모니터링하는 대시보드가 있으면 지속적으로 품질을 개선할 수 있을 것 같습니다.


마무리

이번 웹집사 MCP 개발을 통해 웹팀 그리고 개발팀 전체의 생산성을 높일 수 있는 좋은 경험을 했다고 생각합니다. 무엇보다 AI를 활용해 더 나은 가치를 만들기 위한 노력이 생각보다 재미있다는 사실을 알게 되어서, 앞으로도 이러한 비효율적인 부분을 찾아 해결하고 싶습니다.


참고