스냅툴
Published on

2.1B 온디바이스 LLM을 앱에 넣기까지 — 사주가계부 개발기

Authors
  • avatar
    Name
    스냅툴
    Twitter

사주가계부는 지출을 기록하는 순간, 사용자의 사주와 오늘의 일진을 읽은 AI가 운세 멘트 한 줄을 던지는 가계부 앱입니다.

"절약의 기운이 강하니, 외식은 줄이고 저축의 불씨를 더 키워라."

이 멘트를 만드는 LLM은 서버가 아니라 사용자의 아이폰 안에서 돌아갑니다. 카카오의 Kanana 1.5 2.1B 모델을 GGUF로 양자화해 앱에 통째로 넣었습니다. 이 글은 그 과정에서 내린 결정들과 밟은 함정들의 기록입니다.

왜 온디바이스인가

이 앱이 다루는 데이터를 나열해 보면 답이 나옵니다.

  • 생년월일시 — 사주 계산의 입력. 주민등록번호 앞자리보다 정확한 개인 식별 정보입니다.
  • 소비 내역 전체 — 언제, 어디에, 얼마를 썼는지.

이 둘을 조합해 외부 LLM API로 보내는 순간, "가장 민감한 개인정보를 제3자 서버에 전송하는 앱"이 됩니다. 개인정보처리방침이 복잡해지고, 사용자에게 설명하기도 어렵습니다. 반대로 온디바이스라면 이야기가 한 문장으로 끝납니다. "당신의 생년월일과 소비 내역은 폰 밖으로 나가지 않습니다."

부수 효과도 큽니다. 지출 기록마다 LLM을 호출하는 구조인데, 이게 전부 기기 안에서 처리되니 API 비용이 0원입니다. 오프라인에서도 동작하고요. 1인 개발자가 무료 앱을 지속 운영하는 데 이만한 구조가 없습니다.

모델 선정 — 진짜 함정은 라이선스였다

한국어 멘트를 생성해야 하니 한국어를 잘하는 소형 모델이 필요했습니다. 후보를 모아 동일 조건(사주 톤 3종 × 일진 관계 5종 매트릭스, 총 60개 멘트 생성)으로 비교했습니다.

모델상업 라이선스크기(Q4)tok/s영어 누수이모지다문장
Kanana 1.5 2.1BApache-2.0 ✅1.5GB27.00045
gemma2:2bGemma ToU ✅1.6GB28.801413
gemma3:4bGemma ToU ✅3.3GB19.0100
llama3.2:3bLlama ✅2.0GB22.91808
exaone3.5:2.4bNC ❌1.6GB33.854553

tok/s는 M1 Mac(ollama) 기준. 실기기 수치는 아래에서.

기술 벤치마크보다 먼저 발목을 잡은 건 라이선스였습니다.

  • EXAONE 3.5 — 한국어 품질은 최상위권인데, EXAONE AI Model License 1.1-NC. "expressly prohibited … for any commercial purposes". 유료든 무료든 상업 앱 탑재 불가.
  • Qwen2.5-3B — Qwen 계열 중 하필 3B(와 72B)만 Qwen Research License(비상업). 0.5/1.5/7B는 Apache-2.0인데 딱 모바일에 알맞은 3B만 막혀 있습니다.
  • Kanana도 함정이 있습니다 — 구버전 kanana-nano-2.1b는 CC-BY-NC. 상업 사용이 가능한 건 kanana-1.5-2.1b-instruct-2505(Apache-2.0)부터입니다. 같은 이름 계열인데 버전에 따라 라이선스가 다릅니다.

소형 LLM을 상업 앱에 넣을 계획이라면, 벤치마크를 돌리기 전에 라이선스 전문부터 읽는 걸 권합니다. 저는 EXAONE으로 품질 검증을 다 끝낸 뒤에 라이선스 위반임을 발견하고 재검증을 돌렸습니다.

