raphael 아이콘

raphael

2026.09 ~

소개

저 한 명이 쓰는 개인 비서라 사용자 지표는 없습니다. 이하 내용은 작은 로컬 모델의 한계 안에서 무엇을 모델에게 맡기고 무엇을 코드로 옮겼는지에 대한 설계 기록입니다.

Discord DM으로 부탁하면 집 서버에서 돌아가는 오픈소스 AI 모델(Ollama)이 처리하는 개인 비서 봇입니다. ChatGPT 같은 외부 AI 서비스 없이 그래픽 메모리 2~4GB GPU에서 동작하며, 일정·할일 관리(Google Calendar·Tasks), 웹 검색, 알림·반복 작업, 무작위 시각 체크인, 매일 아침 보안 뉴스와 코드 의존성 점검을 한 봇에서 처리합니다.

  • 기획·설계·구현·홈서버 배포·운영 1인 수행
  • 모델의 도구 호출 루프, 명령 체계, Google API 인증을 외부 SDK 없이 직접 구현
  • 체크인·보안 다이제스트는 기획 문서 작성부터 구현·테스트까지

여기서부터는 실제로 부딪힌 문제를 케이스로 정리했습니다. 각 케이스는 어떤 상황이었고 무엇이 제약이었는지에서 시작해, 그래서 어떻게 판단했고 무엇이 남았는지까지 이어집니다.

  1. 문제 1작은 모델이 수정·삭제에 필요한 긴 id를 자주 틀리게 옮겨 적음
  2. 문제 2정해진 시각의 브리핑은 익숙해지면 읽지 않고 넘기게 됨
  3. 문제 3보안 뉴스가 내 레포와 관련 있는지는 직접 확인해야 했음

배경

집에 있는 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
  • 조작 경로는 명령과 자연어 두 갈래이고, 뒤에서는 같은 코드를 씁니다. 명령은 입력이 같으면 결과도 늘 같은 확실한 경로, 자연어는 편하지만 모델이 틀릴 수 있는 경로입니다
  • 서버에서는 Docker로 Ollama · SearXNG · raphael을 함께 띄웁니다. Ollama와 SearXNG는 외부에서 접속할 수 없게 막아 두었습니다
  • 대화 기록, 알림, 반복 작업, 체크인 기록은 파일 하나짜리 데이터베이스(SQLite)에 저장합니다

문제 1작은 모델은 긴 id를 정확히 옮겨 적지 못했다

  1. 상황
  2. 접근과 결정
  3. 결과

상황

  1. 상황
  2. 접근과 결정
  3. 결과

일정을 고치거나 지우려면, 먼저 조회한 결과에 나온 일정의 고유 id(긴 무작위 문자열)를 다음 요청에 그대로 옮겨 적어야 합니다. 2B급 모델은 이 단계에서 자주 틀렸습니다. 삭제처럼 되돌릴 수 없는 동작에서는 실수의 비용이 큽니다.

접근과 결정

  1. 상황
  2. 접근과 결정
  3. 결과

먼저 모델이 틀려도 스스로 고칠 기회를 주도록 도구 호출 루프를 만들었습니다.

  • 도구마다 인자의 형식(어떤 값이 필수이고 어떤 모양이어야 하는지)을 zod 스키마로 한 번만 정의하고, 이 정의를 모델에게 보여 주는 설명서와 실제 입력 검사에 함께 씁니다
  • 인자가 틀렸거나 없는 도구를 불렀으면 프로그램을 멈추지 않고, 무엇이 틀렸는지를 모델에게 돌려줘 다시 시도하게 합니다
  • 재시도는 최대 3회입니다. 넘기면 도구 없이 지금까지의 결과로 답하게 해서 끝없이 반복되는 것을 막습니다
  • 대화 기록에는 도구를 부른 중간 과정을 빼고 질문과 최종 답만 남깁니다. 작은 모델이 한 번에 읽을 수 있는 분량이 적기 때문입니다

그래도 긴 id를 옮겨 적는 일 자체는 모델에게 맡기지 않는 편이 낫다고 판단했습니다. 그래서 같은 기능을 명령으로도 열었습니다.

명령은 정의 하나에서 세 가지를 만들어 냅니다.

