← 전체 글

키워드 발굴 25초를 0.2초로 — 검색창 자동완성을 프로덕션에 넣기까지

· 12 분 분량
어드민 키워드 설정 화면 — 검색창 자동완성에서 발굴한 키워드 7개가 구글·네이버 출처 배지와 함께 목록으로 표시된다
"건강검진 대상자"를 넣고 [가져오기]를 누르면 0.2초 뒤 이 목록이 뜹니다. 오른쪽 아이콘이 어느 검색창에서 나온 말인지 알려줍니다.

블로그 글을 자동으로 만드는 파이프라인에서 제일 앞에 있는 단계는 키워드를 정하는 일입니다. 여기가 느리면 뒤가 전부 밀리고, 여기가 비면 뒤에서 만들 것이 없습니다.

제가 담당하던 파이프라인은 이 단계에 키워드 하나당 25초를 썼습니다. 느리다는 건 다들 알았고, 원인은 “네이버 API가 느린가 보다” 정도로 짐작하고 있었습니다. 재본 사람은 없었습니다.

짐작은 틀렸습니다. 그리고 진짜 문제는 속도가 아니었습니다.


1. 왜 바꿨나

25초는 어디에 쓰였나

기존 방식은 이렇습니다. 네이버 지식iN에서 해당 주제의 질문을 대량으로 긁어온 뒤, 제목과 본문에서 2어절에서 3어절짜리 조각을 기계적으로 잘라냅니다. 그 조각을 LLM에 두 번 태웁니다. 한 번은 비슷한 것끼리 묶어달라고, 한 번은 그중 검색될 만한 말을 골라달라고.

구간별로 쟀습니다.

하는 일걸린 시간비중
네이버 API 호출 순서 잡기0.1초0.4%
지식iN 질문 긁어오기 (5개 요청 동시)2.4초9.7%
LLM ① 긁어온 조각들을 묶기12.4초49%
LLM ② 그중 검색될 말 고르기7.5초30%

외부 API는 2.4초에 끝납니다. 25초 중 20초는 그 뒤에 우리가 붙인 LLM 두 번입니다. 병목은 남의 것이 아니라 우리 것이었습니다.

그 LLM은 뺄 수 없습니다

여기서 “LLM을 빼면 되겠네”로 가면 틀립니다. 질문 문장을 기계로 토막 내면 이런 게 수천 개 쏟아집니다.

"청년 전세대출 연장 조건이 어떻게 되나요?"
→ 청년 전세대출 / 전세대출 연장 / 연장 조건이 / 대출 연장 / 조건이 어떻게 ...

문장 부스러기지 키워드가 아닙니다. 묶고 고르는 단계를 빼면 방식 자체가 성립하지 않습니다. 25초는 낭비가 아니라 부스러기에서 키워드를 만들어내는 값입니다. 느리게 하고 있었던 게 아니라 오래 걸리는 일을 하고 있었습니다.

그래서 최적화로는 답이 안 나옵니다. 방식을 바꿔야 합니다.

그리고 가끔 빈손으로 나옵니다

키워드 19개를 뽑으려고 지식iN 질문 24,558건을 읽었고 149개를 건졌습니다. 질문 100건당 0.6개입니다.

수율이 낮은 건 방식상 당연합니다. 문제는 수율이 0인 경우입니다.

질의: "서울 부동산 이재명"
지식iN 질문 1,000건 이상 읽음 · 19.9초 소요 · 결과 0개
자동완성 아무것도 안 읽음 · 0.2초 소요 · 결과 4개

그 주제를 물어본 사람이 적으면 반복되는 표현이 안 생깁니다. 그럼 묶을 것도 고를 것도 없습니다. 프로덕션 실행 이력 43건을 봐도 평균 1,116건을 읽고 5개를 냈습니다.

느린 건 기다리면 되지만 이건 기다려도 안 나옵니다. 방식을 바꾸게 만든 건 25초가 아니라 이쪽입니다.

