> ## 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로 장소 검색 서비스 만드는 중입니다 (기술 6) — 라벨링 데이터 공장: 6천 곳을 사람 없이 분류하기
- URL: https://www.popit.kr/ai-place-search-tech-6-labeling-factory-6000-places/
- Published: 2026-08-04T22:08:24.000Z
- Updated: 2026-08-04T22:08:24.000Z
- Author: 김형준
- Tags: ai, 검색, #서비스소개, LLM, 라벨링

![t4-four-axes](https://www.popit.kr/content/images/wp/2026/08/t4-four-axes-600x258.png)

>  
>  
>  
>  
> 기술 4편의 하드게이트도, 5편의 청크도 **각 장소에 라벨이 붙어 있어야** 작동합니다. 그런데 라벨은 누가 붙일까요. 장소는 6천 곳이 넘고 매일 늘어납니다. 이번 글은 그 라벨을 사람 없이 채우는 **데이터 공장** 이야기 — 그리고 LLM이 자꾸 없는 바다를 만들어내던 사건 — 입니다.

## 라벨이 없으면 게이트도 없다

기술 4편에서 "한옥 카페"는 `venueStyles`에 '한옥'이 붙은 장소만 통과시킨다고 했습니다. 깔끔한 설계입니다. 그런데 그 글에서 안 한 이야기가 있습니다. **처음엔 그 라벨이 없었습니다.**

얼마나 없었냐면, 이랬습니다.

> 전라도 지역 식당 **187곳** 중, 태그에 '한옥'이 붙어 있는 곳: **1곳**

전라도에 한옥 식당이 한 곳일 리 없습니다. 색인에 **"이 장소가 어떤 건물·공간인가"라는 신호 자체가 없었던** 겁니다. 그러니 하드게이트를 걸 근거도 없었고, 임시방편으로 "리뷰 텍스트에 '한옥'이라는 글자가 있으면 통과"를 쓰고 있었습니다. 그 결과가 기술 4편에서 본 사고들이었고요.

결국 검색 품질 문제의 상당수는 **검색 알고리즘 문제가 아니라 라벨 부재 문제**였습니다. 그래서 라벨을 만드는 공장을 지어야 했습니다.

## 첫 번째 문제 — 8,070개의 라벨은 라벨이 아니다

라벨이 아예 없던 건 아닙니다. 리뷰에서 속성(aspect)을 뽑아내는 파이프라인은 이미 있었고, 꽤 열심히 뽑고 있었습니다. **8,070종**을 뽑았습니다.

그게 문제였습니다.

| 실제로 뽑힌 라벨들 | 건수    |
| ---------- | ----- |
| 주차         | 2,743 |
| 주차장        | 1,157 |
| 친절         | 548   |
| 친절함        | 278   |
| 직원친절       | 279   |

**같은 뜻인데 다 다른 라벨입니다.** "주차 되는 곳"을 검색하면 '주차'가 붙은 2,743곳은 걸리고 '주차장'이 붙은 1,157곳은 안 걸립니다. 친절은 세 갈래로 쪼개져 각각 절반씩 놓칩니다. 라벨이 8,070종이라는 건 **사실상 라벨이 없는 것과 비슷합니다** — 검색어와 라벨이 정확히 같은 글자여야만 만나니까요.

그래서 **닫힌 집합**으로 접근을 바꿨습니다.

> **닫힌 집합(closed set) 분류**: LLM에게 "이 장소의 특징을 자유롭게 뽑아줘"라고 하지 않고, **미리 정한 목록을 주고 그 안에서만 고르게** 하는 방식. 표현이 갈라질 여지가 없어지고, 검색어 쪽도 같은 목록으로 번역하면 항상 만납니다. 대신 목록에 없는 개념은 영영 못 잡으니, 목록을 잘 짜는 게 일의 절반입니다.

**8,070개의 자유 라벨을 188개의 정규 속성으로 묶었습니다.** '주차'와 '주차장'은 하나의 `parking_available`로, '친절/친절함/직원친절'은 하나의 `friendly_staff`로 모입니다. 각 정규 속성은 한글 이름과 동의어 목록을 갖고 있어서, 새 표현이 들어와도 같은 자리로 정규화됩니다.

여기엔 사소하지만 잊기 쉬운 함정이 하나 있었습니다. **"웨이팅"이라는 속성은 부호가 반대입니다.** 리뷰에서 웨이팅이 긍정적으로 언급되면(= 대기가 짧다) 좋은 신호지만, 부정적으로 언급되면(= 대기가 길다) 나쁜 신호입니다. 다른 속성처럼 "언급되면 가산"으로 처리하면 **줄이 제일 긴 집이 '웨이팅 짧은 집'으로 검색됩니다.** 이런 반전 속성은 따로 표시해 뒀습니다.

## 두 번째 문제 — "한옥"과 "한옥 느낌"은 다르다

공간·건물 축(`venueStyles`)은 33종의 닫힌 집합으로 정의했습니다 — 한옥·루프탑·테라스·정원·유럽풍·지중해풍·노포·오마카세 등. 그리고 LLM에게 각 장소를 이 목록으로 분류시켰습니다.

첫 결과에서 곧바로 문제가 나왔습니다. **감자탕집이 '한옥'으로 분류됐습니다.**

리뷰를 열어보니 이런 문장이 있었습니다.

> *"천장에 한옥 서까래 느낌으로 인테리어를 해놔서 분위기가 좋아요"*

**한옥이 아니라 한옥 느낌입니다.** 한옥 인테리어를 흉내 낸 현대식 건물이죠. 그런데 "한옥 식당"을 검색하는 사람은 진짜 한옥을 원합니다. 인테리어 소품이 아니라요.

그래서 분류 프롬프트에 이 구분을 **첫 번째 규칙**으로 박았습니다.

> **"한옥"과 "한옥 느낌 / 한옥풍 / 한옥 스타일"은 다르다 — 실제 건물만 분류한다.** 음식 종류는 스타일이 아니다. 추측 금지. 0\~2개만 고른다.

"추측 금지"와 "0\~2개"가 중요합니다. LLM은 빈 답을 어색해해서, 놔두면 어떻게든 뭔가를 채워 넣으려 합니다. **아무것도 해당 없으면 아무것도 고르지 않는 게 정답**이라고 명시해야 빈 배열을 냅니다.

표본 60건으로 검증한 결과입니다.

| 장소                   | 분류 결과             |
| -------------------- | ----------------- |
| 굴피집(한식)              | 한옥, 야외            |
| 스타벅스 더북한산            | 테라스, 대형           |
| 블루보틀                 | 모던, 통유리           |
| 교동떡갈비(전주 한옥마을)       | 한옥                |
| **뼈통감자탕**(한옥 서까래 느낌) | **(없음)** ← 의도한 결과 |

## LLM이 자꾸 없는 바다를 만들어냈다

뷰(전망)도 같은 방식으로 분류했습니다 — 오션뷰·한강뷰·강뷰·마운틴뷰·시티뷰·야경. 그런데 여기서 훨씬 고약한 문제를 만났습니다.

**LLM은 "전망이 좋다"는 잘 맞혔지만, 그게 무슨 전망인지를 자주 틀렸습니다.**

- 내륙 한복판의 **교회**를 오션뷰로 분류
- **한강변** 카페를 오션뷰로 분류
- **산자락** 카페를 오션뷰로 분류

바다가 없는 곳에 자꾸 바다를 만들어냈습니다. 그래서 프롬프트에 규칙을 추가했습니다.

> "뷰는 그 장소에서 **실제로 보이는 전망**만. 나들이·주변·다른 장소 이야기는 제외. **오션뷰 = 바다만**(호수·저수지·강은 강뷰)."

**그런데도 안 지켰습니다.** 규칙을 더 강하게 써도, 예시를 붙여도 마찬가지였습니다. 프롬프트로 못 고치는 종류의 오류였습니다.

그래서 접근을 바꿨습니다. **프롬프트로 안 되면 코드로 검증한다.**

분류기가 LLM 출력을 받은 뒤, 뷰 라벨에 대해서만 **근거를 직접 확인합니다.**

```
LLM이 '오션뷰'라고 답함
  ↓
근거 텍스트(상호 + 카테고리 + 태그 + 리뷰)에 '바다·오션·해변'이 실제로 있나?
  ├─ 있다 → 오션뷰 유지
  └─ 없다 → 그 라벨을 삭제
```

'한강'이 있으면 한강뷰로, '호수·저수지'가 있으면 강뷰로, '산뷰·숲뷰·계곡뷰'면 마운틴뷰로 — **근거 키워드와 라벨을 결정적으로 대조**하고, **근거가 없으면 그냥 지웁니다.** 재현율을 조금 잃더라도 정밀도를 택한 겁니다.

실데이터 검증 결과:

| 장소         | LLM 출력 | 교정 후     |
| ---------- | ------ | -------- |
| 광진교 8번가    | (뷰 있음) | 한강뷰 ✓    |
| **수원제일교회** | 오션뷰    | **삭제** ✓ |
| 더숲, 1인1잔   | (뷰 있음) | 마운틴뷰 ✓   |
| 고복저수지      | 오션뷰    | 강뷰 ✓     |
| 모이핀 오션점    | 오션뷰    | 오션뷰 유지 ✓ |

이 작업에서 얻은 원칙이 하나 있습니다. **LLM이 잘하는 일과 못하는 일을 나눠 맡겨라.** "이 장소에 전망이 있는가"는 문맥 판단이라 LLM이 잘합니다. "그 전망이 바다인가"는 **사실 확인**이라 코드가 잘합니다. 둘을 한 번에 시키니 실패했고, 나눠 맡기니 됐습니다.

## 입력을 잘못 주면 아무리 좋은 모델도 못 맞힌다

한동안 원인을 못 찾은 누락이 하나 있었습니다. **동락식당** — 사진만 봐도 명백한 한옥인데, 분류 결과가 계속 **빈 값**이었습니다.

프롬프트를 의심했고, 모델을 의심했습니다. 답은 **입력**에 있었습니다.

분류기에 넣는 텍스트(`searchText`)는 리뷰의 투표 키워드로 앞부분이 채워져 있었고, 정작 "기와지붕이 멋있다" 같은 **한옥 묘사는 입력 길이 제한 밖으로 밀려나 있었습니다.** 모델은 한옥이라는 단서를 **본 적이 없었습니다.**

그래서 입력에 별도로 추출해 둔 **태그**("한옥/기와집", "뷰")를 함께 넣었습니다. 동락식당은 곧바로 한옥으로 분류됐고, 뼈통감자탕은 여전히 빈 값이었습니다(정밀도 유지). **모델을 바꾸기 전에 모델이 뭘 보고 있는지부터 봐야 했습니다.**

## 말랑한 축은 참/거짓 대신 점수로

'한옥이다/아니다'는 이분법이 됩니다. 그런데 **"인스타 감성", "데이트하기 좋은", "조용한"** 은 안 됩니다. 정도의 문제니까요.

그래서 이 축은 **점수로 라벨링**합니다. 앞에서 만든 188개 정규 속성 목록을 통째로 LLM에게 메뉴판처럼 보여주고, 리뷰를 근거로 **해당하는 것만 0.5\~1.0으로 채점**하게 합니다. 실제 프롬프트는 짧습니다.

> 장소 리뷰를 읽고, 아래 특성 목록 중 리뷰 근거로 '**분명히 해당하는**' 것만 0.5\~1.0으로 채점한다. **근거 없는 특성은 출력하지 않는다**(생략). 점수 = 근거 강도(1.0 = 강하게 반복 언급, 0.5 = 약하게 시사). **추측 금지** — 리뷰에 단서가 있는 것만. 출력은 JSON 객체 하나만.

여기서도 방어가 두 겹입니다. 프롬프트로 "추측 금지"를 걸고, 파싱 단계에서 **목록에 없는 id와 0.5\~1.0 밖의 값을 버립니다.** LLM이 존재하지 않는 속성을 지어내도 코드가 걸러냅니다. 앞의 뷰 교정과 같은 철학입니다.

실제 결과는 이렇게 나옵니다.

- 산모퉁이 → `panoramic_viewpoint`, `rooftop_space`
- 예전한정식 → `korean_courtesy_meal`, `hanok_aesthetic`

그리고 검색 시점에 `concepts.<속성> ≥ 0.6`으로 자릅니다. **"인스타 감성 카페"라는 말랑한 문장이 딱딱한 부등식이 되는 지점**이고, 그 효과가 기술 4편의 P@8 29% → 100%였습니다.

> 여담: 이 채점은 원래 외부 파이썬 스크립트로만 돌렸는데, **그 스크립트가 유실되는 사고**가 있었습니다. 필드는 DB에 남아 있는데 새 장소를 채울 방법이 없어진 거죠. 결국 백엔드로 다시 포팅해 운영을 일원화했습니다. **핵심 파이프라인은 코드로 남겨라** — 값비싸게 배운 교훈입니다.

## "모름"과 "아님"을 구분하기

공장을 매일 돌리려면 **"이미 처리한 것"을 정확히 알아야 합니다.** 여기서 함정이 하나 있습니다.

파인다이닝 분류를 돌렸는데 어떤 식당이 파인다이닝이 아니라고 판정됐다고 합시다. 이 장소의 품질 라벨은 **비어 있습니다.** 그런데 라벨이 비어 있는 상태는 두 가지 뜻일 수 있습니다.

- **"평가했고, 아니었다"**
- **"아직 평가 안 했다"**

이 둘을 구분하지 않으면, 매일 밤 스케줄러가 "라벨이 없네? 평가해야지" 하며 **6천 곳 전부를 다시 LLM에 물어봅니다.** 결과는 매번 똑같고 돈만 나갑니다.

그래서 **"평가한 축"을 따로 기록**합니다. 라벨이 비어 있어도 평가 기록이 있으면 건너뜁니다. 같은 원리가 점수 채점에도 적용됩니다 — 리뷰가 없거나 LLM 호출이 실패해서 **판정 자체를 못 한 경우는 비워둬서 다음 실행에 다시 시도**하고, 정상 판정 결과가 **빈 목록인 경우는 빈 목록을 저장해 완료 표시**를 합니다. `null`과 `{}`가 다른 뜻입니다.

배치 파이프라인에서 이 구분은 생각보다 자주 필요합니다. **"결과 없음"과 "실행 안 함"을 같은 값으로 저장하는 순간, 재시도와 건너뛰기를 동시에 만족시킬 수 없게 됩니다.**

## 공장을 돌리는 법 — 색인 즉시와 매일 새벽

라벨을 붙이는 타이밍은 둘로 나뉩니다.

**① 색인 즉시** (장소가 들어오는 순간): 네이버 카테고리, 편의시설 태그, 메뉴 토큰, 속성, 임베딩, 썸네일. **LLM 판단이 필요 없거나 검색에 즉시 필요한 것들**입니다.

**② 매일 새벽 4시 30분** (크롤·정제가 끝난 뒤): 공간/뷰, 품질 등급, 188-속성 채점. **LLM이 필요한 것들**입니다. 세 분류를 순서대로 돌리고, 각각은 실패해도 다음 것이 이어서 돕니다.

배치는 전부 **증분**입니다.

> **증분(incremental) + 멱등(idempotent)**: 증분은 "아직 처리 안 한 것만" 처리한다는 뜻이고, 멱등은 "같은 배치를 몇 번 다시 돌려도 결과가 같다"는 뜻입니다. 배치가 중간에 죽어도 그냥 다시 실행하면 되니, 긴 배치를 운영할 때 사실상 필수입니다.

전체를 처음 한 번 채우는 백필은 순서와 비용이 있었습니다. **편의시설(약 3시간) → 공간/뷰 → 품질 → 속성 채점.** 가장 비쌌던 건 속성 채점 전량으로, **6,041곳에 약 $31**이었습니다. 기술 2편의 "비싼 건 실시간이 아니라 일회성 배치, 그리고 그 배치는 생각보다 싸다"가 여기서도 그대로였습니다.

## 병렬로 돌렸더니 오히려 느려졌다

백필을 빨리 끝내려고 당연히 병렬화를 시도했습니다. 4개 동시 요청. 결과는 예상 밖이었습니다.

| 방식       | 처리량       |
| -------- | --------- |
| 순차 (1개씩) | **40건/분** |
| 4병렬      | 26건/분     |

**병렬이 35% 더 느렸습니다.**

원인은 당시 분류에 쓰던 로컬 LLM이 **GPU 한 장짜리 Ollama**였기 때문입니다. 동시 요청이 와도 실제로는 GPU에서 **직렬로 처리**되고, 대신 요청을 붙들어 두는 오버헤드와 컨텍스트 전환 비용만 추가됩니다. **병렬화는 뒤에 여러 일꾼이 있을 때만 이득**인데, 일꾼이 하나면 줄만 복잡해지는 거죠.

병렬화를 걷어내고 순차로 돌렸습니다. 6,365건에 **2.5\~3시간**. (같은 이유로 클라우드 모델을 쓰는 지금의 채점 서비스는 4개 동시 처리를 씁니다 — 저쪽은 일꾼이 여럿이니까요.)

## 새 축을 추가하는 비용

품질 등급 축을 하나 더 넣고 싶을 때, **분류기 클래스 하나**만 추가하면 됩니다. 이름·후보 조건·판정 로직 세 가지만 구현하면 서비스가 자동으로 수집해서 백필·매일 스케줄·신규 장소 처리에 전부 편입시킵니다. 스케줄러 코드는 손대지 않습니다. (기술 4편에서 짧게 언급한 그 레지스트리 패턴입니다.)

**"새 검색 기능 하나 = 코드 한 조각"** 이 되도록 만드는 게 목표였습니다. 축을 늘릴 때마다 배포 절차가 늘어나면, 결국 축을 안 늘리게 되니까요.

## 애매한 건 사람에게 — 검토 큐

자동화의 목표는 사람을 없애는 게 아니라 **사람이 애매한 것만 보게 하는 것**이었습니다.

영상에서 장소를 추출해 네이버와 매칭하는 과정에서(기술 10편) 확신이 안 서는 후보가 쌓입니다. 한때 **1,793건**이 밀려 있었습니다. 사람이 전수 검토하기엔 너무 많았고, 그렇다고 자동 승인하기엔 위험했습니다.

그래서 **자동 심사를 3단계로** 나눴습니다.

1. **네이버에서 아예 못 찾은 것(804건)** — 재검색까지 해도 0건이면 좌표도 주소도 없어 지도에 올릴 방법이 없습니다. **LLM을 부르지 않고 규칙으로 반려**합니다. 검토할 실익이 없는 건 사람에게도 안 보냅니다.
2. **찾긴 했는데 확신이 없는 것(989건)** — 여기만 LLM에게 물어봅니다. 추출된 정보(이름·지역·영상 발췌)와 네이버 결과(상호·업종·주소)를 나란히 보여주고 **한 단어로 답하게** 합니다: `SAME` / `DIFFERENT` / `NOT_A_PLACE` / `UNSURE`.
3. **`UNSURE`와 LLM 실패는 사람에게 남깁니다.** 이게 핵심입니다. LLM에게 "모르겠다"고 말할 선택지를 명시적으로 주면, **모를 때 찍는 대신 모른다고 합니다.** 보수적 기본값이죠.

989건을 처리하는 데 **약 20분, 수십 센트**였습니다. 그리고 반려할 때는 사유를 `... (was: 원래 사유)` 형태로 **원래 사유를 보존**합니다. 나중에 "이거 왜 반려됐지"를 되짚거나 복구할 수 있게요.

관리자가 실제로 하는 일은 이제 남은 애매한 것들입니다 — 같은 이름의 다른 가게 고르기, 폐업 처리, 그리고 추출이 **"샤퀴테리" 같은 업종어를 상호로 오인**한 걸 바로잡기. 고치면 즉시 재색인됩니다.

## 남은 한계

- **재현율은 결국 리뷰가 뭘 말했느냐에 달려 있습니다.** 여수 돌산의 스타벅스는 명백한 오션뷰인데, 리뷰에 바다 이야기가 충분히 없어 라벨이 안 붙었습니다. 근거 없으면 지운다는 규칙의 대가입니다 — 정밀도를 택한 대신 이런 누락을 받아들였습니다. (이 누락을 나중에 메운 게 기술 5편의 청크 임베딩입니다.)
- **드문 오분류는 남아 있습니다.** 제물포구락부가 '하와이안'으로 분류된 적이 있습니다. 목록 가지치기와 프롬프트 보강으로 줄여가는 중입니다.
- **닫힌 집합의 근본 한계** — 33종 목록에 없는 공간 유형은 영영 못 잡습니다. 목록을 늘리면 잡히지만, 목록이 길어질수록 LLM의 선택 정확도가 떨어집니다. 이 균형점은 아직 감으로 잡고 있습니다.

## 이 글의 결론

- 검색 품질 문제의 상당수는 **알고리즘이 아니라 라벨 부재**였습니다. 전라도 식당 187곳 중 한옥 태그가 1곳이던 상태에선 어떤 게이트도 못 겁니다.
- 자유롭게 뽑은 **8,070개 라벨은 라벨이 아닙니다**('주차' 2,743 vs '주차장' 1,157). **닫힌 집합 188개**로 정규화해야 검색어와 만납니다.
- **LLM에게 잘하는 것만 시킵니다.** "전망이 있는가"는 LLM, "그 전망이 바다인가"는 코드. 프롬프트로 세 번 실패한 걸 근거 대조 코드가 한 번에 고쳤습니다.
- 모델을 의심하기 전에 **입력을 확인합니다.** 동락식당이 한옥으로 안 잡힌 건 모델 탓이 아니라 단서가 입력에 안 들어가서였습니다.
- 배치는 **"모름"과 "아님"을 구분**해야 합니다. 안 그러면 매일 밤 6천 곳을 다시 물어봅니다.
- **병렬화가 항상 빠른 건 아닙니다** — GPU 한 장 뒤에서는 순차가 35% 빨랐습니다.
- 자동화의 목표는 **사람을 없애는 게 아니라 애매한 것만 남기는 것**. 1,793건 중 사람이 볼 것만 남겼습니다.

다음 글(기술 7)에서는 이 모든 개선을 **무엇으로 믿었나** — 사람 라벨 정답셋(gold)과 "정답이 어느 단계에서 탈락했는지" 추적하는 recall 귀인 — 을 봅니다.

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