품질 면에서도 Kanana가 최선이었습니다. 사주 용어를 정확하고 자연스럽게 쓰고("계수 일간은 수렴의 기운인데 갑술로 목기가 강해져…"), 일진 관계와 소비 조언의 논리(인성→절약, 재성→흐르되 절제, 관성→충동 주의)가 어긋나지 않았습니다. 반면 gemma3:4b는 문장 규율은 완벽했지만 "기해상록", "환재불면" 같은 가짜 명리 용어를 지어냈습니다. "진짜 사주 계산"이 셀링 포인트인 앱에서 이건 치명적입니다. llama3.2는 60개 중 18개에 영어가 섞여 나와 탈락했고요.

1.5GB 모델을 어떻게 배포하나

Q4_K_M 양자화 기준 모델 파일이 1.5GB입니다. 앱 번들에 넣으면 앱스토어 다운로드 용량이 앱 자체보다 모델이 커지는 기형이 되니, 첫 실행 시 다운로드 방식을 택했습니다. 여기에 세 가지 장치를 붙였습니다.

1. OS 관리 백그라운드 전송. 1.5GB면 와이파이에서도 몇 분 걸립니다. 그동안 사용자가 앱을 벗어나면 다운로드가 죽는 구조로는 안 됩니다. iOS는 background NSURLSession, Android는 Foreground Service로 OS에 전송을 위임해, 앱이 백그라운드로 가거나 프로세스가 회수돼도 다운로드가 이어집니다. 앱을 재실행하면 진행 중이던 태스크에 재접속해 이어받습니다.

2. 미러 폴백. 원본은 Hugging Face에서 받는데, HF 직다운은 지역·시간대에 따라 실패하는 경우가 있습니다. 실패 시 Cloudflare R2에 올려둔 동일 바이트의 미러로 1회 재시도합니다. R2는 Range 요청과 ETag를 네이티브로 지원해 이어받기에도 안전합니다.

3. 바이트 단위 크기 검증. "파일이 존재한다"로 완료 판정하면 중간에 끊긴 파일이나 에러 응답이 저장된 파일을 모델로 오인합니다. 완료 판정은 파일 크기가 정확히 1,522,796,768바이트일 때만. 불일치면 지우고 처음부터 다시 받습니다.

/** 모델이 온전히 받아져 있는지 — 크기가 정확히 일치해야 완료 */
export async function isModelDownloaded(): Promise<boolean> {
  const info = await FileSystem.getInfoAsync(modelPath())
  return info.exists && !info.isDirectory && info.size === MODEL_SIZE_BYTES
}

llama.rn으로 실기기 추론

추론 런타임은 llama.rn(llama.cpp의 React Native 바인딩)입니다. 초기화 파라미터에 저사양 대응을 담았습니다.

ctx = await initLlama({
  model: modelFilePath,
  n_ctx: 1024,        // 멘트 프롬프트는 짧다 → KV 캐시 메모리 절감 (4GB RAM 기기 대비)
  n_gpu_layers: 99,   // 가능한 레이어 전부 GPU(Metal)로
  n_threads: 4,
})

n_ctx(컨텍스트 길이)를 1024로 줄인 게 핵심입니다. 컨텍스트 길이는 KV 캐시 메모리에 직결되는데, 이 앱의 프롬프트는 사주 요약 + 방금 지출 정보로 짧기 때문에 긴 컨텍스트가 필요 없습니다. RAM 4GB인 iPhone 12 mini에서도 2.1B 모델이 올라가는 건 이 덕분입니다.

실기기 성능은 이렇게 나왔습니다.

환경생성 속도멘트 1건(~40자)
M1 Mac (ollama, 검증 대역)27 tok/s~2.4초
iPhone 12 mini (A14, 4GB, 2020)7.3 tok/s~5.9초

저사양 기준선으로 잡은 iPhone 12 mini에서 6초 정도. 지출 입력 직후 1회성 생성이라 치명적이진 않지만, 6초 빈 화면은 깁니다. 그래서 토큰 스트리밍으로 풀었습니다. llama.rn의 completion은 토큰 콜백을 지원하니, 글자가 도착하는 대로 화면에 흘려보내면 체감 지연은 첫 토큰까지의 1초 남짓으로 줄어듭니다. 점집에서 한 글자씩 말이 나오는 것 같은 연출이 되어 오히려 앱 컨셉과 잘 맞았습니다.

