[feat] solution/backend: 상호명 공개 검색 + 업종 자동 판별 — 랜딩이 로그인 앞에서 부른다

랜딩 첫 화면이 상호명을 받으려면 검색이 로그인 앞에 있어야 하는데, 후보 조회는
place_id 와 토큰을 둘 다 요구했다. 로그인 관문을 에디터 진입 하나로 되돌려 놓고도
(b94daa9) API 는 그대로였다.

업종은 AI 를 한 번 더 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가 이미
들어 있고(category_group_code / category_name), 지금까지 받아 놓고 안 썼다.

- place.py: GET /v1/place/search 신설(인증 없음). ★ /{place_id} 앞에 둬야 한다 —
  뒤에 두면 "search" 가 place_id 로 잡혀 422 다
- place_category: AD5·CE7·FD6 우선, 없으면 분류 문자열. 못 정하면 None —
  억지로 고르면 틀린 스키마로 시작한다. HP8 은 피부과·성형외과일 때만
- kakao: KakaoPlace 에 category_group_code. 한글 분류는 바뀌어도 코드는 안 바뀐다
- rate_limit: 인증 없이 유료 API 를 부르는 경로라 IP 당 분당 20회(프로세스 메모리)
- 확정 경로(verify/candidates)는 인증 유지 — 남의 place_id 존재 여부를 열지 않는다

전체 562 passed
This commit is contained in:
Mina Choi 2026-09-03 09:41:34 +09:00
parent e8cda02a4b
commit 2026fde80f
9 changed files with 377 additions and 2 deletions

View File

@ -5,6 +5,28 @@
---
## 2026-09-03 — 상호명 검색을 로그인 앞으로 · 업종은 LLM 없이 정한다
**왜**
랜딩 첫 화면에서 상호명을 치게 하려면 검색이 로그인 앞에 있어야 하는데,
후보 조회는 `place_id` 와 토큰을 둘 다 요구했다(`place.py` 확정 경로). 로그인 관문을
에디터 진입 하나로 되돌려 놓고도 API 는 그대로였다.
그리고 업종은 사장님에게 고르게 하고 있었는데 — 경계(베이커리 카페, 브런치집)에서 멈춘다.
**한 일**
- `GET /v1/place/search` 신설(인증 없음). 사업장을 만들지도, 우리 DB 를 읽지도 않는다.
확정 경로(`/{place_id}/verify/candidates`)는 인증을 그대로 둔다 — 남의 place_id 존재
여부까지 열 이유가 없다
- `place_category.guess_category()`: 카카오 `category_group_code`(AD5·CE7·FD6) 우선,
없으면 분류 문자열. **LLM 호출 0건** — 상호명 검색 응답에 이미 들어 있던 값이다
- 못 정하면 `None`. 억지로 고르지 않는다 — 업종은 수집 스키마와 JSON-LD 타입을 통째로
정해서 틀리면 되돌리는 비용이 크다. HP8(병원)은 피부과·성형외과일 때만 받는다
- `rate_limit`: 인증 없이 유료 외부 API 를 부르는 경로라 IP 당 분당 20회.
프로세스 메모리라 완전하지 않다(앞단 nginx 가 제대로 된 자리)
**검증** — 전체 562 passed. 공개 응답에 place_id·전화·좌표가 안 나가는 것,
검색만으로 사업장이 생기지 않는 것을 테스트로 고정.
## 2026-09-03 — 발행하면 썸네일이 남는다 (랜딩 쇼케이스용)
**왜**

View File

