한 줄로
사람들은 “어느 식당에 갈까”보다 “오늘 뭐 먹지”로 먼저 고민합니다. Locals Only는 지금 있는 곳 근처에서 먹을 수 있는 음식을 먼저 보여주고, 음식을 고르면 그 음식을 잘하는 로컬 가게로 좁혀 줍니다. 앱, 187만 개 메뉴 이름을 정리하는 데이터 파이프라인, 파이프라인이 틀린 곳을 사람이 고치는 어드민까지 혼자 만들어 운영하고 있습니다.
왜 음식부터인가
이전 버전은 가게 랭킹이 중심이었습니다. 리뷰 신뢰도로 점수를 매기고, 결과를 가게 목록으로 보여줬습니다. 랭킹은 쓸 만했지만 쓰는 흐름에서 막혔습니다.
- 가게 이름만으로는 고를 수 없습니다. 특히 여행자에게 “한일관”, “서울순대국” 같은 이름은 로마자로 봐도 무슨 집인지 알 수 없습니다. 사람은 가게보다 음식으로 고릅니다.
- 점수 숫자는 잘못 읽힙니다. 아래 로컬 픽 배지에서 설명하듯, 점수는 좁은 구간에 몰려 있어 그대로 보여주면 오해를 삽니다.
- 모드가 두 개로 갈라져 있었습니다. 내 동네에서 쓰는 모드와 여행지에서 쓰는 모드가 따로 있었는데, 결국 둘 다 “여기서 뭐 먹지”라는 같은 질문이었습니다.
그래서 탐색 순서를 음식 → 가게로 뒤집고 두 모드를 한 흐름으로 합쳤습니다. 랭킹은 버리지 않고 보이지 않는 곳으로 옮겼습니다. 어떤 음식을 타일로 올릴지, 그 음식을 파는 가게를 어떤 순서로 보여줄지, 어느 가게에 배지를 줄지를 정하는 데 씁니다.
서비스 흐름
- 위치 고르기 — 지도에서 핀을 옮기거나, 지역·동·랜드마크를 검색하거나, 서비스 권역 칩(서울, 제주, 강원 동해 등)을 누릅니다. 마지막 위치는 기기에 저장하고, 로그인하면 계정에도 저장해 다른 기기에서 이어집니다.
- 음식 타일(홈) — 그 위치 근처 잘하는 가게들이 많이 파는 음식을 12장씩 보여줍니다. 다시 뽑기를 누르면 아직 보여주지 않은 음식이 나오고, 남은 음식이 부족하면 반경을 1km에서 2km, 3km로 넓힙니다.
- 가게 목록 · 지도 — 고른 음식을 파는 근처 가게를 목록과 지도로 보여줍니다. 한국어가 아닐 때는 바로 넘어가지 않고 음식 설명을 먼저 보여줍니다.
- 가게 상세 — 영업시간, 로컬 픽 배지를 보여주고 카카오맵·네이버지도·구글맵으로 연결합니다. 한국에서는 구글맵의 도보 길찾기가 잘 안 되기 때문에 국내 지도 앱 연결은 여행자에게 특히 중요합니다.
- 인기 픽 · 즐겨찾기 · 팔로우 — 운영자가 고른 인기 픽 모음이 있습니다. 즐겨찾기는 그룹 단위로 저장하고 그룹마다 공개 여부를 정합니다. 팔로우한 사람의 공개 즐겨찾기는 따로 모아 볼 수 있습니다.
앱 전체는 390×844 모바일 프레임 안에서 돌고 한국어·영어·일본어를 지원합니다. 로그인은 카카오·구글·애플 소셜 로그인을 씁니다.
1. 메뉴 파이프라인 — 187만 개의 메뉴 이름을 대표메뉴 320개로
음식에서 시작하려면 “원조 충무김밥”, “충무김밥(2인)”, “충무 김밥”이 같은 음식이라는 걸 알아야 합니다. 이 작업이 Locals Only의 가장 큰 기반 작업입니다.
- 규모 — 가게 146,858곳, 메뉴 행 1,869,603개. 정규화한 뒤에도 고유 메뉴 이름이 263,724개이고, 그중 72.4%는 가게 한 곳에만 나옵니다.
- 음식이 아닌 항목 걸러내기 — 음료·주류·디저트·사이드·요금을 사전 기반으로 분류합니다(test 정확도 97.9%, 비음식 precision 100%).
- 정규화는 모자라게 합니다 — 수식어 “생”을 문자열 어디서나 지우면
초밥→밥,수제비→비,생선구이→선구이가 됩니다. 정규화가 모자라면 사전 항목이 늘어날 뿐이지만, 지나치면 서로 다른 음식이 합쳐집니다. 그래서 “생 삼겹살”의 “생”은 지우고 “생삼겹살”은 그대로 둡니다. 둘은 별칭 사전이 이어 줍니다. - 규칙과 LLM을 섞은 계층 라우터 — 740건 gold set으로 모든 변경을 판정했습니다. 규칙만으로는 51.6%에서 68.1%까지 올렸습니다. 로컬 LLM(Qwen, llama.cpp)을 섞어 73.6%가 됐고, dev와 test 점수가 같게 나왔습니다. LLM은 모든 구간에서 원래 이름을 그대로 두려는 편향이 있었습니다. 그 편향이 정답 분포와 맞는 빈도 200~1,000위 구간에만 LLM을 쓰고, 나머지는 규칙을 씁니다.
- 실측으로 버린 가정들 — “후보는 넓게 주고 판단은 판정기에 맡기면 된다”며 접두어 후보를 넣었더니
참치마요김밥→참치마요처럼 판정기가 미끼를 물었습니다. 접미어 후보만 남기자 dev 정확도가 66.1%에서 70.9%로 올랐습니다. 한국어는 핵심 명사가 끝에 오는 언어라는 점이 그대로 드러났습니다. - 300개로 시작해 어드민으로 키웠습니다 — 대표메뉴 후보는 2,140개로 모였습니다. “구이·전골·세트”처럼 조리법만 남은 이름은 눌러도 볼 게 없어서 뺐고, 첫 발행은 300개로 시작했습니다. 이후 어드민에서 원본 메뉴를 보고 58개를 새로 만들고, 겹치는 31개는 통합하고, 애매한 7개는 숨기거나 지웠습니다. 지금 앱에 노출 중인 대표메뉴는 320개입니다(전체 358개).
- 연산은 로컬, Supabase에는 결과만 — 정규화 결과, 점수, 후보 큐, gold set 같은 중간 산출물은 DuckDB와 Parquet로 로컬에 둡니다. Supabase에는 앱이 실제로 읽는 테이블만 올립니다.
2. 음식 타일은 위치마다 새로 계산한다
홈 타일은 전국에 하나로 고정된 목록이 아닙니다. 위치를 받을 때마다 서버 RPC가 새로 계산합니다. 처음 떠올린 방식은 반경 안 모든 가게의 인기도를 더하는 것이었는데, 그렇게 하면 어디서나 똑같은 화면이 나왔습니다.
120개 위치 · 반경 1km · 위치당 top 20
A 인기도 합 카탈로그 142개 · 타일 하나가 평균 15.9개 위치에 등장 ← 어디서나 치킨·피자·버거
B 인기 상위만 카탈로그 292개 · 평균 7.7개 위치 ← 채택
C 지역 특이도 카탈로그 928개 · 평균 2.4개 위치 ← 노이즈가 섞임
A에서는 점수가 아니라 가게 수가 이겼습니다. 프랜차이즈가 어디에나 있기 때문입니다. 인기 상위 가게로 먼저 좁힌 뒤 음식을 세자 압구정동에서는 치즈버거·오마카세·베이글, 서귀동에서는 오겹살·갈치조림·고등어구이처럼 동네마다 다른 타일이 나왔습니다. 운영하면서 몇 가지를 더 다듬었습니다.
- 상위 가게는 카테고리 안에서 고릅니다 — 반경 전체에서 한 줄로 상위 가게를 자르면 리뷰가 많이 쌓이는 고깃집·국밥집이 윗자리를 차지하고, 작은 카테고리는 잘려서 사라집니다. 그래서 중분류마다 상위 5%를 따로 뽑습니다.
- 주메뉴만 셉니다 — 가게당 메뉴가 중앙값 10개라 사이드 메뉴가 대표 메뉴와 같은 무게로 세어졌습니다. 그 가게의 주메뉴 상위 5개만 셉니다.
- 카테고리당 상한 — 서귀동에서는 타일 30칸 중 23칸이 두 카테고리였습니다. 카테고리마다 1등씩 돌아가며 뽑는 방식도 시도했는데, 가게 한 곳뿐인 음식이 1등으로 올라와 되돌렸습니다. 지금은 인기 순서를 그대로 두고 넘치는 것만 덜어내는 상한(카테고리당 3개)을 둡니다.
- 간식·카페는 뒤로 — 크로플·플랫화이트가 삼겹살 사이에 섞여 나왔습니다. 닭강정·토스트·떡처럼 로컬이 실제로 가는 간식도 있어서 빼지는 않고, 정렬만 뒤로 보냅니다. 첫 12장에는 식사 메뉴가 나오고, 다시 뽑기를 누르다 보면 “간식 · 카페” 구분선이 나옵니다.
- 대분류가 빈 가게 추론 — 가게의 19%는 대분류가 비어 있어 카페 음식이 식사로 분류됐습니다. 같은 음식을 파는 다른 가게들의 대분류 분포를 합쳐 가장 많은 대분류를 붙였습니다. 자기 자신을 빼고 검증했을 때 확신도 0.6 이상에서 약 93%가 맞아 그 이상만 채웠습니다. 원본은 건드리지 않고 별도 테이블에 확신도와 함께 둡니다.
3. 로컬 픽 배지 — 랭킹 점수를 숫자 대신 배지로 보여준다
랭킹은 학습 모델이 아니라 해석 가능한 Bayesian 점수입니다. 리뷰어가 그 리뷰를 쓸 당시 평소 주던 별점보다 얼마나 높게 줬는지(편차 40%), 리뷰어 등급, 최신성, 신뢰·반응을 점수로 만들고, 리뷰가 적은 가게는 같은 카테고리 평균 쪽으로 보정합니다.
실데이터(리뷰가 있는 112,216곳)에서 이 점수는 p10=26 · p50=31 · p90=34로 좁게 몰려 있었습니다. “32점”을 그대로 보여주면 사실 상위권인데 100점 만점에 낙제로 읽힙니다. 그래서 숫자는 보여주지 않고 상위 5% 안에 드는지만 배지로 보여줍니다.
- 전국 기준 하나로 자르면 서울 배지가 됩니다. 서울은 1,408곳이 통과하는데 제주는 1곳, 강원은 0곳이었습니다. 강원은 최고점도 전국 기준선보다 낮았습니다.
- 그래서 기준선을 권역 × 대분류 셀마다 따로 잡았습니다. 표본이 100곳이 안 되는 셀은 권역 기준선, 그다음 전국 기준선으로 차례로 넘깁니다. 리뷰 20개 이상인 27,608곳 중 97.3%가 셀 기준선을 받습니다.
- 가게 목록도 점수만으로 정렬하지 않습니다. 리뷰가 한두 개뿐인 가게가 우연히 위로 올라오지 않도록, 리뷰 수 기준(서울 20개, 그 외 15개)을 넘는지로 먼저 나누고 그 안에서 점수순으로 정렬합니다.
4. 큐레이션 어드민 — 파이프라인이 틀린 곳을 사람이 고친다
매핑 정확도 73.6%는 26%는 사람이 봐야 한다는 뜻입니다. 그래서 운영자가 데이터를 직접 고치는 어드민(/admin)을 만들었습니다.
- 대표메뉴 관리 — 이름·설명 편집, 후보 갤러리에서 대표 이미지 고르기, 노출 중지·통합·삭제
- 소속 메뉴 정리 — 대표메뉴에 묶인 원본 메뉴를 이름별로 모아 연결이 많은 순으로 30개씩 검토합니다. 여러 이름을 체크해 한 번에 다른 대표메뉴로 옮기거나 새 대표메뉴로 올릴 수 있습니다. 부분 검색은 한글 NFC/NFD를 함께 처리하고, 한 번에 최대 1,000개 이름까지 처리합니다.
- 원본 메뉴 탐색 — 전체 원본 메뉴에서 아직 대표메뉴가 없는 음식을 찾아 새로 등록합니다.
- 가게별 조정 — 가게마다 대표메뉴 개수·순서·제외·수동 추가를 정합니다.
- 인기 픽 큐레이션 — 픽 항목을 편집하고, 메뉴로 가게를 찾아 넣고, 노출 순서를 정합니다.
어드민에서 가장 신경 쓴 원칙은 “사람이 고친 것은 재발행이 덮지 않는다”입니다.
- 파이프라인을 다시 돌려 테이블을 통째로 다시 올려도(TRUNCATE 후 COPY) 수동 큐레이션이 자동으로 다시 적용됩니다. 잠금 플래그, 별도 override 테이블, 트리거로 처리합니다.
- 일괄 변경은 적용 전에 실제로 바뀌는 수를 미리 보여주고, 그사이 데이터가 바뀌면 다시 확인을 받습니다. 100개가 넘는 묶음도 한 트랜잭션으로 처리합니다.
- 삭제는 상태만 바꾸는 soft delete입니다. 숨김·삭제·통합된 항목은 restrictive RLS로 공개 API에서 빠집니다. 쓰기는 서버 라우트에서만 service role 키로 해서 브라우저에 키가 노출되지 않습니다.
- 빈 임시 DB에서 도는 SQL 테스트로 페이지와 무관한 집계, 일부만 바뀌는 일 방지, 동시 변경 감지, 재발행 후 보존을 검증합니다.
4년짜리 반복
2022년 “평균 평점 3.5 이상”이라는 내 맛집 찾기 습관을 규칙으로 옮긴 kakaoRok, “유의미하게 만족한 리뷰어 수”로 목록을 추린 Streamlit 기반 What2Eat(머먹), 그리고 리뷰 신뢰도 랭킹을 뒤로 숨기고 음식에서 시작하는 Locals Only까지 이어졌습니다. “좋은 식당이란 무엇인가”라는 질문은 그대로이고, 답을 어떻게 보여줄 것인가가 바뀌어 왔습니다.
한계와 다음 단계
- “뭘 시킬까 · 어떻게 시킬까”는 아직 꺼져 있습니다. 가게 상세에서 메뉴를 골라 직원에게 보여줄 한국어 주문 문장을 만드는 기능인데, 가게별 메뉴 데이터 품질이 들쭉날쭉해 숨겨 두었습니다. 여행자에게 가장 기대하는 기능이라 메뉴 데이터 정리가 다음 과제입니다.
- 대표메뉴 매핑은 73.6%이고 나머지는 어드민 큐레이션에 기대고 있습니다. gold set precision 0.99 이상을 지키는 임계값으로 별칭 사전을 자동 승격하는 것이 다음 단계입니다.
- 랭킹은 개인화가 아닙니다. 누구에게나 믿을 만한 가게를 고르는 랭킹이고, 개인화는 쩝쩝LAB 리서치의 추천 레이어를 얹는 다음 단계입니다.
- 사용자 행동(클릭·즐겨찾기)으로 타일과 랭킹을 검증하는 온라인 지표는 아직 없습니다.
비상업적 개인 연구·학습 프로젝트입니다. 지도 서비스의 공개 리뷰 데이터를 연구 목적으로 수집해 사용하며, 수익화 계획은 없습니다.