자동완성은 성질이 반대입니다. 돌려주는 값이 정의상 “사람이 실제로 검색창에 친 말”이라, 그 말로 검색한 사람이 있으면 나옵니다. 없는 걸 만들어내지 않으니 만드는 값을 치를 이유도 없습니다.

지식iN 질문 문장 → 조각 내기 → LLM 묶기 → LLM 고르기 → 키워드
자동완성 (이미 키워드)

2. 그 말은 어디에 있나

검색창에 글자를 치면 브라우저는 한 글자마다 요청을 하나씩 보냅니다. 여드름 흉터를 칠 때 실제로 나가는 건 이렇습니다.

키 1타 q='여' → 연금
키 3타 q='여드름' → 여드름 흉터
키 6타 q='여드름 흉터' → 여드름 흉터 없애는법
글자 6개 = 요청 6건, 1.2초

우리가 보낼 요청도 이것과 같습니다. 다른 건 타이밍뿐입니다.

인증이 없는 것도 당연합니다. 자동완성은 로그인 전, 세션 수립 전, 첫 글자를 치는 순간에 동작해야 합니다. 인증을 걸 지점이 구조적으로 없습니다. 브라우저에 내려보낸 키는 누구나 보니 비밀을 심을 수도 없습니다.

2.1 구글은 2015년에 쓰지 말라고 했습니다

그렇다고 마음대로 써도 되는 건 아닙니다. 구글은 2015년 7월 24일에 공지를 냈고 8월 10일부터 서드파티 접근을 막았습니다.

자동완성은 검색을 보완하려고 만든 것이지, 검색과 분리되어 존재하도록 의도한 적이 없다. 자동완성의 내용은 웹 검색 결과와 함께 쓰이도록 최적화된 것이라, 검색이라는 맥락 밖에서는 의미 있는 사용자 가치를 주지 못한다.

같은 공지에 이 문장이 있습니다.

지원되지 않고 공개되지 않은 API를 쓰면 그 API가 사라질 위험을 함께 진다. 지금이 바로 그런 경우다.

네이버는 애초에 공식 API를 낸 적이 없습니다.

그런데 그 주소는 11년이 지난 지금도 응답합니다. 2026년 8월 10일에 직접 때려봤습니다.

GET /complete/search?client=chrome&hl=ko&gl=kr&oe=utf-8&q=여드름
200 · text/javascript; charset=UTF-8 · 258ms · 15개
["여드름", ["여드름 흉터","여드름 패치","여드름 약", ...]]

구글이 계속 열어두겠다고 한 적은 없습니다. 지금 응답한다는 건 제가 관측한 상태일 뿐이고 경고는 그대로 유효합니다. 반박할 게 아니라 받아들여야 합니다.

3장과 4장은 이 엔드포인트가 언제 끊겨도 되게 짰습니다. 5장은 프로덕션에 물려도 되는지를 따로 확인한 기록입니다.


3. 가져오기

3.1 파라미터 고르는 데 시간이 꽤 들었습니다

q에 무엇을 넣느냐부터 규칙이 필요합니다. 사용자가 친 말을 그대로 쓰되 앞 3어절까지만 자릅니다.

시드가 짧으면 구글이 어절이 아니라 글자 접두어로 처리해서 엉뚱한 데로 샙니다. 이사를 넣으면 이상형 월드컵, 이상민, 이상해씨가 나옵니다. 15개가 전부 무관했습니다. 같은 시드에서 네이버는 포장이사, 이사짐센터를 제대로 줬으니 엔진 공통 문제가 아니라 구글 쪽 특성입니다. 반대로 시드가 길면 자동완성 나무의 잎사귀라 후보가 급감합니다. 여드름 종류는 6개였습니다.

구글 client는 네 가지를 다 호출해 보고 chrome을 골랐습니다. 15개로 제일 많이 주고, 점수를 주는 유일한 값이고, 파싱이 JSON.parse 한 번으로 끝납니다. youtubegws-wiz는 JSONP 언랩이 필요하고 후자는 <b> 태그까지 벗겨야 합니다. client마다 응답 모양이 이렇게 다른 이유는 명확합니다. 제품별 내부 포맷이지 외부와 맺은 계약이 아닙니다.

