> ## 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로 장소 검색 서비스 만드는 중입니다 (기술 9) — 속도 최적화 상세: 어디가 진짜 병목이었나
- URL: https://www.popit.kr/ai-place-search-tech-9-latency-optimization-bottleneck/
- Published: 2026-08-12T00:29:00.000Z
- Updated: 2026-08-12T00:29:00.000Z
- Author: 김형준
- Tags: ai, 검색, #서비스소개, 유추카, LLM

![t9-speed](https://www.popit.kr/content/images/wp/2026/07/t9-speed-600x216.png)

> 6편에서 "10초 걸리던 검색을 1초로"를 이야기했습니다. 이번 글은 그 뒤의 **정확한 숫자와 방법**입니다. 그리고 이 글의 진짜 주제는 속도가 아니라 **"내가 느리다고 생각한 곳은 대체로 느린 곳이 아니었다"** 입니다.  
>  
>  
>  
> 원칙 하나: **추측하지 말고 측정하라.** 이 글에 나오는 네 번의 최적화 중 **세 번은 제가 처음에 지목한 범인이 아니었습니다.**

## 왜 추측이 계속 틀리나

성능 최적화에서 직관이 유독 잘 틀리는 이유가 있습니다. **우리는 "복잡해 보이는 것"을 느리다고 착각합니다.**

검색이 10\~20초 걸릴 때 제 머릿속의 범인 순위는 이랬습니다.

1. LLM 리랭크 (AI니까 당연히 느리겠지)
2. 임베딩 계산 (벡터 수천 개를 비교하니까)
3. MongoDB 쿼리

**셋 다 아니었습니다.** 실제 1등은 목록에 없던 것이었고요.

그래서 제일 먼저 한 일은 최적화가 아니라 **계측**이었습니다. 검색 서비스에 단계별 타이밍 로그를 넣었습니다.  

```
[timing] parse=1.0s  filter=0.6s  rerank=?  thumbnails=?
```

찍어보니 파싱은 1초, 필터는 0.6초. 제가 의심하던 곳들은 이미 충분히 빨랐습니다. 남은 시간이 다 어디로 갔는지가 그제야 보였습니다.

> **왜 로그부터 넣나**: 최적화는 "어디를 고칠까"를 정하는 게 일의 90%입니다. 잘못된 곳을 고치면 코드는 복잡해지고 속도는 그대로입니다. 그리고 **잘못된 곳인지 아닌지는 재보기 전에는 절대 알 수 없습니다.**

## 1등 범인 — 책 한 줄 보려고 책 114권을 들고 왔다

가장 큰 병목은 **썸네일**이었습니다.

구조는 이랬습니다. 검색 결과가 114곳이면, 각 장소의 사진을 보여주려고 네이버 문서를 조회합니다. 그런데 그 문서 하나에는 **리뷰 30개 + 블로그 후기 10개**가 통째로 들어 있습니다. 우리가 실제로 쓰는 건 그중 **사진 URL 딱 하나**고요.

**책갈피 한 줄을 보려고 책 전체를 114권 들고 온** 셈이었습니다.

해결은 **비정규화**였습니다.

> **비정규화(denormalization)**: 정석대로면 데이터는 한 곳에만 두고 필요할 때 조인해 씁니다(정규화). 비정규화는 그 반대로, **자주 읽는 값을 읽는 쪽에 미리 복사해 두는 것**입니다. 저장 공간이 늘고 갱신할 곳이 늘어나는 대신, 읽기가 조회 한 번으로 끝납니다. 읽기가 쓰기보다 압도적으로 많을 때 쓰는 고전적인 거래입니다.

사진 URL을 **색인할 때 검색 문서에 미리 복사**해 두고, 검색은 네이버 문서를 아예 안 건드리게 했습니다.

> **"데이트" 검색: 9.5초 → 6.2초** (이것 하나로)

### 그리고 더 나은 답이 있었다

여기서 사용자(서비스 주인)의 지적이 하나 더 있었습니다.

**"검색 직후엔 지도 핀만 보이고 목록은 접혀 있는데, 썸네일을 왜 그때 가져와요?"**

맞는 말이었습니다. 사용자가 목록을 펼치기 전까지 썸네일은 화면에 없습니다. 즉 **그 시점에 필요 없는 데이터를 위해 3.3초를 쓰고 있었던** 겁니다.

최적화의 순서가 여기 있습니다.

1. **안 해도 되는 일인가?** ← 가장 강력
2. 미리 해둘 수 있는가? (비정규화)
3. 더 빨리 할 수 있는가?

대부분 3번부터 고민하는데, 1번과 2번이 훨씬 크게 먹힙니다.

## 2등 범인 — LLM 지연은 '읽는 시간'이 아니라 '쓰는 시간'이었다

썸네일을 걷어내니 리랭크가 남았습니다. 여기서 두 번째 오해를 발견했습니다.

리랭크를 빠르게 하려고 처음 시도한 건 **입력을 줄이는 것**이었습니다. 후보마다 넘기는 태그를 12개로, 발췌를 5개로 줄였죠. 효과가 있긴 했는데 기대만큼은 아니었습니다.

측정해 보니 지연을 지배하는 건 입력이 아니라 **출력**이었습니다.

> **LLM 지연의 구조**: 입력 토큰은 한꺼번에 병렬로 처리되지만(prefill), **출력 토큰은 한 개씩 순차적으로 생성**됩니다(decode). 그래서 입력 5,000토큰 + 출력 100토큰보다, 입력 1,000토큰 + 출력 2,000토큰이 훨씬 느립니다. **"얼마나 읽히느냐"보다 "얼마나 쓰게 하느냐"가 지연을 결정합니다.**

유추카의 리랭커는 후보 20개에 대해 **"왜 이 장소를 추천하는지" 이유를 써서** 돌려주고 있었습니다. 이유 20개면 **2,000토큰이 넘습니다.** 지연의 대부분이 여기 있었습니다.

### 이 발견이 속도와 품질을 동시에 풀었다

이걸 알고 나니 재미있는 설계가 가능해졌습니다.

당시 품질 쪽에서는 정반대 요구가 있었습니다. 기술 7편의 귀인 진단이 **"정답의 50%가 리랭크 창 밖에서 죽는다"** 고 지목했거든요. 창을 20개에서 60개로 **3배 넓혀야** 했습니다. 그런데 넓히면 당연히 3배 느려질 것 같았죠.

**출력이 지연을 지배한다면, 이유를 안 쓰게 하면 됩니다.**

```
후보 60개
 ├─ 배치 1 (상위 20)  → 판정 + 이유 생성   (기존과 동일, 무겁다)
 ├─ 배치 2 (21~40)    → 판정만             ← 인덱스 + 적합여부만 출력
 └─ 배치 3 (41~60)    → 판정만
        ↑ 4-스레드로 병렬 호출
```

확장 배치는 **판정 전용**입니다. 출력이 `{인덱스, 적합여부}` 목록뿐이라 **약 2초**면 끝납니다. 판정 규칙(근거 검증 등)은 배치 1과 완전히 동일하게 유지했으니 정밀도도 같고요. 그리고 세 배치를 **병렬로** 부르니 전체 지연은 **가장 느린 배치 하나**입니다.

결과적으로 **평가 창을 3배로 넓히면서 추가 지연은 거의 0**이었습니다. 노출된 확장 결과의 추천 이유는 나중에 화면에서 필요해질 때 따로 채웁니다(지연 로딩).

**속도 최적화가 품질 개선의 수단이 된 드문 경우**였습니다. 보통은 둘이 서로 잡아먹는데, 이번엔 지연의 구조를 정확히 알았더니 교환하지 않아도 됐습니다.

## 3등 — 모델 교체

마지막으로 리랭크 모델 자체를 A/B로 골랐습니다.

| 모델                        | 평균 속도    | 품질             |
| ------------------------- | -------- | -------------- |
| qwen-flash                | \~10초    | 동등             |
| qwen-plus                 | \~15초    | 동등             |
| **gemini-2.5-flash-lite** | **\~5초** | 동등             |
| gemini-2.5-flash (상위 티어)  | \~9.8초   | 동등 + 이유 빈 값 사고 |

**품질이 동등하면 가장 빠른 걸 고릅니다.** 상위 티어 모델이 오히려 느렸다는 이야기는 기술 8편에 따로 적었습니다.

여기까지 합쳐서 **검색 응답이 10~~20초 → 2~~5초**가 됐습니다.

(참고로 초기에 "1초, 8배 빨라졌다"고 기록한 적이 있는데, 폐기된 모델명을 불러 **LLM을 아예 안 타고 있던** 착시였습니다. 정직한 개선은 "2배쯤"입니다.)

## 우리가 안 느린데 느린 경우

측정을 하다 보면 **우리 코드와 무관한 느림**을 만납니다. 이걸 구분 못 하면 엉뚱한 곳을 뜯게 됩니다.

리랭크 창 확대를 배포한 날 밤, 응답이 **8\~22초**로 튀었습니다. 방금 창을 3배로 넓혔으니 당연히 그 탓인 줄 알았습니다.

로그를 열어보니 **Gemini API가 503(high demand)을 뱉고 있었습니다.** 재시도 백오프와 교차 폴백이 겹치면서 지연이 부풀어 있었죠. 같은 시각 API를 직접 3병렬로 찔러보니 **2.2초**가 나왔습니다. **우리 설계는 멀쩡했습니다.**

**교훈**: 성능 회귀를 발견하면 **"우리가 바꾼 것" 말고 다른 원인부터 배제**합니다. 배포와 장애가 같은 날 겹치면 인과가 뒤바뀌기 딱 좋습니다. 그리고 외부 API 지연은 **재시도 정책이 지연을 증폭**시킵니다 — 실패를 빨리 인정하고 폴백하는 게 나은 경우가 많습니다.

## 개발 속도도 자원이다 — 평가 러너 479초 → 7초

사용자 응답과는 별개로, 느려서 아팠던 게 하나 더 있습니다. **품질 평가 러너**입니다.

55개 쿼리를 전부 돌려 A/B를 재는 데 **479초**가 걸렸습니다. 8분입니다. 이러면 하루에 실험을 몇 번 못 하고, **실험 횟수가 곧 품질 개선 속도**입니다.

여기서도 병목은 LLM이었고, 해법은 **캐시**였습니다(기술 7편에서 결정성 확보용으로 만든 그 캐시입니다).

| 단계        | 러너 시간         |
| --------- | ------------- |
| 최적화 전     | **479초**      |
| 리랭크 판정 캐시 | **26초** (18배) |
| \+ 파싱 결정화 | **7초**        |

**8분이 7초가 됐습니다.** 실험을 하루에 몇 번 하던 게 몇십 번이 됐고, 이게 기술 7·8편의 A/B 기록이 그렇게 촘촘한 이유입니다.

그리고 이 캐시는 **운영에도 그대로 효과가 났습니다.** 인기 검색어는 판정이 캐시에 있으니 **리랭크 LLM 호출이 0회**입니다. 측정하려고 만든 장치가 사용자 응답 속도까지 개선한 셈이죠.

> 다만 이건 오프라인 러너 숫자입니다. 운영 응답시간(2\~5초)과 혼동하면 안 됩니다.

## 2GB 서버에 JVM 4개 — 메모리 튜닝

운영 서버는 넉넉하지 않습니다. **2GB 박스에 JVM이 4개**(유추카 + 이웃 서비스들) 떠 있습니다.

진단부터 했더니 이랬습니다.

|                    | 값            |
| ------------------ | ------------ |
| 유추카가 실제로 쓰는 힙      | **198MB**    |
| JVM이 잡고 있던 기본 최대 힙 | **494MB**    |
| 박스 전체 스왑           | **2.4GB 과적** |

**아무 옵션 없이 두면 JVM은 물리 메모리의 약 ¼을 최대 힙으로 잡습니다.** 넷이 각자 494MB를 예약하면 2GB 박스에서 감당이 안 됩니다. 실제로 쓰는 건 198MB인데 말이죠.

그래서 옵션을 조였습니다.  

```
-Xms192m -Xmx384m -XX:MaxMetaspaceSize=192m
-XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=30
-XX:G1PeriodicGCInterval=300000
```

앞의 두 줄은 상한을 정하는 거고, **뒤의 두 줄이 덜 알려진 부분**입니다.

> **힙을 OS에 돌려주기**: JVM은 한 번 확보한 힙을 잘 안 돌려줍니다. 순간 부하로 힙이 커지면 그 뒤로 계속 물고 있죠. 혼자 쓰는 서버라면 상관없지만, **이웃과 메모리를 나눠 쓰는 박스에서는 민폐**입니다. `MaxHeapFreeRatio`는 "여유 힙이 30%를 넘으면 줄여라", `G1PeriodicGCInterval`은 "5분마다 한가할 때 정리해라"라는 뜻입니다. 최대 성능을 조금 포기하고 **공존**을 사는 옵션입니다.

힙 안에서 뭐가 자리를 차지하는지도 뜯어봤습니다.

| 점유             | 크기   |
| -------------- | ---- |
| BM25 역색인       | 78MB |
| 바이트 배열         | 62MB |
| 문서 임베딩 (float) | 52MB |

문서 임베딩은 청크에 쓴 것과 같은 int8 양자화를 적용하면 **52MB → 13MB**로 줄일 여지가 있습니다(아직 안 했습니다).

## 메모리를 아꼈더니 기능이 하나 들어갔다

이 메모리 작업에는 뒷이야기가 있습니다. **원래 목적이 속도가 아니었습니다.**

기술 5편의 청크 임베딩을 운영에 올려야 하는데, 인덱스가 float32로 167MB였습니다. 그때 JVM은 무옵션 상태로 494MB를 잡고 스왑을 밀어내고 있었고요. **"기능을 켜면 서버가 죽는다"** 가 명확했습니다.

그래서 순서가 이렇게 됐습니다.

1. JVM 진단 → 실제 사용 198MB 확인 → 캡을 384MB로 조임
2. 청크 인덱스를 int8로 양자화 → **167MB → 39MB**
3. 그제야 기능 활성화

활성화 후 실측: Full GC 후 live **277MB**(캡 384 안), RSS 633MB, 시스템 가용 306MB. 인덱스 리로드 주기도 저메모리 박스를 배려해 **1시간 → 24시간**으로 늦췄습니다(리로드 순간에 새 인덱스와 옛 인덱스가 잠시 함께 존재하니까요).

**메모리 최적화가 성능 개선이 아니라 기능 출시의 전제조건**이었던 경우입니다. 작은 서버에서는 이런 일이 자주 생깁니다.

## 재시작 직후 40\~60초는 "고장"이 아니다

마지막으로 배포 때 자주 하는 오판 하나.

**재시작 직후 40\~60초 동안 검색 결과가 엉망**입니다. 이걸 "배포 실패"로 착각하고 롤백한 적이 있습니다.

원인은 **콜드 스타트**입니다. API 서버는 곧바로 응답을 시작하지만, 인메모리 인덱스들(문서 임베딩 6,370벡터 · BM25 역색인 · 청크 인덱스 · 메뉴 색인)은 애플리케이션 준비 이벤트 직후에 **순차적으로 적재**됩니다. 그 사이 검색은 임베딩 후보 0건이라 **평점순 폴백**으로 나옵니다.

즉 **"검색이 망가진" 게 아니라 "아직 안 켜진"** 겁니다.

그래서 배포 판정 절차를 바꿨습니다. 응답이 오는지가 아니라, **검색 방식이 '임베딩 검색 → 리랭크'로 바뀌었는지를 폴링해서** 확인합니다. 로그에 인덱스 적재 완료가 찍히는지도 함께 보고요.

> **일반화하면**: 인메모리 인덱스를 쓰는 서비스는 **"살아 있다(liveness)"와 "일할 준비가 됐다(readiness)"가 다릅니다.** 이 둘을 구분하지 않으면 배포할 때마다 사람이 놀라거나, 로드밸런서가 준비 안 된 인스턴스로 트래픽을 보냅니다.

## 이 글의 결론

- **병목은 예상 밖에 있습니다.** 제가 지목한 세 용의자(LLM·임베딩·DB)는 다 무죄였고, 진범은 목록에 없던 썸네일이었습니다 — 9.5초 → 6.2초.
- 최적화 순서는 **① 안 해도 되는 일인가 → ② 미리 해둘 수 있는가 → ③ 더 빨리 할 수 있는가.** 대부분 ③부터 고민하는데 ①②가 훨씬 큽니다.
- **LLM 지연은 입력이 아니라 출력이 지배합니다.** 이걸 알고 나니 확장 배치를 "판정 전용"으로 만들어 **평가 창을 3배 넓히면서 지연은 그대로** 둘 수 있었습니다.
- **성능 회귀를 보면 우리 배포 말고 다른 원인부터 배제**합니다. 8\~22초의 범인은 우리 설계가 아니라 외부 API 503이었습니다.
- **개발 속도도 자원입니다.** 평가 러너 479초 → 7초가 A/B 횟수를 몇십 배로 늘렸고, 그게 품질을 밀어 올렸습니다.
- 작은 서버에서는 **힙을 조이고 OS에 돌려주게** 해야 이웃과 공존합니다. 그리고 그 메모리 작업이 **새 기능의 전제조건**이 되기도 합니다.
- **"살아 있다"와 "준비됐다"는 다릅니다.** 재시작 후 40\~60초 폴백은 정상입니다.

다음 글(기술 10)에서는 이 모든 검색이 딛고 선 **데이터 파이프라인** — 유튜브 영상 하나가 지도 위 핀이 되기까지, 크롤 → 추출 → 검증 → 매칭의 전 과정 — 을 봅니다.

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