> ## 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로 장소 검색 서비스 만드는 중입니다 (기술 10) — 데이터 파이프라인: 유튜브 영상 하나가 지도 핀이 되기까지
- URL: https://www.popit.kr/ai-place-search-tech-10-data-pipeline-youtube-to-map-pin/
- Published: 2026-08-14T00:11:09.000Z
- Updated: 2026-08-14T00:13:54.000Z
- Author: 김형준
- Tags: ai, 검색, #서비스소개, 유추카, LLM

![t10-pipeline](https://www.popit.kr/content/images/wp/2026/07/t10-pipeline-600x258.png)

> 기술 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 (추출=…, 네이버=…)`

**사유를 분리한 게 나중에 크게 도움이 됐습니다.** 기술 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 & 색인 — 검색에 올리기

마지막으로 검색 문서를 만듭니다. 이 순서에 함정이 하나 있어서 절차로 굳혀 뒀습니다.

1. **검색 문서 생성** — 임베딩·BM25 색인·라벨. 변경 감지로 신규만 처리합니다.
2. **리뷰 AI 요약 생성** — 이걸 빼먹기 쉽습니다. 사용자에게 보이는 요약은 네이버 것이 아니라 **자체 생성**이라, 안 돌리면 신규 장소 상세 화면의 요약이 텅 빕니다.
3. **청크 임베딩**(기술 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** 입니다.

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