AI로 장소 검색 서비스 만드는 중입니다 (기술 9) — 속도 최적화 상세: 어디가 진짜 병목이었나

t9-speed

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편에 따로 적었습니다.

여기까지 합쳐서 검색 응답이 1020초 → 25초가 됐습니다.

(참고로 초기에 "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)에서는 이 모든 검색이 딛고 선 데이터 파이프라인 — 유튜브 영상 하나가 지도 위 핀이 되기까지, 크롤 → 추출 → 검증 → 매칭의 전 과정 — 을 봅니다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.