[2026 하계 모각코] - Team 하모 5회차

농담곰·2026년 7월 17일

2026.07.14 19:00 - 22:00
Discord 영상통화로 진행

Week 05 — MCP로 외부 사람들의 일정 가져오기

들어가며

지금까지 4주 동안 만든 건 전부 "나 혼자"의 데이터였다.

1주차: 말 → tool 실행 (임시 메모리)
2주차: 말 → 구조화 (StructuredRequest)
3주차: 구조화 → SQLite 저장 (영구 기록장)
4주차: 저장된 것을 출처별로 검색 (RAG)

내가 만든 일정, 내가 적은 메모, 내가 저장한 기록. 전부 나 하나에 대한 이야기였다.

5주차부터 확 바뀐다. 이제 남의 일정도 다뤄야 한다. 그리고 그 데이터는 내 앱 안이 아니라 외부 서버에 있다.


MCP가 뭔데 갑자기 나오나

지금까지 데이터에 접근하던 방식은 이랬다.

AppSQLiteStore(CONFIG.app_db_path).list_schedules(...)   # 3주차, 내 DB에 직접
REFERENCE_STORE.search_personal_references(...)          # 4주차, 내 ChromaDB에 직접

그런데 다른 사람의 일정은 이렇게 못 한다. 생각해보면 당연하다. 실제로 친구의 카카오톡 대화나 구글 캘린더 DB에 우리 앱이 직접 접속할 수 있을까? 카카오나 구글이 데이터베이스 비밀번호를 우리에게 줄 리 없다. 상대가 열어준 창구를 통해서만 접근할 수 있다.

MCP(Model Context Protocol)는 그 창구를 표준화한 규격이다. Anthropic이 만들어 공개했다.

USB-C 비유

옛날엔 기기마다 케이블이 달랐다. 삼성 폰용, 아이폰용, 카메라용... 그런데 USB-C 표준이 생기니 케이블 하나로 다 된다.

MCP도 같다. "AI와 외부 데이터를 연결할 때는 이런 형식으로 주고받자"고 약속한 것이다. 그러면 어떤 서비스든, 어떤 AI든 같은 방식으로 연결된다.

우리 프로젝트에서 실제로 어떻게 도나

fixed/mcp_client.py를 보면 놀랍게도 MCP 서버가 별도 프로세스로 돌아간다.

client = MultiServerMCPClient({
    "kanana_sqlite": {
        "transport": "stdio",          # 표준 입출력으로 통신
        "command": sys.executable,     # 파이썬을 새로 실행해서
        "args": [str(server_path)],    # 이 서버 파일을 띄운다
    }
})

파이썬 프로세스를 하나 더 띄우고 메시지를 주고받는 구조다. 우리 앱과 완전히 분리된 프로그램인 셈이다. 그래서 "외부 데이터"라는 표현이 진짜로 외부인 것이다.


이번 주 핵심: wrapper Tool

과제 문서에 이번 주 성격이 명확하게 적혀 있었다.

"학생이 직접 SQL을 작성하는 주차가 아니라, MCP tool을 호출하고 그 결과를 agent용 JSON으로 전달하는 wrapper tool을 만드는 주차입니다."

그래서 이번 주 코드는 SQL을 한 줄도 안 짠다. 이런 식이다.

@tool(args_schema=SearchPreviousConversationsInput)
def search_previous_conversations(query, member_names=None, limit=5) -> str:
    """외부 SQLite 데이터베이스에 저장된 이전 대화를 검색합니다."""
    return call_mcp_tool_sync(
        "search_previous_conversations",
        {"query": query, "member_names": member_names, "limit": limit},
    )

함수 본문이 딱 한 줄이다. 실제 조회는 MCP 서버가 다 하고, 우리는 요청을 전달만 한다.

그럼 wrapper는 왜 필요한가

"MCP 서버가 다 하는데 껍데기를 왜 또 만들어?" 싶었다. 이유가 있었다.

LLM은 @tool 데코레이터가 붙은 함수의 이름과 docstring을 읽고 도구를 선택한다. MCP 서버의 tool은 MCP 프로토콜 형식이라 LangChain agent가 바로 알아보지 못한다. wrapper가 그 사이에서 "이건 LangChain용 도구입니다"라고 번역해주는 통역사 역할이다.

거기에 args_schema=...로 Pydantic 입력 검증까지 붙으니, 이상한 인자는 MCP 서버까지 가기 전에 걸러진다.

LLM
 ↓ (docstring 읽고 판단)
