2026.09 ~
소개
저 한 명이 쓰는 개인 비서라 사용자 지표는 없습니다. 이하 내용은 작은 로컬 모델의 한계 안에서 무엇을 모델에게 맡기고 무엇을 코드로 옮겼는지에 대한 설계 기록입니다.
Discord DM으로 부탁하면 집 서버에서 돌아가는 오픈소스 AI 모델(Ollama)이 처리하는 개인 비서 봇입니다. ChatGPT 같은 외부 AI 서비스 없이 그래픽 메모리 2~4GB GPU에서 동작하며, 일정·할일 관리(Google Calendar·Tasks), 웹 검색, 알림·반복 작업, 무작위 시각 체크인, 매일 아침 보안 뉴스와 코드 의존성 점검을 한 봇에서 처리합니다.
여기서부터는 실제로 부딪힌 문제를 케이스로 정리했습니다. 각 케이스는 어떤 상황이었고 무엇이 제약이었는지에서 시작해, 그래서 어떻게 판단했고 무엇이 남았는지까지 이어집니다.
집에 있는 10년 된 노트북을 서버로 바꾸고, 그 위에 Ollama(내 컴퓨터에서 오픈소스 AI 모델을 돌리는 도구)와 n8n(자동화 도구)을 올려 아침마다 뉴스 요약을 Discord로 받게 했습니다(→ 블로그 시리즈). 하지만 이건 정해진 시각에 정해진 일만 하는 서버였고, 제가 무언가를 부탁할 방법은 없었습니다.
부탁을 알아듣게 하려고 n8n 안에서 요청 종류별로 갈림길(IF 노드)을 만들었더니, 할 수 있는 일이 하나 늘 때마다 갈림길도 하나씩 늘었습니다. 그런데 “이 요청은 일정 등록이다” 같은 판단은 모델이 이미 할 수 있는 일이었습니다. 그래서 모델의 tool calling을 코드로 직접 다루는 봇을 만들었습니다. tool calling은 모델이 답을 문장으로 쓰는 대신 “캘린더 등록 함수를 이 인자로 불러 달라”는 요청을 돌려주는 기능입니다. 모델은 무엇을 할지 고르기만 하고, 실제 실행은 제가 쓴 코드가 합니다. 이후 n8n에는 뉴스 안내만 남기고 나머지는 이 봇으로 옮겼습니다.
전제는 하나였습니다. ChatGPT 같은 외부 AI 서비스를 쓰지 않는다. 일정·할일 같은 개인 데이터가 집 밖으로 나가지 않고 사용량 과금도 없게 하려면, 그래픽 메모리 2~4GB짜리 GPU에 들어가는 작은 모델(매개변수 20억~40억 개 규모, 이하 2B·4B급)로 해결해야 했습니다. 이런 작은 모델은 대형 서비스보다 실수가 잦습니다. 이 프로젝트의 설계 결정은 대부분 이 제약에서 나왔습니다.
모든 대화는 Discord DM으로 합니다. 말로 부탁해도 되고, /todo 같은 명령으로 해도 됩니다.
| 기능 | 예시 |
|---|---|
| 일정 · 할일 | “내일 3시에 회의 잡아줘”, “오늘 뭐 해야 해?” → Google Calendar · Google Tasks |
| 알림 · 반복 작업 | “내일 9시에 블로그 쓰라고 알려줘”, “매주 월요일 9시에 주간 점검 알려줘” |
| 아침 브리핑 | 매일 아침(기본 8시) 오늘의 할일과 일정을 정리해 보냄 |
| 웹 검색 | 직접 운영하는 검색 엔진(SearXNG)으로 검색해 답함 |
| 체크인 | 하루 몇 번 무작위 시각에 “『견적서 보내기』 끝났어요?”처럼 묻고 버튼으로 답을 받음 |
| 보안 다이제스트 | 매일 10:30, 보안 뉴스 요약과 제 코드 저장소의 취약한 의존성 점검 결과를 보냄 |
같은 기능을 두 가지 방법으로 쓸 수 있습니다. /로 시작하는 명령을 직접 입력하거나, 평소 말투로 부탁하면 모델이 tool calling으로 알맞은 도구를 골라 직접 호출합니다.
| 명령 | 하는 일 | 말로 부탁할 때 모델이 부르는 도구 |
|---|---|---|
/todo list · add · done · delete | 할일 조회·추가·완료·삭제 | get_daily_todos add_todo complete_todo delete_todo |
/event list · add · edit · delete | 일정 조회·등록·수정·삭제. 반복 일정은 명령으로만 | get_calendar_events create_calendar_event update_calendar_event delete_calendar_event |
/reminder list · add · cancel | 정한 시각에 한 번 보내는 알림 | set_reminder list_reminders cancel_reminder |
/routine list · add · edit · toggle · delete | 매일·평일·매주·매월 반복 작업. 수정·일시 중지는 명령으로만 | add_routine list_routines cancel_routine |
/today | 오늘의 할일·일정·예정 알림·반복 작업을 한 번에 | – |
/checkin status · on · off · config · now | 체크인 상태 확인, 켜고 끄기, 횟수·시간대·주말·유형 설정, 즉시 발송 | – |
/security run · audit · repos · repo-add · repo-remove | 보안 다이제스트 즉시 실행, 저장소 하나의 취약 패키지 전체 보기, 점검 대상 관리 | – |
/help, /reset | 명령 사용법 보기, 대화 기록 초기화 | – |
명령이 없는 도구도 있습니다. 웹 검색(web_search)과 현재 시각 확인(get_current_time)은 모델만 씁니다. 모두 16개입니다.
예를 들어 “다음 주 월요일 회의를 3시로 옮겨 줘”라고 보내면 이렇게 처리됩니다.
사용자: "다음 주 월요일 회의를 3시로 옮겨 줘"
↓ 모델: get_calendar_events({ date: "2026-10-26" }) ← 대상 일정 찾기
↓ 코드: Google Calendar 조회 → 결과를 모델에게 전달
↓ 모델: update_calendar_event({ id: "…", start: "2026-10-26T15:00" }) ← 찾은 일정 수정
↓ 코드: Google Calendar 수정 → 결과를 모델에게 전달
모델: "다음 주 월요일 회의를 15:00으로 옮겼습니다."모델은 어떤 도구를 어떤 값으로 부를지만 정하고, 실제 조회·수정은 코드가 합니다. 새 기능을 붙일 때는 도구를 하나 정의해 등록하면 되고, 대화 흐름을 고칠 필요는 없습니다. 다만 작은 모델은 이처럼 도구를 이어 부르는 과정에서 틀릴 수 있습니다. 그래서 확실해야 하는 수정·삭제는 명령을 함께 열어 두었습니다(→ 문제 1).
Discord DM ─┬─ 명령 (/todo, /event …) ─▶ 명령 체계 ─────┐
└─ 자연어 ─▶ 도구 호출 루프 ⇄ Ollama ─▶ 도구 ─┤
▼
Google Calendar · Google Tasks · SearXNG · SQLite
스케줄러 ─▶ 알림 · 반복 작업 · 체크인 · 보안 다이제스트 ─▶ DM일정을 고치거나 지우려면, 먼저 조회한 결과에 나온 일정의 고유 id(긴 무작위 문자열)를 다음 요청에 그대로 옮겨 적어야 합니다. 2B급 모델은 이 단계에서 자주 틀렸습니다. 삭제처럼 되돌릴 수 없는 동작에서는 실수의 비용이 큽니다.
먼저 모델이 틀려도 스스로 고칠 기회를 주도록 도구 호출 루프를 만들었습니다.
그래도 긴 id를 옮겨 적는 일 자체는 모델에게 맡기지 않는 편이 낫다고 판단했습니다. 그래서 같은 기능을 명령으로도 열었습니다.
명령은 정의 하나에서 세 가지를 만들어 냅니다.
| 만들어지는 것 | 하는 일 |
|---|---|
| 텍스트 명령 | DM에 /todo done id:...처럼 직접 입력한 명령을 해석 |
| 슬래시 명령 | Discord의 / 메뉴에 등록. 정의가 바뀌었을 때만 다시 등록 |
| 도움말 | /help 내용을 정의에서 생성해 코드와 설명이 어긋나지 않음 |
confirm:true로 확인합니다아침 브리핑과 미리 등록해 둔 알림이 있었지만, 둘 다 끝냈는지 되묻지는 않았습니다. 매일 같은 시각에 오는 브리핑은 익숙해지면 읽지 않고 넘기게 되고, 알림은 등록한 것만 알려 줍니다. 등록만 해 두고 끝냈는지 아무도 묻지 않는 할일이 쌓였습니다.
예측할 수 없는 시각에, 할일 하나를 콕 집어 묻는 체크인을 기획했습니다. “할일 확인하세요”가 아니라 “『견적서 보내기』 끝났어요?”입니다.
문구는 전부 미리 써 둔 템플릿입니다. 모델이 문구를 쓰면 없는 할일을 지어낼 수 있기 때문입니다. 모델을 쓰는 곳은 한 군데입니다. 오늘·내일 할일과 일정이 모두 없는 날, 기한 없는 할일 중 가장 가볍게 끝낼 만한 하나를 추천할 때입니다.
n8n이 보내던 뉴스 안내는 일반 기사 위주였고, 제가 운영하는 코드와 관련이 있는지는 알려 주지 않았습니다. 코드가 쓰는 외부 패키지(의존성)가 오래됐거나 취약한지 확인하는 점검(bun outdated)도 생각날 때만 돌렸습니다. 매일 아침 그날의 보안 뉴스와, 그게 내 코드에 영향이 있는지를 한 번에 받고 싶었습니다.
bun install)하면, 설치 과정에서 그 패키지의 스크립트가 실행됩니다. 점검 대상이 악성일 수 있는데 점검하다 그 코드를 실행하게 됩니다bun audit)과 업데이트 여부(bun outdated)를 점검합니다영향 판정은 확실한 근거를 앞에 두고, 근거의 종류를 표시로 구분했습니다.
| 표시 | 근거 |
|---|---|
| 🔴 | 점검 도구(bun audit)가 심각(critical/high) 취약점을 찾음. 내 코드가 직접 쓰는 패키지를 앞에 둠 |
| 🟠 | 기사 제목·요약에 내 코드가 직접 쓰는 패키지 이름이 나옴. 흔한 단어 같은 짧은 이름은 npm 같은 문맥이 함께 있어야 인정 |
| 🟡 | 모델이 기사에서 뽑은 소프트웨어 이름이 내 패키지 목록과 일치. 목록에 없는 이름은 버림 |
/security audit 명령으로 따로 보게 나눴습니다처음에는 설정 파일에 고정돼 있던 아침 브리핑 시각을 데이터베이스에 저장되는 반복 작업으로 바꿨습니다. 반복 작업은 cron 형식(분·시·일·월·요일로 주기를 적는 표기)으로 저장하고, 새 정기 작업은 코드 수정이나 재배포 없이 대화나 명령으로 추가합니다. cron 식은 모델이 쓰지 않습니다. 모델은 “매주 / 월요일 / 9시” 같은 항목만 고르고, 변환은 코드가 합니다.
검색 엔진 SearXNG를 같은 서버에 띄워 검색 도구를 만들었습니다. 검색어도 외부 검색 API로 나가지 않습니다. 결과는 제목·주소·요약(건당 300자)만 넘기고 본문은 가져오지 않습니다. 작은 모델이 읽을 수 있는 분량을 넘기지 않기 위해서입니다. 설정 과정은 블로그 글로 정리했습니다.
할일·일정·검색 결과처럼 외부에서 들어온 글 속에 “이전 지시를 무시하라” 같은 문장이 있어도 데이터로만 보고 따르지 말라는 규칙을 시스템 프롬프트에 넣었습니다. 작은 모델이라 완전히 막지는 못한다는 점, 그 경우 영향이 미칠 수 있는 곳은 일정·할일·알림을 만들고 바꾸는 도구라는 점은 남은 위험으로 문서에 기록했습니다. 허용된 사용자 목록이 비어 있으면 아무에게도 응답하지 않습니다.
반복 주기 변환, 체크인 추첨·판정, 뉴스 파싱, 점검 결과 해석, 메시지 렌더링 같은 로직과 데이터 저장·스케줄러를 bun test로 검증합니다. Discord 없이 터미널에서 대화를 시험하는 명령(bun run chat)과, 보안 다이제스트를 DM 대신 화면에 출력하는 점검 스크립트도 따로 두었습니다.
| 레이어 | 기술 |
|---|---|
| 런타임 · 언어 | Bun, TypeScript |
| 봇 | discord.js (DM, 슬래시 명령, 자동완성, 버튼, 모달) |
| AI 모델 | Ollama (gemma4:e2b 대화, gemma4:e4b 보안 요약), tool calling, JSON 형식 응답 |
| 입력 검증 | zod (도구 인자 · 명령 인자 · 환경변수 · 외부 응답) |
| 저장소 | SQLite (bun:sqlite) |
| 스케줄링 | croner |
| 외부 연동 | Google Calendar · Tasks REST API (서비스 계정), SearXNG |
| 배포 | Docker Compose (홈서버, GPU) |