Compare commits
75 Commits
v0.1.30
...
e8748f3434
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8748f3434 | ||
|
|
b259cec11b | ||
|
|
aba6fac2ae | ||
|
|
63354fa63a | ||
|
|
09ddc47103 | ||
|
|
77aad4ab14 | ||
|
|
935c73129b | ||
|
|
1815a4c4b2 | ||
|
|
c13b976ca3 | ||
|
|
e0e9ab6936 | ||
|
|
fd4ab18fbf | ||
|
|
622ea58cd1 | ||
|
|
7bf21b5b45 | ||
|
|
6754976e72 | ||
|
|
5d62866941 | ||
|
|
7639fd009d | ||
|
|
4760b5a284 | ||
|
|
2eb16ce861 | ||
|
|
14384b0c71 | ||
|
|
250b067d87 | ||
|
|
6a885c5203 | ||
|
|
a9dc1dad6a | ||
|
|
21eb1e9562 | ||
|
|
94be5a81e6 | ||
|
|
1d767bee37 | ||
|
|
0676a31cfd | ||
|
|
1164139312 | ||
|
|
78f7e83a0e | ||
|
|
247547c516 | ||
|
|
8de8696424 | ||
|
|
a4de9ba87b | ||
|
|
cf37e496d4 | ||
|
|
ce3e34938c | ||
|
|
5199475d67 | ||
|
|
bd8d82dd5d | ||
|
|
bc83923261 | ||
|
|
f17ba9e37a | ||
|
|
7789671fbc | ||
|
|
c5b0216a37 | ||
|
|
40e448fe95 | ||
|
|
8a21646031 | ||
|
|
52090057de | ||
|
|
d73947444f | ||
|
|
c1050f3abd | ||
|
|
a504bf8ee5 | ||
|
|
f1164b63c5 | ||
|
|
47020fd649 | ||
|
|
88bbf3ca25 | ||
|
|
8152b71119 | ||
|
|
d6ee62230e | ||
|
|
cf1055bdf9 | ||
|
|
2580414790 | ||
|
|
730727a7a6 | ||
|
|
9ba905aad8 | ||
|
|
8c4b0c3e9a | ||
|
|
3815221535 | ||
|
|
49ef0322ac | ||
|
|
cc4bc0b7e4 | ||
|
|
515f5c1d1a | ||
|
|
6cbf7feaf5 | ||
|
|
fda2d76514 | ||
|
|
7d95ecb3cb | ||
|
|
7b2753b9fd | ||
|
|
7411c8956f | ||
|
|
be302612f5 | ||
|
|
91d9813253 | ||
|
|
11e1cf7877 | ||
|
|
648ccde4d7 | ||
|
|
ed61d29632 | ||
|
|
51f7b5c7d3 | ||
|
|
f4cb95e88c | ||
|
|
109ad106ac | ||
|
|
319fd18258 | ||
|
|
0fa58a622c | ||
|
|
9743f96af7 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -20,3 +20,7 @@ k8s/secrets.yaml
|
||||
backend/cookies.txt
|
||||
backend-java/cookies.txt
|
||||
**/cookies.txt
|
||||
|
||||
# 작업 산출물 (로컬 전용)
|
||||
reviews/
|
||||
screenshots/
|
||||
|
||||
349
CHANGELOG.md
349
CHANGELOG.md
@@ -4,8 +4,357 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-15
|
||||
|
||||
### 🚀 [Release] v0.1.67 운영 배포 — #532 / #533 / #545
|
||||
- 대상: 2026-07-27 이후 main에 누적된 15개 커밋 (v0.1.66 이후 미배포분)
|
||||
- #532 지역 표시 계층화(`한국|서울` → `한국 › 서울`), #533 언급 적은 식당 제외 필터(min_mentions), #545 해외 식당 예약버튼 숨김
|
||||
- 절차: Colima(aarch64) 빌드 → OCIR push(`icn.ocir.io/idyhsdamac8c/tasteby/{backend,frontend}:v0.1.67`) → `kubectl set image` → rollout 완료
|
||||
- 운영 검증: `min_mentions=0` → 500곳, `=3` → 36곳, `=5` → 5곳 — 서버사이드 필터 정상 동작
|
||||
- git tag `v0.1.67` 생성 및 Gitea push. Redmine #532/#533/#545 → 08-Documenter 전진
|
||||
|
||||
### 📄 [Docs] 모두의 창업 2차 지원서 작업방 신설 — `docs/ir/modu-startup-2026-09/`
|
||||
- `questions.md`(문항 원문·제약), `v1.md`(1차 지원서 원고 보존), `v2.md`(Codex 작성분 이관), `v3.md`(실측 반영), `v4.md`(Q8 완성)
|
||||
- 실측 지표 확보: 채널 6곳 / 영상 909편 / 노출 식당 500곳(좌표 100%) / 해외 60여 곳(일본 28) / LLM 미검증 1,071건
|
||||
- v3에서 로드맵 수정: "3개월 내 식당 5,000곳"(현재의 10배, 달성 불가) → 1,500곳 + 미검증 1,071건 정리를 0~3개월 과제로 재정의
|
||||
- Redmine #791 연계
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-27
|
||||
|
||||
### ✅ [Reviewer] #533 언급 적은 식당 제외 필터 — 승인 (게이트 통과)
|
||||
- 검토 대상: backend(`Restaurant.videoCount`, `RestaurantMapper` xml/iface, `RestaurantService`, `RestaurantController`), frontend(`lib/api.ts`, `lib/filters.ts`, `app/page.tsx`), `__tests__/filters.test.ts`
|
||||
- 정확성: video_count 스칼라 서브쿼리(strong/unknown)와 min_mentions 서버필터 로직 일치. DISTINCT+스칼라 서브쿼리는 r.id 단위라 결과 불변. 캐시 키에 `mm=` 포함(필터별 분리). `minMentions > 0` 가드로 음수/null 안전. `includeHidden` 오버로드 리팩터링에 유실된 호출부 없음(true 사용처 부재 확인)
|
||||
- 설계 일치: `docs/design/533-min-mentions-filter/README.md`와 구현 일치. 순수 함수 `passesMinMentions()` 분리로 경계값/null 테스트 6케이스 커버
|
||||
- 직접 수정(Reviewer): 설계 §7 "내위치 토글 ON → setMinMentions(0)" 요구 대비 데스크톱 토글만 반영되고 모바일 토글 2곳 누락 → 두 모바일 토글에 `setMinMentions(0)` 추가(반응형 리사이즈 시 일관성 확보, 통상 모바일 흐름에선 no-op). tsc/jest(6 suites, 32 tests) 통과
|
||||
- 06-Reviewer 승인 → 07-Release 대기 (배포는 Release 단계에서 수행)
|
||||
|
||||
### ✅ [Reviewer] #532 지역 표시 계층화 — 승인 (게이트 통과)
|
||||
- 검토 대상: `frontend/src/lib/region.ts`(`formatRegion`), `RestaurantDetail.tsx`/`RestaurantList.tsx`(적용·렌더 가드), `__tests__/region.test.ts`
|
||||
- 정확성: `formatRegion`은 순수 함수(I/O 없음, 단일 책임). falsy/`null` 토큰(대소문자 무시)/빈 토큰/더미 `나라`/trim/전량 더미(`""`) 모두 정상 처리 — 재실행한 단위 테스트 8/8 통과
|
||||
- 설계 일치: 구현이 `docs/design/532-region-display/README.md`와 일치. Designer 단계의 렌더 가드 변경(`region &&` → `formatRegion(region) &&`)이 설계서에 선반영됨 → 전 토큰 더미 시 빈 배지/빈 줄 미렌더 확인
|
||||
- 회귀 안전성: c13b976에서 착오로 되돌아간 #545 접근성 표시(title/aria-hidden)가 935c731로 복원됨을 확인 — 현재 파일 상태 정상
|
||||
- 범위 판단: `buildSearchQuery`(공백 조인, 외부 지도 검색)는 의도적으로 유지 — 적절. Admin 패널(UsersPanel/VideosPanel/RestaurantsPanel)의 raw region 표기는 설계서 비범위(관리자 화면)로, 이번 사용자 화면 버그와 무관 — 반려 사유 아님(후속 정리 권고)
|
||||
- 06-Reviewer 승인 → 07-Release 대기 (배포는 Release 단계에서 수행)
|
||||
|
||||
### 🛠 [Developer] #533 Reviewer 반려 수정 — "전체보기" 버튼 언급수 필터 초기화
|
||||
- 파일: `frontend/src/app/page.tsx`
|
||||
- 전역 "전체보기" 버튼 노출 조건에 `|| minMentions > 0` 추가 → 언급수 필터만 켜져 있어도 버튼 노출
|
||||
- onClick 초기화 목록에 `setMinMentions(0)` 추가 → 다른 필터와 함께 켠 상태에서도 전체 초기화됨
|
||||
- "내위치" 토글 ON 초기화 목록에도 `setMinMentions(0)` 추가(내위치 ↔ 언급수 상호배타 일관성)
|
||||
- 설계서 동기화: `docs/design/533-min-mentions-filter/README.md` §7에 "필터 리셋 일관성" 명시
|
||||
- 검증: `tsc --noEmit` 통과, jest 6 suites / 32 tests 통과. 코어 필터 로직은 이전과 동일(변경 없음)
|
||||
- 03-Developer → 06-Reviewer(재검토)
|
||||
|
||||
### 🛑 [Reviewer] #533 반려 — 전역 "전체보기" 버튼이 언급수 필터를 초기화하지 않음
|
||||
- 파일: `frontend/src/app/page.tsx:910`(노출 조건), `912-919`(onClick 초기화 목록)
|
||||
- 문제: 전역 필터 초기화 버튼("전체보기")의 노출 조건과 초기화 로직에 `minMentions`가 누락됨
|
||||
- 언급수 필터만 켜져 있으면 버튼 자체가 노출되지 않음(그룹 로컬 X 버튼으로는 초기화 가능)
|
||||
- 다른 필터와 언급수 필터를 함께 켠 상태에서 "전체보기"를 눌러도 언급수 필터는 계속 적용된 채로 남아, 사용자가 전체 초기화됐다고 오인할 수 있음
|
||||
- 권고: 910행 조건에 `|| minMentions > 0` 추가, 912-919행에 `setMinMentions(0)` 추가. "내위치" 토글 초기화 목록도 함께 점검 권고
|
||||
- 코어 필터 로직(설계서 일치, 경계값, 보안)은 문제 없음 — 이 결함만으로 반려
|
||||
- 06-Reviewer → 03-Developer 로 반려
|
||||
|
||||
### 🎨 [Designer] #533 UX 검토 — 코드 변경 없음
|
||||
- 데스크톱 툴바 "언급" 셀렉트가 기존 필터(장르/가격/나라)와 동일한 패턴(플레이스홀더/강조색/그룹 X 초기화/tooltip)을 따름을 확인, 문구 변경 불필요
|
||||
- 전역 "전체보기" 버튼 결함 발견 → 동작 변경이 필요해 Designer 범위 밖으로 판단, Reviewer 단계로 이관
|
||||
- 05-Designer → 06-Reviewer 로 전진 (코드 변경 없음)
|
||||
|
||||
### ✅ [QA] #533 언급 적은 식당 제외 필터 — 검증 통과
|
||||
- 설계서(`docs/design/533-min-mentions-filter/README.md`)와 구현(commit 7639fd0) 일치 확인
|
||||
- 백엔드: `RestaurantMapper.xml findAll` 스칼라 서브쿼리(`video_count`) + `min_mentions` 필터, `DISTINCT` 조합에도 중복 없음 확인
|
||||
- 프론트: `page.tsx` `filteredRestaurants`에서 `video_count` 가드 적용, 데스크톱 툴바 "언급" 셀렉트(2/3/5회+)
|
||||
- dev API 실측 검증: `min_mentions=0/1/2/3/5/100` 전 구간에서 경계 위반 0건 (=2→116개, =3→36개, =5→5개, =100→0개, 음수/0은 미적용과 동일)
|
||||
- `cuisine`/`channel` 등 기존 필터와 조합 시에도 회귀 없음 확인
|
||||
- 회귀 테스트 추가: `passesMinMentions()`을 `frontend/src/lib/filters.ts`로 추출(동작 변경 없음, 테스트 가능성 확보) + `frontend/__tests__/filters.test.ts`(6케이스: 미적용/음수/미만/경계값/초과/null·undefined)
|
||||
- 백엔드 빌드, `tsc --noEmit`, 프론트 전체 jest 스위트(6 suites, 32 tests) 통과
|
||||
- 갭(참고, 반려 사유 아님): `min_mentions` 필터 UI가 데스크톱 툴바에만 있고 모바일 FilterSheet에는 미적용 — 설계서에서 서버사이드 일원화(#535)로 이미 분리한 범위, 모바일 UI 적용은 #535 진행 시 함께 처리 권고
|
||||
- 04-QA → 05-Designer 로 전진
|
||||
|
||||
### 🎨 [Designer] #532 지역 표시 빈 배지 방지 — 렌더 가드 정리
|
||||
- 문제: `RestaurantList.tsx`/`RestaurantDetail.tsx`가 원본 `region` 문자열(예: `"|"`, `"한국|"`)의 truthy 여부로 표시 여부를 결정 → 전 토큰이 더미/빈 값이어서 `formatRegion()` 결과가 `""`이 되는 경우에도 빈 지역 배지/줄이 렌더되어 레이아웃에 불필요한 간격이 남을 수 있었음(dev 데이터에서는 미발견, 이론적 엣지케이스)
|
||||
- 해결: 표시 가드를 `r.region &&` → `formatRegion(r.region) &&` 로 변경(두 파일 모두). `formatRegion()` 로직/시그니처는 변경 없음 — 호출부 조건만 정리
|
||||
- `tsc --noEmit`, 프론트 전체 jest 스위트(5 suites, 26 tests) 통과
|
||||
- 05-Designer → 06-Reviewer 로 전진
|
||||
|
||||
### ✅ [QA] #545 해외 식당 예약 버튼 숨김 — 검증 통과
|
||||
- 설계서(`docs/design/545-hide-foreign-reservation-buttons/README.md`) 인수조건 대비 구현(`RestaurantDetail.tsx`) 일치 확인
|
||||
- 테이블링/캐치테이블 버튼 2곳 모두 `isKoreaRestaurant(restaurant) &&` 게이트 적용됨 (지도 링크 분기와 동일 판정 기준 재사용)
|
||||
- 회귀 테스트 추가: `frontend/__tests__/RestaurantDetail.test.tsx` (5케이스)
|
||||
- 국내 좌표(서울) → 버튼 노출, 해외 좌표(방콕, 대표 발견 케이스) → 버튼 숨김
|
||||
- 해외+URL 없음(도쿄) → 숨김 유지, 좌표 없음+region null → fallback으로 국내 간주(기존 동작 유지)
|
||||
- 예약 URL `"NONE"` 문자열 → 국내 식당이어도 버튼 없음(기존 게이트 유지, 회귀 없음)
|
||||
- `tsc --noEmit` 통과, 프론트 전체 jest 스위트(5 suites, 26 tests) 통과
|
||||
- 04-QA → 05-Designer 로 전진
|
||||
|
||||
### ✅ [QA] #532 지역 표시 계층화 — 검증 통과
|
||||
- `formatRegion()` 설계서(docs/design/532-region-display/README.md) 명세대로 구현됨을 확인
|
||||
- 단위 테스트 추가: `frontend/__tests__/region.test.ts` (falsy, 계층 조인, `null`/빈 토큰/더미 `나라` 제거, 단일 토큰, 전량 더미, trim 8케이스) — 전부 통과
|
||||
- `tsc --noEmit` 통과, 프론트 전체 jest 스위트(4 suites, 21 tests) 통과
|
||||
- 실제 dev DB 데이터(`region: "한국|서울|"` 등)로 엣지케이스 재확인 — 설계서 예시와 일치
|
||||
- 04-QA → 05-Designer 로 전진
|
||||
|
||||
## 2026-07-19
|
||||
|
||||
### 📋 설계서/Redmine 현행화 — #545/#533/#532 파이프라인 정지 점검
|
||||
- 발견: #545/#533/#532 세 이슈가 Developer 구현 완료(2026-06-28) 이후 04-QA 단계에서 3주간 정지 확인
|
||||
- #545: Architect 단계 없이 구현되어 설계서 게이트 위반 확인 → `docs/design/545-hide-foreign-reservation-buttons/README.md` 소급 작성
|
||||
- #533, #532: 기존 설계서가 실제 구현과 일치함을 재확인 (수정 없음)
|
||||
- Redmine 3건에 현재 상태 저널 노트 반영 (잔여: 육안 QA)
|
||||
- git/Redmine/설계서 3자 정합성 점검 — git 클린, 대응 커밋 확인 완료
|
||||
|
||||
## 2026-06-28
|
||||
|
||||
### 🐛 해외 식당에 테이블링/캐치테이블 예약 버튼 노출 수정 (#545)
|
||||
- 문제: 국내 전용 예약 서비스 버튼이 해외 식당 상세에도 노출 (URL만 있으면 렌더)
|
||||
- 해결: RestaurantDetail.tsx 두 예약 버튼을 `isKoreaRestaurant()`로 게이트 (지도 링크와 동일 기준)
|
||||
- 후속(옵션): 백엔드 예약 URL 검색/벌크가 해외 식당 제외
|
||||
|
||||
### ✨ 언급 적은(관련도 낮은) 식당 제외 필터 (#533)
|
||||
- 신호: "의미있는 언급 수" = video_restaurants 중 relevance ∈ (strong, unknown) 개수 (weak/incidental 제외)
|
||||
- 백엔드: 식당 목록 응답에 `video_count` 추가 + `min_mentions` 서버 필터 파라미터
|
||||
- RestaurantMapper.xml findAll: 스칼라 서브쿼리 video_count + minMentions 필터
|
||||
- Restaurant.videoCount 필드 / Service·Controller 스레드 / 캐시키 `mm=` 포함
|
||||
- 검증: min_mentions=2 → 116개(전부 ≥2, 위반 0), =3 → 36개
|
||||
- 프론트: Restaurant.video_count 타입 + 데스크톱 툴바 "언급" 필터(2/3/5회+) 클라이언트 필터
|
||||
- 후속: 모바일 FilterSheet 적용 + 서버사이드 일원화(#535)
|
||||
- 설계서: `docs/design/533-min-mentions-filter/README.md`
|
||||
|
||||
### 🐛 지역 표시 계층화 — "한국|서울" → "한국 › 서울" (#532)
|
||||
- 문제: 식당 상세/리스트가 region 원본(`한국|서울|강남구`, 파이프 노출)을 그대로 표시
|
||||
- 해결: 공용 유틸 `lib/region.ts` `formatRegion()` 신설 — 빈토큰/`null`/더미(`나라`) 제거 후 ` › ` 계층 조인
|
||||
- 적용: `RestaurantDetail.tsx`(지역 표기), `RestaurantList.tsx`(카드 지역)
|
||||
- 검색 쿼리용 `buildSearchQuery`(공백 조인)는 유지 — 외부 지도 검색 호환
|
||||
- 설계서: `docs/design/532-region-display/README.md`
|
||||
|
||||
## 2026-06-17
|
||||
|
||||
### 🎯 클러스터 expansion 후 마커 클릭 시 재묶임 방지 (v0.1.66)
|
||||
- 기존: selected 시 무조건 zoom 16 → 클러스터 expansion(18)에서 마커 클릭하면 다시 16으로 줄어 재묶임
|
||||
- 변경: 현재 zoom ≥ 16이면 유지, 미만이면 16으로 (NaverMap morph + GoogleMap setZoom 조건부)
|
||||
- page.tsx의 setRegionFlyTo 호출 제거 — selected useEffect로 일원화 (zoom 16 강제 회피)
|
||||
|
||||
### 🐛 NaverMap 클러스터 클릭 시 중앙 이동 fix (v0.1.65)
|
||||
- panTo(애니메이션) + setZoom(z, true)(애니메이션) 두 애니메이션 동시 호출이 서로 cancel
|
||||
- 단일 식당은 줌 변화 없어서 안 보였고, 클러스터는 줌 크게 변경 → 충돌 가시화
|
||||
- NaverMap SDK의 `morph(latlng, zoom)` 한 메서드로 통일 — center+zoom 동시 변경
|
||||
|
||||
### 🎯 NaverMapView selected 자동 panTo + zoom (v0.1.64)
|
||||
- 마커/클러스터/리스트 어디서 선택해도 그 식당이 화면 중앙으로 + zoom 16
|
||||
- GoogleMapView에는 이미 있던 useEffect [selected] 패턴을 동일하게 추가
|
||||
|
||||
## 2026-06-16
|
||||
|
||||
### 🧹 미커밋 잡변경 정리 + 5개 분리 commit (v0.1.63)
|
||||
- backend: 어드민 권한 토글 service/mapper/CORS PATCH 미커밋분 적용
|
||||
- backend: JsonUtil.normalizeEvaluation 정식 commit (PipelineService/RestaurantService 이미 호출 중)
|
||||
- frontend: Phosphor 아이콘 마이그레이션 + FoodIcon 신규 + brand-guide.md
|
||||
- docs: oke-deploy-howto.md 신규
|
||||
- chore: ecosystem PORT=3001 + reviews/screenshots gitignore
|
||||
|
||||
### 🎯 식당 선택 시 지도 자동 줌인/이동 (v0.1.62)
|
||||
- 리스트 / 검색결과에서 식당 클릭 → setRegionFlyTo로 그 식당 좌표 + zoom 16
|
||||
- 지도가 선택 식당으로 panTo + zoom — NaverMap/GoogleMap 둘 다
|
||||
- 마커의 selected 강조(1.15× + 파란 박스)와 함께 동작
|
||||
|
||||
### 🏷️ NaverMapView 마커에 식당명 박스 (v0.1.61)
|
||||
- 단순 동그라미 → GoogleMapView와 동일 핀 디자인(박스+화살표+식당명+cuisine 아이콘)
|
||||
- 채널별 배경/테두리/화살표 색상, 폐업(business_status CLOSED_*) 표시 회색 + 취소선
|
||||
- selected 식당 강조 (1.15× scale + 파란 박스), zIndex 1000
|
||||
- InfoWindow 제거 (식당명 자체가 박스로 보이므로 불필요)
|
||||
|
||||
### 🎨 NaverMapView 채널별 마커 색상 (v0.1.60)
|
||||
- GoogleMapView와 동일 팔레트 (amber/blue/green/pink/purple/red/teal/yellow)
|
||||
- 식당의 첫 채널 기준 색상, activeChannel 있으면 그 채널 우선
|
||||
|
||||
### ⚡ NaverMapView SDK 네이티브 마커 + InfoWindow (v0.1.59)
|
||||
- 마커를 React `absolute div` overlay → `naver.maps.Marker` 네이티브로 교체
|
||||
- 줌/팬 시 SDK가 GPU 최적화, 매 frame React 리렌더링 없음 → 랙 해소
|
||||
- 식당명 InfoWindow 추가 (마커 클릭 시 표시)
|
||||
- bounds_changed → idle 이벤트로 sync (줌/팬 중 발화 빈도 ↓)
|
||||
- 클러스터도 네이티브 마커 (HTML 콘텐츠로 숫자 표시)
|
||||
|
||||
### 🗺️ NaverMapView 안정화 + 재활성 (v0.1.57)
|
||||
- divRef 항상 마운트 (이전: ready 가드로 첫 렌더 ref 누락 가능)
|
||||
- 명시적 width/height + 회색 배경(시각적 로딩 표시)
|
||||
- ResizeObserver + requestAnimationFrame으로 컨테이너 0×0 → 정상 크기 시 refresh
|
||||
- try/catch + initError state로 init 실패 가시화
|
||||
- Naver 키 재활성
|
||||
|
||||
### ⏪ NaverMap 임시 비활성, 한국도 GoogleMap fallback (v0.1.55)
|
||||
- NaverMapView 골격이 실 운영에서 지도/마커 렌더 실패 (정확한 원인 추후 진단)
|
||||
- NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 빈 값으로 dispatcher가 GoogleMap fallback (회귀 0)
|
||||
- NaverMapView 코드는 유지 — 안정화 후 환경변수 채우면 재활성
|
||||
|
||||
### 🐛 /api/stats/visits 500 — StatsMapper resultType int → long (v0.1.54)
|
||||
- StatsMapper interface는 `long` 반환인데 XML resultType이 `int` → Integer를 long에 cast 실패
|
||||
- ClassCastException: Integer → Long. resultType만 long으로 교정
|
||||
|
||||
### 🐛 NaverMap 인증 파라미터 ncpClientId → ncpKeyId (v0.1.53)
|
||||
- NCLOUD 신 정책: `ncpKeyId` 사용 (옛 `ncpClientId`는 NAVER Developers용)
|
||||
- 인증 200/Failed의 진짜 원인 — 도메인 등록은 정확했으나 파라미터 이름 차이로 키 인식 실패
|
||||
- 새 NCLOUD Maps Client ID(`fg01bipxbo`)로 prod 재빌드
|
||||
- 참고: https://github.com/navermaps/maps.js.ncp/blob/master/index.html
|
||||
|
||||
### 🗺️ #363 메인 지도 SDK 국내(네이버)/해외(구글) 분기 (v0.1.52)
|
||||
- MapView를 dispatcher로 전환: 좌표가 KR bbox + NAVER_MAP_CLIENT_ID 설정 시 NaverMapView, 그 외 GoogleMapView
|
||||
- NaverMapView 신규 (네이버 v3 직접 wrapper, Supercluster 재사용, 마커/클러스터/flyTo)
|
||||
- GoogleMapView 신규 (기존 MapView 내용 rename)
|
||||
- MapView.types.ts 공용 (MapBounds/FlyTo/MapViewProps + isKoreaCoord)
|
||||
- Dockerfile + deploy.sh: NEXT_PUBLIC_NAVER_MAP_CLIENT_ID build-arg 추가
|
||||
- 키 미설정 시 GoogleMap fallback (회귀 0)
|
||||
- 설계서: docs/design/363-map-sdk-branch/README.md
|
||||
- Refs: #363
|
||||
|
||||
### 🗺️ 식당 상세 지도 링크 국내/해외 분기 (v0.1.51)
|
||||
- 좌표 기반 한국 판정 (WGS84 KR bbox 33~38.7°N, 124~132°E)
|
||||
- 국내: 네이버 지도 primary + Google Maps 보조 (네이버 URL은 신 도메인 /p/search/)
|
||||
- 해외: Google Maps 단독
|
||||
- 좌표 없으면 region 첫 토큰 fallback (구 데이터 호환)
|
||||
- frontend-only 배포
|
||||
|
||||
## 2026-06-15
|
||||
|
||||
### 🐛 캐치테이블 URL 패턴 수정 (v0.1.50)
|
||||
- 실제 catchtable URL은 `app.catchtable.co.kr/ct/shop/...` 형식 (옛 `/shop/`, `/dining/`은 매칭 실패)
|
||||
- 첫 회차(v0.1.49) 캐치테이블 벌크 결과 1044건 전부 미발견(매핑 0%)의 원인
|
||||
- 패턴을 `catchtable.co.kr/ct/shop/`, `catchtable.co.kr/ct/dining/`로 교정 후 NONE 해제 + 재실행
|
||||
|
||||
### 🐛 WebSearchService HTTP timeout 추가 (v0.1.49)
|
||||
- 벌크 백필 중 특정 검색에서 무한 hang → backend executor virtual thread 점유로 후속 작업 중단 (90건 처리 후 멈춤)
|
||||
- connectTimeout=5s + request timeout=15s (Naver/DDG 둘 다)
|
||||
- 해당 식당은 HttpTimeoutException → notfound로 안전 처리
|
||||
|
||||
### ⏱️ bulk-tabling/catchtable SSE timeout 10분 → 3시간 (v0.1.48)
|
||||
- 대량 백필(724건 ≈ 100분) 시 10분 SSE timeout으로 중간 끊김 → 3시간으로 확장
|
||||
- 백엔드 작업은 virtual thread로 별도 진행됐지만 emit() 예외로 마지막 cache.flush + complete 누락이슈 해소
|
||||
|
||||
### 🐛 #357 후속 — tabling-url validation에 www. 호스트 허용 (v0.1.47)
|
||||
- Naver/DDG 결과가 `https://www.tabling.co.kr/...` 형태인데 #290 validation은 `tabling.co.kr/`만 허용 → 단건 매핑 PUT 거부
|
||||
- bulk-tabling SSE는 validation 없이 service.update 직접 호출이라 통과 → 단일/벌크 불일치
|
||||
- `www.tabling.co.kr` prefix도 허용 (catchtable은 이미 app/www 둘 다 허용)
|
||||
- 시연 등록: bbq 부천은하마을점 → BBQ 치킨 부천은하마을점
|
||||
|
||||
### 🔍 #359 1단계 — google_place_id 중복 조회 API (v0.1.46)
|
||||
- GET /api/admin/restaurants/duplicates/place-id (어드민 전용)
|
||||
- 응답: 그룹별 식당 + video/review/memo 카운트 (병합 의사결정 자료)
|
||||
- 정리/병합 + UNIQUE 제약은 별도 PR (데이터 위험 분리)
|
||||
- 설계서: docs/design/359a-duplicate-place-id-view/README.md
|
||||
- Refs: #359 (조회 단계 완료, 후속 분리 유지)
|
||||
|
||||
### 📋 #358 RestaurantUpdateDTO + @Valid 표준화 (v0.1.45)
|
||||
- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable)
|
||||
- Bean Validation: @Size/@Pattern(URL or NONE)/@DecimalMin·Max/@Min·Max
|
||||
- RestaurantController.update 시그니처 Map → @Valid DTO 교체
|
||||
- toFieldMap()으로 null 제외 후 기존 Service.update 호출 (회귀 0)
|
||||
- #332 ALLOWED_UPDATE_FIELDS Set 제거 (DTO 필드 자체가 화이트리스트)
|
||||
- 설계서: docs/design/358-restaurant-update-dto/README.md
|
||||
- Refs: #358 (close)
|
||||
|
||||
### 🔎 #357 DDG → Naver Search 정식 API + DDG 폴백 (v0.1.44)
|
||||
- WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG 폴백)
|
||||
- RestaurantController.searchTabling/searchCatchtable 내부 호출 교체, DDG 인라인 80줄 제거
|
||||
- application.yml: app.naver.client-id/secret (NAVER_CLIENT_ID/SECRET 환경변수)
|
||||
- k8s/secrets.yaml.template에 NAVER_CLIENT_ID/SECRET 항목 추가
|
||||
- 미사용 import 정리 (HttpClient/URI/URLEncoder/Pattern 등 RestaurantController에서)
|
||||
- 설계서: docs/design/357-web-search-api/README.md
|
||||
- Refs: #357 (close)
|
||||
|
||||
### 🎯 #356 영상-식당 관련도 LLM 평가 (v0.1.43)
|
||||
- DB: video_restaurants 컬럼 추가 (relevance/relevance_reason/relevance_evaluated_at) + idx_vr_relevance
|
||||
- VideoRelevanceService 신규 (#322 RestaurantVerifyService 패턴 모방, @Async verifyAsync/verify/verifyAll)
|
||||
- PipelineService.processExtract — linkVideoRestaurant 후 verifyAsync(linkId) 자동 트리거
|
||||
- GET /api/restaurants/{id}/videos: 기본 strong/unknown만 응답 (안전 기본값), ?include_weak=true 시 모두
|
||||
- AdminVideoRelevanceController 신규 (pending/all/{id}/evaluate/{id} PATCH)
|
||||
- 응답 매핑: relevance, relevance_reason 필드 동봉
|
||||
- 기존 1244 링크는 'unknown' 시작 → 어드민 백필로 점진 평가
|
||||
- 설계서: docs/design/356-video-relevance-llm/README.md
|
||||
- Refs: #356 (close)
|
||||
|
||||
### 🧹 #351 admin SSE 6곳 consumeSseStream 통일 (v0.1.42)
|
||||
- VideosPanel 4곳(bulkTranscript/Extract, rebuildVectors, remapCuisine, remapFoods)
|
||||
- RestaurantsPanel 2곳(bulkTabling, bulkCatchtable)
|
||||
- response.body?.getReader 직접 호출 0건 (lib/admin-utils.ts의 consumeSseStream 활용)
|
||||
- 149줄 삭제 → 74줄 압축, npm test 13/13 통과
|
||||
- Refs: #351 (close)
|
||||
|
||||
### 🧪 #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns (v0.1.40)
|
||||
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers 도입
|
||||
- next/jest 자동 SWC 통합, jest.config.ts + jest.setup.ts (setupFilesAfterEnv)
|
||||
- npm scripts: test, test:watch
|
||||
- 샘플 테스트 3개 13/13 통과: i18n/config(5), Stars(5), admin-utils(4)
|
||||
- MyReviewsList: role=tablist/tab/aria-selected/aria-controls/tabIndex + tabpanel
|
||||
- next.config.ts remotePatterns: Google avatar + YouTube thumbnail/avatar
|
||||
- 후속: 전체 컴포넌트 테스트 확장, 백엔드 JUnit, E2E(Playwright), CI 통합
|
||||
- 설계서: docs/design/343-frontend-test-infra/README.md
|
||||
- Refs: #343 (close)
|
||||
|
||||
### 🔤 #348 isNameSimilar 한국어 자모 + Sørensen-Dice (v0.1.38)
|
||||
- HangulSimilarity 유틸 신규 (Unicode NFD 분해 + bigram Sørensen-Dice)
|
||||
- RestaurantController.isNameSimilar 교체, 임계값 0.45
|
||||
- 짧은 한국어 이름 매칭 정확도 향상 (예: "스타벅스 강남" vs "스타벅스 강남점")
|
||||
- 후속 분리: #357(DDG→정식 API), #358(DTO+@Valid), #359(UNIQUE+데이터 정리)
|
||||
- 설계서: docs/design/348-name-similarity/README.md
|
||||
- Refs: #348 (close)
|
||||
|
||||
### 🌐 #352 i18n 뼈대 ko/en/ja/es (v0.1.37)
|
||||
- next-intl 5.x 도입
|
||||
- src/i18n/{config,LocaleProvider} + src/messages/{ko,en,ja,es}.json (30 키)
|
||||
- LanguageSwitcher 컴포넌트 (헤더, ARIA listbox, 44px, 국기+네이티브명)
|
||||
- localStorage tasteby_locale + 브라우저 언어 감지 + ko fallback
|
||||
- 설계서: docs/design/352-i18n-skeleton/README.md
|
||||
- 미적용: URL 라우팅 i18n, SEO meta, 사용자 콘텐츠 번역, 어드민(한국어 유지)
|
||||
- Refs: #352 (close)
|
||||
|
||||
### 🧹 #329 admin/page.tsx 분리 (v0.1.35→v0.1.36 운영 반영)
|
||||
- page.tsx 2817 → 107 LOC (탭 라우팅 + 헤더만)
|
||||
- _panels/{Channels,Videos,Restaurants,Users,Daemon}Panel.tsx 5개 분리
|
||||
- localStorage.getItem 10곳 → getAdminToken() (admin-utils.ts)
|
||||
- SSE 통일은 후속 #351 분리
|
||||
- 설계서: docs/design/329-admin-split/README.md
|
||||
- Refs: #329 (close)
|
||||
|
||||
### ⚡ #331 VectorService batchUpdate (v0.1.34)
|
||||
- saveRestaurantVectors: N+1 단건 INSERT → 단일 jdbc.batchUpdate(SqlParameterSource[])
|
||||
- UUID 인라인 변환 제거 → IdGenerator.newId() 공통화
|
||||
- 현재 N=1이지만 chunk 분할 도입 시 효과 본격화
|
||||
- 설계서: docs/design/331-vector-batch-insert/README.md
|
||||
- Refs: #331 (close)
|
||||
|
||||
### ⚡ #326 parseJson 단일 패스 (v0.1.33)
|
||||
- OciGenAiService.parseJson 잘린 배열 복구를 brace depth counter 단일 패스로 교체
|
||||
- 이전 O(N²) + Jackson 예외 양산 → O(N) + 명시적 에러 경로
|
||||
- 문자열/escape 처리 정확
|
||||
- 설계서: docs/design/326-parsejson-optimization/README.md
|
||||
- Refs: #326 (close)
|
||||
|
||||
### 🛡️ #332 Restaurant PUT 화이트리스트 명시 (v0.1.32)
|
||||
- ALLOWED_UPDATE_FIELDS set으로 PUT /api/restaurants/{id} body 필터
|
||||
- 허용 외 키 silent drop + DEBUG 로그
|
||||
- sanitized.isEmpty()면 200 + no-op
|
||||
- 후속 분리: #348 (DDG → 정식 API, isNameSimilar 한국어, DTO 표준화)
|
||||
- Refs: #332 (close)
|
||||
|
||||
### 🛡️ #337 통계 봇 필터 + 레이트리밋 (v0.1.31)
|
||||
- BotDetector: UA 정규식 (bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse)
|
||||
- RateLimitService: Redis SET NX EX(60s) 패턴, fail-open (의존성 최소화)
|
||||
- StatsController.recordVisit: X-Forwarded-For 우선 IP + 봇/IP 가드
|
||||
- 응답: {ok, counted:bool} — 차단도 200 (사용자 페이지 지장 X)
|
||||
- application.yml: app.rate-limit.visit-window-seconds (기본 60)
|
||||
- 운영 검증: Googlebot/Mozilla/즉시 재호출 인수조건 모두 충족
|
||||
- 설계서: docs/design/337-stats-bot-ratelimit/README.md
|
||||
- Refs: #337 (close)
|
||||
|
||||
### 🔒 #335 데몬 분산 락 ShedLock+Redis (v0.1.30)
|
||||
- shedlock-spring 5.16.0 + shedlock-provider-redis-spring
|
||||
- @EnableSchedulerLock(defaultLockAtMostFor=PT15M)
|
||||
- DaemonScheduler.run: @SchedulerLock(name="daemon-runner", lockAtMostFor=PT15M, lockAtLeastFor=PT30S)
|
||||
- ShedLockConfig: RedisLockProvider Bean (in-cluster Redis 재사용)
|
||||
- 멀티 파드(RollingUpdate) + dev/prod ATP 공유 환경에서 데몬 중복 실행 차단
|
||||
- 설계서: docs/design/335-daemon-distributed-lock/README.md
|
||||
- Refs: #335 (close)
|
||||
|
||||
### 💾 #336 캐시 SCAN/UNLINK + 자동 복구 + 에러 메트릭 (v0.1.29)
|
||||
- CacheService.flush: redis.keys() 블로킹 → SCAN cursor + UNLINK 논블로킹 (500 batch)
|
||||
- @Scheduled(30s) checkHealth: Redis ping → disabled 자동 토글 (재기동 시 자동 복구)
|
||||
|
||||
@@ -32,6 +32,8 @@ dependencies {
|
||||
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'
|
||||
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.16.0'
|
||||
|
||||
// #337 — IP 레이트리밋은 Redis SET NX EX 패턴으로 자체 구현 (기존 spring-data-redis 활용)
|
||||
|
||||
// Oracle JDBC + Security (Wallet support for Oracle ADB)
|
||||
implementation 'com.oracle.database.jdbc:ojdbc11:23.7.0.25.01'
|
||||
implementation 'com.oracle.database.security:oraclepki:23.7.0.25.01'
|
||||
|
||||
@@ -21,7 +21,7 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
|
||||
@@ -62,6 +62,15 @@ public class AdminRestaurantController {
|
||||
return Map.of("success", true, "id", id);
|
||||
}
|
||||
|
||||
// #359 1단계 — google_place_id 중복 조회 (정리/UNIQUE는 후속).
|
||||
@GetMapping("/duplicates/place-id")
|
||||
public Map<String, Object> duplicatePlaceIds() {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
var groups = restaurantService.findDuplicatePlaceIdGroups();
|
||||
log.info("[ADMIN] {} duplicate place_id groups: {}", admin.getSubject(), groups.size());
|
||||
return Map.of("groups", groups, "group_count", groups.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 어드민용 hidden 토글.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.RestaurantService;
|
||||
import com.tasteby.service.VideoRelevanceService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* #356 영상-식당 관련도 LLM 평가 어드민 API.
|
||||
* - 미평가 카운트 / 일괄 백필 / 단건 재평가 / 수동 토글
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/video-relevance")
|
||||
public class AdminVideoRelevanceController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AdminVideoRelevanceController.class);
|
||||
private static final Set<String> VALID = Set.of("strong", "weak", "incidental", "unknown");
|
||||
|
||||
private final RestaurantService restaurantService;
|
||||
private final VideoRelevanceService relevanceService;
|
||||
|
||||
public AdminVideoRelevanceController(RestaurantService restaurantService, VideoRelevanceService relevanceService) {
|
||||
this.restaurantService = restaurantService;
|
||||
this.relevanceService = relevanceService;
|
||||
}
|
||||
|
||||
@GetMapping("/pending")
|
||||
public Map<String, Object> pendingCount() {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
int n = restaurantService.countUnevaluatedLinks();
|
||||
log.info("[ADMIN] {} video-relevance pending: {}", admin.getSubject(), n);
|
||||
return Map.of("pending", n);
|
||||
}
|
||||
|
||||
@PostMapping("/all")
|
||||
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
int pending = restaurantService.countUnevaluatedLinks();
|
||||
log.info("[ADMIN] {} triggered video-relevance verifyAllAsync (batchSize={}, pending={})", admin.getSubject(), batchSize, pending);
|
||||
// 비동기 트리거 — HTTP request는 즉시 응답. 진행은 /pending 폴링으로 확인.
|
||||
relevanceService.verifyAllAsync(batchSize);
|
||||
return Map.of("started", true, "pending", pending);
|
||||
}
|
||||
|
||||
@PostMapping("/{linkId}/evaluate")
|
||||
public Map<String, Object> evaluateOne(@PathVariable String linkId) {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
log.info("[ADMIN] {} video-relevance evaluate({})", admin.getSubject(), linkId);
|
||||
relevanceService.verify(linkId);
|
||||
return Map.of("success", true, "linkId", linkId);
|
||||
}
|
||||
|
||||
@PatchMapping("/{linkId}")
|
||||
public Map<String, Object> setRelevance(@PathVariable String linkId, @RequestBody Map<String, Object> body) {
|
||||
var admin = AuthUtil.requireAdmin();
|
||||
Object relObj = body.get("relevance");
|
||||
if (!(relObj instanceof String relevance) || !VALID.contains(relevance)) {
|
||||
return Map.of("success", false, "error", "relevance must be one of strong|weak|incidental|unknown");
|
||||
}
|
||||
String reason = body.get("reason") instanceof String s ? s : "manual";
|
||||
restaurantService.updateLinkRelevance(linkId, relevance, reason);
|
||||
log.info("[ADMIN] {} manual relevance={} for link {}", admin.getSubject(), relevance, linkId);
|
||||
return Map.of("success", true, "linkId", linkId, "relevance", relevance);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.dto.RestaurantUpdateDTO;
|
||||
import com.tasteby.service.CacheService;
|
||||
import com.tasteby.service.GeocodingService;
|
||||
import com.tasteby.service.RestaurantService;
|
||||
import com.tasteby.service.WebSearchService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -15,19 +18,10 @@ import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/restaurants")
|
||||
@@ -39,13 +33,15 @@ public class RestaurantController {
|
||||
private final GeocodingService geocodingService;
|
||||
private final CacheService cache;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final WebSearchService webSearch;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper) {
|
||||
public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper, WebSearchService webSearch) {
|
||||
this.restaurantService = restaurantService;
|
||||
this.geocodingService = geocodingService;
|
||||
this.cache = cache;
|
||||
this.objectMapper = objectMapper;
|
||||
this.webSearch = webSearch;
|
||||
}
|
||||
|
||||
// #290 — Bean 종료 시 virtual thread executor를 정리하여 리소스 누수 방지.
|
||||
@@ -60,17 +56,18 @@ public class RestaurantController {
|
||||
@RequestParam(defaultValue = "0") int offset,
|
||||
@RequestParam(required = false) String cuisine,
|
||||
@RequestParam(required = false) String region,
|
||||
@RequestParam(required = false) String channel) {
|
||||
@RequestParam(required = false) String channel,
|
||||
@RequestParam(name = "min_mentions", required = false) Integer minMentions) {
|
||||
if (limit > 500) limit = 500;
|
||||
String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset,
|
||||
"c=" + cuisine, "r=" + region, "ch=" + channel);
|
||||
"c=" + cuisine, "r=" + region, "ch=" + channel, "mm=" + minMentions);
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
||||
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
|
||||
}
|
||||
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
|
||||
var result = restaurantService.findAll(limit, offset, cuisine, region, channel, minMentions, false);
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
@@ -91,14 +88,17 @@ public class RestaurantController {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||
public Map<String, Object> update(@PathVariable String id, @Valid @RequestBody RestaurantUpdateDTO dto) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
|
||||
// #358 — DTO → Map (null 제외). 화이트리스트는 DTO 필드 자체로 표현.
|
||||
Map<String, Object> sanitized = dto.toFieldMap();
|
||||
|
||||
// Re-geocode if name or address changed
|
||||
String newName = (String) body.get("name");
|
||||
String newAddress = (String) body.get("address");
|
||||
String newName = (String) sanitized.get("name");
|
||||
String newAddress = (String) sanitized.get("address");
|
||||
boolean nameChanged = newName != null && !newName.equals(r.getName());
|
||||
boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress());
|
||||
if (nameChanged || addressChanged) {
|
||||
@@ -106,26 +106,30 @@ public class RestaurantController {
|
||||
String geoAddr = newAddress != null ? newAddress : r.getAddress();
|
||||
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
|
||||
if (geo != null) {
|
||||
body.put("latitude", geo.get("latitude"));
|
||||
body.put("longitude", geo.get("longitude"));
|
||||
body.put("google_place_id", geo.get("google_place_id"));
|
||||
sanitized.put("latitude", geo.get("latitude"));
|
||||
sanitized.put("longitude", geo.get("longitude"));
|
||||
sanitized.put("google_place_id", geo.get("google_place_id"));
|
||||
if (geo.containsKey("formatted_address")) {
|
||||
body.put("address", geo.get("formatted_address"));
|
||||
sanitized.put("address", geo.get("formatted_address"));
|
||||
}
|
||||
if (geo.containsKey("rating")) body.put("rating", geo.get("rating"));
|
||||
if (geo.containsKey("rating_count")) body.put("rating_count", geo.get("rating_count"));
|
||||
if (geo.containsKey("phone")) body.put("phone", geo.get("phone"));
|
||||
if (geo.containsKey("business_status")) body.put("business_status", geo.get("business_status"));
|
||||
if (geo.containsKey("rating")) sanitized.put("rating", geo.get("rating"));
|
||||
if (geo.containsKey("rating_count")) sanitized.put("rating_count", geo.get("rating_count"));
|
||||
if (geo.containsKey("phone")) sanitized.put("phone", geo.get("phone"));
|
||||
if (geo.containsKey("business_status")) sanitized.put("business_status", geo.get("business_status"));
|
||||
|
||||
// formatted_address에서 region 파싱 (예: "대한민국 서울특별시 강남구 ..." → "한국|서울|강남구")
|
||||
String addr = (String) geo.get("formatted_address");
|
||||
if (addr != null) {
|
||||
body.put("region", GeocodingService.parseRegionFromAddress(addr));
|
||||
sanitized.put("region", GeocodingService.parseRegionFromAddress(addr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restaurantService.update(id, body);
|
||||
if (sanitized.isEmpty()) {
|
||||
// 허용 키가 하나도 없으면 no-op
|
||||
return Map.of("ok", true, "restaurant", r);
|
||||
}
|
||||
|
||||
restaurantService.update(id, sanitized);
|
||||
cache.flush();
|
||||
var updated = restaurantService.findById(id);
|
||||
return Map.of("ok", true, "restaurant", updated);
|
||||
@@ -172,7 +176,7 @@ public class RestaurantController {
|
||||
@PostMapping("/bulk-tabling")
|
||||
public SseEmitter bulkTabling() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
@@ -249,8 +253,11 @@ public class RestaurantController {
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
String url = body.get("tabling_url");
|
||||
// #290 — javascript:/외부 악성 URL 차단. 빈 문자열은 매핑 해제로 허용.
|
||||
if (url != null && !url.isBlank() && !url.startsWith("https://tabling.co.kr/")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://tabling.co.kr/ 만 허용");
|
||||
// Naver/DDG 결과가 www.tabling.co.kr 형태로도 옴.
|
||||
if (url != null && !url.isBlank()
|
||||
&& !url.startsWith("https://tabling.co.kr/")
|
||||
&& !url.startsWith("https://www.tabling.co.kr/")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "테이블링 URL은 https://(www.)tabling.co.kr/ 만 허용");
|
||||
}
|
||||
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
|
||||
cache.flush();
|
||||
@@ -303,7 +310,7 @@ public class RestaurantController {
|
||||
@PostMapping("/bulk-catchtable")
|
||||
public SseEmitter bulkCatchtable() {
|
||||
AuthUtil.requireAdmin();
|
||||
SseEmitter emitter = new SseEmitter(600_000L);
|
||||
SseEmitter emitter = new SseEmitter(10_800_000L); // 3h — 대량 백필 대응
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
@@ -390,8 +397,10 @@ public class RestaurantController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/videos")
|
||||
public List<Map<String, Object>> videos(@PathVariable String id) {
|
||||
String key = cache.makeKey("restaurant_videos", id);
|
||||
public List<Map<String, Object>> videos(
|
||||
@PathVariable String id,
|
||||
@RequestParam(name = "include_weak", defaultValue = "false") boolean includeWeak) {
|
||||
String key = cache.makeKey("restaurant_videos", id, includeWeak ? "all" : "strong");
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
@@ -400,100 +409,25 @@ public class RestaurantController {
|
||||
}
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
var result = restaurantService.findVideoLinks(id);
|
||||
var result = restaurantService.findVideoLinks(id, includeWeak);
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── DuckDuckGo HTML search helpers ─────────────────────────────────
|
||||
// ─── 예약 사이트 URL 검색 (#357 WebSearchService: Naver primary + DDG fallback) ───
|
||||
|
||||
private static final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
private static final Pattern DDG_RESULT_PATTERN = Pattern.compile(
|
||||
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
|
||||
Pattern.DOTALL
|
||||
);
|
||||
|
||||
/**
|
||||
* DuckDuckGo HTML 검색을 통해 특정 사이트의 URL을 찾는다.
|
||||
* html.duckduckgo.com은 서버사이드 렌더링이라 봇 판정 없이 HTTP 요청만으로 결과를 파싱할 수 있다.
|
||||
*/
|
||||
private List<Map<String, Object>> searchDuckDuckGo(String query, String... urlPatterns) throws Exception {
|
||||
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
String searchUrl = "https://html.duckduckgo.com/html/?q=" + encoded;
|
||||
log.info("[DDG] Searching: {}", query);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(searchUrl))
|
||||
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||
.header("Accept", "text/html,application/xhtml+xml")
|
||||
.header("Accept-Language", "ko-KR,ko;q=0.9")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
String html = response.body();
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
Set<String> seen = new HashSet<>();
|
||||
Matcher matcher = DDG_RESULT_PATTERN.matcher(html);
|
||||
|
||||
while (matcher.find() && results.size() < 5) {
|
||||
String href = matcher.group(1);
|
||||
String title = matcher.group(2).replaceAll("<[^>]+>", "").trim();
|
||||
|
||||
// DDG 링크에서 실제 URL 추출 (uddg 파라미터)
|
||||
String actualUrl = extractDdgUrl(href);
|
||||
if (actualUrl == null) continue;
|
||||
|
||||
boolean matches = false;
|
||||
for (String pattern : urlPatterns) {
|
||||
if (actualUrl.contains(pattern)) {
|
||||
matches = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches && !seen.contains(actualUrl)) {
|
||||
seen.add(actualUrl);
|
||||
results.add(Map.of("title", title, "url", actualUrl));
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[DDG] Found {} results for '{}'", results.size(), query);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** DDG 리다이렉트 URL에서 실제 URL 추출 */
|
||||
private String extractDdgUrl(String ddgHref) {
|
||||
try {
|
||||
// //duckduckgo.com/l/?uddg=ENCODED_URL&rut=...
|
||||
if (ddgHref.contains("uddg=")) {
|
||||
String uddgParam = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
|
||||
int ampIdx = uddgParam.indexOf('&');
|
||||
if (ampIdx > 0) uddgParam = uddgParam.substring(0, ampIdx);
|
||||
return URLDecoder.decode(uddgParam, StandardCharsets.UTF_8);
|
||||
}
|
||||
// 직접 URL인 경우
|
||||
if (ddgHref.startsWith("http")) return ddgHref;
|
||||
} catch (Exception e) {
|
||||
log.debug("[DDG] Failed to extract URL from: {}", ddgHref);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> searchTabling(String restaurantName) throws Exception {
|
||||
return searchDuckDuckGo(
|
||||
private List<Map<String, Object>> searchTabling(String restaurantName) {
|
||||
return webSearch.search(
|
||||
"site:tabling.co.kr " + restaurantName,
|
||||
"tabling.co.kr/restaurant/", "tabling.co.kr/place/"
|
||||
);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> searchCatchtable(String restaurantName) throws Exception {
|
||||
return searchDuckDuckGo(
|
||||
private List<Map<String, Object>> searchCatchtable(String restaurantName) {
|
||||
// 실제 캐치테이블 URL은 /ct/shop/ 형식. 옛 /dining/ /shop/ 패턴은 매칭 실패.
|
||||
return webSearch.search(
|
||||
"site:app.catchtable.co.kr " + restaurantName,
|
||||
"catchtable.co.kr/dining/", "catchtable.co.kr/shop/"
|
||||
"catchtable.co.kr/ct/shop/", "catchtable.co.kr/ct/dining/"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -501,25 +435,12 @@ public class RestaurantController {
|
||||
* 식당 이름과 검색 결과 제목의 유사도 검사.
|
||||
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
|
||||
*/
|
||||
/**
|
||||
* #348 — 한국어 자모 분해 + Sørensen-Dice bigram 유사도(임계값 0.45).
|
||||
* 짧은 한국어 이름에서 이전 Jaccard-like(set 비율) 방식보다 정확.
|
||||
*/
|
||||
private boolean isNameSimilar(String restaurantName, String resultTitle) {
|
||||
String a = normalize(restaurantName);
|
||||
String b = normalize(resultTitle);
|
||||
if (a.isEmpty() || b.isEmpty()) return false;
|
||||
|
||||
// 포함 관계 체크
|
||||
if (a.contains(b) || b.contains(a)) return true;
|
||||
|
||||
// 공통 문자 비율 (Jaccard-like)
|
||||
var setA = a.chars().boxed().collect(java.util.stream.Collectors.toSet());
|
||||
var setB = b.chars().boxed().collect(java.util.stream.Collectors.toSet());
|
||||
long common = setA.stream().filter(setB::contains).count();
|
||||
double ratio = (double) common / Math.max(setA.size(), setB.size());
|
||||
return ratio >= 0.4;
|
||||
}
|
||||
|
||||
private String normalize(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replaceAll("[\\s·\\-_()()\\[\\]【】]", "").toLowerCase();
|
||||
return com.tasteby.util.HangulSimilarity.similarity(restaurantName, resultTitle) >= 0.45;
|
||||
}
|
||||
|
||||
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.SiteVisitStats;
|
||||
import com.tasteby.service.RateLimitService;
|
||||
import com.tasteby.service.StatsService;
|
||||
import com.tasteby.util.BotDetector;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -10,20 +15,51 @@ import java.util.Map;
|
||||
@RequestMapping("/api/stats")
|
||||
public class StatsController {
|
||||
|
||||
private final StatsService statsService;
|
||||
private static final Logger log = LoggerFactory.getLogger(StatsController.class);
|
||||
|
||||
public StatsController(StatsService statsService) {
|
||||
private final StatsService statsService;
|
||||
private final RateLimitService rateLimitService;
|
||||
|
||||
public StatsController(StatsService statsService, RateLimitService rateLimitService) {
|
||||
this.statsService = statsService;
|
||||
this.rateLimitService = rateLimitService;
|
||||
}
|
||||
|
||||
@PostMapping("/visit")
|
||||
public Map<String, Object> recordVisit() {
|
||||
public Map<String, Object> recordVisit(HttpServletRequest req) {
|
||||
// #337 — 봇 UA + IP 레이트리밋. 모두 통과해야 카운트 진행.
|
||||
String ua = req.getHeader("User-Agent");
|
||||
if (BotDetector.isBot(ua)) {
|
||||
log.debug("visit skipped (bot): {}", ua);
|
||||
return Map.of("ok", true, "counted", false);
|
||||
}
|
||||
|
||||
String clientIp = resolveClientIp(req);
|
||||
if (!rateLimitService.tryConsume(clientIp)) {
|
||||
log.debug("visit skipped (rate-limit): {}", clientIp);
|
||||
return Map.of("ok", true, "counted", false);
|
||||
}
|
||||
|
||||
statsService.recordVisit();
|
||||
return Map.of("ok", true);
|
||||
return Map.of("ok", true, "counted", true);
|
||||
}
|
||||
|
||||
@GetMapping("/visits")
|
||||
public SiteVisitStats getVisits() {
|
||||
return statsService.getVisits();
|
||||
}
|
||||
|
||||
/**
|
||||
* #337 — X-Forwarded-For 우선 (Nginx Ingress 뒤). chain이면 첫 번째(원본).
|
||||
* 없으면 RemoteAddr 폴백.
|
||||
*/
|
||||
private static String resolveClientIp(HttpServletRequest req) {
|
||||
String fwd = req.getHeader("X-Forwarded-For");
|
||||
if (fwd != null && !fwd.isBlank()) {
|
||||
int comma = fwd.indexOf(',');
|
||||
return (comma > 0 ? fwd.substring(0, comma) : fwd).trim();
|
||||
}
|
||||
String addr = req.getRemoteAddr();
|
||||
return addr != null ? addr : "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,4 +39,7 @@ public class Restaurant {
|
||||
// Transient enrichment fields
|
||||
private List<String> channels;
|
||||
private List<String> foodsMentioned;
|
||||
|
||||
// #533 — 의미있는(strong/unknown) 영상 언급 수
|
||||
private Integer videoCount;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.tasteby.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* #358 식당 부분 업데이트 DTO.
|
||||
* - null = 변경 없음 (toFieldMap에서 제외).
|
||||
* - 화이트리스트는 record 필드로 표현 — Jackson SNAKE_CASE 매핑 유지.
|
||||
* - URL: http(s) / "NONE" / 빈 문자열만 허용 ("NONE"은 DDG/Naver 매칭 실패 마킹).
|
||||
*/
|
||||
public record RestaurantUpdateDTO(
|
||||
@Size(min = 1, max = 200)
|
||||
String name,
|
||||
|
||||
@Size(max = 500)
|
||||
String address,
|
||||
|
||||
@Size(max = 100)
|
||||
String region,
|
||||
|
||||
@JsonProperty("cuisine_type")
|
||||
@Size(max = 50)
|
||||
String cuisineType,
|
||||
|
||||
@JsonProperty("price_range")
|
||||
@Min(1) @Max(5)
|
||||
Integer priceRange,
|
||||
|
||||
@Size(max = 50)
|
||||
String phone,
|
||||
|
||||
@Pattern(regexp = "^(https?://.*|NONE|)$")
|
||||
String website,
|
||||
|
||||
@JsonProperty("tabling_url")
|
||||
@Pattern(regexp = "^(https?://.*|NONE|)$")
|
||||
String tablingUrl,
|
||||
|
||||
@JsonProperty("catchtable_url")
|
||||
@Pattern(regexp = "^(https?://.*|NONE|)$")
|
||||
String catchtableUrl,
|
||||
|
||||
@DecimalMin("-90.0") @DecimalMax("90.0")
|
||||
BigDecimal latitude,
|
||||
|
||||
@DecimalMin("-180.0") @DecimalMax("180.0")
|
||||
BigDecimal longitude,
|
||||
|
||||
@JsonProperty("google_place_id")
|
||||
@Size(max = 200)
|
||||
String googlePlaceId,
|
||||
|
||||
@JsonProperty("business_status")
|
||||
@Size(max = 50)
|
||||
String businessStatus,
|
||||
|
||||
@DecimalMin("0.0") @DecimalMax("5.0")
|
||||
BigDecimal rating,
|
||||
|
||||
@JsonProperty("rating_count")
|
||||
@Min(0)
|
||||
Integer ratingCount
|
||||
) {
|
||||
/** null이 아닌 필드만 DB 컬럼명 키로 변환. */
|
||||
public Map<String, Object> toFieldMap() {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
if (name != null) m.put("name", name);
|
||||
if (address != null) m.put("address", address);
|
||||
if (region != null) m.put("region", region);
|
||||
if (cuisineType != null) m.put("cuisine_type", cuisineType);
|
||||
if (priceRange != null) m.put("price_range", priceRange);
|
||||
if (phone != null) m.put("phone", phone);
|
||||
if (website != null) m.put("website", website);
|
||||
if (tablingUrl != null) m.put("tabling_url", tablingUrl);
|
||||
if (catchtableUrl != null) m.put("catchtable_url", catchtableUrl);
|
||||
if (latitude != null) m.put("latitude", latitude);
|
||||
if (longitude != null) m.put("longitude", longitude);
|
||||
if (googlePlaceId != null) m.put("google_place_id", googlePlaceId);
|
||||
if (businessStatus != null) m.put("business_status", businessStatus);
|
||||
if (rating != null) m.put("rating", rating);
|
||||
if (ratingCount != null) m.put("rating_count", ratingCount);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public interface RestaurantMapper {
|
||||
@Param("cuisine") String cuisine,
|
||||
@Param("region") String region,
|
||||
@Param("channel") String channel,
|
||||
@Param("minMentions") Integer minMentions,
|
||||
@Param("includeHidden") boolean includeHidden);
|
||||
|
||||
// #322 LLM 검증: hidden 표시 갱신
|
||||
@@ -28,9 +29,24 @@ public interface RestaurantMapper {
|
||||
|
||||
int countUnverified();
|
||||
|
||||
// #356 영상-식당 관련도
|
||||
void updateLinkRelevance(@Param("linkId") String linkId,
|
||||
@Param("relevance") String relevance,
|
||||
@Param("reason") String reason);
|
||||
|
||||
Map<String, Object> findLinkContext(@Param("linkId") String linkId);
|
||||
|
||||
List<Map<String, Object>> findUnevaluatedLinks(@Param("limit") int limit);
|
||||
|
||||
int countUnevaluatedLinks();
|
||||
|
||||
// #359 1단계 — google_place_id 중복 조회
|
||||
List<Map<String, Object>> findDuplicatePlaceIdRows();
|
||||
|
||||
Restaurant findById(@Param("id") String id);
|
||||
|
||||
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId);
|
||||
List<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId,
|
||||
@Param("includeWeak") boolean includeWeak);
|
||||
|
||||
void insertRestaurant(Restaurant r);
|
||||
|
||||
|
||||
@@ -21,4 +21,6 @@ public interface UserMapper {
|
||||
List<UserInfo> findAllWithCounts(@Param("limit") int limit, @Param("offset") int offset);
|
||||
|
||||
int countAll();
|
||||
|
||||
int updateAdmin(@Param("id") String id, @Param("admin") int admin);
|
||||
}
|
||||
|
||||
@@ -150,26 +150,25 @@ public class OciGenAiService {
|
||||
return mapper.readValue(raw, Object.class);
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
// Try to recover truncated array
|
||||
// #326 — Recover truncated array. Brace depth counter로 단일 패스 O(N).
|
||||
// 이전: 각 idx에서 end를 1씩 늘려가며 매번 readValue → O(N²) + 예외 스택트레이스 양산.
|
||||
if (raw.trim().startsWith("[")) {
|
||||
List<Object> items = new ArrayList<>();
|
||||
int idx = raw.indexOf('[') + 1;
|
||||
while (idx < raw.length()) {
|
||||
while (idx < raw.length() && " \t\n\r,".indexOf(raw.charAt(idx)) >= 0) idx++;
|
||||
if (idx >= raw.length() || raw.charAt(idx) == ']') break;
|
||||
if (raw.charAt(idx) != '{') break; // 객체 시작이 아니면 복구 중단
|
||||
|
||||
// Try to parse next object
|
||||
boolean found = false;
|
||||
for (int end = idx + 1; end <= raw.length(); end++) {
|
||||
try {
|
||||
Object obj = mapper.readValue(raw.substring(idx, end), Object.class);
|
||||
items.add(obj);
|
||||
idx = end;
|
||||
found = true;
|
||||
break;
|
||||
} catch (Exception ignored2) {}
|
||||
int end = findObjectEnd(raw, idx);
|
||||
if (end < 0) break; // 잘린 객체 — 거기서 멈춤
|
||||
try {
|
||||
Object obj = mapper.readValue(raw.substring(idx, end + 1), Object.class);
|
||||
items.add(obj);
|
||||
} catch (Exception ignored2) {
|
||||
break; // 불가해 객체 — 멈춤
|
||||
}
|
||||
if (!found) break;
|
||||
idx = end + 1;
|
||||
}
|
||||
if (!items.isEmpty()) {
|
||||
log.info("Recovered {} items from truncated JSON", items.size());
|
||||
@@ -179,4 +178,27 @@ public class OciGenAiService {
|
||||
|
||||
throw new RuntimeException("JSON parse failed: " + raw.substring(0, Math.min(80, raw.length())));
|
||||
}
|
||||
|
||||
/**
|
||||
* #326 — JSON 객체 시작 위치(`{`)에서 매칭되는 닫는 `}` 인덱스를 반환.
|
||||
* 문자열 안의 `{` `}`와 escape는 무시. 매칭 못 찾으면 -1.
|
||||
*/
|
||||
private static int findObjectEnd(String raw, int start) {
|
||||
int depth = 0;
|
||||
boolean inString = false;
|
||||
boolean escaped = false;
|
||||
for (int i = start; i < raw.length(); i++) {
|
||||
char c = raw.charAt(i);
|
||||
if (escaped) { escaped = false; continue; }
|
||||
if (c == '\\') { escaped = true; continue; }
|
||||
if (c == '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (c == '{') depth++;
|
||||
else if (c == '}') {
|
||||
depth--;
|
||||
if (depth == 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public class PipelineService {
|
||||
private final VectorService vectorService;
|
||||
private final CacheService cacheService;
|
||||
private final RestaurantVerifyService verifyService;
|
||||
private final VideoRelevanceService relevanceService;
|
||||
|
||||
public PipelineService(YouTubeService youTubeService,
|
||||
ExtractorService extractorService,
|
||||
@@ -37,7 +38,8 @@ public class PipelineService {
|
||||
VideoService videoService,
|
||||
VectorService vectorService,
|
||||
CacheService cacheService,
|
||||
RestaurantVerifyService verifyService) {
|
||||
RestaurantVerifyService verifyService,
|
||||
VideoRelevanceService relevanceService) {
|
||||
this.youTubeService = youTubeService;
|
||||
this.extractorService = extractorService;
|
||||
this.geocodingService = geocodingService;
|
||||
@@ -46,6 +48,7 @@ public class PipelineService {
|
||||
this.vectorService = vectorService;
|
||||
this.cacheService = cacheService;
|
||||
this.verifyService = verifyService;
|
||||
this.relevanceService = relevanceService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,13 +148,16 @@ public class PipelineService {
|
||||
evaluationJson = JsonUtil.toJson(s);
|
||||
}
|
||||
|
||||
restaurantService.linkVideoRestaurant(
|
||||
String linkId = restaurantService.linkVideoRestaurant(
|
||||
videoDbId, restId,
|
||||
foods instanceof List<?> ? (List<String>) foods : null,
|
||||
evaluationJson,
|
||||
guests instanceof List<?> ? (List<String>) guests : null
|
||||
);
|
||||
|
||||
// #356 — 신규 등록 직후 비동기 관련도 평가
|
||||
relevanceService.verifyAsync(linkId);
|
||||
|
||||
// Vector embeddings
|
||||
var chunks = VectorService.buildChunks(name, restData, title);
|
||||
if (!chunks.isEmpty()) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* #337 — IP 기반 레이트리밋 (방문 카운트 어뷰즈 차단).
|
||||
*
|
||||
* 단순 Redis SETIFABSENT(SET NX EX) 패턴:
|
||||
* - 첫 호출 시 키 등록 + TTL → 허용
|
||||
* - TTL 동안 다음 호출은 키 존재로 차단
|
||||
*
|
||||
* Redis 다운 시 fail-open (true 반환) — 사용자 페이지 로드 우선.
|
||||
* 멀티 파드 + Redis 단일 인스턴스 환경에서 자연스럽게 동작.
|
||||
*/
|
||||
@Service
|
||||
public class RateLimitService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RateLimitService.class);
|
||||
private static final String PREFIX = "ratelimit:visit:";
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
|
||||
@Value("${app.rate-limit.visit-window-seconds:60}")
|
||||
private long visitWindowSeconds;
|
||||
|
||||
public RateLimitService(StringRedisTemplate redis) {
|
||||
this.redis = redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 IP의 visit 호출 허용 여부.
|
||||
* @return true = 허용 (첫 호출 또는 윈도우 만료), false = 차단 (윈도우 안 재호출)
|
||||
*/
|
||||
public boolean tryConsume(String ipKey) {
|
||||
try {
|
||||
String key = PREFIX + ipKey;
|
||||
Boolean ok = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(visitWindowSeconds));
|
||||
return Boolean.TRUE.equals(ok);
|
||||
} catch (Exception e) {
|
||||
// fail-open: Redis 문제로 통계가 약간 부풀어도 사용자 영향 X
|
||||
log.debug("RateLimit error (fail-open): {}", e.getMessage());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,13 @@ public class RestaurantService {
|
||||
}
|
||||
|
||||
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
|
||||
return findAll(limit, offset, cuisine, region, channel, false);
|
||||
return findAll(limit, offset, cuisine, region, channel, null, false);
|
||||
}
|
||||
|
||||
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel, boolean includeHidden) {
|
||||
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel, includeHidden);
|
||||
// #533 — minMentions: 의미있는 언급 수가 N 미만인 식당 제외 (null/0이면 미적용)
|
||||
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel,
|
||||
Integer minMentions, boolean includeHidden) {
|
||||
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel, minMentions, includeHidden);
|
||||
enrichRestaurants(restaurants);
|
||||
return restaurants;
|
||||
}
|
||||
@@ -77,7 +79,11 @@ public class RestaurantService {
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findVideoLinks(String restaurantId) {
|
||||
var rows = mapper.findVideoLinks(restaurantId);
|
||||
return findVideoLinks(restaurantId, false);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findVideoLinks(String restaurantId, boolean includeWeak) {
|
||||
var rows = mapper.findVideoLinks(restaurantId, includeWeak);
|
||||
return rows.stream().map(row -> {
|
||||
var m = JsonUtil.lowerKeys(row);
|
||||
m.put("foods_mentioned", JsonUtil.parseStringList(m.get("foods_mentioned")));
|
||||
@@ -87,6 +93,43 @@ public class RestaurantService {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// #356 영상-식당 관련도
|
||||
public void updateLinkRelevance(String linkId, String relevance, String reason) {
|
||||
mapper.updateLinkRelevance(linkId, relevance, reason);
|
||||
}
|
||||
|
||||
public Map<String, Object> findLinkContext(String linkId) {
|
||||
var row = mapper.findLinkContext(linkId);
|
||||
return row != null ? JsonUtil.lowerKeys(row) : null;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> findUnevaluatedLinks(int limit) {
|
||||
return mapper.findUnevaluatedLinks(limit).stream()
|
||||
.map(JsonUtil::lowerKeys)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public int countUnevaluatedLinks() {
|
||||
return mapper.countUnevaluatedLinks();
|
||||
}
|
||||
|
||||
// #359 1단계 — google_place_id 중복 그룹 (참조 카운트 동봉)
|
||||
public List<Map<String, Object>> findDuplicatePlaceIdGroups() {
|
||||
var rows = mapper.findDuplicatePlaceIdRows().stream()
|
||||
.map(JsonUtil::lowerKeys)
|
||||
.toList();
|
||||
Map<String, List<Map<String, Object>>> grouped = new LinkedHashMap<>();
|
||||
for (var r : rows) {
|
||||
String key = (String) r.get("google_place_id");
|
||||
grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(r);
|
||||
}
|
||||
List<Map<String, Object>> out = new ArrayList<>(grouped.size());
|
||||
for (var e : grouped.entrySet()) {
|
||||
out.add(Map.of("google_place_id", e.getKey(), "items", e.getValue()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public void update(String id, Map<String, Object> fields) {
|
||||
mapper.updateFields(id, fields);
|
||||
}
|
||||
@@ -138,12 +181,13 @@ public class RestaurantService {
|
||||
}
|
||||
}
|
||||
|
||||
public void linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> guests) {
|
||||
public String linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> guests) {
|
||||
String id = IdGenerator.newId();
|
||||
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
|
||||
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
|
||||
String evalJson = JsonUtil.normalizeEvaluation(evaluation);
|
||||
mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evalJson, guestsJson);
|
||||
return id;
|
||||
}
|
||||
|
||||
public void updateCuisineType(String id, String cuisineType) {
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.tasteby.service;
|
||||
import com.tasteby.domain.UserInfo;
|
||||
import com.tasteby.mapper.UserMapper;
|
||||
import com.tasteby.util.IdGenerator;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -47,4 +49,12 @@ public class UserService {
|
||||
public int countAll() {
|
||||
return mapper.countAll();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateAdmin(String userId, boolean admin) {
|
||||
int rows = mapper.updateAdmin(userId, admin ? 1 : 0);
|
||||
if (rows == 0) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.util.IdGenerator;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
@@ -64,6 +66,9 @@ public class VectorService {
|
||||
|
||||
/**
|
||||
* Save vector embeddings for a restaurant.
|
||||
*
|
||||
* #331 — N개 청크를 단일 batchUpdate 호출로 처리 (이전: N+1 INSERT round-trip).
|
||||
* UUID 생성은 IdGenerator.newId() 공통 유틸 사용 (인라인 변환 코드 제거).
|
||||
*/
|
||||
public void saveRestaurantVectors(String restaurantId, List<String> chunks) {
|
||||
if (chunks.isEmpty()) return;
|
||||
@@ -75,19 +80,20 @@ public class VectorService {
|
||||
VALUES (:id, :rid, :chunk, :emb)
|
||||
""";
|
||||
|
||||
SqlParameterSource[] batch = new SqlParameterSource[chunks.size()];
|
||||
for (int i = 0; i < chunks.size(); i++) {
|
||||
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase();
|
||||
float[] vec = new float[embeddings.get(i).size()];
|
||||
List<Double> emb = embeddings.get(i);
|
||||
float[] vec = new float[emb.size()];
|
||||
for (int j = 0; j < vec.length; j++) {
|
||||
vec[j] = embeddings.get(i).get(j).floatValue();
|
||||
vec[j] = emb.get(j).floatValue();
|
||||
}
|
||||
var params = new MapSqlParameterSource();
|
||||
params.addValue("id", id);
|
||||
params.addValue("rid", restaurantId);
|
||||
params.addValue("chunk", chunks.get(i));
|
||||
params.addValue("emb", vec);
|
||||
jdbc.update(sql, params);
|
||||
batch[i] = new MapSqlParameterSource()
|
||||
.addValue("id", IdGenerator.newId())
|
||||
.addValue("rid", restaurantId)
|
||||
.addValue("chunk", chunks.get(i))
|
||||
.addValue("emb", vec);
|
||||
}
|
||||
jdbc.batchUpdate(sql, batch);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* #356 영상-식당 관련도 LLM 평가.
|
||||
* 설계서: docs/design/356-video-relevance-llm/README.md
|
||||
*
|
||||
* 신규 등록 시 자동 평가 + 어드민 백필. 결과는 video_restaurants.relevance에 저장.
|
||||
* - strong: 본격 다룸 (방문 리뷰, 메뉴 평가)
|
||||
* - weak: 잠깐 언급, 비교 대상
|
||||
* - incidental: 일반 토픽 중 단순 언급, 입점 전
|
||||
* - unknown: 미평가 or LLM 실패 (안전 기본값으로 표시 유지)
|
||||
*/
|
||||
@Service
|
||||
public class VideoRelevanceService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(VideoRelevanceService.class);
|
||||
private static final Set<String> VALID = Set.of("strong", "weak", "incidental", "unknown");
|
||||
private static final long BACKFILL_SLEEP_MS = 200;
|
||||
|
||||
private final RestaurantService restaurantService;
|
||||
private final OciGenAiService genAi;
|
||||
private final ObjectMapper jsonMapper = new ObjectMapper();
|
||||
|
||||
public VideoRelevanceService(RestaurantService restaurantService, OciGenAiService genAi) {
|
||||
this.restaurantService = restaurantService;
|
||||
this.genAi = genAi;
|
||||
}
|
||||
|
||||
@Async
|
||||
public void verifyAsync(String linkId) {
|
||||
try {
|
||||
verify(linkId);
|
||||
} catch (Exception e) {
|
||||
log.warn("verifyAsync failed for link {}: {}", linkId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void verify(String linkId) {
|
||||
Map<String, Object> ctx = restaurantService.findLinkContext(linkId);
|
||||
if (ctx == null) return;
|
||||
VerifyResult result;
|
||||
try {
|
||||
String prompt = buildPrompt(ctx);
|
||||
String response = genAi.chat(prompt, 120);
|
||||
result = parseRelevance(response);
|
||||
} catch (Exception e) {
|
||||
log.warn("verify({}) LLM failed: {} — keeping unknown", linkId, e.getMessage());
|
||||
return;
|
||||
}
|
||||
restaurantService.updateLinkRelevance(linkId, result.relevance(), truncate(result.reason(), 120));
|
||||
}
|
||||
|
||||
@Async
|
||||
public void verifyAllAsync(int batchSize) {
|
||||
try {
|
||||
int n = verifyAll(batchSize);
|
||||
log.info("[VideoRelevance] backfill done: {} processed", n);
|
||||
} catch (Exception e) {
|
||||
log.warn("verifyAllAsync failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public int verifyAll(int batchSize) {
|
||||
int total = 0;
|
||||
List<Map<String, Object>> batch;
|
||||
while (!(batch = restaurantService.findUnevaluatedLinks(batchSize)).isEmpty()) {
|
||||
for (Map<String, Object> row : batch) {
|
||||
String linkId = (String) row.get("link_id");
|
||||
if (linkId == null) continue;
|
||||
try {
|
||||
verify(linkId);
|
||||
} catch (Exception e) {
|
||||
log.warn("verifyAll({}) failed: {}", linkId, e.getMessage());
|
||||
}
|
||||
total++;
|
||||
try { Thread.sleep(BACKFILL_SLEEP_MS); } catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return total;
|
||||
}
|
||||
}
|
||||
if (batch.size() < batchSize) break;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ---- pure helpers ----
|
||||
|
||||
String buildPrompt(Map<String, Object> ctx) {
|
||||
String foods = safeStr(ctx.get("foods_mentioned"));
|
||||
String evaluation = safeStr(ctx.get("evaluation"));
|
||||
return "다음 YouTube 영상이 이 식당을 어떻게 다루는지 판정하라.\n\n" +
|
||||
"식당명: " + safeStr(ctx.get("restaurant_name")) + "\n" +
|
||||
"주소: " + safeStr(ctx.get("address")) + "\n" +
|
||||
"음식 분류: " + safeStr(ctx.get("cuisine_type")) + "\n" +
|
||||
"언급된 음식: " + (foods.isEmpty() ? "(없음)" : foods) + "\n\n" +
|
||||
"영상 제목: " + safeStr(ctx.get("video_title")) + "\n" +
|
||||
"영상 채널: " + safeStr(ctx.get("channel_name")) + "\n" +
|
||||
"영상에 등장한 평가: " + (evaluation.isEmpty() ? "(없음)" : evaluation) + "\n\n" +
|
||||
"응답 형식(JSON만, 다른 텍스트 없이):\n" +
|
||||
"{\"relevance\": \"strong\"|\"weak\"|\"incidental\", \"reason\": \"20자 이내 한국어\"}\n\n" +
|
||||
"가이드:\n" +
|
||||
"- strong: 영상이 이 식당을 본격 다룸 (방문 리뷰, 메뉴 평가).\n" +
|
||||
"- weak: 잠깐 언급, 다른 식당과 비교 대상으로 등장.\n" +
|
||||
"- incidental: 일반 토픽 중 단순 언급, 식당 입점 전 영상.\n" +
|
||||
"- 판단 모호 시 strong (보수적 — 사용자에게 표시 유지).";
|
||||
}
|
||||
|
||||
private static final Pattern JSON_BLOCK = Pattern.compile("\\{[^{}]*\\}", Pattern.DOTALL);
|
||||
|
||||
VerifyResult parseRelevance(String raw) {
|
||||
if (raw == null) return VerifyResult.unknown();
|
||||
String trimmed = raw.trim();
|
||||
String json = (trimmed.startsWith("{") && trimmed.endsWith("}")) ? trimmed : null;
|
||||
if (json == null) {
|
||||
Matcher m = JSON_BLOCK.matcher(raw);
|
||||
if (m.find()) json = m.group();
|
||||
}
|
||||
if (json == null) return VerifyResult.unknown();
|
||||
try {
|
||||
JsonNode node = jsonMapper.readTree(json);
|
||||
String rel = node.path("relevance").asText("unknown").toLowerCase();
|
||||
if (!VALID.contains(rel)) rel = "unknown";
|
||||
String reason = node.path("reason").asText("");
|
||||
return new VerifyResult(rel, reason);
|
||||
} catch (Exception e) {
|
||||
return VerifyResult.unknown();
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeStr(Object o) {
|
||||
return o == null ? "" : o.toString();
|
||||
}
|
||||
|
||||
private static String truncate(String s, int max) {
|
||||
return s == null ? null : (s.length() <= max ? s : s.substring(0, max));
|
||||
}
|
||||
|
||||
public record VerifyResult(String relevance, String reason) {
|
||||
public static VerifyResult unknown() { return new VerifyResult("unknown", "parse_failed"); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* #357 웹 검색 추상화.
|
||||
* - Naver Search webkr.json 우선 (한국 식당 정확도 높음, 무료 일 25k).
|
||||
* - 키 미설정 또는 5xx/timeout 시 DDG HTML 파싱으로 폴백.
|
||||
* - 결과는 urlPatterns로 필터링 (기존 searchDuckDuckGo와 동일 인터페이스).
|
||||
*/
|
||||
@Service
|
||||
public class WebSearchService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WebSearchService.class);
|
||||
private static final int MAX_RESULTS = 5;
|
||||
|
||||
private static final Duration REQ_TIMEOUT = Duration.ofSeconds(15);
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
|
||||
private static final Pattern DDG_RESULT = Pattern.compile(
|
||||
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
|
||||
Pattern.DOTALL);
|
||||
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
private final String naverClientId;
|
||||
private final String naverClientSecret;
|
||||
|
||||
public WebSearchService(
|
||||
@Value("${app.naver.client-id:}") String naverClientId,
|
||||
@Value("${app.naver.client-secret:}") String naverClientSecret) {
|
||||
this.naverClientId = naverClientId == null ? "" : naverClientId.trim();
|
||||
this.naverClientSecret = naverClientSecret == null ? "" : naverClientSecret.trim();
|
||||
log.info("WebSearchService init — Naver={}", naverClientId.isEmpty() ? "off" : "on");
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> search(String query, String... urlPatterns) {
|
||||
if (!naverClientId.isEmpty() && !naverClientSecret.isEmpty()) {
|
||||
try {
|
||||
List<Map<String, Object>> n = searchNaver(query, urlPatterns);
|
||||
if (!n.isEmpty()) return n;
|
||||
} catch (Exception e) {
|
||||
log.warn("[NaverSearch] failed, falling back to DDG: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
try {
|
||||
return searchDdg(query, urlPatterns);
|
||||
} catch (Exception e) {
|
||||
log.warn("[DDG] failed: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Naver ───
|
||||
|
||||
List<Map<String, Object>> searchNaver(String query, String... urlPatterns) throws Exception {
|
||||
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
String url = "https://openapi.naver.com/v1/search/webkr.json?query=" + encoded + "&display=30";
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(REQ_TIMEOUT)
|
||||
.header("X-Naver-Client-Id", naverClientId)
|
||||
.header("X-Naver-Client-Secret", naverClientSecret)
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() >= 400) {
|
||||
throw new RuntimeException("Naver " + resp.statusCode());
|
||||
}
|
||||
JsonNode root = json.readTree(resp.body());
|
||||
JsonNode items = root.path("items");
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (JsonNode it : items) {
|
||||
if (out.size() >= MAX_RESULTS) break;
|
||||
String link = it.path("link").asText("");
|
||||
String title = stripTags(it.path("title").asText(""));
|
||||
if (link.isEmpty() || !matchesPattern(link, urlPatterns)) continue;
|
||||
if (seen.add(link)) out.add(Map.of("title", title, "url", link));
|
||||
}
|
||||
log.info("[NaverSearch] '{}' → {}", query, out.size());
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── DDG ───
|
||||
|
||||
List<Map<String, Object>> searchDdg(String query, String... urlPatterns) throws Exception {
|
||||
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
String url = "https://html.duckduckgo.com/html/?q=" + encoded;
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.timeout(REQ_TIMEOUT)
|
||||
.header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||
.header("Accept", "text/html,application/xhtml+xml")
|
||||
.header("Accept-Language", "ko-KR,ko;q=0.9")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
String html = resp.body();
|
||||
Matcher m = DDG_RESULT.matcher(html);
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
Set<String> seen = new HashSet<>();
|
||||
while (m.find() && out.size() < MAX_RESULTS) {
|
||||
String href = m.group(1);
|
||||
String title = m.group(2).replaceAll("<[^>]+>", "").trim();
|
||||
String actual = extractDdgUrl(href);
|
||||
if (actual == null || !matchesPattern(actual, urlPatterns)) continue;
|
||||
if (seen.add(actual)) out.add(Map.of("title", title, "url", actual));
|
||||
}
|
||||
log.info("[DDG] '{}' → {}", query, out.size());
|
||||
return out;
|
||||
}
|
||||
|
||||
private String extractDdgUrl(String ddgHref) {
|
||||
try {
|
||||
if (ddgHref.contains("uddg=")) {
|
||||
String p = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
|
||||
int amp = p.indexOf('&');
|
||||
if (amp > 0) p = p.substring(0, amp);
|
||||
return URLDecoder.decode(p, StandardCharsets.UTF_8);
|
||||
}
|
||||
if (ddgHref.startsWith("http")) return ddgHref;
|
||||
} catch (Exception e) {
|
||||
log.debug("[DDG] url extract failed: {}", ddgHref);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String stripTags(String s) {
|
||||
return s == null ? "" : s.replaceAll("<[^>]+>", "").trim();
|
||||
}
|
||||
|
||||
static boolean matchesPattern(String url, String[] patterns) {
|
||||
if (patterns == null || patterns.length == 0) return true;
|
||||
for (String p : patterns) {
|
||||
if (url.contains(p)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
25
backend-java/src/main/java/com/tasteby/util/BotDetector.java
Normal file
25
backend-java/src/main/java/com/tasteby/util/BotDetector.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.tasteby.util;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* #337 — User-Agent 기반 봇 패턴 매칭.
|
||||
*
|
||||
* Googlebot / bingbot / facebookexternalhit / 일반 crawler/spider 등을 일괄 차단.
|
||||
* 빈 UA는 봇으로 간주하지 않음(모바일 앱 등 정상 케이스 보호).
|
||||
*/
|
||||
public final class BotDetector {
|
||||
|
||||
private BotDetector() {}
|
||||
|
||||
// 일반적인 봇/크롤러 패턴. 케이스 무시.
|
||||
private static final Pattern BOT_PATTERN = Pattern.compile(
|
||||
"bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
|
||||
public static boolean isBot(String userAgent) {
|
||||
if (userAgent == null || userAgent.isBlank()) return false;
|
||||
return BOT_PATTERN.matcher(userAgent).find();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.tasteby.util;
|
||||
|
||||
import java.text.Normalizer;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* #348 — 한국어 자모 분해(Unicode NFD) + Sørensen-Dice bigram 유사도.
|
||||
*
|
||||
* 음절 단위 Jaccard보다 짧은 한국어 이름에 정확. 예:
|
||||
* similarity("스타벅스 강남", "스타벅스 강남점") ≈ 0.85+
|
||||
* similarity("스타벅스 강남", "스타벅스 종로") ≈ 0.55~0.85
|
||||
* similarity("스타벅스", "맥도날드") < 0.20
|
||||
*
|
||||
* 공백/구두점은 제거하고 소문자화한 뒤 NFD 분해.
|
||||
*/
|
||||
public final class HangulSimilarity {
|
||||
|
||||
private HangulSimilarity() {}
|
||||
|
||||
/** 공백/구두점 제거 + 소문자화 + NFD 분해(한글 음절 → 자모). */
|
||||
public static String decompose(String s) {
|
||||
if (s == null || s.isEmpty()) return "";
|
||||
String stripped = s.replaceAll("[\\s·\\-_()()\\[\\]【】]", "").toLowerCase();
|
||||
return Normalizer.normalize(stripped, Normalizer.Form.NFD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sørensen-Dice 계수 (bigram multiset 기반). 0.0~1.0.
|
||||
* 동일 문자열 → 1.0. 빈 입력 → 0.0.
|
||||
*/
|
||||
public static double similarity(String a, String b) {
|
||||
String da = decompose(a);
|
||||
String db = decompose(b);
|
||||
if (da.isEmpty() || db.isEmpty()) return 0.0;
|
||||
if (da.equals(db)) return 1.0;
|
||||
|
||||
// 포함 관계는 강한 신호로 1.0 처리 (기존 동작과 일관)
|
||||
if (da.contains(db) || db.contains(da)) return 1.0;
|
||||
|
||||
if (da.length() < 2 || db.length() < 2) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
Map<String, Integer> bigramsA = bigrams(da);
|
||||
Map<String, Integer> bigramsB = bigrams(db);
|
||||
int common = 0;
|
||||
for (var e : bigramsA.entrySet()) {
|
||||
Integer countB = bigramsB.get(e.getKey());
|
||||
if (countB != null) {
|
||||
common += Math.min(e.getValue(), countB);
|
||||
}
|
||||
}
|
||||
int sizeA = da.length() - 1;
|
||||
int sizeB = db.length() - 1;
|
||||
return (2.0 * common) / (sizeA + sizeB);
|
||||
}
|
||||
|
||||
private static Map<String, Integer> bigrams(String s) {
|
||||
Map<String, Integer> map = new HashMap<>();
|
||||
for (int i = 0; i < s.length() - 1; i++) {
|
||||
String gram = s.substring(i, i + 2);
|
||||
map.merge(gram, 1, Integer::sum);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,8 @@ public final class JsonUtil {
|
||||
try {
|
||||
return MAPPER.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
return Collections.emptyMap();
|
||||
// Plain text or malformed JSON (e.g. Python-style single quotes) → wrap as {"text": "..."}
|
||||
return Map.of("text", json.trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +75,24 @@ public final class JsonUtil {
|
||||
return rows.stream().map(JsonUtil::lowerKeys).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize evaluation to a valid JSON object string (e.g. {"text":"..."}).
|
||||
* Plain text is wrapped, already-valid JSON is returned as-is, and text is truncated to maxLen.
|
||||
*/
|
||||
public static String normalizeEvaluation(String eval, int maxLen) {
|
||||
if (eval == null || eval.isBlank()) return null;
|
||||
String trimmed = eval.trim();
|
||||
if (trimmed.startsWith("{")) return trimmed;
|
||||
if (trimmed.length() > maxLen) {
|
||||
trimmed = trimmed.substring(0, maxLen);
|
||||
}
|
||||
return toJson(Map.of("text", trimmed));
|
||||
}
|
||||
|
||||
public static String normalizeEvaluation(String eval) {
|
||||
return normalizeEvaluation(eval, 300);
|
||||
}
|
||||
|
||||
public static String toJson(Object value) {
|
||||
try {
|
||||
return MAPPER.writeValueAsString(value);
|
||||
|
||||
@@ -56,6 +56,11 @@ app:
|
||||
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
|
||||
client-id: ${GOOGLE_CLIENT_ID:635551099330-2l003d3ernjmkqavd4f6s78r8r405iml.apps.googleusercontent.com}
|
||||
|
||||
# #357 — Naver Search API (Tabling/Catchtable URL 매칭). 미설정 시 DDG 폴백.
|
||||
naver:
|
||||
client-id: ${NAVER_CLIENT_ID:}
|
||||
client-secret: ${NAVER_CLIENT_SECRET:}
|
||||
|
||||
cache:
|
||||
ttl-seconds: 600
|
||||
|
||||
@@ -64,6 +69,10 @@ app:
|
||||
# 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값.
|
||||
max-distance: ${SEARCH_MAX_DISTANCE:0.57}
|
||||
|
||||
rate-limit:
|
||||
# #337 — 같은 IP에서 visit 카운트 허용 간격(초). 기본 60.
|
||||
visit-window-seconds: ${VISIT_WINDOW_SECONDS:60}
|
||||
|
||||
build:
|
||||
# #338 — 배포 시 deploy.sh가 env로 주입. dev에서는 dev/unknown.
|
||||
version: ${APP_VERSION:dev}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- #356 영상-식당 관련도 LLM 평가
|
||||
ALTER TABLE video_restaurants ADD (
|
||||
relevance VARCHAR2(16) DEFAULT 'unknown' NOT NULL,
|
||||
relevance_reason VARCHAR2(120),
|
||||
relevance_evaluated_at TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_vr_relevance ON video_restaurants(relevance);
|
||||
@@ -25,6 +25,7 @@
|
||||
<result property="hidden" column="hidden" javaType="java.lang.Boolean"/>
|
||||
<result property="hiddenReason" column="hidden_reason"/>
|
||||
<result property="verifiedAt" column="verified_at"/>
|
||||
<result property="videoCount" column="video_count"/>
|
||||
</resultMap>
|
||||
|
||||
<!-- ===== Queries ===== -->
|
||||
@@ -33,7 +34,9 @@
|
||||
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||
r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
|
||||
r.business_status, r.rating, r.rating_count, r.updated_at,
|
||||
r.hidden, r.hidden_reason, r.verified_at
|
||||
r.hidden, r.hidden_reason, r.verified_at,
|
||||
(SELECT COUNT(*) FROM video_restaurants vrc
|
||||
WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) AS video_count
|
||||
FROM restaurants r
|
||||
<if test="channel != null and channel != ''">
|
||||
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
|
||||
@@ -55,6 +58,10 @@
|
||||
<if test="channel != null and channel != ''">
|
||||
AND c_f.channel_name = #{channel}
|
||||
</if>
|
||||
<if test="minMentions != null and minMentions > 0">
|
||||
AND (SELECT COUNT(*) FROM video_restaurants vrc
|
||||
WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) >= #{minMentions}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY r.updated_at DESC
|
||||
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
|
||||
@@ -69,14 +76,20 @@
|
||||
</select>
|
||||
|
||||
<select id="findVideoLinks" resultType="map">
|
||||
SELECT v.video_id, v.title, v.url,
|
||||
<!-- #356 — relevance 컬럼 SELECT + includeWeak 가드 -->
|
||||
SELECT vr.id AS link_id,
|
||||
v.video_id, v.title, v.url,
|
||||
TO_CHAR(v.published_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS published_at,
|
||||
vr.foods_mentioned, vr.evaluation, vr.guests,
|
||||
vr.relevance, vr.relevance_reason,
|
||||
c.channel_name, c.channel_id
|
||||
FROM video_restaurants vr
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
JOIN channels c ON c.id = v.channel_id
|
||||
WHERE vr.restaurant_id = #{restaurantId}
|
||||
<if test="includeWeak == null or !includeWeak">
|
||||
AND vr.relevance IN ('strong', 'unknown')
|
||||
</if>
|
||||
ORDER BY v.published_at DESC
|
||||
</select>
|
||||
|
||||
@@ -315,4 +328,53 @@
|
||||
SELECT COUNT(*) FROM restaurants WHERE verified_at IS NULL
|
||||
</select>
|
||||
|
||||
<!-- ===== #356 영상-식당 관련도 ===== -->
|
||||
|
||||
<update id="updateLinkRelevance">
|
||||
UPDATE video_restaurants
|
||||
SET relevance = #{relevance},
|
||||
relevance_reason = #{reason,jdbcType=VARCHAR},
|
||||
relevance_evaluated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{linkId}
|
||||
</update>
|
||||
|
||||
<select id="findLinkContext" resultType="map">
|
||||
<!-- LLM 평가에 필요한 정보 -->
|
||||
SELECT vr.id AS link_id, vr.foods_mentioned, vr.evaluation,
|
||||
r.id AS restaurant_id, r.name AS restaurant_name, r.address, r.cuisine_type,
|
||||
v.title AS video_title, c.channel_name
|
||||
FROM video_restaurants vr
|
||||
JOIN restaurants r ON r.id = vr.restaurant_id
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
JOIN channels c ON c.id = v.channel_id
|
||||
WHERE vr.id = #{linkId}
|
||||
</select>
|
||||
|
||||
<select id="findUnevaluatedLinks" resultType="map">
|
||||
SELECT id AS link_id FROM video_restaurants
|
||||
WHERE relevance_evaluated_at IS NULL
|
||||
FETCH FIRST #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
<select id="countUnevaluatedLinks" resultType="int">
|
||||
SELECT COUNT(*) FROM video_restaurants WHERE relevance_evaluated_at IS NULL
|
||||
</select>
|
||||
|
||||
<!-- #359 1단계 — google_place_id 중복 조회 (그룹 식당 + 참조 카운트) -->
|
||||
<select id="findDuplicatePlaceIdRows" resultType="map">
|
||||
SELECT r.id, r.google_place_id, r.name, r.address,
|
||||
TO_CHAR(r.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS created_at,
|
||||
r.hidden,
|
||||
(SELECT COUNT(*) FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS video_count,
|
||||
(SELECT COUNT(*) FROM user_reviews rv WHERE rv.restaurant_id = r.id) AS review_count,
|
||||
(SELECT COUNT(*) FROM user_memos mm WHERE mm.restaurant_id = r.id) AS memo_count
|
||||
FROM restaurants r
|
||||
WHERE r.google_place_id IN (
|
||||
SELECT google_place_id FROM restaurants
|
||||
WHERE google_place_id IS NOT NULL
|
||||
GROUP BY google_place_id HAVING COUNT(*) > 1
|
||||
)
|
||||
ORDER BY r.google_place_id, r.created_at
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1)
|
||||
</update>
|
||||
|
||||
<select id="getTodayVisits" resultType="int">
|
||||
<select id="getTodayVisits" resultType="long">
|
||||
SELECT NVL(visit_count, 0)
|
||||
FROM site_visits
|
||||
WHERE visit_date = TRUNC(SYSDATE)
|
||||
</select>
|
||||
|
||||
<select id="getTotalVisits" resultType="int">
|
||||
<select id="getTotalVisits" resultType="long">
|
||||
SELECT NVL(SUM(visit_count), 0)
|
||||
FROM site_visits
|
||||
</select>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</select>
|
||||
|
||||
<select id="findAllWithCounts" resultMap="userResultMap">
|
||||
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
|
||||
SELECT u.id, u.email, u.nickname, u.avatar_url, u.is_admin, u.provider, u.created_at,
|
||||
NVL(fav.cnt, 0) AS favorite_count,
|
||||
NVL(rev.cnt, 0) AS review_count,
|
||||
NVL(memo.cnt, 0) AS memo_count
|
||||
@@ -53,4 +53,8 @@
|
||||
SELECT COUNT(*) FROM tasteby_users
|
||||
</select>
|
||||
|
||||
<update id="updateAdmin">
|
||||
UPDATE tasteby_users SET is_admin = #{admin,jdbcType=NUMERIC} WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -62,15 +62,18 @@ if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
|
||||
# Read build args from env or .env file
|
||||
MAPS_KEY="${NEXT_PUBLIC_GOOGLE_MAPS_API_KEY:-}"
|
||||
CLIENT_ID="${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}"
|
||||
NAVER_MAP_ID="${NEXT_PUBLIC_NAVER_MAP_CLIENT_ID:-}"
|
||||
|
||||
if [[ -f frontend/.env.local ]]; then
|
||||
MAPS_KEY="${MAPS_KEY:-$(grep NEXT_PUBLIC_GOOGLE_MAPS_API_KEY frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||
CLIENT_ID="${CLIENT_ID:-$(grep NEXT_PUBLIC_GOOGLE_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||
NAVER_MAP_ID="${NAVER_MAP_ID:-$(grep NEXT_PUBLIC_NAVER_MAP_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)}"
|
||||
fi
|
||||
|
||||
docker build --platform "$PLATFORM" \
|
||||
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
|
||||
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="$CLIENT_ID" \
|
||||
--build-arg NEXT_PUBLIC_NAVER_MAP_CLIENT_ID="$NAVER_MAP_ID" \
|
||||
-t "$REGISTRY/frontend:$TAG" \
|
||||
-t "$REGISTRY/frontend:latest" \
|
||||
frontend/
|
||||
|
||||
81
docs/design/326-parsejson-optimization/README.md
Normal file
81
docs/design/326-parsejson-optimization/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 설계서: OciGenAiService.parseJson 단일 패스 최적화 (#326)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #326 · 부모: #292 (추출 파이프라인 Reviewer 후속, 09-Done)
|
||||
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/OciGenAiService.java`
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
LLM 응답이 잘린(truncated) 배열일 때 `parseJson`의 복구 로직이 O(N²): 각 객체 시작점에서 `end`를 1씩 늘려가며 매번 `mapper.readValue(substring)`을 try. 8192 토큰 응답(약 30KB)에서 매우 비효율 + 매 try마다 Jackson 예외 객체 생성(스택트레이스 양산). brace depth counter로 단일 패스 O(N)으로 교체.
|
||||
|
||||
## 2. 범위
|
||||
|
||||
- **포함**: `parseJson`의 truncated-array 복구 로직을 brace depth counter로 변경.
|
||||
- **제외**: `parseJson`의 마크다운/콤마 정규식 전처리는 그대로. Jackson streaming API 도입은 추가 최적화이지만 본 범위 밖.
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] 정상 JSON 배열 → 동일 결과 반환.
|
||||
- [ ] 잘린 배열(끝 `}` 누락) → 가능한 만큼 객체 추출 + 로그.
|
||||
- [ ] 문자열 안의 `{` `}` `"` (escape 포함) 잘못 카운트 안 됨.
|
||||
- [ ] 8192 token 응답 처리 시간 < 10ms (이전: 수백 ms 가능).
|
||||
- [ ] 회귀 없음 (기존 추출 파이프라인 시나리오 통과).
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- LLM 응답은 마크다운 + JSON 혼합 가능.
|
||||
- 응답 크기 최대 약 30KB (8192 token × 4 char/token).
|
||||
- mapper는 Jackson ObjectMapper.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
```
|
||||
parseJson(raw)
|
||||
├ strip markdown/trailing commas (기존)
|
||||
├ try readValue(raw) → 성공 시 반환
|
||||
└ truncated array 복구:
|
||||
idx = '['의 다음
|
||||
while idx < len:
|
||||
skip whitespace, ','
|
||||
if raw[idx] != '{': break // 객체 아님
|
||||
depth=0, inString=false, escaped=false
|
||||
단일 패스로 객체 끝 (depth==0 && } 만남) 찾음
|
||||
items.add(readValue(substring))
|
||||
idx = 객체 끝 다음
|
||||
return items
|
||||
```
|
||||
|
||||
## 6. 함수 명세
|
||||
|
||||
| 함수 | 책임 | 비고 |
|
||||
|------|------|------|
|
||||
| `parseJson(raw)` (수정) | brace depth + 단일 readValue | private 헬퍼 `findObjectEnd(raw, start)` 추출 |
|
||||
|
||||
## 7. 흐름
|
||||
|
||||
1. 기존 정규식 전처리.
|
||||
2. 전체 파싱 시도.
|
||||
3. 실패 + 배열 시작이면 위 알고리즘으로 객체 단위 복구.
|
||||
|
||||
## 8. 엣지케이스
|
||||
|
||||
- **빈 배열 `[]`**: 일반 readValue가 처리.
|
||||
- **문자열 안 `{` `}`**: inString 토글로 무시.
|
||||
- **escape `\"` `\\`**: escaped 토글로 무시.
|
||||
- **객체가 아닌 원시값 배열 `[1, 2, 3]`**: 첫 char가 `{`가 아니므로 break. 전체 파싱이 성공할 경우 도달 안 함.
|
||||
- **매우 짧은 응답**: 전체 파싱이 성공 → 복구 경로 미진입.
|
||||
|
||||
## 9. 테스트
|
||||
|
||||
- 정상 배열, 잘린 끝, 마크다운 wrap, escape 포함 5케이스 unit test (후속).
|
||||
|
||||
## 10. 리스크 & 대안
|
||||
|
||||
- **선택**: brace depth counter (단일 패스).
|
||||
- **대안 A**: Jackson `JsonParser` streaming API — 더 빠르지만 코드 복잡.
|
||||
- **대안 B**: 응답을 모두 받지 않고 streaming 파싱 — 본 범위 밖.
|
||||
|
||||
## 11. 미해결 질문
|
||||
|
||||
- LLM 응답에 객체 외 다른 타입 섞일 수 있는가? 현재 추출 결과는 `[{...}, {...}]` 형태로 가정.
|
||||
107
docs/design/329-admin-split/README.md
Normal file
107
docs/design/329-admin-split/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 설계서: admin/page.tsx 분리 + 토큰/SSE 유틸 통일 (#329)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #329 · 부모: #304 (현행화 frontend-admin, 09-Done)
|
||||
> · 구현 파일: `frontend/src/app/admin/page.tsx`, `frontend/src/app/admin/_panels/*.tsx` (신규), `frontend/src/lib/admin-utils.ts`
|
||||
> · 테스트: 수동 (각 탭 진입 + 기존 시나리오 회귀)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
`admin/page.tsx`가 2817 LOC 단일 파일. 5개 패널이 같은 파일 안에 함께 있어 (a) 빌드 변경 시 무관한 패널까지 재빌드/재배포, (b) 코드 리뷰 시 충돌 가능성↑, (c) 로직 격리 어려움. 또한 `localStorage` 직접 호출 + SSE 파싱 코드 중복이 남아있어 #304 후속에서 한 번 더 정리.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
- **포함**
|
||||
- 5개 패널을 `app/admin/_panels/<Panel>.tsx`로 추출 (Next.js underscore prefix로 라우팅 제외).
|
||||
- `page.tsx`는 탭 라우팅 + 헤더 + 패널 import만.
|
||||
- 남은 `localStorage.getItem("tasteby_token")` 호출 → `getAdminToken()`/`authHeaders()` 교체.
|
||||
- SSE 파싱 중복(약 5~6곳) → `consumeSseStream()` 활용.
|
||||
- 공유 타입(`AdminUser`/`UserFavorite`/`UserReview`/`UserMemo`/`VideoSortKey`)을 _panels 파일 내부 또는 `api.ts`에 옮김(짧은 타입만).
|
||||
- **제외**
|
||||
- 패널 내부 로직 변경 (state/effect 그대로).
|
||||
- catch{/*ignore*/} 일괄 로깅 (별도 후속).
|
||||
- ad-hoc 타입 캐스팅 정리 (별도 후속).
|
||||
- 디자인 시스템 색상 통일.
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] 5개 파일 신규: `_panels/{Channels,Videos,Restaurants,Users,Daemon}Panel.tsx`.
|
||||
- [ ] `page.tsx` < 200 LOC (이전 2817).
|
||||
- [ ] localStorage 직접 호출이 admin 페이지 내에 0건.
|
||||
- [ ] SSE reader 직접 호출(`response.body?.getReader()`)이 0건.
|
||||
- [ ] 모든 탭 진입 + 기존 시나리오 회귀 없음(빌드 통과 + 수동 smoke).
|
||||
- [ ] dev 빌드 + 운영 배포 성공.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- Next.js 16 (App Router). `_` prefix 디렉토리는 route 제외.
|
||||
- `"use client"` 지시문 각 패널 파일 상단에 필요.
|
||||
- React Server Components 미사용 (모두 클라이언트 컴포넌트).
|
||||
- `useAuth()` 훅이 `auth-context`에 있음. 부모 `AdminPage`가 isAdmin 판정 후 prop으로 패널에 전달.
|
||||
- 패널 간 상태 공유 없음 (각각 독립).
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
```
|
||||
app/admin/
|
||||
page.tsx ← 탭 라우팅 + 헤더 + CacheFlushButton + 패널 import
|
||||
_panels/
|
||||
ChannelsPanel.tsx ← 214 LOC
|
||||
VideosPanel.tsx ← 1272 LOC (가장 큼)
|
||||
RestaurantsPanel.tsx ← 667 LOC
|
||||
UsersPanel.tsx ← 332 LOC
|
||||
DaemonPanel.tsx ← 223 LOC
|
||||
|
||||
lib/admin-utils.ts (이미 존재)
|
||||
├─ getAdminToken()
|
||||
├─ authHeaders()
|
||||
└─ consumeSseStream()
|
||||
```
|
||||
|
||||
## 6. 함수 명세
|
||||
|
||||
| 단위 | 책임 | 비고 |
|
||||
|------|------|------|
|
||||
| `page.tsx` (재작성) | 탭 라우팅 + 패널 import | < 200 LOC |
|
||||
| `_panels/*.tsx` (신규 5개) | 각 패널 로직 그대로 옮김 | "use client" |
|
||||
| `localStorage` 호출 (~10곳) | `getAdminToken()`/`authHeaders()`로 통일 | 의미 동일 |
|
||||
| SSE `getReader` (~5곳) | `consumeSseStream(resp, onEvent)`로 통일 | 의미 동일 |
|
||||
|
||||
## 7. 흐름
|
||||
|
||||
1. `_panels/` 디렉토리 생성.
|
||||
2. 각 패널 함수 + 그에 종속된 타입/상수를 `<Panel>Panel.tsx`로 잘라 옮김.
|
||||
3. 각 파일에 `"use client"` + import 추가.
|
||||
4. `localStorage.getItem("tasteby_token")` → `getAdminToken()` 일괄.
|
||||
5. SSE `getReader/decoder/buf.split/match` 패턴 → `consumeSseStream(resp, onEvent)` 일괄.
|
||||
6. `page.tsx` 재작성 — 탭 라우팅 + 패널 import.
|
||||
7. `npm run build`.
|
||||
8. `pm2 restart` 또는 `deploy.sh --frontend-only`.
|
||||
|
||||
## 8. 엣지케이스
|
||||
|
||||
- **순환 import**: 패널 간 의존 없음 → 안전.
|
||||
- **Type 중복**: `AdminUser` 등 패널 내부 타입은 그대로 옮김. 공유 타입은 `api.ts`에 있음.
|
||||
- **default export vs named**: 각 패널은 named export. `page.tsx`에서 `import { ChannelsPanel } from "./_panels/ChannelsPanel"`.
|
||||
- **빌드 크기**: 동일(코드 splitting은 별도 작업).
|
||||
|
||||
## 9. 테스트
|
||||
|
||||
- 빌드: `npm run build` 통과.
|
||||
- 수동:
|
||||
- `/admin` 접근 → 5탭 모두 진입 가능.
|
||||
- 채널 추가, 영상 강제 추출(SSE), 식당 검색/수정, 유저 권한 토글, 데몬 설정 변경 — 모두 정상.
|
||||
- 자동: 별도 후속(테스트 인프라 #343).
|
||||
|
||||
## 10. 리스크 & 대안
|
||||
|
||||
- **선택**: 5개 파일 추출 + 내부 로직 그대로.
|
||||
- **대안 A**: 추출 + 내부 리팩터링 동시 — 회귀 위험↑, 별도 후속이 안전.
|
||||
- **대안 B**: Atomic Design (atoms/molecules/organisms) — 큰 재구조화. 미루기.
|
||||
- **트레이드오프**: 외형은 동일, 유지보수성/충돌 가능성만 개선. 점진적 접근.
|
||||
|
||||
## 11. 미해결 질문
|
||||
|
||||
- 공통 panel layout(헤더/리스트/페이징) 추상화 — 후속.
|
||||
- VideosPanel 1272 LOC 내부 분할(상태 머신 + SSE 흐름 분리) — 별도 후속.
|
||||
81
docs/design/331-vector-batch-insert/README.md
Normal file
81
docs/design/331-vector-batch-insert/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 설계서: VectorService batch insert + IdGenerator 공통화 (#331)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #331 · 부모: #293 (검색/벡터 Reviewer 후속, 09-Done)
|
||||
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/VectorService.java`
|
||||
> · 테스트: 본 이슈 범위 밖 (단위 테스트 인프라 도입은 #343 후속 묶음에 해당)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
`VectorService.saveRestaurantVectors`가 chunk N개를 N번의 단건 `jdbc.update`로 처리한다. 현재 `buildChunks`가 1개 청크만 반환해 N=1이지만, 향후 chunk 분할 도입 시 N+1 INSERT 비효율. 또한 UUID 생성 코드가 인라인 변환(`UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase()`)으로 다른 곳의 `IdGenerator.newId()`와 중복.
|
||||
|
||||
## 2. 범위
|
||||
|
||||
- **포함**
|
||||
- `jdbc.batchUpdate(sql, SqlParameterSource[])`로 단일 호출 전환.
|
||||
- UUID 생성을 `IdGenerator.newId()` 공통 유틸로 교체.
|
||||
- **제외**
|
||||
- 단위/통합 테스트 도입 (테스트 인프라 미도입 — 별도 후속 #343 묶음).
|
||||
- `buildChunks`의 chunk 분할 로직 자체 변경 (현재 단일 청크 정책 유지).
|
||||
- `restaurant_vectors` 스키마 변경.
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] `saveRestaurantVectors`가 한 번의 `jdbc.batchUpdate` 호출로 N개 청크 삽입.
|
||||
- [ ] UUID 인라인 변환 제거 → `IdGenerator.newId()` 호출.
|
||||
- [ ] 회귀 없음 — 신규 식당 등록 시 `restaurant_vectors`에 정상 row 추가.
|
||||
- [ ] N=0 가드(`chunks.isEmpty()`)는 유지.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- Spring `NamedParameterJdbcTemplate.batchUpdate(String, SqlParameterSource[])` 사용.
|
||||
- Oracle VECTOR 타입 파라미터는 `float[]`로 그대로 바인딩 가능 (`MapSqlParameterSource.addValue`).
|
||||
- 한 batch 안 `int[]` 반환 → batch 결과 카운트는 사용하지 않음(throw if 어쩌고 미적용).
|
||||
- `IdGenerator.newId()` 시그니처: `public static String newId()` → 32-char uppercase hex (현재 인라인과 동일).
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
```
|
||||
saveRestaurantVectors(restaurantId, chunks)
|
||||
├ if chunks.isEmpty() → return
|
||||
├ embeddings = genAi.embedTexts(chunks)
|
||||
├ params[] = build N개 MapSqlParameterSource
|
||||
│ .addValue("id", IdGenerator.newId())
|
||||
│ .addValue("rid", restaurantId)
|
||||
│ .addValue("chunk", chunks.get(i))
|
||||
│ .addValue("emb", float[] embeddings[i])
|
||||
└ jdbc.batchUpdate(sql, params)
|
||||
```
|
||||
|
||||
## 6. 함수 명세
|
||||
|
||||
| 함수 | 책임 | 비고 |
|
||||
|------|------|------|
|
||||
| `VectorService.saveRestaurantVectors(id, chunks)` (수정) | batchUpdate 1회 | IdGenerator 사용 |
|
||||
|
||||
## 7. 흐름
|
||||
|
||||
1. embed 호출 (기존).
|
||||
2. `SqlParameterSource[]` 생성.
|
||||
3. `jdbc.batchUpdate(sql, params)` 단일 호출.
|
||||
|
||||
## 8. 엣지케이스
|
||||
|
||||
- **chunks 빈 배열**: 조기 return (기존 유지).
|
||||
- **embed 결과와 chunks 크기 불일치**: 현재 OCI GenAI는 입력 N → 출력 N 보장. 안전 가드 추가는 본 범위 밖 (필요 시 후속).
|
||||
|
||||
## 9. 테스트 (수동만)
|
||||
|
||||
- dev에서 신규 식당 등록(데몬 또는 수동 trigger) → `SELECT count(*) FROM restaurant_vectors WHERE restaurant_id = '...'` 정상 row 확인.
|
||||
|
||||
## 10. 리스크 & 대안
|
||||
|
||||
- **선택**: `NamedParameterJdbcTemplate.batchUpdate`. 단일 트랜잭션 + 단일 round-trip.
|
||||
- **대안 A**: `JdbcTemplate.batchUpdate(BatchPreparedStatementSetter)` — 더 저수준이지만 named param 손실.
|
||||
- **대안 B**: MERGE로 upsert — 동일 restaurant_id 재처리 시 중복 제거 가능. 다만 본 이슈 범위 밖.
|
||||
|
||||
## 11. 미해결 질문
|
||||
|
||||
- chunk 분할 정책(현재 1개 단일 청크) — 후속 (검색 정확도 vs 토큰 비용 트레이드오프 결정).
|
||||
- batchUpdate 결과 row 수 검증 — 운영 모니터링 도구 도입 후 결정.
|
||||
94
docs/design/332-restaurant-update-whitelist/README.md
Normal file
94
docs/design/332-restaurant-update-whitelist/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 설계서: Restaurant 임의 필드 업데이트 화이트리스트 (#332)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #332 · 부모: #290 (식당 CRUD Reviewer 후속, 09-Done)
|
||||
> · 구현 파일: `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`, `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`
|
||||
> · 테스트: 수동 (curl로 화이트리스트 외 필드 시도)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
`PUT /api/restaurants/{id}` body가 `Map<String, Object>`로 임의 키를 받는다. SQL 측 `updateFields`는 컬럼별 `<if test="containsKey('...')">` 가드로 화이트리스트가 이미 적용되어 있어 임의 컬럼 갱신은 차단된다. 다만 Controller 레벨에서 화이트리스트가 명시되지 않아 — (a) 의도 모호, (b) 향후 SQL 측 가드가 무력화되거나 다른 매퍼로 확장되면 위험. 명시적 화이트리스트로 의도 강화.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
- **포함**
|
||||
- `RestaurantController.update(id, body)`에서 허용된 키만 통과시키는 `ALLOWED_UPDATE_FIELDS` set.
|
||||
- 허용되지 않은 키는 무시(silently drop) + 디버그 로그. 400 응답은 hard policy라 사용자 경험 영향 큼.
|
||||
- **제외 (out of scope)**
|
||||
- DTO 클래스 도입 (RestaurantUpdateDTO + Bean Validation) — 더 강한 표준화지만 코드 영향 큼. 후속.
|
||||
- DDG HTML 검색 → 정식 API 전환 (별도 후속, 비용/계약 결정 필요).
|
||||
- isNameSimilar 한국어 자모 알고리즘 (별도 후속).
|
||||
- UNIQUE(google_place_id) 제약 강화 (DB 마이그레이션 + 데이터 정리 필요, 별도).
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] `ALLOWED_UPDATE_FIELDS` 상수 정의 (SQL `updateFields` 가드와 일치).
|
||||
- [ ] PUT 호출 시 body에서 허용 외 키는 자동 제거 + 디버그 로그.
|
||||
- [ ] 허용 키만 있는 정상 호출 → 200 정상 동작 회귀 없음.
|
||||
- [ ] 허용 외 키만 있는 호출 → 200 + 변경 없음 (또는 200 + 빈 업데이트).
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- Spring MVC. 변경 최소화. DTO 도입 없이도 가드 가능.
|
||||
- 운영 영향 없음 (어드민이 화면에서 호출하는 키는 모두 허용 set 안에).
|
||||
- 가정: 화이트리스트 set은 SQL `updateFields` 의 `<if>` 키들과 1:1.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
```
|
||||
PUT /api/restaurants/{id} body
|
||||
│
|
||||
▼
|
||||
RestaurantController.update
|
||||
├ requireAdmin
|
||||
├ allowed = ALLOWED_UPDATE_FIELDS
|
||||
├ filtered = body where key ∈ allowed
|
||||
├ if (filtered.isEmpty()) → 200 + no-op
|
||||
└ restaurantService.update(id, filtered) → mapper.updateFields
|
||||
```
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
`Set<String> ALLOWED_UPDATE_FIELDS = Set.of(name, address, region, cuisine_type, price_range, phone, website, tabling_url, catchtable_url, latitude, longitude, google_place_id, business_status, rating, rating_count)` — SQL `updateFields`의 키들과 일치.
|
||||
|
||||
## 7. 함수 명세
|
||||
|
||||
| 함수 | 책임 | 비고 |
|
||||
|------|------|------|
|
||||
| `RestaurantController.update(id, body)` (수정) | 화이트리스트 필터 후 위임 | filtered.isEmpty()면 no-op |
|
||||
|
||||
## 8. 흐름
|
||||
|
||||
1. body Map 수신.
|
||||
2. `body.entrySet().stream().filter(e -> ALLOWED_UPDATE_FIELDS.contains(e.getKey())).collect(toMap)`.
|
||||
3. 비어있으면 200 응답하고 끝.
|
||||
4. 아니면 `restaurantService.update(id, filtered)` 호출.
|
||||
|
||||
## 9. 엣지케이스
|
||||
|
||||
- **빈 body**: 200 + no-op.
|
||||
- **허용 외 키만**: 200 + no-op + 디버그 로그.
|
||||
- **null 값을 포함한 허용 키**: SQL `updateFields`가 그대로 NULL 저장 — 의도된 동작 (좌표/주소 해제 등).
|
||||
|
||||
## 10. 테스트
|
||||
|
||||
- 수동:
|
||||
```
|
||||
curl -X PUT -H "Authorization: Bearer <admin>" -H "Content-Type: application/json" \
|
||||
-d '{"name":"테스트", "is_admin":true}' /api/restaurants/<id>
|
||||
→ name만 갱신, is_admin은 무시
|
||||
```
|
||||
- 자동: 별도 후속(통합 테스트 인프라 도입 시).
|
||||
|
||||
## 11. 리스크 & 대안
|
||||
|
||||
- **선택**: Controller 화이트리스트 set + silent drop.
|
||||
- **대안 A**: DTO + Bean Validation — 표준화 깔끔하지만 변경 범위 큼.
|
||||
- **대안 B**: 허용 외 키 발견 시 400 — 사용자 경험 부담, 클라이언트가 잘못된 키 보내면 즉시 실패. 본 변경은 보수적으로 silent drop.
|
||||
|
||||
## 12. 미해결 질문
|
||||
|
||||
- DDG → 정식 검색 API 전환 — 별도 후속 (비용/계약 결정).
|
||||
- isNameSimilar 한국어 알고리즘 — 별도 후속.
|
||||
- DTO 도입 표준화 — 별도 후속.
|
||||
114
docs/design/337-stats-bot-ratelimit/README.md
Normal file
114
docs/design/337-stats-bot-ratelimit/README.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 설계서: 통계 방문 카운트 봇 필터 + 레이트리밋 (#337)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #337 · 부모: #274 (현행화 backend-stats, 09-Done)
|
||||
> · 구현 파일: `backend-java/build.gradle`, `backend-java/src/main/java/com/tasteby/controller/StatsController.java`, `backend-java/src/main/java/com/tasteby/util/BotDetector.java` (신규), `backend-java/src/main/java/com/tasteby/service/RateLimitService.java` (신규)
|
||||
> · 테스트: 수동 (curl로 봇 UA + 같은 IP 다중 호출)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
`POST /api/stats/visit`이 인증 없이 누구나 호출 가능 + 봇/크롤러 필터 없음 → (a) 일반 봇이 사이트 인덱싱하면서 카운터 인플레이션, (b) 악의적 새로고침 어뷰즈로 통계 왜곡. 또한 페이지 로드마다 DB write QPS 증가 — 클라이언트 어뷰즈에 취약.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
- **포함**
|
||||
- User-Agent 기반 봇 패턴 필터 (Googlebot, bingbot, crawler 등 일반 패턴).
|
||||
- IP 기반 레이트리밋: 같은 IP에서 1분에 1회만 카운트 (Bucket4j + Redis).
|
||||
- X-Forwarded-For 헤더 우선 (Nginx Ingress 뒤이므로).
|
||||
- 봇/리밋 초과: 200 응답하되 카운터 미증가 (사용자 경험 영향 X).
|
||||
- **제외 (out of scope)**
|
||||
- WAF 수준 봇 차단 (Cloudflare 등).
|
||||
- UU(고유 방문자) — 별도 후속 (쿠키/세션).
|
||||
- Redis INCR 기반 비동기 통계 (DB write 분리) — 별도 후속.
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] build.gradle에 bucket4j-redis 추가.
|
||||
- [ ] User-Agent에 'bot'/'crawler'/'spider' 포함 시 카운트 skip + 디버그 로그.
|
||||
- [ ] 같은 IP에서 60초 안에 두 번째 visit 호출 시 카운트 skip.
|
||||
- [ ] 응답은 항상 `{ "ok": true, "counted": bool }` 형태.
|
||||
- [ ] 봇/리밋 초과 호출도 200 응답 (사용자 페이지 로드 지장 X).
|
||||
- [ ] 운영 배포 후 회귀 없음 — 통계 카운터가 정상 증가.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- Bucket4j 8.x + Redis backend (Lettuce 호환).
|
||||
- 메모리만 사용하는 경우(단일 파드) ConcurrentLinkedHashMap도 가능하나 ShedLock 사례처럼 멀티 파드 미래 대비 Redis.
|
||||
- 운영 트래픽 작아 Bucket4j 부하 미미.
|
||||
- 1분 1회 정책은 동일 IP에서 사용자가 페이지 새로고침해도 영향 작음. 너무 빡빡하면 다중 사용자 NAT(회사/카페) 영향 → 1분/IP는 균형.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
```
|
||||
사용자 페이지 로드
|
||||
│
|
||||
▼ POST /api/stats/visit (X-Forwarded-For: client-ip)
|
||||
StatsController.recordVisit(req)
|
||||
│
|
||||
├─ BotDetector.isBot(userAgent) → skip
|
||||
│
|
||||
├─ RateLimitService.tryConsume(clientIp) → false면 skip
|
||||
│
|
||||
▼
|
||||
StatsService.recordVisit() → DB MERGE
|
||||
```
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
Redis 키:
|
||||
- `bucket4j:visit:<ip>` — Bucket4j가 자동 관리 (token bucket state)
|
||||
|
||||
응답:
|
||||
```json
|
||||
{ "ok": true, "counted": true } // 정상 카운트
|
||||
{ "ok": true, "counted": false } // 봇/리밋 초과
|
||||
```
|
||||
|
||||
## 7. 함수 명세
|
||||
|
||||
| 함수 | 책임 | 시그니처 | 비고 |
|
||||
|------|------|----------|------|
|
||||
| `BotDetector.isBot(ua)` | UA 문자열 봇 패턴 매칭 | `static boolean isBot(String)` | 순수 함수 |
|
||||
| `RateLimitService.tryConsume(key)` | Bucket4j 1 토큰 소비 | `boolean tryConsume(String)` | Bucket 1/min |
|
||||
| `StatsController.recordVisit(req)` (수정) | 봇 + IP 필터 후 카운트 | `@PostMapping("/visit")` | request 헤더 활용 |
|
||||
|
||||
## 8. 흐름
|
||||
|
||||
1. POST `/api/stats/visit` 진입.
|
||||
2. `X-Forwarded-For` 우선 → 없으면 `request.getRemoteAddr()`.
|
||||
3. User-Agent 검사 (`isBot` true면 counted=false).
|
||||
4. IP 레이트 검사 (`tryConsume` false면 counted=false).
|
||||
5. 통과 시 `recordVisit()` 호출.
|
||||
6. 응답 `{ok:true, counted: ...}`.
|
||||
|
||||
## 9. 엣지케이스
|
||||
|
||||
- **여러 IP가 헤더에 chain**: X-Forwarded-For 첫 번째(원본 클라이언트) 사용.
|
||||
- **헤더 위조**: Nginx Ingress 뒤라 외부 위조는 어렵지만, 신뢰는 가정.
|
||||
- **Redis 다운**: Bucket4j Redis 에러 → fail open(즉, counted=true 진행). 통계는 약간 부풀지만 사용자 경험 우선.
|
||||
- **빈 UA**: 봇 판정 안 함 (정상 모바일 앱일 수도).
|
||||
|
||||
## 10. 테스트
|
||||
|
||||
- 수동:
|
||||
```
|
||||
curl -X POST -H "User-Agent: Googlebot/2.1" https://www.tasteby.net/api/stats/visit
|
||||
→ counted=false
|
||||
curl -X POST -H "User-Agent: Mozilla/5.0" https://www.tasteby.net/api/stats/visit
|
||||
→ counted=true
|
||||
(즉시 재호출) → counted=false (rate limit)
|
||||
```
|
||||
|
||||
## 11. 리스크 & 대안
|
||||
|
||||
- **선택**: Bucket4j + Redis backend + UA 정규식.
|
||||
- **대안 A**: 메모리 LRU만 사용 — 단일 파드는 OK, 멀티 파드는 어뷰즈 가능.
|
||||
- **대안 B**: Nginx Ingress에서 rate limit — 인프라 분리 깔끔. 다만 봇 UA 필터는 ingress nginx 모듈 추가 부담, 어플리케이션 가시성↓.
|
||||
- **대안 C**: 비동기 큐(@Async 또는 Redis INCR) — DB write 부담 해소. 본 이슈는 어뷰즈 차단이 우선이라 후속으로 분리.
|
||||
|
||||
## 12. 미해결 질문
|
||||
|
||||
- UU(쿠키 기반 고유 방문자) — 별도 후속 (#274 후속 #337의 잔여 항목으로 분리).
|
||||
- Bucket4j 1분 정책의 적정성 — 운영 1주일 관찰 후 조정.
|
||||
- 봇 UA 화이트리스트(친화적 검색 엔진은 카운트 포함?) — 현재 일괄 skip.
|
||||
117
docs/design/343-frontend-test-infra/README.md
Normal file
117
docs/design/343-frontend-test-infra/README.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# 설계서: RTL/Jest 인프라 + next/image + ARIA Tabs (#343)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #343 · 부모: #281 (현행화 frontend-review-memo, 09-Done)
|
||||
> · 구현 파일: `frontend/package.json`, `frontend/jest.config.ts` (신규), `frontend/jest.setup.ts` (신규), `frontend/next.config.ts`, `frontend/__tests__/*` (신규), `frontend/src/components/MyReviewsList.tsx`, `frontend/src/components/ReviewSection.tsx`
|
||||
> · 테스트: 본 이슈가 테스트 인프라 자체를 도입
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
본 프로젝트는 지금까지 자동화된 단위 테스트가 0건. 누적된 "후속 테스트" 항목이 12+개(StatsService/CacheService/SearchService/Stars/HangulSimilarity 등)이며, 그동안 분리된 후속 이슈를 처리할 인프라가 없어 모두 보류 상태. 본 이슈에서 Jest + RTL 인프라 도입 + next/image 적용 + ARIA Tabs 보강.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
- **포함**
|
||||
- Jest 30 + `next/jest` 자동 설정 + `@testing-library/react` + `@testing-library/jest-dom`.
|
||||
- `jest.config.ts`, `jest.setup.ts`, `package.json scripts: test, test:watch`.
|
||||
- 샘플 테스트 3개 — 가장 안전한 순수 함수/단순 컴포넌트로 인프라 검증:
|
||||
- `Stars` 컴포넌트 렌더 + 별점 표시
|
||||
- 기존 `HangulSimilarity` (#348) — 자모/유사도
|
||||
- `BotDetector` (#337) — 봇 UA 패턴
|
||||
- `next.config.ts` `images.remotePatterns`에 Google avatar 도메인(`lh3.googleusercontent.com`) + YouTube thumbnail(`i.ytimg.com`).
|
||||
- `ReviewSection`/`MyReviewsList`의 `<img>` 일부를 `next/image` 또는 명시적 eslint-disable로 정리.
|
||||
- `MyReviewsList` 탭에 WAI-ARIA Tabs 패턴(role=tablist/tab/aria-selected/aria-controls).
|
||||
- **제외 (후속)**
|
||||
- 백엔드 JUnit 테스트 인프라 (별도 큰 작업).
|
||||
- E2E (Playwright) 도입.
|
||||
- CI 통합 (GitHub Actions 또는 OCI DevOps).
|
||||
- 모든 컴포넌트 테스트 — 점진적으로 추가.
|
||||
- 모든 `<img>` → `next/image` 전수 교체 — 점진적.
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] `npm test`가 단일 명령으로 동작 (0건 → 샘플 3개 통과).
|
||||
- [ ] `npm run build`가 회귀 없이 통과.
|
||||
- [ ] `next.config.ts`에 `remotePatterns` 설정.
|
||||
- [ ] `ReviewSection`의 user_avatar_url `<img>`에 `next/image` 또는 eslint-disable 주석.
|
||||
- [ ] `MyReviewsList` 탭이 `role="tablist"`/`role="tab"`/`aria-selected`/`aria-controls`/`tabIndex` 설정.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- Next.js 16.1.6 + Turbopack.
|
||||
- `next/jest`는 SWC/Babel 자동 통합. Turbopack 빌드와는 분리(테스트만 Jest 별도).
|
||||
- Pretendard/Geist 폰트는 `next/font/local` 사용 → 테스트에선 mock 불필요.
|
||||
- 패널 분리(#329)로 admin 영역은 단위 테스트 도입 더 쉬워짐.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── package.json
|
||||
│ └── scripts: test, test:watch
|
||||
├── jest.config.ts (next/jest createNextJestConfig 사용)
|
||||
├── jest.setup.ts (@testing-library/jest-dom 확장 matchers)
|
||||
├── __tests__/
|
||||
│ ├── Stars.test.tsx
|
||||
│ ├── HangulSimilarity.test.ts (자체 구현은 backend Java, TS 포팅은 미적용 → 다른 순수 함수로 대체)
|
||||
│ └── BotDetector.test.ts (마찬가지 — backend → TS 동등 포팅 불가)
|
||||
└── (대안) 프론트 측 순수 함수:
|
||||
├── lib/cuisine-icons.ts 의 getPhosphorCuisineIcon
|
||||
├── components/Stars 의 0.5 단위 렌더
|
||||
└── i18n/config.ts 의 isLocale/detectBrowserLocale
|
||||
```
|
||||
|
||||
→ 백엔드 Java 코드는 TS 테스트로 검증 불가. 프론트 측 순수 함수 3개로 대체:
|
||||
- `Stars` 렌더 (RTL component test)
|
||||
- `i18n/config.ts` `isLocale` (pure)
|
||||
- `i18n/config.ts` `detectBrowserLocale` (navigator mock)
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
`__tests__/*.test.{tsx,ts}` — Jest 표준 컨벤션.
|
||||
|
||||
## 7. 함수 명세
|
||||
|
||||
| 단위 | 책임 | 비고 |
|
||||
|------|------|------|
|
||||
| `jest.config.ts` | `createJestConfig(customConfig)` + moduleNameMapper `@/*` | next/jest |
|
||||
| `jest.setup.ts` | `import "@testing-library/jest-dom"` | 확장 matchers |
|
||||
| `Stars.test.tsx` | 별점 0/2.5/5 렌더, aria-label 확인 | RTL |
|
||||
| `i18n/config.test.ts` | isLocale/detectBrowserLocale | navigator mock |
|
||||
| `MyReviewsList` Tabs 패치 | tablist/tab/aria-selected | role + aria |
|
||||
| `ReviewSection` img → eslint-disable | 최소 변경 | next/image는 후속 |
|
||||
|
||||
## 8. 흐름
|
||||
|
||||
1. `npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event @types/jest`.
|
||||
2. `jest.config.ts` + `jest.setup.ts` 작성.
|
||||
3. `package.json`에 `"test": "jest"` + `"test:watch": "jest --watch"` 추가.
|
||||
4. `__tests__/`에 3개 샘플 테스트.
|
||||
5. `next.config.ts`에 `remotePatterns` 추가.
|
||||
6. `MyReviewsList` Tabs ARIA 보강.
|
||||
7. `ReviewSection`의 `<img>` 라인에 `// eslint-disable-next-line @next/next/no-img-element` (next/image 전환은 후속).
|
||||
8. `npm test` 통과 → `npm run build` 통과.
|
||||
|
||||
## 9. 엣지케이스
|
||||
|
||||
- **Turbopack vs Jest**: 무관 (테스트는 별도 SWC 컴파일).
|
||||
- **CSS modules / globals.css import**: jest.config.ts의 moduleNameMapper로 `\\.(css|scss)$` → `identity-obj-proxy` 대신 next/jest가 자동 처리.
|
||||
- **Next.js Server Components**: 본 프로젝트는 모두 `"use client"` 컴포넌트라 RTL이 통상 동작.
|
||||
|
||||
## 10. 테스트
|
||||
|
||||
자기 자신 — `npm test`가 통과해야 본 이슈 완료.
|
||||
|
||||
## 11. 리스크 & 대안
|
||||
|
||||
- **선택**: `next/jest` + RTL. Next.js 공식 권장.
|
||||
- **대안 A**: Vitest — 더 빠르지만 Next.js 공식 가이드 부재, 본 프로젝트 규모에서 차이 작음.
|
||||
- **대안 B**: Playwright Component Testing — 더 무겁고 E2E 통합 안 됨.
|
||||
- **트레이드오프**: Jest 30 + RTL은 React 19에 호환. 의존성 부담은 dev-only.
|
||||
|
||||
## 12. 미해결 질문
|
||||
|
||||
- CI(테스트 자동 실행) — 본 이슈 범위 밖. OCI DevOps Build Pipeline은 ARM64 미지원 → GitHub Actions 또는 Gitea Actions 후속.
|
||||
- 백엔드 JUnit 테스트 인프라 — 별도 큰 이슈.
|
||||
- E2E (Playwright) — 별도.
|
||||
80
docs/design/348-name-similarity/README.md
Normal file
80
docs/design/348-name-similarity/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 설계서: isNameSimilar 한국어 자모 분해 + Sørensen-Dice (#348)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #348 · 부모: #332 (이미 close)
|
||||
> · 구현 파일: `backend-java/src/main/java/com/tasteby/util/HangulSimilarity.java` (신규), `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`
|
||||
|
||||
## 1. 목적
|
||||
|
||||
기존 `isNameSimilar`가 Jaccard-like(문자 set 교집합 비율 ≥ 0.4)로 짧은 한국어 이름에서 오탐 가능. 자모 분해 + Sørensen-Dice bigram으로 정확도 향상.
|
||||
|
||||
## 2. 범위
|
||||
|
||||
- **포함**
|
||||
- `HangulSimilarity.similarity(a, b)` 유틸 신규
|
||||
- `RestaurantController.isNameSimilar` 호출부를 새 유틸로 교체
|
||||
- **제외 (별도 후속으로 분리)**
|
||||
- DDG → 정식 검색 API 전환 (외부 API 결정 + 비용/계약 필요)
|
||||
- DTO RestaurantUpdateDTO + @Valid 표준화 (#332 화이트리스트 set으로 SQL 측 가드 확보)
|
||||
- UNIQUE(google_place_id) 제약 강화 — DB 중복 정리 선행 필요(현재 10+건 중복 확인)
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] `HangulSimilarity.similarity(a, b)` 0.0~1.0 반환 (1.0=동일)
|
||||
- [ ] 한국어 음절을 Unicode NFD로 자모 분해(초성·중성·종성)
|
||||
- [ ] 분해 후 bigram 기반 Sørensen-Dice 계수 계산
|
||||
- [ ] 빈 문자열 안전 처리 (둘 다 비면 0.0, 한쪽만 비면 0.0)
|
||||
- [ ] `RestaurantController.isNameSimilar` 임계값 0.45로 호출 (Jaccard 0.4와 유사 보수성)
|
||||
- [ ] 회귀 없음 — 기존 정상 매칭 시나리오 통과
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- Java 21 `Normalizer.normalize(Form.NFD)` 활용.
|
||||
- 한글 음절(가-힣) NFD → 초성(ㄱ-ㅎ 호환자모 또는 조합자모) + 중성 + 종성.
|
||||
- 영문/숫자는 그대로 통과.
|
||||
- Sørensen-Dice: `2 * |A ∩ B| / (|A| + |B|)` — bigram 다중집합(multiset) 기준.
|
||||
|
||||
## 5. 함수 명세
|
||||
|
||||
| 함수 | 책임 | 시그니처 |
|
||||
|------|------|---------|
|
||||
| `decomposeHangul(s)` | NFD 자모 분해 + 공백/구두점 제거 + 소문자화 | `static String decompose(String)` |
|
||||
| `bigrams(s)` | 2글자 bigram 리스트 | `static List<String> bigrams(String)` |
|
||||
| `similarity(a, b)` | Sørensen-Dice 0.0~1.0 | `static double similarity(String, String)` |
|
||||
|
||||
## 6. 흐름
|
||||
|
||||
1. 두 이름을 `decompose`로 자모 분해 + 정규화.
|
||||
2. 각 분해 결과를 `bigrams`로 분해.
|
||||
3. multiset 교집합 크기 카운트.
|
||||
4. `2 * common / (sizeA + sizeB)`.
|
||||
|
||||
## 7. 엣지케이스
|
||||
|
||||
- **둘 다 빈 문자열**: 0.0 반환.
|
||||
- **bigram 1개 이하**: 두 문자열 같으면 1.0, 아니면 0.0.
|
||||
- **포함 관계**: 기존 코드의 `a.contains(b) || b.contains(a)` 단축 평가 유지 (1.0 반환).
|
||||
- **혼합(한영)**: NFD가 한글만 분해 → 영문은 그대로. bigram 계산은 동일하게 동작.
|
||||
|
||||
## 8. 테스트 (수동)
|
||||
|
||||
```
|
||||
similarity("스타벅스 강남", "스타벅스 강남점") → ≥ 0.85
|
||||
similarity("스타벅스 강남", "스타벅스 종로") → ≥ 0.55, < 0.85
|
||||
similarity("스타벅스", "맥도날드") → < 0.20
|
||||
similarity("PIZZAHUT", "피자헛") → 한글 + 영문 혼재 가드 통과
|
||||
```
|
||||
|
||||
## 9. 리스크 & 대안
|
||||
|
||||
- **선택**: NFD 분해 + bigram Sørensen-Dice. Java 표준 라이브러리만 사용.
|
||||
- **대안 A**: Apache Commons Text `JaroWinklerSimilarity` — 라이브러리 추가 부담.
|
||||
- **대안 B**: Hangul.js류 라이브러리 — Java 포팅 없음.
|
||||
- **대안 C**: Levenshtein 거리 — 자모 분해와 결합 시 좋으나 구현 복잡.
|
||||
|
||||
## 10. 미해결 질문 / 분리된 후속
|
||||
|
||||
- DDG → 정식 검색 API: Naver Search API 또는 Google Custom Search (외부 API 결정 + 비용 검토 필요) — 별도 신규 이슈
|
||||
- DTO RestaurantUpdateDTO + @Valid: #332 set 화이트리스트로 1차 가드. 본격 DTO는 큰 변경 — 별도 신규 이슈
|
||||
- UNIQUE(google_place_id) 제약: 현재 10+건 중복. 데이터 정리(병합/삭제) 선행 → 별도 신규 이슈
|
||||
141
docs/design/352-i18n-skeleton/README.md
Normal file
141
docs/design/352-i18n-skeleton/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 설계서: i18n 뼈대 — ko/en/ja/es (#352)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #352
|
||||
> · 구현 파일: `frontend/package.json`, `frontend/next.config.ts`, `frontend/src/i18n/*` (신규), `frontend/src/messages/{ko,en,ja,es}.json` (신규), `frontend/src/components/LanguageSwitcher.tsx` (신규)
|
||||
> · 테스트: 수동 (언어 전환 + ko fallback)
|
||||
|
||||
## 1. 목적
|
||||
|
||||
장기적으로 영어/일본어/스페인어 시장으로 확장 가능하도록 i18n 뼈대 구축. 본 이슈는 *뼈대*만 — 약 30개 키 초기 번역.
|
||||
|
||||
## 2. 범위
|
||||
|
||||
- **포함**
|
||||
- `next-intl` 라이브러리 도입 (Next.js 16 App Router 권장).
|
||||
- 4개 로케일: `ko, en, ja, es`. 기본 `ko`, fallback `ko`.
|
||||
- 메시지 디렉토리 `src/messages/{ko,en,ja,es}.json`.
|
||||
- Provider (`<NextIntlClientProvider>`) 루트 layout 적용.
|
||||
- `LanguageSwitcher` 컴포넌트 (헤더 우측).
|
||||
- 로케일 저장: localStorage `tasteby_locale`.
|
||||
- 초기 번역 키 ~30개: 헤더(로그인/검색/메뉴) + 주요 액션(저장/취소/삭제/확인) + 페이지 제목.
|
||||
- **제외**
|
||||
- URL 라우팅 i18n (`/ko/...`).
|
||||
- SEO meta tags i18n.
|
||||
- 식당명/리뷰 등 사용자 콘텐츠 번역.
|
||||
- 어드민 페이지(운영자만 사용, 한국어 유지).
|
||||
- 식당 카드/상세 시트 전체 키 추출 (점진).
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] `npm i next-intl` + 4개 메시지 파일 생성.
|
||||
- [ ] `tasteby_locale`이 localStorage에 있으면 사용, 없으면 브라우저 언어 감지(`navigator.language`) → 매칭 안되면 `ko`.
|
||||
- [ ] 헤더 우측 LanguageSwitcher 드롭다운(국기 + 코드).
|
||||
- [ ] 초기 번역 키 약 30개 — 4개 언어 모두 채움.
|
||||
- [ ] 미번역 키는 `ko` fallback (에러 없이).
|
||||
- [ ] 빌드/배포 회귀 없음.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- Next.js 16 + App Router + `"use client"` 컴포넌트 다수.
|
||||
- 기존 `auth-context`처럼 i18n도 React Context 패턴.
|
||||
- `next-intl`은 server/client 모두 지원, 본 프로젝트는 client-side switching이라 `NextIntlClientProvider` 중심.
|
||||
- URL 라우팅 변경 없이 단순 메시지만 교체(낮은 비용).
|
||||
- 폰트: Pretendard Variable이 한국어/영어 잘 표시, 일본어는 시스템 폰트 fallback OK, 스페인어는 라틴 문자라 OK.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── i18n/
|
||||
│ │ ├── config.ts ← 로케일 목록/기본값 상수
|
||||
│ │ ├── LocaleProvider.tsx ← NextIntlClientProvider wrap + localStorage 저장
|
||||
│ │ └── useTranslations.ts ← (next-intl 재export)
|
||||
│ ├── messages/
|
||||
│ │ ├── ko.json ← 기본
|
||||
│ │ ├── en.json
|
||||
│ │ ├── ja.json
|
||||
│ │ └── es.json
|
||||
│ ├── components/
|
||||
│ │ └── LanguageSwitcher.tsx ← 헤더용
|
||||
│ └── app/
|
||||
│ └── layout.tsx ← LocaleProvider로 감싸기
|
||||
```
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
### 메시지 키 (초기 ~30)
|
||||
```json
|
||||
{
|
||||
"header": {
|
||||
"search": "검색", "login": "로그인", "logout": "로그아웃",
|
||||
"menu": "메뉴", "myReviews": "내 리뷰", "favorites": "즐겨찾기"
|
||||
},
|
||||
"actions": {
|
||||
"save": "저장", "cancel": "취소", "delete": "삭제",
|
||||
"edit": "수정", "confirm": "확인", "close": "닫기",
|
||||
"loading": "로딩 중...", "submit": "제출"
|
||||
},
|
||||
"filter": {
|
||||
"title": "필터", "cuisine": "음식 종류", "price": "가격대",
|
||||
"region": "지역", "channel": "채널", "reset": "초기화"
|
||||
},
|
||||
"restaurant": {
|
||||
"rating": "평점", "address": "주소", "phone": "전화",
|
||||
"website": "웹사이트", "closed": "폐업", "tempClosed": "임시휴업"
|
||||
},
|
||||
"review": {
|
||||
"title": "리뷰", "write": "리뷰 작성", "noReviews": "아직 리뷰가 없습니다"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
총 5개 카테고리 × 평균 6개 = 30개 키.
|
||||
|
||||
## 7. 함수 명세
|
||||
|
||||
| 함수/컴포넌트 | 책임 | 비고 |
|
||||
|---|---|---|
|
||||
| `i18n/config.ts` | LOCALES, DEFAULT_LOCALE 상수 | 단순 |
|
||||
| `LocaleProvider.tsx` | NextIntlClientProvider wrap + 메시지 동적 로딩 + localStorage 동기화 | client |
|
||||
| `LanguageSwitcher.tsx` | 헤더 드롭다운 (국기 + 코드) | client, 44px 터치 |
|
||||
| `messages/<lang>.json` | 키 → 텍스트 | flat or nested |
|
||||
|
||||
## 8. 흐름
|
||||
|
||||
1. 사용자 첫 방문 → `tasteby_locale` 없음 → `navigator.language.split('-')[0]`이 LOCALES에 있으면 사용, 아니면 `ko`.
|
||||
2. LocaleProvider가 해당 로케일 메시지 파일 import → NextIntlClientProvider에 전달.
|
||||
3. 컴포넌트는 `useTranslations('header')` 등으로 호출.
|
||||
4. LanguageSwitcher에서 변경 → localStorage 저장 → 페이지 새로고침 또는 state 업데이트.
|
||||
|
||||
## 9. 엣지케이스
|
||||
|
||||
- **메시지 파일 누락 키**: next-intl 기본 동작은 키 자체 표시 + 콘솔 경고. fallback 처리는 messages 명시.
|
||||
- **localStorage 비활성/SSR**: typeof window 체크.
|
||||
- **로케일 코드 대소문자**: 항상 소문자 정규화.
|
||||
- **placeholder 변수**: next-intl ICU 메시지 형식 지원 (`{name}` 등). 초기 키에는 미적용.
|
||||
|
||||
## 10. 테스트
|
||||
|
||||
- 수동:
|
||||
- 한국어 첫 방문 → "검색" 표시.
|
||||
- LanguageSwitcher에서 English → "Search" 표시.
|
||||
- 새로고침 후 영어 유지.
|
||||
- 메시지 누락 키 → 콘솔 경고 + 키 표시.
|
||||
|
||||
## 11. 리스크 & 대안
|
||||
|
||||
- **선택**: `next-intl` (Next.js 16 App Router 권장, ICU 메시지, 활발 유지보수).
|
||||
- **대안 A**: `react-i18next` — 더 일반적이지만 App Router 통합 next-intl이 더 매끄러움.
|
||||
- **대안 B**: 자체 구현 + Context — 의존성 ↓ but 기능/표준화 ↓.
|
||||
- **트레이드오프**: 30개 키는 단순하지만 ICU 메시지(복수형, 성별 등) 필요 시 next-intl 가치 큼.
|
||||
|
||||
## 12. 미해결 질문
|
||||
|
||||
- 식당명/리뷰 콘텐츠 번역 — 사용자 작성이라 자동 번역(LLM)? 별도 정책 결정.
|
||||
- URL 라우팅 i18n (`/en/`) — 후속.
|
||||
- SEO meta tags i18n — 후속.
|
||||
- 어드민 페이지는 운영자 한국어 유지 — 확정.
|
||||
- 통화/날짜 포맷(Intl API) — 후속.
|
||||
172
docs/design/356-video-relevance-llm/README.md
Normal file
172
docs/design/356-video-relevance-llm/README.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# 설계서: 영상-식당 관련도 LLM 평가로 약한 매칭 자동 숨김 (#356)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #356 · 유사 패턴: #322(식당 LLM 검증, 09-Done) · 부모 영역: #270(영상→식당 추출 파이프라인 현행화, 09-Done)
|
||||
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/VideoRelevanceService.java`(신규), `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`, `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminVideoRelevanceController.java`(신규), DB 마이그레이션 SQL
|
||||
> · 테스트: 본 범위 밖 (테스트 인프라 #343 도입됨, 후속에서 점진 확장)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
식당 상세에 연결된 영상 중 식당과 **본격적으로 관련 없는 약한 언급**(비교 대상, 일반 토픽 중 잠깐 언급, 식당 입점 전 영상 등)이 노이즈로 표시. 실제 케이스 — 파이브가이즈 강남의 영상 7개 중 3건이 약한 매칭(쉐이크쉑 비교 / 미국 비만율 일반 토픽 / 한국 입점 전 미국 여행). LLM 평가로 약한 매칭 자동 숨김.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
- **포함**
|
||||
- `video_restaurants` 테이블에 `relevance`, `relevance_reason`, `relevance_evaluated_at` 컬럼 추가.
|
||||
- `VideoRelevanceService` 신규 — LLM 판정 + DB 반영 (`#322` 패턴 모방).
|
||||
- `PipelineService.processExtract` 완료 후 `verifyAsync(linkId)` 호출 — 신규 등록 자동 평가.
|
||||
- `GET /api/restaurants/{id}/videos`: 기본 `relevance = 'strong'`만 응답. `?include_weak=true` 시 모두 포함.
|
||||
- 어드민 API: 단건 재평가 / 일괄 백필 / 수동 토글.
|
||||
- **제외 (별도 후속)**
|
||||
- 어드민 UI(검증 칼럼 / 토글) — `#322`의 RestaurantsPanel UI와 같은 패턴으로 별도 후속.
|
||||
- 프론트 사용자 옵션 UI("약한 매칭도 보기" 토글) — 별도 후속.
|
||||
- LLM 비용 모니터링/메트릭 — 별도.
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] `video_restaurants` 테이블에 `relevance VARCHAR2(16) DEFAULT 'unknown'`, `relevance_reason VARCHAR2(120)`, `relevance_evaluated_at TIMESTAMP` 컬럼 + `idx_vr_relevance` 인덱스.
|
||||
- [ ] 가능한 값: `strong | weak | incidental | unknown` (unknown = 미평가).
|
||||
- [ ] 신규 등록 시 60초 안에 `relevance_evaluated_at` 설정.
|
||||
- [ ] `GET /api/restaurants/{id}/videos` 기본 응답: `relevance IN ('strong','unknown')` (안전한 기본값 = 평가 실패 시 표시).
|
||||
- [ ] `?include_weak=true`: 모두 포함 + `relevance`, `relevance_reason` 필드 동봉.
|
||||
- [ ] 어드민 API:
|
||||
- `GET /api/admin/video-relevance/pending` → 미평가(unknown) 카운트
|
||||
- `POST /api/admin/video-relevance/all?batchSize=10` → 백필
|
||||
- `POST /api/admin/video-relevance/{linkId}/evaluate` → 단건 재평가
|
||||
- `PATCH /api/admin/video-relevance/{linkId}` → 수동 강제 토글 `{relevance, reason}`
|
||||
- [ ] LLM 호출 실패 시 `unknown` 유지 + 로그 (`#322`와 같은 안전 기본값).
|
||||
- [ ] 빌드/배포 회귀 없음.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- 기존 `OciGenAiService.chat(prompt, maxTokens)` 재사용.
|
||||
- LLM 비용: 영상-식당 페어당 1회 단발. 현재 1,244건 → 백필 시 약 1,244 호출.
|
||||
- `video_restaurants`는 한 영상에 여러 식당, 한 식당에 여러 영상이 m:n 관계.
|
||||
- 같은 페어는 `relevance_evaluated_at`이 NULL 아니면 재평가 안 함 (캐시).
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
```
|
||||
PipelineService.processExtract (기존)
|
||||
│
|
||||
▼
|
||||
RestaurantService.linkVideoRestaurant (video_restaurants INSERT)
|
||||
│
|
||||
▼
|
||||
VideoRelevanceService.verifyAsync(linkId) ← #356 신규
|
||||
│ (비동기)
|
||||
▼
|
||||
OciGenAiService.chat(prompt, 120)
|
||||
│
|
||||
▼
|
||||
parseRelevance → { relevance: strong|weak|incidental, reason: string }
|
||||
│
|
||||
▼
|
||||
RestaurantMapper.updateRelevance(linkId, relevance, reason)
|
||||
│
|
||||
▼ (조회 시)
|
||||
RestaurantMapper.findVideoLinks(restaurantId, includeWeak)
|
||||
├ includeWeak=false (기본): WHERE relevance IN ('strong','unknown')
|
||||
└ includeWeak=true: 모두 + relevance/reason 필드 노출
|
||||
```
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
### DB 마이그레이션
|
||||
```sql
|
||||
ALTER TABLE video_restaurants ADD (
|
||||
relevance VARCHAR2(16) DEFAULT 'unknown' NOT NULL,
|
||||
relevance_reason VARCHAR2(120),
|
||||
relevance_evaluated_at TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_vr_relevance ON video_restaurants(relevance);
|
||||
```
|
||||
|
||||
### 도메인 (`VideoRestaurantLink` 확장은 본 범위 밖 — findVideoLinks는 `resultType="map"`)
|
||||
응답 Map에 키 추가:
|
||||
- `relevance`: `"strong" | "weak" | "incidental" | "unknown"`
|
||||
- `relevance_reason`: `string | null`
|
||||
|
||||
### LLM 응답 스키마
|
||||
```json
|
||||
{
|
||||
"relevance": "strong" | "weak" | "incidental",
|
||||
"reason": "20자 이내"
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 함수 명세
|
||||
|
||||
| 함수 | 책임 | 비고 |
|
||||
|---|---|---|
|
||||
| `VideoRelevanceService.verifyAsync(linkId)` | 비동기 트리거 | `#322`의 `RestaurantVerifyService.verifyAsync` 유사 |
|
||||
| `VideoRelevanceService.verify(linkId)` | 단건 검증 + DB 반영 | LLM 실패 시 unknown 유지 |
|
||||
| `VideoRelevanceService.verifyAll(batchSize)` | 백필 (식당당 200ms sleep) | |
|
||||
| `VideoRelevanceService.buildPrompt(...)` | 프롬프트 생성 | 식당명·주소·음식·영상 제목·평가 |
|
||||
| `VideoRelevanceService.parseRelevance(raw)` | LLM 응답 → DTO | 파싱 실패 시 unknown 안전 기본값 |
|
||||
| `RestaurantMapper.updateRelevance(linkId, rel, reason)` | DB 갱신 | |
|
||||
| `RestaurantMapper.findVideoLinks(restaurantId, includeWeak)` | 기존 SQL에 WHERE 조건 추가 | |
|
||||
| `AdminVideoRelevanceController` 신규 | 4개 admin endpoint | requireAdmin |
|
||||
|
||||
## 8. 흐름
|
||||
|
||||
### 신규 등록 자동 평가
|
||||
1. `PipelineService.processExtract` → `linkVideoRestaurant` → linkId 획득.
|
||||
2. `VideoRelevanceService.verifyAsync(linkId)` 호출(@Async).
|
||||
3. 별도 스레드: 영상/식당/평가 데이터 조회 → buildPrompt → LLM → parse → DB.
|
||||
|
||||
### 백필
|
||||
1. 어드민 `POST /api/admin/video-relevance/all` 호출.
|
||||
2. `WHERE relevance_evaluated_at IS NULL` 인 link 10개씩 조회 → 순차 검증.
|
||||
3. 식당당 200ms sleep (LLM rate 보호).
|
||||
|
||||
### 프롬프트 예시
|
||||
```
|
||||
다음 YouTube 영상이 이 식당을 어떻게 다루는지 판정하라.
|
||||
|
||||
식당명: {restaurantName}
|
||||
주소: {address}
|
||||
음식: {foodsMentioned}
|
||||
|
||||
영상 제목: {videoTitle}
|
||||
영상 채널: {channelName}
|
||||
영상에 등장한 평가 내용: {evaluation}
|
||||
|
||||
응답 형식(JSON만, 다른 텍스트 없이):
|
||||
{"relevance": "strong"|"weak"|"incidental", "reason": "20자 이내 한국어"}
|
||||
|
||||
가이드:
|
||||
- strong: 영상 본편이 이 식당을 본격 다룸. 방문 리뷰, 메뉴 평가 등.
|
||||
- weak: 영상에서 잠깐 언급, 비교 대상으로만 등장, 다른 식당의 일부로.
|
||||
- incidental: 식당 입점 전 영상에서 단순 언급, 일반 토픽(미국 비만, 환율 등)에서 잠깐.
|
||||
- 판단 모호 시 strong (보수적 — 사용자에게 표시 유지).
|
||||
```
|
||||
|
||||
## 9. 엣지케이스
|
||||
|
||||
- **LLM 응답 비-JSON**: parseRelevance → unknown 기본값.
|
||||
- **LLM 호출 실패**: unknown 유지 → 다음 백필 재시도.
|
||||
- **영상 데이터 누락(transcript 없음, evaluation 비어있음)**: 프롬프트에 "(미상)" 표기. LLM이 판정 어려우면 strong 보수적.
|
||||
- **동시성**: 같은 linkId verifyAsync 두 번 호출 → idempotent.
|
||||
- **삭제된 영상**: linkId 조회 결과 없으면 no-op.
|
||||
|
||||
## 10. 테스트 (수동)
|
||||
|
||||
- 파이브가이즈 강남 케이스 백필 → 7건 중 3건이 weak/incidental로 마킹되는지 확인.
|
||||
- 공개 API `/api/restaurants/{id}/videos` → 약한 매칭 제외 확인.
|
||||
- `?include_weak=true` → 모두 포함 확인.
|
||||
|
||||
## 11. 리스크 & 대안
|
||||
|
||||
- **선택**: `#322` 동일 패턴 + DB 마이그레이션.
|
||||
- **대안 A**: 사용자가 직접 "약한 매칭도 보기" 토글 → 사용자 결정 부담.
|
||||
- **대안 B**: 추출 단계에서 한 번에 판정 → 비용 ↓이지만 ExtractorService 비대.
|
||||
- **트레이드오프**: 단발 LLM 평가는 비용 합리적. false positive는 어드민 수동 토글 + `unknown` 안전 기본값으로 보완.
|
||||
|
||||
## 12. 미해결 질문
|
||||
|
||||
- 임계값(weak/incidental 둘 다 숨김 vs incidental만 숨김) — 현재는 둘 다 숨김.
|
||||
- 영상 자막 전체를 LLM에 보낼지 vs 평가 텍스트만 → 비용/정확도 트레이드오프. 현재는 evaluation만(짧음).
|
||||
- 사용자에게 "약한 매칭도 보기" UI → 별도 후속.
|
||||
- 어드민 UI — 별도 후속 (#322 패턴 모방).
|
||||
114
docs/design/357-web-search-api/README.md
Normal file
114
docs/design/357-web-search-api/README.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 설계서: DDG HTML 파싱 → 정식 검색 API 전환 (#357)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #357 · 부모: #348(이름 유사도, 09-Done) · 관련: searchTabling/searchCatchtable
|
||||
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/WebSearchService.java`(신규), `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`, `backend-java/src/main/resources/application.yml`, `k8s/secrets.yaml.template`
|
||||
> · 테스트: 본 범위 밖 (수동 — Tabling/Catchtable 매핑 SSE에서 확인)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
`searchDuckDuckGo`는 html.duckduckgo.com을 정규식으로 긁어 Tabling/Catchtable URL을 추출. 봇 차단/HTML 구조 변경 시 대량 `NONE` 마킹 위험. 정식 검색 API로 교체해 안정성/정확도 확보.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
- **포함**
|
||||
- `WebSearchService` 신규 — Naver Search webkr.json 우선, 미설정/실패 시 DDG 폴백.
|
||||
- `RestaurantController.searchTabling/searchCatchtable` 내부 호출을 새 서비스로 교체.
|
||||
- `application.yml` + `k8s/secrets.yaml.template`에 `NAVER_CLIENT_ID/SECRET` 항목 추가.
|
||||
- **제외 (별도 후속)**
|
||||
- Kakao Local API 검색 (식당 검색 전용이라 사이트 URL 매칭 부적합).
|
||||
- Google Custom Search Engine (비용 + 무료 100/day 제약).
|
||||
- 결과 캐시/메트릭 — 후속.
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] `NAVER_CLIENT_ID/SECRET` 환경변수 등록 시 Naver Search webkr.json 호출.
|
||||
- [ ] 미설정 또는 5xx/timeout 시 DDG로 자동 폴백 — 회귀 없음.
|
||||
- [ ] 응답 형식: 기존 `List<Map<String, Object>>` 유지 — `{title, url}` 키.
|
||||
- [ ] URL 패턴 필터 동일 동작 (예: `tabling.co.kr/restaurant/`, `tabling.co.kr/place/`).
|
||||
- [ ] 빌드/배포 회귀 없음.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- Naver Search 무료 한도: 일 25,000건. Tabling/Catchtable 매핑 백필이 1,200 식당 × 2회 ≈ 2,400건 1회성 → 한도 내.
|
||||
- Naver는 `site:` 연산자 미지원 — 결과 URL 패턴 필터링으로 대체.
|
||||
- `WebClient` 또는 `HttpClient` — 기존 `HttpClient`(static) 재사용.
|
||||
- 키 미설정 환경(dev local 일부)에서도 DDG 폴백으로 동작 보장.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
```
|
||||
RestaurantController.searchTabling(name)
|
||||
│
|
||||
▼
|
||||
WebSearchService.search(query, urlPatterns)
|
||||
│
|
||||
├─ NAVER_CLIENT_ID 설정 시
|
||||
│ └─ Naver webkr.json → URL 패턴 필터 → 결과
|
||||
│ (실패/0건 시 ▼)
|
||||
└─ DDG html.duckduckgo.com → 정규식 파싱 → URL 패턴 필터
|
||||
```
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
### 응답 (기존 유지)
|
||||
```json
|
||||
[ { "title": "스타벅스 강남대로점", "url": "https://app.catchtable.co.kr/dining/12345" } ]
|
||||
```
|
||||
|
||||
### Naver 응답 → 변환
|
||||
```json
|
||||
{ "items": [ { "title": "<b>스타벅스</b> 강남점", "link": "https://..." } ] }
|
||||
```
|
||||
- `title`은 `<b>` 태그 제거 후 사용.
|
||||
- `link`가 urlPatterns 중 하나에 매칭되면 결과에 포함.
|
||||
|
||||
## 7. 함수 명세
|
||||
|
||||
| 함수 | 책임 | 비고 |
|
||||
|---|---|---|
|
||||
| `WebSearchService.search(query, urlPatterns)` | 외부 진입점 | Naver 우선 → DDG 폴백 |
|
||||
| `WebSearchService.searchNaver(...)` | Naver Search webkr.json 호출 + 필터 | 키 미설정 시 즉시 빈 결과 |
|
||||
| `WebSearchService.searchDdg(...)` | 기존 DDG 로직 이관 | RestaurantController에서 옮김 |
|
||||
| `WebSearchService.stripTags(s)` | `<b>...</b>` 제거 | |
|
||||
| `WebSearchService.matchesPattern(url, patterns)` | URL 패턴 매칭 | `urlPatterns.length == 0` 면 모두 통과 |
|
||||
|
||||
## 8. 흐름
|
||||
|
||||
### 정상
|
||||
1. `searchTabling(name)` → `webSearchService.search("스타벅스", ["tabling.co.kr/restaurant/", "tabling.co.kr/place/"])`.
|
||||
2. Naver 호출 → 200, items 30 → URL 패턴 매칭 0~N건 추출.
|
||||
3. 0건이면 DDG 폴백, 그래도 0건이면 빈 리스트.
|
||||
|
||||
### 폴백
|
||||
- Naver 5xx / IOException / 키 미설정 → DDG.
|
||||
|
||||
### 키 등록 (운영자 작업)
|
||||
1. https://developers.naver.com/apps/#/list 검색 앱 등록.
|
||||
2. `NAVER_CLIENT_ID`, `NAVER_CLIENT_SECRET` 발급.
|
||||
3. dev: `.env`에 추가, prod: `k8s/secrets.yaml`에 추가 + `kubectl apply`.
|
||||
|
||||
## 9. 엣지케이스
|
||||
|
||||
- **키 미설정**: searchNaver 즉시 빈 리스트 → DDG 폴백 (회귀 없음).
|
||||
- **Naver rate limit (429)**: DDG 폴백.
|
||||
- **DDG도 막힘**: 빈 리스트 반환 → 호출자(매핑 SSE)에서 `NONE` 처리 — 기존 동작 동일.
|
||||
- **URL 패턴 빈 배열**: 패턴 매칭 스킵, 모든 결과 반환 (API 일반화 대비).
|
||||
|
||||
## 10. 테스트 (수동)
|
||||
|
||||
- Dev: `NAVER_CLIENT_ID/SECRET` 등록 → 어드민 `bulkTabling` SSE 실행 → 매칭율 비교 (이전 DDG 대비 ≥).
|
||||
- 키 일부 제거 → DDG 폴백 동작 확인.
|
||||
|
||||
## 11. 리스크 & 대안
|
||||
|
||||
- **선택**: Naver primary + DDG fallback. 한국 식당 정확도 + 무료 한도 + 폴백 안정성.
|
||||
- **대안 A**: Kakao Local Search — 식당 정보 직접 검색은 가능하지만 Tabling/Catchtable URL 매핑 부적합.
|
||||
- **대안 B**: Google CSE — 비용/한도 제약.
|
||||
- **트레이드오프**: Naver 응답 정확도가 압도적이지만 키 발급 운영자 작업 필요. 폴백으로 회귀 0 보장.
|
||||
|
||||
## 12. 미해결 질문
|
||||
|
||||
- Naver의 인덱스 신선도 vs Tabling/Catchtable 최신 입점 식당 미반영 가능성 — 백필 주기 후속.
|
||||
- 결과 캐시(같은 식당 재호출) — 후속.
|
||||
75
docs/design/358-restaurant-update-dto/README.md
Normal file
75
docs/design/358-restaurant-update-dto/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# 설계서: RestaurantUpdateDTO + @Valid 표준화 (#358)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #358 · 부모: #348(09-Done) · 관련: #332(화이트리스트 1차)
|
||||
> · 구현 파일: `backend-java/src/main/java/com/tasteby/dto/RestaurantUpdateDTO.java`(신규), `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java`
|
||||
> · 테스트: 본 범위 밖 (수동 — 어드민 식당 편집 동작 확인)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
`#332`에서 Set 화이트리스트로 1차 가드 적용했지만, 타입 안전성·validation·API 명세는 여전히 `Map<String, Object>`로 흐릿함. 본격 DTO 표준화로 잘못된 입력 자동 거부 + 명세 명확화.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
- **포함**
|
||||
- `RestaurantUpdateDTO` record — 화이트리스트 14필드 모두 Optional(null 시 미변경).
|
||||
- `@Valid` + Bean Validation 어노테이션 적용 (`@Size`, `@Pattern`, `@DecimalMin/Max`, `@Min`).
|
||||
- Controller `PUT /api/restaurants/{id}` 시그니처: `Map → RestaurantUpdateDTO`.
|
||||
- DTO → Map 변환(`toFieldMap()`) — Service 계층은 그대로 (재작업 0).
|
||||
- 잘못된 입력 시 400 자동 응답 (Spring 기본 `MethodArgumentNotValidException`).
|
||||
- **제외 (별도 후속)**
|
||||
- `tabling-url` / `catchtable-url` PUT 엔드포인트 — 단일 필드라 현행 유지.
|
||||
- PATCH 시멘틱 (부분 업데이트) — 현재 PUT이 부분 업데이트 의미로 사용 중.
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] 모든 화이트리스트 필드 record에 등재 + null 가능.
|
||||
- [ ] `name`: `@Size(min=1, max=200)`.
|
||||
- [ ] `website`/`tabling_url`/`catchtable_url`: `@Pattern(http(s)://... | "NONE" | "")`.
|
||||
- [ ] `latitude`: `@DecimalMin("-90.0") @DecimalMax("90.0")`.
|
||||
- [ ] `longitude`: `@DecimalMin("-180.0") @DecimalMax("180.0")`.
|
||||
- [ ] `rating`: `@DecimalMin("0.0") @DecimalMax("5.0")`.
|
||||
- [ ] `rating_count`: `@Min(0)`.
|
||||
- [ ] `price_range`: `@Min(1) @Max(5)`.
|
||||
- [ ] 잘못된 입력 → HTTP 400 자동 응답.
|
||||
- [ ] 기존 동작 회귀 없음 (geocode/cache flush 흐름 동일).
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- `spring-boot-starter-validation` 이미 의존성 등록됨.
|
||||
- record + Bean Validation: 컴파일 시 어노테이션 인식 OK.
|
||||
- Jackson SNAKE_CASE 매핑 유지: `cuisine_type`, `tabling_url` 등.
|
||||
- `null`은 "변경 없음" 시그널 — `toFieldMap()`에서 제외.
|
||||
|
||||
## 5. 함수 명세
|
||||
|
||||
| 함수 | 책임 | 비고 |
|
||||
|---|---|---|
|
||||
| `RestaurantUpdateDTO` (record) | 입력 표면 | 14 필드, 모두 nullable |
|
||||
| `RestaurantUpdateDTO.toFieldMap()` | null 제외 Map 변환 | Service `update` 시그니처 유지 |
|
||||
| `RestaurantController.update(...)` | DTO 받음 + geocode 분기 | `@Valid @RequestBody RestaurantUpdateDTO` |
|
||||
|
||||
## 6. 흐름
|
||||
|
||||
1. 클라이언트 → `PUT /api/restaurants/{id}` JSON.
|
||||
2. Spring 역직렬화 + Bean Validation. 실패 시 400 자동.
|
||||
3. `dto.toFieldMap()` → null 제외.
|
||||
4. 기존 geocode 분기 + `restaurantService.update(id, fieldMap)`.
|
||||
|
||||
## 7. 엣지케이스
|
||||
|
||||
- **모든 필드 null**: `toFieldMap()` 빈 Map → no-op (현행 유지).
|
||||
- **`tabling_url = "NONE"` / 빈 문자열**: Pattern에 포함 → 통과.
|
||||
- **숫자 범위 위반**: 400.
|
||||
- **알 수 없는 필드 (예: `xxx`)**: Jackson 기본은 무시 (mapper 설정 유지) → 안전.
|
||||
|
||||
## 8. 리스크 & 대안
|
||||
|
||||
- **선택**: record + Bean Validation. 코드 최소.
|
||||
- **대안 A**: class + setter. 보일러플레이트 다수.
|
||||
- **대안 B**: 개별 PATCH endpoint per 필드. 표면 폭증.
|
||||
|
||||
## 9. 미해결 질문
|
||||
|
||||
- bulkUpdate (batch) 도입 시 별도 DTO — 후속.
|
||||
47
docs/design/359a-duplicate-place-id-view/README.md
Normal file
47
docs/design/359a-duplicate-place-id-view/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 설계서: google_place_id 중복 조회 API (#359 1단계)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
|
||||
> **추적성** — Redmine: #359 · 1단계(조회 전용, 위험 0). 2단계(자동 병합) / 3단계(UNIQUE)는 별도 PR.
|
||||
> · 구현 파일: `backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml`, `backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java`, `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminRestaurantController.java`
|
||||
> · 테스트: 본 범위 밖 (수동 — admin token으로 호출).
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
같은 `google_place_id`에 다중 식당이 매핑된 경우 운영자가 어떤 것을 유지/병합할지 결정 필요. 본 단계는 **조회만** — 그룹과 후보 식당을 메타데이터(연결된 영상/리뷰/메모 수)와 함께 보여줘 의사결정 자료 제공.
|
||||
|
||||
## 2. 범위
|
||||
|
||||
- 포함: `GET /api/admin/restaurants/duplicates/place-id` — 운영자만, 그룹별 식당 + 카운트 동봉.
|
||||
- 제외 (별도 PR): 병합/삭제, UNIQUE constraint.
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] requireAdmin 보호.
|
||||
- [ ] 응답 구조: `[{ google_place_id, items: [{ id, name, address, created_at, video_count, review_count, memo_count, hidden }] }]`.
|
||||
- [ ] 그룹은 `COUNT(*) > 1` 만 반환.
|
||||
|
||||
## 4. SQL
|
||||
|
||||
```sql
|
||||
SELECT r.id, r.google_place_id, r.name, r.address,
|
||||
TO_CHAR(r.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS created_at,
|
||||
r.hidden,
|
||||
(SELECT COUNT(*) FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS video_count,
|
||||
(SELECT COUNT(*) FROM reviews rv WHERE rv.restaurant_id = r.id) AS review_count,
|
||||
(SELECT COUNT(*) FROM memos mm WHERE mm.restaurant_id = r.id) AS memo_count
|
||||
FROM restaurants r
|
||||
WHERE r.google_place_id IN (
|
||||
SELECT google_place_id FROM restaurants
|
||||
WHERE google_place_id IS NOT NULL
|
||||
GROUP BY google_place_id HAVING COUNT(*) > 1
|
||||
)
|
||||
ORDER BY r.google_place_id, r.created_at
|
||||
```
|
||||
|
||||
Service 계층에서 google_place_id로 그룹핑하여 응답 구조 변환.
|
||||
|
||||
## 5. 엣지케이스
|
||||
|
||||
- 중복 0건 → 빈 배열.
|
||||
- 누군가가 google_place_id를 동시에 변경 중 → 다음 호출에서 반영 (캐시 X).
|
||||
112
docs/design/363-map-sdk-branch/README.md
Normal file
112
docs/design/363-map-sdk-branch/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# 설계서: 메인 지도 탭 SDK 국내/해외 분기 (#363)
|
||||
|
||||
> **상태**: Approved
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-16
|
||||
> **추적성** — Redmine: #363 · 부모: v0.1.51 1단계(외부 링크 분기) · 관련: MapView.tsx, mobile nearby
|
||||
> · 구현 파일: `frontend/src/components/MapView.tsx`(dispatcher), `frontend/src/components/GoogleMapView.tsx`(rename from 기존 MapView 내용), `frontend/src/components/NaverMapView.tsx`(신규), `frontend/src/lib/map-utils.ts`(공용 헬퍼)
|
||||
> · 테스트: 본 범위 밖 (수동 — dev 브라우저 검증)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
현재 MapView는 `@vis.gl/react-google-maps` 단일 사용. 한국 식당은 네이버 지도가 지번/도로명/상호/길찾기에서 압도적으로 정확. 메인 지도 탭 자체를 국내/해외 분기.
|
||||
|
||||
## 2. 범위
|
||||
|
||||
- 포함: MapView를 dispatcher로 전환, 좌표 기반 자동 분기(KR bbox), 네이버 키 미설정 시 GoogleMap fallback.
|
||||
- 제외 (별도 후속): 사용자 강제 토글 UI, mixed 화면(한국+해외 동시) 최적화, 모바일 nearby도 동일 분기는 1차 적용 후 검토.
|
||||
|
||||
## 3. 인수조건
|
||||
|
||||
- [ ] `NEXT_PUBLIC_NAVER_MAP_CLIENT_ID` 환경변수 설정 + 화면 중심이 KR bbox 안이면 NaverMap 렌더.
|
||||
- [ ] 키 미설정 또는 화면이 KR 밖이면 GoogleMap 렌더 (현행 동일).
|
||||
- [ ] Supercluster + 클러스터/단일 마커 표시, 클릭 → onSelectRestaurant 콜백 동일.
|
||||
- [ ] flyTo, onBoundsChanged, 내 위치, 채널 색상 동일하게 동작.
|
||||
- [ ] 빌드/타입 회귀 없음.
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
- 네이버 지도 v3: `https://oapi.map.naver.com/openapi/v3/maps.js?ncpClientId=<ID>` 스크립트 로드.
|
||||
- 네이버 좌표계: 기본 WGS84 (`naver.maps.LatLng(lat, lng)`).
|
||||
- 직접 wrapper 채택 (react-naver-maps 의존성 제거 — 메인터넌스 리스크).
|
||||
- Supercluster는 SDK 독립이라 재사용.
|
||||
- KR bbox: 위도 33~38.7, 경도 124~132. 화면 중심좌표가 안에 있으면 한국.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
```
|
||||
MapView (dispatcher)
|
||||
│
|
||||
├─ 화면 중심 좌표가 KR bbox AND 네이버 키 있음 → NaverMapView
|
||||
│ ├─ <script src=naver maps v3> 동적 로드
|
||||
│ ├─ useEffect: new naver.maps.Map(div, ...)
|
||||
│ ├─ Supercluster로 cluster 계산 → markers div overlay
|
||||
│ └─ flyTo: map.setCenter + setZoom
|
||||
│
|
||||
└─ 그 외 → GoogleMapView (기존 MapView 내용 그대로 이전)
|
||||
```
|
||||
|
||||
## 6. 함수 명세
|
||||
|
||||
| 함수 | 책임 | 비고 |
|
||||
|---|---|---|
|
||||
| `MapView` (dispatcher) | 좌표 기반 분기 | flyTo 또는 첫 마운트 좌표로 판정 |
|
||||
| `GoogleMapView` | 기존 MapView 내용 | rename만, 로직 변경 X |
|
||||
| `NaverMapView` | 신규 — 네이버 지도 + Supercluster + markers | wrapper 직접 |
|
||||
| `useNaverMaps(clientId)` | 스크립트 로드 + ready boolean | 한 번만 로드 |
|
||||
| `isKoreaBounds(lat, lng)` | KR bbox 판정 | map-utils 공용 |
|
||||
|
||||
## 7. 흐름
|
||||
|
||||
1. MapView 마운트 → flyTo or 첫 식당 평균 좌표로 초기 중심 계산.
|
||||
2. KR bbox + 키 있음 → NaverMapView 마운트.
|
||||
3. NaverMapView: `useNaverMaps` 훅으로 v3 스크립트 로드, ready되면 `new naver.maps.Map(divRef, options)` 생성.
|
||||
4. Supercluster로 cluster 계산 → 마커는 absolute positioned div overlay (네이버 OverlayView 또는 자체 좌표 변환).
|
||||
5. 사용자 줌/팬 → bounds_changed 이벤트 → 클러스터 재계산 + onBoundsChanged 콜백.
|
||||
|
||||
## 8. 엣지케이스
|
||||
|
||||
- **네이버 스크립트 로드 실패**: ready=false 유지, dispatcher가 다음 렌더 사이클에서 GoogleMap fallback.
|
||||
- **flyTo가 해외 좌표인데 현재 NaverMap 중**: dispatcher 재판정 → GoogleMap로 교체 (remount).
|
||||
- **mixed 화면(한국+해외 식당)**: 화면 중심 기준 SDK 선택 → 다른 나라 식당은 화면 밖에 있어 무관.
|
||||
- **키 미설정**: 항상 GoogleMap (회귀 0).
|
||||
|
||||
## 9. 리스크 & 대안
|
||||
|
||||
- **선택**: 직접 wrapper. 의존성 최소, 유지보수 자유.
|
||||
- **대안 A**: `react-naver-maps` npm — 빠른 시작이지만 메인터넌스 상태 불확실.
|
||||
- **대안 B**: 단일 SDK(Maplibre + 네이버 타일) — 타일 권리 이슈.
|
||||
- **트레이드오프**: 직접 wrapper는 초기 코드 양 ↑이지만 한 번 만들면 안정.
|
||||
|
||||
## 10. 미해결 질문
|
||||
|
||||
- 한 화면 mixed(국가 경계 근처) 동시 마커 — 후속.
|
||||
- 사용자 토글 UI — 후속.
|
||||
- 모바일 nearby 동일 분기 — 1차 적용 후 결정.
|
||||
|
||||
## 11. 실제 구현 기록 (2026-06-16)
|
||||
|
||||
### 배포 흐름
|
||||
| 버전 | 내용 |
|
||||
|---|---|
|
||||
| v0.1.51 | **1단계** — 식당 상세 외부 링크 좌표 기반 분기 (`RestaurantDetail.tsx`) |
|
||||
| v0.1.52 | **2단계** — MapView dispatcher + NaverMapView/GoogleMapView 분리 + Dockerfile/deploy.sh build-arg |
|
||||
| v0.1.53 | **fix**: 인증 파라미터 `ncpClientId` → `ncpKeyId` (NCLOUD 신 정책, 옛 NAVER Developers와 다름) |
|
||||
| v0.1.55–56 | 임시 fallback (운영 일시 GoogleMap, 디버그) |
|
||||
| v0.1.57 | **안정화 + 재활성** — divRef 첫 렌더 누락 fix, ResizeObserver/rAF, try/catch |
|
||||
|
||||
### 운영 진단에서 확인된 사항
|
||||
- NCLOUD Maps Application의 Web 서비스 URL은 **스킴 포함**(`https://...`).
|
||||
- 옛 NAVER Developers와 NCLOUD는 다른 시스템 — Search Application과 Maps Application은 도메인 중복 충돌 없음.
|
||||
- NCLOUD 콘솔의 신규 경로: `Services > Application Services > Maps > Application`.
|
||||
|
||||
### NaverMapView 안정화 핵심 결정사항
|
||||
- **`divRef` 항상 마운트** (early return 제거) — `ready=false` 동안에도 div를 두고 로딩 메시지는 overlay로 표시.
|
||||
- **명시적 `width:100%; height:100%`** + 회색 배경 — 컨테이너 영역이 시각적으로 확인 가능.
|
||||
- **ResizeObserver + requestAnimationFrame**으로 컨테이너 0×0 → 정상 크기 변경 시 `m.refresh(true)`.
|
||||
- **try/catch + `initError` state** — init 실패 시 화면 가시화.
|
||||
|
||||
### 후속 (별도 PR)
|
||||
- 사용자 토글 (네이버/구글 강제 선택) UI.
|
||||
- mixed 화면(국경 근처) 동시 마커.
|
||||
- 모바일 nearby 탭 동일 분기 검토.
|
||||
- 채널 색상/InfoWindow 등 GoogleMapView 수준의 디테일을 NaverMapView에 도입.
|
||||
46
docs/design/532-region-display/README.md
Normal file
46
docs/design/532-region-display/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# #532 — 지역 표시 계층화 (formatRegion)
|
||||
|
||||
## 배경 / 문제
|
||||
지역(`region`)은 `나라|시도|구군` 파이프 구분 문자열(예: `한국|서울|강남구`).
|
||||
사용자 화면에 **원본이 그대로 노출**되어 `한국|서울|강남구`(파이프) 또는 평문 `한국 서울`로 보임.
|
||||
기대: **`한국 › 서울 › 구군`** 계층(breadcrumb) 표시.
|
||||
|
||||
## 범위
|
||||
- 식당 상세 시트의 "지역" 표기 (`RestaurantDetail.tsx`)
|
||||
- 식당 리스트 카드의 지역 표기 (`RestaurantList.tsx`)
|
||||
- 공용 유틸 `formatRegion()` 신설 (`lib/region.ts`)
|
||||
|
||||
비범위: 지역 *필터* 동작/정렬(정상), 외부 지도 검색 쿼리(`buildSearchQuery` — 공백 조인 유지), 데이터 정합성(별도 이슈).
|
||||
|
||||
## 함수 설계
|
||||
|
||||
### `formatRegion(region: string | null | undefined): string`
|
||||
- **입력**: 파이프 구분 지역 문자열 (예: `"한국|서울|강남구"`, `"일본|null"`, `"한국||관악구"`, `""`)
|
||||
- **처리**:
|
||||
1. falsy → `""` 반환
|
||||
2. `split("|")` → 각 토큰 `trim()`
|
||||
3. 빈 토큰, `"null"`(대소문자 무시), 더미 `"나라"` 제거
|
||||
4. 남은 토큰을 `" › "`(U+203A)로 조인
|
||||
- **출력 예**:
|
||||
| 입력 | 출력 |
|
||||
|---|---|
|
||||
| `한국\|서울\|강남구` | `한국 › 서울 › 강남구` |
|
||||
| `일본\|null` | `일본` |
|
||||
| `한국\|\|관악구` | `한국 › 관악구` |
|
||||
| `""` / `null` | `""` (호출부에서 미표시) |
|
||||
- **순수 함수**(I/O 없음) → 단위 테스트 용이.
|
||||
|
||||
### 적용
|
||||
- `RestaurantDetail.tsx:132`: `{restaurant.region}` → `{formatRegion(restaurant.region)}`
|
||||
- `RestaurantList.tsx:54`: `{r.region}` → `{formatRegion(r.region)}`
|
||||
- 표시 가드: 원본 `region &&`(truthy) 가 아니라 `formatRegion(region) &&`(변환 결과 truthy) 로 감싼다.
|
||||
원본이 `"|"`/`"한국|"`처럼 전 토큰 더미인 경우 원본은 truthy 여도 변환 결과는 `""` 이므로,
|
||||
가드를 원본 기준으로 두면 빈 배지/빈 줄이 렌더된다(#532 Designer 단계에서 발견·수정).
|
||||
|
||||
## 엣지케이스
|
||||
- 모든 토큰이 더미/빈 값(`"한국|"`, `"|"`) → 유효 토큰만 남김(`한국`) 또는 `""`. `formatRegion(region) &&` 가드로 `""`이면 미표시.
|
||||
- 단일 토큰(`"한국"`) → `한국` 그대로.
|
||||
|
||||
## 검증
|
||||
- 단위 테스트(가능 시) 또는 dev 화면에서 상세/리스트 지역 표기가 `›` 계층으로 보이는지.
|
||||
- `npm run build`(또는 tsc/lint) 통과.
|
||||
48
docs/design/533-min-mentions-filter/README.md
Normal file
48
docs/design/533-min-mentions-filter/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# #533 — 언급 적은(관련도 낮은) 식당 제외 필터
|
||||
|
||||
## 배경 / 문제
|
||||
영상에서 잠깐·우연히 언급된(=관련도 낮은) 식당까지 모두 노출되어 신호 대비 잡음이 큼.
|
||||
사용자가 "언급이 충분한 식당만" 보도록 제외 필터 제공.
|
||||
|
||||
## 신호 정의
|
||||
"의미있는 언급 수(`video_count`)" = 해당 식당의 `video_restaurants` 링크 중
|
||||
**relevance ∈ ('strong','unknown')** 개수. (weak/incidental = 곁다리 언급 → 제외)
|
||||
→ 기존 `findVideoLinks` 기본 노출 정책(strong/unknown)과 일관.
|
||||
|
||||
## 범위
|
||||
- **백엔드**: 식당 목록 응답에 `video_count` 포함 + `min_mentions` 서버 필터 파라미터.
|
||||
- **프론트**: `Restaurant.video_count` 타입 + 클라이언트 필터 UI(전체/2회+/3회+).
|
||||
- 서버사이드 필터 일원화(현 클라 필터 패턴 통일)는 **#535**로 분리.
|
||||
|
||||
## 함수/변경 설계
|
||||
|
||||
### 백엔드
|
||||
1. `domain/Restaurant.java` — `Integer videoCount` 필드 추가(전이 enrichment).
|
||||
2. `mapper/RestaurantMapper.xml`
|
||||
- `restaurantMap` resultMap: `<result property="videoCount" column="video_count"/>`
|
||||
- `findAll` SELECT에 스칼라 서브쿼리 추가:
|
||||
`(SELECT COUNT(*) FROM video_restaurants vrc WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) AS video_count`
|
||||
- `findAll` `<where>`에 optional 필터:
|
||||
`<if test="minMentions != null and minMentions > 0"> AND (위 서브쿼리) >= #{minMentions} </if>`
|
||||
3. `mapper/RestaurantMapper.java` — `@Param("minMentions") Integer minMentions` 추가.
|
||||
4. `service/RestaurantService.java` — `findAll(..., Integer minMentions, boolean includeHidden)`로 스레드. 5-arg 오버로드는 `null, false` 전달.
|
||||
5. `controller/RestaurantController.java` — `@RequestParam("min_mentions") Integer minMentions` + 캐시 키에 `mm=` 포함.
|
||||
|
||||
### 프론트
|
||||
6. `lib/api.ts` — `Restaurant.video_count?: number | null`; `getRestaurants` 파라미터 `min_mentions?`(미래/서버필터용).
|
||||
7. `app/page.tsx` — 필터 상태 `minMentions`(0/2/3/5) + `filteredRestaurants`에 `(r.video_count ?? 0) >= minMentions` 가드 + UI 컨트롤 + useMemo deps 추가.
|
||||
- **필터 리셋 일관성**: `minMentions`도 다른 필터(장르/가격/지역/채널)와 동일하게 취급.
|
||||
- 전역 "전체보기" 버튼: 노출 조건에 `minMentions > 0` 포함, 클릭 시 `setMinMentions(0)`로 함께 초기화.
|
||||
- "내위치" 토글 ON: 다른 필터 초기화 목록에 `setMinMentions(0)` 포함(내위치와 상호배타).
|
||||
데스크톱/모바일 토글 **전부**(3곳)에 동일 적용 — 반응형 리사이즈 시에도 일관.
|
||||
|
||||
## 엣지케이스
|
||||
- `video_count` 미산정(findById 등) → null. 클라 필터는 `?? 0`로 안전.
|
||||
- minMentions=0(기본) → 필터 미적용(기존 동작 유지).
|
||||
- DISTINCT + 스칼라 서브쿼리: r.id 단위라 결과 불변.
|
||||
|
||||
## 검증
|
||||
- 백엔드 빌드 성공 → PM2 재시작.
|
||||
- `GET /api/restaurants?min_mentions=2` 가 2회 미만 식당 제외하는지.
|
||||
- 응답에 `video_count` 포함 확인.
|
||||
- 프론트 토글로 목록/지도가 줄어드는지.
|
||||
86
docs/design/545-hide-foreign-reservation-buttons/README.md
Normal file
86
docs/design/545-hide-foreign-reservation-buttons/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 설계서: 해외 식당 예약 버튼(테이블링/캐치테이블) 숨김 (#545)
|
||||
|
||||
> **상태**: Approved · QA 통과 · Designer 접근성 다듬기 완료
|
||||
> **작성**: [AI] Architect (소급 작성 — Developer 구현 후 게이트 보완) · **최종수정**: 2026-07-27 ([AI] Designer)
|
||||
> **추적성** — Redmine: #545 · 관련 ADR: 없음
|
||||
> · 구현 파일: `frontend/src/components/RestaurantDetail.tsx` · 테스트: `frontend/__tests__/RestaurantDetail.test.tsx` (5케이스, 육안 QA를 자동화 회귀 테스트로 대체)
|
||||
|
||||
## 1. 목적 (Why)
|
||||
해외 식당 상세에 국내 전용 예약 서비스(테이블링/캐치테이블) 버튼이 노출되는 버그를 막는다.
|
||||
테이블링/캐치테이블은 국내 식당만 지원하므로, 해외 식당에서는 두 버튼을 숨겨야 한다.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
- **포함**: `RestaurantDetail.tsx`의 테이블링/캐치테이블 예약 버튼 2종에 국내 판정 게이트 적용.
|
||||
- **제외 (out of scope)**: 백엔드 예약 URL 검색/벌크 수집이 해외 식당을 제외하도록 하는 것 (#545 후속, 별도 이슈로 분리 가능).
|
||||
|
||||
## 3. 인수조건 (Acceptance Criteria)
|
||||
- [x] 해외 식당(좌표가 한국 bbox 밖) 상세에서 테이블링/캐치테이블 버튼이 보이지 않는다.
|
||||
- [x] 국내 식당 상세에서는 기존과 동일하게 두 버튼이 보인다 (URL 존재 시).
|
||||
- [x] `tsc` 타입체크 통과.
|
||||
- [x] QA 확인 (해외/국내 식당 각 1건 이상, 대표 발견 케이스 포함) — 자동화 회귀 테스트로 대체 검증 완료(`RestaurantDetail.test.tsx`).
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
- 의존성: `Restaurant.latitude/longitude`, `Restaurant.region` (좌표 없는 구 데이터 fallback).
|
||||
- 제약: 기존 지도 링크 분기(151행)에 이미 쓰이던 `isKoreaRestaurant()` 판정 기준과 동일하게 유지해야 함(판정 기준 이원화 방지).
|
||||
- 가정: `tabling_url`/`catchtable_url`이 `"NONE"`이 아니고 값이 있어도, 해외 식당이면 노출하지 않는다.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
- 변경 파일: `frontend/src/components/RestaurantDetail.tsx` 1개.
|
||||
- 데이터 흐름: `restaurant` prop → `isKoreaRestaurant(restaurant)` 순수 판정 → JSX 렌더 게이트.
|
||||
- I/O 없음, 순수 함수 기반 조건부 렌더링만 추가.
|
||||
|
||||
```
|
||||
Restaurant (좌표/region)
|
||||
│
|
||||
▼
|
||||
isKoreaRestaurant(r) ──false──▶ 테이블링/캐치테이블 버튼 미렌더
|
||||
│
|
||||
true
|
||||
│
|
||||
▼
|
||||
기존 로직: url 존재 && != "NONE" → 버튼 렌더
|
||||
```
|
||||
|
||||
## 6. 데이터 모델
|
||||
- 입력: `Restaurant { latitude?, longitude?, region?, tabling_url?, catchtable_url? }` (기존 타입, 변경 없음).
|
||||
- 출력: 없음 (렌더 분기만).
|
||||
- 경계 검증: `isKoreaRestaurant()` 내부에서 이미 null/undefined 가드 처리(좌표 없으면 region 첫 토큰 fallback).
|
||||
|
||||
## 7. 함수 명세 (Function Specs)
|
||||
|
||||
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||
|------|-----------|----------|------|------|-----------|-------|
|
||||
| `isKoreaRestaurant` | 좌표/region 기반 국내 식당 판정 (기존 함수, 변경 없음 — 적용 범위만 확대) | `(r: Restaurant) => boolean` | `Restaurant` | `boolean` | 없음 (순수 함수) | 단순 |
|
||||
|
||||
> 신규 함수 없음. 기존 `isKoreaRestaurant()`(30행, 지도 링크 분기용으로 이미 존재)를 예약 버튼 2곳에도 동일 적용.
|
||||
|
||||
## 8. 흐름 / 알고리즘
|
||||
1. `isKoreaRestaurant(restaurant)` 평가 (좌표 우선, KR bbox 33~38.7°N / 124~132°E; 좌표 없으면 `region` 첫 토큰이 `"한국"`인지로 fallback).
|
||||
2. `true`일 때만 기존 조건(`tabling_url`/`catchtable_url` 존재 && `!== "NONE"`)을 이어서 평가.
|
||||
3. 최종 `true`일 때만 버튼 렌더.
|
||||
|
||||
## 9. 엣지케이스 & 에러 처리
|
||||
- 좌표와 region 모두 없는 구 데이터 → `isKoreaRestaurant`는 `!r.region` 조건으로 `true` 반환(국내로 간주, 기존 동작 유지 — 회귀 아님).
|
||||
- 좌표가 KR bbox 경계값에 걸치는 경우(제주/울릉도 등) → 기존 지도 링크 판정과 동일 로직이므로 별도 리스크 없음(기존에 검증된 임계값 재사용).
|
||||
|
||||
## 10. 테스트 계획
|
||||
- `tsc` 통과 (완료).
|
||||
- 자동화 회귀 테스트(`frontend/__tests__/RestaurantDetail.test.tsx`, RTL) — [AI] QA 작성, 5케이스 전부 통과:
|
||||
1. 국내 좌표(서울) + URL 존재 → 두 버튼 노출.
|
||||
2. 해외 좌표(방콕, 대표 발견 케이스) + URL 존재 → 두 버튼 숨김.
|
||||
3. 해외 좌표(도쿄) + URL 없음 → 숨김 유지(회귀 없음).
|
||||
4. 좌표 없음 + region null → fallback으로 국내 간주, 버튼 노출(기존 동작 유지).
|
||||
5. URL이 `"NONE"` 문자열 → 국내 식당이어도 버튼 없음(기존 게이트 유지).
|
||||
|
||||
## 11. 리스크 & 대안 검토
|
||||
- 대안: 백엔드에서 해외 식당의 `tabling_url`/`catchtable_url`을 애초에 null로 채우지 않는 방법도 있으나, 프론트 게이트가 더 즉각적이고 기존 판정 로직 재사용 가능 → 채택.
|
||||
- 되돌리기 어려운 결정 없음. ADR 불필요.
|
||||
|
||||
## 12. 미해결 질문 (Open Questions)
|
||||
- 백엔드 예약 URL 수집/검색 단계에서 해외 식당을 원천 제외할지 여부는 별도 이슈로 분리할 가치가 있음 (설명에 "후속(옵션)"으로 명시됨).
|
||||
|
||||
## 13. Designer 다듬기 (표현/접점, 기능 변경 없음)
|
||||
- 판정 로직(`isKoreaRestaurant`)과 노출 조건은 QA 통과 상태 그대로 — 변경하지 않음.
|
||||
- 테이블링/캐치테이블 앵커에 `title` 속성 추가(`"… (새 탭)"`) — 새 탭 이동을 사전에 알려 예약 흐름 중 예기치 않은 이탈감 완화.
|
||||
- 로고 대체용 단일 문자 배지(`T`/`C`)에 `aria-hidden="true"` 적용 — 스크린리더가 의미 없는 낱글자를 중복 낭독하지 않고 실제 라벨만 읽도록 정리.
|
||||
- 회귀 확인: `tsc --noEmit` 통과, `frontend/__tests__/RestaurantDetail.test.tsx` 5/5 통과.
|
||||
90
docs/ir/modu-startup-2026-09/README.md
Normal file
90
docs/ir/modu-startup-2026-09/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 모두의 창업 2차 — Tasteby 지원서 작업방
|
||||
|
||||
- **지원 아이템**: Tasteby — 신뢰하는 유튜버/유명인의 맛집을 AI가 자동 정리해 한 지도에 모아주는 디스커버리 플랫폼
|
||||
- **구분**: 2차 지원(재도전) — Q8 재도전 가점 항목 존재
|
||||
- **서비스**: https://www.tasteby.net (MVP 운영 중)
|
||||
|
||||
## 파일 구성
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `questions.md` | 지원서 문항 원문·제약 (고정, 수정하지 않음) |
|
||||
| `v1.md` | 최초 스냅샷 (보유 원고를 문항에 매핑) |
|
||||
| `v2.md` | Codex(gpt-5.6) 작성분을 이관 |
|
||||
| `v3.md` | v2에 실측 지표 반영 |
|
||||
| `v4.md` | **현재 제출안**. Q8 완성 |
|
||||
|
||||
## 버저닝 규칙
|
||||
|
||||
1. 수정할 때 이전 버전을 고치지 않는다. `cp v1.md v2.md` 후 v2를 고친다.
|
||||
2. 각 버전 머리말에 **무엇을 왜 바꿨는지** 한 줄씩 남긴다.
|
||||
3. 최종 제출 시 `FINAL.md` 로 복사하고, 실제 폼에 붙여넣은 시각을 기록한다.
|
||||
|
||||
## 버전 이력
|
||||
|
||||
| 버전 | 날짜 | 요약 |
|
||||
|------|------|------|
|
||||
| v1 | 2026-09-15 | 보유 원고를 12개 문항에 매핑한 최초 스냅샷. Q1/Q2는 폼 입력 원문 보존 |
|
||||
| v2 | 2026-09-15 | Codex 작성. 전 문항 작성 완료. "출처 기반 발견 → 비공개 기록 → 선택적 공유"로 논리 재편. Redmine #791 |
|
||||
| v4 | 2026-09-15 | Q8 완성 — 1차 원문이 `v1.md`임을 확인하고 1차의 약점 4가지와 2차 수정을 1:1 대응 |
|
||||
| v3 | 2026-09-15 | 실측 반영. Q4-1의 "3개월 5,000곳"(10배, 달성 불가) → 현재 500곳 기준 배수 목표로 교체. 미검증 1,071건 정리를 0~3개월 과제로. 해외 60여 곳을 Q3-1/Q4-1 근거로. 깨진 링크 3개 제거 |
|
||||
|
||||
## 현재 진척 (v2 기준, 글자수 실측)
|
||||
|
||||
| 문항 | 글자수 | 제한 | 상태 |
|
||||
|------|--------|------|------|
|
||||
| Q1 한 줄 소개 | 54 | 100 | 🟡 v1(62자)과 문구 다름 — 택1 필요 |
|
||||
| Q2 배경 이야기 | 996 | 2,000 | 🟡 폼에 든 v1 원문과 다름 — 교체 여부 결정 |
|
||||
| Q3-1 차별점 | 1,286 | 2,000 | ✅ v3에서 해외 데이터 근거 추가 |
|
||||
| Q3-2 수익 창출 | 1,050 | 2,000 | ✅ 원칙·구조 有 / 숫자 無 |
|
||||
| Q4-1 사업화 계획 | 1,302 | 2,000 | ✅ v3에서 실측 기반 재작성 |
|
||||
| Q4-2 멘토 도움 | 846 | 2,000 | ✅ 질문에 맞게 재작성됨 |
|
||||
| Q5 소개 영상 | 구성안 | - | ⬜ 촬영·편집 필요 |
|
||||
| Q6 사업 분야 | 선택 기준 | - | ⬜ 드롭다운 실물 확인 |
|
||||
| Q7 창업 여부 | 판단표 | - | ⬜ 법적 사실 확인 |
|
||||
| Q8 1차 대비 개선점 | 1,118 | - | ✅ v4에서 완성 |
|
||||
| Q9 본인 역량 | 916 | 2,000 | 🟡 경력·배경 한 단락 추가 여지 |
|
||||
| Q10 팀원 수 | - | - | ✅ 팀원 없음 |
|
||||
| Q11 자랑하기 | 183 | - | ✅ 문구 有 / 스크린샷 필요 |
|
||||
| Q12 서약 동의 | - | - | ⬜ 제출 직전 |
|
||||
|
||||
## v2 검증 결과 (2026-09-15)
|
||||
|
||||
**사실로 확인된 것**
|
||||
- 찜·공개 리뷰·비공개 메모 기능 실존: `MemoController`/`MemoService`(사용자별, 공개 엔드포인트 없음), `ReviewController`, 프론트 `RestaurantDetail.tsx`
|
||||
- MVP 운영 중: PM2 `tasteby-api`/`tasteby-web` 가동, www.tasteby.net
|
||||
|
||||
**확인되지 않은 것**
|
||||
- v2 머리말의 참조 문서 3개(`docs/design/705-tasteby-bm-v1-1/`, `docs/strategy/tasteby-personalization-…`, `docs/design/711-tasteby-marketing-v1-5/`)가 **리포에 없음**
|
||||
- Codex가 보고한 산출 경로 `docs/applications/moduui-changup-round2/`, 워크트리 `~/claude-workspace/business-model`, 커밋 `f50bbab` 모두 이 머신에 **없음** (Redmine #791은 실존)
|
||||
- 로드맵 수치(식당 5,000곳/채널 10곳/MAU 5만)의 출발점이 될 현재 실측치 미확인
|
||||
|
||||
## 남은 과제 (우선순위)
|
||||
|
||||
1. **Q1·Q2 택1** — 폼에는 v1 문구가 이미 들어가 있음. v3 문구로 교체할지 결정.
|
||||
2. **Q3-2에 숫자** — 원칙은 섰으나 단가·매출 추정이 없어 "언제 돈을 버는가"가 불명확.
|
||||
3. **Q4-1 획득 경로** — "검색·커뮤니티·콘텐츠"가 아직 범주 수준. 다만 v3에서 공개 모집 전 단계임을 명시해 과장은 제거됨.
|
||||
4. **Q5 영상** — 구성안만 있고 미제작.
|
||||
5. **사진 8장 확보** — 도식 3장 제작 필요.
|
||||
6. **Q9 경력 보강**
|
||||
7. **미배포 15커밋** — Q8이 언급한 개선(#532/#533/#545)이 main에만 있고 운영 미반영. 제출 전 배포 필요 — 공개 가능한 경력·수상이 있으면 한 단락.
|
||||
|
||||
## 실측 지표 (2026-09-15 확인)
|
||||
|
||||
| 항목 | 현재 | 확인 방법 |
|
||||
|------|------|-----------|
|
||||
| 등록 채널 | **6개** | Admin 채널 관리 |
|
||||
| 수집 영상 | **909편** | 채널별 합계 (성시경52·김사원세끼270·더들리188·떡볶퀸92·비밀이야196·버거코치111) |
|
||||
| 노출 식당 | **500곳** | `GET /api/restaurants?limit=100000` |
|
||||
| 좌표 보유 | 500/500 (100%) | 동 API |
|
||||
| 해외 식당 | 일본 28 · 미국 9 · 싱가포르 8 · 이탈리아 4 · 홍콩 3 (국내 417) | region 집계 |
|
||||
| 2회 이상 언급 | 63곳 (3회 이상 21곳) | `video_count` 집계 |
|
||||
| LLM 미검증 | 1,071건 | Admin 식당 관리 |
|
||||
| 가입자 수 | **공개 모집 전** | 지원서에 수치로 쓰지 않고, "제품·데이터 우선 확보 단계"로 서술 |
|
||||
|
||||
**로드맵 대비 위치**: 채널 6→10(1.7배). 식당 500→5,000은 10배로, 채널당 평균 83곳 기준 채널 60개 수준의 커버리지를 요구해 3개월 내 달성 불가 → **v3에서 1,500곳으로 조정하고 0~3개월 과제를 미검증 1,071건 정리로 재정의함**.
|
||||
|
||||
## 참고 자산
|
||||
|
||||
- `docs/ir/bunkershot-2026-06-29/` — 6월 벙커샷 IR 자료 (피치덱, 1pager, 예상 QA, 시연 시나리오). Q5 영상 대본·Q3 소재로 재활용 가능
|
||||
- `screenshots/` — 서비스 스크린샷 (사진 첨부용, 전체 합산 10장 제한)
|
||||
26
docs/ir/modu-startup-2026-09/questions.md
Normal file
26
docs/ir/modu-startup-2026-09/questions.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 모두의 창업 2차 — 지원서 문항 원문
|
||||
|
||||
> 출처: 지원서 작성 화면 캡처 (2026-09-15 확인)
|
||||
> 이 파일은 **문항 원문 보존용**이다. 답변은 `vN.md` 에 쓴다.
|
||||
|
||||
| 문항 | 필수 | 글자수 | 비고 |
|
||||
|------|------|--------|------|
|
||||
| Q1. 나의 아이디어를 한 줄로 소개해주세요. | 필수 | 10~100자 | |
|
||||
| Q2. 아이디어를 떠올린 배경 이야기를 들려주세요. | 필수 | 2,000자 | 사진 첨부 가능 |
|
||||
| Q3-1. 기존 제품 및 서비스 대비 차별점과 어떤 문제를 해결할 수 있나요? | 필수 | 2,000자 | 사진 첨부 가능 |
|
||||
| Q3-2. 어떻게 수익을 창출할 계획인가요? | 선택 | 2,000자 | 사진 첨부 가능 |
|
||||
| Q4-1. 아이디어를 어떻게 사업화 하실 계획이신가요? | 필수 | 2,000자 | 사진 첨부 가능 |
|
||||
| Q4-2. 책임멘토를 통해 어떤 도움을 받고 싶은가요? | 선택 | 2,000자 | 사진 첨부 가능 |
|
||||
| Q5. 나의 아이디어를 소개하는 영상을 링크로 제출해주세요. | 선택 | URL | **유튜브 링크만** |
|
||||
| Q6. 사업 분야를 선택해주세요. | 필수 | 드롭다운 | |
|
||||
| Q7. 지원자의 현재 창업 여부를 알려주세요. | 필수 | 택1 | 예비창업자 / 기창업자 |
|
||||
| Q8. 2차 지원서에서는 1차 지원서보다 어떤 점이 더 개선되었나요? | 선택 | - | **재도전 가점 반영** |
|
||||
| Q9. 아이디어를 사업화 시킬 수 있는 본인의 역량을 알려주세요. | 선택 | 2,000자 | |
|
||||
| Q10. 함께하는 팀원이 있다면 본인을 제외한 팀원 명수를 알려주세요. | 선택 | 드롭다운 | 기본값 "팀원 없음" |
|
||||
| Q11. 내 아이디어를 사람들에게 자랑해보세요. | 선택 | - | **홈페이지 공개 + 좋아요** |
|
||||
| Q12. 서약서 동의 | 필수 | 체크 | 참여/본인아이디어/비밀유지 |
|
||||
|
||||
## 운영 제약
|
||||
- **사진은 전체 문항 합산 최대 10장.** 문항별이 아니라 총량 제한이므로 배분 계획 필요.
|
||||
- Q7 기창업자 기준: 창업일이 **2019.08.21.~2026.08.20.** 범위이고, 기존 사업과 **다른 아이템(이종창업)** 희망자.
|
||||
- Q8/Q11은 선택이지만 각각 **가점**과 **공개 노출** 효과가 있어 실질적으로 작성 권장.
|
||||
163
docs/ir/modu-startup-2026-09/v1.md
Normal file
163
docs/ir/modu-startup-2026-09/v1.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# 모두의 창업 2차 지원서 — v1 (초안 스냅샷)
|
||||
|
||||
- **작성일**: 2026-09-15
|
||||
- **상태**: 보유 원고를 문항에 매핑한 최초 스냅샷. 문장 다듬기·수치 검증은 v2에서.
|
||||
- **원칙**: Q1/Q2는 **이미 폼에 입력된 원문 그대로** 보존(오타 포함). 손대지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## Q1. 나의 아이디어를 한 줄로 소개해주세요. (필수 / 100자)
|
||||
|
||||
내가 신뢰하는 유튜버/유명인의 맛집을, AI가 영상/글에서 자동으로 정리해 한 지도에 모아주는 디스커버리 플랫폼
|
||||
|
||||
> [현황] 폼 입력 완료 (63자). 원문 유지.
|
||||
|
||||
---
|
||||
|
||||
## Q2. 아이디어를 떠올린 배경 이야기를 들려주세요. (필수 / 2,000자)
|
||||
|
||||
평소 유튜브로 맛집 영상을 자주 보는 한 사람으로서, 한 가지 반복되는 답답함이 있었습니다. "성시경 채널에서 본 그 가게, 어디였더라?" 영상을 다시 뒤져 자막에서 가게 이름을 받아 적고, 지도에 옮겨야 합니다. 좋아하는 채널 5개만 구독해도 한 달치 영상이 수십 개. 그중 정작 가고 싶은 가게를 다시 꺼내는 일은 사실 불편하고 번거로웠습니다.
|
||||
|
||||
기존 맛집 서비스를 써봐도 갈증은 풀리지 않았습니다. 익명 다수의 별점에 의존하는 서비스는 가짜 리뷰 논란이 끊이지 않고, 광고성 콘텐츠가 상위에 노출됩니다. 정작 제가 신뢰하는 사람, 음식을 진심으로 다루는 유튜버의 추천은 그 어디에도 정리되어 있지 않았습니다. 이 문제는 해외여행 때 더 극심해졌습니다. 일본 여행을 준비하며 성시경의 일본 맛집 시리즈를 다 보고 갔지만, 정작 도쿄 거리에서 그 가게를 찾는 데 오랜 시간이 걸렸습니다. 영상 속 정보가 활용에는 그 식당을 언급하는 장면을 다시 돌려본다던가 하는 불편함이 있어 쉽지 않았습니다. 그래서 이런생각을 했습니다. "신뢰하는 사람의 선택을 한 지도에 모아주는 서비스." 그러나 손으로 정리하는 건 불가능합니다. 영상 한 편에서 식당 정보를 추출하는 데 사람이 30분씩 걸리는데, 한 채널만 해도 영상이 수백 개입니다.
|
||||
|
||||
LLM(거대언어모델)의 발전이 이 문제를 해결해 주었습니다. 영상 자막을 LLM에 넣으면 식당 정보를 구조화해 추출할 수 있고, 추출한 주소를 지오코딩해 지도에 꽂으면 됩니다. 더 이상 사람이 손으로 정리할 필요가 없습니다. 이 깨달음이 Tasteby의 출발점입니다. 기술이 가능하게 만든 "신뢰 기반 큐레이션" 알고리즘이 아닌 사람의 신뢰를, 기계가 자동으로 정리해 모두에게 열어주는 플랫폼입니다.
|
||||
|
||||
> [현황] 폼 입력 완료 (854자). 원문 유지.
|
||||
> [v2 검토 예정] 마지막 문단 두 문장(“영상 속 정보가 활용에는…”, “기술이 가능하게 만든 …열어주는 플랫폼입니다.”)이 비문. 2,000자 중 854자만 사용 — 여유 1,100자.
|
||||
|
||||
---
|
||||
|
||||
## Q3-1. 기존 제품 및 서비스 대비 차별점과 어떤 문제를 해결할 수 있나요? (필수 / 2,000자)
|
||||
|
||||
**1차 타겟: 외식·맛집에 진심인 한국인**
|
||||
20~40대 약 1,500만 명. 유튜브 맛집 콘텐츠를 일상적으로 소비하지만, 영상 속 정보가 분산되어 있어 정작 가고 싶을 때 활용하지 못합니다. "그 곱창집이 어디였지?", "이 셰프가 추천한 동네 중식당이 어디였더라?" 매번 영상을 거꾸로 돌려 찾아야 합니다.
|
||||
|
||||
**2차 타겟: 해외여행을 자주 가는 한국인**
|
||||
연 700만 명 이상의 일본 여행객을 비롯해, 좋아하는 한국 유튜버의 해외 콘텐츠를 신뢰하는 사람들. 익명의 외국 리뷰 사이트보다 자신이 신뢰하는 한국 유튜버의 추천을 따르고 싶지만, 현지에서 그 정보를 활용할 도구가 없습니다.
|
||||
|
||||
**3차 타겟: 콘텐츠를 만드는 유튜버**
|
||||
음식 콘텐츠 크리에이터는 자신이 다녀온 가게가 영상 한 편으로 소비되고 끝납니다. 새로운 시청자가 그 가게를 나중에 검색해도 영상에 도달하지 않습니다. 자신의 큐레이션이 자산화되지 않는 구조입니다.
|
||||
|
||||
**기존 서비스가 풀지 못한 이유**
|
||||
- 망고플레이트·다이닝코드: 익명 리뷰 의존 → 신뢰 붕괴
|
||||
- 캐치테이블: 예약 중심 → 발견 기능 약함
|
||||
- 구글 지도: 별점 평균만 표시 → "누가" 추천했는지 알 수 없음
|
||||
- 유튜브 직접 검색: 정작 위치·주소 정보가 영상 속에 묻혀있음
|
||||
|
||||
**Tasteby가 해결하는 본질**
|
||||
사용자에게는 "신뢰하는 사람의 선택"이 한 지도에 정리되어 보입니다. 가게를 클릭하면 그 유튜버가 영상에서 어떤 평가를 했는지 즉시 확인하고, 원본 영상으로 회귀할 수 있습니다.
|
||||
|
||||
결정적 차별화는 **"분야별 전문 큐레이터" 포지셔닝**입니다. 성시경은 노포·국밥 전문, 더틀리는 호텔·파인다이닝, 떡볶퀸은 분식 전문, 버거코치는 햄버거 전문 같이 각 채널을 분야별 권위자로 포지셔닝합니다. 사용자는 "오늘은 떡볶이가 먹고 싶다 → 떡볶퀸이 검증한 가게"라는 직관적 경로를 가지고, 유튜버는 자신의 전문성을 명확한 정체성으로 자산화합니다. 잡지 에디터 모델을 디지털·자동화로 옮긴 구조이고, 이것은 국내 이용자와 해외 여행자 모두에게 동일하게 적용됩니다.
|
||||
|
||||
**핵심 기술: LLM 자동 추출 + 벡터 시맨틱 검색**
|
||||
YouTube 자막·메타데이터 수집 → OCI GenAI(LLM)가 식당명·주소·메뉴·유튜버 평가를 구조화 추출 → Cohere Embed v4로 벡터 임베딩 후 Oracle 23ai Vector DB 저장 → 키워드가 아닌 의미 기반 유사도 검색으로 큐레이션. "혼술하기 좋은 분위기", "성시경 스타일의 노포" 같은 의미 단위 질의가 가능합니다.
|
||||
|
||||
> [현황] 폼 미입력. 보유 원고를 배치한 상태.
|
||||
> [v2 검토 예정] 분량 초과 여부 확인 → 아래 글자수 표 참조. 타겟 3종을 앞세우는 현재 구성은 "차별점" 질문에 대한 답이 뒤에 나오므로, **차별점을 먼저 → 타겟은 압축**하는 재배치 검토.
|
||||
|
||||
---
|
||||
|
||||
## Q3-2. 어떻게 수익을 창출할 계획인가요? (선택 / 2,000자)
|
||||
|
||||
**수익 모델 (3단계 점진 확장)**
|
||||
- 단기: 유튜버 큐레이터 파트너십(채널 페이지 + 수익 분배), 식당 B2B 제휴(검증된 노출/예약)
|
||||
- 장기: 글로벌 확장(일본→동남아), 외식 트렌드 데이터 인사이트 B2B
|
||||
|
||||
> [현황] 폼 미입력. **원고 부족** — 현재 2줄. 2,000자 대비 태부족.
|
||||
> [v2 필수 보강] ① 3단계인데 단기/장기 2개만 기술됨(중기 누락). ② 단가·과금 방식·예상 매출 규모 없음. ③ "12개월에 첫 매출 발생" 로드맵과 연결 필요.
|
||||
|
||||
---
|
||||
|
||||
## Q4-1. 아이디어를 어떻게 사업화 하실 계획이신가요? (필수 / 2,000자)
|
||||
|
||||
**현재 단계: MVP 운영 중**
|
||||
- 도메인: www.tasteby.net (안정 가동 중)
|
||||
- 기술 스택: Spring Boot 3.3 + Java 21, Next.js 16, Oracle 23ai 벡터 DB, Redis, Kubernetes(OKE) — 프로덕션 인프라 구축 완료
|
||||
- 구현 기능: LLM 식당 정보 추출, YouTube 자막 처리, 지오코딩, 클러스터링 지도, 회원·찜·리뷰·메모, 모바일 최적화, 자체 디자인 시스템(Saffron)
|
||||
- 풀스택 1인 개발자가 설계·구현·배포 전 과정을 완료한 검증된 실행력 보유
|
||||
|
||||
**12개월 단계별 로드맵**
|
||||
- 3개월: 식당 DB 5,000+, 등록 유튜브 채널 10+
|
||||
- 6개월: 일본 식당 베타 출시, 유튜버 큐레이터 파트너십 3건 체결
|
||||
- 12개월: MAU 5만, B2B 식당 제휴 20건, 첫 매출 발생
|
||||
|
||||
Tasteby의 경쟁우위는 "모든 가게의 평균"이 아닌 **"분야별 권위자의 검증된 모음"**입니다. 채널의 전체 영상을 LLM이 분석해 전문성을 자동 태깅/큐레이션합니다.
|
||||
|
||||
> [현황] 폼 미입력. 보유 원고를 배치한 상태.
|
||||
> [v2 검토 예정] ① "MAU 5만"에 도달하는 **획득 경로(마케팅)** 가 비어 있음 — 심사에서 가장 먼저 찔릴 지점. ② 유튜버 파트너십 3건의 **접근 방법**(저작권/초상 이슈 포함) 필요. ③ 현재 실측 지표(식당 수/채널 수/가입자) 기재 시 설득력 급상승.
|
||||
|
||||
---
|
||||
|
||||
## Q4-2. 책임멘토를 통해 어떤 도움을 받고 싶은가요? (선택 / 2,000자)
|
||||
|
||||
**본 사업 자금·멘토링 활용 계획**
|
||||
1. 인플루언서 파트너십 확보 — 계약·법무 비용, 채널별 큐레이션 페이지 개발
|
||||
2. LLM 처리 인프라 확장 — 영상 처리량 10배 확대
|
||||
3. 글로벌 확장 — 다국어·다지역 처리 모듈 개발 (일본 우선)
|
||||
|
||||
> [현황] 폼 미입력. 현재 원고는 **자금 사용 계획**이지 "멘토에게 받고 싶은 도움"이 아님.
|
||||
> [v2 필수 재작성] 멘토 질문에 맞게 전환: 크리에이터 IP 계약 실무, 콘텐츠 인용 저작권 자문, B2B 식당 영업 채널, 초기 트래픽 획득 전략 등 **1인 개발자가 못 메우는 영역** 중심으로. 자금 사용 계획은 Q4-1로 이동 검토.
|
||||
|
||||
---
|
||||
|
||||
## Q5. 아이디어 소개 영상 (선택 / 유튜브 링크)
|
||||
|
||||
> [미정] 제작 여부 결정 필요. 기존 `docs/ir/bunkershot-2026-06-29/04-시연-시나리오.md` 를 대본 베이스로 재활용 가능.
|
||||
|
||||
---
|
||||
|
||||
## Q6. 사업 분야 (필수 / 드롭다운)
|
||||
|
||||
> [미정] 선택지 목록 확인 필요. 후보: 플랫폼/O2O, AI·데이터, 관광·여행, 푸드테크.
|
||||
|
||||
---
|
||||
|
||||
## Q7. 현재 창업 여부 (필수 / 택1)
|
||||
|
||||
> [미정] 예비창업자 / 기창업자 — 본인 확인 필요.
|
||||
|
||||
---
|
||||
|
||||
## Q8. 2차 지원서에서는 1차 지원서보다 어떤 점이 더 개선되었나요? (선택 / 재도전 가점)
|
||||
|
||||
> [미작성] **1차 지원서 원문이 리포에 없음.** 원문 확보 후 작성.
|
||||
> 작성 축(안): ① 1차 이후 실제 개발 진척(프로덕션 배포, 기능 추가), ② 포지셔닝 구체화("분야별 전문 큐레이터" 개념), ③ 수익모델 단계화, ④ 1차 심사 피드백 반영 내용.
|
||||
|
||||
---
|
||||
|
||||
## Q9. 아이디어를 사업화 시킬 수 있는 본인의 역량 (선택 / 2,000자)
|
||||
|
||||
> [미작성] 소재: 풀스택 1인 개발자로 설계→구현→운영 배포 전 과정 완수. Spring Boot/Next.js/Oracle 23ai 벡터 DB/Kubernetes(OKE) 프로덕션 운영. LLM 파이프라인 직접 구축. 8단계 AI 페르소나 개발 파이프라인 등 **혼자서도 팀 수준 산출물을 내는 운영 체계** 보유.
|
||||
> [확인 필요] 경력·이력(직장/프로젝트), 수상·자격 등 서술 가능한 배경.
|
||||
|
||||
---
|
||||
|
||||
## Q10. 팀원 수 (선택)
|
||||
|
||||
팀원 없음
|
||||
|
||||
---
|
||||
|
||||
## Q11. 내 아이디어를 사람들에게 자랑해보세요. (선택 / 홈페이지 공개)
|
||||
|
||||
> [미작성] 공개 노출 + 좋아요 대상. Q1의 한 줄 + 실제 서비스 링크(www.tasteby.net) + 스크린샷 조합으로 짧고 감각적으로. 심사문서 톤이 아니라 **일반 독자 대상 톤**.
|
||||
|
||||
---
|
||||
|
||||
## Q12. 서약서 동의 (필수)
|
||||
|
||||
> 제출 직전 체크: 참여 서약서 / 본인 아이디어 확인 서약서 / 비밀유지 서약서.
|
||||
|
||||
---
|
||||
|
||||
## 사진 배분 계획 (전체 합산 10장 제한)
|
||||
|
||||
| 문항 | 장수(안) | 내용 |
|
||||
|------|---------|------|
|
||||
| Q2 | 1 | "영상 → 지도" 문제 상황 대비 이미지 |
|
||||
| Q3-1 | 3 | 지도 화면, 식당 상세(유튜버 평가), 채널별 큐레이션 |
|
||||
| Q4-1 | 2 | 아키텍처 다이어그램, 로드맵 |
|
||||
| Q11 | 2 | 대표 스크린샷(모바일/데스크톱) |
|
||||
| 예비 | 2 | |
|
||||
|
||||
> [확인 필요] 스크린샷은 `screenshots/` 디렉토리 기존 자산 활용 가능 여부 점검.
|
||||
184
docs/ir/modu-startup-2026-09/v2.md
Normal file
184
docs/ir/modu-startup-2026-09/v2.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 모두의 창업 2차 지원서 — v2 개선안
|
||||
|
||||
> 작성일: 2026-09-15
|
||||
> 상태: 문항별 붙여넣기 초안. `제출 전 확정` 표기가 있는 항목은 사실 확인 뒤에만 확정한다.
|
||||
> 수익·사업화의 근거: [Tasteby BM v1.1](../../design/705-tasteby-bm-v1-1/README.md), [개인화 전략](../../strategy/tasteby-personalization-as-taste-translation.md), [공유 성장 루프](../../design/711-tasteby-marketing-v1-5/README.md)
|
||||
|
||||
> ⚠️ **[2026-09-15 검증] 위 참조 문서 3개는 현재 리포에 존재하지 않는다.** Redmine #705/#711 이슈는 열려 있으나(08-Documenter) 문서 파일이 없다. 링크를 살리려면 문서를 작성하거나, 링크를 제거해야 한다.
|
||||
|
||||
## v1 → v2 변경 요약
|
||||
- Q1: 한 줄 소개를 "AI 자동 정리 플랫폼" → "발견에서 나의 기록으로 연결" 관점으로 교체
|
||||
- Q2: 비문 정리 + "다시 쓸 수 있게"라는 문제 정의로 재구성
|
||||
- Q3-1: 타겟 나열 → 차별점 3가지(출처 있는 발견 / 큐레이터 기준 탐색 / 비공개 맛 기억) 구조로 재배치
|
||||
- Q3-2: 111자 → 수익 원칙 + 4개 수익원의 단계적 검증 순서로 전면 작성
|
||||
- Q4-1: 로드맵 나열 → 쐐기시장(큐레이터 1 × 지역 1 × 메뉴 1) 전제의 0-3/4-6/7-12개월 검증 계획
|
||||
- Q4-2: 자금 사용 계획 → 멘토에게 받을 도움 4가지로 재작성
|
||||
- Q5~Q12: 전부 신규 작성 (영상 구성안, 분야 선택 기준, Q7 판단표, Q8 재도전 답안 틀, Q9 역량, Q11 공개 소개문, 사진 배분)
|
||||
|
||||
---
|
||||
|
||||
## Q1. 나의 아이디어를 한 줄로 소개해주세요. (필수 / 10~100자)
|
||||
|
||||
> 신뢰하는 미식 큐레이터의 영상 속 맛집을 찾아, 나의 다음 식사와 기록으로 연결하는 Tasteby
|
||||
|
||||
- v2 검증: 54자. 제출 화면의 실제 글자 수를 최종 확인한다.
|
||||
|
||||
## Q2. 아이디어를 떠올린 배경 이야기를 들려주세요. (필수 / 2,000자)
|
||||
|
||||
저는 맛집 영상을 보는 것을 좋아하지만, 막상 식사 약속이 잡히면 같은 문제를 반복해서 겪었습니다. "성시경 영상에서 본 그 국밥집이 어디였지?" 기억나는 것은 사람과 메뉴의 인상뿐이었습니다. 다시 영상을 찾아 자막을 넘기고, 식당 이름을 받아 적어 지도에 옮겨야 했습니다. 좋아하는 채널이 늘수록 저장한 정보도 늘었지만, 정작 필요한 순간에는 더 찾기 어려워졌습니다.
|
||||
|
||||
이 불편은 일본 여행에서 더 선명해졌습니다. 여행 전에 한국 미식 유튜버의 일본 맛집 영상을 여러 편 봤지만, 현지에서는 어느 영상의 어느 장면에 나온 식당인지 다시 찾는 데 시간이 들었습니다. 별점과 후기 수가 많은 식당은 쉽게 찾을 수 있었지만, 제가 실제로 궁금했던 것은 "내가 신뢰하는 이 사람이 왜 이곳을 골랐는가"였습니다. 영상, 지도, 메모앱이 각각 답의 일부만 갖고 있었습니다.
|
||||
|
||||
그래서 출발한 질문은 "맛집을 더 많이 모으려면 어떻게 해야 할까?"가 아니었습니다. **신뢰하는 사람의 선택을, 다음 식사를 고를 때 다시 쓸 수 있게 하려면 어떻게 해야 할까?**였습니다.
|
||||
|
||||
Tasteby는 미식 큐레이터의 영상·글 속 식당, 메뉴, 언급 맥락을 구조화해 지도와 탐색 화면에서 찾게 하는 서비스입니다. 사용자는 원본 콘텐츠로 다시 돌아가 맥락을 확인할 수 있고, 마음에 드는 곳은 자신의 찜과 비공개 메모에 남길 수 있습니다. 즉, Tasteby는 원본 영상을 대체하거나 익명 다수의 평가를 더 크게 만드는 서비스가 아니라, 흩어진 신뢰의 단서를 실제 외식 결정에 쓸 수 있게 연결하는 도구입니다.
|
||||
|
||||
AI는 이 일을 가능하게 하는 수단입니다. 영상 자막과 공개 정보를 바탕으로 식당 후보를 구조화하되, 원본 출처를 남기고 오류는 정정할 수 있게 설계합니다. 저는 "AI가 맛집을 대신 추천한다"보다 "사람이 쌓아 온 신뢰를 사용자가 다시 쓸 수 있게 한다"는 방향이 더 오래 갈 수 있다고 믿습니다. Tasteby는 좋은 한 끼를 찾는 순간과, 좋았던 한 끼를 다시 기억하는 순간을 하나의 경험으로 만들고자 합니다.
|
||||
|
||||
## Q3-1. 기존 제품 및 서비스 대비 차별점과 어떤 문제를 해결할 수 있나요? (필수 / 2,000자)
|
||||
|
||||
Tasteby는 또 하나의 맛집 순위 서비스가 아닙니다. 출발점이 "평점이 높은 곳은 어디인가?"가 아니라 **"내가 신뢰하는 사람이 어느 콘텐츠에서, 어떤 메뉴를, 왜 언급했는가?"**이기 때문입니다.
|
||||
|
||||
기존 맛집 서비스는 대체로 다수 이용자의 별점·후기, 예약 가능 여부, 지역 검색을 중심으로 설계돼 있습니다. 이 방식은 넓은 선택지를 빠르게 보여 주는 데 유용하지만, 영상을 보고 특정 사람의 선택을 기억한 사용자에게는 두 가지 공백이 남습니다. 첫째, 영상 속 식당의 이름·주소·메뉴가 콘텐츠 안에 흩어져 있어 나중에 다시 찾기 어렵습니다. 둘째, 평균 별점만으로는 왜 그 사람이 그 식당을 언급했는지, 내가 오늘 갈 이유가 있는지 판단하기 어렵습니다.
|
||||
|
||||
Tasteby의 차별점은 세 가지입니다.
|
||||
|
||||
첫째, **출처가 있는 발견**입니다. 식당을 보여 줄 때 원본 콘텐츠와 메뉴·언급 맥락을 함께 연결합니다. 사용자는 Tasteby의 요약만 믿을 필요 없이 원본으로 돌아가 확인할 수 있습니다. 추천인지 단순 언급인지, 협찬 또는 정정 정보가 확인되는 경우에는 이를 구분해 보이는 방향으로 신뢰를 설계합니다.
|
||||
|
||||
둘째, **분야별 큐레이터의 선택을 탐색 기준으로 사용**합니다. 사용자는 무작위 목록이 아니라 자신이 신뢰하는 미식 큐레이터, 먹고 싶은 메뉴, 갈 지역을 함께 놓고 후보를 좁힐 수 있습니다. 이는 "유명해서"가 아니라 특정 음식과 상황을 꾸준히 다뤄 온 사람의 관점을 발견의 출발점으로 삼는 방식입니다.
|
||||
|
||||
셋째, **개인의 맛 기억을 기본 비공개로 보존**합니다. 사용자는 방문 뒤 좋았던 이유와 다음에 갈 조건을 자기 메모로 남길 수 있습니다. 공개 리뷰를 많이 쓰게 하는 대신, 먼저 나를 위한 기록이 다음 선택에 다시 쓰이도록 하는 것이 목표입니다. 나중에 사용자가 원할 때만 일부 후보를 동행에게 공유할 수 있으며, 비공개 메모는 공유되지 않습니다.
|
||||
|
||||
Tasteby가 해결하려는 문제는 정보 부족이 아니라 **신뢰할 만한 정보를 다시 쓸 수 없는 문제**입니다. 음식 콘텐츠를 보는 사람은 다음 식사를 더 쉽게 결정하고, 크리에이터는 자신의 콘텐츠가 일회성 조회를 넘어 정정 가능하고 탐색 가능한 아카이브로 이어질 수 있습니다. AI는 이 구조를 빠르게 만들지만, 경쟁력의 핵심은 AI 자체가 아니라 출처를 보존하고 개인의 선택으로 이어 주는 제품 경험입니다.
|
||||
|
||||
## Q3-2. 어떻게 수익을 창출할 계획인가요? (선택 / 2,000자)
|
||||
|
||||
Tasteby의 수익 원칙은 분명합니다. **사용자의 비공개 찜과 메모를 판매하지 않고, 돈을 낸 식당이 자연 추천·큐레이터 출처·사용자 기록의 순서를 바꾸지 않게 한다**는 것입니다. 신뢰가 제품의 출발점이므로 단기 매출을 위해 이 원칙을 훼손하면 사업 자체가 약해집니다.
|
||||
|
||||
첫 번째 수익원은 공개 탐색 화면의 **명시형 문맥 광고와 프로모션**입니다. 예를 들어 사용자가 공개된 식당·메뉴·상황 페이지에서 정보를 찾을 때, 해당 맥락에 맞는 행사·신메뉴·예약 가능 정보 등을 `광고` 또는 `프로모션`으로 명확히 구분해 노출합니다. 광고주는 의사결정을 앞둔 공개 독자에게 도달할 기회를 구매하고, 사용자는 광고와 큐레이터의 자연 언급을 혼동하지 않습니다. 이 모델은 충분한 공개 탐색 세션과 신뢰 기준이 생긴 뒤 소규모로 검증합니다.
|
||||
|
||||
두 번째는 식당·브랜드의 **투명한 캠페인 상품**입니다. 신메뉴, 지역 행사, 예약 가능 정보처럼 사용자의 의사결정에 실질적으로 도움이 되는 정보를 공개 페이지의 별도 영역에서 제공합니다. 유료 여부는 표기하고, 광고비가 검색·추천·공개 리뷰의 순위를 바꾸지 않게 운영합니다. 따라서 판매 단위는 "상위 노출 보장"이 아니라, 표기된 캠페인의 도달과 행동 결과입니다.
|
||||
|
||||
세 번째는 사용자가 거래를 원할 때만 연결하는 **예약·주문 제휴 수수료**입니다. Tasteby가 사용자를 수수료가 높은 식당으로 유도하지 않고, 사용자가 이미 고른 식당에서 예약·주문을 원할 때만 제휴 경로를 제공합니다. 이 수익원은 사용자 행동과 파트너 연동이 검증된 뒤 도입합니다.
|
||||
|
||||
추가로, 크리에이터가 자신의 출처·정정·아카이브 관리를 원한다면 공식 큐레이터 페이지와 관리 도구를 유료 또는 수익 분배 방식으로 검토할 수 있습니다. 다만 기존 콘텐츠를 단순 재포장해 과금하지 않고, 크리에이터와 시청자의 재탐색 문제를 실제로 줄이는 경우에만 추진합니다.
|
||||
|
||||
초기 12개월의 목표는 매출을 과장하는 것이 아니라, 출처 기반 탐색 → 후보 저장 → 재열람이 반복되는 공개 의사결정 문맥을 검증하는 것입니다. 그 증거가 생긴 뒤 광고·프로모션·제휴를 순서대로 시험해 신뢰를 지키며 매출을 만든다는 계획입니다.
|
||||
|
||||
## Q4-1. 아이디어를 어떻게 사업화 하실 계획이신가요? (필수 / 2,000자)
|
||||
|
||||
Tasteby는 아이디어 단계에 머물지 않고, 큐레이터 출처·식당·메뉴 탐색, 지도 기반 발견, 회원·찜·공개 리뷰·비공개 메모를 갖춘 MVP를 개발해 왔습니다. 서비스의 핵심은 영상·글에서 식당 후보 정보를 구조화하고, 지오코딩과 검색을 통해 사용자가 큐레이터·지역·메뉴 기준으로 다시 찾게 하는 것입니다. 저는 Spring Boot와 Next.js를 중심으로 백엔드·프론트엔드·데이터 파이프라인·배포 환경을 직접 설계하고 구현해 왔습니다.
|
||||
|
||||
다음 단계의 사업화는 전국의 모든 맛집을 한꺼번에 늘리는 방식이 아닙니다. `큐레이터 1명 × 지역 1곳 × 메뉴 또는 식사 상황 1개`라는 작은 시장에서 출처 정확도와 실제 사용 행동을 먼저 증명하겠습니다.
|
||||
|
||||
**0~3개월**에는 첫 쐐기시장의 원본·식당·메뉴 데이터를 검수하고, 오류 정정 경로와 발견→원본 확인→후보 저장의 행동 계측을 갖추겠습니다. 식당 DB 5,000곳 이상과 등록 채널 10곳 이상은 단순 수량 목표가 아니라, 원본 출처와 메뉴 연결의 정확도를 통과한 데이터만 늘리는 품질 목표로 관리하겠습니다.
|
||||
|
||||
**4~6개월**에는 검색·커뮤니티·콘텐츠를 통해 이미 영상 속 식당을 찾는 사용자를 정확한 출처 페이지로 데려오겠습니다. 광고성 무차별 유입보다 "영상 속 그 집을 오늘의 후보로"라는 문제를 가진 사용자의 탐색·저장 행동을 검증하겠습니다. 동시에 큐레이터 또는 매니저에게는 단순 홍보 요청이 아니라, 오래된 콘텐츠의 출처 정리·정정·시청자 재탐색이라는 가치를 제안해 파트너십 3건을 목표로 협의하겠습니다.
|
||||
|
||||
**7~12개월**에는 한국의 검증 결과를 바탕으로 일본 여행 콘텐츠의 제한된 베타를 검토하고, 사용자가 찜한 후보를 동행과 함께 고를 수 있는 공유 기능을 도입하겠습니다. 목표 지표는 MAU 5만, B2B 식당·브랜드 캠페인 20건이지만, 이 수치는 단순 방문자 수가 아니라 출처 확인·후보 저장·재열람·공유 수신자의 탐색으로 검증하겠습니다. 첫 매출은 자연 추천과 분리된 공개 문맥 광고 또는 투명한 캠페인 상품에서 만들 계획입니다.
|
||||
|
||||
사업화의 핵심 위험은 기술보다 신뢰입니다. 따라서 원본 링크를 보존하고, 협찬·정정 상태를 관리하며, 비공개 메모를 광고 타기팅에 쓰지 않겠습니다. 저는 기술 개발, 데이터 품질, 사용자 경험, 초기 유통을 하나의 반복 실험으로 운영해 "더 많은 식당"이 아니라 "다시 쓰이는 신뢰"를 증명하겠습니다.
|
||||
|
||||
> 제출 전 사실 확인: `MVP 운영 중`, 실제 도메인 접속 상태, 현재 식당/채널 수는 제출일에 확인하여 위 첫 문단 또는 로드맵 표현을 조정한다.
|
||||
|
||||
## Q4-2. 책임멘토를 통해 어떤 도움을 받고 싶은가요? (선택 / 2,000자)
|
||||
|
||||
저는 Tasteby의 제품과 기술 구현은 직접 추진해 왔지만, 다음 단계에서 필요한 것은 개발 조언보다 **신뢰를 훼손하지 않는 시장 진입과 계약의 기준**입니다. 책임멘토에게 네 가지 도움을 받고 싶습니다.
|
||||
|
||||
첫째, 크리에이터 파트너십의 실무입니다. Tasteby는 타인의 콘텐츠를 대신 소비시키는 서비스가 아니라 원본으로 다시 연결하는 서비스여야 합니다. 큐레이터·매니저에게 어떤 가치 제안을 해야 하는지, 출처·초상·인용·협찬 표기를 어떤 계약과 운영 절차로 정리해야 하는지 조언을 받고 싶습니다.
|
||||
|
||||
둘째, 초기 B2B 상품의 검증입니다. 식당이나 브랜드가 돈을 낸다고 해서 자연 추천 순위가 바뀌면 Tasteby의 차별점은 사라집니다. 공개 탐색 문맥에서 사용자가 받아들일 수 있는 광고·프로모션의 형태, 가격 이전에 검증해야 할 성과 지표, 첫 고객을 찾는 영업 경로를 함께 설계하고 싶습니다.
|
||||
|
||||
셋째, 초기 사용자 획득 전략입니다. 검색·커뮤니티·콘텐츠 중 어떤 좁은 시장에서 출처 탐색과 후보 저장이 실제로 반복되는지, 작은 예산으로 무엇을 먼저 검증해야 하는지에 대한 피드백이 필요합니다. '바이럴'이나 조회 수가 아니라 재방문과 결정 행동으로 판단하는 방법을 배우고 싶습니다.
|
||||
|
||||
넷째, 해외 확장 우선순위입니다. 일본 여행 콘텐츠는 분명한 사용 사례이지만, 국내에서 어떤 데이터 품질과 파트너십 기준을 먼저 증명해야 하는지, 다국어·현지 정보·권리 이슈를 어떤 순서로 풀어야 하는지 검토받고 싶습니다.
|
||||
|
||||
멘토링을 통해 막연한 인맥 소개보다, 파트너십·수익·유통에서 반복 가능한 의사결정 기준을 얻고 싶습니다. 이를 바탕으로 1인 개발자가 만든 MVP를 신뢰할 수 있는 소비자 서비스와 지속 가능한 사업으로 확장하겠습니다.
|
||||
|
||||
## Q5. 나의 아이디어를 소개하는 영상을 링크로 제출해주세요. (선택 / 유튜브 링크만)
|
||||
|
||||
**권장: 제출한다.** 75~90초 분량으로 아래 흐름을 사용한다.
|
||||
|
||||
1. 0~15초 — "그 영상의 그 식당, 어디였지?"라는 개인 경험과 재검색 장면
|
||||
2. 15~40초 — Tasteby에서 큐레이터·식당·메뉴·원본을 연결해 찾는 실제 화면
|
||||
3. 40~60초 — 찜과 비공개 메모, 동행과 후보를 고르는 공유 카드의 가치
|
||||
4. 60~80초 — AI는 정보를 구조화하고, Tasteby는 사람의 신뢰를 다시 쓰게 한다는 차별점
|
||||
5. 80~90초 — 12개월 검증 목표와 멘토링 요청
|
||||
|
||||
> 제출 전 입력: `[YouTube URL]`
|
||||
> 제작 원칙: Tasteby 자체 화면·직접 제작한 도식만 사용한다. 타인의 유튜브 영상·썸네일·음원을 무단 삽입하지 않는다.
|
||||
|
||||
## Q6. 사업 분야를 선택해주세요. (필수 / 드롭다운)
|
||||
|
||||
**제출 전 실제 선택지 확인 후 선택한다.**
|
||||
|
||||
1순위는 `AI·데이터`다. Tasteby의 사용자 가치는 외식 발견이지만, 영상·글에서 식당 정보를 구조화하고 출처 기반으로 탐색하게 하는 핵심 실행 수단이 AI·데이터이기 때문이다. 해당 선택지가 없으면 2순위는 `플랫폼/O2O`, 그다음은 `푸드테크`다. 예약·배달 거래가 현재 핵심이 아니므로 관광·여행은 일본 베타 이후의 보조 분야로 둔다.
|
||||
|
||||
## Q7. 지원자의 현재 창업 여부를 알려주세요. (필수 / 택1)
|
||||
|
||||
**법적 사실 확인 전 선택하지 않는다.**
|
||||
|
||||
| 확인 결과 | 선택 |
|
||||
|---|---|
|
||||
| 본인 명의 사업자/법인의 창업일이 기준 기간에 없거나, 기창업자 요건에 해당하지 않음 | `예비창업자` |
|
||||
| 창업일이 2019-08-21~2026-08-20이고 Tasteby가 기존 사업과 다른 이종 창업 아이템임 | `기창업자` |
|
||||
|
||||
MVP를 개발하거나 도메인을 운영했다는 사실만으로 Q7을 판단하지 않는다. 사업자등록·법인 설립·공고의 세부 기준을 제출 전에 확인한다.
|
||||
|
||||
## Q8. 2차 지원서에서는 1차 지원서보다 어떤 점이 더 개선되었나요? (선택 / 재도전 가점)
|
||||
|
||||
**1차 원문/피드백 확보 후 제출할 답안이다. 현재는 아래 대괄호만 1차 사실로 교체한다.**
|
||||
|
||||
1차에서는 [1차에 제시한 핵심 문제와 해결 방식]을 중심으로 아이디어를 설명했습니다. 2차에서는 이를 단순한 맛집 정보 서비스가 아니라, `큐레이터 출처로 발견 → 개인 찜과 비공개 기록으로 재사용 → 선택한 후보를 동행과 공유`하는 제품 구조로 구체화했습니다.
|
||||
|
||||
또한 아이디어 수준의 설명에서 나아가, MVP에 구현한 출처·식당·메뉴 탐색과 찜·리뷰·메모 기능을 기준으로 사업화 순서를 다시 설계했습니다. 수익도 여러 가능성을 나열하는 대신, 공개 탐색 문맥의 명시형 광고·프로모션을 먼저 검증하고 예약·주문 제휴와 크리에이터 관리 도구는 조건이 충족될 때 단계적으로 검토하도록 정리했습니다.
|
||||
|
||||
마지막으로 2차에서는 기술 자체보다 신뢰를 핵심 경쟁력으로 명확히 했습니다. 원본 콘텐츠를 대체하지 않고 출처를 보존하며, 사용자의 비공개 메모를 광고 타기팅이나 외부 판매에 쓰지 않는 원칙을 세웠습니다. 이번 지원서는 "AI로 맛집을 모으겠다"는 설명에서 한 단계 더 나아가, 어떤 사용자가 왜 다시 쓰고 어떤 방식으로 사업이 되는지를 검증 가능한 계획으로 보완한 결과입니다.
|
||||
|
||||
## Q9. 아이디어를 사업화 시킬 수 있는 본인의 역량을 알려주세요. (선택 / 2,000자)
|
||||
|
||||
Tasteby의 가장 큰 강점 중 하나는 아이디어를 설명하는 데서 멈추지 않고, 제품으로 만들고 운영 가능한 형태로 옮겨 왔다는 점입니다. 저는 1인 풀스택 개발자로서 문제 정의부터 데이터 구조, 백엔드·프론트엔드 구현, AI 처리 파이프라인, 배포 환경까지 직접 설계하고 연결해 왔습니다.
|
||||
|
||||
Tasteby에서는 Spring Boot와 Java 기반의 서비스 로직, Next.js 기반의 사용자 화면, Oracle 23ai 벡터 데이터베이스와 Redis를 활용한 데이터 처리 구조를 구성했습니다. 영상·글에서 식당 후보 정보를 구조화하고, 지오코딩·지도·검색으로 사용자가 다시 찾을 수 있게 만드는 파이프라인도 직접 만들었습니다. 기술을 개별 기능으로 쌓기보다, "출처를 찾는다 → 후보를 저장한다 → 방문 뒤 기억한다 → 다음 선택에 다시 쓴다"는 한 사용 흐름이 완성되도록 설계한 경험이 있습니다.
|
||||
|
||||
저의 실행 방식은 작은 가설을 빠르게 제품으로 옮기고, 실제 사용 행동으로 다음 우선순위를 정하는 것입니다. 예를 들어 Tasteby의 비공개 메모는 리뷰를 많이 받기 위한 기능이 아니라, 사용자가 좋았던 경험을 다음 외식에 다시 쓰게 하기 위한 기능입니다. 찜 공유 역시 전체 취향을 공개하는 기능이 아니라, 사용자가 고른 후보만 동행과 함께 결정하게 하는 기능으로 분리했습니다. 이처럼 기술의 가능성보다 사용자의 신뢰와 권한을 먼저 설계하는 것이 저의 제품 역량입니다.
|
||||
|
||||
또한 혼자서도 기획·개발·운영을 연결할 수 있지만, 모든 것을 혼자 해결하려 하지 않습니다. 크리에이터 계약, 저작권·표기, 초기 B2B 영업, 해외 확장은 멘토와 파트너의 전문성이 필요한 영역으로 분명히 인식하고 있습니다. 저는 빠르게 만들 수 있는 실행력과, 모르는 영역을 검증 가능한 질문으로 바꾸는 운영 방식을 바탕으로 Tasteby를 사업으로 성장시키겠습니다.
|
||||
|
||||
> 제출 전 보강 후보: 공개 가능한 직장 경력, 이전 프로젝트, 수상·자격, 서비스 운영 수치가 있으면 사실 확인 뒤 한 단락만 추가한다.
|
||||
|
||||
## Q10. 함께하는 팀원이 있다면 본인을 제외한 팀원 명수를 알려주세요. (선택 / 드롭다운)
|
||||
|
||||
`팀원 없음`
|
||||
|
||||
현재는 1인 창업자로 제품을 직접 개발·운영한다. 다만 법무·크리에이터 파트너십·B2B 영업은 외부 전문가 및 멘토와 보완할 계획이다.
|
||||
|
||||
## Q11. 내 아이디어를 사람들에게 자랑해보세요. (선택 / 홈페이지 공개 + 좋아요)
|
||||
|
||||
> **맛집 영상을 보다가 "그 집 어디였지?"를 되묻는 사람을 위해 만들었습니다.**
|
||||
> Tasteby는 신뢰하는 미식 큐레이터가 언급한 식당과 메뉴를 원본 출처와 함께 찾고, 마음에 드는 곳은 내 찜에 남겨 다음 식사에 다시 쓰게 합니다.
|
||||
> 좋은 한 끼를 발견하는 순간과, 좋았던 한 끼를 기억하는 순간을 연결합니다.
|
||||
> `www.tasteby.net`
|
||||
|
||||
> 게시 전: 실제 접속 URL과 대표 스크린샷을 확인한다.
|
||||
|
||||
## Q12. 서약서 동의 (필수)
|
||||
|
||||
제출 직전에 아래를 각각 읽고 동의한다.
|
||||
|
||||
- 참여 서약서
|
||||
- 본인 아이디어 확인 서약서
|
||||
- 비밀유지 서약서
|
||||
|
||||
## 사진 배분 계획 (전체 10장 제한)
|
||||
|
||||
| 문항 | 장수 | 권장 이미지 | 심사에서 보여 줄 사실 |
|
||||
|------|---:|---|---|
|
||||
| Q2 | 1 | 직접 만든 `영상 속 정보 → Tasteby 출처·지도 → 다음 식사` 흐름 도식 | 왜 이 서비스가 필요한가 |
|
||||
| Q3-1 | 3 | 지도 탐색, 식당 상세의 원본/메뉴 근거, 찜·비공개 메모 화면 | 출처·개인 기록의 차별점 |
|
||||
| Q4-1 | 2 | 데이터 처리·정정 흐름 도식, 12개월 로드맵 | 이미 만든 것과 다음 검증 순서 |
|
||||
| Q9 | 1 | 실제 서비스 아키텍처 또는 개발·배포 흐름 | 1인 실행 역량 |
|
||||
| Q11 | 1 | 모바일 또는 데스크톱 대표 화면 한 장 | 공개 소개의 첫 인상 |
|
||||
| 예비 | 2 | 수신자 공유 카드, 모바일 탐색 화면 | 심사 코멘트 대응 또는 품질이 높은 화면 교체 |
|
||||
|
||||
총 8장을 기본으로 사용하고 2장은 예비로 남긴다. 모든 화면은 제출일 기준 실제 동작·표현과 일치해야 하며, 원본 영상 캡처·타인 음식 사진은 사용권을 확인하지 못하면 사용하지 않는다.
|
||||
180
docs/ir/modu-startup-2026-09/v3.md
Normal file
180
docs/ir/modu-startup-2026-09/v3.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 모두의 창업 2차 지원서 — v3
|
||||
|
||||
> 작성일: 2026-09-15
|
||||
> 상태: v2에 **실측 지표**를 반영한 판. `제출 전 확정` 표기 항목은 사실 확인 뒤에만 확정한다.
|
||||
|
||||
## v2 → v3 변경 요약
|
||||
- **Q4-1 전면 수정**: "3개월에 식당 5,000곳"(현재의 10배, 달성 불가) → 현재 실측치(채널 6·영상 909·식당 500)에서 출발하는 배수 목표로 교체. 0~3개월 과제를 수량이 아니라 **LLM 미검증 1,071건 정리**로 재정의
|
||||
- **Q4-1에 사용자 현황 명시**: 공개 회원 모집을 아직 시작하지 않은 단계임을 밝히고, 4~6개월의 핵심 과제를 첫 사용자 집단 확보로 지정
|
||||
- **Q3-1·Q4-1에 해외 데이터 반영**: 일본 28곳 포함 해외 60여 곳이 이미 축적됨 → 일본 확장이 "구상"이 아니라 "이미 시작된 축적"임을 근거로 제시
|
||||
- **깨진 참조 링크 3개 제거** (`docs/design/705-…`, `docs/strategy/…`, `docs/design/711-…` — 리포에 존재하지 않음)
|
||||
|
||||
> 실측 출처: 2026-09-15 Admin 화면 및 `GET /api/restaurants?limit=100000` 조회. 상세는 `README.md` 실측 지표 표.
|
||||
|
||||
## Q1. 나의 아이디어를 한 줄로 소개해주세요. (필수 / 10~100자)
|
||||
|
||||
> 신뢰하는 미식 큐레이터의 영상 속 맛집을 찾아, 나의 다음 식사와 기록으로 연결하는 Tasteby
|
||||
|
||||
- v2 검증: 54자. 제출 화면의 실제 글자 수를 최종 확인한다.
|
||||
|
||||
## Q2. 아이디어를 떠올린 배경 이야기를 들려주세요. (필수 / 2,000자)
|
||||
|
||||
저는 맛집 영상을 보는 것을 좋아하지만, 막상 식사 약속이 잡히면 같은 문제를 반복해서 겪었습니다. "성시경 영상에서 본 그 국밥집이 어디였지?" 기억나는 것은 사람과 메뉴의 인상뿐이었습니다. 다시 영상을 찾아 자막을 넘기고, 식당 이름을 받아 적어 지도에 옮겨야 했습니다. 좋아하는 채널이 늘수록 저장한 정보도 늘었지만, 정작 필요한 순간에는 더 찾기 어려워졌습니다.
|
||||
|
||||
이 불편은 일본 여행에서 더 선명해졌습니다. 여행 전에 한국 미식 유튜버의 일본 맛집 영상을 여러 편 봤지만, 현지에서는 어느 영상의 어느 장면에 나온 식당인지 다시 찾는 데 시간이 들었습니다. 별점과 후기 수가 많은 식당은 쉽게 찾을 수 있었지만, 제가 실제로 궁금했던 것은 "내가 신뢰하는 이 사람이 왜 이곳을 골랐는가"였습니다. 영상, 지도, 메모앱이 각각 답의 일부만 갖고 있었습니다.
|
||||
|
||||
그래서 출발한 질문은 "맛집을 더 많이 모으려면 어떻게 해야 할까?"가 아니었습니다. **신뢰하는 사람의 선택을, 다음 식사를 고를 때 다시 쓸 수 있게 하려면 어떻게 해야 할까?**였습니다.
|
||||
|
||||
Tasteby는 미식 큐레이터의 영상·글 속 식당, 메뉴, 언급 맥락을 구조화해 지도와 탐색 화면에서 찾게 하는 서비스입니다. 사용자는 원본 콘텐츠로 다시 돌아가 맥락을 확인할 수 있고, 마음에 드는 곳은 자신의 찜과 비공개 메모에 남길 수 있습니다. 즉, Tasteby는 원본 영상을 대체하거나 익명 다수의 평가를 더 크게 만드는 서비스가 아니라, 흩어진 신뢰의 단서를 실제 외식 결정에 쓸 수 있게 연결하는 도구입니다.
|
||||
|
||||
AI는 이 일을 가능하게 하는 수단입니다. 영상 자막과 공개 정보를 바탕으로 식당 후보를 구조화하되, 원본 출처를 남기고 오류는 정정할 수 있게 설계합니다. 저는 "AI가 맛집을 대신 추천한다"보다 "사람이 쌓아 온 신뢰를 사용자가 다시 쓸 수 있게 한다"는 방향이 더 오래 갈 수 있다고 믿습니다. Tasteby는 좋은 한 끼를 찾는 순간과, 좋았던 한 끼를 다시 기억하는 순간을 하나의 경험으로 만들고자 합니다.
|
||||
|
||||
## Q3-1. 기존 제품 및 서비스 대비 차별점과 어떤 문제를 해결할 수 있나요? (필수 / 2,000자)
|
||||
|
||||
Tasteby는 또 하나의 맛집 순위 서비스가 아닙니다. 출발점이 "평점이 높은 곳은 어디인가?"가 아니라 **"내가 신뢰하는 사람이 어느 콘텐츠에서, 어떤 메뉴를, 왜 언급했는가?"**이기 때문입니다.
|
||||
|
||||
기존 맛집 서비스는 대체로 다수 이용자의 별점·후기, 예약 가능 여부, 지역 검색을 중심으로 설계돼 있습니다. 이 방식은 넓은 선택지를 빠르게 보여 주는 데 유용하지만, 영상을 보고 특정 사람의 선택을 기억한 사용자에게는 두 가지 공백이 남습니다. 첫째, 영상 속 식당의 이름·주소·메뉴가 콘텐츠 안에 흩어져 있어 나중에 다시 찾기 어렵습니다. 둘째, 평균 별점만으로는 왜 그 사람이 그 식당을 언급했는지, 내가 오늘 갈 이유가 있는지 판단하기 어렵습니다.
|
||||
|
||||
Tasteby의 차별점은 세 가지입니다.
|
||||
|
||||
첫째, **출처가 있는 발견**입니다. 식당을 보여 줄 때 원본 콘텐츠와 메뉴·언급 맥락을 함께 연결합니다. 사용자는 Tasteby의 요약만 믿을 필요 없이 원본으로 돌아가 확인할 수 있습니다. 추천인지 단순 언급인지, 협찬 또는 정정 정보가 확인되는 경우에는 이를 구분해 보이는 방향으로 신뢰를 설계합니다.
|
||||
|
||||
둘째, **분야별 큐레이터의 선택을 탐색 기준으로 사용**합니다. 사용자는 무작위 목록이 아니라 자신이 신뢰하는 미식 큐레이터, 먹고 싶은 메뉴, 갈 지역을 함께 놓고 후보를 좁힐 수 있습니다. 이는 "유명해서"가 아니라 특정 음식과 상황을 꾸준히 다뤄 온 사람의 관점을 발견의 출발점으로 삼는 방식입니다.
|
||||
|
||||
셋째, **개인의 맛 기억을 기본 비공개로 보존**합니다. 사용자는 방문 뒤 좋았던 이유와 다음에 갈 조건을 자기 메모로 남길 수 있습니다. 공개 리뷰를 많이 쓰게 하는 대신, 먼저 나를 위한 기록이 다음 선택에 다시 쓰이도록 하는 것이 목표입니다. 나중에 사용자가 원할 때만 일부 후보를 동행에게 공유할 수 있으며, 비공개 메모는 공유되지 않습니다.
|
||||
|
||||
이 구조는 국내에만 적용되는 것이 아닙니다. 현재도 한국 큐레이터가 다룬 일본·미국·싱가포르 등 해외 식당 60여 곳이 같은 방식으로 쌓이고 있습니다. 여행지에서 익명 리뷰를 처음부터 다시 읽는 대신, 이미 신뢰하는 사람의 선택을 그대로 꺼내 쓸 수 있다는 뜻입니다.
|
||||
|
||||
Tasteby가 해결하려는 문제는 정보 부족이 아니라 **신뢰할 만한 정보를 다시 쓸 수 없는 문제**입니다. 음식 콘텐츠를 보는 사람은 다음 식사를 더 쉽게 결정하고, 크리에이터는 자신의 콘텐츠가 일회성 조회를 넘어 정정 가능하고 탐색 가능한 아카이브로 이어질 수 있습니다. AI는 이 구조를 빠르게 만들지만, 경쟁력의 핵심은 AI 자체가 아니라 출처를 보존하고 개인의 선택으로 이어 주는 제품 경험입니다.
|
||||
|
||||
## Q3-2. 어떻게 수익을 창출할 계획인가요? (선택 / 2,000자)
|
||||
|
||||
Tasteby의 수익 원칙은 분명합니다. **사용자의 비공개 찜과 메모를 판매하지 않고, 돈을 낸 식당이 자연 추천·큐레이터 출처·사용자 기록의 순서를 바꾸지 않게 한다**는 것입니다. 신뢰가 제품의 출발점이므로 단기 매출을 위해 이 원칙을 훼손하면 사업 자체가 약해집니다.
|
||||
|
||||
첫 번째 수익원은 공개 탐색 화면의 **명시형 문맥 광고와 프로모션**입니다. 예를 들어 사용자가 공개된 식당·메뉴·상황 페이지에서 정보를 찾을 때, 해당 맥락에 맞는 행사·신메뉴·예약 가능 정보 등을 `광고` 또는 `프로모션`으로 명확히 구분해 노출합니다. 광고주는 의사결정을 앞둔 공개 독자에게 도달할 기회를 구매하고, 사용자는 광고와 큐레이터의 자연 언급을 혼동하지 않습니다. 이 모델은 충분한 공개 탐색 세션과 신뢰 기준이 생긴 뒤 소규모로 검증합니다.
|
||||
|
||||
두 번째는 식당·브랜드의 **투명한 캠페인 상품**입니다. 신메뉴, 지역 행사, 예약 가능 정보처럼 사용자의 의사결정에 실질적으로 도움이 되는 정보를 공개 페이지의 별도 영역에서 제공합니다. 유료 여부는 표기하고, 광고비가 검색·추천·공개 리뷰의 순위를 바꾸지 않게 운영합니다. 따라서 판매 단위는 "상위 노출 보장"이 아니라, 표기된 캠페인의 도달과 행동 결과입니다.
|
||||
|
||||
세 번째는 사용자가 거래를 원할 때만 연결하는 **예약·주문 제휴 수수료**입니다. Tasteby가 사용자를 수수료가 높은 식당으로 유도하지 않고, 사용자가 이미 고른 식당에서 예약·주문을 원할 때만 제휴 경로를 제공합니다. 이 수익원은 사용자 행동과 파트너 연동이 검증된 뒤 도입합니다.
|
||||
|
||||
추가로, 크리에이터가 자신의 출처·정정·아카이브 관리를 원한다면 공식 큐레이터 페이지와 관리 도구를 유료 또는 수익 분배 방식으로 검토할 수 있습니다. 다만 기존 콘텐츠를 단순 재포장해 과금하지 않고, 크리에이터와 시청자의 재탐색 문제를 실제로 줄이는 경우에만 추진합니다.
|
||||
|
||||
초기 12개월의 목표는 매출을 과장하는 것이 아니라, 출처 기반 탐색 → 후보 저장 → 재열람이 반복되는 공개 의사결정 문맥을 검증하는 것입니다. 그 증거가 생긴 뒤 광고·프로모션·제휴를 순서대로 시험해 신뢰를 지키며 매출을 만든다는 계획입니다.
|
||||
|
||||
## Q4-1. 아이디어를 어떻게 사업화 하실 계획이신가요? (필수 / 2,000자)
|
||||
|
||||
Tasteby는 아이디어 단계에 머물지 않고, 큐레이터 출처·식당·메뉴 탐색, 지도 기반 발견, 회원·찜·공개 리뷰·비공개 메모를 갖춘 서비스를 직접 개발해 운영하고 있습니다. 2026년 9월 기준 등록 채널 6곳, 수집 영상 909편에서 추출해 좌표까지 연결한 식당 500곳을 확보했고, 그중에는 일본 28곳을 포함한 해외 식당 60여 곳이 이미 포함돼 있습니다. 저는 Spring Boot와 Next.js를 중심으로 백엔드·프론트엔드·데이터 파이프라인·배포 환경을 직접 설계하고 구현했습니다.
|
||||
|
||||
다음 단계의 사업화는 전국의 모든 맛집을 한꺼번에 늘리는 방식이 아닙니다. `큐레이터 1명 × 지역 1곳 × 메뉴 또는 식사 상황 1개`라는 작은 시장에서 출처 정확도와 실제 사용 행동을 먼저 증명하겠습니다.
|
||||
|
||||
**0~3개월에는 수량보다 신뢰도를 먼저 올리겠습니다.** 현재 LLM 검증을 거치지 않은 영상-식당 연결이 1,071건 남아 있습니다. 이를 정리해 출처와 메뉴 연결이 검증된 데이터만 노출하고, 오류 정정 경로와 `발견 → 원본 확인 → 후보 저장`의 행동 계측을 갖추겠습니다. 데이터는 채널을 10곳으로 늘려 식당 1,500곳 규모를 목표로 하되, 정확도 기준을 통과한 것만 셈에 넣겠습니다.
|
||||
|
||||
**4~6개월에는 첫 사용자 집단을 만들겠습니다.** 현재는 공개 회원 모집을 시작하지 않았고 제품과 데이터 품질을 먼저 확보하는 단계입니다. 따라서 이 구간의 핵심 과제는 방문자 수를 늘리는 것이 아니라, "영상 속 그 집을 오늘의 후보로"라는 문제를 이미 가진 사용자를 검색·커뮤니티·콘텐츠를 통해 정확한 출처 페이지로 데려와 탐색과 저장이 반복되는지 확인하는 것입니다. 동시에 큐레이터 또는 매니저에게 단순 홍보 요청이 아니라 오래된 콘텐츠의 출처 정리·정정·시청자 재탐색이라는 가치를 제안해 파트너십 3건을 협의하겠습니다.
|
||||
|
||||
**7~12개월에는 검증 결과를 확장하겠습니다.** 이미 확보한 일본 28곳은 한국 큐레이터가 현지에서 남긴 기록이므로, 새로운 데이터 수집 없이도 여행 사용 맥락을 검증할 출발점이 됩니다. 여기에 사용자가 찜한 후보를 동행과 함께 고르는 공유 기능을 더하겠습니다. 목표는 MAU 5만과 B2B 캠페인 20건이지만, 이 수치는 단순 방문자 수가 아니라 출처 확인·후보 저장·재열람·공유 수신자의 탐색으로 검증하겠습니다. 첫 매출은 자연 추천과 분리된 공개 문맥 광고 또는 투명한 캠페인 상품에서 만들 계획입니다.
|
||||
|
||||
사업화의 핵심 위험은 기술보다 신뢰입니다. 따라서 원본 링크를 보존하고, 협찬·정정 상태를 관리하며, 비공개 메모를 광고 타기팅에 쓰지 않겠습니다. 저는 "더 많은 식당"이 아니라 "다시 쓰이는 신뢰"를 증명하겠습니다.
|
||||
|
||||
> 제출 전 사실 확인: 위 실측치(채널 6·영상 909·식당 500·미검증 1,071·해외 60여)는 2026-09-15 기준이다. 제출일에 다시 조회해 갱신한다.
|
||||
|
||||
## Q4-2. 책임멘토를 통해 어떤 도움을 받고 싶은가요? (선택 / 2,000자)
|
||||
|
||||
저는 Tasteby의 제품과 기술 구현은 직접 추진해 왔지만, 다음 단계에서 필요한 것은 개발 조언보다 **신뢰를 훼손하지 않는 시장 진입과 계약의 기준**입니다. 책임멘토에게 네 가지 도움을 받고 싶습니다.
|
||||
|
||||
첫째, 크리에이터 파트너십의 실무입니다. Tasteby는 타인의 콘텐츠를 대신 소비시키는 서비스가 아니라 원본으로 다시 연결하는 서비스여야 합니다. 큐레이터·매니저에게 어떤 가치 제안을 해야 하는지, 출처·초상·인용·협찬 표기를 어떤 계약과 운영 절차로 정리해야 하는지 조언을 받고 싶습니다.
|
||||
|
||||
둘째, 초기 B2B 상품의 검증입니다. 식당이나 브랜드가 돈을 낸다고 해서 자연 추천 순위가 바뀌면 Tasteby의 차별점은 사라집니다. 공개 탐색 문맥에서 사용자가 받아들일 수 있는 광고·프로모션의 형태, 가격 이전에 검증해야 할 성과 지표, 첫 고객을 찾는 영업 경로를 함께 설계하고 싶습니다.
|
||||
|
||||
셋째, 초기 사용자 획득 전략입니다. 검색·커뮤니티·콘텐츠 중 어떤 좁은 시장에서 출처 탐색과 후보 저장이 실제로 반복되는지, 작은 예산으로 무엇을 먼저 검증해야 하는지에 대한 피드백이 필요합니다. '바이럴'이나 조회 수가 아니라 재방문과 결정 행동으로 판단하는 방법을 배우고 싶습니다.
|
||||
|
||||
넷째, 해외 확장 우선순위입니다. 일본 여행 콘텐츠는 분명한 사용 사례이지만, 국내에서 어떤 데이터 품질과 파트너십 기준을 먼저 증명해야 하는지, 다국어·현지 정보·권리 이슈를 어떤 순서로 풀어야 하는지 검토받고 싶습니다.
|
||||
|
||||
멘토링을 통해 막연한 인맥 소개보다, 파트너십·수익·유통에서 반복 가능한 의사결정 기준을 얻고 싶습니다. 이를 바탕으로 1인 개발자가 만든 MVP를 신뢰할 수 있는 소비자 서비스와 지속 가능한 사업으로 확장하겠습니다.
|
||||
|
||||
## Q5. 나의 아이디어를 소개하는 영상을 링크로 제출해주세요. (선택 / 유튜브 링크만)
|
||||
|
||||
**권장: 제출한다.** 75~90초 분량으로 아래 흐름을 사용한다.
|
||||
|
||||
1. 0~15초 — "그 영상의 그 식당, 어디였지?"라는 개인 경험과 재검색 장면
|
||||
2. 15~40초 — Tasteby에서 큐레이터·식당·메뉴·원본을 연결해 찾는 실제 화면
|
||||
3. 40~60초 — 찜과 비공개 메모, 동행과 후보를 고르는 공유 카드의 가치
|
||||
4. 60~80초 — AI는 정보를 구조화하고, Tasteby는 사람의 신뢰를 다시 쓰게 한다는 차별점
|
||||
5. 80~90초 — 12개월 검증 목표와 멘토링 요청
|
||||
|
||||
> 제출 전 입력: `[YouTube URL]`
|
||||
> 제작 원칙: Tasteby 자체 화면·직접 제작한 도식만 사용한다. 타인의 유튜브 영상·썸네일·음원을 무단 삽입하지 않는다.
|
||||
|
||||
## Q6. 사업 분야를 선택해주세요. (필수 / 드롭다운)
|
||||
|
||||
**제출 전 실제 선택지 확인 후 선택한다.**
|
||||
|
||||
1순위는 `AI·데이터`다. Tasteby의 사용자 가치는 외식 발견이지만, 영상·글에서 식당 정보를 구조화하고 출처 기반으로 탐색하게 하는 핵심 실행 수단이 AI·데이터이기 때문이다. 해당 선택지가 없으면 2순위는 `플랫폼/O2O`, 그다음은 `푸드테크`다. 예약·배달 거래가 현재 핵심이 아니므로 관광·여행은 일본 베타 이후의 보조 분야로 둔다.
|
||||
|
||||
## Q7. 지원자의 현재 창업 여부를 알려주세요. (필수 / 택1)
|
||||
|
||||
**법적 사실 확인 전 선택하지 않는다.**
|
||||
|
||||
| 확인 결과 | 선택 |
|
||||
|---|---|
|
||||
| 본인 명의 사업자/법인의 창업일이 기준 기간에 없거나, 기창업자 요건에 해당하지 않음 | `예비창업자` |
|
||||
| 창업일이 2019-08-21~2026-08-20이고 Tasteby가 기존 사업과 다른 이종 창업 아이템임 | `기창업자` |
|
||||
|
||||
MVP를 개발하거나 도메인을 운영했다는 사실만으로 Q7을 판단하지 않는다. 사업자등록·법인 설립·공고의 세부 기준을 제출 전에 확인한다.
|
||||
|
||||
## Q8. 2차 지원서에서는 1차 지원서보다 어떤 점이 더 개선되었나요? (선택 / 재도전 가점)
|
||||
|
||||
**1차 원문/피드백 확보 후 제출할 답안이다. 현재는 아래 대괄호만 1차 사실로 교체한다.**
|
||||
|
||||
1차에서는 [1차에 제시한 핵심 문제와 해결 방식]을 중심으로 아이디어를 설명했습니다. 2차에서는 이를 단순한 맛집 정보 서비스가 아니라, `큐레이터 출처로 발견 → 개인 찜과 비공개 기록으로 재사용 → 선택한 후보를 동행과 공유`하는 제품 구조로 구체화했습니다.
|
||||
|
||||
또한 아이디어 수준의 설명에서 나아가, MVP에 구현한 출처·식당·메뉴 탐색과 찜·리뷰·메모 기능을 기준으로 사업화 순서를 다시 설계했습니다. 수익도 여러 가능성을 나열하는 대신, 공개 탐색 문맥의 명시형 광고·프로모션을 먼저 검증하고 예약·주문 제휴와 크리에이터 관리 도구는 조건이 충족될 때 단계적으로 검토하도록 정리했습니다.
|
||||
|
||||
마지막으로 2차에서는 기술 자체보다 신뢰를 핵심 경쟁력으로 명확히 했습니다. 원본 콘텐츠를 대체하지 않고 출처를 보존하며, 사용자의 비공개 메모를 광고 타기팅이나 외부 판매에 쓰지 않는 원칙을 세웠습니다. 이번 지원서는 "AI로 맛집을 모으겠다"는 설명에서 한 단계 더 나아가, 어떤 사용자가 왜 다시 쓰고 어떤 방식으로 사업이 되는지를 검증 가능한 계획으로 보완한 결과입니다.
|
||||
|
||||
## Q9. 아이디어를 사업화 시킬 수 있는 본인의 역량을 알려주세요. (선택 / 2,000자)
|
||||
|
||||
Tasteby의 가장 큰 강점 중 하나는 아이디어를 설명하는 데서 멈추지 않고, 제품으로 만들고 운영 가능한 형태로 옮겨 왔다는 점입니다. 저는 1인 풀스택 개발자로서 문제 정의부터 데이터 구조, 백엔드·프론트엔드 구현, AI 처리 파이프라인, 배포 환경까지 직접 설계하고 연결해 왔습니다.
|
||||
|
||||
Tasteby에서는 Spring Boot와 Java 기반의 서비스 로직, Next.js 기반의 사용자 화면, Oracle 23ai 벡터 데이터베이스와 Redis를 활용한 데이터 처리 구조를 구성했습니다. 영상·글에서 식당 후보 정보를 구조화하고, 지오코딩·지도·검색으로 사용자가 다시 찾을 수 있게 만드는 파이프라인도 직접 만들었습니다. 기술을 개별 기능으로 쌓기보다, "출처를 찾는다 → 후보를 저장한다 → 방문 뒤 기억한다 → 다음 선택에 다시 쓴다"는 한 사용 흐름이 완성되도록 설계한 경험이 있습니다.
|
||||
|
||||
저의 실행 방식은 작은 가설을 빠르게 제품으로 옮기고, 실제 사용 행동으로 다음 우선순위를 정하는 것입니다. 예를 들어 Tasteby의 비공개 메모는 리뷰를 많이 받기 위한 기능이 아니라, 사용자가 좋았던 경험을 다음 외식에 다시 쓰게 하기 위한 기능입니다. 찜 공유 역시 전체 취향을 공개하는 기능이 아니라, 사용자가 고른 후보만 동행과 함께 결정하게 하는 기능으로 분리했습니다. 이처럼 기술의 가능성보다 사용자의 신뢰와 권한을 먼저 설계하는 것이 저의 제품 역량입니다.
|
||||
|
||||
또한 혼자서도 기획·개발·운영을 연결할 수 있지만, 모든 것을 혼자 해결하려 하지 않습니다. 크리에이터 계약, 저작권·표기, 초기 B2B 영업, 해외 확장은 멘토와 파트너의 전문성이 필요한 영역으로 분명히 인식하고 있습니다. 저는 빠르게 만들 수 있는 실행력과, 모르는 영역을 검증 가능한 질문으로 바꾸는 운영 방식을 바탕으로 Tasteby를 사업으로 성장시키겠습니다.
|
||||
|
||||
> 제출 전 보강 후보: 공개 가능한 직장 경력, 이전 프로젝트, 수상·자격, 서비스 운영 수치가 있으면 사실 확인 뒤 한 단락만 추가한다.
|
||||
|
||||
## Q10. 함께하는 팀원이 있다면 본인을 제외한 팀원 명수를 알려주세요. (선택 / 드롭다운)
|
||||
|
||||
`팀원 없음`
|
||||
|
||||
현재는 1인 창업자로 제품을 직접 개발·운영한다. 다만 법무·크리에이터 파트너십·B2B 영업은 외부 전문가 및 멘토와 보완할 계획이다.
|
||||
|
||||
## Q11. 내 아이디어를 사람들에게 자랑해보세요. (선택 / 홈페이지 공개 + 좋아요)
|
||||
|
||||
> **맛집 영상을 보다가 "그 집 어디였지?"를 되묻는 사람을 위해 만들었습니다.**
|
||||
> Tasteby는 신뢰하는 미식 큐레이터가 언급한 식당과 메뉴를 원본 출처와 함께 찾고, 마음에 드는 곳은 내 찜에 남겨 다음 식사에 다시 쓰게 합니다.
|
||||
> 좋은 한 끼를 발견하는 순간과, 좋았던 한 끼를 기억하는 순간을 연결합니다.
|
||||
> `www.tasteby.net`
|
||||
|
||||
> 게시 전: 실제 접속 URL과 대표 스크린샷을 확인한다.
|
||||
|
||||
## Q12. 서약서 동의 (필수)
|
||||
|
||||
제출 직전에 아래를 각각 읽고 동의한다.
|
||||
|
||||
- 참여 서약서
|
||||
- 본인 아이디어 확인 서약서
|
||||
- 비밀유지 서약서
|
||||
|
||||
## 사진 배분 계획 (전체 10장 제한)
|
||||
|
||||
| 문항 | 장수 | 권장 이미지 | 심사에서 보여 줄 사실 |
|
||||
|------|---:|---|---|
|
||||
| Q2 | 1 | 직접 만든 `영상 속 정보 → Tasteby 출처·지도 → 다음 식사` 흐름 도식 | 왜 이 서비스가 필요한가 |
|
||||
| Q3-1 | 3 | 지도 탐색, 식당 상세의 원본/메뉴 근거, 찜·비공개 메모 화면 | 출처·개인 기록의 차별점 |
|
||||
| Q4-1 | 2 | 데이터 처리·정정 흐름 도식, 12개월 로드맵 | 이미 만든 것과 다음 검증 순서 |
|
||||
| Q9 | 1 | 실제 서비스 아키텍처 또는 개발·배포 흐름 | 1인 실행 역량 |
|
||||
| Q11 | 1 | 모바일 또는 데스크톱 대표 화면 한 장 | 공개 소개의 첫 인상 |
|
||||
| 예비 | 2 | 수신자 공유 카드, 모바일 탐색 화면 | 심사 코멘트 대응 또는 품질이 높은 화면 교체 |
|
||||
|
||||
총 8장을 기본으로 사용하고 2장은 예비로 남긴다. 모든 화면은 제출일 기준 실제 동작·표현과 일치해야 하며, 원본 영상 캡처·타인 음식 사진은 사용권을 확인하지 못하면 사용하지 않는다.
|
||||
187
docs/ir/modu-startup-2026-09/v4.md
Normal file
187
docs/ir/modu-startup-2026-09/v4.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# 모두의 창업 2차 지원서 — v4
|
||||
|
||||
> 작성일: 2026-09-15
|
||||
> 상태: Q8(재도전 가점)까지 채운 판. `제출 전 확정` 표기 항목만 사실 확인 뒤 확정한다.
|
||||
|
||||
## v3 → v4 변경 요약
|
||||
- **Q8 완성**: 1차 지원서 원문이 `v1.md`(사용자 제공 원고)임을 확인 → 대괄호를 실제 1차 내용으로 채움. 1차의 실제 약점 4가지(제품 정의·수익모델 나열·근거 없는 수치·멘토 문항 오답)와 2차의 수정을 1:1로 대응시킴
|
||||
|
||||
## v2 → v3 변경 요약
|
||||
- **Q4-1 전면 수정**: "3개월에 식당 5,000곳"(현재의 10배, 달성 불가) → 현재 실측치(채널 6·영상 909·식당 500)에서 출발하는 배수 목표로 교체. 0~3개월 과제를 수량이 아니라 **LLM 미검증 1,071건 정리**로 재정의
|
||||
- **Q4-1에 사용자 현황 명시**: 공개 회원 모집을 아직 시작하지 않은 단계임을 밝히고, 4~6개월의 핵심 과제를 첫 사용자 집단 확보로 지정
|
||||
- **Q3-1·Q4-1에 해외 데이터 반영**: 일본 28곳 포함 해외 60여 곳이 이미 축적됨 → 일본 확장이 "구상"이 아니라 "이미 시작된 축적"임을 근거로 제시
|
||||
- **깨진 참조 링크 3개 제거** (`docs/design/705-…`, `docs/strategy/…`, `docs/design/711-…` — 리포에 존재하지 않음)
|
||||
|
||||
> 실측 출처: 2026-09-15 Admin 화면 및 `GET /api/restaurants?limit=100000` 조회. 상세는 `README.md` 실측 지표 표.
|
||||
|
||||
## Q1. 나의 아이디어를 한 줄로 소개해주세요. (필수 / 10~100자)
|
||||
|
||||
> 신뢰하는 미식 큐레이터의 영상 속 맛집을 찾아, 나의 다음 식사와 기록으로 연결하는 Tasteby
|
||||
|
||||
- v2 검증: 54자. 제출 화면의 실제 글자 수를 최종 확인한다.
|
||||
|
||||
## Q2. 아이디어를 떠올린 배경 이야기를 들려주세요. (필수 / 2,000자)
|
||||
|
||||
저는 맛집 영상을 보는 것을 좋아하지만, 막상 식사 약속이 잡히면 같은 문제를 반복해서 겪었습니다. "성시경 영상에서 본 그 국밥집이 어디였지?" 기억나는 것은 사람과 메뉴의 인상뿐이었습니다. 다시 영상을 찾아 자막을 넘기고, 식당 이름을 받아 적어 지도에 옮겨야 했습니다. 좋아하는 채널이 늘수록 저장한 정보도 늘었지만, 정작 필요한 순간에는 더 찾기 어려워졌습니다.
|
||||
|
||||
이 불편은 일본 여행에서 더 선명해졌습니다. 여행 전에 한국 미식 유튜버의 일본 맛집 영상을 여러 편 봤지만, 현지에서는 어느 영상의 어느 장면에 나온 식당인지 다시 찾는 데 시간이 들었습니다. 별점과 후기 수가 많은 식당은 쉽게 찾을 수 있었지만, 제가 실제로 궁금했던 것은 "내가 신뢰하는 이 사람이 왜 이곳을 골랐는가"였습니다. 영상, 지도, 메모앱이 각각 답의 일부만 갖고 있었습니다.
|
||||
|
||||
그래서 출발한 질문은 "맛집을 더 많이 모으려면 어떻게 해야 할까?"가 아니었습니다. **신뢰하는 사람의 선택을, 다음 식사를 고를 때 다시 쓸 수 있게 하려면 어떻게 해야 할까?**였습니다.
|
||||
|
||||
Tasteby는 미식 큐레이터의 영상·글 속 식당, 메뉴, 언급 맥락을 구조화해 지도와 탐색 화면에서 찾게 하는 서비스입니다. 사용자는 원본 콘텐츠로 다시 돌아가 맥락을 확인할 수 있고, 마음에 드는 곳은 자신의 찜과 비공개 메모에 남길 수 있습니다. 즉, Tasteby는 원본 영상을 대체하거나 익명 다수의 평가를 더 크게 만드는 서비스가 아니라, 흩어진 신뢰의 단서를 실제 외식 결정에 쓸 수 있게 연결하는 도구입니다.
|
||||
|
||||
AI는 이 일을 가능하게 하는 수단입니다. 영상 자막과 공개 정보를 바탕으로 식당 후보를 구조화하되, 원본 출처를 남기고 오류는 정정할 수 있게 설계합니다. 저는 "AI가 맛집을 대신 추천한다"보다 "사람이 쌓아 온 신뢰를 사용자가 다시 쓸 수 있게 한다"는 방향이 더 오래 갈 수 있다고 믿습니다. Tasteby는 좋은 한 끼를 찾는 순간과, 좋았던 한 끼를 다시 기억하는 순간을 하나의 경험으로 만들고자 합니다.
|
||||
|
||||
## Q3-1. 기존 제품 및 서비스 대비 차별점과 어떤 문제를 해결할 수 있나요? (필수 / 2,000자)
|
||||
|
||||
Tasteby는 또 하나의 맛집 순위 서비스가 아닙니다. 출발점이 "평점이 높은 곳은 어디인가?"가 아니라 **"내가 신뢰하는 사람이 어느 콘텐츠에서, 어떤 메뉴를, 왜 언급했는가?"**이기 때문입니다.
|
||||
|
||||
기존 맛집 서비스는 대체로 다수 이용자의 별점·후기, 예약 가능 여부, 지역 검색을 중심으로 설계돼 있습니다. 이 방식은 넓은 선택지를 빠르게 보여 주는 데 유용하지만, 영상을 보고 특정 사람의 선택을 기억한 사용자에게는 두 가지 공백이 남습니다. 첫째, 영상 속 식당의 이름·주소·메뉴가 콘텐츠 안에 흩어져 있어 나중에 다시 찾기 어렵습니다. 둘째, 평균 별점만으로는 왜 그 사람이 그 식당을 언급했는지, 내가 오늘 갈 이유가 있는지 판단하기 어렵습니다.
|
||||
|
||||
Tasteby의 차별점은 세 가지입니다.
|
||||
|
||||
첫째, **출처가 있는 발견**입니다. 식당을 보여 줄 때 원본 콘텐츠와 메뉴·언급 맥락을 함께 연결합니다. 사용자는 Tasteby의 요약만 믿을 필요 없이 원본으로 돌아가 확인할 수 있습니다. 추천인지 단순 언급인지, 협찬 또는 정정 정보가 확인되는 경우에는 이를 구분해 보이는 방향으로 신뢰를 설계합니다.
|
||||
|
||||
둘째, **분야별 큐레이터의 선택을 탐색 기준으로 사용**합니다. 사용자는 무작위 목록이 아니라 자신이 신뢰하는 미식 큐레이터, 먹고 싶은 메뉴, 갈 지역을 함께 놓고 후보를 좁힐 수 있습니다. 이는 "유명해서"가 아니라 특정 음식과 상황을 꾸준히 다뤄 온 사람의 관점을 발견의 출발점으로 삼는 방식입니다.
|
||||
|
||||
셋째, **개인의 맛 기억을 기본 비공개로 보존**합니다. 사용자는 방문 뒤 좋았던 이유와 다음에 갈 조건을 자기 메모로 남길 수 있습니다. 공개 리뷰를 많이 쓰게 하는 대신, 먼저 나를 위한 기록이 다음 선택에 다시 쓰이도록 하는 것이 목표입니다. 나중에 사용자가 원할 때만 일부 후보를 동행에게 공유할 수 있으며, 비공개 메모는 공유되지 않습니다.
|
||||
|
||||
이 구조는 국내에만 적용되는 것이 아닙니다. 현재도 한국 큐레이터가 다룬 일본·미국·싱가포르 등 해외 식당 60여 곳이 같은 방식으로 쌓이고 있습니다. 여행지에서 익명 리뷰를 처음부터 다시 읽는 대신, 이미 신뢰하는 사람의 선택을 그대로 꺼내 쓸 수 있다는 뜻입니다.
|
||||
|
||||
Tasteby가 해결하려는 문제는 정보 부족이 아니라 **신뢰할 만한 정보를 다시 쓸 수 없는 문제**입니다. 음식 콘텐츠를 보는 사람은 다음 식사를 더 쉽게 결정하고, 크리에이터는 자신의 콘텐츠가 일회성 조회를 넘어 정정 가능하고 탐색 가능한 아카이브로 이어질 수 있습니다. AI는 이 구조를 빠르게 만들지만, 경쟁력의 핵심은 AI 자체가 아니라 출처를 보존하고 개인의 선택으로 이어 주는 제품 경험입니다.
|
||||
|
||||
## Q3-2. 어떻게 수익을 창출할 계획인가요? (선택 / 2,000자)
|
||||
|
||||
Tasteby의 수익 원칙은 분명합니다. **사용자의 비공개 찜과 메모를 판매하지 않고, 돈을 낸 식당이 자연 추천·큐레이터 출처·사용자 기록의 순서를 바꾸지 않게 한다**는 것입니다. 신뢰가 제품의 출발점이므로 단기 매출을 위해 이 원칙을 훼손하면 사업 자체가 약해집니다.
|
||||
|
||||
첫 번째 수익원은 공개 탐색 화면의 **명시형 문맥 광고와 프로모션**입니다. 예를 들어 사용자가 공개된 식당·메뉴·상황 페이지에서 정보를 찾을 때, 해당 맥락에 맞는 행사·신메뉴·예약 가능 정보 등을 `광고` 또는 `프로모션`으로 명확히 구분해 노출합니다. 광고주는 의사결정을 앞둔 공개 독자에게 도달할 기회를 구매하고, 사용자는 광고와 큐레이터의 자연 언급을 혼동하지 않습니다. 이 모델은 충분한 공개 탐색 세션과 신뢰 기준이 생긴 뒤 소규모로 검증합니다.
|
||||
|
||||
두 번째는 식당·브랜드의 **투명한 캠페인 상품**입니다. 신메뉴, 지역 행사, 예약 가능 정보처럼 사용자의 의사결정에 실질적으로 도움이 되는 정보를 공개 페이지의 별도 영역에서 제공합니다. 유료 여부는 표기하고, 광고비가 검색·추천·공개 리뷰의 순위를 바꾸지 않게 운영합니다. 따라서 판매 단위는 "상위 노출 보장"이 아니라, 표기된 캠페인의 도달과 행동 결과입니다.
|
||||
|
||||
세 번째는 사용자가 거래를 원할 때만 연결하는 **예약·주문 제휴 수수료**입니다. Tasteby가 사용자를 수수료가 높은 식당으로 유도하지 않고, 사용자가 이미 고른 식당에서 예약·주문을 원할 때만 제휴 경로를 제공합니다. 이 수익원은 사용자 행동과 파트너 연동이 검증된 뒤 도입합니다.
|
||||
|
||||
추가로, 크리에이터가 자신의 출처·정정·아카이브 관리를 원한다면 공식 큐레이터 페이지와 관리 도구를 유료 또는 수익 분배 방식으로 검토할 수 있습니다. 다만 기존 콘텐츠를 단순 재포장해 과금하지 않고, 크리에이터와 시청자의 재탐색 문제를 실제로 줄이는 경우에만 추진합니다.
|
||||
|
||||
초기 12개월의 목표는 매출을 과장하는 것이 아니라, 출처 기반 탐색 → 후보 저장 → 재열람이 반복되는 공개 의사결정 문맥을 검증하는 것입니다. 그 증거가 생긴 뒤 광고·프로모션·제휴를 순서대로 시험해 신뢰를 지키며 매출을 만든다는 계획입니다.
|
||||
|
||||
## Q4-1. 아이디어를 어떻게 사업화 하실 계획이신가요? (필수 / 2,000자)
|
||||
|
||||
Tasteby는 아이디어 단계에 머물지 않고, 큐레이터 출처·식당·메뉴 탐색, 지도 기반 발견, 회원·찜·공개 리뷰·비공개 메모를 갖춘 서비스를 직접 개발해 운영하고 있습니다. 2026년 9월 기준 등록 채널 6곳, 수집 영상 909편에서 추출해 좌표까지 연결한 식당 500곳을 확보했고, 그중에는 일본 28곳을 포함한 해외 식당 60여 곳이 이미 포함돼 있습니다. 저는 Spring Boot와 Next.js를 중심으로 백엔드·프론트엔드·데이터 파이프라인·배포 환경을 직접 설계하고 구현했습니다.
|
||||
|
||||
다음 단계의 사업화는 전국의 모든 맛집을 한꺼번에 늘리는 방식이 아닙니다. `큐레이터 1명 × 지역 1곳 × 메뉴 또는 식사 상황 1개`라는 작은 시장에서 출처 정확도와 실제 사용 행동을 먼저 증명하겠습니다.
|
||||
|
||||
**0~3개월에는 수량보다 신뢰도를 먼저 올리겠습니다.** 현재 LLM 검증을 거치지 않은 영상-식당 연결이 1,071건 남아 있습니다. 이를 정리해 출처와 메뉴 연결이 검증된 데이터만 노출하고, 오류 정정 경로와 `발견 → 원본 확인 → 후보 저장`의 행동 계측을 갖추겠습니다. 데이터는 채널을 10곳으로 늘려 식당 1,500곳 규모를 목표로 하되, 정확도 기준을 통과한 것만 셈에 넣겠습니다.
|
||||
|
||||
**4~6개월에는 첫 사용자 집단을 만들겠습니다.** 현재는 공개 회원 모집을 시작하지 않았고 제품과 데이터 품질을 먼저 확보하는 단계입니다. 따라서 이 구간의 핵심 과제는 방문자 수를 늘리는 것이 아니라, "영상 속 그 집을 오늘의 후보로"라는 문제를 이미 가진 사용자를 검색·커뮤니티·콘텐츠를 통해 정확한 출처 페이지로 데려와 탐색과 저장이 반복되는지 확인하는 것입니다. 동시에 큐레이터 또는 매니저에게 단순 홍보 요청이 아니라 오래된 콘텐츠의 출처 정리·정정·시청자 재탐색이라는 가치를 제안해 파트너십 3건을 협의하겠습니다.
|
||||
|
||||
**7~12개월에는 검증 결과를 확장하겠습니다.** 이미 확보한 일본 28곳은 한국 큐레이터가 현지에서 남긴 기록이므로, 새로운 데이터 수집 없이도 여행 사용 맥락을 검증할 출발점이 됩니다. 여기에 사용자가 찜한 후보를 동행과 함께 고르는 공유 기능을 더하겠습니다. 목표는 MAU 5만과 B2B 캠페인 20건이지만, 이 수치는 단순 방문자 수가 아니라 출처 확인·후보 저장·재열람·공유 수신자의 탐색으로 검증하겠습니다. 첫 매출은 자연 추천과 분리된 공개 문맥 광고 또는 투명한 캠페인 상품에서 만들 계획입니다.
|
||||
|
||||
사업화의 핵심 위험은 기술보다 신뢰입니다. 따라서 원본 링크를 보존하고, 협찬·정정 상태를 관리하며, 비공개 메모를 광고 타기팅에 쓰지 않겠습니다. 저는 "더 많은 식당"이 아니라 "다시 쓰이는 신뢰"를 증명하겠습니다.
|
||||
|
||||
> 제출 전 사실 확인: 위 실측치(채널 6·영상 909·식당 500·미검증 1,071·해외 60여)는 2026-09-15 기준이다. 제출일에 다시 조회해 갱신한다.
|
||||
|
||||
## Q4-2. 책임멘토를 통해 어떤 도움을 받고 싶은가요? (선택 / 2,000자)
|
||||
|
||||
저는 Tasteby의 제품과 기술 구현은 직접 추진해 왔지만, 다음 단계에서 필요한 것은 개발 조언보다 **신뢰를 훼손하지 않는 시장 진입과 계약의 기준**입니다. 책임멘토에게 네 가지 도움을 받고 싶습니다.
|
||||
|
||||
첫째, 크리에이터 파트너십의 실무입니다. Tasteby는 타인의 콘텐츠를 대신 소비시키는 서비스가 아니라 원본으로 다시 연결하는 서비스여야 합니다. 큐레이터·매니저에게 어떤 가치 제안을 해야 하는지, 출처·초상·인용·협찬 표기를 어떤 계약과 운영 절차로 정리해야 하는지 조언을 받고 싶습니다.
|
||||
|
||||
둘째, 초기 B2B 상품의 검증입니다. 식당이나 브랜드가 돈을 낸다고 해서 자연 추천 순위가 바뀌면 Tasteby의 차별점은 사라집니다. 공개 탐색 문맥에서 사용자가 받아들일 수 있는 광고·프로모션의 형태, 가격 이전에 검증해야 할 성과 지표, 첫 고객을 찾는 영업 경로를 함께 설계하고 싶습니다.
|
||||
|
||||
셋째, 초기 사용자 획득 전략입니다. 검색·커뮤니티·콘텐츠 중 어떤 좁은 시장에서 출처 탐색과 후보 저장이 실제로 반복되는지, 작은 예산으로 무엇을 먼저 검증해야 하는지에 대한 피드백이 필요합니다. '바이럴'이나 조회 수가 아니라 재방문과 결정 행동으로 판단하는 방법을 배우고 싶습니다.
|
||||
|
||||
넷째, 해외 확장 우선순위입니다. 일본 여행 콘텐츠는 분명한 사용 사례이지만, 국내에서 어떤 데이터 품질과 파트너십 기준을 먼저 증명해야 하는지, 다국어·현지 정보·권리 이슈를 어떤 순서로 풀어야 하는지 검토받고 싶습니다.
|
||||
|
||||
멘토링을 통해 막연한 인맥 소개보다, 파트너십·수익·유통에서 반복 가능한 의사결정 기준을 얻고 싶습니다. 이를 바탕으로 1인 개발자가 만든 MVP를 신뢰할 수 있는 소비자 서비스와 지속 가능한 사업으로 확장하겠습니다.
|
||||
|
||||
## Q5. 나의 아이디어를 소개하는 영상을 링크로 제출해주세요. (선택 / 유튜브 링크만)
|
||||
|
||||
**권장: 제출한다.** 75~90초 분량으로 아래 흐름을 사용한다.
|
||||
|
||||
1. 0~15초 — "그 영상의 그 식당, 어디였지?"라는 개인 경험과 재검색 장면
|
||||
2. 15~40초 — Tasteby에서 큐레이터·식당·메뉴·원본을 연결해 찾는 실제 화면
|
||||
3. 40~60초 — 찜과 비공개 메모, 동행과 후보를 고르는 공유 카드의 가치
|
||||
4. 60~80초 — AI는 정보를 구조화하고, Tasteby는 사람의 신뢰를 다시 쓰게 한다는 차별점
|
||||
5. 80~90초 — 12개월 검증 목표와 멘토링 요청
|
||||
|
||||
> 제출 전 입력: `[YouTube URL]`
|
||||
> 제작 원칙: Tasteby 자체 화면·직접 제작한 도식만 사용한다. 타인의 유튜브 영상·썸네일·음원을 무단 삽입하지 않는다.
|
||||
|
||||
## Q6. 사업 분야를 선택해주세요. (필수 / 드롭다운)
|
||||
|
||||
**제출 전 실제 선택지 확인 후 선택한다.**
|
||||
|
||||
1순위는 `AI·데이터`다. Tasteby의 사용자 가치는 외식 발견이지만, 영상·글에서 식당 정보를 구조화하고 출처 기반으로 탐색하게 하는 핵심 실행 수단이 AI·데이터이기 때문이다. 해당 선택지가 없으면 2순위는 `플랫폼/O2O`, 그다음은 `푸드테크`다. 예약·배달 거래가 현재 핵심이 아니므로 관광·여행은 일본 베타 이후의 보조 분야로 둔다.
|
||||
|
||||
## Q7. 지원자의 현재 창업 여부를 알려주세요. (필수 / 택1)
|
||||
|
||||
**법적 사실 확인 전 선택하지 않는다.**
|
||||
|
||||
| 확인 결과 | 선택 |
|
||||
|---|---|
|
||||
| 본인 명의 사업자/법인의 창업일이 기준 기간에 없거나, 기창업자 요건에 해당하지 않음 | `예비창업자` |
|
||||
| 창업일이 2019-08-21~2026-08-20이고 Tasteby가 기존 사업과 다른 이종 창업 아이템임 | `기창업자` |
|
||||
|
||||
MVP를 개발하거나 도메인을 운영했다는 사실만으로 Q7을 판단하지 않는다. 사업자등록·법인 설립·공고의 세부 기준을 제출 전에 확인한다.
|
||||
|
||||
## Q8. 2차 지원서에서는 1차 지원서보다 어떤 점이 더 개선되었나요? (선택 / 재도전 가점)
|
||||
|
||||
1차에서는 Tasteby를 "유튜브 맛집 영상에서 AI가 식당 정보를 자동으로 추출해 지도에 모아주는 서비스"로 설명했습니다. 타겟 규모와 기술 구성, 12개월 로드맵을 제시했지만 지원서를 다시 읽어 보니 네 가지가 비어 있었습니다. 제품을 정보 수집 기능으로만 설명했고, 수익 모델이 몇 줄의 나열에 그쳤으며, 목표 수치에 근거가 없었고, 멘토링 문항에는 멘토에게 받고 싶은 도움 대신 자금 사용 계획을 적었습니다.
|
||||
|
||||
**첫째, 제품의 정의를 바꿨습니다.** "AI가 맛집을 모아준다"에서 "신뢰하는 큐레이터의 출처로 발견하고, 내 비공개 기록으로 남겨 다음 식사에 다시 쓰고, 필요한 후보만 동행과 공유한다"는 사용 흐름으로 다시 썼습니다. 경쟁력은 수집 기술이 아니라 사용자가 신뢰를 다시 쓰게 만드는 구조에 있다고 판단했기 때문입니다.
|
||||
|
||||
**둘째, 수익 모델을 원칙과 순서로 정리했습니다.** 1차의 "유튜버 파트너십·식당 제휴·장기 데이터 사업" 나열 대신, 사용자의 비공개 기록을 판매하지 않고 광고비가 자연 추천 순위를 바꾸지 않는다는 원칙을 먼저 세웠습니다. 그 위에 공개 문맥 광고 → 투명한 캠페인 상품 → 사용자가 원할 때의 거래 제휴 순으로 검증 조건을 붙였습니다.
|
||||
|
||||
**셋째, 목표 수치를 실측에서 다시 세웠습니다.** 1차에 적은 "3개월 내 식당 5,000곳"은 현재 500곳 기준으로 10배여서 달성 가능한 계획이 아니었습니다. 2차에서는 채널 6곳·영상 909편·식당 500곳이라는 현재 값을 밝히고, 0~3개월의 과제를 수량 확대가 아니라 아직 검증되지 않은 영상-식당 연결 1,071건을 정리하는 일로 바꿨습니다. 공개 회원 모집을 아직 시작하지 않았다는 사실도 그대로 적었습니다.
|
||||
|
||||
**넷째, 멘토링 문항을 질문에 맞게 다시 썼습니다.** 자금 사용 계획 대신 크리에이터 계약 실무, B2B 상품 검증, 초기 사용자 획득, 해외 확장 우선순위 네 가지로 구체화했습니다.
|
||||
|
||||
1차가 "무엇을 만들 수 있는가"에 대한 답이었다면, 2차는 "누가 왜 다시 쓰고, 어떤 순서로 검증하며, 어떻게 사업이 되는가"에 대한 답입니다. 그사이 서비스도 멈춰 있지 않았습니다. 지역 표시 계층화, 언급이 적은 식당을 걸러내는 필터, 해외 식당의 국내 전용 예약 버튼 제거처럼 신뢰와 정확도에 관한 개선을 계속 반영해 왔습니다.
|
||||
|
||||
## Q9. 아이디어를 사업화 시킬 수 있는 본인의 역량을 알려주세요. (선택 / 2,000자)
|
||||
|
||||
Tasteby의 가장 큰 강점 중 하나는 아이디어를 설명하는 데서 멈추지 않고, 제품으로 만들고 운영 가능한 형태로 옮겨 왔다는 점입니다. 저는 1인 풀스택 개발자로서 문제 정의부터 데이터 구조, 백엔드·프론트엔드 구현, AI 처리 파이프라인, 배포 환경까지 직접 설계하고 연결해 왔습니다.
|
||||
|
||||
Tasteby에서는 Spring Boot와 Java 기반의 서비스 로직, Next.js 기반의 사용자 화면, Oracle 23ai 벡터 데이터베이스와 Redis를 활용한 데이터 처리 구조를 구성했습니다. 영상·글에서 식당 후보 정보를 구조화하고, 지오코딩·지도·검색으로 사용자가 다시 찾을 수 있게 만드는 파이프라인도 직접 만들었습니다. 기술을 개별 기능으로 쌓기보다, "출처를 찾는다 → 후보를 저장한다 → 방문 뒤 기억한다 → 다음 선택에 다시 쓴다"는 한 사용 흐름이 완성되도록 설계한 경험이 있습니다.
|
||||
|
||||
저의 실행 방식은 작은 가설을 빠르게 제품으로 옮기고, 실제 사용 행동으로 다음 우선순위를 정하는 것입니다. 예를 들어 Tasteby의 비공개 메모는 리뷰를 많이 받기 위한 기능이 아니라, 사용자가 좋았던 경험을 다음 외식에 다시 쓰게 하기 위한 기능입니다. 찜 공유 역시 전체 취향을 공개하는 기능이 아니라, 사용자가 고른 후보만 동행과 함께 결정하게 하는 기능으로 분리했습니다. 이처럼 기술의 가능성보다 사용자의 신뢰와 권한을 먼저 설계하는 것이 저의 제품 역량입니다.
|
||||
|
||||
또한 혼자서도 기획·개발·운영을 연결할 수 있지만, 모든 것을 혼자 해결하려 하지 않습니다. 크리에이터 계약, 저작권·표기, 초기 B2B 영업, 해외 확장은 멘토와 파트너의 전문성이 필요한 영역으로 분명히 인식하고 있습니다. 저는 빠르게 만들 수 있는 실행력과, 모르는 영역을 검증 가능한 질문으로 바꾸는 운영 방식을 바탕으로 Tasteby를 사업으로 성장시키겠습니다.
|
||||
|
||||
> 제출 전 보강 후보: 공개 가능한 직장 경력, 이전 프로젝트, 수상·자격, 서비스 운영 수치가 있으면 사실 확인 뒤 한 단락만 추가한다.
|
||||
|
||||
## Q10. 함께하는 팀원이 있다면 본인을 제외한 팀원 명수를 알려주세요. (선택 / 드롭다운)
|
||||
|
||||
`팀원 없음`
|
||||
|
||||
현재는 1인 창업자로 제품을 직접 개발·운영한다. 다만 법무·크리에이터 파트너십·B2B 영업은 외부 전문가 및 멘토와 보완할 계획이다.
|
||||
|
||||
## Q11. 내 아이디어를 사람들에게 자랑해보세요. (선택 / 홈페이지 공개 + 좋아요)
|
||||
|
||||
> **맛집 영상을 보다가 "그 집 어디였지?"를 되묻는 사람을 위해 만들었습니다.**
|
||||
> Tasteby는 신뢰하는 미식 큐레이터가 언급한 식당과 메뉴를 원본 출처와 함께 찾고, 마음에 드는 곳은 내 찜에 남겨 다음 식사에 다시 쓰게 합니다.
|
||||
> 좋은 한 끼를 발견하는 순간과, 좋았던 한 끼를 기억하는 순간을 연결합니다.
|
||||
> `www.tasteby.net`
|
||||
|
||||
> 게시 전: 실제 접속 URL과 대표 스크린샷을 확인한다.
|
||||
|
||||
## Q12. 서약서 동의 (필수)
|
||||
|
||||
제출 직전에 아래를 각각 읽고 동의한다.
|
||||
|
||||
- 참여 서약서
|
||||
- 본인 아이디어 확인 서약서
|
||||
- 비밀유지 서약서
|
||||
|
||||
## 사진 배분 계획 (전체 10장 제한)
|
||||
|
||||
| 문항 | 장수 | 권장 이미지 | 심사에서 보여 줄 사실 |
|
||||
|------|---:|---|---|
|
||||
| Q2 | 1 | 직접 만든 `영상 속 정보 → Tasteby 출처·지도 → 다음 식사` 흐름 도식 | 왜 이 서비스가 필요한가 |
|
||||
| Q3-1 | 3 | 지도 탐색, 식당 상세의 원본/메뉴 근거, 찜·비공개 메모 화면 | 출처·개인 기록의 차별점 |
|
||||
| Q4-1 | 2 | 데이터 처리·정정 흐름 도식, 12개월 로드맵 | 이미 만든 것과 다음 검증 순서 |
|
||||
| Q9 | 1 | 실제 서비스 아키텍처 또는 개발·배포 흐름 | 1인 실행 역량 |
|
||||
| Q11 | 1 | 모바일 또는 데스크톱 대표 화면 한 장 | 공개 소개의 첫 인상 |
|
||||
| 예비 | 2 | 수신자 공유 카드, 모바일 탐색 화면 | 심사 코멘트 대응 또는 품질이 높은 화면 교체 |
|
||||
|
||||
총 8장을 기본으로 사용하고 2장은 예비로 남긴다. 모든 화면은 제출일 기준 실제 동작·표현과 일치해야 하며, 원본 영상 캡처·타인 음식 사진은 사용권을 확인하지 못하면 사용하지 않는다.
|
||||
766
docs/oke-deploy-howto.md
Normal file
766
docs/oke-deploy-howto.md
Normal file
@@ -0,0 +1,766 @@
|
||||
# OKE(Oracle Kubernetes Engine) 배포 가이드
|
||||
|
||||
> Spring Boot + Next.js 앱을 OKE에 배포하는 전체 과정을 정리한 문서.
|
||||
> Colima(로컬 ARM64 Docker) → OCIR(이미지 레지스트리) → OKE(K8s 클러스터) 파이프라인 기준.
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [사전 준비](#1-사전-준비)
|
||||
2. [인프라 아키텍처](#2-인프라-아키텍처)
|
||||
3. [최초 클러스터 설정 (1회성)](#3-최초-클러스터-설정-1회성)
|
||||
4. [K8s 매니페스트 구조](#4-k8s-매니페스트-구조)
|
||||
5. [Dockerfile 작성](#5-dockerfile-작성)
|
||||
6. [배포 스크립트 (deploy.sh)](#6-배포-스크립트-deploysh)
|
||||
7. [일상적인 배포 절차](#7-일상적인-배포-절차)
|
||||
8. [환경별 설정 관리](#8-환경별-설정-관리)
|
||||
9. [도메인 및 SSL 설정](#9-도메인-및-ssl-설정)
|
||||
10. [트러블슈팅](#10-트러블슈팅)
|
||||
11. [유용한 kubectl 명령어](#11-유용한-kubectl-명령어)
|
||||
|
||||
---
|
||||
|
||||
## 1. 사전 준비
|
||||
|
||||
### 필요한 도구
|
||||
|
||||
| 도구 | 용도 | 설치 |
|
||||
|------|------|------|
|
||||
| **OCI CLI** | OKE 인증, kubeconfig 설정 | `brew install oci-cli` |
|
||||
| **kubectl** | K8s 클러스터 관리 | `brew install kubectl` |
|
||||
| **Colima** | 로컬 ARM64 Docker 빌드 (Docker Desktop 대체) | `brew install colima` |
|
||||
| **Docker CLI** | 이미지 빌드/푸시 | `brew install docker` |
|
||||
| **Helm** | Nginx Ingress, cert-manager 설치 | `brew install helm` |
|
||||
|
||||
### OCI 설정
|
||||
|
||||
```bash
|
||||
# OCI CLI 프로파일 설정 (~/.oci/config)
|
||||
[DEFAULT]
|
||||
user=ocid1.user.oc1..xxxx
|
||||
fingerprint=xx:xx:xx:...
|
||||
tenancy=ocid1.tenancy.oc1..xxxx
|
||||
region=ap-seoul-1
|
||||
key_file=~/.oci/oci_api_key.pem
|
||||
```
|
||||
|
||||
### kubeconfig 설정
|
||||
|
||||
```bash
|
||||
# OKE 클러스터 접근 설정
|
||||
oci ce cluster create-kubeconfig \
|
||||
--cluster-id ocid1.cluster.oc1.<region>.<cluster-id> \
|
||||
--file $HOME/.kube/config \
|
||||
--region <region> \
|
||||
--token-version 2.0.0 \
|
||||
--kube-endpoint PUBLIC_ENDPOINT
|
||||
|
||||
# 연결 확인
|
||||
kubectl get nodes
|
||||
```
|
||||
|
||||
### OCIR(Oracle Container Image Registry) 로그인
|
||||
|
||||
```bash
|
||||
# OCIR 로그인
|
||||
# 사용자명 형식: <namespace>/oracleidentitycloudservice/<email>
|
||||
# 비밀번호: OCI Console → User Settings → Auth Tokens에서 발급
|
||||
|
||||
docker login <region-code>.ocir.io
|
||||
# 예) docker login icn.ocir.io
|
||||
```
|
||||
|
||||
### Colima 시작 (ARM64 빌드용)
|
||||
|
||||
```bash
|
||||
# OKE 노드가 ARM64인 경우 반드시 ARM64로 빌드해야 함
|
||||
colima start --arch aarch64 --cpu 4 --memory 4
|
||||
|
||||
# ⚠️ Colima 시작 시 kubectl 컨텍스트가 'colima'로 바뀜
|
||||
# OKE 컨텍스트로 복원 필수
|
||||
kubectl config use-context <oke-context-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 인프라 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Internet (사용자) │
|
||||
│ https://www.example.com │
|
||||
└────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
┌───────▼────────┐
|
||||
│ DNS Provider │ ← A 레코드 → NLB Public IP
|
||||
└───────┬────────┘
|
||||
│
|
||||
┌────────────▼──────────────┐
|
||||
│ OCI Network Load Balancer │ ← Nginx Ingress가 자동 생성
|
||||
└────────────┬──────────────┘
|
||||
│
|
||||
┌────────────▼──────────────────────────────────┐
|
||||
│ OKE Cluster │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ Nginx Ingress Controller │ │
|
||||
│ │ + cert-manager (Let's Encrypt)│ │
|
||||
│ └───┬─────────────┬───────────┘ │
|
||||
│ │ │ │
|
||||
│ /api/* /* │
|
||||
│ │ │ │
|
||||
│ ┌───▼──────┐ ┌──▼───────┐ ┌──────────┐ │
|
||||
│ │ Backend │ │ Frontend │ │ Redis │ │
|
||||
│ │ :8000 │ │ :3001 │ │ :6379 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└───────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────▼──────────────────────┐
|
||||
│ External Services │
|
||||
│ ├─ Oracle ADB (mTLS + Wallet) │
|
||||
│ ├─ OCI GenAI │
|
||||
│ └─ 기타 외부 API │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 리소스 할당 예시 (ARM64 × 2노드, 2CPU/8GB 각)
|
||||
|
||||
| 컴포넌트 | CPU request/limit | Memory request/limit |
|
||||
|----------|-------------------|----------------------|
|
||||
| Backend | 500m / 1 | 768Mi / 1536Mi |
|
||||
| Frontend | 200m / 500m | 256Mi / 512Mi |
|
||||
| Redis | 100m / 200m | 128Mi / 256Mi |
|
||||
| **합계** | **~800m** | **~1.2GB** |
|
||||
|
||||
---
|
||||
|
||||
## 3. 최초 클러스터 설정 (1회성)
|
||||
|
||||
### 3.1 Nginx Ingress Controller 설치
|
||||
|
||||
```bash
|
||||
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
|
||||
helm repo update
|
||||
|
||||
helm install ingress-nginx ingress-nginx/ingress-nginx \
|
||||
--namespace ingress-nginx \
|
||||
--create-namespace \
|
||||
--set controller.service.type=LoadBalancer \
|
||||
--set controller.service.annotations."oci\.oraclecloud\.com/load-balancer-type"="nlb" \
|
||||
--set controller.service.externalTrafficPolicy=Local
|
||||
|
||||
# NLB 외부 IP 확인 (DNS에 연결할 IP)
|
||||
kubectl get svc -n ingress-nginx ingress-nginx-controller
|
||||
```
|
||||
|
||||
> **OKE 네트워크 설정**: VCN Security List에서 NodePort 범위(30000~32767)를 NLB IP 대역에서 허용해야 함.
|
||||
|
||||
### 3.2 cert-manager 설치 (Let's Encrypt 자동 인증서)
|
||||
|
||||
```bash
|
||||
helm repo add jetstack https://charts.jetstack.io
|
||||
helm repo update
|
||||
|
||||
helm install cert-manager jetstack/cert-manager \
|
||||
--namespace cert-manager \
|
||||
--create-namespace \
|
||||
--set crds.enabled=true
|
||||
```
|
||||
|
||||
### 3.3 ClusterIssuer 생성
|
||||
|
||||
```yaml
|
||||
# k8s/cert-manager/cluster-issuer.yaml
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: your-email@example.com
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/cert-manager/cluster-issuer.yaml
|
||||
```
|
||||
|
||||
### 3.4 앱 네임스페이스 및 시크릿 생성
|
||||
|
||||
```bash
|
||||
# 네임스페이스
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
|
||||
# OCIR pull secret (이미지 다운로드용)
|
||||
kubectl create secret docker-registry ocir-secret \
|
||||
--namespace <app-namespace> \
|
||||
--docker-server=<region>.ocir.io \
|
||||
--docker-username='<namespace>/oracleidentitycloudservice/<email>' \
|
||||
--docker-password='<auth-token>'
|
||||
|
||||
# Oracle Wallet (DB mTLS 인증)
|
||||
kubectl create secret generic oracle-wallet \
|
||||
--namespace <app-namespace> \
|
||||
--from-file=cwallet.sso \
|
||||
--from-file=ewallet.p12 \
|
||||
--from-file=tnsnames.ora \
|
||||
--from-file=sqlnet.ora \
|
||||
--from-file=ojdbc.properties \
|
||||
--from-file=keystore.jks \
|
||||
--from-file=truststore.jks
|
||||
|
||||
# OCI 설정 (GenAI 등 OCI SDK 인증)
|
||||
kubectl create secret generic oci-config \
|
||||
--namespace <app-namespace> \
|
||||
--from-file=config=~/.oci/config \
|
||||
--from-file=oci_api_key.pem=~/.oci/oci_api_key.pem
|
||||
|
||||
# 앱 시크릿/설정
|
||||
kubectl apply -f k8s/secrets.yaml
|
||||
kubectl apply -f k8s/configmap.yaml
|
||||
```
|
||||
|
||||
### 3.5 앱 배포 (최초)
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/redis-deployment.yaml
|
||||
kubectl apply -f k8s/backend-deployment.yaml
|
||||
kubectl apply -f k8s/frontend-deployment.yaml
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
|
||||
# 롤아웃 확인
|
||||
kubectl rollout status deployment/backend -n <app-namespace>
|
||||
kubectl rollout status deployment/frontend -n <app-namespace>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. K8s 매니페스트 구조
|
||||
|
||||
```
|
||||
k8s/
|
||||
├── namespace.yaml # 네임스페이스 정의
|
||||
├── configmap.yaml # 비민감 설정 (Redis 호스트, API 엔드포인트 등)
|
||||
├── secrets.yaml # 민감 정보 (DB 비밀번호, API 키 등) ← .gitignore
|
||||
├── secrets.yaml.template # 시크릿 템플릿 (Git에 포함)
|
||||
├── backend-deployment.yaml # Backend Deployment + Service
|
||||
├── frontend-deployment.yaml # Frontend Deployment + Service
|
||||
├── redis-deployment.yaml # Redis Deployment + Service
|
||||
├── ingress.yaml # Ingress (라우팅 + TLS)
|
||||
└── cert-manager/
|
||||
└── cluster-issuer.yaml # Let's Encrypt ClusterIssuer
|
||||
```
|
||||
|
||||
### Backend Deployment 예시
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
namespace: <app-namespace>
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backend
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: ocir-secret
|
||||
containers:
|
||||
- name: backend
|
||||
image: <region>.ocir.io/<namespace>/<repo>/backend:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: <app>-config
|
||||
- secretRef:
|
||||
name: <app>-secrets
|
||||
volumeMounts:
|
||||
- name: oracle-wallet
|
||||
mountPath: /etc/oracle/wallet
|
||||
readOnly: true
|
||||
- name: oci-config
|
||||
mountPath: /root/.oci
|
||||
readOnly: true
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "768Mi"
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: "1536Mi"
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: 8000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: 8000
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
volumes:
|
||||
- name: oracle-wallet
|
||||
secret:
|
||||
secretName: oracle-wallet
|
||||
- name: oci-config
|
||||
secret:
|
||||
secretName: oci-config
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: backend
|
||||
namespace: <app-namespace>
|
||||
spec:
|
||||
selector:
|
||||
app: backend
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
```
|
||||
|
||||
### Ingress 예시
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: <app>-ingress
|
||||
namespace: <app-namespace>
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- www.example.com
|
||||
secretName: <app>-tls
|
||||
rules:
|
||||
- host: www.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: backend
|
||||
port:
|
||||
number: 8000
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: frontend
|
||||
port:
|
||||
number: 3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Dockerfile 작성
|
||||
|
||||
### Backend (Spring Boot 멀티스테이지)
|
||||
|
||||
```dockerfile
|
||||
# ── Build Stage ──
|
||||
FROM eclipse-temurin:21-jdk AS build
|
||||
WORKDIR /app
|
||||
|
||||
# 의존성 캐싱 (소스 변경 시에도 재사용)
|
||||
COPY gradlew settings.gradle build.gradle ./
|
||||
COPY gradle/ gradle/
|
||||
RUN chmod +x gradlew && ./gradlew dependencies --no-daemon || true
|
||||
|
||||
# 소스 복사 & 빌드
|
||||
COPY src/ src/
|
||||
RUN ./gradlew bootJar -x test --no-daemon
|
||||
|
||||
# ── Runtime Stage ──
|
||||
FROM eclipse-temurin:21-jre
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build/libs/*.jar app.jar
|
||||
EXPOSE 8000
|
||||
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
|
||||
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
|
||||
```
|
||||
|
||||
**포인트:**
|
||||
- Gradle 의존성을 먼저 복사/다운로드 → Docker 레이어 캐시 활용
|
||||
- `-XX:MaxRAMPercentage=75.0` → 컨테이너 메모리 제한의 75%를 JVM 힙으로 사용
|
||||
- 최종 이미지에 JDK 대신 JRE만 포함 → 이미지 크기 절감
|
||||
|
||||
### Frontend (Next.js standalone 멀티스테이지)
|
||||
|
||||
```dockerfile
|
||||
# ── Build Stage ──
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# 빌드 시점 환경변수 주입 (NEXT_PUBLIC_* 변수)
|
||||
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime Stage ──
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# standalone 출력 + 정적 파일 + public 복사
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
COPY --from=build /app/public ./public
|
||||
|
||||
EXPOSE 3001
|
||||
ENV PORT=3001 HOSTNAME=0.0.0.0
|
||||
CMD ["node", "server.js"]
|
||||
```
|
||||
|
||||
**포인트:**
|
||||
- `next.config.ts`에 `output: "standalone"` 필수
|
||||
- `NEXT_PUBLIC_*` 환경변수는 **빌드 시점**에만 주입 가능 (런타임 X)
|
||||
- `.next/static`과 `public/`은 standalone에 포함되지 않으므로 수동 복사
|
||||
|
||||
---
|
||||
|
||||
## 6. 배포 스크립트 (deploy.sh)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ──
|
||||
REGISTRY="<region>.ocir.io/<namespace>/<repo>"
|
||||
APP_NAMESPACE="<app-namespace>"
|
||||
PLATFORM="linux/arm64" # OKE 노드 아키텍처에 맞춤
|
||||
|
||||
# ── Parse arguments ──
|
||||
TARGET="all" # all | backend | frontend
|
||||
MESSAGE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--backend-only) TARGET="backend"; shift ;;
|
||||
--frontend-only) TARGET="frontend"; shift ;;
|
||||
--dry-run) echo "[DRY RUN]"; exit 0 ;;
|
||||
-m) MESSAGE="$2"; shift 2 ;;
|
||||
*) MESSAGE="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Auto-increment version ──
|
||||
LATEST_TAG=$(git tag --sort=-v:refname | grep '^v' | head -1 2>/dev/null || echo "v0.1.0")
|
||||
MAJOR=$(echo "$LATEST_TAG" | cut -d. -f1)
|
||||
MINOR=$(echo "$LATEST_TAG" | cut -d. -f2)
|
||||
PATCH=$(echo "$LATEST_TAG" | cut -d. -f3)
|
||||
TAG="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
|
||||
echo "━━━ Deploying ${TAG} (${TARGET}) ━━━"
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
# ── Build & Push ──
|
||||
if [[ "$TARGET" == "all" || "$TARGET" == "backend" ]]; then
|
||||
echo "▶ Building backend..."
|
||||
docker build --platform "$PLATFORM" \
|
||||
-t "$REGISTRY/backend:$TAG" \
|
||||
-t "$REGISTRY/backend:latest" \
|
||||
backend-java/
|
||||
docker push "$REGISTRY/backend:$TAG"
|
||||
docker push "$REGISTRY/backend:latest"
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
|
||||
echo "▶ Building frontend..."
|
||||
|
||||
# .env.local에서 빌드 인자 읽기
|
||||
MAPS_KEY=$(grep NEXT_PUBLIC_GOOGLE_MAPS_API_KEY frontend/.env.local 2>/dev/null | cut -d= -f2)
|
||||
CLIENT_ID=$(grep NEXT_PUBLIC_GOOGLE_CLIENT_ID frontend/.env.local 2>/dev/null | cut -d= -f2)
|
||||
|
||||
docker build --platform "$PLATFORM" \
|
||||
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$MAPS_KEY" \
|
||||
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID="$CLIENT_ID" \
|
||||
-t "$REGISTRY/frontend:$TAG" \
|
||||
-t "$REGISTRY/frontend:latest" \
|
||||
frontend/
|
||||
docker push "$REGISTRY/frontend:$TAG"
|
||||
docker push "$REGISTRY/frontend:latest"
|
||||
fi
|
||||
|
||||
# ── Rolling Update ──
|
||||
# ⚠️ kubectl 컨텍스트가 OKE를 가리키는지 반드시 확인
|
||||
if [[ "$TARGET" == "all" || "$TARGET" == "backend" ]]; then
|
||||
kubectl set image deployment/backend \
|
||||
backend="$REGISTRY/backend:$TAG" -n "$APP_NAMESPACE"
|
||||
kubectl rollout status deployment/backend -n "$APP_NAMESPACE" --timeout=180s
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" == "all" || "$TARGET" == "frontend" ]]; then
|
||||
kubectl set image deployment/frontend \
|
||||
frontend="$REGISTRY/frontend:$TAG" -n "$APP_NAMESPACE"
|
||||
kubectl rollout status deployment/frontend -n "$APP_NAMESPACE" --timeout=120s
|
||||
fi
|
||||
|
||||
# ── Git Tag ──
|
||||
git tag -a "$TAG" -m "Deploy ${TAG}: ${MESSAGE}"
|
||||
git push origin "$TAG"
|
||||
|
||||
echo "━━━ ✅ Deploy complete: ${TAG} ━━━"
|
||||
kubectl get pods -n "$APP_NAMESPACE"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 일상적인 배포 절차
|
||||
|
||||
```bash
|
||||
# 1. Colima 시작 (ARM64 Docker 빌드용, 꺼져있을 때만)
|
||||
colima start --arch aarch64 --cpu 4 --memory 4
|
||||
|
||||
# 2. kubectl 컨텍스트를 OKE로 복원 (Colima가 바꿔버림)
|
||||
kubectl config use-context <oke-context-name>
|
||||
|
||||
# 3. 배포 실행
|
||||
./deploy.sh "변경 내용 설명" # 전체 배포
|
||||
./deploy.sh --backend-only "API 수정" # 백엔드만
|
||||
./deploy.sh --frontend-only "UI 수정" # 프론트엔드만
|
||||
|
||||
# 4. 확인
|
||||
kubectl get pods -n <app-namespace>
|
||||
kubectl logs -f deployment/backend -n <app-namespace>
|
||||
|
||||
# 5. (선택) Colima 중지 (리소스 절약)
|
||||
colima stop
|
||||
```
|
||||
|
||||
### 시크릿/설정만 변경할 때
|
||||
|
||||
```bash
|
||||
# secrets.yaml 수정 후
|
||||
kubectl apply -f k8s/secrets.yaml
|
||||
|
||||
# 시크릿 변경은 Pod를 자동 재시작하지 않음 → 수동 재시작 필요
|
||||
kubectl rollout restart deployment/backend -n <app-namespace>
|
||||
```
|
||||
|
||||
### 롤백
|
||||
|
||||
```bash
|
||||
# 이전 버전으로 롤백
|
||||
kubectl rollout undo deployment/backend -n <app-namespace>
|
||||
kubectl rollout undo deployment/frontend -n <app-namespace>
|
||||
|
||||
# 특정 리비전으로 롤백
|
||||
kubectl rollout history deployment/backend -n <app-namespace>
|
||||
kubectl rollout undo deployment/backend -n <app-namespace> --to-revision=3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 환경별 설정 관리
|
||||
|
||||
### 개발(Local) vs 운영(OKE) 비교
|
||||
|
||||
| 항목 | 개발 (Local) | 운영 (OKE) |
|
||||
|------|-------------|-----------|
|
||||
| DB 설정 | `backend/.env` | K8s Secret (`secrets.yaml`) |
|
||||
| 환경변수 | 파일 기반 (`.env`, `.env.local`) | ConfigMap + Secret |
|
||||
| Oracle Wallet | 로컬 디렉토리 | K8s Secret → Volume 마운트 |
|
||||
| OCI 인증 | `~/.oci/config` | K8s Secret → Volume 마운트 |
|
||||
| Redis | 로컬 IP (`192.168.x.x`) | K8s Service DNS (`redis`) |
|
||||
| 프로세스 관리 | PM2 | K8s Deployment |
|
||||
| 프론트엔드 모드 | `npm run dev` | `node server.js` (standalone) |
|
||||
| DB 프로파일 | `_low` (리소스 절약) | `_medium` (적정 성능) |
|
||||
|
||||
### Oracle ADB 프로파일 선택
|
||||
|
||||
| 프로파일 | 병렬 처리 | 용도 |
|
||||
|---------|----------|------|
|
||||
| `_high` | 최대 | 대규모 배치/분석 |
|
||||
| `_medium` | 중간 | **운영 권장** |
|
||||
| `_low` | 최소 | **개발/테스트** (OCPU 절약) |
|
||||
| `_tp` | 트랜잭션 | OLTP 워크로드 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 도메인 및 SSL 설정
|
||||
|
||||
### DNS 설정
|
||||
|
||||
```
|
||||
# DNS Provider (예: Namecheap)에서 설정
|
||||
Type Host Value TTL
|
||||
A @ <NLB Public IP> 300
|
||||
A www <NLB Public IP> 300
|
||||
```
|
||||
|
||||
NLB Public IP 확인:
|
||||
|
||||
```bash
|
||||
kubectl get svc -n ingress-nginx ingress-nginx-controller \
|
||||
-o jsonpath='{.status.loadBalancer.ingress[0].ip}'
|
||||
```
|
||||
|
||||
### SSL 인증서
|
||||
|
||||
cert-manager + ClusterIssuer가 설정되어 있으면 Ingress에 annotation만 추가하면 자동 발급/갱신됨.
|
||||
|
||||
```yaml
|
||||
# Ingress에 추가
|
||||
metadata:
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- www.example.com
|
||||
secretName: <app>-tls # cert-manager가 자동 생성
|
||||
```
|
||||
|
||||
인증서 상태 확인:
|
||||
|
||||
```bash
|
||||
kubectl get certificate -n <app-namespace>
|
||||
kubectl describe certificate <app>-tls -n <app-namespace>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 트러블슈팅
|
||||
|
||||
### 이미지 Pull 실패 (ImagePullBackOff)
|
||||
|
||||
```bash
|
||||
kubectl describe pod <pod-name> -n <app-namespace>
|
||||
# Events 섹션에서 에러 확인
|
||||
|
||||
# 원인 1: OCIR 인증 실패 → pull secret 재생성
|
||||
kubectl delete secret ocir-secret -n <app-namespace>
|
||||
kubectl create secret docker-registry ocir-secret \
|
||||
--namespace <app-namespace> \
|
||||
--docker-server=<region>.ocir.io \
|
||||
--docker-username='<namespace>/oracleidentitycloudservice/<email>' \
|
||||
--docker-password='<auth-token>'
|
||||
|
||||
# 원인 2: 이미지가 존재하지 않음 → OCIR 콘솔에서 확인
|
||||
```
|
||||
|
||||
### OKE CRI-O: short image name 오류
|
||||
|
||||
```
|
||||
# ❌ 틀림
|
||||
image: redis:7-alpine
|
||||
|
||||
# ✅ 맞음 (docker.io/library/ 접두사 필수)
|
||||
image: docker.io/library/redis:7-alpine
|
||||
```
|
||||
|
||||
OKE는 CRI-O 런타임을 사용하며, Docker Hub의 `library/` 이미지도 전체 경로를 명시해야 함.
|
||||
|
||||
### DB 연결 실패 (HikariPool timeout)
|
||||
|
||||
```bash
|
||||
# Pod 로그 확인
|
||||
kubectl logs deployment/backend -n <app-namespace> | grep -i "hikari\|oracle\|connection"
|
||||
|
||||
# 원인 1: Wallet이 마운트되지 않음
|
||||
kubectl exec deployment/backend -n <app-namespace> -- ls /etc/oracle/wallet/
|
||||
|
||||
# 원인 2: ORACLE_DSN에 TNS_ADMIN 경로가 잘못됨
|
||||
# 형식: <tns-alias>_medium?TNS_ADMIN=/etc/oracle/wallet
|
||||
```
|
||||
|
||||
### Let's Encrypt 인증서 발급 실패
|
||||
|
||||
```bash
|
||||
# Challenge 상태 확인
|
||||
kubectl get challenges -n <app-namespace>
|
||||
kubectl describe challenge <name> -n <app-namespace>
|
||||
|
||||
# 원인: VCN Security List에서 80번 포트가 막혀있음
|
||||
# OCI Console → VCN → Security Lists → Ingress Rules에 HTTP 80 허용 추가
|
||||
```
|
||||
|
||||
### kubectl 인증 실패
|
||||
|
||||
```bash
|
||||
# kubeconfig 재생성
|
||||
oci ce cluster create-kubeconfig \
|
||||
--cluster-id <cluster-ocid> \
|
||||
--file $HOME/.kube/config \
|
||||
--region <region> \
|
||||
--token-version 2.0.0 \
|
||||
--kube-endpoint PUBLIC_ENDPOINT
|
||||
|
||||
# OCI CLI 프로파일 지정 (여러 프로파일 사용 시)
|
||||
# kubeconfig의 args에 --profile <PROFILE_NAME> 추가
|
||||
```
|
||||
|
||||
### Colima 시작 후 kubectl 안 됨
|
||||
|
||||
```bash
|
||||
# Colima가 kubectl 컨텍스트를 가져감
|
||||
kubectl config get-contexts
|
||||
kubectl config use-context <oke-context-name> # OKE로 복원
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 유용한 kubectl 명령어
|
||||
|
||||
```bash
|
||||
# ── 상태 확인 ──
|
||||
kubectl get pods -n <ns> # Pod 목록
|
||||
kubectl get pods -n <ns> -w # 실시간 감시
|
||||
kubectl get svc -n <ns> # Service 목록
|
||||
kubectl get ingress -n <ns> # Ingress 확인
|
||||
kubectl top pods -n <ns> # 리소스 사용량
|
||||
|
||||
# ── 로그 ──
|
||||
kubectl logs deployment/backend -n <ns> # 최근 로그
|
||||
kubectl logs deployment/backend -n <ns> -f # 실시간 로그
|
||||
kubectl logs deployment/backend -n <ns> --previous # 이전 Pod 로그 (크래시 시)
|
||||
|
||||
# ── 디버깅 ──
|
||||
kubectl describe pod <pod> -n <ns> # Pod 상세 (이벤트 포함)
|
||||
kubectl exec -it deployment/backend -n <ns> -- sh # Pod 접속
|
||||
kubectl port-forward svc/backend 8000:8000 -n <ns> # 로컬 포트 포워딩
|
||||
|
||||
# ── 배포 관리 ──
|
||||
kubectl rollout status deployment/backend -n <ns> # 롤아웃 상태
|
||||
kubectl rollout restart deployment/backend -n <ns> # 재시작 (설정 변경 반영)
|
||||
kubectl rollout undo deployment/backend -n <ns> # 이전 버전 롤백
|
||||
kubectl rollout history deployment/backend -n <ns> # 배포 이력
|
||||
|
||||
# ── 설정 변경 ──
|
||||
kubectl apply -f k8s/configmap.yaml # ConfigMap 업데이트
|
||||
kubectl apply -f k8s/secrets.yaml # Secret 업데이트
|
||||
kubectl edit deployment backend -n <ns> # 직접 수정 (비추천)
|
||||
|
||||
# ── 정리 ──
|
||||
kubectl delete pod <pod> -n <ns> # Pod 강제 재시작
|
||||
kubectl scale deployment/backend --replicas=0 -n <ns> # 일시 중지
|
||||
kubectl scale deployment/backend --replicas=1 -n <ns> # 복원
|
||||
```
|
||||
@@ -26,6 +26,7 @@ module.exports = {
|
||||
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
|
||||
script: "npm",
|
||||
args: "run dev",
|
||||
env: { PORT: 3001 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ RUN npm ci
|
||||
COPY . .
|
||||
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_NAVER_MAP_CLIENT_ID
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime stage ──
|
||||
|
||||
117
frontend/__tests__/RestaurantDetail.test.tsx
Normal file
117
frontend/__tests__/RestaurantDetail.test.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* #545 — 해외 식당 상세에서 테이블링/캐치테이블 예약 버튼 숨김 회귀 테스트.
|
||||
* 좌표 기반(isKoreaRestaurant) 판정으로 국내/해외 식당에서 버튼 노출 여부가
|
||||
* 올바르게 갈리는지 검증한다.
|
||||
*/
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import RestaurantDetail from "@/components/RestaurantDetail";
|
||||
import type { Restaurant } from "@/lib/api";
|
||||
|
||||
// 컴포넌트 마운트 시 발생하는 비동기 데이터 로딩(영상/리뷰 등)을 플러시해
|
||||
// act() 경고 없이 안정적으로 렌더 결과를 검증한다.
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
jest.mock("@/lib/api", () => ({
|
||||
getToken: jest.fn(() => null),
|
||||
api: {
|
||||
getRestaurantVideos: jest.fn(() => Promise.resolve([])),
|
||||
getFavoriteStatus: jest.fn(() => Promise.resolve({ favorited: false })),
|
||||
getReviews: jest.fn(() =>
|
||||
Promise.resolve({ reviews: [], avg_rating: null, review_count: 0 })
|
||||
),
|
||||
getMemo: jest.fn(() => Promise.resolve(null)),
|
||||
},
|
||||
}));
|
||||
|
||||
function makeRestaurant(overrides: Partial<Restaurant>): Restaurant {
|
||||
return {
|
||||
id: "r1",
|
||||
name: "테스트 식당",
|
||||
address: null,
|
||||
region: null,
|
||||
latitude: 37.5,
|
||||
longitude: 127.0,
|
||||
cuisine_type: null,
|
||||
price_range: null,
|
||||
google_place_id: null,
|
||||
tabling_url: null,
|
||||
catchtable_url: null,
|
||||
business_status: null,
|
||||
rating: null,
|
||||
rating_count: null,
|
||||
phone: null,
|
||||
website: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RestaurantDetail — #545 해외 식당 예약 버튼 숨김", () => {
|
||||
it("국내 식당(좌표 기반)이면 테이블링/캐치테이블 버튼이 보인다", async () => {
|
||||
const restaurant = makeRestaurant({
|
||||
latitude: 37.5665,
|
||||
longitude: 126.978, // 서울
|
||||
tabling_url: "https://tabling.example.com/1",
|
||||
catchtable_url: "https://catchtable.example.com/1",
|
||||
});
|
||||
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
|
||||
await flush();
|
||||
expect(screen.getByText("테이블링에서 줄서기")).toBeInTheDocument();
|
||||
expect(screen.getByText("캐치테이블에서 예약하기")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("해외 식당(좌표가 KR bbox 밖)이면 두 버튼 모두 숨겨진다 (대표 발견 케이스)", async () => {
|
||||
const restaurant = makeRestaurant({
|
||||
latitude: 13.7563,
|
||||
longitude: 100.5018, // 방콕
|
||||
tabling_url: "https://tabling.example.com/1",
|
||||
catchtable_url: "https://catchtable.example.com/1",
|
||||
});
|
||||
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
|
||||
await flush();
|
||||
expect(screen.queryByText("테이블링에서 줄서기")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("캐치테이블에서 예약하기")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("해외 식당이어도 예약 URL이 없으면 애초에 버튼이 없다 (회귀 없음 확인)", async () => {
|
||||
const restaurant = makeRestaurant({
|
||||
latitude: 35.6762,
|
||||
longitude: 139.6503, // 도쿄
|
||||
tabling_url: null,
|
||||
catchtable_url: null,
|
||||
});
|
||||
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
|
||||
await flush();
|
||||
expect(screen.queryByText("테이블링에서 줄서기")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("캐치테이블에서 예약하기")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("좌표 없는 구 데이터는 region으로 fallback되어 국내로 간주된다 (기존 동작 유지)", async () => {
|
||||
const restaurant = makeRestaurant({
|
||||
latitude: undefined as unknown as number,
|
||||
longitude: undefined as unknown as number,
|
||||
region: null,
|
||||
tabling_url: "https://tabling.example.com/1",
|
||||
catchtable_url: null,
|
||||
});
|
||||
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
|
||||
await flush();
|
||||
expect(screen.getByText("테이블링에서 줄서기")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("예약 URL이 'NONE' 문자열이면 국내 식당이어도 버튼이 없다 (기존 게이트 유지)", async () => {
|
||||
const restaurant = makeRestaurant({
|
||||
latitude: 37.5665,
|
||||
longitude: 126.978,
|
||||
tabling_url: "NONE",
|
||||
catchtable_url: "NONE",
|
||||
});
|
||||
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
|
||||
await flush();
|
||||
expect(screen.queryByText("테이블링에서 줄서기")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("캐치테이블에서 예약하기")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
36
frontend/__tests__/Stars.test.tsx
Normal file
36
frontend/__tests__/Stars.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* #343 — Stars 컴포넌트 렌더 테스트.
|
||||
*/
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Stars from "@/components/Stars";
|
||||
|
||||
describe("Stars", () => {
|
||||
it("renders 5 star slots", () => {
|
||||
const { container } = render(<Stars rating={3} />);
|
||||
// 빈 별 5개 (text-gray-300 클래스 갖는 span)
|
||||
const emptyStars = container.querySelectorAll("span.text-gray-300");
|
||||
expect(emptyStars.length).toBe(5);
|
||||
});
|
||||
|
||||
it("shows aria-label with rating", () => {
|
||||
render(<Stars rating={4.5} />);
|
||||
expect(screen.getByLabelText("4.5점")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clamps rating to 0~5", () => {
|
||||
render(<Stars rating={-1} />);
|
||||
expect(screen.getByLabelText("0점")).toBeInTheDocument();
|
||||
render(<Stars rating={10} />);
|
||||
expect(screen.getByLabelText("5점")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows number when showNumber + rating > 0", () => {
|
||||
const { container } = render(<Stars rating={3.5} showNumber />);
|
||||
expect(container.textContent).toContain("3.5");
|
||||
});
|
||||
|
||||
it("does not show number when rating is 0 even with showNumber", () => {
|
||||
const { container } = render(<Stars rating={0} showNumber />);
|
||||
expect(container.textContent).not.toContain("0");
|
||||
});
|
||||
});
|
||||
28
frontend/__tests__/admin-utils.test.ts
Normal file
28
frontend/__tests__/admin-utils.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* #343 — admin-utils 순수 함수 단위 테스트.
|
||||
*/
|
||||
import { getAdminToken, authHeaders } from "@/lib/admin-utils";
|
||||
|
||||
describe("admin-utils", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("getAdminToken returns null when not set", () => {
|
||||
expect(getAdminToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("getAdminToken returns stored token", () => {
|
||||
localStorage.setItem("tasteby_token", "abc123");
|
||||
expect(getAdminToken()).toBe("abc123");
|
||||
});
|
||||
|
||||
it("authHeaders is empty when no token", () => {
|
||||
expect(authHeaders()).toEqual({});
|
||||
});
|
||||
|
||||
it("authHeaders includes Bearer when token set", () => {
|
||||
localStorage.setItem("tasteby_token", "xyz");
|
||||
expect(authHeaders()).toEqual({ Authorization: "Bearer xyz" });
|
||||
});
|
||||
});
|
||||
36
frontend/__tests__/filters.test.ts
Normal file
36
frontend/__tests__/filters.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* #533 — passesMinMentions() 순수 함수 단위 테스트.
|
||||
* 설계서: docs/design/533-min-mentions-filter/README.md
|
||||
*/
|
||||
import { passesMinMentions } from "@/lib/filters";
|
||||
|
||||
describe("passesMinMentions", () => {
|
||||
it("minMentions=0(기본)이면 필터 미적용 — 항상 true", () => {
|
||||
expect(passesMinMentions(0, 0)).toBe(true);
|
||||
expect(passesMinMentions(null, 0)).toBe(true);
|
||||
expect(passesMinMentions(100, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("음수 minMentions도 필터 미적용으로 취급한다", () => {
|
||||
expect(passesMinMentions(0, -1)).toBe(true);
|
||||
});
|
||||
|
||||
it("video_count 가 minMentions 미만이면 제외(false)", () => {
|
||||
expect(passesMinMentions(1, 2)).toBe(false);
|
||||
expect(passesMinMentions(0, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("video_count 가 minMentions 와 같으면 포함(경계값 포함)", () => {
|
||||
expect(passesMinMentions(2, 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("video_count 가 minMentions 초과면 포함", () => {
|
||||
expect(passesMinMentions(5, 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("video_count 가 null/undefined(미산정)이면 0으로 간주한다", () => {
|
||||
expect(passesMinMentions(null, 2)).toBe(false);
|
||||
expect(passesMinMentions(undefined, 2)).toBe(false);
|
||||
expect(passesMinMentions(null, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
42
frontend/__tests__/i18n-config.test.ts
Normal file
42
frontend/__tests__/i18n-config.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* #343 — i18n/config 순수 함수 단위 테스트.
|
||||
*/
|
||||
import { isLocale, detectBrowserLocale, DEFAULT_LOCALE } from "@/i18n/config";
|
||||
|
||||
describe("i18n/config.isLocale", () => {
|
||||
it("returns true for supported locales", () => {
|
||||
expect(isLocale("ko")).toBe(true);
|
||||
expect(isLocale("en")).toBe(true);
|
||||
expect(isLocale("ja")).toBe(true);
|
||||
expect(isLocale("es")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unsupported / null / undefined", () => {
|
||||
expect(isLocale("fr")).toBe(false);
|
||||
expect(isLocale("zh")).toBe(false);
|
||||
expect(isLocale(null)).toBe(false);
|
||||
expect(isLocale(undefined)).toBe(false);
|
||||
expect(isLocale("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("i18n/config.detectBrowserLocale", () => {
|
||||
// jsdom의 navigator.language는 기본 'en-US'
|
||||
it("returns supported locale from navigator.language", () => {
|
||||
Object.defineProperty(navigator, "language", { value: "en-US", configurable: true });
|
||||
expect(detectBrowserLocale()).toBe("en");
|
||||
Object.defineProperty(navigator, "language", { value: "ko-KR", configurable: true });
|
||||
expect(detectBrowserLocale()).toBe("ko");
|
||||
Object.defineProperty(navigator, "language", { value: "ja", configurable: true });
|
||||
expect(detectBrowserLocale()).toBe("ja");
|
||||
Object.defineProperty(navigator, "language", { value: "es-MX", configurable: true });
|
||||
expect(detectBrowserLocale()).toBe("es");
|
||||
});
|
||||
|
||||
it("falls back to DEFAULT_LOCALE for unsupported", () => {
|
||||
Object.defineProperty(navigator, "language", { value: "fr-FR", configurable: true });
|
||||
expect(detectBrowserLocale()).toBe(DEFAULT_LOCALE);
|
||||
Object.defineProperty(navigator, "language", { value: "zh-CN", configurable: true });
|
||||
expect(detectBrowserLocale()).toBe(DEFAULT_LOCALE);
|
||||
});
|
||||
});
|
||||
44
frontend/__tests__/region.test.ts
Normal file
44
frontend/__tests__/region.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* #532 — formatRegion() 순수 함수 단위 테스트.
|
||||
* 설계서: docs/design/532-region-display/README.md
|
||||
*/
|
||||
import { formatRegion } from "@/lib/region";
|
||||
|
||||
describe("formatRegion", () => {
|
||||
it("falsy 입력은 빈 문자열", () => {
|
||||
expect(formatRegion("")).toBe("");
|
||||
expect(formatRegion(null)).toBe("");
|
||||
expect(formatRegion(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("파이프 구분 지역을 계층(breadcrumb)으로 변환한다", () => {
|
||||
expect(formatRegion("한국|서울|강남구")).toBe("한국 › 서울 › 강남구");
|
||||
});
|
||||
|
||||
it("'null' 토큰(대소문자 무시)은 제거한다", () => {
|
||||
expect(formatRegion("일본|null")).toBe("일본");
|
||||
expect(formatRegion("일본|NULL")).toBe("일본");
|
||||
});
|
||||
|
||||
it("빈 토큰은 제거한다", () => {
|
||||
expect(formatRegion("한국||관악구")).toBe("한국 › 관악구");
|
||||
});
|
||||
|
||||
it("더미 토큰 '나라'는 제거한다", () => {
|
||||
expect(formatRegion("나라|서울")).toBe("서울");
|
||||
});
|
||||
|
||||
it("단일 토큰은 그대로 반환한다", () => {
|
||||
expect(formatRegion("한국")).toBe("한국");
|
||||
});
|
||||
|
||||
it("모든 토큰이 더미/빈 값이면 빈 문자열", () => {
|
||||
expect(formatRegion("한국|")).toBe("한국");
|
||||
expect(formatRegion("|")).toBe("");
|
||||
expect(formatRegion("나라|null|")).toBe("");
|
||||
});
|
||||
|
||||
it("토큰 앞뒤 공백은 trim 된다", () => {
|
||||
expect(formatRegion(" 한국 | 서울 ")).toBe("한국 › 서울");
|
||||
});
|
||||
});
|
||||
341
frontend/docs/brand-guide.md
Normal file
341
frontend/docs/brand-guide.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Tasteby Brand & Design Guide
|
||||
|
||||
> Tasteby의 브랜드 아이덴티티, 디자인 시스템, 아이콘 정책을 정의하는 가이드 문서.
|
||||
> 최종 수정: 2026-03-14
|
||||
|
||||
---
|
||||
|
||||
## 1. 브랜드 개요
|
||||
|
||||
### 1.1 서비스 정의
|
||||
**Tasteby**는 유튜브 맛집 영상에서 레스토랑 정보를 추출하여 지도 위에 큐레이션하는 서비스.
|
||||
"유튜버의 입맛으로(Taste by)" 찾는 맛집이라는 의미를 담고 있다.
|
||||
|
||||
### 1.2 브랜드 키워드
|
||||
- **미식 큐레이션** — 단순 검색이 아닌, 유튜버가 직접 방문한 맛집을 큐레이팅
|
||||
- **신뢰** — 실제 영상 기반, 출처가 명확한 정보
|
||||
- **따뜻함** — 음식과 사람을 연결하는 따뜻한 경험
|
||||
- **프리미엄 캐주얼** — 고급스럽지만 접근하기 쉬운 톤
|
||||
|
||||
### 1.3 브랜드 보이스
|
||||
| Do | Don't |
|
||||
|----|-------|
|
||||
| 친근하고 자연스러운 한국어 | 과도한 존댓말·마케팅 투 |
|
||||
| 간결하고 핵심적인 정보 전달 | 불필요한 수식어 남발 |
|
||||
| 음식에 대한 애정이 묻어나는 톤 | 딱딱한 기술 용어 노출 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 로고
|
||||
|
||||
### 2.1 로고 구성
|
||||
Tasteby 로고는 **워드마크(Wordmark)** 형태로, 글자 "e" 안에 **위치 핀 마커**와 **스마일 곡선**이 결합되어 있다.
|
||||
|
||||
- 핀 마커 = 지도 기반 서비스
|
||||
- 스마일 = 맛있는 음식의 만족감
|
||||
- 색상: 본문 다크 그레이(`#3C3C3C`) + 핀/스마일 레드(`#E8534A`)
|
||||
|
||||
### 2.2 로고 파일
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| `logo.png` | 원본 (고해상도) |
|
||||
| `logo-200h.png` | 히어로/소개 영역 |
|
||||
| `logo-120h.png` | 헤더/네비게이션 |
|
||||
| `logo-80h.png` | 파비콘/소형 UI |
|
||||
| `logo-dark.png` | 다크 배경용 (현재 미사용) |
|
||||
| `logo-dark-120h.png` | 다크 배경 헤더용 (현재 미사용) |
|
||||
| `logo-dark-80h.png` | 다크 배경 소형 (현재 미사용) |
|
||||
|
||||
### 2.3 로고 사용 규칙
|
||||
- 최소 여백: 로고 높이의 50% 이상
|
||||
- 배경: 크림 화이트(`#FFFAF5`) 또는 순백(`#FFFFFF`) 위에 사용
|
||||
- 변형 금지: 회전, 기울임, 색상 임의 변경 불가
|
||||
- 현재 라이트 모드 전용 — 다크 모드 미지원
|
||||
|
||||
---
|
||||
|
||||
## 3. 컬러 시스템
|
||||
|
||||
### 3.1 디자인 테마: Saffron (사프란)
|
||||
따뜻한 금빛 오렌지 톤. 고급스러운 미식 큐레이션 느낌을 전달한다.
|
||||
|
||||
### 3.2 브랜드 팔레트
|
||||
CSS 변수로 `globals.css`의 `@theme inline`에 등록되어 있다.
|
||||
|
||||
| 토큰 | Hex | 용도 |
|
||||
|------|-----|------|
|
||||
| `brand-50` | `#FFF8F0` | 선택 상태 배경, 카드 hover |
|
||||
| `brand-100` | `#FFEDD5` | 연한 배경, 뱃지 배경 |
|
||||
| `brand-200` | `#FFD6A5` | 보조 배경 |
|
||||
| `brand-300` | `#FFBC72` | 호버 보더, 보조 강조 |
|
||||
| `brand-400` | `#F5A623` | 스피너, 중간 강조 |
|
||||
| `brand-500` | `#F59E3F` | Primary 라이트 |
|
||||
| `brand-600` | `#E8720C` | **Primary** — 아이콘, 버튼, 핵심 강조색 |
|
||||
| `brand-700` | `#C45A00` | Primary 다크 — 텍스트 강조 |
|
||||
| `brand-800` | `#9A4500` | 진한 강조 |
|
||||
| `brand-900` | `#6B3000` | 다크 상태 |
|
||||
| `brand-950` | `#3D1A00` | 최진한 강조 |
|
||||
|
||||
### 3.3 시맨틱 토큰
|
||||
| 토큰 | Hex | 용도 |
|
||||
|------|-----|------|
|
||||
| `background` | `#FFFAF5` | 페이지 배경 (크림 화이트) |
|
||||
| `foreground` | `#171717` | 본문 텍스트 |
|
||||
| `surface` | `#FFFFFF` | 카드/패널 배경 |
|
||||
|
||||
### 3.4 컬러 사용 원칙
|
||||
- 인터랙티브/강조 요소에는 반드시 `brand-*` 토큰 사용 (`orange-*` 금지)
|
||||
- Tailwind 기본 `gray` 팔레트는 변경하지 않음 (다크 모드 호환 문제)
|
||||
- 라이트 모드 전용 (`color-scheme: only light`)
|
||||
- 다크 모드 CSS는 코멘트 처리하여 보존 중 (향후 지원 가능성)
|
||||
|
||||
---
|
||||
|
||||
## 4. 타이포그래피
|
||||
|
||||
### 4.1 서체 스택
|
||||
```
|
||||
Pretendard Variable → Geist → system-ui → sans-serif
|
||||
```
|
||||
|
||||
| 서체 | 역할 | 로딩 방식 |
|
||||
|------|------|----------|
|
||||
| **Pretendard Variable** | 1순위 본문 서체 | 로컬 woff2 (`src/fonts/`) |
|
||||
| **Geist** | 폴백 서체 | Google Fonts (`next/font/google`) |
|
||||
|
||||
### 4.2 선택 이유
|
||||
- **Pretendard**: 한국어 가독성 최적화, 다양한 웨이트 지원, 깔끔하고 현대적
|
||||
- **Geist**: 영문/숫자 렌더링 우수, Next.js 기본 서체로 호환성 확보
|
||||
|
||||
### 4.3 사용 원칙
|
||||
- 웨이트: Regular(400), Medium(500), SemiBold(600), Bold(700) 주로 사용
|
||||
- 한글 최소 본문 크기: 14px (가독성 확보)
|
||||
- 레이블/캡션: 12px까지 허용
|
||||
- HTML lang: `ko` (한국어 기본)
|
||||
|
||||
---
|
||||
|
||||
## 5. 아이콘 시스템
|
||||
|
||||
Tasteby는 **3개의 아이콘 레이어**를 사용한다.
|
||||
|
||||
### 5.1 Material Symbols Rounded — UI 아이콘
|
||||
**용도**: 범용 UI (검색, 재생, 네비게이션, 상태 표시 등)
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 폰트 | Material Symbols Rounded (Google Fonts) |
|
||||
| 기본 설정 | FILL 0, wght 400, GRAD 0, opsz 24 |
|
||||
| 컴포넌트 | `<Icon name="search" size={20} />` |
|
||||
| 파일 | `src/components/Icon.tsx` |
|
||||
|
||||
**사용 위치**:
|
||||
- RestaurantList 음식 카테고리 아이콘
|
||||
- MapView 마커/InfoWindow 카테고리 아이콘
|
||||
- SearchBar, 네비게이션, 재생 버튼 등 모든 UI 아이콘
|
||||
- RestaurantDetail 상세 정보 아이콘
|
||||
|
||||
**주요 아이콘 매핑** (`getCuisineIcon()`):
|
||||
- 한식 → `rice_bowl`, 일식 → `set_meal`, 중식 → `skillet`
|
||||
- 양식 → `dinner_dining`, 아시아 → `restaurant`, 기타 → `flatware`
|
||||
- 소분류별 세부 매핑 (67개 규칙) — `cuisine-icons.ts` 참조
|
||||
|
||||
**스타일 규칙**:
|
||||
- 아이콘 크기와 `width`/`height`를 동일하게 설정하여 종횡비 고정
|
||||
- `overflow: hidden`으로 비정형 아이콘(예: sake) 크기 넘침 방지
|
||||
|
||||
### 5.2 Phosphor Icons — 장르 카드 픽토그램
|
||||
**용도**: 홈 탭 장르 필터 카드의 카테고리 아이콘
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 패키지 | `@phosphor-icons/react` (^2.1.10) |
|
||||
| 라이선스 | MIT |
|
||||
| 스타일 | Regular (outline) 기본 |
|
||||
| 파일 | `src/lib/cuisine-icons.ts` → `getPhosphorCuisineIcon()` |
|
||||
|
||||
**Phosphor 아이콘 매핑**:
|
||||
|
||||
| 카테고리 | 아이콘 | Phosphor 컴포넌트명 |
|
||||
|----------|--------|---------------------|
|
||||
| 한식 | 밥그릇 | `BowlFood` |
|
||||
| 일식 | 물고기 | `FishSimple` |
|
||||
| 중식 | 불꽃 | `Fire` |
|
||||
| 양식 | 피자 | `Pizza` |
|
||||
| 아시아 | 김 나는 그릇 | `BowlSteam` |
|
||||
| 기타 | 쿠키 | `Cookie` |
|
||||
|
||||
**소분류 Phosphor 매핑** (주요 항목):
|
||||
| 소분류 | 아이콘 | 비고 |
|
||||
|--------|--------|------|
|
||||
| 국밥/해장국 | `BowlSteam` | |
|
||||
| 소고기/한우구이 | `Cow` | |
|
||||
| 곱창/막창 | `Flame` | |
|
||||
| 닭/오리구이 | `Bird` | |
|
||||
| 회/횟집 | `Fish` | |
|
||||
| 해산물 | `Shrimp` | |
|
||||
| 주점/포차 | `BeerStein` | |
|
||||
| 이자카야 | `Martini` | 술잔 모양 |
|
||||
| 파인다이닝/코스 | `Champagne` | |
|
||||
| 스시/오마카세 | `Fish` | |
|
||||
| 스테이크 | `Knife` | |
|
||||
| 햄버거 | `Hamburger` | |
|
||||
| 피자 | `Pizza` | |
|
||||
| 프렌치 | `Champagne` | |
|
||||
| 마라/훠궈 | `Pepper` | |
|
||||
| 딤섬/만두 | `Egg` | |
|
||||
| 비건/샐러드 | `Leaf` | |
|
||||
| 카페/디저트 | `Coffee` | |
|
||||
| 베이커리 | `Bread` | |
|
||||
|
||||
### 5.3 Custom SVG Food Icons — 커스텀 음식 픽토그램
|
||||
**용도**: Phosphor에 적합한 아이콘이 없는 특수 음식 카테고리
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 컴포넌트 | `<FoodIcon name="noodle" size={22} />` |
|
||||
| 파일 | `src/components/FoodIcon.tsx` |
|
||||
| 접두어 | `food:` (예: `food:noodle`) |
|
||||
| 렌더링 | SVG inline, `fill` 또는 `stroke` 모드 |
|
||||
|
||||
**등록된 커스텀 아이콘**:
|
||||
|
||||
| 이름 | 대상 소분류 | viewBox | 렌더링 모드 | 출처 |
|
||||
|------|------------|---------|------------|------|
|
||||
| `jjigae` | 찌개/전골/탕 | 0 0 24 24 | stroke | 자체 제작 (뚝배기+김) |
|
||||
| `tteok` | 분식 | 0 0 24 24 | stroke | 자체 제작 (가래떡) |
|
||||
| `noodle` | 면, 라멘, 소바/우동, 베트남 | 0 0 363.674 363.674 | fill | 외부 SVG (그릇+면+젓가락) |
|
||||
| `tempura` | 텐동/튀김 | 0 0 64 64 | fill | Flaticon (텐푸라) |
|
||||
| `pig` | 삼겹살/돼지구이, 족발/보쌈, 돈카츠 | 0 0 90 90 | fill | 외부 SVG (돼지 전신) |
|
||||
|
||||
### 5.4 아이콘 선택 기준
|
||||
|
||||
```
|
||||
1단계: Phosphor Icons에 적합한 아이콘이 있는가?
|
||||
→ 있으면 Phosphor 사용
|
||||
|
||||
2단계: 없으면 커스텀 SVG(FoodIcon)로 제작/수급
|
||||
→ food: 접두어로 매핑
|
||||
|
||||
3단계: UI 범용 아이콘은 Material Symbols
|
||||
→ 검색, 네비, 상태 표시 등
|
||||
```
|
||||
|
||||
### 5.5 아이콘 제작/추가 가이드
|
||||
|
||||
**커스텀 SVG 추가 시:**
|
||||
1. `FoodIcon.tsx`의 `FOOD_ICONS` Record에 `IconDef` 추가
|
||||
2. `viewBox`는 원본 SVG에 맞게 설정 (24×24가 아닐 수 있음)
|
||||
3. `fill: true`면 fill 모드, 생략/false면 stroke 모드
|
||||
4. `cuisine-icons.ts`의 `PHOSPHOR_SUB_RULES`에서 `food:이름`으로 매핑
|
||||
5. SVG 경로는 JSX로 변환 (`clip-rule` → `clipRule` 등)
|
||||
|
||||
**라이선스 확인:**
|
||||
- Phosphor Icons: MIT ✅
|
||||
- Material Symbols: Apache 2.0 ✅
|
||||
- 외부 SVG: 출처별 라이선스 확인 필수 (Flaticon은 Attribution 필요할 수 있음)
|
||||
|
||||
---
|
||||
|
||||
## 6. 레이아웃 & 컴포넌트 스타일
|
||||
|
||||
### 6.1 홈 탭 구성
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [로고] [검색] [유튜버▼] │ ← 헤더
|
||||
├─────────────────────────────────────┤
|
||||
│ [한식][일식][중식][양식]... →scroll │ ← 장르 카드 (가로 스크롤+드래그)
|
||||
│ [가격▼][지역▼][내위치][N개 결과] │ ← 필터 바 (flex-wrap)
|
||||
├─────────────────────────────────────┤
|
||||
│ 지도 / 리스트 / 근처 탭 │ ← 메인 콘텐츠
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 장르 카드 스타일
|
||||
- 칩(chip) 형태, 가로 스크롤 + 마우스 드래그 지원 (`useDragScroll`)
|
||||
- 아이콘(22px) + 텍스트 라벨
|
||||
- 선택 시: `bg-brand-50 border-brand-300 text-brand-700`
|
||||
- 비선택 시: `bg-white border-gray-200 text-gray-600`
|
||||
- 아이콘 색상: 선택 시 `brand-600`, 비선택 시 `gray-400`
|
||||
|
||||
### 6.3 카드/서피스
|
||||
- 배경: `surface` (#FFFFFF)
|
||||
- 보더: `border-gray-200` (1px)
|
||||
- 라운딩: `rounded-lg` (8px) 기본
|
||||
- 그림자: 최소한 사용 (flat 디자인 지향)
|
||||
|
||||
### 6.4 버튼/인터랙티브
|
||||
- Primary: `bg-brand-600 text-white hover:bg-brand-700`
|
||||
- Secondary: `bg-brand-50 text-brand-700 border-brand-300`
|
||||
- 포커스 링: `ring-brand-400`
|
||||
|
||||
---
|
||||
|
||||
## 7. 지도 마커 스타일
|
||||
|
||||
### 7.1 마커 디자인
|
||||
텍스트 라벨 마커 (핀이 아닌 말풍선 형태):
|
||||
- 배경: 흰색 (폐업 시 회색)
|
||||
- 보더: 카테고리별 색상
|
||||
- 텍스트: 레스토랑 이름 + 카테고리 아이콘 (Material Symbols)
|
||||
- 하단 꼬리(arrow): 삼각형
|
||||
- 선택 시: 파란색(`#1d4ed8`) 보더+꼬리
|
||||
|
||||
### 7.2 마커 카테고리 색상
|
||||
| 카테고리 | 보더 | 배경 | 화살표 |
|
||||
|----------|------|------|--------|
|
||||
| 한식 | `#E8720C` | `#FFF8F0` | `#E8720C` |
|
||||
| 일식 | `#D94F5A` | `#FFF5F5` | `#D94F5A` |
|
||||
| 기타 | 기본 그레이 | 기본 화이트 | 기본 그레이 |
|
||||
|
||||
### 7.3 클러스터링
|
||||
- 라이브러리: supercluster
|
||||
- 클러스터 마커: 원형 + 개수 텍스트
|
||||
|
||||
---
|
||||
|
||||
## 8. 향후 방향
|
||||
|
||||
### 8.1 다크 모드
|
||||
- 현재 라이트 모드 전용
|
||||
- 다크 모드 CSS 토큰은 정의되어 있으나 비활성 상태
|
||||
- 다크 배경 로고 에셋 준비 완료 (`logo-dark-*.png`)
|
||||
- 활성화 시 Tailwind 기본 gray 팔레트 유지 (커스텀 gray 금지)
|
||||
|
||||
### 8.2 아이콘 정리
|
||||
- `@tabler/icons-react` 의존성 제거 가능 (현재 미사용)
|
||||
- 커스텀 SVG 아이콘 추가 확장 가능 (소고기/해산물 등)
|
||||
- Flaticon 에셋 사용 시 Attribution 라이선스 확인 필요
|
||||
|
||||
### 8.3 디자인 토큰 확장
|
||||
- 현재: 색상 + 폰트 토큰만 정의
|
||||
- 향후: spacing, radius, shadow, motion 토큰 추가 가능
|
||||
- Tailwind v4 `@theme` 기반으로 확장
|
||||
|
||||
---
|
||||
|
||||
## 부록: 파일 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── docs/
|
||||
│ ├── brand-guide.md ← 이 문서
|
||||
│ └── design-concepts.md ← 초기 컨셉 후보안
|
||||
├── public/
|
||||
│ ├── logo.png ← 로고 에셋들
|
||||
│ ├── logo-{80h,120h,200h}.png
|
||||
│ ├── logo-dark.png
|
||||
│ └── logo-dark-{80h,120h}.png
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── globals.css ← 디자인 토큰 (@theme inline)
|
||||
│ │ ├── layout.tsx ← 폰트, 메타 설정
|
||||
│ │ └── page.tsx ← 홈 탭 (장르 카드 렌더링)
|
||||
│ ├── components/
|
||||
│ │ ├── Icon.tsx ← Material Symbols 래퍼
|
||||
│ │ └── FoodIcon.tsx ← 커스텀 SVG 아이콘
|
||||
│ ├── fonts/
|
||||
│ │ └── PretendardVariable.woff2
|
||||
│ └── lib/
|
||||
│ └── cuisine-icons.ts ← 아이콘 매핑 (Material + Phosphor + Food)
|
||||
```
|
||||
21
frontend/jest.config.ts
Normal file
21
frontend/jest.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// #343 — Jest 설정. next/jest로 SWC 자동 통합.
|
||||
|
||||
import type { Config } from "jest";
|
||||
import nextJest from "next/jest.js";
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// 테스트 환경의 Next.js 앱 루트
|
||||
dir: "./",
|
||||
});
|
||||
|
||||
const customConfig: Config = {
|
||||
// jest-dom matchers는 setupFilesAfterEnv로 등록 (Jest framework 로드 후)
|
||||
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
|
||||
testEnvironment: "jsdom",
|
||||
moduleNameMapper: {
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
},
|
||||
testPathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/"],
|
||||
};
|
||||
|
||||
export default createJestConfig(customConfig);
|
||||
2
frontend/jest.setup.ts
Normal file
2
frontend/jest.setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// #343 — Jest setup. @testing-library/jest-dom matchers 확장.
|
||||
import "@testing-library/jest-dom";
|
||||
@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// #343 — 외부 이미지 도메인 허용 (next/image)
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "lh3.googleusercontent.com" }, // Google avatar
|
||||
{ protocol: "https", hostname: "i.ytimg.com" }, // YouTube thumbnail
|
||||
{ protocol: "https", hostname: "yt3.ggpht.com" }, // YouTube channel avatar
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
6407
frontend/package-lock.json
generated
6407
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,25 +6,35 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@react-oauth/google": "^0.13.4",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"@vis.gl/react-google-maps": "^1.7.1",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "^4.13.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"supercluster": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jest": "^30.4.2",
|
||||
"jest-environment-jsdom": "^30.4.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
222
frontend/src/app/admin/_panels/ChannelsPanel.tsx
Normal file
222
frontend/src/app/admin/_panels/ChannelsPanel.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Channel } from "@/lib/api";
|
||||
|
||||
// #329 — admin/page.tsx에서 추출 (내부 로직 그대로)
|
||||
export function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [newId, setNewId] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newFilter, setNewFilter] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.getChannels().then(setChannels).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newId.trim() || !newName.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.addChannel(newId.trim(), newName.trim(), newFilter.trim() || undefined);
|
||||
setNewId("");
|
||||
setNewName("");
|
||||
setNewFilter("");
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : "채널 추가 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||
const [editDesc, setEditDesc] = useState("");
|
||||
const [editTags, setEditTags] = useState("");
|
||||
const [editOrder, setEditOrder] = useState<number>(99);
|
||||
|
||||
const handleSaveChannel = async (id: string) => {
|
||||
try {
|
||||
await api.updateChannel(id, { description: editDesc, tags: editTags, sort_order: editOrder });
|
||||
setEditingChannel(null);
|
||||
load();
|
||||
} catch {
|
||||
alert("채널 수정 실패");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (channelId: string, channelName: string) => {
|
||||
if (!confirm(`"${channelName}" 채널을 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
await api.deleteChannel(channelId);
|
||||
load();
|
||||
} catch {
|
||||
alert("채널 삭제 실패");
|
||||
}
|
||||
};
|
||||
|
||||
const handleScan = async (channelId: string, full: boolean = false) => {
|
||||
setScanResult((prev) => ({ ...prev, [channelId]: full ? "전체 스캔 중..." : "스캔 중..." }));
|
||||
try {
|
||||
const res = await api.scanChannel(channelId, full);
|
||||
setScanResult((prev) => ({
|
||||
...prev,
|
||||
[channelId]: `${res.total_fetched}개 조회, ${res.new_videos}개 신규${(res as Record<string, unknown>).filtered ? `, ${(res as Record<string, unknown>).filtered}개 필터링` : ""}`,
|
||||
}));
|
||||
} catch {
|
||||
setScanResult((prev) => ({ ...prev, [channelId]: "스캔 실패" }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isAdmin && <div className="bg-surface rounded-lg shadow p-4 mb-6">
|
||||
<h2 className="font-semibold mb-3">채널 추가</h2>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
placeholder="YouTube Channel ID"
|
||||
value={newId}
|
||||
onChange={(e) => setNewId(e.target.value)}
|
||||
className="border rounded px-3 py-2 flex-1 text-sm bg-surface text-gray-900"
|
||||
/>
|
||||
<input
|
||||
placeholder="채널 이름"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="border rounded px-3 py-2 flex-1 text-sm bg-surface text-gray-900"
|
||||
/>
|
||||
<input
|
||||
placeholder="제목 필터 (선택)"
|
||||
value={newFilter}
|
||||
onChange={(e) => setNewFilter(e.target.value)}
|
||||
className="border rounded px-3 py-2 w-40 text-sm bg-surface text-gray-900"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={loading}
|
||||
className="bg-brand-600 text-white px-4 py-2 rounded text-sm hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="bg-surface rounded-lg shadow">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">채널 이름</th>
|
||||
<th className="text-left px-4 py-3">Channel ID</th>
|
||||
<th className="text-left px-4 py-3">제목 필터</th>
|
||||
<th className="text-left px-4 py-3">설명</th>
|
||||
<th className="text-left px-4 py-3">태그</th>
|
||||
<th className="text-center px-4 py-3">순서</th>
|
||||
<th className="text-right px-4 py-3">영상 수</th>
|
||||
{isAdmin && <th className="text-left px-4 py-3">액션</th>}
|
||||
<th className="text-left px-4 py-3">스캔 결과</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{channels.map((ch) => (
|
||||
<tr key={ch.id} className="border-b hover:bg-brand-50/50">
|
||||
<td className="px-4 py-3 font-medium">{ch.channel_name}</td>
|
||||
<td className="px-4 py-3 text-gray-500 font-mono text-xs">
|
||||
{ch.channel_id}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{ch.title_filter ? (
|
||||
<span className="px-2 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">
|
||||
{ch.title_filter}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">전체</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{editingChannel === ch.id ? (
|
||||
<input value={editDesc} onChange={(e) => setEditDesc(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs w-32 bg-surface text-gray-900" placeholder="설명" />
|
||||
) : (
|
||||
<span className="text-gray-600 cursor-pointer" onClick={() => {
|
||||
if (!isAdmin) return;
|
||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99);
|
||||
}}>{ch.description || <span className="text-gray-400">-</span>}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{editingChannel === ch.id ? (
|
||||
<div className="flex gap-1">
|
||||
<input value={editTags} onChange={(e) => setEditTags(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs w-40 bg-surface text-gray-900" placeholder="태그 (쉼표 구분)" />
|
||||
<button onClick={() => handleSaveChannel(ch.id)} className="text-brand-600 text-xs hover:underline">저장</button>
|
||||
<button onClick={() => setEditingChannel(null)} className="text-gray-400 text-xs hover:underline">취소</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 cursor-pointer" onClick={() => {
|
||||
if (!isAdmin) return;
|
||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99);
|
||||
}}>{ch.tags ? ch.tags.split(",").map(t => t.trim()).join(", ") : <span className="text-gray-400">-</span>}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-xs">
|
||||
{editingChannel === ch.id ? (
|
||||
<input type="number" value={editOrder} onChange={(e) => setEditOrder(Number(e.target.value))}
|
||||
className="border rounded px-2 py-1 text-xs w-14 text-center bg-surface text-gray-900" min={1} />
|
||||
) : (
|
||||
<span className="text-gray-500">{ch.sort_order ?? 99}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium">
|
||||
{ch.video_count > 0 ? (
|
||||
<span className="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs">{ch.video_count}개</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">0</span>
|
||||
)}
|
||||
</td>
|
||||
{isAdmin && <td className="px-4 py-3 flex gap-3">
|
||||
<button
|
||||
onClick={() => handleScan(ch.channel_id)}
|
||||
className="text-brand-600 hover:underline text-sm"
|
||||
>
|
||||
스캔
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleScan(ch.channel_id, true)}
|
||||
className="text-purple-600 hover:underline text-sm"
|
||||
>
|
||||
전체 스캔
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(ch.id, ch.channel_name)}
|
||||
className="text-red-500 hover:underline text-sm"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</td>}
|
||||
<td className="px-4 py-3 text-gray-600">
|
||||
{scanResult[ch.channel_id] || "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{channels.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
등록된 채널이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 영상 관리 ─── */
|
||||
type VideoSortKey = "status" | "channel_name" | "title" | "published_at";
|
||||
|
||||
231
frontend/src/app/admin/_panels/DaemonPanel.tsx
Normal file
231
frontend/src/app/admin/_panels/DaemonPanel.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { DaemonConfig } from "@/lib/api";
|
||||
|
||||
// #329 — admin/page.tsx에서 추출
|
||||
export function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [config, setConfig] = useState<DaemonConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [running, setRunning] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
// Editable fields
|
||||
const [scanEnabled, setScanEnabled] = useState(false);
|
||||
const [scanInterval, setScanInterval] = useState(60);
|
||||
const [processEnabled, setProcessEnabled] = useState(false);
|
||||
const [processInterval, setProcessInterval] = useState(60);
|
||||
const [processLimit, setProcessLimit] = useState(10);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api.getDaemonConfig().then((cfg) => {
|
||||
setConfig(cfg);
|
||||
setScanEnabled(cfg.scan_enabled);
|
||||
setScanInterval(cfg.scan_interval_min);
|
||||
setProcessEnabled(cfg.process_enabled);
|
||||
setProcessInterval(cfg.process_interval_min);
|
||||
setProcessLimit(cfg.process_limit);
|
||||
}).catch(console.error).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setResult(null);
|
||||
try {
|
||||
await api.updateDaemonConfig({
|
||||
scan_enabled: scanEnabled,
|
||||
scan_interval_min: scanInterval,
|
||||
process_enabled: processEnabled,
|
||||
process_interval_min: processInterval,
|
||||
process_limit: processLimit,
|
||||
});
|
||||
setResult("설정 저장 완료");
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
setResult(e instanceof Error ? e.message : "저장 실패");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunScan = async () => {
|
||||
setRunning("scan");
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await api.runDaemonScan();
|
||||
setResult(`채널 스캔 완료: 신규 ${res.new_videos}개 영상`);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
setResult(e instanceof Error ? e.message : "스캔 실패");
|
||||
} finally {
|
||||
setRunning(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunProcess = async () => {
|
||||
setRunning("process");
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await api.runDaemonProcess(processLimit);
|
||||
setResult(`영상 처리 완료: ${res.restaurants_extracted}개 식당 추출`);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
setResult(e instanceof Error ? e.message : "처리 실패");
|
||||
} finally {
|
||||
setRunning(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-gray-500">로딩 중...</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Schedule Config */}
|
||||
<div className="bg-surface rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">스케줄 설정</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
데몬이 실행 중일 때, 아래 설정에 따라 자동으로 채널 스캔 및 영상 처리를 수행합니다.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Scan config */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">채널 스캔</h3>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scanEnabled}
|
||||
onChange={(e) => setScanEnabled(e.target.checked)}
|
||||
disabled={!isAdmin}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className={`text-sm ${scanEnabled ? "text-green-600 font-medium" : "text-gray-500"}`}>
|
||||
{scanEnabled ? "활성" : "비활성"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">주기 (분)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={scanInterval}
|
||||
onChange={(e) => setScanInterval(Number(e.target.value))}
|
||||
disabled={!isAdmin}
|
||||
min={1}
|
||||
className="border rounded px-3 py-1.5 text-sm w-32"
|
||||
/>
|
||||
</div>
|
||||
{config?.last_scan_at && (
|
||||
<p className="text-xs text-gray-400">마지막 스캔: {config.last_scan_at}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Process config */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">영상 처리</h3>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={processEnabled}
|
||||
onChange={(e) => setProcessEnabled(e.target.checked)}
|
||||
disabled={!isAdmin}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className={`text-sm ${processEnabled ? "text-green-600 font-medium" : "text-gray-500"}`}>
|
||||
{processEnabled ? "활성" : "비활성"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">주기 (분)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={processInterval}
|
||||
onChange={(e) => setProcessInterval(Number(e.target.value))}
|
||||
disabled={!isAdmin}
|
||||
min={1}
|
||||
className="border rounded px-3 py-1.5 text-sm w-32"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">처리 건수</label>
|
||||
<input
|
||||
type="number"
|
||||
value={processLimit}
|
||||
onChange={(e) => setProcessLimit(Number(e.target.value))}
|
||||
disabled={!isAdmin}
|
||||
min={1}
|
||||
max={50}
|
||||
className="border rounded px-3 py-1.5 text-sm w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{config?.last_process_at && (
|
||||
<p className="text-xs text-gray-400">마지막 처리: {config.last_process_at}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-brand-600 text-white text-sm rounded hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "저장 중..." : "설정 저장"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Triggers */}
|
||||
<div className="bg-surface rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">수동 실행</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
스케줄과 관계없이 즉시 실행합니다. 처리 시간이 걸릴 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleRunScan}
|
||||
disabled={running !== null}
|
||||
className="px-4 py-2 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{running === "scan" ? "스캔 중..." : "채널 스캔 실행"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRunProcess}
|
||||
disabled={running !== null}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm rounded hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{running === "process" ? "처리 중..." : "영상 처리 실행"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<p className={`mt-3 text-sm ${result.includes("실패") || result.includes("API") ? "text-red-600" : "text-green-600"}`}>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config updated_at */}
|
||||
{config?.updated_at && (
|
||||
<p className="text-xs text-gray-400 text-right">설정 수정일: {config.updated_at}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
654
frontend/src/app/admin/_panels/RestaurantsPanel.tsx
Normal file
654
frontend/src/app/admin/_panels/RestaurantsPanel.tsx
Normal file
@@ -0,0 +1,654 @@
|
||||
"use client";
|
||||
import { getAdminToken, consumeSseStream } from "@/lib/admin-utils";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Restaurant, VideoLink } from "@/lib/api";
|
||||
|
||||
// #329 — admin/page.tsx에서 추출
|
||||
export function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [restaurants, setRestaurants] = useState<Restaurant[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const [nameSearch, setNameSearch] = useState("");
|
||||
const [selected, setSelected] = useState<Restaurant | null>(null);
|
||||
const [editForm, setEditForm] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [videos, setVideos] = useState<VideoLink[]>([]);
|
||||
const [tablingSearching, setTablingSearching] = useState(false);
|
||||
const [bulkTabling, setBulkTabling] = useState(false);
|
||||
const [bulkTablingProgress, setBulkTablingProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 });
|
||||
const [catchtableSearching, setCatchtableSearching] = useState(false);
|
||||
const [bulkCatchtable, setBulkCatchtable] = useState(false);
|
||||
const [bulkCatchtableProgress, setBulkCatchtableProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 });
|
||||
type RestSortKey = "name" | "region" | "cuisine_type" | "price_range" | "rating" | "business_status";
|
||||
const [sortKey, setSortKey] = useState<RestSortKey>("name");
|
||||
const [sortAsc, setSortAsc] = useState(true);
|
||||
const perPage = 20;
|
||||
|
||||
const handleSort = (key: RestSortKey) => {
|
||||
if (sortKey === key) setSortAsc(!sortAsc);
|
||||
else { setSortKey(key); setSortAsc(true); }
|
||||
};
|
||||
const sortIcon = (key: RestSortKey) => sortKey !== key ? " ↕" : sortAsc ? " ↑" : " ↓";
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.getRestaurants({ limit: 500 })
|
||||
.then(setRestaurants)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// #322/#323 LLM 검증 UI
|
||||
const [verifyPending, setVerifyPending] = useState<number | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [verifyResult, setVerifyResult] = useState<string | null>(null);
|
||||
|
||||
const loadVerifyPending = useCallback(() => {
|
||||
api.getVerifyPending().then((r) => setVerifyPending(r.pending)).catch(() => setVerifyPending(null));
|
||||
}, []);
|
||||
useEffect(() => { loadVerifyPending(); }, [loadVerifyPending]);
|
||||
|
||||
const handleVerifyAll = async () => {
|
||||
if (!isAdmin) return;
|
||||
if (!confirm(`미검증 식당 ${verifyPending ?? "?"}건을 LLM으로 일괄 검증합니다.\n잘못 등록된 데이터/프랜차이즈를 자동으로 숨김 처리합니다.\n진행할까요?`)) return;
|
||||
setVerifying(true);
|
||||
setVerifyResult(null);
|
||||
try {
|
||||
const r = await api.verifyAll(10);
|
||||
setVerifyResult(`${r.processed}건 검증 완료`);
|
||||
loadVerifyPending();
|
||||
load();
|
||||
} catch (e) {
|
||||
setVerifyResult(`실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleHidden = async (r: Restaurant) => {
|
||||
if (!isAdmin) return;
|
||||
const becomingHidden = !r.hidden;
|
||||
const reason = becomingHidden ? (prompt("숨김 사유(선택)", "manual") ?? "manual") : "";
|
||||
try {
|
||||
await api.setRestaurantHidden(r.id, becomingHidden, reason || "manual");
|
||||
load();
|
||||
} catch (e) {
|
||||
alert(`실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = restaurants.filter((r) => {
|
||||
if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
let av: string | number = a[sortKey] ?? "";
|
||||
let bv: string | number = b[sortKey] ?? "";
|
||||
if (sortKey === "rating") {
|
||||
av = a.rating ?? 0;
|
||||
bv = b.rating ?? 0;
|
||||
}
|
||||
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / perPage));
|
||||
const paged = sorted.slice(page * perPage, (page + 1) * perPage);
|
||||
|
||||
const handleSelect = (r: Restaurant) => {
|
||||
if (selected?.id === r.id) {
|
||||
setSelected(null);
|
||||
setVideos([]);
|
||||
return;
|
||||
}
|
||||
setSelected(r);
|
||||
api.getRestaurantVideos(r.id).then(setVideos).catch(() => setVideos([]));
|
||||
setEditForm({
|
||||
name: r.name || "",
|
||||
address: r.address || "",
|
||||
region: r.region || "",
|
||||
cuisine_type: r.cuisine_type || "",
|
||||
price_range: r.price_range || "",
|
||||
phone: r.phone || "",
|
||||
website: r.website || "",
|
||||
latitude: r.latitude != null ? String(r.latitude) : "",
|
||||
longitude: r.longitude != null ? String(r.longitude) : "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selected) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const data: Record<string, unknown> = { ...editForm };
|
||||
if (editForm.latitude) data.latitude = parseFloat(editForm.latitude);
|
||||
else data.latitude = null;
|
||||
if (editForm.longitude) data.longitude = parseFloat(editForm.longitude);
|
||||
else data.longitude = null;
|
||||
await api.updateRestaurant(selected.id, data as Partial<Restaurant>);
|
||||
load();
|
||||
setSelected(null);
|
||||
} catch (e) {
|
||||
alert("저장 실패: " + (e instanceof Error ? e.message : String(e)));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selected) return;
|
||||
if (!confirm(`"${selected.name}" 식당을 삭제하시겠습니까?\n연결된 영상 매핑, 리뷰, 벡터도 함께 삭제됩니다.`)) return;
|
||||
try {
|
||||
await api.deleteRestaurant(selected.id);
|
||||
setSelected(null);
|
||||
load();
|
||||
} catch {
|
||||
alert("삭제 실패");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="식당 이름 검색..."
|
||||
value={nameSearch}
|
||||
onChange={(e) => { setNameSearch(e.target.value); setPage(0); }}
|
||||
onKeyDown={(e) => e.key === "Escape" && setNameSearch("")}
|
||||
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-surface text-gray-900"
|
||||
/>
|
||||
{nameSearch ? (
|
||||
<button
|
||||
onClick={() => { setNameSearch(""); setPage(0); }}
|
||||
className="border rounded-r px-2 py-2 text-sm text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => load()}
|
||||
className="border rounded-r px-2 py-2 text-sm text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||
title="새로고침"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (<>
|
||||
{/* #322/#323 — LLM 검증 */}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>미검증 {verifyPending ?? "?"}건</span>
|
||||
<button
|
||||
onClick={handleVerifyAll}
|
||||
disabled={verifying || verifyPending === 0}
|
||||
className="px-3 py-1 text-xs rounded bg-amber-500 hover:bg-amber-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{verifying ? "검증 중..." : "LLM 검증"}
|
||||
</button>
|
||||
{verifyResult && <span className="text-amber-600">{verifyResult}</span>}
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const pending = await fetch(`/api/restaurants/tabling-pending`, {
|
||||
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||
}).then(r => r.json());
|
||||
if (pending.count === 0) { alert("테이블링 미연결 식당이 없습니다"); return; }
|
||||
if (!confirm(`테이블링 미연결 식당 ${pending.count}개를 벌크 검색합니다.\n식당당 5~15초 소요됩니다. 진행할까요?`)) return;
|
||||
setBulkTabling(true);
|
||||
setBulkTablingProgress({ current: 0, total: pending.count, name: "", linked: 0, notFound: 0 });
|
||||
try {
|
||||
const res = await fetch("/api/restaurants/bulk-tabling", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||
});
|
||||
// #351 — consumeSseStream으로 통일
|
||||
await consumeSseStream(res, (raw) => {
|
||||
const evt = raw as { type: string; [k: string]: unknown };
|
||||
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
|
||||
setBulkTablingProgress(p => ({
|
||||
...p, current: evt.current as number, total: (evt.total as number) || p.total, name: evt.name as string,
|
||||
linked: evt.type === "done" ? p.linked + 1 : p.linked,
|
||||
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
|
||||
}));
|
||||
} else if (evt.type === "complete") {
|
||||
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}개`);
|
||||
}
|
||||
});
|
||||
} catch (e) { alert("벌크 테이블링 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||
finally { setBulkTabling(false); load(); }
|
||||
}}
|
||||
disabled={bulkTabling}
|
||||
className="px-3 py-1.5 text-xs bg-brand-500 text-white rounded hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
{bulkTabling ? `테이블링 검색 중 (${bulkTablingProgress.current}/${bulkTablingProgress.total})` : "벌크 테이블링 연결"}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm("테이블링 매핑을 전부 초기화하시겠습니까?")) return;
|
||||
try {
|
||||
await fetch("/api/restaurants/reset-tabling", {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||
});
|
||||
alert("테이블링 매핑 초기화 완료");
|
||||
load();
|
||||
} catch (e) { alert("실패: " + e); }
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100"
|
||||
>
|
||||
테이블링 초기화
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm("캐치테이블 매핑을 전부 초기화하시겠습니까?")) return;
|
||||
try {
|
||||
await fetch("/api/restaurants/reset-catchtable", {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||
});
|
||||
alert("캐치테이블 매핑 초기화 완료");
|
||||
load();
|
||||
} catch (e) { alert("실패: " + e); }
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100"
|
||||
>
|
||||
캐치테이블 초기화
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const pending = await fetch(`/api/restaurants/catchtable-pending`, {
|
||||
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||
}).then(r => r.json());
|
||||
if (pending.count === 0) { alert("캐치테이블 미연결 식당이 없습니다"); return; }
|
||||
if (!confirm(`캐치테이블 미연결 식당 ${pending.count}개를 벌크 검색합니다.\n식당당 5~15초 소요됩니다. 진행할까요?`)) return;
|
||||
setBulkCatchtable(true);
|
||||
setBulkCatchtableProgress({ current: 0, total: pending.count, name: "", linked: 0, notFound: 0 });
|
||||
try {
|
||||
const res = await fetch("/api/restaurants/bulk-catchtable", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getAdminToken()}` },
|
||||
});
|
||||
// #351 — consumeSseStream으로 통일
|
||||
await consumeSseStream(res, (raw) => {
|
||||
const evt = raw as { type: string; [k: string]: unknown };
|
||||
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
|
||||
setBulkCatchtableProgress(p => ({
|
||||
...p, current: evt.current as number, total: (evt.total as number) || p.total, name: evt.name as string,
|
||||
linked: evt.type === "done" ? p.linked + 1 : p.linked,
|
||||
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
|
||||
}));
|
||||
} else if (evt.type === "complete") {
|
||||
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}개`);
|
||||
}
|
||||
});
|
||||
} catch (e) { alert("벌크 캐치테이블 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||
finally { setBulkCatchtable(false); load(); }
|
||||
}}
|
||||
disabled={bulkCatchtable}
|
||||
className="px-3 py-1.5 text-xs bg-violet-500 text-white rounded hover:bg-violet-600 disabled:opacity-50"
|
||||
>
|
||||
{bulkCatchtable ? `캐치테이블 검색 중 (${bulkCatchtableProgress.current}/${bulkCatchtableProgress.total})` : "벌크 캐치테이블 연결"}
|
||||
</button>
|
||||
</>)}
|
||||
<span className="text-sm text-gray-400 ml-auto">
|
||||
{nameSearch ? `${sorted.length} / ` : ""}총 {restaurants.length}개 식당
|
||||
</span>
|
||||
</div>
|
||||
{bulkTabling && bulkTablingProgress.name && (
|
||||
<div className="bg-brand-50 rounded p-3 mb-4 text-sm">
|
||||
<div className="flex justify-between mb-1">
|
||||
<span>{bulkTablingProgress.current}/{bulkTablingProgress.total} - {bulkTablingProgress.name}</span>
|
||||
<span className="text-xs text-gray-500">연결: {bulkTablingProgress.linked} / 미발견: {bulkTablingProgress.notFound}</span>
|
||||
</div>
|
||||
<div className="w-full bg-brand-200 rounded-full h-1.5">
|
||||
<div className="bg-brand-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkTablingProgress.current / bulkTablingProgress.total) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bulkCatchtable && bulkCatchtableProgress.name && (
|
||||
<div className="bg-violet-50 rounded p-3 mb-4 text-sm">
|
||||
<div className="flex justify-between mb-1">
|
||||
<span>{bulkCatchtableProgress.current}/{bulkCatchtableProgress.total} - {bulkCatchtableProgress.name}</span>
|
||||
<span className="text-xs text-gray-500">연결: {bulkCatchtableProgress.linked} / 미발견: {bulkCatchtableProgress.notFound}</span>
|
||||
</div>
|
||||
<div className="w-full bg-violet-200 rounded-full h-1.5">
|
||||
<div className="bg-violet-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkCatchtableProgress.current / bulkCatchtableProgress.total) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-surface rounded-lg shadow overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("name")}>이름{sortIcon("name")}</th>
|
||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("region")}>지역{sortIcon("region")}</th>
|
||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("cuisine_type")}>음식 종류{sortIcon("cuisine_type")}</th>
|
||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("price_range")}>가격대{sortIcon("price_range")}</th>
|
||||
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("rating")}>평점{sortIcon("rating")}</th>
|
||||
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("business_status")}>상태{sortIcon("business_status")}</th>
|
||||
<th className="text-center px-4 py-3">검증</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
onClick={() => handleSelect(r)}
|
||||
className={`border-b cursor-pointer hover:bg-brand-50/50 ${selected?.id === r.id ? "bg-brand-50" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">{r.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600 text-xs">{r.region || "-"}</td>
|
||||
<td className="px-4 py-3 text-gray-600 text-xs">{r.cuisine_type || "-"}</td>
|
||||
<td className="px-4 py-3 text-gray-600 text-xs">{r.price_range || "-"}</td>
|
||||
<td className="px-4 py-3 text-center text-xs">
|
||||
{r.rating ? (
|
||||
<span><span className="text-yellow-500">★</span> {r.rating}</span>
|
||||
) : "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{r.business_status === "CLOSED_PERMANENTLY" ? (
|
||||
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
||||
) : r.business_status === "CLOSED_TEMPORARILY" ? (
|
||||
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold">임시휴업</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{r.hidden ? (
|
||||
<button
|
||||
onClick={() => handleToggleHidden(r)}
|
||||
disabled={!isAdmin}
|
||||
title={r.hidden_reason || "manual"}
|
||||
className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold hover:bg-red-200 disabled:opacity-50"
|
||||
>
|
||||
숨김 {r.hidden_reason ? `(${r.hidden_reason.slice(0, 12)})` : ""}
|
||||
</button>
|
||||
) : r.verified_at ? (
|
||||
<button
|
||||
onClick={() => handleToggleHidden(r)}
|
||||
disabled={!isAdmin}
|
||||
title="검증 통과 — 클릭하면 숨김"
|
||||
className="px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px] font-semibold hover:bg-green-200 disabled:opacity-50"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">미검증</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
식당 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button onClick={() => setPage(0)} disabled={page === 0} className="px-3 py-1 rounded border text-sm disabled:opacity-30 hover:bg-gray-100">«</button>
|
||||
<button onClick={() => setPage(page - 1)} disabled={page === 0} className="px-3 py-1 rounded border text-sm disabled:opacity-30 hover:bg-gray-100">‹</button>
|
||||
<span className="text-sm text-gray-600 px-2">{page + 1} / {totalPages}</span>
|
||||
<button onClick={() => setPage(page + 1)} disabled={page >= totalPages - 1} className="px-3 py-1 rounded border text-sm disabled:opacity-30 hover:bg-gray-100">›</button>
|
||||
<button onClick={() => setPage(totalPages - 1)} disabled={page >= totalPages - 1} className="px-3 py-1 rounded border text-sm disabled:opacity-30 hover:bg-gray-100">»</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 식당 상세/수정 패널 */}
|
||||
{selected && (
|
||||
<div className="mt-6 bg-surface rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-base">{selected.name}</h3>
|
||||
<button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">x</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{[
|
||||
{ key: "name", label: "이름" },
|
||||
{ key: "address", label: "주소" },
|
||||
{ key: "region", label: "지역" },
|
||||
{ key: "cuisine_type", label: "음식 종류" },
|
||||
{ key: "price_range", label: "가격대" },
|
||||
{ key: "phone", label: "전화" },
|
||||
{ key: "website", label: "웹사이트" },
|
||||
{ key: "latitude", label: "위도" },
|
||||
{ key: "longitude", label: "경도" },
|
||||
].map(({ key, label }) => (
|
||||
<div key={key}>
|
||||
<label className="text-xs text-gray-500">{label}</label>
|
||||
<input
|
||||
value={editForm[key] || ""}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
className="w-full border rounded px-2 py-1.5 text-sm bg-surface text-gray-900"
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selected.business_status && (
|
||||
<p className="mt-3 text-xs text-gray-500">
|
||||
Google 상태: <span className="font-medium">{selected.business_status}</span>
|
||||
{selected.rating && <> / ★ {selected.rating} ({selected.rating_count?.toLocaleString()})</>}
|
||||
</p>
|
||||
)}
|
||||
{selected.google_place_id && (
|
||||
<p className="mt-1">
|
||||
<a
|
||||
href={`https://www.google.com/maps/place/?q=place_id:${selected.google_place_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-600 hover:underline text-xs"
|
||||
>
|
||||
Google Maps에서 보기
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{/* 테이블링 연결 */}
|
||||
{isAdmin && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-xs font-semibold text-gray-500">테이블링</h4>
|
||||
{selected.tabling_url === "NONE" ? (
|
||||
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||
) : selected.tabling_url ? (
|
||||
<a href={selected.tabling_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-brand-600 hover:underline text-xs">{selected.tabling_url}</a>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">미연결</span>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setTablingSearching(true);
|
||||
try {
|
||||
const results = await api.searchTabling(selected.id);
|
||||
if (results.length === 0) {
|
||||
alert("테이블링에서 검색 결과가 없습니다");
|
||||
} else {
|
||||
const best = results[0];
|
||||
if (confirm(`"${best.title}"\n${best.url}\n\n이 테이블링 페이지를 연결할까요?`)) {
|
||||
await api.setTablingUrl(selected.id, best.url);
|
||||
setSelected({ ...selected, tabling_url: best.url });
|
||||
load();
|
||||
}
|
||||
}
|
||||
} catch (e) { alert("검색 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||
finally { setTablingSearching(false); }
|
||||
}}
|
||||
disabled={tablingSearching}
|
||||
className="px-2 py-0.5 text-[11px] bg-brand-500 text-white rounded hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
{tablingSearching ? "검색 중..." : "테이블링 검색"}
|
||||
</button>
|
||||
{selected.tabling_url && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await api.setTablingUrl(selected.id, "");
|
||||
setSelected({ ...selected, tabling_url: null });
|
||||
load();
|
||||
}}
|
||||
className="px-2 py-0.5 text-[11px] text-red-500 border border-red-200 rounded hover:bg-red-50"
|
||||
>
|
||||
연결 해제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 캐치테이블 연결 */}
|
||||
{isAdmin && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-xs font-semibold text-gray-500">캐치테이블</h4>
|
||||
{selected.catchtable_url === "NONE" ? (
|
||||
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||
) : selected.catchtable_url ? (
|
||||
<a href={selected.catchtable_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-brand-600 hover:underline text-xs">{selected.catchtable_url}</a>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">미연결</span>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setCatchtableSearching(true);
|
||||
try {
|
||||
const results = await api.searchCatchtable(selected.id);
|
||||
if (results.length === 0) {
|
||||
alert("캐치테이블에서 검색 결과가 없습니다");
|
||||
} else {
|
||||
const best = results[0];
|
||||
if (confirm(`"${best.title}"\n${best.url}\n\n이 캐치테이블 페이지를 연결할까요?`)) {
|
||||
await api.setCatchtableUrl(selected.id, best.url);
|
||||
setSelected({ ...selected, catchtable_url: best.url });
|
||||
load();
|
||||
}
|
||||
}
|
||||
} catch (e) { alert("검색 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||
finally { setCatchtableSearching(false); }
|
||||
}}
|
||||
disabled={catchtableSearching}
|
||||
className="px-2 py-0.5 text-[11px] bg-violet-500 text-white rounded hover:bg-violet-600 disabled:opacity-50"
|
||||
>
|
||||
{catchtableSearching ? "검색 중..." : "캐치테이블 검색"}
|
||||
</button>
|
||||
{selected.catchtable_url && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await api.setCatchtableUrl(selected.id, "");
|
||||
setSelected({ ...selected, catchtable_url: null });
|
||||
load();
|
||||
}}
|
||||
className="px-2 py-0.5 text-[11px] text-red-500 border border-red-200 rounded hover:bg-red-50"
|
||||
>
|
||||
연결 해제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videos.length > 0 && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<h4 className="text-xs font-semibold text-gray-500 mb-2">연결된 영상 ({videos.length})</h4>
|
||||
<div className="space-y-1.5 max-h-32 overflow-y-auto">
|
||||
{videos.map((v) => (
|
||||
<div key={v.video_id} className="flex items-center gap-2 text-xs">
|
||||
<span className="px-1.5 py-0.5 bg-red-50 text-red-600 rounded text-[10px] font-medium shrink-0">
|
||||
{v.channel_name}
|
||||
</span>
|
||||
<a href={v.url} target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline truncate">
|
||||
{v.title}
|
||||
</a>
|
||||
<span className="text-gray-400 shrink-0">{v.published_at?.slice(0, 10)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
{isAdmin && <button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
</button>}
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
className="px-4 py-2 text-sm border rounded text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
{isAdmin ? "취소" : "닫기"}
|
||||
</button>
|
||||
{isAdmin && <button
|
||||
onClick={handleDelete}
|
||||
className="px-4 py-2 text-sm text-red-500 border border-red-200 rounded hover:bg-red-50 ml-auto"
|
||||
>
|
||||
식당 삭제
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ─── 유저 관리 ─── */
|
||||
interface AdminUser {
|
||||
id: string;
|
||||
email: string | null;
|
||||
nickname: string | null;
|
||||
avatar_url: string | null;
|
||||
is_admin: boolean;
|
||||
provider: string | null;
|
||||
created_at: string | null;
|
||||
favorite_count: number;
|
||||
review_count: number;
|
||||
memo_count: number;
|
||||
}
|
||||
|
||||
interface UserFavorite {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
region: string | null;
|
||||
cuisine_type: string | null;
|
||||
rating: number | null;
|
||||
business_status: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
interface UserReview {
|
||||
id: string;
|
||||
restaurant_id: string;
|
||||
rating: number;
|
||||
review_text: string | null;
|
||||
visited_at: string | null;
|
||||
created_at: string | null;
|
||||
restaurant_name: string | null;
|
||||
}
|
||||
|
||||
interface UserMemo {
|
||||
id: string;
|
||||
restaurant_id: string;
|
||||
rating: number | null;
|
||||
memo_text: string | null;
|
||||
visited_at: string | null;
|
||||
created_at: string;
|
||||
restaurant_name: string | null;
|
||||
}
|
||||
|
||||
383
frontend/src/app/admin/_panels/UsersPanel.tsx
Normal file
383
frontend/src/app/admin/_panels/UsersPanel.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// #329 — admin/page.tsx에서 추출
|
||||
interface AdminUser {
|
||||
id: string;
|
||||
email: string | null;
|
||||
nickname: string | null;
|
||||
avatar_url: string | null;
|
||||
is_admin: boolean;
|
||||
provider: string | null;
|
||||
created_at: string | null;
|
||||
favorite_count: number;
|
||||
review_count: number;
|
||||
memo_count: number;
|
||||
}
|
||||
|
||||
interface UserFavorite {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
region: string | null;
|
||||
cuisine_type: string | null;
|
||||
rating: number | null;
|
||||
business_status: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
interface UserReview {
|
||||
id: string;
|
||||
restaurant_id: string;
|
||||
rating: number;
|
||||
review_text: string | null;
|
||||
visited_at: string | null;
|
||||
created_at: string | null;
|
||||
restaurant_name: string | null;
|
||||
}
|
||||
|
||||
interface UserMemo {
|
||||
id: string;
|
||||
restaurant_id: string;
|
||||
rating: number | null;
|
||||
memo_text: string | null;
|
||||
visited_at: string | null;
|
||||
created_at: string;
|
||||
restaurant_name: string | null;
|
||||
}
|
||||
|
||||
export function UsersPanel() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
|
||||
const [reviews, setReviews] = useState<UserReview[]>([]);
|
||||
const [memos, setMemos] = useState<UserMemo[]>([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const perPage = 20;
|
||||
|
||||
const loadUsers = useCallback(async (p: number) => {
|
||||
try {
|
||||
const res = await api.getAdminUsers({ limit: perPage, offset: p * perPage });
|
||||
setUsers(res.users);
|
||||
setTotal(res.total);
|
||||
} catch (e) {
|
||||
console.error("Failed to load users:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers(page);
|
||||
}, [page, loadUsers]);
|
||||
|
||||
const handleSelectUser = async (u: AdminUser) => {
|
||||
if (selectedUser?.id === u.id) {
|
||||
setSelectedUser(null);
|
||||
setFavorites([]);
|
||||
setReviews([]);
|
||||
setMemos([]);
|
||||
return;
|
||||
}
|
||||
setSelectedUser(u);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const [favs, revs, mems] = await Promise.all([
|
||||
api.getAdminUserFavorites(u.id),
|
||||
api.getAdminUserReviews(u.id),
|
||||
api.getAdminUserMemos(u.id),
|
||||
]);
|
||||
setFavorites(favs);
|
||||
setReviews(revs);
|
||||
setMemos(mems);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold">유저 관리 ({total}명)</h2>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-surface rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">사용자</th>
|
||||
<th className="text-left px-4 py-2">이메일</th>
|
||||
<th className="text-center px-4 py-2">관리자</th>
|
||||
<th className="text-center px-4 py-2">찜</th>
|
||||
<th className="text-center px-4 py-2">리뷰</th>
|
||||
<th className="text-center px-4 py-2">메모</th>
|
||||
<th className="text-left px-4 py-2">가입일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr
|
||||
key={u.id}
|
||||
onClick={() => handleSelectUser(u)}
|
||||
className={`border-t cursor-pointer transition-colors ${
|
||||
selectedUser?.id === u.id
|
||||
? "bg-brand-50"
|
||||
: "hover:bg-brand-50/50"
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{u.avatar_url ? (
|
||||
<img
|
||||
src={u.avatar_url}
|
||||
alt=""
|
||||
className="w-7 h-7 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-xs font-semibold text-gray-500">
|
||||
{(u.nickname || u.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{u.nickname || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{u.email || "-"}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.updateAdminUserAdmin(u.id, !u.is_admin);
|
||||
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, is_admin: !u.is_admin } : x));
|
||||
} catch (err) {
|
||||
console.error("Failed to update admin:", err);
|
||||
alert("관리자 권한 변경에 실패했습니다.");
|
||||
}
|
||||
}}
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||
u.is_admin
|
||||
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||
: "bg-gray-100 text-gray-400 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{u.is_admin ? "ON" : "OFF"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{u.favorite_count > 0 ? (
|
||||
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded-full text-xs font-medium">
|
||||
{u.favorite_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{u.review_count > 0 ? (
|
||||
<span className="inline-block px-2 py-0.5 bg-brand-50 text-brand-600 rounded-full text-xs font-medium">
|
||||
{u.review_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{u.memo_count > 0 ? (
|
||||
<span className="inline-block px-2 py-0.5 bg-purple-50 text-purple-600 rounded-full text-xs font-medium">
|
||||
{u.memo_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-400 text-xs">
|
||||
{u.created_at?.slice(0, 10) || "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1 text-sm border rounded disabled:opacity-30"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1 text-sm border rounded disabled:opacity-30"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected User Detail */}
|
||||
{selectedUser && (
|
||||
<div className="bg-surface rounded-lg shadow p-5 space-y-4">
|
||||
<div className="flex items-center gap-3 pb-3 border-b">
|
||||
{selectedUser.avatar_url ? (
|
||||
<img
|
||||
src={selectedUser.avatar_url}
|
||||
alt=""
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-lg font-semibold text-gray-500">
|
||||
{(selectedUser.nickname || selectedUser.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-bold">{selectedUser.nickname || "-"}</div>
|
||||
<div className="text-sm text-gray-500">{selectedUser.email || "-"}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{selectedUser.provider} · 가입: {selectedUser.created_at?.slice(0, 10)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailLoading ? (
|
||||
<p className="text-sm text-gray-500">로딩 중...</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Favorites */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2 text-red-600">
|
||||
찜한 식당 ({favorites.length})
|
||||
</h3>
|
||||
{favorites.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">찜한 식당이 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{favorites.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="flex items-center justify-between border rounded px-3 py-2 text-xs"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">{f.name}</span>
|
||||
{f.region && (
|
||||
<span className="ml-1.5 text-gray-400">{f.region}</span>
|
||||
)}
|
||||
{f.cuisine_type && (
|
||||
<span className="ml-1.5 text-gray-400">· {f.cuisine_type}</span>
|
||||
)}
|
||||
{f.business_status === "CLOSED_PERMANENTLY" && (
|
||||
<span className="ml-1.5 px-1 bg-red-100 text-red-600 rounded text-[10px]">
|
||||
폐업
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{f.rating && (
|
||||
<span className="text-yellow-500 shrink-0">
|
||||
★ {f.rating}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reviews */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2 text-brand-600">
|
||||
작성한 리뷰 ({reviews.length})
|
||||
</h3>
|
||||
{reviews.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">작성한 리뷰가 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{reviews.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="border rounded px-3 py-2 text-xs space-y-0.5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{r.restaurant_name || "알 수 없음"}
|
||||
</span>
|
||||
<span className="text-yellow-500 shrink-0">
|
||||
{"★".repeat(Math.round(r.rating))} {r.rating}
|
||||
</span>
|
||||
</div>
|
||||
{r.review_text && (
|
||||
<p className="text-gray-600 line-clamp-2">
|
||||
{r.review_text}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-gray-400 text-[10px]">
|
||||
{r.visited_at && `방문: ${r.visited_at} · `}
|
||||
{r.created_at?.slice(0, 10)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Memos */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2 text-purple-600">
|
||||
작성한 메모 ({memos.length})
|
||||
</h3>
|
||||
{memos.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">작성한 메모가 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{memos.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="border border-purple-200 rounded px-3 py-2 text-xs space-y-0.5 bg-purple-50/30"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{m.restaurant_name || "알 수 없음"}
|
||||
</span>
|
||||
{m.rating && (
|
||||
<span className="text-yellow-500 shrink-0">
|
||||
{"★".repeat(Math.round(m.rating))} {m.rating}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.memo_text && (
|
||||
<p className="text-gray-600 line-clamp-2">
|
||||
{m.memo_text}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-gray-400 text-[10px]">
|
||||
{m.visited_at && `방문: ${m.visited_at} · `}
|
||||
{m.created_at?.slice(0, 10)}
|
||||
<span className="ml-1 text-purple-400">비공개</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 데몬 설정 ─── */
|
||||
1230
frontend/src/app/admin/_panels/VideosPanel.tsx
Normal file
1230
frontend/src/app/admin/_panels/VideosPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GoogleLogin } from "@react-oauth/google";
|
||||
import LoginMenu from "@/components/LoginMenu";
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Restaurant, Channel, Review, Memo } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
@@ -14,6 +15,7 @@ import MyReviewsList from "@/components/MyReviewsList";
|
||||
import BottomSheet from "@/components/BottomSheet";
|
||||
import FilterSheet, { FilterOption } from "@/components/FilterSheet";
|
||||
import { getCuisineIcon, getPhosphorCuisineIcon } from "@/lib/cuisine-icons";
|
||||
import { passesMinMentions } from "@/lib/filters";
|
||||
import Icon from "@/components/Icon";
|
||||
import FoodIcon from "@/components/FoodIcon";
|
||||
import * as PhosphorIcons from "@phosphor-icons/react";
|
||||
@@ -195,6 +197,7 @@ export default function Home() {
|
||||
const [channelFilter, setChannelFilter] = useState("");
|
||||
const [cuisineFilter, setCuisineFilter] = useState("");
|
||||
const [priceFilter, setPriceFilter] = useState("");
|
||||
const [minMentions, setMinMentions] = useState(0); // #533 — 의미있는 언급 N회 미만 식당 제외 (0=전체)
|
||||
const [viewMode, setViewMode] = useState<"map" | "list">("list");
|
||||
const [mobileTab, setMobileTab] = useState<"home" | "list" | "nearby" | "favorites" | "profile">("home");
|
||||
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
||||
@@ -247,6 +250,7 @@ export default function Home() {
|
||||
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
|
||||
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
|
||||
if (priceFilter && !matchPriceGroup(r.price_range, priceFilter)) return false;
|
||||
if (!passesMinMentions(r.video_count, minMentions)) return false;
|
||||
if (countryFilter) {
|
||||
const parsed = parseRegion(r.region);
|
||||
if (!parsed || parsed.country !== countryFilter) return false;
|
||||
@@ -270,7 +274,7 @@ export default function Home() {
|
||||
if (da !== db) return da - db;
|
||||
return (b.rating || 0) - (a.rating || 0);
|
||||
});
|
||||
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
||||
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, minMentions, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
||||
|
||||
// FilterSheet option builders
|
||||
const cuisineOptions = useMemo<FilterOption[]>(() => {
|
||||
@@ -365,6 +369,7 @@ export default function Home() {
|
||||
setChannelFilter("");
|
||||
setCuisineFilter("");
|
||||
setPriceFilter("");
|
||||
setMinMentions(0);
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
@@ -384,6 +389,8 @@ export default function Home() {
|
||||
const handleSelectRestaurant = useCallback((r: Restaurant) => {
|
||||
setSelected(r);
|
||||
setShowDetail(true);
|
||||
// selected 변경 자체가 MapView 내부의 useEffect에서 중앙 이동을 처리.
|
||||
// (flyTo를 같이 set하면 줌 16으로 강제되어 클러스터 expansion 후 마커 클릭 시 다시 묶임)
|
||||
}, []);
|
||||
|
||||
const handleCloseDetail = useCallback(() => {
|
||||
@@ -474,6 +481,7 @@ export default function Home() {
|
||||
setChannelFilter("");
|
||||
setCuisineFilter("");
|
||||
setPriceFilter("");
|
||||
setMinMentions(0);
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
@@ -719,6 +727,8 @@ export default function Home() {
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{/* #352 — Language switcher */}
|
||||
<LanguageSwitcher />
|
||||
{/* Desktop user area */}
|
||||
{authLoading ? null : user ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
@@ -813,9 +823,23 @@ export default function Home() {
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{(cuisineFilter || priceFilter) && (
|
||||
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
|
||||
<select
|
||||
value={minMentions}
|
||||
onChange={(e) => { setMinMentions(Number(e.target.value)); if (Number(e.target.value)) setBoundsFilterOn(false); }}
|
||||
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||
minMentions > 0 ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
title="영상에서 충분히 언급된(관련도 높은) 식당만 보기"
|
||||
>
|
||||
<option value={0}>언급</option>
|
||||
<option value={2}>2회+</option>
|
||||
<option value={3}>3회+</option>
|
||||
<option value={5}>5회+</option>
|
||||
</select>
|
||||
{(cuisineFilter || priceFilter || minMentions > 0) && (
|
||||
<button
|
||||
onClick={() => { setCuisineFilter(""); setPriceFilter(""); }}
|
||||
onClick={() => { setCuisineFilter(""); setPriceFilter(""); setMinMentions(0); }}
|
||||
className="p-1.5 -mr-1 text-gray-400 hover:text-brand-500 transition-colors touch-manipulation"
|
||||
title="음식 필터 초기화"
|
||||
>
|
||||
@@ -883,7 +907,7 @@ export default function Home() {
|
||||
)}
|
||||
</div>
|
||||
{/* 필터 전체 해제 */}
|
||||
{(channelFilter || cuisineFilter || priceFilter || countryFilter) && (
|
||||
{(channelFilter || cuisineFilter || priceFilter || countryFilter || minMentions > 0) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setChannelFilter("");
|
||||
@@ -892,6 +916,7 @@ export default function Home() {
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
setMinMentions(0);
|
||||
setRegionFlyTo(null);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-lg px-2.5 py-1.5 bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500 transition-colors touch-manipulation"
|
||||
@@ -912,6 +937,7 @@ export default function Home() {
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
setMinMentions(0);
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => { setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }); },
|
||||
@@ -1136,6 +1162,7 @@ export default function Home() {
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
setMinMentions(0);
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => { setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }); },
|
||||
@@ -1248,6 +1275,7 @@ export default function Home() {
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
setMinMentions(0);
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => { setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }); setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }); },
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { LocaleProvider } from "@/i18n/LocaleProvider";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||
@@ -9,7 +10,9 @@ const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<LocaleProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</LocaleProvider>
|
||||
</GoogleOAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
93
frontend/src/components/FoodIcon.tsx
Normal file
93
frontend/src/components/FoodIcon.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
interface FoodIconProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface IconDef {
|
||||
viewBox: string;
|
||||
fill?: boolean; // true = fill icon (no stroke), false = stroke icon (default)
|
||||
paths: React.ReactNode;
|
||||
}
|
||||
|
||||
const FOOD_ICONS: Record<string, IconDef> = {
|
||||
// 찌개/전골/탕: 뚝배기 + 김
|
||||
"jjigae": {
|
||||
viewBox: "0 0 24 24",
|
||||
paths: (
|
||||
<>
|
||||
<path d="M5 11h14v1c0 4-2.5 7-7 7s-7-3-7-7v-1z" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M4 11h16" strokeLinecap="round" />
|
||||
<path d="M3 11.5h1M20 11.5h1" strokeLinecap="round" strokeWidth="1.5" />
|
||||
<path d="M9 8c0-1.5 1-2 0-3M12 7c0-1.5 1-2 0-3M15 8c0-1.5 1-2 0-3" strokeLinecap="round" fill="none" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
// 분식: 가래떡 1개 사선
|
||||
"tteok": {
|
||||
viewBox: "0 0 24 24",
|
||||
paths: (
|
||||
<rect x="2" y="10" width="20" height="4" rx="2" transform="rotate(-30 12 12)" strokeLinecap="round" strokeLinejoin="round" />
|
||||
),
|
||||
},
|
||||
// 면: 그릇에 면+젓가락 (noodles-in-a-bowl)
|
||||
"noodle": {
|
||||
viewBox: "0 0 363.674 363.674",
|
||||
fill: true,
|
||||
paths: (
|
||||
<>
|
||||
<path d="M79.496,292.615c-16.686-10.313-28.229-27.017-33.383-48.308c-0.975-4.025-5.027-6.502-9.054-5.524c-4.026,0.975-6.499,5.027-5.524,9.054c6.102,25.21,19.959,45.105,40.074,57.538c1.229,0.759,2.59,1.121,3.936,1.121c2.514,0,4.969-1.263,6.387-3.558C84.109,299.415,83.02,294.793,79.496,292.615z" />
|
||||
<path d="M363.465,70.469c-0.967-4.027-5.016-6.5-9.045-5.542l-179.247,43.032V85.908l163.056-69.827c3.807-1.631,5.572-6.039,3.941-9.847c-1.631-3.809-6.039-5.574-9.848-3.942L174.755,69.768c-1.017-2.937-3.8-5.049-7.082-5.049c-4.142,0-7.5,3.357-7.5,7.5v3.794l-4.55,1.948c-0.631-3.505-3.689-6.166-7.375-6.166c-4.143,0-7.5,3.357-7.5,7.5v5.036l-4.543,1.945c-0.611-3.527-3.68-6.213-7.382-6.213c-4.142,0-7.5,3.357-7.5,7.5v5.086l-4.497,1.926c-0.482-3.677-3.621-6.517-7.428-6.517c-4.143,0-7.5,3.357-7.5,7.5v5.409l-4.631,1.983c-0.779-3.313-3.745-5.78-7.295-5.78c-4.142,0-7.5,3.357-7.5,7.5v4.615l-20.778,8.898c-3.808,1.631-5.572,6.039-3.942,9.847c1.219,2.846,3.988,4.55,6.898,4.55c0.984,0,1.986-0.195,2.949-0.607l14.873-6.369v4.608l-14.318,3.438c-4.027,0.967-6.509,5.016-5.542,9.044c0.825,3.439,3.899,5.751,7.286,5.751c0.58,0,1.17-0.068,1.758-0.209l10.816-2.597v64.284H7.5c-4.142,0-7.5,3.357-7.5,7.5c0,28.225,6.892,54.242,19.93,75.239c12.92,20.807,31.711,36.318,54.55,45.096v16.73c0,4.143,3.358,7.5,7.5,7.5h97.493c4.143,0,7.5-3.357,7.5-7.5v-16.73c22.838-8.777,41.631-24.29,54.549-45.096c13.039-20.997,19.932-47.015,19.932-75.239c0-4.143-3.358-7.5-7.5-7.5H116.898v-72.549l4.719-1.133c0.898,3.138,3.781,5.436,7.206,5.436c4.142,0,7.5-3.357,7.5-7.5v-1.466l4.471-1.074c0.405,3.76,3.587,6.688,7.454,6.688c4.143,0,7.5-3.357,7.5-7.5v-2.777l4.425-1.063v14.33c0,4.143,3.358,7.5,7.5,7.5s7.5-3.357,7.5-7.5v-17.931l182.749-43.873C361.949,78.546,364.432,74.497,363.465,70.469z M97.473,119.181l4.426-1.895v8.264l-4.426,1.063V119.181z M89.479,346.99v-7.19h82.493v7.19H89.479z M178.76,324.8H82.693c-40.367-14.153-65.217-51.11-67.519-99.875h231.103C243.975,273.689,219.127,310.646,178.76,324.8z M101.898,209.925h-4.426v-67.886l4.426-1.063V209.925z M116.898,110.863l4.425-1.895v11.918l-4.425,1.063V110.863z M136.323,102.545l4.425-1.896v15.574l-4.425,1.063V102.545z M155.748,112.623V94.226l4.425-1.896v19.229L155.748,112.623z" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
// 텐동/튀김: 텐푸라 (flaticon tempura)
|
||||
"tempura": {
|
||||
viewBox: "0 0 64 64",
|
||||
fill: true,
|
||||
paths: (
|
||||
<>
|
||||
<path d="M59.96092,38.28982c-.58769-2.348-4.12861-1.72934-6.10276-1.50418a48.12688,48.12688,0,0,0,2.50872-4.73925A1.91121,1.91121,0,0,0,54.08314,29.416c-3.94455,1.2-6.79557,4.77253-8.49153,7.55764a8.05234,8.05234,0,0,1-1.77036,2.03025c-.05638-.04614-.115-.097-.16482-.13169a5.09683,5.09683,0,0,0-5.11816-.48233,4.7649,4.7649,0,0,0-5.21482-.55654,5.28288,5.28288,0,0,0-3.90841-.05207,4.9359,4.9359,0,0,0-4.18868-1.25476A4.63263,4.63263,0,0,0,21.0592,34.644a4.73344,4.73344,0,0,0-8.3529-1.07386c-2.98316-.34766-6.15924,2.35525-5.34459,5.723A5.18758,5.18758,0,0,0,5.5691,43.06339a4.84285,4.84285,0,0,0,3.72,4.64886,4.72272,4.72272,0,0,0,5.13667,4.69644A4.74936,4.74936,0,0,0,20.736,54.771a4.73286,4.73286,0,0,0,6.6833,1.47475.99956.99956,0,1,0-1.1287-1.65,2.71212,2.71212,0,0,1-3.84593-.86364,2.05527,2.05527,0,0,0-2.603-.75617,2.5586,2.5586,0,0,1-1.09452.24556,2.73778,2.73778,0,0,1-2.67039-2.17292,1.00314,1.00314,0,0,0-.46182-.65271c-.46123-.33887-1.17682.06863-1.60712.03614a2.72229,2.72229,0,0,1-2.7192-2.71971c-.00244-.45741.32815-1.1176-.06741-1.54852a1.00725,1.00725,0,0,0-.93241-.38128,2.72268,2.72268,0,0,1-1.6968-4.8248,2.11365,2.11365,0,0,0,.72141-2.09722c-.55724-1.9936,1.76582-3.57014,2.96729-3.2786a2.07918,2.07918,0,0,0,2.08545-.89638,2.709,2.709,0,0,1,4.94632,1.24537,1.00583,1.00583,0,0,0,1.30933.8426,2.64386,2.64386,0,0,1,3.00723.9549,2.065,2.065,0,0,0,2.04257.78843,2.793,2.793,0,0,1,2.357.70543,2.12238,2.12238,0,0,0,2.32378.41545,2.67127,2.67127,0,0,1,2.48878.1982.99706.99706,0,0,0,1.10819-.0415,2.72809,2.72809,0,0,1,3.29624.12156,2.05987,2.05987,0,0,0,2.27495.27241,2.70644,2.70644,0,0,1,3.72479,3.33392c-.38932.81316.21723,1.64631.15282,2.47613A2.71632,2.71632,0,0,1,40.1902,48.528a2.00549,2.00549,0,0,0-2.17527,1.16787,2.71478,2.71478,0,0,1-2.49171,1.64568,2.592,2.592,0,0,1-.949-.17282,2.03329,2.03329,0,0,0-2.42337.79575,2.73019,2.73019,0,0,1-1.58075,1.17262.99911.99911,0,0,0-.69908,1.22877c.77166,1.84716,3.33875-.273,3.98066-1.33265A4.69753,4.69753,0,0,0,39.828,50.49447,4.90789,4.90789,0,0,0,44.20519,49.091c1.07331-1.28142.69028-2.47595,2.314-3.18146a7.17839,7.17839,0,0,1,2.02786-.83393,32.47063,32.47063,0,0,0,10.64445-4.85112A1.88349,1.88349,0,0,0,59.96092,38.28982ZM48.05305,43.138a8.88032,8.88032,0,0,0-2.879,1.26343c.07057-1.027.56233-2.1911-.21569-3.75184,3.30834-2.7934,4.28439-7.41256,9.50762-9.25727A41.37828,41.37828,0,0,1,50.91767,37.626c-1.32533.664-3.54,1.03493-2.8774,2.52866a.99875.99875,0,0,0,1.36213.37843c2.1403-1.32937,6.2594-2.42108,8.52181-1.86132A30.48308,30.48308,0,0,1,48.05305,43.138Z" />
|
||||
<path d="M20.71747,49.98436A3.136,3.136,0,0,0,24.414,47.5605a1.00042,1.00042,0,0,0-1.95861-.40708,1.12553,1.12553,0,0,1-2.20366-.45852,1.00043,1.00043,0,0,0-1.95863-.407A3.12939,3.12939,0,0,0,20.71747,49.98436Z" />
|
||||
<path d="M33.96005,46.60805a1.13194,1.13194,0,0,1-1.5456-.38031,1.00012,1.00012,0,0,0-1.71058,1.036,3.126,3.126,0,0,0,5.34752-3.23828,1.0001,1.0001,0,0,0-1.71052,1.03606A1.13019,1.13019,0,0,1,33.96005,46.60805Z" />
|
||||
<path d="M14.89436,39.24667A1.131,1.131,0,0,1,16.44,39.627a1.00011,1.00011,0,0,0,1.71058-1.036A3.12612,3.12612,0,0,0,12.803,41.82975a1.00009,1.00009,0,0,0,1.71052-1.03606A1.13084,1.13084,0,0,1,14.89436,39.24667Z" />
|
||||
<path d="M6.12651,33.96448a2.694,2.694,0,0,1,.36907-2.33061c1.05665-1.50674-.70244-2.64347-.19038-4.31314C7.64257,24.515,9.289,26.224,10.16584,23.95766a2.71747,2.71747,0,0,1,5.09174-.25918,1.00613,1.00613,0,0,0,1.51436.41191,2.23774,2.23774,0,0,1,.74692-.37979,2.64262,2.64262,0,0,1,2.39017.41545,2.05437,2.05437,0,0,0,2.192.15622,2.71372,2.71372,0,0,1,2.47706-.00635,2.09934,2.09934,0,0,0,2.31206-.28413,2.64664,2.64664,0,0,1,2.44094-.54725,1.00237,1.00237,0,0,0,1.04765-.35882,2.68307,2.68307,0,0,1,3.20154-.85528,2.04549,2.04549,0,0,0,2.23589-.40278c.466-.57119,2.41006-1.24944,3.47261-.19757a2.77124,2.77124,0,0,1,1.07535,2.30556,2.12056,2.12056,0,0,0,.42765,1.36351,2.71658,2.71658,0,0,1-1.84936,4.30037,2.00726,2.00726,0,0,0-1.774,1.76536,2.64106,2.64106,0,0,1-1.3718,2.06064,1.00024,1.00024,0,0,0,.953,1.75842,4.56245,4.56245,0,0,0,2.38621-3.594,4.8526,4.8526,0,0,0,3.78058-2.63716,5.43994,5.43994,0,0,0,.35986-2.5729,8.82313,8.82313,0,0,1,2.61707-2.53306A32.49913,32.49913,0,0,0,54.64846,16.108a1.88292,1.88292,0,0,0,.167-2.07578c-1.24835-2.07129-4.45549-.44274-6.275.35141a48.01336,48.01336,0,0,0,1.00846-5.265,1.91029,1.91029,0,0,0-2.95353-1.84583c-3.42122,2.30473-5.09863,6.55538-5.90317,9.71591-.21291.58392-.67229,2.46176-1.29549,2.3801a5.14553,5.14553,0,0,0-5.03435,1.04112,4.69673,4.69673,0,0,0-5.14842.99789,5.16687,5.16687,0,0,0-3.75118,1.09557,4.88152,4.88152,0,0,0-4.37323.02872,4.60545,4.60545,0,0,0-4.14375-.71718,3.74334,3.74334,0,0,0-.39739.1362A4.70187,4.70187,0,0,0,8.256,23.365a5.23681,5.23681,0,0,0-3.69162,2.97413,4.56732,4.56732,0,0,0,.26544,4.08018,5.13094,5.13094,0,0,0-.62272,4.1052A1.01478,1.01478,0,1,0,6.12651,33.96448Zm41.413-24.91369a41.36518,41.36518,0,0,1-1.56268,6.99793,18.57825,18.57825,0,0,0-2.04893,1.8495.99993.99993,0,0,0,1.45289,1.37371c1.65257-1.89613,5.27564-4.14923,7.60011-4.27732a30.49647,30.49647,0,0,1-8.12739,7.16451A8.87242,8.87242,0,0,0,42.465,24.21738a5.82025,5.82025,0,0,0-1.30541-3.52356C43.51355,17.05538,43.06651,12.37112,47.53947,9.05079Z" />
|
||||
<path d="M31.62065,29.98282a1.14811,1.14811,0,0,1-.81332-.28461,1.00012,1.00012,0,0,0-1.33173,1.49194,3.10428,3.10428,0,0,0,2.07768.79424,3.14507,3.14507,0,0,0,2.08541-5.458,1.00008,1.00008,0,0,0-1.3316,1.492A1.13309,1.13309,0,0,1,31.62065,29.98282Z" />
|
||||
<path d="M12.78637,27.78646a1.13979,1.13979,0,0,1,.81332.28462,1.00013,1.00013,0,0,0,1.33173-1.49195,3.12577,3.12577,0,0,0-4.16309,4.66374,1.00009,1.00009,0,0,0,1.3316-1.492A1.13318,1.13318,0,0,1,12.78637,27.78646Z" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
// 돼지 (삼겹살/돼지구이, 족발/보쌈, 돈카츠)
|
||||
"pig": {
|
||||
viewBox: "0 0 90 90",
|
||||
fill: true,
|
||||
paths: (
|
||||
<>
|
||||
<path d="m66.005 57.841c-.76 3.428-2.077 6.355-3.24 8.521h-2.301c.645-2.74 1.151-5.421 1.312-7.973 1.428-.158 2.844-.334 4.229-.548zm-27.672-.004c.745.129 1.505.239 2.265.348.147 2.609.667 5.365 1.324 8.177h-2.344c-.729-1.359-1.505-2.968-2.172-4.901.355-1.156.672-2.37.927-3.624zm27.09-33.334c8.703-.376 14.869 5.025 17.547 10.541 2.765 5.713.796 11.839-3.765 16.443-.209.208-.328.489-.339.781-.224 6.339-2.589 12.077-4.584 15.739h-3.864c.765-3.176 1.396-6.301 1.572-9.244.032-.547-.323-1.041-.853-1.188-3.975-1.072-5.729-3.661-6.443-6.213-.407-1.511-2.667-.88-2.24.62.349 1.245 1.161 2.469 2.041 3.645-9.041 1.261-17.015 1.464-25.776-.088.219-1.615.328-3.287.276-4.989.011-1.604-2.421-1.532-2.317.068.199 6.905-2.432 13.395-4.609 17.389h-3.844c1.532-6.337 2.12-12.307 1.089-17.645-.1-.557-.589-.964-1.152-.959-.739 0-1.285.677-1.135 1.396.183.937.296 1.912.38 2.901-4.984-.828-8.287-2.407-11.203-4.027-3.157-1.755-5.933-3.624-9.776-4.385-.048-.009-.183-.067-.355-.395-.172-.329-.312-.855-.344-1.396-.025-.543.057-1.095.199-1.469.145-.369.307-.505.411-.541 4.541-1.765 7.147-3.251 11.161-7.683.396-.443.401-1.109.005-1.552-2.077-2.349-2.219-4.749-1.869-7.177l8.244 6.371c1.224.948 2.647-.891 1.423-1.839l-1-.771c1.296-1.172 2.421-2.256 6.073-3.172 4.364-1.089 8.124-.729 13.389-.376 5.272.349 9.917.663 19.887-.629.599-.079 1.192-.131 1.771-.156zm14.598-4.849c-1.547.073-1.437 2.396.115 2.323 1.817 0 2.536 1.063 2.599 2.344.057 1.244-.625 2.64-2.583 3.24-4.048-3.667-9.797-6.109-16.803-5.199-9.787 1.265-14.197.953-19.427.604-5.235-.348-9.324-.749-14.104.448-4.167 1.041-5.891 2.489-7.443 3.932l-6.964-5.385c-.219-.167-.484-.255-.756-.244-.509.02-.943.375-1.077.864-.823 3.109-.667 6.781 1.683 10.136-3.491 3.739-5.568 4.963-9.772 6.599-.891.344-1.432 1.115-1.728 1.88-.297.771-.396 1.599-.349 2.423.041.823.224 1.629.604 2.353.374.718 1.026 1.416 1.964 1.598 3.339.661 5.833 2.319 9.093 4.131 3.099 1.724 6.875 3.505 12.443 4.364.041 3.948-.584 8.235-1.797 12.803-.197.74.36 1.464 1.125 1.459h5.989c.423 0 .808-.224 1.016-.589.765-1.359 1.588-3.052 2.36-4.952.536 1.244 1.088 2.369 1.609 3.301.203.369.593.599 1.009.599h4.448c.767.005 1.323-.719 1.131-1.457-.808-3.079-1.303-5.996-1.464-8.745 6.043.672 10.36.672 16.495.14-.172 2.709-.661 5.579-1.452 8.605-.199.739.364 1.463 1.124 1.457h4.448c.423 0 .813-.229 1.016-.599 1.281-2.297 2.765-5.631 3.645-9.489.412.244.849.473 1.344.661-.229 3.041-.744 6.235-1.64 9.609-.199.735.359 1.459 1.12 1.459h5.995c.416 0 .801-.224 1.005-.589 2.145-3.796 4.771-10.063 5.088-17.093 4.792-5.088 7.167-12.131 3.937-18.615-.891-1.776-2.052-3.427-3.156-4.692 2.135-1.1 3.24-3.167 3.145-5.131-.109-2.333-2.025-4.552-4.921-4.552-.04-.001-.078-.001-.114-.001z" />
|
||||
<path clipRule="evenodd" d="m18.354 36.679c1.177 0 2.135.959 2.135 2.141 0 1.183-.957 2.14-2.135 2.14-1.183 0-2.141-.957-2.141-2.14 0-1.182.959-2.141 2.141-2.141z" fillRule="evenodd" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export default function FoodIcon({ name, size = 24, className = "" }: FoodIconProps) {
|
||||
const icon = FOOD_ICONS[name];
|
||||
if (!icon) return null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={icon.viewBox}
|
||||
fill={icon.fill ? "currentColor" : "none"}
|
||||
stroke={icon.fill ? "none" : "currentColor"}
|
||||
strokeWidth={icon.fill ? undefined : 1.5}
|
||||
className={className}
|
||||
>
|
||||
{icon.paths}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
396
frontend/src/components/GoogleMapView.tsx
Normal file
396
frontend/src/components/GoogleMapView.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
APIProvider,
|
||||
Map,
|
||||
AdvancedMarker,
|
||||
InfoWindow,
|
||||
useMap,
|
||||
} from "@vis.gl/react-google-maps";
|
||||
import Supercluster from "supercluster";
|
||||
import type { Restaurant } from "@/lib/api";
|
||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
|
||||
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
||||
|
||||
// Channel color palette
|
||||
const CHANNEL_COLORS = [
|
||||
{ bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default)
|
||||
{ bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue
|
||||
{ bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green
|
||||
{ bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink
|
||||
{ bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple
|
||||
{ bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red
|
||||
{ bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal
|
||||
{ bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow
|
||||
];
|
||||
|
||||
function getChannelColorMap(restaurants: Restaurant[]) {
|
||||
const channels = new Set<string>();
|
||||
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch)));
|
||||
const map: Record<string, typeof CHANNEL_COLORS[0]> = {};
|
||||
let i = 0;
|
||||
for (const ch of channels) {
|
||||
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
||||
i++;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
import type { MapBounds, FlyTo, MapViewProps } from "@/components/MapView.types";
|
||||
|
||||
type RestaurantProps = { restaurant: Restaurant };
|
||||
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
||||
|
||||
function useSupercluster(restaurants: Restaurant[]) {
|
||||
// #278 — indexRef 제거 (set만 되고 read 없는 dead code)
|
||||
const points: RestaurantFeature[] = useMemo(
|
||||
() =>
|
||||
restaurants.map((r) => ({
|
||||
type: "Feature" as const,
|
||||
geometry: { type: "Point" as const, coordinates: [r.longitude, r.latitude] },
|
||||
properties: { restaurant: r },
|
||||
})),
|
||||
[restaurants]
|
||||
);
|
||||
|
||||
const index = useMemo(() => {
|
||||
const sc = new Supercluster<{ restaurant: Restaurant }>({
|
||||
radius: 60,
|
||||
maxZoom: 16,
|
||||
minPoints: 2,
|
||||
});
|
||||
sc.load(points);
|
||||
return sc;
|
||||
}, [points]);
|
||||
|
||||
const getClusters = useCallback(
|
||||
(bounds: MapBounds, zoom: number) => {
|
||||
return index.getClusters(
|
||||
[bounds.west, bounds.south, bounds.east, bounds.north],
|
||||
Math.floor(zoom)
|
||||
);
|
||||
},
|
||||
[index]
|
||||
);
|
||||
|
||||
const getExpansionZoom = useCallback(
|
||||
(clusterId: number): number => {
|
||||
try {
|
||||
return index.getClusterExpansionZoom(clusterId);
|
||||
} catch {
|
||||
return 17;
|
||||
}
|
||||
},
|
||||
[index]
|
||||
);
|
||||
|
||||
return { getClusters, getExpansionZoom, index };
|
||||
}
|
||||
|
||||
function getClusterSize(count: number): number {
|
||||
if (count < 10) return 36;
|
||||
if (count < 50) return 42;
|
||||
if (count < 100) return 48;
|
||||
return 54;
|
||||
}
|
||||
|
||||
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
|
||||
const map = useMap();
|
||||
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
||||
const [zoom, setZoom] = useState(13);
|
||||
const [bounds, setBounds] = useState<MapBounds | null>(null);
|
||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
||||
|
||||
// #278 — restaurantMap 제거 (빌드만 되고 렌더에서 사용 안 됨, dead code)
|
||||
|
||||
const clusters = useMemo(() => {
|
||||
if (!bounds) return [];
|
||||
return getClusters(bounds, zoom);
|
||||
}, [bounds, zoom, getClusters]);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(r: Restaurant) => {
|
||||
setInfoTarget(r);
|
||||
onSelectRestaurant?.(r);
|
||||
},
|
||||
[onSelectRestaurant]
|
||||
);
|
||||
|
||||
const handleClusterClick = useCallback(
|
||||
(clusterId: number, lng: number, lat: number) => {
|
||||
if (!map) return;
|
||||
const expansionZoom = Math.min(getExpansionZoom(clusterId), 18);
|
||||
map.panTo({ lat, lng });
|
||||
map.setZoom(expansionZoom);
|
||||
},
|
||||
[map, getExpansionZoom]
|
||||
);
|
||||
|
||||
// Track camera changes for clustering
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const listener = map.addListener("idle", () => {
|
||||
const b = map.getBounds();
|
||||
const z = map.getZoom();
|
||||
if (b && z != null) {
|
||||
const ne = b.getNorthEast();
|
||||
const sw = b.getSouthWest();
|
||||
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
||||
setZoom(z);
|
||||
}
|
||||
});
|
||||
// Trigger initial bounds
|
||||
const b = map.getBounds();
|
||||
const z = map.getZoom();
|
||||
if (b && z != null) {
|
||||
const ne = b.getNorthEast();
|
||||
const sw = b.getSouthWest();
|
||||
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
||||
setZoom(z);
|
||||
}
|
||||
return () => google.maps.event.removeListener(listener);
|
||||
}, [map]);
|
||||
|
||||
// Fly to a specific location (region filter)
|
||||
useEffect(() => {
|
||||
if (!map || !flyTo) return;
|
||||
map.panTo({ lat: flyTo.lat, lng: flyTo.lng });
|
||||
if (flyTo.zoom) map.setZoom(flyTo.zoom);
|
||||
}, [map, flyTo]);
|
||||
|
||||
// Pan and zoom to selected restaurant — 현재 줌이 16 이상이면 유지(클러스터 expansion 후 재묶임 방지)
|
||||
useEffect(() => {
|
||||
if (!map || !selected) return;
|
||||
map.panTo({ lat: selected.latitude, lng: selected.longitude });
|
||||
const currentZoom = map.getZoom() ?? 13;
|
||||
if (currentZoom < 16) map.setZoom(16);
|
||||
setInfoTarget(selected);
|
||||
}, [map, selected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{clusters.map((feature) => {
|
||||
const [lng, lat] = feature.geometry.coordinates;
|
||||
const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster;
|
||||
|
||||
if (isCluster) {
|
||||
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
|
||||
const size = getClusterSize(point_count);
|
||||
return (
|
||||
<AdvancedMarker
|
||||
key={`cluster-${cluster_id}`}
|
||||
position={{ lat, lng }}
|
||||
onClick={() => handleClusterClick(cluster_id, lng, lat)}
|
||||
zIndex={100}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`${point_count}개 식당이 모인 클러스터, 클릭하면 확대됩니다`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #E8720C 0%, #f59e0b 100%)",
|
||||
border: "3px solid #fff",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: size > 42 ? 15 : 13,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
transition: "transform 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{point_count}
|
||||
</div>
|
||||
</AdvancedMarker>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual marker
|
||||
const r = (feature.properties as { restaurant: Restaurant }).restaurant;
|
||||
const isSelected = selected?.id === r.id;
|
||||
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
||||
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
||||
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
|
||||
const c = chColor || CHANNEL_COLORS[0];
|
||||
return (
|
||||
<AdvancedMarker
|
||||
key={r.id}
|
||||
position={{ lat: r.latitude, lng: r.longitude }}
|
||||
onClick={() => handleMarkerClick(r)}
|
||||
zIndex={isSelected ? 1000 : 1}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`${r.name}${isClosed ? ' (폐업)' : ''}, 클릭하면 상세 정보가 표시됩니다`}
|
||||
style={{ display: "flex", flexDirection: "column", alignItems: "center", transition: "transform 0.2s ease", transform: isSelected ? "scale(1.15)" : "scale(1)", opacity: isClosed ? 0.5 : 1 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
backgroundColor: isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg,
|
||||
color: isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
borderRadius: 6,
|
||||
border: isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`,
|
||||
boxShadow: isSelected
|
||||
? "0 2px 8px rgba(37,99,235,0.4)"
|
||||
: `0 1px 4px ${c.border}40`,
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 120,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
textDecoration: isClosed ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-rounded" style={{ fontSize: 14, width: 14, height: 14, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
|
||||
{r.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "6px solid transparent",
|
||||
borderRight: "6px solid transparent",
|
||||
borderTop: isSelected ? "6px solid #1d4ed8" : `6px solid ${c.arrow}`,
|
||||
marginTop: -1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AdvancedMarker>
|
||||
);
|
||||
})}
|
||||
|
||||
{infoTarget && (
|
||||
<InfoWindow
|
||||
position={{ lat: infoTarget.latitude, lng: infoTarget.longitude }}
|
||||
onCloseClick={() => setInfoTarget(null)}
|
||||
>
|
||||
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, width: 18, height: 18, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{infoTarget.name}</h3>
|
||||
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
||||
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
||||
)}
|
||||
{infoTarget.business_status === "CLOSED_TEMPORARILY" && (
|
||||
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold">임시휴업</span>
|
||||
)}
|
||||
</div>
|
||||
{infoTarget.rating && (
|
||||
<p className="text-xs mt-0.5">
|
||||
<span className="text-yellow-500">★</span> {infoTarget.rating}
|
||||
{infoTarget.rating_count && (
|
||||
<span className="text-gray-400 ml-1">({infoTarget.rating_count.toLocaleString()})</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{infoTarget.cuisine_type && (
|
||||
<p className="text-xs text-gray-500">{infoTarget.cuisine_type}</p>
|
||||
)}
|
||||
{infoTarget.address && (
|
||||
<p className="text-[11px] text-gray-400 mt-1">{infoTarget.address}</p>
|
||||
)}
|
||||
{infoTarget.price_range && (
|
||||
<p className="text-[11px] text-gray-400">{infoTarget.price_range}</p>
|
||||
)}
|
||||
{infoTarget.phone && (
|
||||
<p className="text-[11px] text-gray-400">{infoTarget.phone}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSelectRestaurant?.(infoTarget)}
|
||||
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
상세 보기
|
||||
</button>
|
||||
</div>
|
||||
</InfoWindow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GoogleMapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel }: MapViewProps) {
|
||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||
const channelNames = useMemo(() => {
|
||||
const names = Object.keys(channelColors);
|
||||
if (activeChannel) return names.filter((n) => n === activeChannel);
|
||||
return names;
|
||||
}, [channelColors, activeChannel]);
|
||||
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCameraChanged = useCallback((ev: { detail: { bounds: { north: number; south: number; east: number; west: number } } }) => {
|
||||
if (!onBoundsChanged) return;
|
||||
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||
boundsTimerRef.current = setTimeout(() => {
|
||||
const { north, south, east, west } = ev.detail.bounds;
|
||||
onBoundsChanged({ north, south, east, west });
|
||||
}, 150);
|
||||
}, [onBoundsChanged]);
|
||||
|
||||
// #278 — 언마운트 시 디바운스 타이머 정리 (메모리 누수 + unmounted setState 경고 방지)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<APIProvider apiKey={API_KEY}>
|
||||
<Map
|
||||
defaultCenter={SEOUL_CENTER}
|
||||
defaultZoom={13}
|
||||
mapId="tasteby-map"
|
||||
className="h-full w-full"
|
||||
colorScheme="LIGHT"
|
||||
mapTypeControl={false}
|
||||
fullscreenControl={false}
|
||||
onCameraChanged={handleCameraChanged}
|
||||
>
|
||||
<MapContent
|
||||
restaurants={restaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={onSelectRestaurant}
|
||||
flyTo={flyTo}
|
||||
activeChannel={activeChannel}
|
||||
/>
|
||||
</Map>
|
||||
{onMyLocation && (
|
||||
<button
|
||||
onClick={onMyLocation}
|
||||
aria-label="내 위치로 이동"
|
||||
// #278 — 44×44px 터치 영역 확보 (이전 36px)
|
||||
className="absolute top-2 right-2 w-11 h-11 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10 touch-manipulation"
|
||||
title="내 위치"
|
||||
>
|
||||
<Icon name="my_location" size={22} />
|
||||
</button>
|
||||
)}
|
||||
{channelNames.length > 0 && (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="채널 범례"
|
||||
className="absolute bottom-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10"
|
||||
>
|
||||
{channelNames.map((ch) => (
|
||||
<div key={ch} className="flex items-center gap-1">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block w-2.5 h-2.5 rounded-full border"
|
||||
style={{ backgroundColor: channelColors[ch].border, borderColor: channelColors[ch].border }}
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</APIProvider>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default function Icon({ name, size = 20, filled, className = "" }: IconPr
|
||||
return (
|
||||
<span
|
||||
className={`material-symbols-rounded ${filled ? "filled" : ""} ${className}`}
|
||||
style={{ fontSize: size }}
|
||||
style={{ fontSize: size, width: size, height: size, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
|
||||
57
frontend/src/components/LanguageSwitcher.tsx
Normal file
57
frontend/src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLocale } from "@/i18n/LocaleProvider";
|
||||
import { LOCALES, LOCALE_LABELS } from "@/i18n/config";
|
||||
|
||||
// #352 — 헤더용 언어 전환 드롭다운. 44px 터치 영역 + ARIA listbox 패턴.
|
||||
export default function LanguageSwitcher() {
|
||||
const { locale, setLocale } = useLocale();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label="언어 선택 / Select language"
|
||||
className="min-h-[44px] min-w-[44px] px-2 py-1.5 flex items-center gap-1 text-sm rounded-lg hover:bg-brand-50 touch-manipulation"
|
||||
>
|
||||
<span aria-hidden="true">{LOCALE_LABELS[locale].flag}</span>
|
||||
<span className="text-xs text-gray-500 uppercase">{locale}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<button
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-40 cursor-default"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label="언어 목록"
|
||||
className="absolute right-0 mt-1 bg-surface rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50 min-w-[160px]"
|
||||
>
|
||||
{LOCALES.map((l) => (
|
||||
<li key={l}>
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={l === locale}
|
||||
onClick={() => { setLocale(l); setOpen(false); }}
|
||||
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-brand-50 ${
|
||||
l === locale ? "font-semibold text-brand-700" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">{LOCALE_LABELS[l].flag}</span>
|
||||
<span>{LOCALE_LABELS[l].native}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,416 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
APIProvider,
|
||||
Map,
|
||||
AdvancedMarker,
|
||||
InfoWindow,
|
||||
useMap,
|
||||
} from "@vis.gl/react-google-maps";
|
||||
import Supercluster from "supercluster";
|
||||
import type { Restaurant } from "@/lib/api";
|
||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||
import Icon from "@/components/Icon";
|
||||
import GoogleMapView from "@/components/GoogleMapView";
|
||||
import NaverMapView from "@/components/NaverMapView";
|
||||
import { isKoreaCoord, type MapBounds, type FlyTo, type MapViewProps } from "@/components/MapView.types";
|
||||
|
||||
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
|
||||
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
||||
export type { MapBounds, FlyTo };
|
||||
|
||||
// Channel color palette
|
||||
const CHANNEL_COLORS = [
|
||||
{ bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default)
|
||||
{ bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue
|
||||
{ bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green
|
||||
{ bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink
|
||||
{ bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple
|
||||
{ bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red
|
||||
{ bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal
|
||||
{ bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow
|
||||
];
|
||||
const NAVER_KEY = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
|
||||
|
||||
function getChannelColorMap(restaurants: Restaurant[]) {
|
||||
const channels = new Set<string>();
|
||||
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch)));
|
||||
const map: Record<string, typeof CHANNEL_COLORS[0]> = {};
|
||||
let i = 0;
|
||||
for (const ch of channels) {
|
||||
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
||||
i++;
|
||||
/**
|
||||
* #363 — 메인 지도 dispatcher.
|
||||
* - 키 미설정: GoogleMap (회귀 0)
|
||||
* - flyTo 또는 selected 좌표가 KR bbox: NaverMap
|
||||
* - 그 외 (해외 + 좌표 추정): GoogleMap
|
||||
* - 초기 마운트 시 화면 중심을 추정할 수 없으면 식당 평균 좌표로.
|
||||
*/
|
||||
export default function MapView(props: MapViewProps) {
|
||||
if (!NAVER_KEY) return <GoogleMapView {...props} />;
|
||||
|
||||
const targetLat = props.flyTo?.lat ?? props.selected?.latitude ?? avgLat(props.restaurants);
|
||||
const targetLng = props.flyTo?.lng ?? props.selected?.longitude ?? avgLng(props.restaurants);
|
||||
|
||||
if (targetLat != null && targetLng != null && isKoreaCoord(targetLat, targetLng)) {
|
||||
return <NaverMapView {...props} />;
|
||||
}
|
||||
return map;
|
||||
return <GoogleMapView {...props} />;
|
||||
}
|
||||
|
||||
export interface MapBounds {
|
||||
north: number;
|
||||
south: number;
|
||||
east: number;
|
||||
west: number;
|
||||
function avgLat(rs: MapViewProps["restaurants"]): number | null {
|
||||
if (!rs.length) return null;
|
||||
return rs.reduce((s, r) => s + r.latitude, 0) / rs.length;
|
||||
}
|
||||
|
||||
export interface FlyTo {
|
||||
lat: number;
|
||||
lng: number;
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
interface MapViewProps {
|
||||
restaurants: Restaurant[];
|
||||
selected?: Restaurant | null;
|
||||
onSelectRestaurant?: (r: Restaurant) => void;
|
||||
onBoundsChanged?: (bounds: MapBounds) => void;
|
||||
flyTo?: FlyTo | null;
|
||||
onMyLocation?: () => void;
|
||||
activeChannel?: string;
|
||||
}
|
||||
|
||||
type RestaurantProps = { restaurant: Restaurant };
|
||||
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
||||
|
||||
function useSupercluster(restaurants: Restaurant[]) {
|
||||
// #278 — indexRef 제거 (set만 되고 read 없는 dead code)
|
||||
const points: RestaurantFeature[] = useMemo(
|
||||
() =>
|
||||
restaurants.map((r) => ({
|
||||
type: "Feature" as const,
|
||||
geometry: { type: "Point" as const, coordinates: [r.longitude, r.latitude] },
|
||||
properties: { restaurant: r },
|
||||
})),
|
||||
[restaurants]
|
||||
);
|
||||
|
||||
const index = useMemo(() => {
|
||||
const sc = new Supercluster<{ restaurant: Restaurant }>({
|
||||
radius: 60,
|
||||
maxZoom: 16,
|
||||
minPoints: 2,
|
||||
});
|
||||
sc.load(points);
|
||||
return sc;
|
||||
}, [points]);
|
||||
|
||||
const getClusters = useCallback(
|
||||
(bounds: MapBounds, zoom: number) => {
|
||||
return index.getClusters(
|
||||
[bounds.west, bounds.south, bounds.east, bounds.north],
|
||||
Math.floor(zoom)
|
||||
);
|
||||
},
|
||||
[index]
|
||||
);
|
||||
|
||||
const getExpansionZoom = useCallback(
|
||||
(clusterId: number): number => {
|
||||
try {
|
||||
return index.getClusterExpansionZoom(clusterId);
|
||||
} catch {
|
||||
return 17;
|
||||
}
|
||||
},
|
||||
[index]
|
||||
);
|
||||
|
||||
return { getClusters, getExpansionZoom, index };
|
||||
}
|
||||
|
||||
function getClusterSize(count: number): number {
|
||||
if (count < 10) return 36;
|
||||
if (count < 50) return 42;
|
||||
if (count < 100) return 48;
|
||||
return 54;
|
||||
}
|
||||
|
||||
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
|
||||
const map = useMap();
|
||||
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
||||
const [zoom, setZoom] = useState(13);
|
||||
const [bounds, setBounds] = useState<MapBounds | null>(null);
|
||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
||||
|
||||
// #278 — restaurantMap 제거 (빌드만 되고 렌더에서 사용 안 됨, dead code)
|
||||
|
||||
const clusters = useMemo(() => {
|
||||
if (!bounds) return [];
|
||||
return getClusters(bounds, zoom);
|
||||
}, [bounds, zoom, getClusters]);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(r: Restaurant) => {
|
||||
setInfoTarget(r);
|
||||
onSelectRestaurant?.(r);
|
||||
},
|
||||
[onSelectRestaurant]
|
||||
);
|
||||
|
||||
const handleClusterClick = useCallback(
|
||||
(clusterId: number, lng: number, lat: number) => {
|
||||
if (!map) return;
|
||||
const expansionZoom = Math.min(getExpansionZoom(clusterId), 18);
|
||||
map.panTo({ lat, lng });
|
||||
map.setZoom(expansionZoom);
|
||||
},
|
||||
[map, getExpansionZoom]
|
||||
);
|
||||
|
||||
// Track camera changes for clustering
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const listener = map.addListener("idle", () => {
|
||||
const b = map.getBounds();
|
||||
const z = map.getZoom();
|
||||
if (b && z != null) {
|
||||
const ne = b.getNorthEast();
|
||||
const sw = b.getSouthWest();
|
||||
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
||||
setZoom(z);
|
||||
}
|
||||
});
|
||||
// Trigger initial bounds
|
||||
const b = map.getBounds();
|
||||
const z = map.getZoom();
|
||||
if (b && z != null) {
|
||||
const ne = b.getNorthEast();
|
||||
const sw = b.getSouthWest();
|
||||
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
||||
setZoom(z);
|
||||
}
|
||||
return () => google.maps.event.removeListener(listener);
|
||||
}, [map]);
|
||||
|
||||
// Fly to a specific location (region filter)
|
||||
useEffect(() => {
|
||||
if (!map || !flyTo) return;
|
||||
map.panTo({ lat: flyTo.lat, lng: flyTo.lng });
|
||||
if (flyTo.zoom) map.setZoom(flyTo.zoom);
|
||||
}, [map, flyTo]);
|
||||
|
||||
// Pan and zoom to selected restaurant
|
||||
useEffect(() => {
|
||||
if (!map || !selected) return;
|
||||
map.panTo({ lat: selected.latitude, lng: selected.longitude });
|
||||
map.setZoom(16);
|
||||
setInfoTarget(selected);
|
||||
}, [map, selected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{clusters.map((feature) => {
|
||||
const [lng, lat] = feature.geometry.coordinates;
|
||||
const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster;
|
||||
|
||||
if (isCluster) {
|
||||
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
|
||||
const size = getClusterSize(point_count);
|
||||
return (
|
||||
<AdvancedMarker
|
||||
key={`cluster-${cluster_id}`}
|
||||
position={{ lat, lng }}
|
||||
onClick={() => handleClusterClick(cluster_id, lng, lat)}
|
||||
zIndex={100}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`${point_count}개 식당이 모인 클러스터, 클릭하면 확대됩니다`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #E8720C 0%, #f59e0b 100%)",
|
||||
border: "3px solid #fff",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: size > 42 ? 15 : 13,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
transition: "transform 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{point_count}
|
||||
</div>
|
||||
</AdvancedMarker>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual marker
|
||||
const r = (feature.properties as { restaurant: Restaurant }).restaurant;
|
||||
const isSelected = selected?.id === r.id;
|
||||
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
||||
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
||||
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
|
||||
const c = chColor || CHANNEL_COLORS[0];
|
||||
return (
|
||||
<AdvancedMarker
|
||||
key={r.id}
|
||||
position={{ lat: r.latitude, lng: r.longitude }}
|
||||
onClick={() => handleMarkerClick(r)}
|
||||
zIndex={isSelected ? 1000 : 1}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`${r.name}${isClosed ? ' (폐업)' : ''}, 클릭하면 상세 정보가 표시됩니다`}
|
||||
style={{ display: "flex", flexDirection: "column", alignItems: "center", transition: "transform 0.2s ease", transform: isSelected ? "scale(1.15)" : "scale(1)", opacity: isClosed ? 0.5 : 1 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
backgroundColor: isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg,
|
||||
color: isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
borderRadius: 6,
|
||||
border: isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`,
|
||||
boxShadow: isSelected
|
||||
? "0 2px 8px rgba(37,99,235,0.4)"
|
||||
: `0 1px 4px ${c.border}40`,
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 120,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
textDecoration: isClosed ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-rounded" style={{ fontSize: 14, width: 14, height: 14, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
|
||||
{r.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "6px solid transparent",
|
||||
borderRight: "6px solid transparent",
|
||||
borderTop: isSelected ? "6px solid #1d4ed8" : `6px solid ${c.arrow}`,
|
||||
marginTop: -1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AdvancedMarker>
|
||||
);
|
||||
})}
|
||||
|
||||
{infoTarget && (
|
||||
<InfoWindow
|
||||
position={{ lat: infoTarget.latitude, lng: infoTarget.longitude }}
|
||||
onCloseClick={() => setInfoTarget(null)}
|
||||
>
|
||||
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, width: 18, height: 18, overflow: "hidden", display: "inline-flex", alignItems: "center", justifyContent: "center", verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{infoTarget.name}</h3>
|
||||
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
||||
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
||||
)}
|
||||
{infoTarget.business_status === "CLOSED_TEMPORARILY" && (
|
||||
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold">임시휴업</span>
|
||||
)}
|
||||
</div>
|
||||
{infoTarget.rating && (
|
||||
<p className="text-xs mt-0.5">
|
||||
<span className="text-yellow-500">★</span> {infoTarget.rating}
|
||||
{infoTarget.rating_count && (
|
||||
<span className="text-gray-400 ml-1">({infoTarget.rating_count.toLocaleString()})</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{infoTarget.cuisine_type && (
|
||||
<p className="text-xs text-gray-500">{infoTarget.cuisine_type}</p>
|
||||
)}
|
||||
{infoTarget.address && (
|
||||
<p className="text-[11px] text-gray-400 mt-1">{infoTarget.address}</p>
|
||||
)}
|
||||
{infoTarget.price_range && (
|
||||
<p className="text-[11px] text-gray-400">{infoTarget.price_range}</p>
|
||||
)}
|
||||
{infoTarget.phone && (
|
||||
<p className="text-[11px] text-gray-400">{infoTarget.phone}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSelectRestaurant?.(infoTarget)}
|
||||
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
상세 보기
|
||||
</button>
|
||||
</div>
|
||||
</InfoWindow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel }: MapViewProps) {
|
||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||
const channelNames = useMemo(() => {
|
||||
const names = Object.keys(channelColors);
|
||||
if (activeChannel) return names.filter((n) => n === activeChannel);
|
||||
return names;
|
||||
}, [channelColors, activeChannel]);
|
||||
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCameraChanged = useCallback((ev: { detail: { bounds: { north: number; south: number; east: number; west: number } } }) => {
|
||||
if (!onBoundsChanged) return;
|
||||
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||
boundsTimerRef.current = setTimeout(() => {
|
||||
const { north, south, east, west } = ev.detail.bounds;
|
||||
onBoundsChanged({ north, south, east, west });
|
||||
}, 150);
|
||||
}, [onBoundsChanged]);
|
||||
|
||||
// #278 — 언마운트 시 디바운스 타이머 정리 (메모리 누수 + unmounted setState 경고 방지)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<APIProvider apiKey={API_KEY}>
|
||||
<Map
|
||||
defaultCenter={SEOUL_CENTER}
|
||||
defaultZoom={13}
|
||||
mapId="tasteby-map"
|
||||
className="h-full w-full"
|
||||
colorScheme="LIGHT"
|
||||
mapTypeControl={false}
|
||||
fullscreenControl={false}
|
||||
onCameraChanged={handleCameraChanged}
|
||||
>
|
||||
<MapContent
|
||||
restaurants={restaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={onSelectRestaurant}
|
||||
flyTo={flyTo}
|
||||
activeChannel={activeChannel}
|
||||
/>
|
||||
</Map>
|
||||
{onMyLocation && (
|
||||
<button
|
||||
onClick={onMyLocation}
|
||||
aria-label="내 위치로 이동"
|
||||
// #278 — 44×44px 터치 영역 확보 (이전 36px)
|
||||
className="absolute top-2 right-2 w-11 h-11 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10 touch-manipulation"
|
||||
title="내 위치"
|
||||
>
|
||||
<Icon name="my_location" size={22} />
|
||||
</button>
|
||||
)}
|
||||
{channelNames.length > 0 && (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="채널 범례"
|
||||
className="absolute bottom-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10"
|
||||
>
|
||||
{channelNames.map((ch) => (
|
||||
<div key={ch} className="flex items-center gap-1">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block w-2.5 h-2.5 rounded-full border"
|
||||
style={{ backgroundColor: channelColors[ch].border, borderColor: channelColors[ch].border }}
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</APIProvider>
|
||||
);
|
||||
function avgLng(rs: MapViewProps["restaurants"]): number | null {
|
||||
if (!rs.length) return null;
|
||||
return rs.reduce((s, r) => s + r.longitude, 0) / rs.length;
|
||||
}
|
||||
|
||||
29
frontend/src/components/MapView.types.ts
Normal file
29
frontend/src/components/MapView.types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Restaurant } from "@/lib/api";
|
||||
|
||||
export interface MapBounds {
|
||||
north: number;
|
||||
south: number;
|
||||
east: number;
|
||||
west: number;
|
||||
}
|
||||
|
||||
export interface FlyTo {
|
||||
lat: number;
|
||||
lng: number;
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
export interface MapViewProps {
|
||||
restaurants: Restaurant[];
|
||||
selected?: Restaurant | null;
|
||||
onSelectRestaurant?: (r: Restaurant) => void;
|
||||
onBoundsChanged?: (bounds: MapBounds) => void;
|
||||
flyTo?: FlyTo | null;
|
||||
onMyLocation?: () => void;
|
||||
activeChannel?: string;
|
||||
}
|
||||
|
||||
// 좌표가 한국 영토 bbox 안인지 (WGS84).
|
||||
export function isKoreaCoord(lat: number, lng: number): boolean {
|
||||
return lat >= 33 && lat <= 38.7 && lng >= 124 && lng <= 132;
|
||||
}
|
||||
@@ -41,8 +41,14 @@ export default function MyReviewsList({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{/* #343 — WAI-ARIA Tabs 패턴 */}
|
||||
<div role="tablist" aria-label="내 활동" className="flex gap-1 border-b">
|
||||
<button
|
||||
role="tab"
|
||||
id="tab-reviews"
|
||||
aria-selected={tab === "reviews"}
|
||||
aria-controls="panel-reviews"
|
||||
tabIndex={tab === "reviews" ? 0 : -1}
|
||||
onClick={() => setTab("reviews")}
|
||||
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === "reviews"
|
||||
@@ -54,6 +60,11 @@ export default function MyReviewsList({
|
||||
리뷰 ({reviews.length})
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
id="tab-memos"
|
||||
aria-selected={tab === "memos"}
|
||||
aria-controls="panel-memos"
|
||||
tabIndex={tab === "memos" ? 0 : -1}
|
||||
onClick={() => setTab("memos")}
|
||||
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === "memos"
|
||||
@@ -67,7 +78,8 @@ export default function MyReviewsList({
|
||||
</div>
|
||||
|
||||
{tab === "reviews" ? (
|
||||
reviews.length === 0 ? (
|
||||
<div role="tabpanel" id="panel-reviews" aria-labelledby="tab-reviews">
|
||||
{reviews.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 py-8 text-center">
|
||||
아직 작성한 리뷰가 없습니다.
|
||||
</p>
|
||||
@@ -100,9 +112,11 @@ export default function MyReviewsList({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
memos.length === 0 ? (
|
||||
<div role="tabpanel" id="panel-memos" aria-labelledby="tab-memos">
|
||||
{memos.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 py-8 text-center">
|
||||
아직 작성한 메모가 없습니다.
|
||||
</p>
|
||||
@@ -137,7 +151,8 @@ export default function MyReviewsList({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
358
frontend/src/components/NaverMapView.tsx
Normal file
358
frontend/src/components/NaverMapView.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Supercluster from "supercluster";
|
||||
import type { Restaurant } from "@/lib/api";
|
||||
import Icon from "@/components/Icon";
|
||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||
import type { MapBounds, MapViewProps } from "@/components/MapView.types";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
naver?: { maps: NaverMaps };
|
||||
}
|
||||
}
|
||||
type LatLng = { lat: () => number; lng: () => number };
|
||||
type NaverMaps = {
|
||||
LatLng: new (lat: number, lng: number) => LatLng;
|
||||
Map: new (el: HTMLElement, opts: Record<string, unknown>) => NaverMapInstance;
|
||||
Marker: new (opts: Record<string, unknown>) => NaverMarker;
|
||||
InfoWindow: new (opts: Record<string, unknown>) => NaverInfoWindow;
|
||||
Event: { addListener: (target: unknown, type: string, fn: (...args: unknown[]) => void) => unknown; removeListener: (handler: unknown) => void };
|
||||
Size: new (w: number, h: number) => unknown;
|
||||
Point: new (x: number, y: number) => unknown;
|
||||
};
|
||||
type NaverMapInstance = {
|
||||
setCenter: (latlng: unknown) => void;
|
||||
setZoom: (zoom: number, useEffect?: boolean) => void;
|
||||
getZoom: () => number;
|
||||
getBounds: () => { getNE: () => LatLng; getSW: () => LatLng };
|
||||
panTo: (latlng: unknown, opts?: Record<string, unknown>) => void;
|
||||
morph: (latlng: unknown, zoom?: number, transitionOptions?: Record<string, unknown>) => void;
|
||||
refresh: (noEffect?: boolean) => void;
|
||||
};
|
||||
type NaverMarker = {
|
||||
setMap: (map: NaverMapInstance | null) => void;
|
||||
setIcon: (icon: Record<string, unknown>) => void;
|
||||
setPosition: (latlng: unknown) => void;
|
||||
getPosition: () => LatLng;
|
||||
};
|
||||
type NaverInfoWindow = {
|
||||
open: (map: NaverMapInstance, marker: NaverMarker) => void;
|
||||
close: () => void;
|
||||
setContent: (content: string) => void;
|
||||
getMap: () => NaverMapInstance | null;
|
||||
};
|
||||
|
||||
const NAVER_CLIENT_ID = process.env.NEXT_PUBLIC_NAVER_MAP_CLIENT_ID || "";
|
||||
|
||||
// Channel color palette — GoogleMapView와 동일
|
||||
const CHANNEL_COLORS = [
|
||||
{ bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default)
|
||||
{ bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue
|
||||
{ bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green
|
||||
{ bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink
|
||||
{ bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple
|
||||
{ bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red
|
||||
{ bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal
|
||||
{ bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow
|
||||
];
|
||||
|
||||
function getChannelColorMap(restaurants: Restaurant[]) {
|
||||
const channels = new Set<string>();
|
||||
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch)));
|
||||
const map: Record<string, typeof CHANNEL_COLORS[0]> = {};
|
||||
let i = 0;
|
||||
for (const ch of channels) {
|
||||
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
||||
i++;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function useNaverMaps(): { ready: boolean; error: string | null } {
|
||||
const [ready, setReady] = useState(typeof window !== "undefined" && !!window.naver?.maps);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!NAVER_CLIENT_ID) { setError("NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 미설정"); return; }
|
||||
if (window.naver?.maps) { setReady(true); return; }
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[data-naver-maps]`);
|
||||
if (existing) {
|
||||
existing.addEventListener("load", () => setReady(true), { once: true });
|
||||
return;
|
||||
}
|
||||
const s = document.createElement("script");
|
||||
s.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${NAVER_CLIENT_ID}`;
|
||||
s.async = true;
|
||||
s.dataset.naverMaps = "1";
|
||||
s.onload = () => setReady(true);
|
||||
s.onerror = () => setError("naver maps v3 스크립트 로드 실패");
|
||||
document.head.appendChild(s);
|
||||
}, []);
|
||||
|
||||
return { ready, error };
|
||||
}
|
||||
|
||||
type RestaurantProps = { restaurant: Restaurant };
|
||||
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
|
||||
|
||||
function useSupercluster(restaurants: Restaurant[]) {
|
||||
const points: RestaurantFeature[] = useMemo(
|
||||
() => restaurants.map((r) => ({
|
||||
type: "Feature" as const,
|
||||
geometry: { type: "Point" as const, coordinates: [r.longitude, r.latitude] },
|
||||
properties: { restaurant: r },
|
||||
})),
|
||||
[restaurants]
|
||||
);
|
||||
const index = useMemo(() => {
|
||||
const sc = new Supercluster<{ restaurant: Restaurant }>({ radius: 60, maxZoom: 16, minPoints: 2 });
|
||||
sc.load(points);
|
||||
return sc;
|
||||
}, [points]);
|
||||
const getClusters = useCallback((bounds: MapBounds, zoom: number) =>
|
||||
index.getClusters([bounds.west, bounds.south, bounds.east, bounds.north], Math.floor(zoom))
|
||||
, [index]);
|
||||
const getExpansionZoom = useCallback((clusterId: number) => {
|
||||
try { return index.getClusterExpansionZoom(clusterId); } catch { return 17; }
|
||||
}, [index]);
|
||||
return { getClusters, getExpansionZoom };
|
||||
}
|
||||
|
||||
function getClusterSize(count: number): number {
|
||||
if (count < 10) return 36;
|
||||
if (count < 50) return 42;
|
||||
if (count < 100) return 48;
|
||||
return 54;
|
||||
}
|
||||
|
||||
// 단일 마커 — 식당명 박스 + 화살표 핀 (GoogleMapView와 동일 디자인)
|
||||
function markerIconHtml(
|
||||
name: string,
|
||||
cuisineIcon: string,
|
||||
c: typeof CHANNEL_COLORS[0],
|
||||
opts: { isSelected: boolean; isClosed: boolean }
|
||||
): string {
|
||||
const { isSelected, isClosed } = opts;
|
||||
const bg = isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg;
|
||||
const text = isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text;
|
||||
const border = isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`;
|
||||
const shadow = isSelected ? "0 2px 8px rgba(37,99,235,0.4)" : `0 1px 4px ${c.border}40`;
|
||||
const arrowColor = isSelected ? "#1d4ed8" : c.arrow;
|
||||
const opacity = isClosed ? 0.5 : 1;
|
||||
const deco = isClosed ? "line-through" : "none";
|
||||
return `
|
||||
<div style="display:flex;flex-direction:column;align-items:center;transition:transform .2s ease;transform:scale(${isSelected ? 1.15 : 1});opacity:${opacity};">
|
||||
<div style="padding:4px 8px;background:${bg};color:${text};font-size:12px;font-weight:600;border-radius:6px;border:${border};box-shadow:${shadow};white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis;text-decoration:${deco};">
|
||||
<span class="material-symbols-rounded" style="font-size:14px;width:14px;height:14px;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;margin-right:3px;vertical-align:middle;color:#E8720C;">${escapeHtml(cuisineIcon)}</span>${escapeHtml(name)}
|
||||
</div>
|
||||
<div style="width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid ${arrowColor};margin-top:-1px;"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
// SVG data URL — 클러스터(숫자)
|
||||
function clusterIconHtml(count: number, size: number): string {
|
||||
return `<div style="width:${size}px;height:${size}px;border-radius:9999px;background:rgba(245,158,11,.92);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:${size > 44 ? 14 : 12}px;border:2px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);">${count}</div>`;
|
||||
}
|
||||
|
||||
export default function NaverMapView({
|
||||
restaurants,
|
||||
selected,
|
||||
onSelectRestaurant,
|
||||
onBoundsChanged,
|
||||
flyTo,
|
||||
onMyLocation,
|
||||
activeChannel,
|
||||
}: MapViewProps) {
|
||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||
const { ready, error } = useNaverMaps();
|
||||
const divRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<NaverMapInstance | null>(null);
|
||||
const markersRef = useRef<NaverMarker[]>([]);
|
||||
const infoWindowRef = useRef<NaverInfoWindow | null>(null);
|
||||
const [bounds, setBounds] = useState<MapBounds | null>(null);
|
||||
const [zoom, setZoom] = useState(13);
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
|
||||
|
||||
// 지도 1회 생성
|
||||
useEffect(() => {
|
||||
if (!ready || !divRef.current || mapRef.current) return;
|
||||
try {
|
||||
const n = window.naver!.maps;
|
||||
const initLat = flyTo?.lat ?? selected?.latitude ?? 37.5665;
|
||||
const initLng = flyTo?.lng ?? selected?.longitude ?? 126.978;
|
||||
const initZoom = flyTo?.zoom ?? 13;
|
||||
const m = new n.Map(divRef.current, {
|
||||
center: new n.LatLng(initLat, initLng),
|
||||
zoom: initZoom,
|
||||
logoControl: false,
|
||||
mapDataControl: false,
|
||||
scaleControl: false,
|
||||
zoomControl: false,
|
||||
});
|
||||
mapRef.current = m;
|
||||
infoWindowRef.current = new n.InfoWindow({
|
||||
borderWidth: 0,
|
||||
anchorSize: new n.Size(10, 10),
|
||||
pixelOffset: new n.Point(0, -8),
|
||||
backgroundColor: "transparent",
|
||||
disableAnchor: false,
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
try { m.refresh(true); } catch { /* noop */ }
|
||||
});
|
||||
ro.observe(divRef.current);
|
||||
|
||||
// bounds_changed가 줌/팬 끝나는 시점에 한 번만 emit (SDK가 throttle)
|
||||
const sync = () => {
|
||||
try {
|
||||
const b = m.getBounds();
|
||||
const ne = b.getNE(), sw = b.getSW();
|
||||
const nb: MapBounds = { north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() };
|
||||
setBounds(nb);
|
||||
setZoom(m.getZoom());
|
||||
onBoundsChanged?.(nb);
|
||||
} catch (e) {
|
||||
console.warn("[NaverMap] sync failed", e);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
try { m.refresh(true); } catch { /* noop */ }
|
||||
sync();
|
||||
});
|
||||
// idle = 줌/팬 끝났을 때 한 번 (bounds_changed보다 적게 발화 → 성능)
|
||||
n.Event.addListener(m, "idle", sync);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("[NaverMap] init failed", e);
|
||||
setInitError(msg);
|
||||
}
|
||||
}, [ready, flyTo, selected, onBoundsChanged]);
|
||||
|
||||
// flyTo 변경 반영 — morph로 panTo+setZoom 충돌 회피
|
||||
useEffect(() => {
|
||||
const m = mapRef.current;
|
||||
if (!m || !flyTo || !window.naver?.maps) return;
|
||||
const latlng = new window.naver.maps.LatLng(flyTo.lat, flyTo.lng);
|
||||
if (flyTo.zoom) m.morph(latlng, flyTo.zoom);
|
||||
else m.panTo(latlng);
|
||||
}, [flyTo]);
|
||||
|
||||
// selected 변경 시 자동 중앙 — 현재 줌이 16 이상이면 그대로, 미만이면 16으로 확대
|
||||
// (클러스터 expansion으로 18까지 들어간 상태에서 마커 클릭 시 다시 16으로 줄어 클러스터링되는 것 방지)
|
||||
useEffect(() => {
|
||||
const m = mapRef.current;
|
||||
if (!m || !selected || !window.naver?.maps) return;
|
||||
if (selected.latitude == null || selected.longitude == null) return;
|
||||
const latlng = new window.naver.maps.LatLng(selected.latitude, selected.longitude);
|
||||
const targetZoom = Math.max(m.getZoom(), 16);
|
||||
m.morph(latlng, targetZoom);
|
||||
}, [selected]);
|
||||
|
||||
// 클러스터 계산 (bounds/zoom 변경 시)
|
||||
const clusters = useMemo(() => {
|
||||
if (!bounds) return [];
|
||||
return getClusters(bounds, zoom);
|
||||
}, [bounds, zoom, getClusters]);
|
||||
|
||||
// 마커를 SDK 네이티브로 그림 — clusters 바뀌면 기존 마커 모두 제거 후 새로 생성
|
||||
useEffect(() => {
|
||||
const m = mapRef.current;
|
||||
const naver = window.naver?.maps;
|
||||
if (!m || !naver) return;
|
||||
|
||||
// 기존 마커 제거
|
||||
for (const mk of markersRef.current) mk.setMap(null);
|
||||
markersRef.current = [];
|
||||
|
||||
for (const feature of clusters) {
|
||||
const [lng, lat] = feature.geometry.coordinates;
|
||||
const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster;
|
||||
if (isCluster) {
|
||||
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
|
||||
const size = getClusterSize(point_count);
|
||||
const marker = new naver.Marker({
|
||||
position: new naver.LatLng(lat, lng),
|
||||
map: m,
|
||||
icon: {
|
||||
content: clusterIconHtml(point_count, size),
|
||||
anchor: new naver.Point(size / 2, size / 2),
|
||||
},
|
||||
});
|
||||
naver.Event.addListener(marker, "click", () => {
|
||||
const z = Math.min(getExpansionZoom(cluster_id), 18);
|
||||
m.morph(new naver.LatLng(lat, lng), z);
|
||||
});
|
||||
markersRef.current.push(marker);
|
||||
} else {
|
||||
const r = (feature.properties as RestaurantProps).restaurant;
|
||||
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
||||
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
|
||||
const isSelected = selected?.id === r.id;
|
||||
const isClosed = r.business_status === "CLOSED_PERMANENTLY" || r.business_status === "CLOSED_TEMPORARILY";
|
||||
const cuisineIcon = getCuisineIcon(r.cuisine_type);
|
||||
const marker = new naver.Marker({
|
||||
position: new naver.LatLng(lat, lng),
|
||||
map: m,
|
||||
title: r.name,
|
||||
zIndex: isSelected ? 1000 : 1,
|
||||
icon: {
|
||||
content: markerIconHtml(r.name, cuisineIcon, chColor ?? CHANNEL_COLORS[0], { isSelected, isClosed }),
|
||||
// 박스 폭 가변 — 화살표 끝(하단 중앙)이 좌표에 위치하도록 추정 anchor
|
||||
// approxWidth = textLen * 7 + 30 (icon+padding), height = box 24 + arrow 6 = 30
|
||||
anchor: new naver.Point(Math.min(r.name.length * 4 + 18, 64), 30),
|
||||
},
|
||||
});
|
||||
naver.Event.addListener(marker, "click", () => {
|
||||
onSelectRestaurant?.(r);
|
||||
});
|
||||
markersRef.current.push(marker);
|
||||
}
|
||||
}
|
||||
}, [clusters, getExpansionZoom, onSelectRestaurant, channelColors, activeChannel, selected]);
|
||||
|
||||
// 컴포넌트 unmount 시 마커 정리
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const mk of markersRef.current) mk.setMap(null);
|
||||
markersRef.current = [];
|
||||
infoWindowRef.current?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<div
|
||||
ref={divRef}
|
||||
className="absolute inset-0"
|
||||
style={{ width: "100%", height: "100%", backgroundColor: "#e5e7eb" }}
|
||||
/>
|
||||
{(error || initError) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-red-600 bg-white/80 pointer-events-none">
|
||||
{error ?? initError}
|
||||
</div>
|
||||
)}
|
||||
{!ready && !error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-500 bg-white/80 pointer-events-none">
|
||||
네이버 지도 로딩 중…
|
||||
</div>
|
||||
)}
|
||||
{onMyLocation && (
|
||||
<button
|
||||
onClick={onMyLocation}
|
||||
aria-label="내 위치"
|
||||
className="absolute right-3 bottom-3 size-11 rounded-full bg-white shadow-lg flex items-center justify-center z-10"
|
||||
>
|
||||
<Icon name="my-location" size={22} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch] as string));
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import ReviewSection from "@/components/ReviewSection";
|
||||
import MemoSection from "@/components/MemoSection";
|
||||
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
|
||||
import Icon from "@/components/Icon";
|
||||
import { formatRegion } from "@/lib/region";
|
||||
|
||||
interface RestaurantDetailProps {
|
||||
restaurant: Restaurant;
|
||||
@@ -24,6 +25,15 @@ function buildSearchQuery(r: Restaurant): string {
|
||||
return r.name;
|
||||
}
|
||||
|
||||
// 좌표 기반 한국 판정 (WGS84). KR bbox 대략 33~38.7°N, 124~132°E.
|
||||
// 좌표 없으면 region 첫 토큰으로 fallback (구 데이터 호환).
|
||||
function isKoreaRestaurant(r: Restaurant): boolean {
|
||||
if (r.latitude != null && r.longitude != null) {
|
||||
return r.latitude >= 33 && r.latitude <= 38.7 && r.longitude >= 124 && r.longitude <= 132;
|
||||
}
|
||||
return !r.region || r.region.split("|")[0] === "한국";
|
||||
}
|
||||
|
||||
export default function RestaurantDetail({
|
||||
restaurant,
|
||||
onClose,
|
||||
@@ -118,9 +128,9 @@ export default function RestaurantDetail({
|
||||
<span className="text-gray-400 dark:text-gray-500">주소</span> <span className="text-gray-600 dark:text-gray-300">{restaurant.address}</span>
|
||||
</p>
|
||||
)}
|
||||
{restaurant.region && (
|
||||
{formatRegion(restaurant.region) && (
|
||||
<p>
|
||||
<span className="text-gray-400 dark:text-gray-500">지역</span> <span className="text-gray-600 dark:text-gray-300">{restaurant.region}</span>
|
||||
<span className="text-gray-400 dark:text-gray-500">지역</span> <span className="text-gray-600 dark:text-gray-300">{formatRegion(restaurant.region)}</span>
|
||||
</p>
|
||||
)}
|
||||
{restaurant.price_range && (
|
||||
@@ -138,48 +148,61 @@ export default function RestaurantDetail({
|
||||
)}
|
||||
{restaurant.google_place_id && (
|
||||
<p className="flex gap-3">
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
|
||||
>
|
||||
Google Maps에서 보기
|
||||
</a>
|
||||
{(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
|
||||
{isKoreaRestaurant(restaurant) ? (
|
||||
<>
|
||||
<a
|
||||
href={`https://map.naver.com/p/search/${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-600 dark:text-green-400 hover:underline text-xs"
|
||||
>
|
||||
네이버 지도에서 보기
|
||||
</a>
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-500 dark:text-gray-400 hover:underline text-xs"
|
||||
>
|
||||
Google Maps
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(buildSearchQuery(restaurant))}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-600 dark:text-green-400 hover:underline text-xs"
|
||||
className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
|
||||
>
|
||||
네이버 지도에서 보기
|
||||
Google Maps에서 보기
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{restaurant.tabling_url && restaurant.tabling_url !== "NONE" && (
|
||||
{isKoreaRestaurant(restaurant) && restaurant.tabling_url && restaurant.tabling_url !== "NONE" && (
|
||||
<a
|
||||
href={restaurant.tabling_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="테이블링에서 줄서기 (새 탭)"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-rose-500 hover:bg-rose-600 text-white rounded-lg text-sm font-semibold transition-colors"
|
||||
>
|
||||
<span>T</span>
|
||||
<span aria-hidden="true">T</span>
|
||||
<span>테이블링에서 줄서기</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{restaurant.catchtable_url && restaurant.catchtable_url !== "NONE" && (
|
||||
{isKoreaRestaurant(restaurant) && restaurant.catchtable_url && restaurant.catchtable_url !== "NONE" && (
|
||||
<a
|
||||
href={restaurant.catchtable_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="캐치테이블에서 예약하기 (새 탭)"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-violet-500 hover:bg-violet-600 text-white rounded-lg text-sm font-semibold transition-colors"
|
||||
>
|
||||
<span>C</span>
|
||||
<span aria-hidden="true">C</span>
|
||||
<span>캐치테이블에서 예약하기</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { Restaurant } from "@/lib/api";
|
||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||
import Icon from "@/components/Icon";
|
||||
import { formatRegion } from "@/lib/region";
|
||||
import { RestaurantListSkeleton } from "@/components/Skeleton";
|
||||
|
||||
interface RestaurantListProps {
|
||||
@@ -50,8 +51,8 @@ export default function RestaurantList({
|
||||
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
|
||||
{r.name}
|
||||
</h4>
|
||||
{r.region && (
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</span>
|
||||
{formatRegion(r.region) && (
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{formatRegion(r.region)}</span>
|
||||
)}
|
||||
{r.rating && (
|
||||
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0">★ {r.rating}</span>
|
||||
|
||||
@@ -257,6 +257,9 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{review.user_avatar_url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
// #343 — Google avatar URL은 remotePatterns에 추가됨.
|
||||
// next/image 전환은 SSR/lazy 효과 미미한 5x5 아바타라 후속에서 일괄 적용.
|
||||
<img
|
||||
src={review.user_avatar_url}
|
||||
alt=""
|
||||
|
||||
56
frontend/src/i18n/LocaleProvider.tsx
Normal file
56
frontend/src/i18n/LocaleProvider.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, Locale, detectBrowserLocale, isLocale } from "./config";
|
||||
|
||||
// #352 — 메시지 파일을 정적 import (Tree-shaking 가능, 클라이언트 번들에 4언어 모두 포함되지만
|
||||
// 30개 키 수준이라 부담 미미. 키가 늘어나면 동적 import로 분할 검토)
|
||||
import ko from "@/messages/ko.json";
|
||||
import en from "@/messages/en.json";
|
||||
import ja from "@/messages/ja.json";
|
||||
import es from "@/messages/es.json";
|
||||
|
||||
const MESSAGES: Record<Locale, typeof ko> = { ko, en, ja, es };
|
||||
|
||||
interface LocaleContextValue {
|
||||
locale: Locale;
|
||||
setLocale: (l: Locale) => void;
|
||||
}
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
export function useLocale() {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (!ctx) throw new Error("useLocale must be used within LocaleProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function LocaleProvider({ children }: { children: React.ReactNode }) {
|
||||
// SSR 단계는 기본 로케일로 시작 (hydration mismatch 방지)
|
||||
const [locale, setLocaleState] = useState<Locale>(DEFAULT_LOCALE);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
const initial: Locale = isLocale(saved) ? saved : detectBrowserLocale();
|
||||
if (initial !== locale) setLocaleState(initial);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const setLocale = (l: Locale) => {
|
||||
setLocaleState(l);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, l);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={{ locale, setLocale }}>
|
||||
<NextIntlClientProvider locale={locale} messages={MESSAGES[locale]}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
28
frontend/src/i18n/config.ts
Normal file
28
frontend/src/i18n/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// #352 i18n 뼈대 — 로케일 목록/기본값
|
||||
|
||||
export const LOCALES = ["ko", "en", "ja", "es"] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = "ko";
|
||||
export const LOCALE_STORAGE_KEY = "tasteby_locale";
|
||||
|
||||
export const LOCALE_LABELS: Record<Locale, { flag: string; label: string; native: string }> = {
|
||||
ko: { flag: "🇰🇷", label: "Korean", native: "한국어" },
|
||||
en: { flag: "🇺🇸", label: "English", native: "English" },
|
||||
ja: { flag: "🇯🇵", label: "Japanese", native: "日本語" },
|
||||
es: { flag: "🇪🇸", label: "Spanish", native: "Español" },
|
||||
};
|
||||
|
||||
export function isLocale(value: string | null | undefined): value is Locale {
|
||||
return value != null && (LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 언어 감지 → 지원 로케일이면 그것, 아니면 기본값.
|
||||
* SSR-safe (typeof window 체크 호출자).
|
||||
*/
|
||||
export function detectBrowserLocale(): Locale {
|
||||
if (typeof navigator === "undefined") return DEFAULT_LOCALE;
|
||||
const code = navigator.language.split("-")[0].toLowerCase();
|
||||
return isLocale(code) ? code : DEFAULT_LOCALE;
|
||||
}
|
||||
@@ -51,6 +51,8 @@ export interface Restaurant {
|
||||
website: string | null;
|
||||
channels?: string[];
|
||||
foods_mentioned?: string[];
|
||||
// #533 — 의미있는(strong/unknown) 영상 언급 수
|
||||
video_count?: number | null;
|
||||
// #322 LLM 검증
|
||||
hidden?: boolean;
|
||||
hidden_reason?: string | null;
|
||||
@@ -168,6 +170,7 @@ export const api = {
|
||||
channel?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
min_mentions?: number;
|
||||
}) {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.cuisine) sp.set("cuisine", params.cuisine);
|
||||
@@ -175,6 +178,7 @@ export const api = {
|
||||
if (params?.channel) sp.set("channel", params.channel);
|
||||
if (params?.limit) sp.set("limit", String(params.limit));
|
||||
if (params?.offset) sp.set("offset", String(params.offset));
|
||||
if (params?.min_mentions) sp.set("min_mentions", String(params.min_mentions));
|
||||
const qs = sp.toString();
|
||||
return fetchApi<Restaurant[]>(`/api/restaurants${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
@@ -39,7 +39,7 @@ const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
|
||||
{ keyword: "라멘", icon: "ramen_dining" },
|
||||
{ keyword: "돈카츠", icon: "lunch_dining" },
|
||||
{ keyword: "텐동/튀김", icon: "tapas" },
|
||||
{ keyword: "이자카야", icon: "sake" },
|
||||
{ keyword: "이자카야", icon: "local_bar" },
|
||||
{ keyword: "야키니쿠", icon: "kebab_dining" },
|
||||
{ keyword: "카레", icon: "skillet" },
|
||||
{ keyword: "소바/우동", icon: "ramen_dining" },
|
||||
@@ -77,78 +77,78 @@ export function getCuisineIcon(cuisineType: string | null | undefined): string {
|
||||
return CUISINE_ICON_MAP[main] || DEFAULT_ICON;
|
||||
}
|
||||
|
||||
// ── Tabler Icons (for genre card chips) ──
|
||||
// Returns Tabler icon component name (PascalCase without "Icon" prefix)
|
||||
// ── Phosphor Icons (for genre card chips) ──
|
||||
// Returns Phosphor icon component name (PascalCase)
|
||||
|
||||
const TABLER_CUISINE_MAP: Record<string, string> = {
|
||||
"한식": "BowlChopsticks",
|
||||
"일식": "Fish",
|
||||
"중식": "Soup",
|
||||
const PHOSPHOR_CUISINE_MAP: Record<string, string> = {
|
||||
"한식": "BowlFood",
|
||||
"일식": "FishSimple",
|
||||
"중식": "Fire",
|
||||
"양식": "Pizza",
|
||||
"아시아": "BowlSpoon",
|
||||
"아시아": "BowlSteam",
|
||||
"기타": "Cookie",
|
||||
};
|
||||
|
||||
const TABLER_SUB_RULES: { keyword: string; icon: string }[] = [
|
||||
const PHOSPHOR_SUB_RULES: { keyword: string; icon: string }[] = [
|
||||
// 한식
|
||||
{ keyword: "백반/한정식", icon: "BowlChopsticks" },
|
||||
{ keyword: "국밥/해장국", icon: "Soup" },
|
||||
{ keyword: "찌개/전골/탕", icon: "Cooker" },
|
||||
{ keyword: "삼겹살/돼지구이", icon: "Meat" },
|
||||
{ keyword: "소고기/한우구이", icon: "Grill" },
|
||||
{ keyword: "곱창/막창", icon: "GrillFork" },
|
||||
{ keyword: "닭/오리구이", icon: "Meat" },
|
||||
{ keyword: "족발/보쌈", icon: "Meat" },
|
||||
{ keyword: "백반/한정식", icon: "BowlFood" },
|
||||
{ keyword: "국밥/해장국", icon: "BowlSteam" },
|
||||
{ keyword: "찌개/전골/탕", icon: "CookingPot" },
|
||||
{ keyword: "삼겹살/돼지구이", icon: "food:pig" },
|
||||
{ keyword: "소고기/한우구이", icon: "Cow" },
|
||||
{ keyword: "곱창/막창", icon: "Flame" },
|
||||
{ keyword: "닭/오리구이", icon: "Bird" },
|
||||
{ keyword: "족발/보쌈", icon: "food:pig" },
|
||||
{ keyword: "회/횟집", icon: "Fish" },
|
||||
{ keyword: "해산물", icon: "Fish" },
|
||||
{ keyword: "분식", icon: "EggFried" },
|
||||
{ keyword: "면", icon: "BowlChopsticks" },
|
||||
{ keyword: "죽/죽집", icon: "BowlSpoon" },
|
||||
{ keyword: "순대/순대국", icon: "Soup" },
|
||||
{ keyword: "장어/민물", icon: "Fish" },
|
||||
{ keyword: "주점/포차", icon: "Beer" },
|
||||
{ keyword: "파인다이닝/코스", icon: "GlassChampagne" },
|
||||
{ keyword: "해산물", icon: "Shrimp" },
|
||||
{ keyword: "분식", icon: "food:tteok" },
|
||||
{ keyword: "면", icon: "food:noodle" },
|
||||
{ keyword: "죽/죽집", icon: "BowlFood" },
|
||||
{ keyword: "순대/순대국", icon: "BowlSteam" },
|
||||
{ keyword: "장어/민물", icon: "FishSimple" },
|
||||
{ keyword: "주점/포차", icon: "BeerStein" },
|
||||
{ keyword: "파인다이닝/코스", icon: "Champagne" },
|
||||
// 일식
|
||||
{ keyword: "스시/오마카세", icon: "Fish" },
|
||||
{ keyword: "라멘", icon: "Soup" },
|
||||
{ keyword: "돈카츠", icon: "Meat" },
|
||||
{ keyword: "텐동/튀김", icon: "EggFried" },
|
||||
{ keyword: "이자카야", icon: "GlassCocktail" },
|
||||
{ keyword: "야키니쿠", icon: "Grill" },
|
||||
{ keyword: "카레", icon: "BowlSpoon" },
|
||||
{ keyword: "소바/우동", icon: "BowlChopsticks" },
|
||||
{ keyword: "라멘", icon: "food:noodle" },
|
||||
{ keyword: "돈카츠", icon: "food:pig" },
|
||||
{ keyword: "텐동/튀김", icon: "food:tempura" },
|
||||
{ keyword: "이자카야", icon: "Martini" },
|
||||
{ keyword: "야키니쿠", icon: "Flame" },
|
||||
{ keyword: "카레", icon: "BowlFood" },
|
||||
{ keyword: "소바/우동", icon: "food:noodle" },
|
||||
// 중식
|
||||
{ keyword: "중화요리", icon: "Soup" },
|
||||
{ keyword: "중화요리", icon: "Fire" },
|
||||
{ keyword: "마라/훠궈", icon: "Pepper" },
|
||||
{ keyword: "딤섬/만두", icon: "Egg" },
|
||||
{ keyword: "양꼬치", icon: "Grill" },
|
||||
{ keyword: "양꼬치", icon: "Flame" },
|
||||
// 양식
|
||||
{ keyword: "파스타/이탈리안", icon: "BowlSpoon" },
|
||||
{ keyword: "스테이크", icon: "Meat" },
|
||||
{ keyword: "햄버거", icon: "Burger" },
|
||||
{ keyword: "파스타/이탈리안", icon: "ForkKnife" },
|
||||
{ keyword: "스테이크", icon: "Knife" },
|
||||
{ keyword: "햄버거", icon: "Hamburger" },
|
||||
{ keyword: "피자", icon: "Pizza" },
|
||||
{ keyword: "프렌치", icon: "GlassChampagne" },
|
||||
{ keyword: "바베큐", icon: "GrillSpatula" },
|
||||
{ keyword: "브런치", icon: "EggFried" },
|
||||
{ keyword: "비건/샐러드", icon: "Salad" },
|
||||
{ keyword: "프렌치", icon: "Champagne" },
|
||||
{ keyword: "바베큐", icon: "Flame" },
|
||||
{ keyword: "브런치", icon: "Coffee" },
|
||||
{ keyword: "비건/샐러드", icon: "Leaf" },
|
||||
// 아시아
|
||||
{ keyword: "베트남", icon: "BowlChopsticks" },
|
||||
{ keyword: "베트남", icon: "food:noodle" },
|
||||
{ keyword: "태국", icon: "Pepper" },
|
||||
{ keyword: "인도/중동", icon: "BowlSpoon" },
|
||||
{ keyword: "동남아기타", icon: "BowlSpoon" },
|
||||
{ keyword: "인도/중동", icon: "BowlFood" },
|
||||
{ keyword: "동남아기타", icon: "BowlFood" },
|
||||
// 기타
|
||||
{ keyword: "치킨", icon: "Meat" },
|
||||
{ keyword: "치킨", icon: "Egg" },
|
||||
{ keyword: "카페/디저트", icon: "Coffee" },
|
||||
{ keyword: "베이커리", icon: "Bread" },
|
||||
{ keyword: "뷔페", icon: "Cheese" },
|
||||
{ keyword: "뷔페", icon: "ForkKnife" },
|
||||
{ keyword: "퓨전", icon: "Cookie" },
|
||||
];
|
||||
|
||||
export function getTablerCuisineIcon(cuisineType: string | null | undefined): string {
|
||||
if (!cuisineType) return "Bowl";
|
||||
for (const rule of TABLER_SUB_RULES) {
|
||||
export function getPhosphorCuisineIcon(cuisineType: string | null | undefined): string {
|
||||
if (!cuisineType) return "ForkKnife";
|
||||
for (const rule of PHOSPHOR_SUB_RULES) {
|
||||
if (cuisineType.includes(rule.keyword)) return rule.icon;
|
||||
}
|
||||
const main = cuisineType.split("|")[0];
|
||||
return TABLER_CUISINE_MAP[main] || "Bowl";
|
||||
return PHOSPHOR_CUISINE_MAP[main] || "ForkKnife";
|
||||
}
|
||||
|
||||
23
frontend/src/lib/filters.ts
Normal file
23
frontend/src/lib/filters.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// 클라이언트 필터 순수 함수 — #533
|
||||
// page.tsx 의 filteredRestaurants 에서 사용하는 경계 로직을 테스트 가능하도록 분리.
|
||||
|
||||
/**
|
||||
* "의미있는 언급 수(video_count)" 기준으로 식당을 포함할지 판정한다.
|
||||
* 설계서: docs/design/533-min-mentions-filter/README.md
|
||||
*
|
||||
* - minMentions <= 0(기본값) → 필터 미적용, 항상 true.
|
||||
* - videoCount 가 null/undefined(미산정) → 0 으로 간주.
|
||||
* - videoCount >= minMentions 인 경우에만 true (경계값 포함).
|
||||
*
|
||||
* 예) passesMinMentions(1, 2) → false (1회는 2회 미만이라 제외)
|
||||
* passesMinMentions(2, 2) → true (경계값 포함)
|
||||
* passesMinMentions(null, 2) → false (미산정은 0 취급)
|
||||
* passesMinMentions(0, 0) → true (필터 미적용)
|
||||
*/
|
||||
export function passesMinMentions(
|
||||
videoCount: number | null | undefined,
|
||||
minMentions: number
|
||||
): boolean {
|
||||
if (minMentions <= 0) return true;
|
||||
return (videoCount ?? 0) >= minMentions;
|
||||
}
|
||||
22
frontend/src/lib/region.ts
Normal file
22
frontend/src/lib/region.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// 지역 표시 유틸 — #532
|
||||
// region 은 "나라|시도|구군" 파이프 구분 문자열. 사용자에게는 계층(breadcrumb)으로 보여준다.
|
||||
|
||||
/** 표시에서 제외할 더미/무의미 토큰. */
|
||||
const DUMMY_TOKENS = new Set(["", "null", "나라"]);
|
||||
|
||||
/**
|
||||
* 파이프 구분 region 을 "나라 › 시도 › 구군" 계층 문자열로 변환한다.
|
||||
* 빈 토큰·"null"·더미("나라")는 제거한다. 유효 토큰이 없으면 "".
|
||||
*
|
||||
* 예) "한국|서울|강남구" → "한국 › 서울 › 강남구"
|
||||
* "일본|null" → "일본"
|
||||
* "한국||관악구" → "한국 › 관악구"
|
||||
*/
|
||||
export function formatRegion(region: string | null | undefined): string {
|
||||
if (!region) return "";
|
||||
return region
|
||||
.split("|")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => !DUMMY_TOKENS.has(t.toLowerCase()))
|
||||
.join(" › ");
|
||||
}
|
||||
41
frontend/src/messages/en.json
Normal file
41
frontend/src/messages/en.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"header": {
|
||||
"search": "Search",
|
||||
"login": "Sign In",
|
||||
"logout": "Sign Out",
|
||||
"menu": "Menu",
|
||||
"myReviews": "My Reviews",
|
||||
"favorites": "Favorites"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"confirm": "OK",
|
||||
"close": "Close",
|
||||
"loading": "Loading...",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"filter": {
|
||||
"title": "Filter",
|
||||
"cuisine": "Cuisine",
|
||||
"price": "Price Range",
|
||||
"region": "Region",
|
||||
"channel": "Channel",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"restaurant": {
|
||||
"rating": "Rating",
|
||||
"address": "Address",
|
||||
"phone": "Phone",
|
||||
"website": "Website",
|
||||
"closed": "Permanently Closed",
|
||||
"tempClosed": "Temporarily Closed"
|
||||
},
|
||||
"review": {
|
||||
"title": "Reviews",
|
||||
"write": "Write a Review",
|
||||
"noReviews": "No reviews yet"
|
||||
}
|
||||
}
|
||||
41
frontend/src/messages/es.json
Normal file
41
frontend/src/messages/es.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"header": {
|
||||
"search": "Buscar",
|
||||
"login": "Iniciar sesión",
|
||||
"logout": "Cerrar sesión",
|
||||
"menu": "Menú",
|
||||
"myReviews": "Mis reseñas",
|
||||
"favorites": "Favoritos"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"confirm": "Aceptar",
|
||||
"close": "Cerrar",
|
||||
"loading": "Cargando...",
|
||||
"submit": "Enviar"
|
||||
},
|
||||
"filter": {
|
||||
"title": "Filtro",
|
||||
"cuisine": "Tipo de cocina",
|
||||
"price": "Rango de precios",
|
||||
"region": "Región",
|
||||
"channel": "Canal",
|
||||
"reset": "Restablecer"
|
||||
},
|
||||
"restaurant": {
|
||||
"rating": "Calificación",
|
||||
"address": "Dirección",
|
||||
"phone": "Teléfono",
|
||||
"website": "Sitio web",
|
||||
"closed": "Cerrado permanentemente",
|
||||
"tempClosed": "Cerrado temporalmente"
|
||||
},
|
||||
"review": {
|
||||
"title": "Reseñas",
|
||||
"write": "Escribir una reseña",
|
||||
"noReviews": "Aún no hay reseñas"
|
||||
}
|
||||
}
|
||||
41
frontend/src/messages/ja.json
Normal file
41
frontend/src/messages/ja.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"header": {
|
||||
"search": "検索",
|
||||
"login": "ログイン",
|
||||
"logout": "ログアウト",
|
||||
"menu": "メニュー",
|
||||
"myReviews": "マイレビュー",
|
||||
"favorites": "お気に入り"
|
||||
},
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"delete": "削除",
|
||||
"edit": "編集",
|
||||
"confirm": "確認",
|
||||
"close": "閉じる",
|
||||
"loading": "読み込み中...",
|
||||
"submit": "送信"
|
||||
},
|
||||
"filter": {
|
||||
"title": "フィルター",
|
||||
"cuisine": "料理ジャンル",
|
||||
"price": "価格帯",
|
||||
"region": "地域",
|
||||
"channel": "チャンネル",
|
||||
"reset": "リセット"
|
||||
},
|
||||
"restaurant": {
|
||||
"rating": "評価",
|
||||
"address": "住所",
|
||||
"phone": "電話",
|
||||
"website": "ウェブサイト",
|
||||
"closed": "閉店",
|
||||
"tempClosed": "一時休業"
|
||||
},
|
||||
"review": {
|
||||
"title": "レビュー",
|
||||
"write": "レビューを書く",
|
||||
"noReviews": "まだレビューがありません"
|
||||
}
|
||||
}
|
||||
41
frontend/src/messages/ko.json
Normal file
41
frontend/src/messages/ko.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"header": {
|
||||
"search": "검색",
|
||||
"login": "로그인",
|
||||
"logout": "로그아웃",
|
||||
"menu": "메뉴",
|
||||
"myReviews": "내 리뷰",
|
||||
"favorites": "즐겨찾기"
|
||||
},
|
||||
"actions": {
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"delete": "삭제",
|
||||
"edit": "수정",
|
||||
"confirm": "확인",
|
||||
"close": "닫기",
|
||||
"loading": "로딩 중...",
|
||||
"submit": "제출"
|
||||
},
|
||||
"filter": {
|
||||
"title": "필터",
|
||||
"cuisine": "음식 종류",
|
||||
"price": "가격대",
|
||||
"region": "지역",
|
||||
"channel": "채널",
|
||||
"reset": "초기화"
|
||||
},
|
||||
"restaurant": {
|
||||
"rating": "평점",
|
||||
"address": "주소",
|
||||
"phone": "전화",
|
||||
"website": "웹사이트",
|
||||
"closed": "폐업",
|
||||
"tempClosed": "임시휴업"
|
||||
},
|
||||
"review": {
|
||||
"title": "리뷰",
|
||||
"write": "리뷰 작성",
|
||||
"noReviews": "아직 리뷰가 없습니다"
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,12 @@ type: Opaque
|
||||
stringData:
|
||||
ORACLE_USER: "<oracle-username>"
|
||||
ORACLE_PASSWORD: "<oracle-password>"
|
||||
ORACLE_DSN: "<tns-alias>_high?TNS_ADMIN=/etc/oracle/wallet"
|
||||
ORACLE_DSN: "<tns-alias>_medium?TNS_ADMIN=/etc/oracle/wallet"
|
||||
JWT_SECRET: "<jwt-secret>"
|
||||
OCI_COMPARTMENT_ID: "<oci-compartment-id>"
|
||||
OCI_CHAT_MODEL_ID: "<oci-chat-model-id>"
|
||||
GOOGLE_MAPS_API_KEY: "<google-maps-api-key>"
|
||||
YOUTUBE_DATA_API_KEY: "<youtube-data-api-key>"
|
||||
# #357 — Naver Search API (선택). 미설정 시 DDG 폴백.
|
||||
NAVER_CLIENT_ID: "<naver-client-id>"
|
||||
NAVER_CLIENT_SECRET: "<naver-client-secret>"
|
||||
|
||||
Reference in New Issue
Block a user