wrapper Tool (우리가 만든 것) — 통역 + 입력 검증
 ↓ (call_mcp_tool_sync)
MCP 서버 Tool (별도 프로세스) — 실제 SQL 실행
 ↓
외부 SQLite

검색 → 로드, 왜 두 단계인가

메인 과제 중 두 도구가 짝을 이룬다.

  • search_previous_conversations — 관련 대화가 어디 있는지 찾기 (ID 반환)
  • load_conversation_messages — 그 ID로 전체 메시지 불러오기

도서관에서 책 찾는 것과 똑같다. 검색 컴퓨터로 청구기호를 찾고(1단계), 그 기호로 서가에서 책을 꺼내 읽는다(2단계). 검색할 때 모든 책의 전문이 나온다면 화면이 터지고 원하는 책 찾기는 더 어려워진다.

두 단계로 나눈 이유는 세 가지다.

데이터 양 — 대화 하나에 메시지가 수백 개일 수 있다. 검색 결과 5개의 전문이 다 딸려오면 LLM 컨텍스트를 낭비한다.

선별 — 검색된 5개 중 실제로 필요한 건 1개일 수 있다.

LLM의 판단 기회 — 이게 agentic 하다는 것의 핵심이다. LLM이 검색 결과를 보고 스스로 "어느 대화를 열지" 결정한다.

실제 trace에서 이게 그대로 일어났다.

1) search_previous_conversations(query="철수")
   → conversation_id: "ext_cs" 획득
2) load_conversation_messages(conversation_id="ext_cs")
   ← LLM이 첫 결과에서 ID를 읽어 직접 넘김

우리가 코드로 "ext_cs를 넣어라"고 지시한 게 아니다. LLM이 1단계 결과를 읽고, 거기서 ID를 발견하고, 2단계 호출에 채워 넣었다.

이 연결이 끊기면

conversation_id가 두 단계 사이의 유일한 연결 고리다.

ID가 없으면 2단계를 호출할 방법이 없고, 최악의 경우 LLM이 존재하지 않는 ID를 지어낸다. 엉뚱한 ID가 가면 더 위험하다. 에러가 안 나고 다른 사람의 대화를 정상 반환하기 때문에, 조용히 틀린 답이 나온다.


이번 주의 완성품: collect_member_schedules

메인 과제의 핵심이자, 6주차의 재료가 되는 도구다. 내 일정과 외부 멤버 일정을 같은 구조로 합친다.

왜 같은 구조여야 하나

키를 재는 상황을 생각하면 쉽다. 철수 키는 cm, 영희 키는 inch, 내 키는 "보통"으로 적혀 있으면 누가 큰지 비교할 수 없다.

6주차에서 할 일이 "모든 사람의 바쁜 시간을 늘어놓고 겹치지 않는 구간 찾기"인데,

7월 8일:
  나    ████░░░░░░░░  (10-11시 바쁨)
  철수  ░░░░████░░░░  (13-14시 바쁨)
  영희  ░░░░░░░░████  (16-17시 바쁨)
        ↑↑↑↑     ↑↑   ← 여기가 다 비는 시간

이렇게 나란히 비교하려면 모든 row가 같은 필드를 가져야 한다.

4주차와 정반대인 원칙

흥미로운 지점이다. 4주차에서는 이렇게 배웠다.

"출처가 다르면 반환 형식도 다르게 하라" — 참고자료는 hits, SQLite는 rows

그런데 5주차는 "출처가 달라도 같은 형식으로 합쳐라"다. 모순 같지만 목적이 다르다.

  • 4주차 목적은 구분 — LLM이 "이 정보가 어디서 온 건지" 알아야 근거를 정확히 인용한다.
  • 5주차 목적은 비교 — 여러 사람 일정을 나란히 놓고 겹치는지 봐야 한다.

같은 데이터라도 무엇을 하려는지에 따라 다르게 다뤄야 한다.

정규화가 필요한 이유

멤버 이름 — "철수", "철수님", "김철수"는 사람에겐 같은 사람이지만 컴퓨터에겐 다른 문자열이다. 통일하지 않으면 "철수님 일정 알려줘"에 "없습니다"라고 답하거나, 한 사람이 두 명으로 세어져 "3명 공통 시간"을 찾는데 실제로는 2명인 상황이 된다.

날짜 형식"2026-07-07""2026-07-07T00:00:00"은 같은 날인데 문자열로 비교하면 다르게 판정된다. 하루 차이로 일정이 빠지거나 잘못 포함된다.

