본문으로 건너뛰기
  1. 포스트/

React Native 온디바이스 AI: llama.cpp로 Qwen 1.7B 돌리기

· loading · loading ·
인재덕
작성자
인재덕
서울에 거주하는 리더 겸 소프트웨어 엔지니어

제가 만들고 있는 커튼 견적 앱 Curtain Estimator의 AI 어시스턴트는 전부 휴대폰 안에서 돌아갑니다. 데이터가 기기 밖으로 나가는 일이 없죠. 사용자는 자연어로 작업을 만들고, 고객을 검색하고, 프로젝트를 관리할 수 있는데, 비행기 모드에서도 그대로 됩니다.

이 글에서는 llama.rn(llama.cpp의 React Native 바인딩)과 Qwen 1.7B로 이걸 어떻게 만들었는지 정리했습니다. 이 크기의 모델치고는 기대 이상으로 일을 잘합니다.

📝 업데이트: 지금은 처음의 Qwen3-1.7B 대신 Qwen3.5-2B-GGUF를 쓰고 있습니다. 또 /no_think 메시지 꼼수를 버리고 chat_template_kwargs 파라미터로 씽킹 모드를 제대로 끄는 방식으로 바꿨습니다:

chat_response = client.chat.completions.create(
    model="Qwen/Qwen3.5-27B",
    messages=messages,
    max_tokens=32768,
    temperature=0.7,
    top_p=0.8,
    presence_penalty=1.5,
    extra_body={
        "top_k": 20,
        "chat_template_kwargs": {"enable_thinking": False},
    },
)

프롬프트에 꼼수를 심는 것보다 훨씬 깔끔하게 제어됩니다.

클라우드 AI를 쓰기 싫었던 이유
#

모바일 앱의 AI 기능은 대개 공식이 같습니다. 사용자가 메시지를 입력하면, 앱이 OpenAI나 Anthropic이나 Google로 보내고, 응답이 돌아오고, 청구서가 쌓입니다.

커튼 시공 업체가 업무용으로 쓰는 앱에서 이 공식을 따르면 곤란한 점이 한둘이 아닙니다. 고객의 이름, 주소, 전화번호가 제3자에게 넘어가고, 프로젝트 내용과 견적, 메모가 남의 서버에 쌓입니다. GDPR이니 데이터 레지던시니 하는, 엮이고 싶지 않은 문제도 따라오고요. 게다가 API 비용은 사용자가 늘수록 커집니다.

대안은 모델을 사용자 휴대폰에서 직접 돌리는 것이고, 그래서 그렇게 했습니다.

이제야 가능해진 이유
#

1년 전이었으면 시도하지 않았을 텐데, 비슷한 시기에 세 가지가 바뀌었습니다. 우선 양자화 모델이 작아졌습니다. Q4_K_M 양자화를 거친 Qwen 1.7B는 1.1 GB로 웬만한 게임보다 작고, 한 번 받으면 앱 저장소에 그대로 있습니다. 다음으로 모바일 GPU가 빨라졌습니다. llama.cpp는 iOS에서 Metal, Android에서 Vulkan을 쓰는데, iPhone 14 Pro 기준 초당 15토큰 정도 나옵니다. 실시간 스트리밍에는 충분하죠. 마지막으로 소형 모델 자체가 쓸 만해졌습니다. Qwen 1.7B는 구조화된 지시를 따르고, JSON을 파싱하고, 여러 단계의 추론도 이어갑니다. 비즈니스 로직에 필요한 게 딱 이 정도입니다. 시를 쓰라는 게 아니니까요.

모델 고르기
#

Qwen3-1.7B로 정착하기 전에 소형 모델 네 개를 테스트했습니다.

TinyLlama 1.1B(637 MB)는 제일 빨랐지만 고객 ID를 지어내거나 필수 필드를 빼먹는 등 구조화된 출력이 도무지 안정되지 않았습니다. Phi-3-mini(1.8 GB)는 추론은 괜찮은데 말이 너무 많아요. 20단어면 될 답이 200단어짜리 작문으로 돌아옵니다. Gemma-2B(1.2 GB)는 분류에는 빠르고 정확했지만 함수 호출이 약해서, 제 도구 시스템에 필요한 <action> 태그를 안정적으로 내놓지 못했습니다. Qwen3-1.7B(1.1 GB)가 딱 적당했습니다. 구조화된 출력이 믿을 만하고, 지시를 정확히 따르고, <think> 태그로 사고 연쇄(chain-of-thought)까지 지원합니다.

