Outward: 밖에 나가야만 전진할 수 있는 어드벤처 스토리
요약
Outward는 사용자가 실제로 외부 활동(산책)을 해야만 스토리가 진행되는 독특한 어드벤처 스토리 게임입니다. GPS와 소형 언어 모델(SLM)을 활용하여, 발견한 주변 환경의 사물이나 경험이 다음 챕터의 플롯으로 작용합니다. 이는 사용자의 호기심을 자극해 외부 활동을 유도하며, 오프라인에서도 작동하도록 설계되었습니다.
핵심 포인트
- 스토리 진행에 실제 산책과 '발견'을 필수 요소로 활용함.
- GPS 추적 및 SLM을 이용해 발견물을 이야기의 플롯으로 통합.
- 오프라인(비행기 모드)에서 스토리텔링 모델이 작동하도록 설계됨.
- Tinker와 Gemma를 사용한 경량화된 온디바이스 AI 구현 사례.
이 글은 Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass에 제출하는 내용입니다.
제가 만든 것 (What I Built)
Outward는 소파에 앉아서 할 수 없는 스토리 게임입니다. 플레이어는 모험의 주인공이 되며, 다음 챕터는 밖에 나가서 작은 실제 임무를 완수하고 발견한 것을 이야기해야만 잠금 해제됩니다.
"기록관(The Archivist)은 도시가 잊어버린 무언가가 필요하다. 10분 동안 걸으면서 누군가 의도적으로 남긴 흔적을 찾아라."
플레이 시간으로 10분, 30분 또는 60분을 선택하고 휴대폰을 주머니에 넣은 뒤 걷습니다. 실제로 거리를 이동했을 때(GPS로 추적하여 250m, 700m 또는 1.4km), Outward가 깨어나 무엇을 발견했는지 물어봅니다. 예: "포장도로에 그려진 분필 하트." 휴대폰에서 구동되는 소형 언어 모델(small language model)이 이 분필 하트를 이야기의 다음 부분으로 작성하고, 절정으로 끝내며 챕터가 완료됩니다. 발견한 내용은 필드 저널에 기록되고, 작은 새싹 친구인 Sprig가 함께 축하해줍니다.
이는 스크린 타임이 자신들의 외부 활동 시간을 서서히 잠식당했다고 느끼는 모든 사람들을 위한 것입니다. 대부분의 '외출' 앱은 죄책감(연속 기록, 걸음 수, 빨간 링)을 사용합니다. Outward는 대신 호기심을 활용합니다. 즉, 다음 이야기가 궁금해서 밖에 나가게 되고, 자신의 눈으로 본 무언가 없이는 스토리가 진행될 수 없습니다.
제가 중요하게 생각한 세 가지 요소가 있습니다:
- 스토리의 핵심은 당신의 산책입니다. 모든 챕터는 목록에서 고른 것이 아니라 _당신_의 발견을 중심으로 쓰여집니다. 물웅덩이, 벽 위의 고양이, 그리고 분필 하트 자체가 플롯이 됩니다.
- 손에 들지 않고 주머니에 넣습니다. 임무를 수행하는 동안 화면은 휴대폰을 넣어두라고 알려줍니다. 쳐다볼 지도도 없고 피드도 없습니다. 이 앱은 화면을 끄고 있는 시간을 좋은 것으로 계산합니다.
- 신호가 없어도 작동합니다. 한 번 설치하면, 스토리텔러 모델(storyteller model), 음성-텍스트 변환(voice-to-text), 내레이션 및 안전 규칙 등 모든 것이 비행기 모드에서 실행됩니다. 공원, 산책로, 지하 공간에서도 오류 없이 작동합니다.
두 명의 파트너, 두 가지 역할:
🧠 Tinker가 브레인을 훈련합니다. 저는 Tinker를 사용하여 Qwen3.5-4B 모델을 미세 조정(fine-tuned)한 후, 이를 241 MB 크기의 Gemma로 증류(distilled)했습니다. 이 Gemma는 4B 선생님과 거의 비슷한 수준의 글쓰기 능력을 보여줍니다 (4.75 vs 4.79). 총 비용은 $2.56입니다.
🚚 Render가 배포합니다. 하나의 Render 서비스가 앱, 모델 가중치(model weights), 그리고 새로운 이야기 시즌을 모든 휴대폰으로 전송하며, 기기 내 AI를 빠르게 만드는 브라우저 헤더와 대역폭 비용에 대한 안전장치를 제공합니다. 그 후에는 휴대폰이 인터넷 연결 없이도 작동할 수 있습니다.
데모 (Demo)
🔗 Render에서 실시간 확인: https://outward-6fhh.onrender.com (브라우저 메뉴에서 앱으로 설치하세요)
🩺 실시간 서버 상태: https://outward-6fhh.onrender.com/api/health (배포된 커밋, Render 디스크에 준비된 모델, 이번 달 사용 대역폭)
시도해 볼 것들:
- Wi-Fi에서 한 번 열어보세요. 설정 화면이
Outward는 산책을 모험으로 바꿔주는 설치형 웹 앱(PWA)입니다. 사용자가 주인공이 됩니다. 이야기는 오직 밖에 나가서 '흐르는 무언가를 찾기'와 같은 실제 미션을 완료할 때만 전진합니다. 발견한 것을 설명하면, 휴대폰에서 구동되는 작은 오픈 웨이트 모델이 이를 이야기 속에 녹여냅니다.
설치 후에는 완전히 오프라인으로 작동합니다: 신호가 필요하지 않으며, 사용자의 위치, 음성 또는 산책 기록은 휴대폰을 벗어나지 않습니다.
상태: 8단계 중 7단계. 오프라인 PWA, 5개 챕터 스토리, 기기 내 Gemma 3 270M 스토리텔러 및 기기 내 음성 입력(Whisper)이 비행기 모드에서 작동하며, 여기에 산책 기반 미션, 그려진 산책 경로, 화면 시간 통계가 추가되었습니다. 휴대폰은 이제 Tinker로 파인튜닝된 Qwen3.5-4B에서 증류된 Gemma 3 270M을 구동합니다 (rubric 2.55 → 4.75; training/RESULTS.md 참조). 스토리 공장에서는 새롭게 검토되고 나레이션된 스토리를 배포합니다…
모노레포: /app (PWA), /server (Render), /training (Tinker 파인튜닝, 증류 및 평가), /content (스토리, 안전 규칙, 프롬프트 및 모델 레지스트리). MIT 라이선스를 따르며, Gemma 가중치는 Gemma 약관을 따릅니다.
제가 이것을 만든 방법
핵심 문제: 좋은 스토리텔러는 4B 모델이지만, 공원에 있는 휴대폰에는 집으로 전송되지 않는 크기 약 15배 작은 것이 필요합니다. 그래서 저는 오픈 웨이트 모델을 엔드 투 엔드로 사용했습니다. 클라우드에서 교사(teacher)를 훈련시키고, 이를 아주 작은 학생(student)으로 증류하여 휴대폰에 배포했습니다.
┌──────────── TRAINING (일회성, ~$2.56) ────────────┐
648 수작업 검토된 ──► │ Qwen3.5-4B ──LoRA on Tinker──► Teacher v3 │
시드 챕터 │ │ │
...
오픈 소스 AI
| 역할 | 모델 / 도구 | 실행 환경 |
|---|---|---|
| 교사 스토리텔러 | Qwen3.5-4B + LoRA r32 | Tinker (학습 및 샘플링) |
| ... |
단계 1: Tinker에서 교사 모델 미세 조정하기
기본 Qwen3.5-4B는 이미 괜찮은 산문을 작성합니다. 문제는 이 모델이 플레이어의 역할을 무시한다는 것입니다. 플레이어가 발견한 내용을 언급하고 나면, 그저 자신만의 플롯을 이어갑니다. "당신이 이것을 만들었다"가 핵심인 이야기에서 이는 가장 큰 실패 요인이 됩니다.
저는 648개의 시드(seed) 챕터를 작성했고, 5가지 항목의 평가 기준표(사용자의 발견 활용 여부, 일관성, 플롯 유도 능력, 안전성, 길이)를 만들고 에포크별 체크포인트로 LoRA를 학습시켰습니다. 첫 번째 실행 후 과적합된 지점에서 나온 검증 손실(validation loss)을 기준으로 체크포인트를 선택했습니다. 50개의 별도 평가 프롬프트 × 3개 샘플에 대한 결과는 다음과 같습니다:
| 기본 Qwen3.5-4B | 미세 조정 모델 (v3) | |
|---|---|---|
| 기준표 점수 (/5점) | 4.66 | 4.79 |
| ... |
플롯 유도 능력은 6%에서 39%로, 게임이 의존하는 속성(property)에 대해 약 6.5배 개선되었습니다. 별도의 개발 세트에서도 (12% → 30%) 성능을 유지했습니다. 솔직히 말하자면: 안전 검사기(safety checker)가 약간 더 많은 결과물(4개 → 6개)에서 플래그를 지정했지만, 이 모든 경우는 모델이 지어낸 것이 아니라 플레이어 자신의 말이 인용된 경우였습니다.
단계 2: 휴대폰에서 실행 가능한 형태로 경량화하기 (Distill)
4B 모델은 휴대폰의 브라우저 탭에 들어갈 만한 크기가 아닙니다. 교사 모델은 2,500개의 프롬프트 × 3개 샘플을 생성했습니다. 각 샘플은 기준표와 안전 필터에 의해 점수가 매겨졌고, 3,186개의 좋은 예시가 남았습니다. 저는 이 데이터를 사용하여 Kaggle 노트북에서 Gemma 3 270M 모델을 완전 미세 조정(full-fine-tuned)하고 Q4_0 GGUF로 양자화(quantised)했습니다.
| 동일한 50개 평가 프롬프트 | 점수 (/5점) | 안전 플래그 건수 | 플롯 유도 능력 |
|---|---|---|---|
| 기본 Gemma 3 270M | 2.55 | 27 | — |
| ... |
241 MB 크기의 270M 모델이 4B 교사 모델과 0.04점 차이밖에 나지 않습니다. 이 모델은 장치(on-device)에서 2~3초 만에 챕터를 작성하며 (CPU 기준 약 53 토큰/초), 챕터당 비용이 전혀 들지 않습니다. 또한 학습 과정에서 본 적 없는 이야기 시즌에 대해서도 좋은 챕터를 작성했습니다.
프로젝트 전체의 Tinker 지출액: $2.56.
단계 3: 외부에서도 안전하게 유지하기
사람들을 외부에 보내는 게임은 _어디_에 대해 신중해야 합니다. 미션들은 도로, 물가, 낯선 사람, 사유지, 등반 또는 어둠을 요구하지 않습니다. 안전 규칙은 rules.json 하나에 존재하며, 이는 **JavaScript 필터(휴대폰에서)**와 Python 필터(학습 시) 모두가 사용합니다. 공유된 케이스 테스트 파일이 이들을 동기화 상태로 유지하며, 만약 불일치하면 CI가 실패합니다. 또한 필터는 모델이 작성하는 모든 것을 확인합니다: 발견을 사용해야 하고, 길이를 지켜야 하며, 예시를 복사하거나 프롬프트를 반복해서 되풀이하지 않아야 하고, 자신이 AI라는 것에 대해 언급해서도 안 됩니다. 만약 출력이 실패하면, 플레이어는 대신 손으로 쓴 대체품을 받습니다. 앱은 어떤 것을 받았는지("휴대폰에서 작성됨" 대 "미리 작성됨")를 보여줍니다.
단계 4: AI를 휴대폰에 구현하는 런타임인 Render
온디바이스(on-device) 모델이라도 기기에 _도달_해야 합니다: 241 MB의 가중치(weights), 특수 브라우저 헤더가 필요한 WASM 런타임, 그리고 출시 후 새로운 스토리들입니다. Render가 바로 그 배포 계층(delivery layer)입니다. 이것은 인도에 있는 저에게 가장 가까운 지역인 싱가포르의 Starter 플랜에 있는 하나의 Node 서비스(Fastify)이며, 단일 render.yaml 청사진(Blueprint)에서 배포됩니다.
git push ──► Render build (npm ci && npm run build) ──► health check /api/health
│
content/models.json ──(pinned HF revision)──► boot sync ──► 1 GB disk /var/data/models
...
Render가 하는 일과 각 부분이 중요한 이유:
- 온디바이스(on-device) 추론을 빠르게 가능하게 합니다. 모든 응답은
Cross-Origin-Opener-Policy와Cross-Origin-Embedder-Policy를 포함합니다. 이 크로스 오리진 격리(cross-origin isolation) 기능이SharedArrayBuffer를 잠금 해제하며, 이는 llama.cpp의 WASM 빌드가 여러 CPU 스레드에서 실행되는 데 필요합니다. 이 두 헤더가 없으면 휴대폰의 스토리텔러는 단일 스레드로 폴백(fallback)됩니다. 헤더 설정이 불가능한 일반적인 정적 호스트로는 작동하지 않았을 것입니다. - 영구 디스크가 모델 저장소입니다. 부팅 시, 서버는
content/models.json을 읽고 학생 모델의 정확하게 고정된 Hugging Face 리비전(revision)을 1 GB 디스크에 다운로드합니다 (.part파일로 크기 확인 후 이름을 변경). 그리고 오래된 버전은 삭제하여 디스크가 가득 차지 않도록 합니다. 더 나은 모델을 배포하는 것은 단순히 레지스트리 항목과git push만으로 가능합니다: Render에서 재구축(rebuild)하면, 디스크가 동기화되고, 휴대폰에서는 새로운 버전의 URL이 보입니다. - 캐싱은 오프라인 앱을 위해 설계되었습니다. 모델 파일과 해시된 에셋은 1년 동안
immutable로 제공되므로, 휴대폰이 241 MB를 두 번 다운로드할 일이 없습니다.index.html과 서비스 워커(service worker)는no-cache로 설정되어 있어 앱 업데이트가 설치된 휴대폰에 도달합니다. - 비용에는 안전장치가 있습니다. 서버는 매월 전송하는 모델 바이트 수를 계산합니다.
MODEL_BANDWIDTH_BUDGET_GB(60 GB)를 초과하면,/models/*경로는 Hugging Face의 동일한 파일로 302 리다이렉트 응답을 보냅니다. 앱은 먼저 Render를 시도하고 자체적으로 Hugging Face로 폴백합니다. 호스팅 비용은 약 $7.25/월에 유지되며, 100개 설치는 대략 $11 정도가 됩니다. 최악의 경우에도 무한정 늘어나지 않고 상한선이 정해져 있습니다. - 앱 업데이트 없이 새로운 스토리 시즌을 가져옵니다.
/api/packs는 승인된 스토리 팩 목록을 제공하며, 이들의 ElevenLabs 내레이션은 콘텐츠 해시 URL에서 제공됩니다. 앱은 이를 가져와 오프라인 재생을 위해 캐싱합니다. 이것이 Season 2 (The Second Atlas)가 배포된 방식입니다. - 서버에 비밀 정보가 없습니다. Render는 모델을 실행하거나 API 키를 보관하지 않습니다. Tinker와 ElevenLabs는 제가 사용하는 Mac의 오프라인 스토리 공장(story factory)에서만 사용됩니다.
만약 누군가 서버에 침입하더라도 훔칠 것이 없고 청구할 비용도 없습니다.
직접 확인해 보실 수 있습니다:
$ curl https://outward-6fhh.onrender.com/api/health
{"ok":true,"commit":"475692a","app":true,
"models":[{"id":"outward-gemma3-270m","version":"distilled-v1-133f250","ready":true}],
...
저는 의도적으로 모델을 Render에서 실행하지 않았습니다. 클라우드 모델은 걸어 다닐 때마다 연결이 필요하고 모든 사용자의 위치를 확인할 수 있습니다. 여기서는 Render가 서버가 잘하는 일, 즉 전송(shipping), 버전 관리(versioning), 업데이트 및 비용 보호 기능을 수행합니다. 휴대폰이 사고를 처리합니다.
5단계: 음성, 스토리 시즌 및 CI
- ElevenLabs가 고정된 스토리 텍스트(George, 아카이브리스트로서)를 나레이션합니다. 클립은 한 번 렌더링되어 오프라인 사용을 위해 캐시되며, 총 용량은 약 2.4MB입니다. 모델의 실시간 텍스트는 휴대폰 자체 음성으로 읽히며, 노래방 스타일 자막과 Sprig의 입 모양이 오디오와 동기화됩니다.
- 새 시즌: 제 Mac에 있는 '스토리 공장(story factory)'은 사람이 작성한 플롯 개요를 받아 Tinker로 Qwen을 이용해 산문을 초안하고, 제가 편집을 적용하며, 나레이션을 렌더링한 후, 제가 승인했을 때만 패키지를 게시합니다. 시즌 2(The Second Atlas) 제작 비용은 Tinker 크레딧으로 $0.007이었습니다.
- GitHub Actions는 푸시(push)가 발생할 때마다 린트(lint), 안전성 동등성 테스트(safety-parity tests), 앱 및 서버 테스트, 빌드, 그리고 Python 테스트를 실행합니다.
- 설치 크기: 총 311MB (모델 241MB, Whisper 44MB, 런타임 24MB, 나레이션 2.4MB, 앱 0.1MB).
Open Innovation은 왜 중요할까요?
전체 아이디어가 폐쇄적인 API로는 작동하지 않기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기