지난 글에서 FastAPI 서버에 스트리밍과 레이트 리미팅을 붙이고, nginx 뒤에 인스턴스를 여러 개 세우고, 큐 깊이와 레이턴시를 지켜보며 LLM을 서비스로 내보내는 과정을 봤다. 그 구조는 전부 요청이 서버까지 왕복한다는 전제 위에 서 있다. GPU도, 큐도, 요청당 비용도 그 전제에서 나온다. 전제 자체를 걷어내는 선택지가 하나 있다. 서버로 보내지 않고 사용자의 기기에서 그대로 돌리는 것이다.
이 선택지가 매력적인 이유는 분명하다. 왕복 지연이 사라지고, 데이터가 기기 밖으로 안 나가고, 비행기 안에서도 되고, 요청당 비용이 0이다. 문제는 그 대가가 눈에 잘 안 보이는 형태로 온다는 것이다. 서버에서는 신경 쓸 필요가 없던 것 — 메모리 대역폭, 열, 배터리, 앱 설치 크기 — 이 갑자기 설계의 중심이 된다.
속도를 정하는 것은 연산기가 아니다
기기에서 모델이 느린 이유를 "칩이 약해서"라고 생각하기 쉽지만, 토큰을 하나씩 뱉는 구간에서 병목은 거의 항상 메모리 대역폭이다. 이유는 계산의 모양에 있다. 토큰 하나를 만들려면 모델의 모든 가중치를 한 번씩 읽어야 하는데, 그렇게 읽어 온 값 하나당 하는 계산은 곱셈 한 번과 덧셈 한 번뿐이다. 연산기는 대부분의 시간을 기다리며 보낸다.
그래서 토큰 속도의 상한이 나눗셈 한 번으로 나온다.
는 파라미터 수, 는 파라미터당 바이트, 는 메모리 대역폭이다. 30억 파라미터를 4비트(0.5바이트)로 누르면 1.5GB, 요즘 휴대폰의 대역폭이 5070GB/s쯤 되니 토큰당 2030ms, 초당 30~50토큰이 계산상의 상한이다.
실제로는 이 값의 50~70% 정도가 나온다. 읽기가 완벽하게 이어지지 않고, 어텐션 계산이 따로 붙고, 다른 앱이 같은 메모리 버스를 쓰기 때문이다. 그래도 이 나눗셈은 쓸모가 있다 — 어떤 모델이 이 기기에서 쓸 만한 속도가 나올지를 벤치마크 전에 판단할 수 있다. 계산 결과가 초당 5토큰이면 최적화로 뒤집을 수 있는 자리가 아니라 모델을 바꿔야 하는 자리다.
두 국면은 병목이 다르다
한 번의 응답은 성격이 전혀 다른 두 구간으로 나뉜다.
| 프리필 | 디코드 | |
|---|---|---|
| 하는 일 | 입력 전체를 한 번에 통과시킨다 | 토큰을 하나씩 만든다 |
| 병목 | 연산 성능 | 메모리 대역폭 |
| 체감 | 첫 글자가 나오기까지의 침묵 | 글자가 흘러나오는 속도 |
| 줄이는 법 | 입력을 짧게, NPU·GPU 활용 | 모델을 작게, 더 세게 양자화 |
이 구분이 중요한 이유는 사용자 불만의 원인이 대개 프리필 쪽이기 때문이다. 초당 30토큰으로 잘 흐르는데도 "느리다"는 말이 나온다면 십중팔구 첫 토큰까지의 2초가 문제다. 긴 문서를 통째로 넣는 설계라면 기기에서는 특히 아프다 — 프리필 비용이 입력 길이에 비례해 정직하게 늘어난다.
메모리 예산은 남는 자리부터 뺀다
기기의 램이 8GB라고 해서 8GB를 쓸 수 있는 것이 아니다. OS와 다른 앱이 이미 쓰고 있고, 우리 앱 자체도 자리를 차지한다.
여기서 자주 빠뜨리는 것이 KV 캐시다. 가중치와 달리 대화가 길어질수록 자라고, 크기는 이렇게 나온다.
층이 28개, KV 헤드가 8개, 헤드 차원이 128, 토큰 4,096개를 fp16으로 들고 있으면 약 0.44GB다. 문맥을 16k로 열면 1.8GB가 되어 모델 자체보다 커진다. 기기에서 문맥 길이는 기능이 아니라 메모리 예산을 쓰는 지출 항목이고, 대화가 길어질수록 앱이 종료될 확률이 올라간다. 캐시를 8비트나 4비트로 누르는 것이 기기 쪽에서 특히 흔한 선택인 이유다.
그리고 서버와 달리 여유분을 넉넉히 잡아야 한다. 램이 모자라면 서버는 요청을 큐에 세우지만 휴대폰의 OS는 앱을 조용히 죽인다. 사용자에게는 "답을 만들다가 앱이 꺼졌다"로 보인다.
양자화는 선택이 아니라 전제다
기기에서 fp16 원본을 그대로 돌리는 경우는 거의 없다. 4비트로 누르면 자리도 4분의 1, 읽어야 할 바이트도 4분의 1이라 속도까지 함께 4배가 된다. 서버에서는 양자화가 비용 최적화지만 기기에서는 돌아가느냐 마느냐를 가르는 조건이다.
실무에서 자주 쓰는 구간은 이렇다. 4비트 중에서도 층별로 비트를 달리 주는 방식이 품질을 가장 잘 지키고, 3비트 아래로 내려가면 출력이 눈에 띄게 무너지기 시작한다. 2비트는 아직 실험 영역이다. 그리고 양자화 결과는 모델마다 다르게 나오므로, 눌러 놓고 평가 묶음으로 다시 재는 단계를 건너뛰면 안 된다.
런타임은 목표 기기가 고른다
기기 쪽 실행 환경은 서버처럼 하나로 수렴하지 않았다. 지금 실무에서 마주치는 갈래는 대략 넷이다.
| 갈래 | 성격 | 잘 맞는 자리 |
|---|---|---|
| llama.cpp 계열 | GGUF 포맷, CPU·GPU 모두, 이식성이 가장 넓다 | 여러 플랫폼을 한 코드로 |
| 애플 계열 | Core ML과 MLX, 통합 메모리와 NPU를 제대로 쓴다 | 아이폰·맥 전용 앱 |
| 모바일 런타임 | ExecuTorch·ONNX Runtime, 그래프를 미리 굳혀 배포 | 안드로이드 중심, 앱 크기 관리 |
| 브라우저 | WebGPU·wasm, 설치 없이 즉시 | 데모, 짧은 작업, 배포 장벽 회피 |
고르는 기준은 성능 수치보다 어느 기기까지 지원할 것인가다. 벤더의 NPU를 쓰면 빠르고 배터리도 아끼지만 그 벤더의 칩에서만 돌고, 이식성을 택하면 어디서나 돌지만 CPU·GPU로 떨어져 열이 더 난다. 대개는 둘을 함께 넣고 기기에 따라 고르게 만들어야 하며, 이 분기 코드가 온디바이스 프로젝트에서 예상보다 큰 몫을 차지한다.
배포에서 실제로 부딪히는 것들
모델이 도는 것과 앱을 배포하는 것은 다른 문제다. 셋을 미리 정해 두면 나중에 덜 아프다.
모델을 앱에 넣을 것인가, 받아 올 것인가. 넣으면 설치 크기가 1~2GB 늘어 설치 이탈이 생기고, 받아 오면 첫 실행에서 다운로드를 기다리게 된다. 대체로 받아 오는 쪽이 낫지만, 그러려면 실패·재시도·저장 공간 부족을 다루는 코드가 필요하고 첫 사용까지의 경험을 따로 설계해야 한다.
첫 로드 시간. 1.5GB짜리 파일을 메모리에 올리는 데 저장장치 속도에 따라 1~5초가 든다. 파일을 메모리에 통째로 복사하지 않고 매핑해서 쓰면 체감이 크게 줄고, 앱을 켤 때 미리 데워 두면 사용자가 기다리는 자리에서는 안 보이게 만들 수 있다.
버전 관리. 서버 모델은 교체하면 끝이지만 기기 모델은 사용자가 업데이트할 때까지 남는다. 몇 달 전 버전이 여전히 돌고 있다는 전제로 프롬프트와 후처리를 짜야 하고, 문제가 생겼을 때 서버로 되돌릴 스위치를 처음부터 넣어 두는 편이 안전하다.
전부 기기에 둘 필요는 없다
온디바이스를 도입할 때 가장 흔한 실수는 전부를 옮기려는 것이다. 실제로 잘 도는 구성은 대개 섞여 있다. 분류·추출·자동완성·요약처럼 짧고 좁은 일은 기기에서 즉시 처리하고, 긴 추론이나 복잡한 계획은 서버로 보낸다. 사용자는 어느 쪽이 처리했는지 모르고, 다만 대부분의 요청이 즉각 반응한다고 느낀다.
기준은 하나로 정리된다. 틀렸을 때 사용자가 바로 알아채고 넘어갈 수 있는 일은 기기에, 틀리면 조용히 손해가 쌓이는 일은 서버에 둔다. 이 경계를 먼저 긋고 나면 모델 크기와 메모리 예산은 그 결정을 따라오는 숫자가 된다.
읽어주셔서 감사합니다. 😊