Q4_K_M 양자화는 4비트 가중치에 k-means 클러스터링을 얹은 방식으로, 전체 정밀도 대비 크기는 75% 정도 줄고 품질 손실은 5% 안팎입니다.

llama.rn 설정
#

llama.rn은 llama.cpp를 React Native용으로 감싼 라이브러리인데, 설치 자체는 쉬운 축에 듭니다:

npm install llama.rn
cd ios && pod install

모델 본체는 첫 사용 시에 내려받습니다:

const MODEL_URL = "https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/main/Qwen3-1.7B-Q4_K_M.gguf";
const MODEL_PATH = FileSystem.documentDirectory + "llama-models/Qwen3-1.7B-Q4_K_M.gguf";

const downloadModel = async () => {
  const downloadResumable = FileSystem.createDownloadResumable(
    MODEL_URL,
    MODEL_PATH,
    {},
    (progress) => {
      const pct = progress.totalBytesWritten / progress.totalBytesExpectedToWrite;
      setDownloadProgress(pct);
    }
  );

  await downloadResumable.downloadAsync();
};

WiFi에서는 23분, LTE에서는 58분쯤 걸립니다. 다 받았으면 GPU 가속을 켜고 로드합니다:

const ctx = await initLlama({
  model: MODEL_PATH,
  n_ctx: 8192,      // 8K context window
  n_gpu_layers: 99, // Use GPU for all layers
});

n_gpu_layers: 99는 빼먹지 마세요. 연산을 CPU 대신 Metal/Vulkan으로 넘기는 것만으로 5배쯤 빨라집니다.

스트리밍 추론
#

10초 동안 스피너만 쳐다보고 싶은 사람은 없으니, 응답은 토큰 단위로 스트리밍합니다:

let fullResponse = "";

await llamaContext.completion(
  {
    messages: [
      { role: "system", content: systemPrompt },
      { role: "user", content: "Create a job for John Smith" }
    ],
    n_predict: 512,
    temperature: 0.7,
    top_p: 0.8,
  },
  (data) => {
    // Called for each token
    fullResponse += data.token;
    setStreamingText(fullResponse);
  }
);

요즘 폰이라면 첫 토큰까지 200ms 정도(프롬프트 처리 시간입니다), 이후로는 토큰당 50~80ms입니다. 네트워크를 거치는 API와 비교하면 체감상 즉각적이죠.

소형 모델도 해낼 수 있는 함수 호출
#

GPT-4식 함수 호출은 JSON 스키마에 기대는데, 소형 모델은 여기서 넘어집니다. JSON이 깨지고, 필수 필드가 빠지고요. 그래서 더 단순한 XML 기반 프로토콜을 씁니다:

const systemPrompt = `You are an AI assistant. When you need to take an action, output:
<action>{"type":"search_customers","query":"Smith"}</action>

Available actions:
- search_customers: {"type":"search_customers","query":"John"}
- create_job: {"type":"create_job","customer_id":5,"alias":"Living Room"}
- update_job: {"type":"update_job","job_id":"J-0042","status":"quoting"}

Rules:
1. ONE action tag per response, at the very end
2. When searching/creating → end with <action>
3. When just chatting → no action tag
`;

XML 태그를 고른 이유는 사실 소박합니다. <action></action>은 시작과 끝이 헷갈릴 여지가 없고, 파싱은 정규식 하나면 끝나고, 프롬프트 안의 예시가 그대로 포맷 문서 역할을 하고, 모델이 태그 앞뒤에 잡소리를 붙여도 잘 동작합니다.

파싱 코드는 보이는 그대로 단순합니다:

function parseAction(text: string): Action | null {
  const match = text.match(/<action>([\s\S]*?)<\/action>/);
  if (!match) return null;

  try {
    return JSON.parse(match[1].trim());
  } catch {
    return null;
  }
}