oe=utf-8은 빠뜨리면 안 됩니다. 없으면 latin-1로 응답해서 한글이 전부 깨집니다. 그런데 상태코드는 200이고 JSON 구조도 멀쩡합니다. 파싱은 성공하고 내용만 쓰레기가 되니 로그로는 못 잡습니다.

네이버는 st=100(통합검색), ans=1(즉답 카드 블록 포함), rev=4(항목별 즉답 표시)를 씁니다. 실제 검색창은 frm, r_enc, run 같은 걸 더 붙이는데 빼고 보내도 결과가 같았습니다.

남는 건 두 줄입니다.

GET google.com/complete/search?client=chrome&hl=ko&gl=kr&oe=utf-8&q=…
GET ac.search.naver.com/nx/ac?q=…&st=100&r_format=json&ans=1&rev=4

3.2 파싱

구글 응답은 배열 안의 배열입니다. [1]번 자리가 제안 목록이고, 점수(google:suggestrelevance)가 든 메타 객체는 뒤쪽 어딘가에 있습니다.

packages/keyword-demand/src/autocomplete.ts
// 메타는 client 에 따라 인덱스가 다르다(chrome 은 4, firefox 는 3). 뒤에서 dict 를 찾는다.
const meta = [...parsed]
.reverse()
.find(
(value) =>
value !== null && typeof value === "object" && !Array.isArray(value),
);

인덱스를 박지 않은 이유는 chrome이 4번째, firefox가 3번째에 두기 때문입니다. 지금 쓰는 게 chrome 하나뿐이라 parsed[4]로 적어도 오늘은 돌아갑니다. 구글이 배열에 원소 하나만 더 끼워 넣으면 그날부터 조용히 점수를 못 읽습니다. 2.1에서 받아들이기로 한 전제가 여기서 처음 코드가 됩니다.

네이버는 단순합니다. items[0]이 목록이고 각 원소가 [텍스트, 즉답표시] 꼴입니다. 응답이 UTF-8 고정이라 인코딩 함정도 없습니다.

두 파서가 같은 형태로 수렴하게 맞췄습니다.

interface SuggestItem {
keyword: string;
/** 그 엔진의 응답 순서(1부터). 병합의 유일한 입력이다. */
rank: number;
relevance?: number;
instantAnswer?: boolean;
}

rank는 응답 순서 그대로입니다. 4장에서 두 목록을 합칠 때 쓰는 유일한 입력이라 여기서 순서를 건드리면 뒤가 전부 어긋납니다.

3.3 200을 성공으로 처리하면 안 됩니다

정상적인 API라면 잘못됐을 때 4xx나 5xx가 옵니다. try/catch면 됩니다. 그런데 이 엔드포인트는 우리와 계약한 적이 없으니 에러 코드를 줄 의무도 없습니다.

실제로는 이렇게 옵니다.

기대 실제
───────────────────────── ─────────────────────────────────────
429 Too Many Requests → 302 → google.com/sorry/index (캡차 페이지)
200 OK + content-type: text/html

fetch는 리다이렉트를 따라가므로 캡차로 튕겨도 손에 들어오는 건 200 OK입니다. if (!response.ok) throw는 영원히 발동하지 않습니다.

그다음이 더 성가십니다. 보통 이렇게 짜니까요.

try {
return JSON.parse(body).suggestions;
} catch {
return []; // 파싱 실패 → 빈 목록
}

JSON.parse가 HTML을 만나 터지고, catch가 빈 배열을 돌려주고, 아무 일 없었다는 듯 넘어갑니다. 로그에는 “이 키워드는 0개”만 남습니다.

그런데 이 파이프라인에서 0개는 이미 정상 결과입니다. 네이버는 아무도 안 치는 조합(매트리스 치료 같은)에 빈 배열을 정직하게 줍니다. 고장이 아니라 “이 말은 수요가 없다”는 판정을 공짜로 얻는 겁니다.