def normalize_external_schedule_date_bounds(member_names, date_from, date_to):
    normalized_date_from = str(date_from).split("T", 1)[0].strip() if date_from is not None else ""
    ...

"T"를 기준으로 잘라 날짜만 남긴다. 이러면 모든 날짜가 YYYY-MM-DD로 통일되어 문자열 비교가 곧 날짜 비교가 된다.


삽질 1: 내 일정이 두 번 나왔다

첫 테스트에서 결과가 이랬다.

{"member_name": "나", "title": "개인 코칭", "notes": "앱에 저장된 내 일정"},
{"member_name": "나", "title": "교수님과 면담", "notes": "앱에 저장된 내 일정"},
{"member_name": "철수", ...} × 3,
{"member_name": "나", "title": "개인 코칭", "notes": "앱 개인 일정 자동 동기화"},      ← 또?
{"member_name": "나", "title": "교수님과 면담", "notes": "앱 개인 일정 자동 동기화"}   ← 또?

notes가 서로 다른 게 힌트였다. 역추적해보니 원인은 3주차에 내가 만든 기능이었다.

3주차부터 AppSQLiteStore가 개인 일정을 저장할 때 sync_personal_schedule_to_shared로 외부 공유 저장소에도 복사해왔다. 내 일정이 다른 사람들에게 "나의 바쁜 시간"으로 보이게 하려는 의도된 기능이다. trace에 나오던 shared_sync가 바로 그거였다.

그런데 5주차에서 member_names["나", "철수"]를 그대로 넘기니:

  • 1단계: 앱 SQLite에서 내 일정을 직접 읽음
  • 2단계: MCP가 "나"도 조회 대상으로 보고 외부 복사본까지 가져옴

3주차에 만든 편리한 기능이 5주차에 와서 중복의 원인이 된 셈이다.

해결은 외부 조회에서 "나"를 빼는 것이었다.

external_members = [
    name for name in normalized_members if name != PERSONAL_SHARED_MEMBER_NAME
]

내 일정은 이미 1단계에서 직접 읽었으니 외부 복사본을 또 가져올 필요가 없다.

여기에 임시 메모리 중복도 별도로 막아야 했다. 3주차 심화 과제에서 personal_create_schedule이 임시 메모리와 SQLite에 이중 저장하게 만들었기 때문이다.

saved_ids = {str(row.get("schedule_id")) for row in saved_schedules if row.get("schedule_id")}

temporary_schedules = [
    schedule for schedule in PERSONAL_SCHEDULES
    if _schedule_scope(schedule) == session_id
    and str(schedule.get("id")) not in saved_ids    # 이미 저장된 건 제외
]

결국 내 일정이 세 군데에 존재할 수 있다는 게 이번 주의 까다로운 지점이었다.

① 앱 SQLite (3주차부터 영구 저장)
② PERSONAL_SCHEDULES (1주차 임시 메모리)
③ 외부 공유 저장소 (3주차부터 자동 동기화된 복사본)

삽질 2: 멘토 리뷰로 발견한 두 가지

동작은 잘 되는데 놓친 부분이 있었다. 멘토님이 질문 형태로 짚어줬다.

"어떤 row가 범위 불변식을 벗어나고, 외부 일정의 출처를 증명하려면 어떤 식별자가 끝까지 보여야 할까요?"

date가 없으면 필터를 통과한다

if request.date and normalized_from and request.date < normalized_from:
    continue

request.date and ... 조건 때문에, dateNone이면 두 if문이 모두 False가 되어 그냥 통과한다.

원래 의도는 "None < "2026-07-07" 비교하다 TypeError 나는 걸 막자"였다. 에러 방지에만 신경 쓰다가, 그 방어가 만든 구멍을 못 봤다.

문제는 7월 7~17일 사이 busy time을 요청했는데 날짜 없는 일정이 섞여 나온다는 것이다. 6주차에서 "그럼 7월 8일 10시는 되나?"를 계산할 때, 날짜 없는 일정은 어느 날에 배치해야 할지 알 수 없다. 멘토님 표현대로 "요청된 범위 내에 실제 날짜가 존재해야 한다는 불변식"이 깨진다.

if not request.date:
    continue    # 날짜 없는 일정은 범위 판정이 불가능하니 제외
if normalized_from and request.date < normalized_from:
    continue

출처를 받아놓고 버렸다

MCP가 반환하는 row에는 source_conversation_id가 있었다.

{
  "member_name": "철수",
  "title": "API 연동 실습",
  "source_conversation_id": "ext_cs"    ← 있었음
}