action이 나오면 실행하고, 결과를 대화에 다시 넣어줍니다:

const action = parseAction(modelResponse);
if (action) {
  const result = await executeAction(action);

  // Inject result as a system message
  conversationHistory.push({
    role: "user",
    content: `[TOOL_RESULT] ${result.message}\n\n→ NEXT: Tell the user what happened.`
  });

  // Continue generation
  await generateNextResponse();
}

이것만으로 다단계 워크플로우가 제대로 돌아갑니다:

사용자: “Create a job for Smith”

  1. 모델: <action>{"type":"search_customers","query":"Smith"}</action>
  2. 시스템: [TOOL_RESULT] Found: Jane Smith (id:42), Bob Smith (id:89)
  3. 모델: “Smith가 두 명 있네요. Jane과 Bob 중 누구인가요?”
  4. 사용자: “Jane”
  5. 모델: <action>{"type":"create_job","customer_id":42}</action>
  6. 시스템: [TOOL_RESULT] Job J-0073 created
  7. 모델: “완료했습니다! Jane Smith의 작업 J-0073을 생성했습니다.”

<think> 태그로 사고 연쇄 끌어내기
#

소형 모델은 추론을 소리 내서 하게 만들면 눈에 띄게 안정됩니다. Qwen에는 씽킹 모드가 기본으로 들어 있습니다:

const systemPrompt = `Before each response, wrap your reasoning in <think> tags:

<think>
INTENT: what does the user want?
HAVE: what data do I already have?
NEED: what's still missing?
DECISION: call tool | ask user | just respond
</think>

Then output your actual response.`;

모델 내부에서는 이런 식으로 굴러갑니다:

<think>
INTENT: create a job
HAVE: customer query "Smith"
NEED: exact customer_id
DECISION: search first
</think>
<action>{"type":"search_customers","query":"Smith"}</action>

<think> 태그는 UI에 내보내기 전에 걷어내지만, 개발 중에는 디버깅용으로 그대로 보이게 둡니다. 효과가 정말 큽니다. 모델이 액션을 확정하기 전에 스스로 논리를 정리하거든요.

긴 대화를 8K 안에 담기
#

8K 컨텍스트 윈도우는 도구 결과가 쌓이기 시작하면 생각보다 빨리 찹니다. 제 경우 30턴쯤에서 한계에 부딪혔습니다.

해결책은 대화 압축입니다. 모델에게 스스로를 요약하게 시킵니다:

const compactPrompt = `Summarize this conversation in under 200 words.
Include: user's goal, customers/jobs created (with IDs), and any unfinished tasks.

${conversationHistory.map(m => `${m.role}: ${m.content}`).join('\n\n')}`;

const summary = await chat([{ role: "user", content: compactPrompt }]);

// Replace history with summary
conversationHistory = [
  { role: "user", content: `[CONTEXT SUMMARY]\n${summary}\n[END SUMMARY]` }
];

메시지 50개짜리 대화가 150토큰 정도로 줄어들고, 모델은 만들어 둔 작업이나 고객 ID를 잊지 않고 이어갑니다.

세션 저장
#

채팅 세션은 Django 백엔드에 저장합니다:

interface SessionMessage {
  role: "user" | "assistant";
  content: string;  // Stripped for display
  raw: string;      // Full with <think> and <action> tags
}

await api.createAIChatSession({
  organization_id: orgId,
  title: firstUserMessage.slice(0, 60),
  messages: [
    { role: "user", content: userText, raw: userText },
    { role: "assistant", content: cleanedResponse, raw: fullModelOutput }
  ]
});

contentraw를 둘 다 들고 있으면 채팅 히스토리는 UI에서 깔끔하게 재생되고, 세션을 다시 열 때는 액션까지 포함한 전체 컨텍스트가 복원됩니다. 사용자는 ChatGPT처럼 히스토리 드롭다운에서 대화를 오갈 수 있습니다.

프로덕션 벤치마크
#

iPhone 14 Pro (A16 Bionic, 6GB RAM):

  • 모델 로드: 약 3초
  • 첫 번째 토큰: 180-220ms
  • 스트리밍: 14-16 tokens/초
  • 메모리: 약 1.8 GB

