하이킹 안전 앱을 하이패스(hard-kill) 했지만 경고가 울린 경험
요약
TrailWatch는 등산 중 안전 체크인 타이머 앱으로, 사용자의 위치와 계획을 기반으로 작동합니다. 로컬에서 실행되는 Gemma가 여행 계획 및 상황 요약을 생성하며, 휴대폰 배터리나 시스템 종료에도 불구하고 안전 알림 기능이 유지되도록 설계되었습니다. 핵심은 AI 자체가 아니라, 정상적인 프로세스 내에 존재하는 '조용히 꺼질 수 없는' 안전 타이머 메커니즘입니다.
핵심 포인트
- 로컬 Gemma를 활용하여 여행 계획 및 상황 요약 생성
- 배터리 절약이나 시스템 종료에도 작동하는 안전 알림 기능 구현
- 사용자 개입을 최소화하고, 비상시 자동 경고 시스템 구축
- Temporal과 같은 워크플로우 엔진으로 안정적인 타이머 관리
이것은 Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass에 제출하는 작품입니다.
제작물 소개
가장 오래된 야외 안전 조언이자 가장 무시되는 조언은 이것입니다. 어디로 가는지, 그리고 언제 돌아올 것인지 누군가에게 알려라. 문제는 그 "누군가"가 기억해야 하고, 당신이 늦었다는 것을 알아차려야 하며, 무엇을 해야 할지 알아야 한다는 것입니다.
TrailWatch가 바로 그 '누군가'입니다. 잊어버릴 수 없는 등산 체크인 타이머입니다.
- 떠나기 전에 한 문장을 입력합니다: "몽크스 트레일(Monk's Trail) 도이 수텝(Doi Suthep)으로 혼자 가고, 5시쯤 돌아올 거야."
- 로컬에서 실행되는 Gemma가 이를 계획으로 변환합니다: 예상 복귀 시간, 회전 시간, 체크인 간격, 그리고 오늘 일몰과 예보를 기반으로 한 팁을 제공합니다.
- 당신은 휴대폰을 내려놓습니다. 이것이 핵심입니다. 알림이 "괜찮으세요?"라고 물어볼 때만 휴대폰이 필요하며, 잠금 화면에서 원터치로 답변하면 됩니다.
- 체크인을 놓치면 TrailWatch가 당신에게 신호를 보내고, 유예 기간을 기다린 후 비상 연락처에 알립니다. 먼저 일반 템플릿 메시지를 즉시 보냅니다. 그런 다음 트립 로그를 기반으로 Gemma가 작성한 상황 요약본을 전송합니다. 신호가 다시 잡히고 **괜찮습니다(I'm OK)**를 누르면, 당신의 연락처는 안전하다는 알림을 받게 됩니다.
전체 등산 시간 동안 화면 사용 시간은 약 30초입니다. 출발 전에 한 문장만 입력하고, 트레일에서 딱 한 번 터치하는 정도입니다.
흥미로운 부분은 AI가 아닙니다. 이것입니다: 정상적인 프로세스 내에 존재하는 안전 타이머는 조용히 꺼질 수 있는 안전 타이머라는 것입니다. 노트북은 잠들고, 서버는 재부팅되며, 휴대폰의 배터리 절약 기능이 앱을 종료시킵니다. 시스템이 반드시 작동해야 하는 순간은 신호가 없고 아무도 지켜보지 않을 때입니다. 그래서 저는 TrailWatch를 만들어 타이머가 사라지지 않도록 했고, 이어서 그것을 고장 내려고 노력했습니다.
데모
이것은 한 번의 촬영으로 기록된 실제 실행 사례입니다. 로컬 Gemma가 여행 계획을 세우고, Temporal이 워크플로우를 실행하며, ntfy.sh를 통해 실제 알림이 전송됩니다. 화면에 보이는 두 가지 트릭이 있습니다:
- 시간이 압축됩니다. '여행 분(trip minute)'은 2초로 계산되므로, 40분 동안의 산책은 2분도 채 걸리지 않습니다. 이는 실제 시간과 동일한 코드 경로를 사용하며, 타이머 길이만 조정된 것입니다.
- '작업자 사망 구간(worker-is-dead stretch)'이 4배속으로 재생됩니다 (구석에 표시됨). 여기서는 특별히 흥미로운 일이 일어나지 않으며, 그것이 요점입니다.
음성 해설은 동일한 노트북에서 오프라인으로 실행되는 오픈 소스 텍스트 음성 변환(text-to-speech) 모델인 Piper를 사용했습니다.
발생 과정:
- Gemma가 여행을 준비합니다: 15분마다 체크인, 유예 기간 15분을 설정합니다.
- 제가 작업자를 강제 종료(hard-kill)합니다 (
TerminateProcess, 정리 작업 없음). TrailWatch 코드는 아무것도 실행하고 있지 않습니다. - 휴대폰 화면이 이를 감지하고 다음과 같이 알립니다: 사용자의 타이머는 Temporal 서버에 존재하며 계속 작동 중입니다.
- 체크인 마감 시간이 지나고, 이어서 유예 기간도 지납니다. 이제 경고가 연체되었지만, 여전히 작업자는 없습니다.
- 제가 작업자를 재시작합니다. Temporal이 기록을 재생하고, 두 타이머 모두 이미 작동했음을 확인한 후, 하이커에게 알리고 연락처에 경고를 보냅니다. 이 경고는 작업자가 돌아온 지 약 10초 후에 발송되었습니다.
- Gemma의 브리프가 도착하고,
앨리가 캠퍼스 뒤 능선을 따라 혼자 하이킹을 나갔는데, 원래는 23:54까지 돌아올 예정이었지만 시간이 지연되었습니다. 앨리는 23:14에 하이킹을 시작했고, 마지막으로 들린 시간도 23:14입니다. 현재 위치를 알 수 없습니다. 샘, 즉시 앨리에게 연락해 보세요. 만약 15분 내로 앨리와 연락이 닿지 않으면, 현지 응급 서비스에 연락하세요.
(Ali와 Sam은 데모 페르소나입니다.)
Code
GitHub logo syncaimain / trailwatch
당신을 잊지 않는 하이킹 체크인 타이머: 로컬 Gemma + Temporal 내구성 워크플로우 + ntfy
TrailWatch
당신을 잊지 않는 하이킹 체크인 타이머.
어디로 가고 언제 돌아올 것인지, 당신의 말 그대로 알려주세요. 로컬 Gemma 모델은 이를 계획으로 변환합니다: 예상 복귀 시간, 회차 시간(turnaround time), 몇 간격으로 체크인할지, 그리고 일몰과 예보를 기반으로 한 안전 팁입니다. 그런 다음 휴대폰을 내려놓으세요.
각 여행은 Temporal 워크플로우입니다. 체크인이 필요할 때까지 내구성 타이머(durable timers) 상태로 대기합니다. 만약 **괜찮아요 (I'm OK)**를 누르지 않으면, 사용자에게 알림을 보내고 유예 기간을 기다린 후, 비상 연락처에 경고를 보냅니다: 먼저 템플릿 메시지(즉시 전송되며, 중요 경로에서는 AI가 작동하지 않음)로, 그다음 로컬 Gemma가 작성한 상황 요약본으로요. 사용자가 나타나서 OK를 누르면, 연락처에게 안전하다는 신호를 받게 됩니다.
작업자(worker)를 종료하거나, 웹 앱을 재시작하거나, 하이킹 중간에 Temporal 서버를 재부팅해도: 타이머는 계속 작동합니다.
TrailWatch는 프로토타입입니다. 이는 ~의 대체품이 아닙니다.
안전 로직은 두 파일에 있습니다: trailwatch/workflows.py (내구성 워크플로우)와 trailwatch/rules.py (모델이 무시할 수 없는 결정론적 규칙). pytest는 12개의 테스트를 실행합니다. scripts/kill_test.py는 사용자 기기에서 동영상을 재현합니다.
작동 방식
스택(Stack): Ollama를 통해 Gemma 3 4B 모델을 사용하고, RTX 3050이 장착된 노트북에서 개발했습니다. Temporal (Python SDK, 로컬 개발 서버)와 액션 버튼이 있는 푸시 알림을 위해 ntfy를 사용했으며, 일몰 시간과 날씨 정보는 Open-Meteo를 이용했습니다. FastAPI와 하나의 HTML 파일로 구성되어 있습니다.
phone (웹 페이지 또는 ntfy 잠금 화면 버튼)
│ 여행 시작 · 괜찮음(신호) · +30분(업데이트) · SOS · 집에 도착함
▼
...
여행당 하나의 워크플로우(One workflow per trip)
모든 여행은 Temporal 워크플로우이며, 제가 사용한 모든 Temporal 기능은 실제 역할을 수행합니다:
| Temporal 기능 | TrailWatch에서 하는 역할 |
|---|---|
| 내구성 타이머 (Durable timers) | 체크인 마감일, 유예 기간 및 30분마다 재알림을 확인합니다. 이들은 제 프로세스에 있는 것이 아니라 서버에 존재합니다. |
| ... |
핵심은 받은 편지함(inbox)과 경쟁하는 타이머입니다:
async def _wait_until(self, trip_min: float) -> bool:
"""받은 편지함과 경쟁하는 내구성 타이머. 체크인이 먼저 도착하면 True를 반환합니다."""
if self.inbox:
...
이 wait_condition 타임아웃은 내구성 타이머입니다. 작업자(worker)가 기다리는 동안 다운되어도 아무것도 손실되지 않습니다. 타이머는 서버에서 작동하며, 다음에 돌아오는 어떤 작업자가 정확히 그 라인부터 작업을 이어받습니다.
AI는 알림의 핵심 경로에 결코 존재하지 않는다 (The AI is never on the alert's critical path)
Gemma는 두 가지 역할을 합니다. 캐주얼한 문장을 계획으로 변환하고, 연락처를 위한 요약 보고서를 작성하는 것입니다. Gemma가 언제 알림을 보낼지 결정하지 않으며, 첫 번째 알림은 결코 Gemma를 기다리지 않습니다:
- 알림 자체가 템플릿이며, 즉시 전송됩니다.
- 그 후에, 최선을 다해 Gemma가 문맥이 풍부한 요약 보고서를 작성합니다. 만약 Gemma가 작동하지 않아도, 연락처는 이미 필요한 정보를 가지고 있습니다.
제 노트북에서 차갑게(cold) Gemma를 로드하는 데 약 70초가 걸립니다. 이는 집에서 계획을 세울 때는 괜찮지만, 알림 경로에서는 용납할 수 없습니다.
코드가 결정하고 모델이 제안한다 (The model proposes, the code decides)
Gemma의 계획은 어떤 것이 활성화되기 전에 rules.py를 통과합니다. 모델은 무엇이든 제안할 수 있지만, 다음은 할 수 없습니다:
- 솔로 여행 시 체크인 간격을 60분 이상, 고위험 지역에서는 45분 이상으로 설정
- 일몰이나 바람이 암시하는 위험 수준을 낮춤
- 되돌아오는 시간이 돌아갈 시간을 남기지 않도록 조정
모든 규칙에는 테스트가 있으며, 그중 대부분은 Gemma가 무언가를 망가뜨렸기 때문에 존재합니다.
알려드릴 만한 세 가지 버그
1. "1440분 지점에서 회항." 3시간 하이킹에 대해 Gemma 3 4B는 1440분의 회항 시간을 제안했는데, 이는 하루 전체 시간입니다. 수정: 회항은 여정의 처음 75% 이내에 이루어져야 하며, 그렇지 않으면 중간 지점으로 대체됩니다.
2. "40분 뒤 복귀" → 내일 12:39 복귀. 23:00에 Gemma는 "40분 뒤 복귀"를 expected_return_local: "12:39"로 변환하고, 같은 응답에서 duration_min: 40을 제시했습니다. 제 파서는 시계 시간을 신뢰했기 때문에 하이커가 13시간이나 늦게 실종된 것으로 보고되었을 것입니다. 제가 이 문제를 발견한 것은 kill-test 실행 결과 계획이 출력되었기 때문입니다. 수정: 모델의 시계 시간과 지속 시간이 불일치할 경우, 더 빠른 시간을 사용해야 합니다. 오경보가 늦은 경보보다 낫습니다.
3. 저 자신의 버그: 다운타임으로 인해 알림이 지연됨. 제 초기 버전에서는 유예 기간(grace period)을 알림 전송 후 시작했습니다. 만약 작업자가 마감 시간까지 작동하지 않았다면, 재시작 시점에 늦게 알림이 나갔고, 유예 기간은 그 시점부터 계산되어 경보가 전체 유예 기간만큼 지연되었습니다. Durable timers는 잘못된 순간을 기준으로 측정한다면 도움이 되지 않습니다. 수정: 유예 기간은 놓친 마감 시간을 기준으로 계산되며, 이는 워크플로우의 기록에 고정되므로 다운타임이 절대로 알림을 지연시킬 수 없습니다. 이제 작업자가 두 마감 시간 모두 동안 작동하지 않는 상황을 유지하고 경보가 돌아오는 순간 발동하는지 확인하는 통합 테스트가 추가되었습니다.
한 번의 테스트에서 Gemma는 또한 10% 확률의 비를 "중요하다(significant)"고 판단했습니다. 이것이 바로 팁은 단순 조언이며 경보는 코드로 온다는 이유입니다.
테스트
- 시간 건너뛰기(Time-skipping): Temporal의 테스트 서버는 6시간 동안 침묵하는 하이킹을 몇 초 만에 실행합니다. 이 과정에서 연락처가 정확히 10개의 알림(75분 경과 시점, 이후 30분마다)과 단 하나의 Gemma 요약본만 받는지를 확인합니다.
- 워커 사망(Worker death): 압축된 클럭을 가진 실제 개발 서버입니다. 작업자가 마감 시간 및 유예 기간을 통해 종료되고, 테스트는 재시작 시 알림이 발생하는지 확인합니다.
- 규칙(Rules): 실제 Gemma의 실수들은 회귀 테스트(regression tests)로 유지됩니다.
오픈 혁신은 왜 중요한가요?
이 앱은 당신이 혼자인지, 그리고 어디에 있을지 알고 있기 때문입니다. 여행 계획은 "이 사람은 오후 5시까지 이 능선에서 혼자일 것이고, 아무도 집에 없을 것이다"라는 내용입니다. 저는 그런 정보가 다른 사람의 로그에 남는 것을 원하지 않습니다. TrailWatch를 사용하면 모델이 제 노트북에서 실행되고, Temporal는 오픈 소스이며 자체 호스팅(self-hosted)이 가능하고, ntfy 역시 자체 호스팅할 수 있으며, 날씨 데이터는 공개 데이터를 이용합니다. 안전 경로에 있는 그 어떤 것도 특정 공급업체의 가동 시간, 가격 또는 서비스 약관에 의존하지 않습니다.
안전 도구가 구독료를 요구해서는 안 되기 때문입니다. TrailWatch를 실행하는 데 비용이 들지 않습니다. 아키텍처가 결정(code와 durable timers)을 담당하고 계획 및 설명을 담당하므로, 작은 오픈 가중치 모델(open-weight model)만으로 충분합니다.
제가 어떻게 실패하는지 확인할 수 있기 때문입니다. Gemma를 로컬에서 실행하면서, 제가 강하게 테스트하여 1440분짜리 실수와 12:39의 실수를 보고, 각각을 규칙과 테스트로 만들 수 있었습니다. 모델을 교체하는 것은 환경 변수(environment variable) 하나만 바꾸면 됩니다. 어떤 Ollama 모델이든, 또는 호스팅된 Gemma를 포함하여 모든 OpenAI-호환 엔드포인트가 가능합니다.
폐쇄형 API가 더 좋았을 경우: 솔직히 말해 속도와 판단력 때문입니다. 대규모의 호스팅된 모델은 10~35초 대신 약 2초 만에 계획을 세우고, 아마 내일 돌아가는 것을 제안하지 않을 것입니다. 하지만 이 문제에 대해서는 단순히 더 똑똑한 것보다는 제가 실행하고, 검사하고, 제한할 수 있는 모델이 낫습니다.
솔직한 한계점
⚠️ [IMG:N] 형식 토큰은 이미지 placeholder 입니다. 번역하지 말고 원래 위치에 그대로 유지하세요.
- 이것은 프로토타입이며, 위성 메신저나 PLB가 아닙니다. 푸시 알림을 받으려면 휴대폰이 필요합니다. 신호가 끊기는 것이 바로 이 앱이 경고하는 상황이지만, 계곡에서 스스로 도움을 요청할 수는 없습니다.
- 비디오는 압축된 시계를 사용합니다. 코드 경로는 실시간과 동일하지만, 여전히 데모입니다.
- ntfy 토픽은 공개 서버의 비밀번호와 같습니다. 길고 무작위적인 것을 사용하거나 ntfy를 자체 호스팅하세요. 앱을 터널 뒤에 배치하기 전에 접근 키(access key)를 추가했기 때문에, 유출된 URL이 여행을 취소할 수 없습니다.
- 푸시 전송은 최소 한 번 보장(at-least-once)입니다. 워커가 푸시 도중에 죽으면 재시도(retry)로 인해 중복 메시지가 전송될 수 있습니다. 제가 테스트한 결과, 워커를 종료했을 때 진행 중이던 푸시는 정확히 한 번 도착했지만, 저는 그것을 보장하지 않습니다.
나의 에이전트 세션
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기