그런데 우리 코드는 6개 필드만 골라 새 dict를 만들었다.

rows.append({
    "member_name": row.get("member_name"),
    "title": row.get("title"),
    "date": row.get("date"),
    "start_time": row.get("start_time"),
    "end_time": row.get("end_time"),
    "notes": row.get("notes"),
    # source_conversation_id가 없음 → 여기서 버려짐
})

정보가 없었던 게 아니라, 받아놓고 흘려버린 것이다.

기능적으로는 문제가 없었다. "철수는 7월 7일 10시에 API 연동 실습이 있어요"라는 답은 잘 나왔다. 문제는 근거를 제시할 수 없다는 것이다.

논문에서 "연구에 따르면 A는 B다"라고만 쓰고 각주를 안 단 것과 같다. 내용은 맞을 수 있지만 "어느 연구요?"라고 물으면 답할 수 없다. 검증 불가능한 주장이 되는 것이다.

5주차 흐름 자체가 "대화 검색 → 그 대화에서 일정 추출"인데, 최종 결과에서 그 연결이 끊기면 사용자가 "이 일정 어디서 나온 정보야?"라고 물었을 때 답할 수 없다. LLM은 search_previous_conversations를 처음부터 다시 호출해야 한다.

rows.append({
    ...,
    "source_conversation_id": row.get("source_conversation_id"),
    "schedule_id": row.get("schedule_id"),
})

내 일정 row에도 같은 키를 두고, source_conversation_idNone(외부 대화 출처가 아니므로), schedule_id는 SQLite ID를 넣어 구조를 일관되게 맞췄다.

수정 후 결과다.

{"member_name": "나",   "title": "개인 코칭",      "source_conversation_id": null, "schedule_id": "sch_912ccb61dd"},
{"member_name": "철수", "title": "API 연동 실습",  "source_conversation_id": "ext_cs", "schedule_id": null}

이제 철수 일정이 ext_cs 대화에서 나왔다는 게 끝까지 남는다.


배운 것

에러가 안 나는 것과 결과가 올바른 것은 다르다

멘토님이 답을 주는 대신 질문으로 물어본 게 인상적이었다. "이 코드 고쳐라"가 아니라 "어떤 row가 불변식을 벗어나는가?"라고. 불변식이라는 관점 자체를 생각해보게 하는 질문이었다.

"날짜 범위로 조회했으면, 결과의 모든 row는 그 범위 안의 날짜를 가져야 한다" — 이게 불변식이다. 코드가 에러 없이 돌아가는 것과, 결과가 논리적으로 올바른 것은 별개다.

데이터 파이프라인에서는 중간에 정보를 흘리지 말 것

지금 당장 안 쓰는 정보라도, 그게 근거이거나 다음 단계의 재료라면 끝까지 들고 가야 한다. 5주차 결과가 6주차 입력이니 더욱 그렇다.

이전 주차의 결정이 나중에 부메랑으로 온다

3주차에 만든 자동 동기화가 5주차에 중복의 원인이 됐다. 기능을 추가할 때는 그게 나중에 어떤 부작용을 만들지도 생각해야 한다는 걸 배웠다.

trace를 보지 않으면 알 수 없다

중복도, 출처 누락도 답변 텍스트만 봐서는 발견할 수 없었다. LLM이 "나는 7월 17일에 개인 코칭이 있어요"라고 자연어로 답하면 중복이 보이지 않는다. trace의 rows를 직접 열어봐야 드러난다. 4주차에서도 엉뚱한 도구가 불린 걸 trace로 발견했는데, 같은 교훈이 반복됐다.


6주차 예고

과제 주석에 이미 다음이 예고되어 있다.

"Week 6 추가 과제(find_common_available_slots)가 이 tool의 rows를 busy_rows 근거로 사용합니다."
"여러 사람의 최종 회의 시간 선택은 Week 6 범위입니다."

5주차는 "각자 언제 바쁜지 모으기"까지, 6주차는 "그럼 다 같이 될 수 있는 시간은?"을 계산한다. 재밌는 건 우리가 모은 게 busy time(안 되는 시간) 인데 6주차에 필요한 건 되는 시간이라는 점이다. 이 데이터를 뒤집어 빈 구간을 찾아야 한다. 그러니 busy time이 부정확하면(중복이거나 날짜가 없거나) 계산 결과가 통째로 어긋난다.

이번 주에 중복 제거와 불변식에 신경 쓴 이유가 바로 여기 있다.

profile
둥글게살자

0개의 댓글