그러니 갈라야 하는 건 결과가 아니라 원인입니다.

결과: 0개
├── 막혔다 → 즉시 알아야 한다. 폴백해야 한다
└── 수요가 없다 → 정상. 오히려 기록해둘 가치가 있다

둘을 못 가르면 차단당한 걸 “아무도 안 치는 말”로 기록합니다. 그리고 4.3의 캐시가 그 거짓말을 7일간 굳힙니다. 그 시드는 일주일 내내 아무것도 못 찾습니다. 1장에서 버리기로 한 증상을 이번엔 직접 만드는 셈입니다.

그래서 세 단계로 가릅니다.

① response.url 에 '/sorry' 가 있나
fetch 가 리다이렉트를 따라가 버려서, 캡차로 튕긴 흔적은 최종 URL 에만 남는다
② content-type 이 text/html 인가
JSON 이 와야 할 자리에 페이지가 왔다 = 우리를 사람 취급하려는 것
③ 기대한 content-type 인가
구글 text/javascript · 네이버 application/json. 아니면 모르는 무언가다
셋을 통과해야 JSON.parse 로 넘어간다
packages/keyword-demand/src/autocomplete.ts
function assertNotBlocked(
engine: SuggestEngine,
response: Response,
expectedType: string,
): void {
if (response.url && response.url.includes("/sorry")) {
throw new SuggestBlockedError(
engine,
"자동완성 요청이 캡차로 리다이렉트되었습니다.",
response.url,
);
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("text/html")) {
throw new SuggestBlockedError(
engine,
"자동완성 응답이 HTML 입니다.",
contentType,
);
}
if (!contentType.includes(expectedType)) {
throw new SuggestBlockedError(
engine,
"자동완성 응답 형식이 예상과 다릅니다.",
contentType,
);
}
}

판정은 파싱보다 앞에 둬야 합니다. 뒤로 미루면 구분이 사라집니다. JSON.parse가 터진 뒤에는 차단이든 형식 변경이든 그냥 빈 응답이든 전부 같은 SyntaxError입니다. 앞에서 갈라야 셋이 각각 다른 예외로 남고, 그래야 알림을 겁니다.

2.1로 돌아가면, 구글이 경고한 그날이 왔을 때 그걸 알아채는 장치가 이 세 줄입니다. 조용히 0개를 쌓는 시스템은 API가 사라진 줄도 모릅니다.

3.4 두 엔진 동시에

호출은 Promise.all이 아니라 Promise.allSettled입니다. 한쪽 실패가 전체를 무너뜨릴 이유가 없습니다. 타임아웃은 비대칭으로 걸었습니다.

구글 300ms (실측 p50 185ms · p95 215ms)
네이버 10초 (실측 p50 24ms · p95 30ms)

네이버는 24ms에 끝납니다. 190ms짜리 때문에 같이 죽이면 손해입니다. 순차 200ms, 병렬 183ms니까 절약 자체는 17ms뿐입니다. 병렬로 간 이유는 다른 데 있습니다. 구글이 느려지거나 죽어도 네이버 결과가 20ms에 손에 들어옵니다. 둘 다 실패해야 실패입니다.


4. 합치기

4.1 점수를 못 쓰니 순위만 씁니다

처음에는 구글이 주는 suggestrelevance로 두 목록을 통합 정렬하려고 했습니다. 값을 열어보고 접었습니다.

16개 키워드 중 14개에서, 4위 이하 점수 폭이 60 이내
전체 점수 빈도: 601(19회) 600(19회) 550(19회) 551(18회) 554(16회) ...

550에서 601 사이 값이 거의 모든 키워드에 똑같이 반복됩니다. 실제 강도가 아니라 순위를 점수 모양으로 찍어낸 값입니다. 변별력이 있는 건 상위 3개까지입니다. 게다가 네이버는 점수가 아예 없습니다. 두 목록을 같은 자로 잴 방법이 없습니다.

그래서 등수만 씁니다.

score(키워드) = Σ w / (K + 그 엔진에서의 등수) K = 10, w = 1
엔진