만들어지는 것하는 일
텍스트 명령DM에 /todo done id:...처럼 직접 입력한 명령을 해석
슬래시 명령Discord의 / 메뉴에 등록. 정의가 바뀌었을 때만 다시 등록
도움말/help 내용을 정의에서 생성해 코드와 설명이 어긋나지 않음
  • 고칠 대상을 고르는 칸에는 자동완성으로 실제 할일·일정 목록이 뜹니다. 사람은 목록에서 고르기만 하면 됩니다. Discord는 자동완성에 3초 안에 답해야 해서, Google 조회 결과를 10초 동안 기억해 두고 2초가 넘으면 기다리지 않습니다
  • 되돌릴 수 없는 명령은 실행 전에 한 번 확인을 받습니다. 슬래시 명령은 버튼, 텍스트 명령은 confirm:true로 확인합니다

결과

  1. 상황
  2. 접근과 결정
  3. 결과
  • 수정·삭제는 사람이 목록에서 고르는 명령 경로를 권장 경로로 두었습니다. 자연어로도 할 수 있으며, 이때 삭제 도구는 실행 전에 대상을 조회해 실제로 있는지 확인합니다
  • 명령을 하나 추가하면 슬래시 명령·텍스트 명령·도움말이 함께 생깁니다

문제 2정해진 시각의 브리핑은 결국 읽지 않게 된다

  1. 상황
  2. 접근과 결정
  3. 결과

상황

  1. 상황
  2. 접근과 결정
  3. 결과

아침 브리핑과 미리 등록해 둔 알림이 있었지만, 둘 다 끝냈는지 되묻지는 않았습니다. 매일 같은 시각에 오는 브리핑은 익숙해지면 읽지 않고 넘기게 되고, 알림은 등록한 것만 알려 줍니다. 등록만 해 두고 끝냈는지 아무도 묻지 않는 할일이 쌓였습니다.

접근과 결정

  1. 상황
  2. 접근과 결정
  3. 결과

예측할 수 없는 시각에, 할일 하나를 콕 집어 묻는 체크인을 기획했습니다. “할일 확인하세요”가 아니라 “『견적서 보내기』 끝났어요?”입니다.

  • 답은 버튼으로만 받습니다(완료 / 내일로 / 아직 등). 한 번 누르면 끝나고, 말로 된 답을 받지 않으니 모델이 끼어들 일이 없습니다
  • 물어볼 게 없으면 보내지 않습니다. 내용 없는 알림은 무시하는 습관만 만듭니다
  • 보낼 시각은 매일 0시 5분에 정합니다. 기본 10~21시 사이에서 서로 90분 이상 떨어지게 무작위로 뽑아 저장해 두므로, 서버를 다시 켜도 바뀌지 않습니다
  • 회의 중이거나, 방금 대화했거나, 다른 알림 앞뒤 30분 안이면 미룹니다
  • 5건 연속 무시하면 하루 횟수를 하나 줄이고, 응답률이 다시 오르면 되돌립니다. 같은 유형을 계속 무시하면 그 유형은 덜 묻습니다

문구는 전부 미리 써 둔 템플릿입니다. 모델이 문구를 쓰면 없는 할일을 지어낼 수 있기 때문입니다. 모델을 쓰는 곳은 한 군데입니다. 오늘·내일 할일과 일정이 모두 없는 날, 기한 없는 할일 중 가장 가볍게 끝낼 만한 하나를 추천할 때입니다.

  • 모델에게 할일 id 대신 1부터 매긴 번호 목록을 주고, 고른 번호와 이유 한 줄만 정해진 JSON 형식으로 받습니다
  • 목록에 없는 번호가 오면 버립니다. 그래서 목록에 없는 할일은 추천될 수 없습니다
  • 할일 제목 안에 명령처럼 보이는 문장이 있어도 따르지 말라고 프롬프트에 적어 두었습니다
  • 모델이 꺼져 있거나, 30초 안에 답하지 않거나, 형식이 틀리면 제목이 가장 짧은 할일을 대신 추천합니다

결과

  1. 상황
  2. 접근과 결정
  3. 결과
  • 시각 추첨, 대상 고르기, 미룰지 판정, 빈도 조절을 외부 상태에 의존하지 않는 함수로 분리했습니다. 난수까지 바꿔 넣을 수 있어 같은 상황을 테스트로 반복 재현할 수 있습니다
  • 버튼은 몇 시간 뒤에 눌러도, 그사이 서버가 재시작됐어도 처리됩니다

문제 3보안 뉴스가 내 코드와 관련 있는지는 직접 확인해야 했다

  1. 상황
  2. 제약
  3. 접근과 결정
  4. 결과

상황

  1. 상황
  2. 제약
  3. 접근과 결정
  4. 결과

n8n이 보내던 뉴스 안내는 일반 기사 위주였고, 제가 운영하는 코드와 관련이 있는지는 알려 주지 않았습니다. 코드가 쓰는 외부 패키지(의존성)가 오래됐거나 취약한지 확인하는 점검(bun outdated)도 생각날 때만 돌렸습니다. 매일 아침 그날의 보안 뉴스와, 그게 내 코드에 영향이 있는지를 한 번에 받고 싶었습니다.

