ENFRKOantony langlois
프로젝트

Vocab Catcher

바로가기자동화PythonTelegramGeminiObsidian
Vocab Catcher demo

방금 접한 단어를 Obsidian 플래시카드로 바꿔주는 Telegram 봇 · Gemini JSON 모드로 정보 보강 · 문자 한 통이 몇 초 만에 간격 반복 학습 카드로 바뀝니다

해결할 문제

저 같은 언어 학습자들은 새로운 단어를 배우고 기억하기 위해 흔히 플래시카드를 사용합니다. 플래시카드 한쪽에는 영어 등 익숙한 언어로 된 뜻이, 다른 쪽에는 배우는 언어의 단어가 적혀 있습니다. 제 경우에는 한국어입니다.

하지만 카드를 만드는 과정은 번거롭고 오래 걸립니다. 친구에게 새 단어를 듣거나 길에서 모르는 단어를 발견해도 그 자리에서 카드를 만들고 자세한 뜻을 적을 여유는 없습니다. 결국 몇 분 뒤에 잊어버리고, 그렇게 놓친 단어가 계속 쌓입니다.

어떻게 해결했는가

새 단어를 빠르게 입력하기만 하면 시스템이 Obsidian 플래시카드를 자동으로 만들도록 했습니다. 일상에서 발견한 단어를 놓치지 않고 저장했다가 나중에 복습할 수 있습니다.

Telegram 봇에 단어와 선택 사항인 짧은 설명을 보내면 VPS에서 실행 중인 Python 스크립트가 Gemini로 뜻을 보완하고 플래시카드를 만듭니다.

카드는 두 장 만들어집니다. 하나는 단어와 뜻을 묻고, 다른 하나는 예문의 일부를 빈칸으로 만들어 실제 문맥에서 단어를 떠올리게 합니다. Obsidian 플래시카드는 단순한 Markdown 파일입니다.

카드가 완성되면 Telegram 봇이 확인 메시지와 뜻을 보내주므로 제대로 처리됐는지 바로 확인할 수 있습니다.

카드는 GitHub 저장소에 푸시됩니다. Obsidian Git 플러그인이 데스크톱과 모바일에서 이를 가져오면 새 카드가 보관함에 자동으로 추가됩니다.

아키텍처

phone
클라이언트 · 포착
Telegram 앱
any device
단어를 접한 순간 전송: 한국어 단어, 로마자 표기, 간단한 영어 설명
SaaS
외부 · 메시지 브로커
Telegram Bot API
api.telegram.org
봇이 가져갈 때까지 업데이트를 대기시킴 · 인바운드 포트 불필요
desktop · mobile
클라이언트 · 복습
Obsidian
Spaced Repetition plugin
각 노트를 플래시카드 두 장으로 표시: word::meaning과 빈칸 문장
Python · asyncio
코어 · 폴링 루프
vocab-catcher 봇
bot.py · single file
Telegram을 롱 폴링하고, 각 단어를 Gemini로 보강하고, 노트와 빈칸을 만들어 보관함에 기록합니다
long-poll getUpdatesJSON 모드 보강빈칸 빌더자기 종료 워치독git 동기화 (VPS 브랜치)
gemini-2.5-flash
외부 · LLM
Google Gemini
google-genai SDK
JSON 호출 한 번으로 표기, 뜻, 예문, 주제, 활용형 반환
Markdown files
저장소 · 노트
Obsidian 보관함
OBSIDIAN_VAULT_PATH
단어당 노트 하나, #flashcards/<lang>/<theme> 태그 부착
private repo
외부 · 동기화
보관함 Git 원격 저장소
SSH deploy key
VPS에서 각 노트를 푸시 · 기기에서 Obsidian Git 플러그인으로 가져오기

사용자가 Telegram 앱에서 봇에게 단어를 보내면 Telegram Bot APIgetUpdates 롱 폴링 요청이 올 때까지 메시지를 보관합니다. 핸들러는 Gemini를 JSON 모드로 한 번 호출해 표기, 뜻, 예문, 주제, 예문에 쓰인 정확한 활용형을 받습니다. 봇은 이 정보로 word::meaning 카드와 빈칸(cloze) 카드를 담은 Markdown 노트를 만들어 Obsidian 보관함에 저장합니다. VPS 브랜치에서는 백그라운드로 보관함 Git 원격 저장소에 커밋하고 푸시합니다. 봇이 Saved: ...라고 답하면 사용자 기기에서 노트를 동기화해 Obsidian으로 복습할 수 있습니다.

배포: 봇 · 노트북 → macOS launchd 사용자 에이전트 (macos-background-service 브랜치) · 봇 · 상시 가동 → VPS · Restart=always 설정의 systemd (vps-deployment 브랜치)

성과

  • ~120줄 · 핵심 전체가 한 파일에 · bot.py
  • 열린 포트 0개 · 롱 폴링만 사용 · 웹훅, 도메인, TLS 불필요
  • 단어당 카드 2개 · 정의 + 빈칸, 자동 생성
  • 자가 복구 · 워치독이 멈추면 종료 · 슈퍼바이저가 재시작

보여준 역량: LLM 구조화 출력(JSON 모드) · 프로세스 관리자가 재시작하는 crash-only 설계 · 비동기 잠금을 적용한 best-effort Git 동기화 · 기능 추가형 Git 브랜치로 나눈 배포 방식

문제와 해결책