@ -0,0 +1,34 @@
"""IP 단위 호출 제한 — 프로세스 메모리에만 있는 아주 단순한 것.
한계를 먼저 적는다. 프로세스가 여럿이면 한도가 수만큼 곱해지고, 재시작하면 리셋된다.
제대로 하려면 앞단(nginx `limit_req`)이나 공유 저장소가 필요하다.
그런데도 두는 이유: 이걸 쓰는 곳이 **인증 없이 유료 외부 API 부르는 경로**.
방어가 0 이면 새로고침을 누르고 있는 것만으로 요금이 나간다
(카카오 키워드 검색은 무료 한도를 넘기면 건당 2 services/external/kakao.py 주석).
"""
import time
from collections import defaultdict, deque
_hits: dict[str, deque] = defaultdict(deque)
def allow(key: str, limit: int, window_sec: float) -> bool:
"""key 가 window_sec 안에서 limit 번을 넘지 않았으면 True(그리고 1회로 센다)."""
now = time.monotonic()
bucket = _hits[key]
while bucket and now - bucket[0] > window_sec:
bucket.popleft()
if len(bucket) >= limit:
return False
bucket.append(now)
# 안 쓰는 키가 쌓이는 걸 막는다. 호출이 뜸하면 자연히 비워진다.
if not bucket:
_hits.pop(key, None)
return True
def reset():
"""테스트 전용 — 프로세스 상태를 비운다."""
_hits.clear()

View File

