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

농담곰·2026년 7월 17일

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

Week 03 — 구조화된 요청을 SQLite에 저장하고 다시 불러오기

들어가며

1주차에서는 tool로 일정을 만들었고, 2주차에서는 자연어를 구조화(StructuredRequest)했다. 그런데 1주차의 일정은 파이썬 리스트(PERSONAL_SCHEDULES)에만 있어서 앱을 끄면 전부 사라졌다. 3주차의 목표는 이 기억을 진짜 데이터베이스(SQLite)로 옮기는 것이다.

한 줄로 말하면, Nana에게 앱을 재시작하거나 새 대화를 열어도 사라지지 않는 "기록장" 을 만들어주는 주차다.


전체 흐름

사용자: "내일 10시 개인 코칭 저장해줘"
         ↓
[2주차] extract_schedule_request → 자연어를 StructuredRequest로 구조화
         ↓
[3주차] save_structured_request → SQLite DB에 저장
         ↓
(앱을 껐다 켜거나 새 대화를 열어도)
         ↓
사용자: "내 일정 보여줘"
         ↓
[3주차] personal_list_saved_schedules → DB에서 조회 → 그대로 남아있음

핵심 개념

1. 얇은 tool + 실제 DB 로직 분리

우리가 만드는 tool은 SQL을 직접 짜지 않는다. 실제 DB 작업은 fixed/app_store.pyAppSQLiteStore가 담당하고, 우리 tool은 그 store에 일을 넘기는 "얇은 입구" 역할만 한다.

def _store() -> AppSQLiteStore:
    return AppSQLiteStore(CONFIG.app_db_path)

@tool(args_schema=SavedRequestListInput)
def list_saved_requests(kind=None, date_from=None, date_to=None) -> str:
    rows = _store().list_saved_requests(kind=kind, date_from=date_from, date_to=date_to)
    return json_payload(tool_result("list_saved_requests", rows=rows))

이 함수 안엔 SQL이 하나도 없다. store의 메서드를 호출하고 결과를 JSON으로 감싸 반환할 뿐이다. 복잡한 SQL은 store에 몰아두고, tool은 "LLM이 부를 수 있는 입구"에만 집중하는 구조다.

2. @tool(args_schema=...) — Pydantic 입력 검증

1~2주차는 @tool만 썼지만, 3주차는 입력 스키마를 붙인다.

@tool(args_schema=SaveStructuredRequestInput)
def save_structured_request(kind="unknown", title=None, ...) -> str:
    # 이 본문이 실행될 땐 이미 인자 검증이 끝나 있다
    payload = {...}
    saved = _store().save_structured_request(payload)
    ...

args_schema가 붙으면 tool 본문이 실행되기 전에 Pydantic이 인자를 검증한다. 그래서 본문에서는 Pydantic 객체를 다시 만들 필요 없이, 이미 검증된 값으로 저장 dict만 정리하면 된다.

SaveStructuredRequestInput은 2주차 StructuredRequest를 상속하고 source_schedule_id 하나만 추가한 것이다. 주차가 쌓이면서 이전 주차 스키마를 재사용·확장하는 구조가 여기서도 드러난다.

3. kind별로 저장 테이블이 다르다

app_store.pysave_structured_request는 kind를 보고 저장 위치를 나눈다.

personal_schedule / group_schedule → schedules 테이블
todo                               → todos 테이블
reminder                           → reminders 테이블
(모든 kind의 원본)                   → structured_requests 테이블 (감사 로그)

즉 원본은 structured_requests에 통째로 남고, 종류별로 조회하기 쉬운 정규화 테이블에 한 번 더 풀어서 저장한다.


저장 → 조회 → 유지 (메인 과제)

메인 과제는 "저장한 일정이 새 대화에서도 유지되는" 최소 기록장을 만드는 것이다. 구현한 tool은:

  • save_structured_request — 구조화 결과를 SQLite에 저장
  • list_saved_requests / get_saved_request — 저장된 원본 요청을 목록/단건 조회
  • personal_list_saved_schedules — 저장된 일정 목록 조회

실제로 "내일 10시 개인 코칭 저장해줘" → "내 일정 보여줘" → 앱 재시작 → "내 일정 보여줘"를 했을 때 일정이 그대로 남아있으면 성공이다.


심화 과제 — 수정/삭제 CRUD와 안전장치

메인 과제 위에 수정·삭제 기능을 얹었다.

