o2o-site-AEO/solution/backend/common/utils/rate_limit.py
Mina Choi 2026fde80f [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
2026-09-03 09:41:34 +09:00

35 lines
1.3 KiB
Python

"""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()