@ -1,7 +1,7 @@
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Request
from common.enums import PlaceCategory, PlaceStatus
from common.models.gmodel import PageParams, UserInfo
@ -22,6 +22,7 @@ from .protocol import (
Res_LinkList,
Res_Place,
Res_PlaceList,
Res_PlaceSearch,
Res_StartCollect,
Res_StartCopy,
Res_StartVision,
@ -46,6 +47,26 @@ async def list_places(
return RemoveNoneResponse(await service.list_places(user_info, pg, search, category, status))
@router.get(
path="/search",
response_model=Res_PlaceSearch,
summary="상호명 공개 검색",
description="상호명으로 외부 장소 DB(카카오 → 없으면 네이버)를 찾아 후보를 그대로 돌려준다. "
"★ 인증이 없다 — 랜딩 첫 화면이 부른다. 사업장을 만들지도, 우리 DB 를 읽지도 않는다. "
"★ 응답의 category 는 외부 분류에서 **추정한 기본값**이다. None 이면 못 정한 것이고, "
"값이 있어도 확정이 아니다 — 화면은 언제나 바꿀 수 있게 둔다.",
)
async def search_places_public(
request: Request,
service: PlaceService = Depends(),
q: str = Query(..., min_length=2, max_length=100, description="상호명"),
):
# ★ 이 라우트는 반드시 `/{place_id}` **앞에** 있어야 한다. FastAPI 는 등록 순서로 매칭해서,
# 뒤에 두면 "search" 가 place_id 로 잡혀 422 가 난다 — 조용히 틀리는 종류다.
client_ip = request.client.host if request.client else "unknown"
return RemoveNoneResponse(await service.search_places_public(q, client_ip))
@router.post(
path="",
response_model=Res_Place,

View File

@ -208,6 +208,33 @@ class Res_VerifyCandidates(Res_WebPacketProtocol):
candidates: list[PlaceCandidate] = []
# ---- 공개 상호명 검색 (랜딩 첫 화면) ----------------------------------------
class PlaceSearchItem(WebPacketProtocol):
"""공개 검색 결과 1건.
외부 장소 DB 공개적으로 주는 값만 담는다. 우리 DB (place_id·company_id·소유자)
하나도 나가지 않는다 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
확정과 수집은 로그인 기존 경로(POST /place verify) 그대로 한다."""
name: str = ""
road_address: Optional[str] = None
category_name: Optional[str] = None # 외부 DB 의 분류 문자열(예: "숙박>펜션")
# 추정 업종. ★ None 이면 못 정한 것이다 — 화면이 사장님에게 직접 고르게 한다.
# 값이 있어도 확정이 아니다. 화면은 언제나 바꿀 수 있게 둔다(경계 업종이 실제로 있다).
category: Optional[PlaceCategory] = None
class Res_PlaceSearch(Res_WebPacketProtocol):
"""상호명 공개 검색 결과.
인증이 없다. 랜딩 화면에서 상호명을 치면 바로 부른다
만들어 보기도 전에 로그인을 요구하지 않기로 결정(로그인 관문은 에디터 진입 하나) 연장이다."""
source: Optional[ExternalPlaceSource] = None
items: list[PlaceSearchItem] = []
class Req_StartCopy(PlaceProtocol):
"""소개문·FAQ 생성 시작.

View File

@ -97,6 +97,9 @@ class KakaoPlace:
latitude: Optional[float] # y
longitude: Optional[float] # x
category_name: Optional[str]
# ★ 업종 자동 판별의 입력. 한글 분류(category_name)는 카카오가 언제든 바꾸지만 이 코드는 안 바뀐다 —
# AD5 숙박 · CE7 카페 · FD6 음식점 · HP8 병원. 매핑은 services/place_category.py 가 소유한다.
category_group_code: Optional[str]
place_url: Optional[str]
@classmethod
@ -110,6 +113,7 @@ class KakaoPlace:
latitude=_to_float(doc.get("y")), # ★ y = 위도
longitude=_to_float(doc.get("x")), # ★ x = 경도
category_name=(doc.get("category_name") or None),
category_group_code=(doc.get("category_group_code") or None),
place_url=(doc.get("place_url") or None),
)

View File

@ -0,0 +1,70 @@
"""외부 장소 DB 의 분류 → 우리 업종(PlaceCategory).
업종 판별에 LLM 부르지 않는다. 상호명으로 카카오·네이버를 부르는 어차피 하는 일이고,
응답에 분류가 함께 온다(`category_name`, 카카오는 `category_group_code` 까지).
지금까지 받아 놓고 쓰지 않았을 뿐이다 여기서 값을 업종으로 옮긴다.
정하면 None 이다. 억지로 하나를 고르지 않는다. 업종은 수집 스키마와 JSON-LD 타입을
통째로 정하는 값이라, 틀린 업종으로 시작하면 되돌리는 비용이 크다.
None 이면 화면이 사장님에게 직접 묻는다.
"""
from typing import Optional
from common.enums import PlaceCategory
# 카카오 category_group_code. 한글 분류 문자열은 카카오가 언제든 바꾸지만 이 코드는 안 바뀐다.
# ★ HP8(병원)은 여기 없다 — 병원 전체가 아니라 피부과·성형외과만 열려 있어서,
# 코드만으로는 우리 업종인지 알 수 없다. 아래 키워드로 한 번 더 좁힌다.
_BY_GROUP_CODE = {
"AD5": PlaceCategory.LODGING,
"CE7": PlaceCategory.CAFE,
"FD6": PlaceCategory.RESTAURANT,
}
_HOSPITAL_GROUP_CODE = "HP8"
# 분류 문자열 키워드. 네이버는 group_code 를 주지 않아 이 경로만 탄다.
# ★ 순서가 결과를 바꾼다. 카카오·네이버 모두 카페를 "음식점 > 카페" 아래 두기 때문에
# CAFE 를 RESTAURANT 보다 먼저 봐야 한다. 뒤집으면 카페가 전부 음식점이 된다.
_KEYWORDS: list[tuple[PlaceCategory, tuple[str, ...]]] = [
# ★ "피부"·"성형" 이 아니라 "피부과"·"성형외과" 다. 앞의 둘로 보면 피부관리실(에스테틱)이
# 병원으로 걸린다 — 의료 광고 규제가 걸리는 업종이라 잘못 붙이면 가장 비싸다.
(PlaceCategory.CLINIC, ("피부과", "성형외과")),
(PlaceCategory.LODGING, ("숙박", "펜션", "호텔", "모텔", "리조트", "게스트하우스", "민박")),
(PlaceCategory.CAFE, ("카페", "커피", "베이커리", "제과", "디저트")),
(PlaceCategory.RESTAURANT, ("음식점", "한식", "일식", "중식", "양식", "분식",
"주점", "술집", "치킨", "횟집", "뷔페", "패스트푸드")),
]
def guess_category(
category_name: Optional[str], group_code: Optional[str] = None
) -> Optional[PlaceCategory]:
"""분류 문자열(+ 카카오 그룹코드)로 업종을 추정한다. 모르면 None.
category_name 카카오 "가정,생활 > 숙박 > 펜션" · 네이버 "숙박>펜션"
"""
text = (category_name or "").replace(" ", "")
code = (group_code or "").strip().upper()
if code == _HOSPITAL_GROUP_CODE:
# 병원이라는 것까지만 안다. 진료과가 문자열에 없으면 우리 업종인지 알 수 없다.
return PlaceCategory.CLINIC if _match(text) is PlaceCategory.CLINIC else None
if code in _BY_GROUP_CODE:
by_code = _BY_GROUP_CODE[code]
# ★ 그룹코드를 문자열로 덮는 경우가 하나 있다: 카카오가 베이커리·브런치 가게를
# FD6(음식점)으로 주면서 분류 문자열엔 "카페"를 다는 일이 있다. 어느 쪽이 맞는지는
# 실측하지 않았다 — 사장님이 바꿀 수 있으니 이름이 더 구체적인 쪽을 기본값으로 둔다.
if by_code is PlaceCategory.RESTAURANT and _match(text) is PlaceCategory.CAFE:
return PlaceCategory.CAFE
return by_code
return _match(text)
def _match(text: str) -> Optional[PlaceCategory]:
for category, words in _KEYWORDS:
if any(word in text for word in words):
return category
return None

View File

@ -28,8 +28,10 @@ from router.v1.place.protocol import (
Res_LinkList,
Res_Place,
Res_PlaceList,
Res_PlaceSearch,
Res_VerifyCandidates,
PlaceCandidate,
PlaceSearchItem,
Res_StartCollect,
Res_StartCopy,
Res_StartVision,
@ -37,6 +39,12 @@ from router.v1.place.protocol import (
Res_UnitList,
)
from router.v1.job.protocol import JobData, Res_Job
# 공개 검색이 한 번에 가져오는 후보 수. 카카오 키워드 검색은 무료 한도를 넘기면 건당 과금이라
# 확정 경로(15건)보다 좁게 잡는다 — 랜딩에서 사람이 훑는 목록은 다섯이면 충분하다.
_PUBLIC_SEARCH_SIZE = 5
# IP 당 분당 허용 횟수. 사람이 상호명을 고쳐 가며 치는 속도를 넘지 않게 잡았다.
_PUBLIC_SEARCH_PER_MIN = 20
# 도로명주소 → 지역 캐시 키. 외부 장소 DB 는 행정구역 코드를 주지 않으므로 여기서 만든다.
from services.external.naver import region_key
from services.job_service import enqueue_job
@ -582,6 +590,74 @@ class PlaceService:
return res
# ---- 동일 업소 후보 조회 (UI 가 사람에게 고르게 한다) ----
# ---- 공개 상호명 검색 (랜딩 첫 화면) ----
async def search_places_public(self, query: str, client_ip: str) -> Res_PlaceSearch:
"""상호명으로 외부 장소 DB 를 찾아 그대로 돌려준다. **인증도, 사업장 행도 없다.**
find_candidates 무엇이 다른가: 저쪽은 이미 만들어진 사업장의 신원을 확정하는
경로라 place_id 로그인이 필요하다. 여기는 아직 아무것도 만들지 않은 사람이
"내 가게가 있나" 보는 경로다 만들어 보기도 전에 로그인을 요구하지 않기로
결정(로그인 관문은 에디터 진입 하나) API 까지 내려온 것이다.
pick_match 돌리지 않는다. 자동 판정은 확정 경로에서만 의미가 있고,
여기서는 사람이 목록에서 고르는 전부다.
DB 읽지도 쓰지도 않는다. 나가는 값은 외부 장소 DB 공개적으로 주는 것뿐이다.
"""
from common.enums import ExternalPlaceSource
from common.utils import rate_limit
from services.external import kakao as kakao_client
from services.external import naver as naver_client
from services.place_category import guess_category
res = Res_PlaceSearch()
q = (query or "").strip()
if len(q) < 2:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
# ★ 인증이 없는데 유료 외부 API 를 부른다 — 방어가 0 이면 새로고침만으로 요금이 나간다.
# 프로세스 메모리 기반이라 완전하지 않다(common/utils/rate_limit.py 주석).
if not rate_limit.allow(f"place-search:{client_ip}", _PUBLIC_SEARCH_PER_MIN, 60.0):
res.result.SetResult(ErrorType.HTTP_TO_MANY_REQUEST)
return res
kakao = kakao_client.KakaoLocalClient()
try:
if kakao.enabled:
res.source = ExternalPlaceSource.KAKAO
rows = await kakao.search_keyword(q, size=_PUBLIC_SEARCH_SIZE)
await kakao.aclose()
else:
client = naver_client.NaverLocalClient()
if not client.enabled:
res.result.SetResult(ErrorType.LOCAL_NOT_CONFIGURED)
return res
res.source = ExternalPlaceSource.NAVER
rows = await client.search_local(q, display=_PUBLIC_SEARCH_SIZE)
await client.aclose()
except (kakao_client.KakaoNotConfigured, naver_client.NaverNotConfigured):
res.result.SetResult(ErrorType.LOCAL_NOT_CONFIGURED)
return res
except (kakao_client.KakaoRequestFailed, naver_client.NaverRequestFailed) as ex:
LOG.w(f"[search] 공개 검색 실패 q={q!r}: {type(ex).__name__}: {ex}")
res.result.SetResult(ErrorType.LOCAL_FETCH_FAILED)
return res
res.items = [
PlaceSearchItem(
name=row.name,
road_address=row.road_address or row.address,
category_name=row.category_name,
# 네이버는 그룹코드를 주지 않는다 — 그때는 분류 문자열만으로 추정한다.
category=guess_category(row.category_name, getattr(row, "category_group_code", None)),
)
for row in rows
]
if not res.items:
res.result.SetResult(ErrorType.PLACE_VERIFY_NO_CANDIDATE)
return res
async def find_candidates(self, user_info: UserInfo, place_id: str, query: str | None = None) -> Res_VerifyCandidates:
"""외부 장소 DB 에서 이 상호명의 후보를 찾아 그대로 내려준다.

View File

@ -64,7 +64,8 @@ def _client(handler, api_key: str = "test-kakao-key") -> KakaoLocalClient:
def _place(name, kakao_id="1", phone=None, road="강원 양양군 A로 1") -> KakaoPlace:
return KakaoPlace(
kakao_place_id=kakao_id, name=name, road_address=road, address=None,
phone=phone, latitude=38.0, longitude=128.6, category_name=None, place_url=None,
phone=phone, latitude=38.0, longitude=128.6, category_name=None,
category_group_code=None, place_url=None,
)

View File

@ -0,0 +1,120 @@
"""상호명 공개 검색 — 로그인 없이 열려 있고, 업종을 LLM 없이 외부 분류로 추정한다.
인증이 없는 유일한 place 경로다. 우리 DB 값이 하나도 나가지 않는지 여기서 고정한다.
"""
from common.enums import ErrorType, PlaceCategory
from common.utils import rate_limit
from services.external import kakao as kakao_client
from services.external import naver as naver_client
from services.place_category import guess_category
def _kp(name, category_name, group_code, kakao_id="1"):
return kakao_client.KakaoPlace(
kakao_place_id=kakao_id, name=name, road_address="강원 양양군 A로 1", address="양양읍 1-1",
phone="033-000-0000", latitude=38.0, longitude=128.6,
category_name=category_name, category_group_code=group_code, place_url="http://x",
)
def _patch_kakao(monkeypatch, rows):
monkeypatch.setattr(kakao_client.KakaoLocalClient, "enabled", property(lambda self: True))
async def _search(self, name, x=None, y=None, size=15):
return rows
async def _close(self):
return None
monkeypatch.setattr(kakao_client.KakaoLocalClient, "search_keyword", _search)
monkeypatch.setattr(kakao_client.KakaoLocalClient, "aclose", _close)
# ── 업종 추정 ────────────────────────────────────────────────────────────
def test_group_code_maps_to_category():
assert guess_category("가정,생활 > 숙박 > 펜션", "AD5") is PlaceCategory.LODGING
assert guess_category("음식점 > 카페", "CE7") is PlaceCategory.CAFE
assert guess_category("음식점 > 한식 > 육류,고기", "FD6") is PlaceCategory.RESTAURANT
def test_hospital_needs_the_right_department():
"""HP8 은 병원이라는 것까지만 말한다 — 우리가 여는 건 피부과·성형외과뿐이다."""
assert guess_category("의료,건강 > 병원 > 피부과", "HP8") is PlaceCategory.CLINIC
assert guess_category("의료,건강 > 병원 > 치과", "HP8") is None
def test_skin_care_shop_is_not_a_clinic():
"""'피부' 로 보면 피부관리실이 병원으로 걸린다 — 의료 광고 규제가 붙는 업종이라 제일 비싸다."""
assert guess_category("생활,서비스 > 미용 > 피부관리") is None
def test_naver_has_no_group_code():
assert guess_category("숙박>게스트하우스") is PlaceCategory.LODGING
assert guess_category("음식점>카페,디저트") is PlaceCategory.CAFE
def test_unknown_category_is_none():
"""모르면 None. 억지로 고르지 않는다 — 화면이 사장님에게 직접 묻는다."""
assert guess_category(None) is None
assert guess_category("스포츠,레저 > 골프장") is None
# ── 엔드포인트 ───────────────────────────────────────────────────────────
async def test_search_needs_no_token(client, monkeypatch):
rate_limit.reset()
_patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")])
res = await client.get("/v1/place/search", params={"q": "하조대펜션"})
assert res.status_code == 200
body = res.json()
assert body["result"]["success"] is True
assert body["items"][0]["name"] == "하조대펜션"
assert body["items"][0]["category"] == PlaceCategory.LODGING.value
async def test_search_leaks_nothing_of_ours(client, monkeypatch):
"""공개 응답이라 우리 DB 값이 새면 그대로 밖이다. 좌표·전화도 뺐다."""
rate_limit.reset()
_patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")])
item = (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()["items"][0]
for leaked in ("place_id", "company_id", "owner_user_id", "phone", "latitude", "longitude",
"external_place_id", "address"):
assert leaked not in item, f"{leaked} 가 공개 응답에 나갔다"
async def test_search_does_not_create_a_place(client, monkeypatch, auth_headers):
"""검색만으로 사업장이 생기면 안 된다 — 랜딩 트래픽이 그대로 DB 가 된다."""
rate_limit.reset()
_patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")])
h = await auth_headers("u1")
before = (await client.get("/v1/place/list", headers=h)).json()["total"]
await client.get("/v1/place/search", params={"q": "하조대펜션"})
after = (await client.get("/v1/place/list", headers=h)).json()["total"]
assert before == after
async def test_search_rejects_short_query(client):
rate_limit.reset()
res = await client.get("/v1/place/search", params={"q": ""})
assert res.status_code == 422 # min_length=2
async def test_rate_limit_closes_the_tap(client, monkeypatch):
"""인증이 없는데 유료 외부 API 를 부른다 — 새로고침만으로 요금이 나가면 안 된다."""
rate_limit.reset()
_patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")])
from services import place_service
monkeypatch.setattr(place_service, "_PUBLIC_SEARCH_PER_MIN", 2)
for _ in range(2):
assert (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()["result"]["success"] is True
blocked = (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()
assert blocked["result"]["success"] is False
assert blocked["result"]["code"] == ErrorType.HTTP_TO_MANY_REQUEST.value