> ## Content Index
> Fetch the complete content index at: https://www.popit.kr/llms.txt
> Use this file to discover other available public pages before exploring further.

# AI로 장소 검색 서비스 만드는 중입니다 (기술 1) — 로컬 LLM의 벽
- URL: https://www.popit.kr/ai-place-search-tech-1-local-llm-wall/
- Published: 2026-07-27T12:30:09.000Z
- Updated: 2026-08-17T10:56:56.000Z
- Author: 김형준
- Tags: #소프트웨어, ai, 모델비교

![t1-rerank-ndcg](https://www.popit.kr/content/images/wp/2026/07/t1-rerank-ndcg-600x308.png)

> 연재 본편이 "삽질기"라면, 이제부터 이어지는 기술편 10편은 그 뒤에서 돌린 **A/B와 평가의 실제 숫자**입니다. 본편 4편에서 "메모리가 터졌다"고만 하고 넘어갔는데, 이번 글은 **그 장비의 정확한 스펙과, 로컬 모델이 정확히 어디서·왜 무너졌는지**를 숫자로 남깁니다.  
>  
>  
>  
> 그리고 이 글에는 예상 못 한 결말이 하나 있습니다. **가장 크게 기대했던 기능이, 안 쓰는 것보다 나빴습니다.**  
>  
> 모든 수치는 유추카 자체 실측(2026-06\~07)입니다. 지연·메모리는 아래 장비 기준의 상대값이라 절대 벤치마크가 아니라 **"이 조건에서 무엇이 되고 안 됐나"** 로 읽어 주세요.

## "내 GPU"의 정체

로컬 LLM을 돌린 장비는 이겁니다.

- **MacBook Pro, Apple M3 Max, 통합 메모리 36GB, macOS**
- 런타임: **Ollama 0.30.8** (백그라운드 상주, 기본 `127.0.0.1:11434`)
- 구성: LLM을 돌리는 맥북과 MongoDB가 도는 맥북이 **서로 다른 기계**입니다. 다른 장비에서 이 M3 Max의 Ollama를 쓸 때는 랜 IP로 직접 붙이거나 터널로 노출했습니다.

"36GB면 넉넉하지 않나?" 싶은데, 여기서 중요한 단어가 **통합 메모리**입니다.

> **통합 메모리(unified memory)**: 게이밍 PC의 NVIDIA 그래픽카드는 GPU 전용 메모리(VRAM)를 따로 갖습니다. VRAM이 24GB면 그 24GB는 오롯이 모델 것이고, 시스템 램은 별개죠. 반면 Apple Silicon은 **CPU·GPU·시스템이 같은 메모리를 나눠 씁니다.** 데이터 복사가 없어서 빠르다는 장점이 있지만, **"내 몫"이 고정돼 있지 않다**는 뜻이기도 합니다.

즉 36GB는 모델 몫이 아니라 **전부의 몫**입니다. 여기에 모델 가중치, 추론 중 만들어지는 캐시, 검색 인덱스, 웹서버, 브라우저까지 얹히면 실제로 모델이 쓸 수 있는 건 훨씬 적어집니다. 본편에서 겪은 메모리 벽의 진짜 원인이 이 공유 구조였습니다.

## 모델 하나가 방을 다 차지한다

모델별로 얼마나 먹고 얼마나 빠른지 재봤습니다.

| 로컬 모델                           | 용도          | 메모리     | 속도(실측)                            |
| ------------------------------- | ----------- | ------- | --------------------------------- |
| qwen3:30b-instruct (MoE, 활성 3B) | 리랭크·추출      | \~18GB  | 65 tok/s·추출 1216초/건               |
| qwen3:14b (dense)               | 추출·검색 이유    | \~9.3GB | thinking 켜면 **58초** · 이유 생성 2.26초 |
| qwen3:8b (dense)                | 리랭크 후보      | \~6GB   | 리랭크 10.6초                         |
| qwen3-embedding:4b              | 임베딩(2560차원) | \~2.5GB | 상주                                |

30B 모델의 "MoE, 활성 3B"라는 표기를 짚어둘 만합니다.

> **MoE(Mixture of Experts)**: 모델 안에 여러 전문가 모듈을 두고, **입력마다 그중 일부만 켜서** 계산하는 구조. 30B 모델인데 한 번에 3B만 켜니 **계산이 빠릅니다**(그래서 65 tok/s가 나옵니다). 그런데 **어느 전문가가 켜질지 미리 알 수 없으니 30B 가중치를 전부 메모리에 올려둬야** 합니다.

**"빠른데 무겁다."** 이게 로컬 대형 모델의 딜레마였습니다. 속도는 3B급인데 메모리는 30B급을 요구하니, 메모리가 병목인 환경에서는 최악의 조합입니다.

### 그리고 서비스는 모델 하나로 안 돌아간다

문제는 이 서비스가 **여러 모델을 동시에** 필요로 한다는 점이었습니다.

- 문서와 검색어를 좌표로 바꾸는 **임베딩 모델**은 검색할 때마다 필요하니 **항상 상주**해야 합니다.
- 영상에서 장소를 뽑는 **추출**엔 큰 모델이 필요합니다.
- 검색 결과에 "왜 이 장소인지" 한 줄을 붙이는 데 또 다른 모델이 붙습니다.

계산해 보면:

> `30b(18GB) + 14b(9.3GB) + 임베딩(2.5GB)` ≈ **30GB**

36GB에서 30GB를 모델이 먹으면 검색 인덱스와 웹서버가 들어갈 자리가 없습니다. 실제로 셋을 동시에 올리자 **OOM(메모리 부족)** 이 났습니다.

> **OOM이 나면 그냥 느려지는 게 아닙니다.** macOS는 메모리가 부족하면 디스크로 스왑을 시작하는데, 모델 가중치가 스왑에 들어가면 **토큰 하나 생성할 때마다 디스크를 읽습니다.** 체감상 "느려짐"이 아니라 "멈춤"에 가깝습니다.

그래서 원칙을 세웠습니다. **채팅 모델은 하나만 상주시킨다.**

```
14b 단일(9.3GB) + 임베딩 4b(2.5GB) = 11.8GB
```

이 정도면 스왑 없이 돕니다. 대신 큰 모델(30b)을 포기해야 했고, 그럼 품질이 떨어질 거라고 생각했습니다.

## 반전 ① — 작은 모델이 더 나았다

30b를 14b로 내리기 전에, 정말 품질이 떨어지는지 재봤습니다. 결과가 예상 밖이었습니다.

**속성 추출**(리뷰에서 특징을 뽑아 정규 목록에 매핑, 기술 6편) 비교:

|             | qwen3:14b | qwen3:30b |
| ----------- | --------- | --------- |
| 매핑률 (한식 표본) | **78%**   | 40%       |

**작은 모델이 두 배 가까이 나았습니다.**

**영상 장소 추출**도 봤습니다. 30b가 후보를 더 많이 뽑긴 했는데, 더 뽑은 것들을 열어보니 이런 것들이었습니다.

> "음악분수", "라면조리실"

**장소가 아니라 시설**입니다. 검증 단계에서 어차피 전부 탈락할 것들이었죠. 반대로 14b가 놓친 진짜 장소는 **영상당 약 0.2곳**으로 드물었습니다.

**즉 30b는 더 많이 뽑았지만 그 대부분이 노이즈였습니다.** "더 큰 모델이 더 많이 찾아낸다"는 사실이었는데, **"더 많이 찾아낸다"와 "더 잘한다"가 달랐습니다.**

메모리 제약 때문에 어쩔 수 없이 내린 결정이 품질에도 나은 선택이었던 셈입니다. 재보지 않았으면 "30b를 못 써서 품질을 희생했다"고 기록했을 겁니다.

## 반전 ② — '생각하기'를 끄려다 통신 규약을 갈아엎다

14b로 통일하려면 먼저 풀어야 할 게 있었습니다. **thinking 모드**입니다.

> **thinking(reasoning) 모드**: 최근 모델들은 답하기 전에 혼자 추론 과정을 길게 써 내려간 뒤 최종 답을 냅니다. 수학이나 복잡한 추론엔 큰 도움이 되지만, **출력 토큰이 몇 배로 늘어납니다.** 그리고 기술 9편에서 보듯 LLM 지연은 출력이 지배합니다.

qwen3 계열은 이 모드가 기본이었습니다. `qwen3:14b`에 간단한 판정 하나를 시키면 **58초**가 걸렸습니다. "이 집이 한옥인가 아닌가" 같은 단순 분류에 58초는 말이 안 됩니다. **8\~10배 느림만 유발**하고 정확도는 그대로였고요.

당연히 껐습니다. 그런데 여기서 이상한 일이 생겼습니다.

당시 우리는 Ollama를 **OpenAI/Anthropic 호환 경로**로 부르고 있었습니다. 표준 인터페이스를 쓰면 나중에 클라우드로 갈아타기 쉬울 테니까요. 그 경로로 thinking을 끄는 신호를 보냈더니 — **모델이 빈 답을 돌려줬습니다.**

프롬프트 문제인 줄 알고 한참 헤맸는데, 원인은 **호환 계층**이었습니다. 호환 API는 여러 모델의 공통분모만 다루기 때문에, **그 런타임에만 있는 옵션은 전달할 방법이 없거나 이상하게 처리**됩니다. thinking 스위치가 딱 그런 옵션이었습니다.

결국 통로 자체를 바꿨습니다. **Ollama 네이티브 API(`/api/chat` \+ `think: false`)** 로요. 그제야 "생각하지 말고 바로 답해"가 제대로 전달됐습니다.

> **교훈**: 호환 계층은 **이식성을 사는 대신 표현력을 팝니다.** 벤더 고유 기능이 필요한 순간, 호환 API는 그걸 전달할 방법이 없습니다. 그리고 그런 순간은 대개 성능이 걸린 지점에서 옵니다.

이 전환이 나중에 뜻밖의 자산이 됩니다. **"프로토콜마다 다르게 말하되 프롬프트와 파싱은 공유하는"** 클라이언트 구조가 여기서 만들어졌고, 그 덕에 클라우드로 갈아탈 때 **엔드포인트만 바꾸면** 됐습니다(기술 2편).

전환하면서 곁가지도 몇 개 고쳤습니다. `temperature: 0`(구조화 출력 안정), 응답이 길이 제한에 잘렸으면 성공으로 처리하지 않기, 그리고 **호출자별 타임아웃이 실제로 반영되게** 하기 — 원래는 어떤 값을 넣든 무시되고 120초로 고정되고 있었습니다.

## 결정적 반전 ③ — 로컬 리랭커가 cosine을 못 이겼다

메모리와 속도는 그렇다 쳐도, 가장 뼈아팠던 건 **품질**이었습니다.

가장 크게 기대한 기능은 **리랭크**였습니다. "임베딩 유사도로 뽑은 순위를, 문장을 읽을 줄 아는 LLM이 다시 매기면 당연히 좋아지겠지."

측정 설계는 이랬습니다. 후보를 뽑는 단계까지는 **완전히 동일**하게 두고, **리랭커만 교체**했습니다. 그래야 차이가 순전히 리랭커 때문이라고 말할 수 있으니까요.

> **nDCG@10**: "상위 10개에 정답을 얼마나 위쪽에 잘 배치했나"를 0\~1로 재는 표준 지표. 단순 정답률과 달리 **순서를 봅니다** — 같은 정답이라도 1등에 있으면 높은 점수, 9등에 있으면 낮은 점수를 줍니다. 1에 가까울수록 좋습니다.

같은 정답셋(119쿼리) 매크로 평균 결과입니다.

| 리랭커                      | nDCG@10   | vs cosine | 지연 / 메모리     | 온라인 가능?           |
| ------------------------ | --------- | --------- | ------------ | ----------------- |
| qwen3:30b-instruct       | **0.594** | +0.042    | \~20초 / 18GB | ❌ 너무 무거움          |
| haiku (클라우드)             | 0.569     | +0.017    | 3.2초         | △                 |
| **none (cosine 순위 그대로)** | **0.552** | —         | **0초**       | ✅                 |
| qwen3:14b                | 0.541     | −0.011    | 19.6초        | ❌ (파싱도 44/119 실패) |
| qwen3:8b / gemma3:12b    | 0.540     | −0.012    | 10.6초        | ❌                 |
| exaone3.5:7.8b           | 0.462     | −0.090    | —            | ❌                 |

**표를 읽는 법**: 굵게 표시한 `none`이 기준선입니다. **아무것도 안 하고 코사인 유사도 순서를 그대로 쓴 것**이죠. 그 아래 줄들은 전부 **LLM으로 재정렬한 결과가 아무것도 안 한 것보다 나빴다**는 뜻입니다.

온라인에서 쓸 수 있는 로컬 모델 중 **코사인을 이긴 게 하나도 없었습니다.** 유일한 승자인 30B는 20초·18GB라 사용자 검색에 못 씁니다.

덤으로 14b는 재정렬을 시키면 **119개 중 44개에서 출력 파싱이 깨졌습니다.** thinking을 껐는데도 형식을 이탈하는 경우가 남아 있었습니다. 순위표를 통째로 재배열해서 내놓는 일 자체가 이 크기 모델에겐 버거웠던 것 같습니다.

### 혹시 우리 자가 편향된 건 아닐까

이 결과가 워낙 예상 밖이라 자를 의심했습니다. 판정자를 바꾼 정답셋으로 다시 쟀지만 순서는 그대로였습니다(none 0.500 · 30b 0.535 · haiku 0.509).

그런데 더 정직하게 말하면, **이 측정에는 코사인에게 유리한 구조적 편향이 있었습니다.**

정답셋의 정답들이 **코사인 유사도 상위에서 뽑혀 라벨된 것**이었거든요. 즉 정답 자체가 "코사인이 잘 찾는 것들"로 기울어 있었습니다. **리랭크의 이득은 오히려 과소평가됐을 가능성이 큽니다.**

그런데도 30b는 그 편향을 뚫고 이겼습니다. 그래서 결론을 이렇게 정리했습니다.

- **리랭크에 실제 신호가 있는 건 30B급부터다.** 그 아래는 편향을 감안해도 이득이 안 보인다.
- **"지연이 느리다"는 이 장비 사정이라 더 좋은 GPU면 달라질 수 있지만, "품질이 코사인만 못하다"는 하드웨어와 무관하다.**

**측정의 한계를 같이 적어두면, 나중에 조건이 바뀌었을 때 재도전할지를 판단할 수 있습니다.** "안 된다"만 적어두면 다시는 안 하게 되고, "왜 안 됐고 무엇이 바뀌면 될지"를 적어두면 재평가가 가능합니다. 실제로 나중에 클라우드 모델로는 리랭크를 다시 도입했습니다 — 다만 역할을 바꿔서요.

## 그럼 로컬 모델은 어디에 남겼나

재정렬엔 못 써도, **검색 이유 한 줄 생성**은 성격이 다릅니다. 순위표를 통째로 다루는 게 아니라 **장소 하나를 읽고 한 문장 쓰는** 일이라, 입력도 작고 출력도 짧습니다. 여기는 로컬도 될 것 같았습니다.

두 모델을 A/B했습니다. 프롬프트에는 "40자 이내", "최고!·필수! 같은 홍보성 표현 금지"라는 제약을 넣었습니다.

| 모델             | 지연        | 평균 길이       | 홍보어 위반     | 판정           |
| -------------- | --------- | ----------- | ---------- | ------------ |
| **qwen3:14b**  | 2.26초     | **43자**     | **0 / 15** | ✅ 규율 준수      |
| exaone3.5:7.8b | **1.09초** | 73자 (목표 초과) | **8 / 15** | ❌ 빠르나 지시 미준수 |

**exaone이 2배 빨랐습니다.** 그리고 2배 빠른 걸 못 썼습니다.

길이 제한을 어기고(43자 목표에 평균 73자), 금지한 홍보어를 **15개 중 8개에서** 썼기 때문입니다. 프롬프트를 강화해도 안 지켜졌습니다. 7.8B 정도 크기에서는 **"지시를 따르는 능력" 자체가 부족**했습니다.

> **작은 모델의 진짜 약점은 지식이 아니라 순종입니다.** "40자로 써라", "이 단어는 쓰지 마라", "JSON만 출력해라" 같은 제약을 못 지킵니다. 그리고 파이프라인에서는 **형식을 안 지키는 출력이 틀린 출력보다 더 곤란합니다** — 파싱이 깨지면 그 건은 통째로 날아가니까요.

### 그런데 2.26초도 못 썼다

qwen3:14b는 규율을 지켰지만 다른 문제가 있었습니다. **모델이 하나뿐이라 요청이 직렬로 처리**됩니다. 화면에 카드 5개면 5 × 2.26초 = **11초**입니다.

실제로 **2건만 붙여도 전역 10초 타임아웃에 걸려** 취소됐습니다. 그래서 감싸는 구조를 만들었습니다.

- 검색 직후 **목록이 아직 접혀 있는 동안** 상위 5건을 백그라운드로 미리 생성
- 한 번에 **3건씩** 나눠 호출 (한 호출이 타임아웃 안에 끝나도록)
- 이 요청만 **별도 타임아웃 30초**
- 그리고 이유가 안 와도 **AI 리뷰 요약이 먼저 떠 있어서** 화면이 비지 않음

**"로컬로 충분히 좋게"는 됐는데 "충분히 빠르게"가 안 됐고, 그 간극을 UX 설계로 메운** 겁니다.

## 임베딩만은 로컬이 나쁘지 않았다

의외로 **임베딩만은 로컬이 클라우드에 안 밀렸습니다.** 로컬 `qwen3-embedding:4b`(2560차원)와 클라우드 `text-embedding-v4`(2048차원)의 정밀도 비교입니다.

| 검색 유형  | v4 (클라우드) | 4b (로컬)  |
| ------ | --------- | -------- |
| 의도     | 0.84      | **0.88** |
| 지역     | 0.70      | **0.81** |
| 페르소나   | 0.81      | **0.85** |
| 랜드마크   | **0.65**  | 0.59     |
| **전체** | 0.75      | **0.78** |

**오히려 로컬이 약간 높았습니다.** 생각해 보면 당연한데, 임베딩은 생성이 아니라 **표현**입니다. 문장을 좌표로 바꾸는 일에는 지시를 따를 필요도, 형식을 지킬 필요도 없습니다. 앞에서 작은 모델이 무너졌던 축들이 **여기선 아예 요구되지 않습니다.**

다만 클라우드 쪽에서 함정을 하나 발견했습니다. **호환 엔드포인트로 임베딩을 부르면 "이건 검색어인가 문서인가"를 알려주는 옵션이 안 실립니다.** 이 구분이 빠지자 의도 검색 정밀도가 0.84에서 **0.32로 붕괴**했습니다. 앞서 thinking 스위치와 정확히 같은 종류의 사고입니다 — **호환 계층이 벤더 고유 옵션을 삼킨** 거죠.

그래서 임베딩은 **네이티브 API + 2048차원 + 검색어와 문서에 같은 모델**을 반드시 지킵니다. (최종적으로는 운영 편의를 위해 클라우드로 통일했지만, **"로컬이 못해서"는 아니었습니다.**)

## 이 글의 결론

- 로컬 LLM의 벽은 "느리다"가 전부가 아닙니다. **통합 메모리를 나눠 쓰는 구조에서 여러 모델을 동시에 못 올리는 것**(필요 30GB vs 가용 36GB)이 더 근본적인 제약이었습니다.
- **더 큰 모델이 더 나은 건 아니었습니다.** 30b는 14b보다 후보를 더 많이 뽑았지만 대부분 노이즈였고, 속성 매핑은 오히려 14b가 두 배 가까이 정확했습니다.
- **호환 계층은 이식성을 사고 표현력을 팝니다.** thinking 스위치도, 임베딩의 질의/문서 구분도 호환 API에서 사라졌습니다. 성능이 걸린 옵션일수록 네이티브 경로가 필요합니다.
- 가장 기대했던 **리랭크는, 온라인 가능한 로컬 모델 중 코사인을 이긴 게 하나도 없었습니다.** 측정이 없었으면 무거운 리랭커를 넣고 "품질을 개선했다"고 믿었을 겁니다.
- 다만 **그 측정의 한계도 같이 기록**했습니다(정답셋이 코사인에 유리했음). 그래야 조건이 바뀌었을 때 재도전할지 판단할 수 있습니다.
- **작은 모델의 약점은 지식이 아니라 순종**이었습니다. 2배 빠른 모델을 형식 위반 때문에 못 썼습니다.
- 그래서 리랭커의 역할을 **"재정렬"이 아니라 "부적합 컷 + 이유 생성"** 으로 재정의하고, 그 일에 맞는 **싸고 빠른 클라우드 모델**로 방향을 틀었습니다.

다음 글(기술 2)에서는 그 "기능마다 다른 클라우드 모델"의 **비용·지연·품질을 표로** 보여드립니다 — 검색 한 번에 얼마가 드는지, 왜 검색어 파싱만은 비싼 모델을 남겼는지, 그리고 영상 추출이 어떻게 10시간에서 15분이 됐는지.

- [유튜버추천카페로 이동하기](https://youchuca.popit.kr/?ref=popit.kr)