AI로 장소 검색 서비스 만드는 중입니다 (기술 10) — 데이터 파이프라인: 유튜브 영상 하나가 지도 핀이 되기까지

기술 3~9편은 전부 검색에 관한 이야기였습니다. 그런데 검색이 아무리 정교해도 그 아래 데이터가 없으면 아무 소용이 없습니다. 마지막 글은 유튜브 채널 하나를 넣으면 지도에 핀이 찍히기까지의 전 과정입니다.
그리고 이 글의 절반은 현실의 데이터가 얼마나 지저분한지에 대한 이야기입니다. 유튜브가 자막을 안 주고, 네이버가 정확한 주소로 검색하면 못 찾고, 매칭 실패의 63%가 환경변수 하나였던 이야기.
전체 흐름 — 채널 하나에서 지도까지
관리자가 유튜브 채널 URL을 넣으면 작업이 순차로 돕니다.
채널 등록
↓ crawl 영상 목록·설명 전량 수집
↓ extract 설명(+자막)에서 장소 이름 캐내기 ← AI가 지어낸다
↓ verify 네이버에서 진짜 있는 가게인지 확인 ← 검색어가 문제
↓ enrich 네이버 상세·리뷰·사진 붙이기 ← 매칭이 문제
↓ refresh 갱신
↓ 색인 임베딩·라벨·청크 생성 (기술 5·6편)
지도 핀
각 단계를 실제 한 채널의 숫자와 함께 따라가겠습니다. 표본은 @RawFishEater(회 전문 채널)입니다.
① crawl — 영상 메타 전량 수집
채널의 영상 목록과 설명·조회수를 전량 가져옵니다. RawFishEater는 736개 영상.
이 단계는 가장 단순하고, 그래서 가장 문제가 없었습니다. 문제는 다음부터입니다.
② extract — AI는 없는 가게를 그럴듯하게 지어낸다
각 영상 설명을 LLM에 넣어 장소 후보를 뽑습니다. RawFishEater는 736영상 → 897개 후보.
이 숫자를 그대로 믿으면 안 됩니다. LLM은 존재하지 않는 가게를 아주 자연스럽게 만들어냅니다. "○○수산 본점" 같은 이름은 실제로 있을 법하게 들리고, 문법적으로 완벽하며, 앞뒤 문맥과도 어울립니다. 지어냈다는 티가 전혀 안 납니다.
흔한 실수가 하나 더 있습니다. 업종어를 상호로 오인합니다. 설명에 "샤퀴테리 맛집"이라고 쓰여 있으면 "샤퀴테리"를 가게 이름으로 뽑습니다(샤퀴테리는 육가공 식품을 뜻하는 일반 명사입니다). 프롬프트 가드로 상당수 막지만 완전히는 안 됩니다.
그래서 추출은 후보를 만드는 단계일 뿐이고, 실재 확인은 반드시 별도 단계로 둡니다. 경험적으로 후보 10개 중 6~7개가 검증을 통과합니다. 나머지 3~4개는 지어낸 가게, 폐업한 가게, 업종어입니다.
원칙: 생성형 모델의 출력에는 반드시 외부 사실 검증을 붙입니다. 검증할 수 없는 필드는 사용자에게 노출하지 않는 게 안전합니다. 이 파이프라인에서 네이버가 그 검증자 역할을 합니다.
설명란이 비어 있으면 어쩌나 — 자막을 가져오기까지의 사투
추출이 0건으로 끝나는 영상이 꽤 있었습니다. 예를 들어 동묘 시장을 다룬 영상 하나는 설명란에 이런 게 전부였습니다.
저작권 고지 · 인스타그램 링크 · 사용한 BGM 목록
가게 이름이 한 글자도 없습니다. 그런데 영상에서는 진행자가 가게 이름을 계속 말합니다. 자막에는 있는 겁니다.
그래서 자막을 가져오기로 했습니다. 여기서부터가 고생길이었습니다.
시도 1 — 유튜브 자막 API를 직접 호출. 유튜브에는 timedtext라는 자막 엔드포인트가 있습니다. 불러봤더니 빈 응답이 왔습니다. 서버에서 불러도, 페이지 안에서 불러도, 브라우저 자동화 도구로 불러도 전부 빈 응답이었습니다. 유튜브가 봇 방지 토큰을 요구하는데, 그 토큰은 플레이어 내부에서만 생성됩니다.
시도 2 — 내부 API. 유튜브 내부 API에도 자막을 가져오는 경로가 있습니다. 자동화 세션에서 부르면 FAILED_PRECONDITION이 돌아옵니다. 같은 벽입니다.
시도 3 — 되는 방법을 찾다. 결국 통한 건 이겁니다.
1. 데스크톱 브라우저 컨텍스트로 영상 페이지를 연다
2. 영상을 실제로 재생한다 (음소거)
3. 자막 토글 키(c)를 누른다
4. → 그러면 플레이어가 스스로 봇 방지 토큰을 만들어 자막을 요청한다
5. 그 네트워크 응답을 가로채서 파싱한다
우리가 자막을 요청하는 게 아니라, 플레이어가 요청하게 만들고 옆에서 받아 적는 겁니다. 우아하지 않지만 유일하게 동작하는 방법이었습니다.
비싼 방법은 게이트를 붙여야 한다
이 방식은 영상을 실제로 재생하기 때문에 영상당 수 초가 걸립니다. 736개 영상에 전부 하면 몇 시간이 날아가고, 봇 탐지 위험도 그만큼 커집니다.
그래서 게이트를 걸었습니다. 설명만으로 장소가 하나도 안 나온 영상에만 자막을 가져옵니다. 나머지는 기존대로 설명만 씁니다.
그리고 기술 6편에서 본 그 패턴이 여기서도 필요했습니다 — "자막이 없다"와 "아직 안 해봤다"를 구분해야 합니다. 시도 시각을 따로 기록해서, "시도했지만 자막이 없는 영상"은 다시 시도하지 않습니다. 이게 없으면 자막 없는 영상을 매번 재생하게 됩니다.
마지막으로 프롬프트 가드를 하나 추가했습니다. 자막은 구어체라 지명·별명·애매한 지칭이 섞여 있어서, 그대로 두면 추출이 더 지저분해집니다. "자막에서는 상호가 명확한 곳만 뽑아라" 를 명시했습니다.
실제 수집 결과: 동묘 영상 5,614자, 서촌 영상 1,279자. 둘 다 설명만으로는 0건이었던 영상입니다.
③ verify — 더 정확한 검색어가 더 나쁜 결과를 냈다
후보가 진짜 존재하는지 네이버 검색으로 확인합니다. 처음엔 이 단계가 잘 될 줄 알았습니다.
그런데 검토 큐에 쌓인 미처리 후보의 80%가 "네이버 결과 0건" 이었습니다. 이름도 있고 지역도 있는데 네이버에서 아무것도 안 나온다는 게 이상해서, 표본 30건을 직접 손으로 검색해 봤습니다.
| 검색어 형태 | 결과 확보 |
|---|---|
| 이름 + 지역 (기존 방식) | 0 / 30 |
| 이름만 | 11 / 30 (37%) |
정보를 더 준 쪽이 완패했습니다.
이유는 주소 표기의 불일치였습니다. 우리가 추출한 지역이 "서울 성북구 정릉동"인데 네이버 상호 등록은 다른 표기를 쓰거나, 지역명이 상호에 안 들어가 있거나, 행정동과 법정동이 다르거나. 검색어에 지역을 붙이는 순간 AND 조건이 하나 더 붙어서 결과가 0이 됩니다.
그래서 쿼리 사다리를 만들었습니다. 구체적인 것부터 시도하고, 0건이면 한 칸씩 완화합니다.
① 이름 + 지역 전체 "산전수전 서울 성북구 정릉동"
↓ 0건이면
② 이름 + 지역 마지막 토큰 "산전수전 정릉동"
↓ 0건이면
③ 이름만 "산전수전"
왜 사다리인가: "느슨한 검색어를 쓰면 되지 않나"가 아닙니다. 느슨하게만 하면 엉뚱한 가게가 걸립니다. 정확한 쿼리부터 시도해서 먼저 걸리는 게 가장 믿을 만한 결과가 되도록 순서를 만든 겁니다. 완화는 앞 단계가 실패했을 때의 보험입니다.
느슨해진 만큼 위험해진다
사다리를 도입하자 곧바로 새 문제가 나왔습니다. "돈방석수산"으로 검색했더니 "길양수산"이 매칭됐습니다. 이름만으로 검색하니 지역이 전혀 다른 동명이인(?) 가게가 걸린 겁니다.
원래 코드는 네이버 결과의 첫 번째 항목만 보고 채택했습니다. 이름 유사도 검사도 없었고요. 검색을 느슨하게 만들면서 이 부분을 안 고치면, 결과가 늘어나는 만큼 오매칭도 늘어납니다.
그래서 채택 로직을 바꿨습니다.
- 결과 상위 5개를 본다 (첫 항목이 타지역 동명 가게여도 뒤에서 찾을 수 있게)
- 지역 일치 + 이름 유사 두 게이트를 모두 통과하는 첫 항목만 채택
- 게이트를 못 넘으면 채택하지 않고 사유를 남겨 검토 큐로
- 결과 0건 →
no naver result - 지역이 안 맞음 →
region mismatch - 지역은 맞는데 이름이 다름 →
name mismatch (추출=…, 네이버=…)
- 결과 0건 →
사유를 분리한 게 나중에 크게 도움이 됐습니다. 기술 6편에서 검토 큐를 자동 심사할 때, "네이버에 아예 없는 것(804건)"과 "찾긴 했는데 확신이 없는 것(989건)"을 나눠 처리할 수 있었던 게 이 사유 덕분입니다.
RawFishEater는 이 단계에서 557 검증 / 340 검토 대기, 장소 420곳을 확보했습니다.
이름이 같다는 걸 어떻게 판정하나
"이름 유사"라고 한 줄로 썼지만, 여기가 이 파이프라인에서 가장 규칙이 많은 곳입니다. 문자열 비교로는 절대 안 됩니다.
실전에서 하나씩 터진 사례를 규칙으로 굳혔습니다.
| 실제 사례 | 판정 | 이유 |
|---|---|---|
| 권금성케이블카 ↔ 권금성 | 매칭 ✓ | 추출명이 더 구체적인 방향은 안전 |
| 빈브라더스 커피하우스 서울숲 ↔ 빈브라더스 서울숲점 | 매칭 ✓ | 지점 접미사는 허용 |
| 가평 ↔ 가평군청 | 차단 ✗ | 추출명이 지역 토큰이면 확장 금지 |
| 연세대 ↔ 백양누리라운지 | 차단 ✗ | 짧은 이름의 확장은 위험 |
| 화개산 전망대 ↔ 강화화개산모노레일 | 차단 ✗ | 2토큰 중 1개만 일치 — 과반 미달 |
| 불암산 ↔ 불암산나비정원 | 차단 ✗ | 4자 미만 단일 토큰의 부분 포함 배제 |
| 무릉별열차 ↔ 무릉별유천지 | 차단 ✗ | 접두어만 겹침 |
규칙을 요약하면 이렇습니다.
- 확장 방향에 비대칭을 둡니다. "추출명이 네이버 상호를 포함"(더 구체적 → 덜 구체적)은 안전하지만, 그 반대 방향은 위험합니다. "가평"이 "가평군청"으로 승격되면 안 되니까요.
- 지역 토큰은 이름 매칭에서 제외합니다. "옥천 수생학습원"과 "우성쭈꾸미"가 '옥천' 하나로 매칭되면 안 됩니다.
- 짧은 이름일수록 엄격하게 봅니다. 4자 미만 토큰의 부분 일치는 아예 안 씁니다.
- 토큰이 2개면 둘 다 일치해야 합니다(엄격 과반).
이 규칙들은 설계로 나온 게 아니라 전부 사고 대응으로 나왔습니다. 하나 터질 때마다 규칙이 하나 늘었습니다. 현실의 이름 매칭이란 게 원래 그렇습니다 — 일반 해법이 없고, 도메인 규칙을 쌓는 수밖에 없습니다.
④ enrich — 실패의 63%는 알고리즘이 아니라 환경변수였다
검증된 장소에 네이버 상세(리뷰·평점·사진)를 붙이는 단계입니다. 사진은 우리 S3에 업로드해서 씁니다.
신규 채널 3개를 처음 돌렸을 때 결과가 참혹했습니다.
대상 1,549건 중 1,348건 실패. 성공률 13%.
여기서 저지를 뻔한 실수가 있습니다. 매칭 알고리즘을 뜯어고치는 것. 성공률이 13%면 당연히 매칭이 문제일 것 같았거든요.
고치기 전에 실패 사유를 분해했습니다.
| 실패 사유 | 건수 | 정체 |
|---|---|---|
| save-error | 848 (63%) | 매칭은 성공했는데 S3 업로드 실패 |
| no-results | 492 | 네이버 검색 0건 |
| ambiguous | 8 | 복수 결과에서 확정 실패 |
실패의 63%는 매칭과 아무 상관이 없었습니다. 매칭은 잘 됐고, 사진을 S3에 올리는 데서 죽고 있었습니다.
원인은 실행 셸에 AWS 자격증명이 없던 것이었습니다. 환경변수 하나 지정하고 재실행하니 848건이 전량 복구됐습니다.
교훈은 성능 튜닝의 그것과 똑같습니다 — 고치기 전에 재고, 재기 전에 분해합니다. "성공률 13%"라는 한 숫자만 보고 알고리즘을 뜯었다면 며칠을 날리고 63%는 그대로 남았을 겁니다. (그리고 이 함정은 재발하기 쉬워서, 설정 예시 파일에 "이 값이 없으면 사진 저장이 전멸한다" 를 명시해 뒀습니다.)
나머지 492건 — 여기서도 정확한 검색어가 졌다
no-results 492건은 verify와 똑같은 병이었습니다. 검색어를 장소명 + 도로명 전체 주소로 만들고 있었거든요.
장소명 + 읍면동으로 바꿔 재실행하니 499건 중 495건, 99%가 매칭됐습니다. 네이버 지도 검색은 긴 전체 주소 쿼리에 약합니다.
그래서 여기도 사다리를 넣었습니다. 긴 쿼리를 먼저 시도하고 0건이면 짧은 쿼리로, 짧은 쿼리에서 동 파싱이 실패하면 긴 쿼리로 — 양방향 자동 폴백입니다. 원래는 사람이 긴 쿼리로 한 번 돌리고, 실패 목록을 모아 짧은 쿼리로 또 돌리던 걸 한 번의 실행 안으로 넣었습니다.
ambiguous 8건 — 여기서만 완화한다
복수 결과에서 완전 일치가 없는 경우입니다. "형제집"처럼 흔한 상호나 지점 접미사가 붙은 경우죠.
여기엔 완화 매칭을 넣되 조건을 아주 좁혔습니다. 이름 유사 + 시·군·구 일치를 모두 만족하는 후보가 오직 하나일 때만 채택합니다. 둘 이상이면 그냥 포기하고 검토 큐로 보냅니다. 애매하면 안 하는 쪽입니다.
최종 결과
세 가지를 다 고치고 나니:
3개 채널 누적 매칭 201 → 1,537 (정보 없음 1,348 → 한 자릿수)
RawFishEater 단독으로는 383곳 매칭입니다.
⑤ refresh & 색인 — 검색에 올리기
마지막으로 검색 문서를 만듭니다. 이 순서에 함정이 하나 있어서 절차로 굳혀 뒀습니다.
- 검색 문서 생성 — 임베딩·BM25 색인·라벨. 변경 감지로 신규만 처리합니다.
- 리뷰 AI 요약 생성 — 이걸 빼먹기 쉽습니다. 사용자에게 보이는 요약은 네이버 것이 아니라 자체 생성이라, 안 돌리면 신규 장소 상세 화면의 요약이 텅 빕니다.
- 청크 임베딩(기술 5편)과 분류 배치(기술 6편) — 여기까지 채워져야 그 장소가 검색에 온전히 뜹니다.
"색인했다"와 "검색에 제대로 뜬다"는 다릅니다. 단계가 여러 개라 하나만 빠져도 그 장소는 반쪽만 검색됩니다. 그래서 채널 추가 후 표준 절차를 문서로 고정해 두고, 새 채널마다 그대로 따릅니다.
애매한 건 사람에게
verify에서 게이트를 못 넘은 후보, 같은 이름 다른 가게, 폐업 의심은 검토 큐로 갑니다. 그 큐를 LLM이 3단계로 자동 심사하고, 정말 애매한 것만 사람에게 남기는 이야기는 기술 6편에 적었습니다. RawFishEater의 340 검토 대기가 그리로 흘러갑니다.
한 채널의 여정, 숫자로
| 단계 | A 채널 실측 |
|---|---|
| crawl (영상) | 736 |
| extract (후보) | 897 |
| verify (검증 / 검토 대기) | 557 / 340 · 장소 420 |
| enrich (매칭) | 383 |
| photo refresh (사진) | 404 / 417 |
736개 영상이 383곳의 검증된 장소가 됐습니다. 그 사이 지어낸 가게, 업종어, 주소가 안 맞는 후보, 폐업한 가게가 단계마다 걸러졌습니다.
숫자만 보면 손실처럼 보이지만, 저 거름망이 없으면 지도에 존재하지 않는 가게 핀이 찍힙니다. 사용자가 차를 몰고 갔는데 그런 가게가 없는 것 — 검색 서비스가 저지를 수 있는 최악의 실패입니다. 거르는 게 파이프라인의 일입니다.
이 글의 결론
- 좋은 검색은 좋은 데이터 위에서만 가능합니다. 기술 3~9편의 모든 정교함은 이 컨베이어 벨트가 돌아야 의미가 있습니다.
- 생성형 모델의 출력에는 반드시 외부 사실 검증을 붙입니다. 후보 10개 중 6~7개만 실재했습니다.
- 더 많은 정보를 주는 게 더 좋은 결과를 뜻하지 않습니다. 이름+지역은 0/30이었고 이름만은 11/30이었습니다. enrich에서도 전체 주소보다 읍면동이 이겼습니다.
- 느슨하게 만들면 반드시 게이트도 같이 만들어야 합니다. 검색을 완화한 대가로 돈방석수산이 길양수산이 됐고, 이름 매칭 규칙 일곱 개가 그 뒤에 생겼습니다.
- 고치기 전에 재고, 재기 전에 분해합니다. "성공률 13%"의 63%는 환경변수 하나였습니다.
- 그리고 자동화가 못 넘는 애매함은 사람에게 넘깁니다 — 다만 애매한 것만.
검색창에 한 줄을 넣고 결과가 뜨기까지, 그 뒤에 이만큼의 결정과 실패와 숫자가 있었습니다.
돌아보면 지금까지의 열 편을 관통하는 문장은 하나였던 것 같습니다. 재보기 전에는 아무것도 모른다. 좋아 보이던 기법의 절반은 손해였고, 병목은 늘 엉뚱한 곳에 있었고, 심지어 측정 도구조차 두 번 틀렸습니다. 그걸 하나씩 확인해 가는 과정이 곧 서비스를 만드는 일이었습니다.
그런데 여기까지 만들고 나서 훨씬 근본적인 문제를 마주했습니다. 아무도 안 왔습니다. 검색 유입이 정확히 0이었어요. 다음 글(기술 11)은 잘 만들어 놓고 발견되지 않던 상태를 진단한 이야기 — 검색 유입 0에서 시작한 SEO 입니다.