@tool(args_schema=SavedScheduleUpdateInput)
def personal_update_saved_schedule(schedule_id, title=None, ...) -> str:
    result = _store().update_schedule(schedule_id, title=title, ...)
    if result is None:
        # ID를 못 찾으면 ok=False
    # 있으면 updated_schedule / shared_sync 반환

특히 삭제는 위험하기 때문에 안전 가드를 뒀다. 뒤에서 자세히 다룬다.


막혔던 지점들 (그리고 멘토 리뷰)

1. 저장했는데 조회가 안 되는 문제

"저장했다"는 답은 나오는데 "내일 일정 알려줘" 하면 "일정 없음"이 나왔다.

trace를 열어보니 LLM이 3주차 tool(personal_list_saved_schedules)이 아니라 이름이 비슷한 1주차 tool(personal_list_schedules) 을 호출하고 있었다. 1주차 것은 임시 메모리만 봐서 SQLite에 저장된 데이터를 못 찾은 것이다.

프롬프트에 "저장된 일정 조회는 반드시 saved 버전을 쓰고, 1주차 tool은 쓰지 마라"고 명시해서 해결했다.

교훈: tool 이름이 비슷하면 LLM이 헷갈린다. 이름과 docstring을 LLM 관점에서 명확히 구분되게 지어야 한다.

2. 미구현 tool이 목록에 있으면 안 된다 (멘토 리뷰)

아직 구현 안 한 tool(personal_update_saved_schedule 등)을 week03_tools() 목록에 넣어뒀더니, LLM이 "삭제해줘" 요청에 그 빈 tool을 호출해서 None을 반환하는 문제가 있었다.

LLM은 등록된 모든 tool의 이름·docstring을 보고 호출 여부를 판단하기 때문에, 미구현 tool은 목록에서 빼거나 구현을 완료해야 한다. 이번엔 심화 과제를 마저 구현하는 쪽을 택했다.

3. 삭제 안전 기준 (멘토 리뷰)

처음 삭제 로직은 date, title, start_time하나라도 있으면 삭제를 진행했다. 문제는 "5시 이후 일정 삭제해줘" 같은 요청이었다. start_time만 필터로 들어가면 모든 날짜의 5시 일정이 다 지워질 수 있었다.

그래서 기준을 세웠다: 삭제는 반드시 schedule_idsdate가 있어야 진행하고, 시간/제목만으로는 거부한다.

strong_filter = bool(schedule_ids) or bool(date)
if not delete_all and not strong_filter:
    return tool_result(
        "personal_delete_saved_schedules",
        ok=False,
        reason="삭제하려면 일정 ID나 날짜가 필요합니다. 시간/제목만으로는 삭제하지 않습니다.",
        ...
    )

실제 테스트에서 trace를 확인하니, LLM이 삭제할 때 date="2026-07-20"를 함께 채워서 "특정 날짜의 그 일정 하나"만 정확히 지웠다. 프롬프트(항상 날짜를 챙기게 유도)와 코드 가드(날짜 없으면 거부)가 두 겹으로 안전을 보장했다.

4. 그 외 리뷰 반영

  • unwrap_legacy_payload가 dict만 처리하던 걸, Pydantic 객체로 들어오는 경우도 model_dump()로 변환해 처리하도록 보완
  • 삭제 응답을 tool_result 헬퍼로 통일해 코드 일관성 확보
  • personal_list_saved_schedules에 todo/reminder가 들어오면 강제로 personal_schedule로 바꾸던 것을, 강제 변환 대신 "이 도구는 일정만 조회한다"고 안내하고 list_saved_requests로 유도하도록 수정

배운 것

가장 크게 배운 건 "LLM의 답변과 실제 동작은 다를 수 있다" 는 점이다. "저장했다"고 말해도 실제로는 안 됐거나, 엉뚱한 tool을 부른 경우가 있었다. 그래서 문제가 생기면 답변 텍스트가 아니라 trace를 먼저 열어서 실제 tool 호출을 확인하는 습관이 필수였다.

또 하나는 위험한 동작(삭제)은 처음부터 안전 가드를 염두에 두고 설계해야 한다는 것이다. "조건이 하나라도 있으면 삭제"처럼 느슨하게 두면, 사용자가 의도한 것보다 훨씬 많은 데이터가 지워지는 사고가 날 수 있다. 멘토 리뷰 덕분에 이런 관점을 실제 코드로 익힐 수 있었다.

profile
둥글게살자

0개의 댓글