이 식을 고른 이유는 겹침 처리가 공짜로 따라오기 때문입니다. 양쪽에 다 나온 키워드는 항이 두 개라 저절로 위로 갑니다. “겹치면 상단에”라는 분기를 따로 짤 필요가 없습니다. 한쪽에만 나온 키워드는 그 엔진에서의 등수로 줄을 서니 두 목록이 지퍼처럼 맞물립니다.

이 배치가 맞는지는 데이터로 확인했습니다. 겹친 항목 43개는 이미 양쪽에서 상위였습니다.

구글 내 평균순위 6.6 (구글 전체 평균 8.0보다 위)
네이버 내 평균순위 4.8 (훨씬 위)

독립된 두 검색 기록이 같은 걸 위로 올렸다는 뜻입니다. 여기서 얻을 수 있는 가장 강한 수요 신호입니다.

적용하면 이렇게 나옵니다.

생리통
1. ■ 생리통 약 0.1742 ■ = 양쪽
2. ■ 생리통 심할때 0.1742 G = 구글만
3. ■ 생리통 완화 0.1394 N = 네이버만
...
7. G 생리통 줄이는법 0.0714
8. N 생리통 완화 음식 0.0714 ← 구글 7위와 동급으로 끼어든다
9. G 생리통 약 순위 0.0667

가중치는 1대 1로 뒀습니다. 구글이 수량은 2배(평균 12.8개 대 6.4개)지만 수량은 품질이 아닙니다. 구글 결과의 12.7%가 커뮤니티 접미사라, 구글을 밀면 노이즈가 같이 올라옵니다.

둘을 합치는 것도 선택이 아니라 필수였습니다. 겹침이 질의별 자카드로 9%에서 11% 사이고, 19개 중 겹침이 아예 0인 키워드가 6개였습니다. 서울 부동산 전망은 네이버 1개에 구글 8개인데 겹치는 게 하나도 없습니다. 두 엔진은 서로의 대체재가 아니라 각자 다른 로그입니다.

4.2 후처리는 사전 하나로 끝냅니다

병합 위에 규칙을 한 겹 얹습니다. 전부 순수 함수라 네트워크도 환경변수도 읽지 않습니다.

띄어쓰기 정규화가 먼저입니다. 네이버는 붙여쓰기(다이어트음식), 구글은 띄어쓰기(다이어트 음식)를 선호합니다. 정규화 없이 합치면 같은 키워드가 두 번 저장되고 겹침 비율도 틀립니다. 실제로 정규화 전 10%가 정규화 후 12%가 됐습니다.

커뮤니티 접미사(디시, 더쿠, 나무위키 같은 것)는 순위를 낮추는 게 아니라 목록에서 뺍니다. 여드름 색소침착 디시로 글을 쓸 일은 없습니다. 순위를 조정할 대상이 아닙니다. 여기에 LLM 판정을 붙이지 않은 이유는 집합이 유한하고 잘 알려져 있어서입니다. 사전이면 같은 입력에 같은 출력이 나오고, 돈이 안 들고, 테스트를 씁니다.

다만 어절 단위로 봐야 합니다. 부분 문자열로 걸었다가 갤럭시, 판교판례이 걸려 멀쩡한 키워드가 통째로 날아갔습니다. , , dc처럼 짧은 항목은 맨 뒤 어절에 단독으로 올 때만 적용합니다. 지워진 키워드는 로그로 남깁니다. 사전이 잘못 거른 걸 나중에 회수하려면 무엇이 빠졌는지가 남아야 합니다.

정렬은 점수, 같으면 구글 순위, 그래도 같으면 사전순입니다. 같은 입력에 같은 출력이 나와야 재현이 됩니다.

4.3 캐시는 속도 때문에 붙인 게 아닙니다

발굴 결과는 7일 보관합니다. 자동완성은 하루 단위로 거의 안 변하니 안전한 폭입니다.