Samsung Galaxy S23 (Snapdragon 8 Gen 2, 8GB RAM):

  • 모델 로드: 약 4초
  • 첫 번째 토큰: 250-300ms
  • 스트리밍: 10-12 tokens/초
  • 메모리: 약 2.1 GB

iPhone 11 (A13, 4GB RAM):

  • 모델 로드: 약 6초
  • 첫 번째 토큰: 400-500ms
  • 스트리밍: 6-8 tokens/초
  • 메모리: 약 2.2 GB (저메모리에서 간헐적 크래시)

제 기준으로는 쾌적하게 쓰려면 RAM 6GB 이상. 4GB에서도 돌긴 하지만 앱이 백그라운드로 갈 때 모델을 내려야 할 겁니다.

메모리 관리
#

메모리를 움켜쥐고 있는 앱은 iOS가 가차 없이 죽이기 때문에, 포그라운드를 벗어나면 모델을 해제합니다:

useEffect(() => {
  const subscription = AppState.addEventListener("change", (nextAppState) => {
    if (nextAppState === "background") {
      llamaContext?.release(); // Free ~2 GB
    } else if (nextAppState === "active") {
      loadModel(); // Reload when foregrounded
    }
  });

  return () => subscription.remove();
}, []);

배터리 영향
#

온디바이스 추론이 전력 면에서 공짜는 아닙니다. 실측 결과는 이렇습니다:

사용 패턴추가 배터리 소모
가벼운 사용 (하루 5-10개 쿼리)하루 <1%
보통 사용 (하루 20-30개 쿼리)하루 약 3-4%
많은 사용 (하루 50개+ 쿼리)하루 약 6-8%

GPU 추론이 CPU보다 전력은 더 먹지만 훨씬 빨리 끝납니다. 사용자들은 확실히 즉각적인 응답을 선호해서, GPU를 그대로 쓰고 있습니다.

두 달 운영, 베타 사용자 약 200명
#

쿼리 분포는 조금 의외였습니다:

  1. “Create a job for [customer name]” - 쿼리의 68%
  2. “Find jobs for [customer]” - 15%
  3. “Update job [ID] to [status]” - 9%
  4. 앱에 대한 일반적인 질문 - 8%

쿼리의 91%는 첫 시도에 성공합니다. 실패는 사용자가 이름 철자를 틀려서 고객을 못 찾는 경우 4%, 아주 긴 대화에서 컨텍스트가 넘치는 경우 3%, 모델이 고객 ID를 지어내는 경우 2%로 나뉩니다.

마지막 ID 날조 문제는 예전엔 훨씬 심했는데, 도구 결과를 산문이 아니라 customer_id=42 같은 명시적 필드로 돌려주게 바꾸자 거의 사라졌습니다. 소형 모델에게는 이 정도까지 떠먹여 주는 구조가 필요합니다.

비용 계산
#

온디바이스 방식은 인프라 비용 $0/월. 사용자당 비용도 최초 1.1 GB 다운로드를 빼면 $0이고, 사용자가 아무리 늘어도 그대로입니다.

클라우드 방식을 대충 계산해 보면, 평균 대화가 약 2,000토큰이고 200명이 월 30회 대화하면 6,000회, 즉 월 1,200만 토큰입니다. GPT-3.5면 월 $18, GPT-4면 월 $360, Claude면 월 $180 정도죠. 이 규모에선 망할 금액은 아니지만, 온디바이스 방식은 첫날부터 본전을 뽑았고 사용자가 10,000명이 되어도 여전히 $0입니다.

알아두어야 할 한계
#

모델 능력. Qwen 1.7B는 구조화된 작업엔 강하지만 여러 단계를 거치는 복잡한 추론, 사실 지식(검색 엔진이 아닙니다), 창작, 미묘한 뉘앙스 파악은 못합니다. 소형 모델이 잘하는 쪽으로 기능을 설계하면 문제 될 게 없습니다.

기기 요구사항. 최소 사양은 RAM 3 GB에 여유 저장공간 2 GB. 현실적으로는 RAM 6 GB, 64비트 프로세서, GPU 지원이 필요합니다. iPhone 8이나 Galaxy S9 같은 구형 기기는 버거우니, 기능 감지를 해서 자연스럽게 폴백시키세요.