핵심 조건은 두 가지입니다. 단어 저장은 몇 초 안에 끝나야 하고, 파이프라인이 아무런 경고 없이 멈춰서는 안 됩니다.

▒ 산문 파싱 대신 구조화된 출력

문제: 자유 텍스트로 된 Gemini 응답은 취약한 문자열 파싱이 필요하고, 파싱이 잘못되면 사용자가 몇 주 뒤 복습 도중에야 발견하는 손상된 플래시카드가 생깁니다.

JSON 모드를 강제하고(response_mime_type: application/json) 키를 정확히 지정하는 시스템 지시를 추가했습니다. 핸들러는 json.loads로 응답을 바로 읽습니다.

  • 정규식으로 일반 문장을 파싱하면 잘못된 카드가 조용히 저장될 수 있어 제외했습니다
  • 응답 형식이 잘못되면 Failed to process 오류를 명확히 보여주며, 사용자는 단어를 다시 보내면 됩니다
  • 이후 단계는 단순한 딕셔너리 조회만 하면 되므로 방어적인 파싱 코드가 필요 없습니다

▩ 활용형을 정확히 반영하는 빈칸 카드

문제: 한국어는 굴절합니다: 동사의 사전형은 실제 문장에 그대로 나타나는 경우가 드물기 때문에, 예문 안에서 사전형을 빈칸으로 만드는 것은 실패합니다.

프롬프트는 Gemini에게 그 자신의 예문 안에 나타나는 정확한 표면형도 요청합니다. 그러면 빈칸 생성은 단일 리터럴 문자열 치환이 됩니다.

  • 모든 언어의 형태론 규칙을 Python으로 직접 구현하기는 어려워 제외했습니다
  • 표면형이 일치하지 않으면, 빈칸 카드는 잘못 저장하기보다 건너뜁니다
  • 언어 처리를 모델에 맡기면 코드는 특정 언어에 종속되지 않습니다. 환경 변수 하나로 한국어를 일본어나 스페인어로 바꿀 수 있습니다

░ 웹훅 대신 롱 폴링

문제: 봇은 노트북이나 저렴한 VPS에서 돌아갑니다; 웹훅은 단일 사용자 도구를 위해 공개 HTTPS 엔드포인트, 도메인, 인증서 관리를 요구합니다.

시작 단계에서 무제한 재시도하는 run_polling 기반 롱 폴링을 사용합니다. Telegram은 봇이 오프라인인 동안 메시지를 서버에 보관했다가 다시 연결되면 전달합니다.

  • 사용자 한 명이 쓰는 봇에는 웹훅 인프라의 이점이 없어 제외했습니다
  • 토큰당 폴러 하나라는 제한을 감수하고, Telegram의 409 Conflict 대응법을 운영 문서에 기록했습니다
  • 인터넷에 노출되는 것이 없으므로, 유지할 공격 표면이 없습니다

▥ 자기 프로세스를 죽이는 워치독

문제: 폴러는 노트북 절전/재개나 네트워크 변경 후에 조용히 멈출 수 있습니다: 프로세스는 아무것도 받지 않으면서도 launchd에게는 살아 있는 것처럼 보입니다.

asyncio 태스크가 30초마다 updater.running을 확인하고, 멈춘 상태라면 **os._exit(1)**을 호출합니다. 그러면 launchd 또는 systemd가 깨끗한 상태로 프로세스를 다시 시작합니다.

  • 라이브러리 내부 상태에 의존하는 프로세스 내 재연결 로직은 취약해 제외했습니다
  • Crash-only 구조에서는 재시작이 유일한 복구 경로라서 항상 같은 방식으로 검증됩니다
  • 같은 방식이 launchd와 systemd 양쪽에서 그대로 동작합니다

▨ 플래그가 아닌 브랜치로 구현한 배포 변형

문제: 세 가지 실행 방식(포그라운드 개발, macOS 백그라운드 에이전트, git 동기화가 있는 VPS)은 단일 파일을 설정 플래그와 죽은 코드 경로로 채울 것입니다.

main은 최소 구성만 유지합니다. launchd 설치 프로그램은 별도 브랜치에 있고, VPS 브랜치는 vault_sync.py와 bot.py 코드 세 줄만 추가합니다.

  • 환경 변수 기능 플래그를 쓰면 모든 배포본에 실행하지 않는 코드가 섞이므로 제외했습니다
  • 브랜치를 main과 계속 동기화해야 하지만, 추가 코드만 담은 66줄짜리 차이로 관리 비용을 낮췄습니다
  • 각 브랜치에는 해당 환경에서 실제로 실행하는 코드만 들어 있습니다

▣ 저장을 결코 막지 않는 git 동기화

문제: VPS에서 보관함은 사용자의 기기들에 도달해야 하지만, 실패한 푸시가 노트를 잃거나 메시지 핸들러를 죽여서는 안 됩니다.

Best-effort 동기화를 사용합니다. 노트를 먼저 디스크에 저장한 뒤, asyncio 잠금으로 보호된 스레드에서 커밋과 푸시를 실행합니다. 실패는 로그에 남기되 예외를 전파하지 않습니다.

  • 비공개 Git 저장소만으로 변경 이력과 배포 키 인증을 쓸 수 있어 Obsidian Sync와 Syncthing은 제외했습니다
  • 잠금으로 동시 메시지를 순서대로 처리해 Git 인덱스 충돌을 막습니다
  • 푸시에 실패해도 다음 단어를 푸시할 때 대기 중인 커밋이 함께 전송됩니다