속도 이득은 부수적입니다. 진짜 목적은 5.3에서 이어집니다. 반복 발굴이 외부 호출을 아예 만들지 않으면, 지속적으로 나가는 트래픽이라는 게 존재하지 않습니다.

규칙이 세 개 붙어 있습니다.

  • 빈 값은 저장하지 않습니다. 자동완성이 일시적으로 죽어서 온 빈 배열을 7일간 굳히면 그 시드는 일주일 내내 아무것도 못 찾습니다. 3.3에서 갈라놓은 두 가지가 여기서 다시 걸립니다.
  • 저장 값에 판 번호를 같이 둡니다. 파서를 고치면 번호를 올립니다. 옛 행이 조회에서 걸러져 저절로 만료되니 DELETE를 돌릴 필요가 없고, 롤백하면 옛 행이 다시 유효해집니다. 응답 형태가 바뀌는 날 쓸 무효화 레버를 미리 달아둔 겁니다.
  • 캐시 실패는 발굴 실패가 아닙니다. 읽기와 쓰기 전 경로를 catch로 감싸서, DB가 흔들려도 발굴은 API를 직접 때려 끝냅니다.

5. 프로덕션에 넣어도 되나

5.1 남은 질문은 하나였습니다

빠르고 공짜라는 건 3장과 4장에서 확인했습니다. 남은 건 이겁니다. 문서도 없고 약관상 허용도 없는 엔드포인트를 프로덕션에 상시로 물려도 되는가.

“별문제 없겠지”로 넣을 수는 없습니다. 막힌다면 언제 어떻게 막히는지를 먼저 봐야 합니다. 도입 전에 부하를 걸었습니다.

5.2 일부러 봇처럼 보이는 조건으로 걸었습니다

춘천에 있는 서버에서 돌렸습니다. 데이터센터 IP입니다.

항목결과
총 요청914건 (네이버 457 / 구글 457)
조건2초 간격 균일 · 브라우저 UA · 완성 질의만
비정상 응답0건
/sorry 캡차 리다이렉트0건
HTML 응답(차단 신호)0건
지연 열화없음

전반부와 후반부를 갈라 봐도 흔들림이 없습니다.

네이버 전반 평균 24ms p50 23 p95 31 후반 평균 23ms p50 24 p95 30
구글 전반 평균185ms p50 185 p95 214 후반 평균187ms p50 186 p95 216

별도로 로컬에서 최대 125 req/s로 700요청을 던졌을 때도 429는 0건이었습니다.

조건은 의도적으로 잡았습니다. 데이터센터 IP, 정확히 2.000초 균일 간격, 접두어가 자라지 않는 완성 질의. 셋 다 사람이 타이핑하면 나올 수 없는 모양입니다. 가장 걸리기 쉬운 조합으로 던졌는데 아무 반응이 없었습니다.

5.3 왜 안 막혔나

타이핑 한 글자마다 요청이 나가는 서비스라는 게 여기서 작용합니다.

사무실 NAT나 통신사 CGNAT 뒤에서는 한 공인 IP에서 초당 수백 건이 정상적으로 발생합니다. 점심시간 사무실 하나가 그렇게 보입니다. IP 단위 초당 쿼터를 걸면 정상 사용자가 먼저 죽습니다. 그 축으로는 애초에 우리를 못 거릅니다.

실제 차단은 속도가 아니라 패턴을 봅니다.

┌─────────────────────────┬──────────────────────────────────┐
│ 타이핑처럼 보이는 것 │ 봇처럼 보이는 것 │
├─────────────────────────┼──────────────────────────────────┤
│ 접두어가 점진적으로 김 │ 완성된 질의만 계속 │
│ 간격이 불규칙 │ 정확히 2.000초 간격 │
│ UA·Accept 헤더 정상 │ UA 없음 │
│ 주거·모바일 IP │ 데이터센터 IP │
│ 낮에 몰리고 새벽에 준다 │ 24시간 균일 │
└─────────────────────────┴──────────────────────────────────┘