소형 모델 길들이기 — 프롬프트보다 후처리

2.1B는 GPT-4가 아닙니다. "한 문장, 40자, 이모지 금지"라고 프롬프트에 아무리 적어도 어기는 날이 옵니다. 그래서 세 겹으로 방어합니다.

1. 프롬프트 — 구조화된 입력 계약. 사주 엔진이 만세력 기준으로 산출한 일간·일진·오행 관계를 결정론적으로 요약해 넣고, 페르소나(정통명리형/도사형/점쟁이형 3종)와 포맷 규칙을 system에 고정합니다. LLM에게 사주 해석을 시키지 않고 해석은 코드가, 문장만 모델이 만들게 역할을 나눈 겁니다. 가짜 명리 용어 환각을 원천 차단하는 구조기도 합니다.

2. stop 시퀀스 + 후처리. 생성이 끝나면 종료 토큰 누수(<|eot_id|> 등), 이모지, "멘트:" 같은 머리말, 감싼 따옴표를 정규식으로 걷어내고, 첫 문장만 남긴 뒤 60자에서 하드 컷합니다. 검증 단계에서 실제로 관측된 실패 유형만 골라 만든 필터라 군더더기가 없습니다.

3. 프리셋 폴백. 모델 다운로드 전, 다운로드 실패, 저사양 기기 — 어떤 경우에도 멘트는 나와야 합니다. 톤 3종 × 일진 관계 5종 = 15칸의 수작업 템플릿을 두고, LLM이 없으면 즉시 이걸로 응답합니다. 사주 계산 자체는 결정론적이라 템플릿도 "오늘의 운세"로서 성립합니다. 온디바이스 AI는 실패 모드가 다양해서, AI 없이도 도는 경로가 반드시 필요합니다.

배운 것

  1. 라이선스가 첫 번째 필터다. 한국어 소형 모델의 절반이 비상업(NC)입니다. 같은 모델 계열도 버전·사이즈에 따라 갈립니다. 벤치마크는 그다음입니다.
  2. 작은 모델일수록 일을 줄여줘야 한다. 사주 해석을 코드로 빼고 문장 생성만 맡기니 2.1B로도 충분했습니다. 모델에게 시킬 일과 코드가 할 일의 경계 설계가 온디바이스 AI의 절반입니다.
  3. 배포가 추론만큼 어렵다. 1.5GB 파일을 모바일에 안정적으로 내려보내는 것(백그라운드 전송, 이어받기, 미러, 크기 검증)에 추론 구현만큼의 코드가 들었습니다.
  4. 폴백 없는 온디바이스 AI는 미완성이다. 다운로드 실패·저사양·구모델 기기에서도 앱의 핵심 루프는 돌아야 합니다.

온디바이스 LLM은 아직 "아무 앱에나 넣는 기술"은 아닙니다. 하지만 이 앱처럼 민감한 데이터 × 짧은 생성 × 잦은 호출이라는 조건이 겹치는 지점에서는, 클라우드 API보다 프라이버시·비용·오프라인 모든 면에서 우위였습니다. 같은 조건의 앱을 고민 중이라면 2B급 + llama.rn 조합은 이미 실전에서 쓸 만합니다.

이 글에서 만든 앱

사주가계부 - 만세력 오늘의 운세
온디바이스 AI데이터 시각화알고리즘·자동화

지출을 기록하면 사주로 답하는 AI 가계부 앱입니다. 생년월일시를 입력하면 만세력 기준으로 사주팔자를 정확히 산출하고, 지출을 기록하는 순간 사주와 오늘의 일진을 읽은 AI가 한마디를 건넵니다. 기록이 작은 재미가 되어 가계부를 꾸준히 이어갈 수 있습니다.

앱 보러 가기 →