제약

  1. 상황
  2. 제약
  3. 접근과 결정
  4. 결과
  • 개발 중인 작업 폴더는 건드리면 안 됩니다
  • 점검하려고 패키지를 설치(bun install)하면, 설치 과정에서 그 패키지의 스크립트가 실행됩니다. 점검 대상이 악성일 수 있는데 점검하다 그 코드를 실행하게 됩니다
  • 요약을 맡는 4B급 모델에게 영향 판정까지 전부 맡기기는 어렵습니다

접근과 결정

  1. 상황
  2. 제약
  3. 접근과 결정
  4. 결과
  • 점검용 **복사본(미러)**을 따로 두고, 매번 원격 저장소의 최신 상태로 덮어씁니다. 읽기만 하는 복사본이라 작업 폴더와 충돌하지 않습니다
  • 패키지를 설치하지 않고, 설치될 버전이 적힌 목록 파일(lockfile)만 보고 취약점(bun audit)과 업데이트 여부(bun outdated)를 점검합니다
  • 비공개 저장소에 접근하는 토큰은 저장소 주소나 설정 파일에 남기지 않고 그 명령 한 번에만 넘깁니다. 오류 메시지에서도 가리고, 점검 프로세스에는 Discord 토큰 같은 다른 비밀값을 넘기지 않습니다
  • 저장소 주소는 https만 허용합니다. 다른 방식의 주소는 서버의 파일을 읽거나 명령을 실행하는 통로가 될 수 있습니다

영향 판정은 확실한 근거를 앞에 두고, 근거의 종류를 표시로 구분했습니다.

표시근거
🔴점검 도구(bun audit)가 심각(critical/high) 취약점을 찾음. 내 코드가 직접 쓰는 패키지를 앞에 둠
🟠기사 제목·요약에 내 코드가 직접 쓰는 패키지 이름이 나옴. 흔한 단어 같은 짧은 이름은 npm 같은 문맥이 함께 있어야 인정
🟡모델이 기사에서 뽑은 소프트웨어 이름이 내 패키지 목록과 일치. 목록에 없는 이름은 버림
  • 뉴스는 보안 뉴스 피드(RSS) 4개에서 처음 보는 기사만 모으고, 제목이 비슷한 기사는 같은 사건으로 묶은 뒤 모델이 최대 7건을 골라 한국어 1~2줄로 요약합니다
  • 뉴스 수집·저장소 점검·모델 요약 중 한 단계가 실패해도 그 부분만 ⚠️로 표시하고 나머지는 보냅니다. 모델이 실패하면 요약 대신 기사 원제목을 보냅니다
  • 기사를 “보냈음”으로 기록하는 건 발송이 끝난 뒤입니다. 중간에 실패하면 다음 실행에서 다시 다룹니다

결과

  1. 상황
  2. 제약
  3. 접근과 결정
  4. 결과
  • 매일 10:30, 내 코드에 대한 영향이 맨 위에 오는 요약을 DM 한 번으로 받습니다. 심각한 항목에는 「할일로 추가」 버튼이 붙어 Google Tasks로 바로 넘길 수 있습니다
  • 실제로 돌려 보니 한 저장소에서만 심각한 취약점 알림이 수십 건 나왔고, 대부분 직접 쓰는 패키지가 아니라 그 패키지가 다시 쓰는 패키지(전이 의존성)에서 나온 것이었습니다. 그래서 패키지 단위로 묶어 직접 쓰는 패키지를 앞세우고, 저장소 하나의 전체 목록은 /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)

기술 선택 이유

  • 도구 호출 루프 직접 구현: AI 에이전트 프레임워크 없이 100줄 남짓의 코드로 검증·재시도·반복 한도를 작은 모델에 맞춰 조정했습니다. 동작 전체를 파일 하나에서 확인할 수 있습니다
  • Google SDK 미사용: 쓰는 기능이 일정·할일 API 몇 개뿐이라 인증과 호출을 직접 구현했습니다. 의존성이 줄고, Google이 돌려주는 오류를 원인별 안내 문구로 바꾸기 쉬웠습니다
  • SQLite: 사용자가 한 명이라 별도 데이터베이스 서버가 필요 없습니다. 파일 하나만 보존하면 재시작 후에도 상태가 유지됩니다

관련 링크

감사합니다

궁금한 점은 언제든 편하게 물어봐 주세요.

tjsals116@gmail.com

← Works 목록으로