모델 업데이트. 모델을 갈아끼우면 사용자는 1.1 GB를 다시 받아야 합니다. 저는 저장 경로에 버전을 넣어 뒀습니다:

const MODEL_PATH = `${FileSystem.documentDirectory}llama-models/v2/Qwen3-1.7B-Q4_K_M.gguf`;

새 모델을 낼 때 경로만 올리면 됩니다. 옛 모델은 앱 삭제 시 자동으로 지워집니다.

iOS와 Android의 차이
#

Metal을 쓰는 iOS가 Android보다 20%쯤 빠르고, 메모리 관리도 낫고, 모델이 기본적으로 iCloud에 백업됩니다(사용자 백업 용량을 잡아먹기 싫다면 NSURLIsExcludedFromBackupKey로 제외하세요). Vulkan/OpenCL의 Android는 기기별 편차가 훨씬 크고, 구형 GPU 중엔 Vulkan을 아예 지원하지 않아 CPU로 떨어지는 것도 있고, 저장소 자동 백업도 없습니다. 양쪽 다 테스트하세요. 좋은 Android 기기와 나쁜 Android 기기의 격차가 플랫폼 간 격차보다 클 정도입니다.

디버깅 팁
#

1. 상세 로깅 켜기

const ctx = await initLlama({
  model: MODEL_PATH,
  n_ctx: 8192,
  n_gpu_layers: 99,
  verbose: true, // Logs every token + timings
});

2. 토큰 수 추적

const tokens = fullResponse.split(/\s+/).length * 1.3; // Rough estimate
console.log(`Generated ${tokens} tokens in ${duration}ms`);

3. 메모리 모니터링

import { MemoryInfo } from 'react-native-device-info';

const memoryUsage = await MemoryInfo.getUsedMemory();
console.log(`Memory: ${(memoryUsage / 1024 / 1024).toFixed(0)} MB`);

4. 오프라인 테스트

비행기 모드를 켜고 앱을 실제로 써 보세요. 모델은 캐시에서 로드되고, 추론은 끝까지 돌고, 실패하는 건 백엔드 저장뿐이어야 합니다. 그것도 조용히 실패해야 하고요.

앞으로 해보고 싶은 것
#

llama.cpp는 이미 비전 모델(LLaVA, Qwen2-VL)을 지원하는데, “이 커튼 원단 사진을 작업에 추가해 줘” 같은 사용법은 이 앱에 딱 맞습니다. 과거 작업 설명과 고객 대화 데이터로 LoRA 어댑터를 파인튜닝하는 것도 시도해 보고 싶고, 도메인 쿼리 기준으로 정확도가 20~30% 오르지 않을까 짐작하고 있습니다. 그 너머로는 Whisper.cpp를 붙인 완전 오프라인 음성 어시스턴트, 그리고 원본 데이터를 기기 밖으로 내보내지 않고 사용자 전체에 걸쳐 모델을 개선하는 연합 학습도 그려 보고 있습니다.

만들 만한가?
#

민감한 데이터를 다루는 비즈니스 앱, 오프라인 우선 워크플로우, 분류나 데이터 입력 같은 구조화된 작업이 중심인 앱이라면, 그리고 규모가 커졌을 때의 API 비용이 걱정된다면 답은 ‘예’입니다. 지금 당장 됩니다.

자유로운 대화가 필요하면 Claude나 GPT-4를 쓰세요. 지식으로 승부하는 작업(소형 모델은 정말 아는 게 없습니다), 저사양 기기 지원이 필수인 경우, 도메인 지식이 모델 업데이트보다 빨리 바뀌는 경우도 마찬가지입니다.

구현에 든 시간은 12시간 남짓. 월 운영비 $0에, 비행기 안에서도 돌아가고, 고객 데이터는 고객 손을 떠나지 않습니다. 솔직히 흠잡을 데가 없네요.


질문이나 피드백은 @jaredlynskey로 주세요. llama.rn 라이브러리는 github.com/mybigday/llama.rn에 있습니다.