4.3의 캐시가 노리는 게 오른쪽 칸 마지막 줄입니다. 레이트리밋 대책이 아니라 “24시간 균일”을 만들지 않으려는 장치입니다. 반복 발굴이 외부 호출을 만들지 않으면 그 줄에 해당할 트래픽이 아예 안 생깁니다.

브라우저 UA를 실어 보내는 것도 같은 이유입니다. 네이버는 UA 없이도 200을 줍니다. 그래도 오른쪽 칸에 한 줄 더 얹을 이유가 없습니다.

5.4 도입했고, 방어는 그대로 뒀습니다

막히는 정황이 하나도 안 나와서 프로덕션에 넣었습니다. 지금 이 경로로 돌고 있습니다.

프로덕션 로그 기준입니다. 발굴은 자동완성 호출만 하고 끝냅니다.

[가져오기] POST /keywords/suggest n=35 p50 206ms · p90 222ms · max 291ms
(min 16ms 는 7일 캐시 적중)

바꾸기 전에는 같은 버튼이 25.1초였습니다. LLM 두 번이 끝나야 목록이 떴으니까요.

근거 질문 수집은 [저장]으로 옮겼습니다. 발굴이 곧 저장이던 예전 구조에서는 사용자가 결과를 읽어보기도 전에 저장이 끝나 있었고, 버릴 키워드에도 수집 비용을 다 치렀습니다. 지금은 체크박스로 고른 것만 저장되고, 그때 새로 들어온 키워드에 대해서만 근거를 모읍니다.

[저장] PUT /keywords n=63 p50 18ms · p90 2,035ms · max 3,370ms

p50이 18ms인 건 새 키워드가 없거나 캐시가 적중한 경우입니다. 새 키워드가 실제로 들어오면 2초에서 3.4초를 씁니다.

결과
속도목록이 뜨기까지 25.1초 → 0.2초. 지연의 79%였던 LLM 두 호출 제거
수량7.8개 → 17.2개. 19개 중 18개에서 자동완성이 앞섬
비용인증 없음 · 쿼터 없음 · 쓸 때마다 드는 돈 없음

검증된 건 “지금은 안 막힌다”까지입니다. 그래서 3.3의 세 단계 검사도 4.3의 캐시도 부하 테스트가 깨끗하게 나온 뒤에 걷어내지 않고 남겼습니다. 막히는 걸 본 적이 없다는 건 방어가 필요 없다는 뜻이 아니라 아직 시험받은 적이 없다는 뜻입니다.

남은 공백은 이렇습니다.

  • 검색량 검증을 안 했습니다. 몇 개 나왔는지는 셌지만 그 키워드가 실제로 검색되는지는 판정하지 않았습니다. 원리적으로는 자동완성이 유리합니다. 돌려주는 값이 정의상 실제로 검색된 질의니까요. 다만 이번 데이터로 확인한 건 아닙니다.
  • 부하는 914건에서 멈췄습니다. 목표는 2,000건이었고, 24시간 이상 지속 부하는 안 돌렸습니다.
  • K=10은 19개 키워드를 눈으로 보고 정했습니다. 튜닝한 값이 아닙니다.

2.1의 경고로 돌아가면, 이 글에서 내린 결정이 대체로 그 한 문장에서 나옵니다.

구글이 경고한 것 → 이 글에서 한 것
───────────────────────────── ────────────────────────────────────
응답이 사라질 수 있다 둘 다 죽어야 실패로 친다 (3.4)
형태가 예고 없이 바뀔 수 있다 메타를 인덱스로 찾지 않는다 (3.2)
캐시에 판 번호를 같이 저장한다 (4.3)
막혔는데 200으로 올 수 있다 파싱 앞에 세 단계 검사를 둔다 (3.3)
계속 두드리면 눈에 띈다 7일 캐시로 지속 트래픽을 없앤다 (4.3)

빠르고 공짜인 건 눈에 보이는 성과입니다. 하지만 이걸 프로덕션에 넣을 수 있었던 근거는 다릅니다. 이 엔드포인트는 내일 사라져도 이상할 게 없고, 처음부터 그걸 전제로 짰습니다.