Compare commits

...

48 Commits

Author SHA1 Message Date
joungmin
e3cbba1a49 [Documenter] #791 지원서 v6 — Q11 공개 소개문 + Q5 영상 대본
- Q11(517자): 홈페이지 공개용. 문제 공감 → 서비스 설명 → "별점이 아니라
  사람이 기준" 차별점 → 실측 규모 → 링크. 일반 독자 톤
- q5-video-script.md: 85초 9컷 대본. 화면·자막·시간 표로 정리.
  자막만으로도 성립하도록 문장 구성, 컷5(유튜버 평가+원본 링크)에 비중
- 비로그인 열람 가능 여부를 page.tsx에서 확인 후 "가입 없이" 문구 사용

Refs #791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:00:59 +09:00
joungmin
ce5de2e8ce [Documenter] #791 지원서 v5 — 심사 기준 정렬, 광고 수익모델 제거
- 심사 공통 항목이 "2개월 내 MVP 제작 가능 수준인가"임을 확인
  → Q4-1을 "이미 운영 중 + 2개월 안에 하는 일" 축으로 재구성
- Q3-2에서 광고·캠페인 상품 삭제: 큐레이터 추천이 강점인 서비스에
  노출 판매를 붙이면 Q3-1의 기존 서비스 비판과 자가당착
  → 크리에이터 파트너십으로 축 단일화, Q4-2·Q8도 동반 수정
- 예약 제휴는 조건부로만 유지 (테이블링/캐치테이블 외부 제휴 프로그램 미확인)
- README에 심사 기준·수익모델 결정 근거 기록

Refs #791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:56:55 +09:00
joungmin
e8748f3434 [Release] #532 #533 #545 v0.1.67 운영 배포 기록
- v0.1.66 이후 미배포 15커밋을 www.tasteby.net 에 반영
- Colima(aarch64) 빌드 → OCIR push → kubectl set image → rollout 완료
- 운영 검증: min_mentions=0/3/5 → 500/36/5곳 (서버사이드 필터 정상)
- Redmine #532/#533/#545 → 08-Documenter 전진

Refs #532, #533, #545

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:40:18 +09:00
joungmin
b259cec11b [Documenter] #791 모두의 창업 2차 지원서 작업방 신설 (v1~v4)
- docs/ir/modu-startup-2026-09/: questions.md(문항 원문), v1(1차 원고 보존),
  v2(Codex 작성분 이관), v3(실측 반영), v4(Q8 완성), README(진척·검증 기록)
- 실측 지표 확보: 채널 6 / 영상 909 / 식당 500(좌표 100%) / 해외 60여 / 미검증 1,071
- v3에서 "3개월 5,000곳"(현재의 10배) → 1,500곳 + 미검증 정리로 로드맵 현실화
- v2 참조 링크 3개가 리포에 부재함을 확인하고 제거

Refs #791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:40:11 +09:00
joungmin
aba6fac2ae [Reviewer] #533 언급수 필터 초기화 — 모바일 내위치 토글 2곳 설계 §7 일관성 보강
- page.tsx: 모바일 "내위치" 토글 2곳 onClick 초기화 목록에 setMinMentions(0) 추가
  (데스크톱 토글만 반영돼 있던 설계 §7 상호배타/리셋 일관성 누락 보강)
- 설계서 §7: 데스크톱/모바일 토글 전부(3곳) 동일 적용 명시
- tsc/jest(6 suites, 32 tests) 통과. 통상 모바일 흐름에선 no-op(minMentions는 데스크톱 전용 select)

Refs #533
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:18:38 +09:00
joungmin
63354fa63a [Reviewer] #532 지역 표시 계층화 승인 — 게이트 통과
- formatRegion 순수함수/엣지케이스/렌더 가드/설계서 일치 검토 완료
- 단위 테스트 8/8 재확인, #545 접근성 회귀 복원 확인
- 06-Reviewer 승인 → 07-Release 대기

Refs #532

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:03:51 +09:00
joungmin
09ddc47103 [Developer] #533 전체보기/내위치 토글 언급수 필터 초기화 반영
- page.tsx: 전역 '전체보기' 노출 조건에 minMentions > 0 추가, onClick에 setMinMentions(0) 추가
- 내위치 토글 ON 초기화 목록에도 setMinMentions(0) 추가 (상호배타 일관성)
- 설계서 §7 필터 리셋 일관성 명시
- tsc/jest(6 suites, 32 tests) 통과

Refs #533

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:02:34 +09:00
joungmin
77aad4ab14 docs(changelog): #533 Designer/Reviewer 통과기록 — 전역 필터 초기화 결함으로 반려
- Designer: UX 패턴 일치 확인, 코드 변경 없음
- Reviewer: page.tsx:910,912-919 "전체보기" 버튼이 minMentions 미초기화 — 03-Developer 반려

Refs #533
2026-07-27 14:00:08 +09:00
joungmin
935c73129b fix: #532 커밋에서 실수로 되돌린 #545 접근성 표시(title/aria-hidden) 복원
동시 진행 중이던 [Designer] #545 커밋(e0e9ab6)의 title 속성/aria-hidden
표시를 #532 작업 중 작업 트리 정리 과정에서 착오로 되돌렸다(commit c13b976).
formatRegion 렌더 가드 변경(#532)과는 무관한 부분이므로 원상 복구.

Refs #532, #545
2026-07-27 13:56:00 +09:00
joungmin
1815a4c4b2 [QA] #533 언급 적은 식당 제외 필터 — 검증 통과, 회귀 테스트 추가
- 설계서(docs/design/533-min-mentions-filter/README.md) 대비 구현 일치 확인
  (backend RestaurantMapper video_count/min_mentions, frontend 필터 UI)
- dev API 실측: min_mentions 0/1/2/3/5/100 전 구간 경계 위반 0건
  (=2→116개, =3→36개, DISTINCT+서브쿼리 중복 없음, 기존 필터 조합 회귀 없음)
- passesMinMentions()를 lib/filters.ts로 추출(동작 변경 없음)하고
  frontend/__tests__/filters.test.ts 6케이스 추가(경계값/null 처리 등)
- 백엔드 빌드, tsc, 프론트 전체 jest(6 suites, 32 tests) 통과

Refs #533
2026-07-27 13:55:41 +09:00
joungmin
c13b976ca3 [Designer] #532 지역 표시 빈 배지 방지 — 렌더 가드 정리
RestaurantList.tsx/RestaurantDetail.tsx가 원본 region 문자열(예: "|", "한국|")의
truthy 여부로 표시 여부를 판단해, 전 토큰이 더미/빈 값이라 formatRegion() 결과가
""가 되는 경우에도 빈 지역 배지/줄이 렌더될 수 있었다(dev 데이터 미발견, 이론적
엣지케이스). 가드를 formatRegion(region) 결과 기준으로 변경. formatRegion() 자체
로직/시그니처는 변경 없음 — 표현(호출부 조건)만 다듬음.

설계서(docs/design/532-region-display/README.md) 갱신: 가드 방식 및 근거 명시.

Refs #532
2026-07-27 13:54:45 +09:00
joungmin
e0e9ab6936 [Designer] #545 해외 식당 예약 버튼 접근성/명확성 다듬기
기능(isKoreaRestaurant 게이트)은 QA 통과 상태 그대로 유지, 표현/접점만 보완.
- 테이블링/캐치테이블 링크에 title="… (새 탭)" 추가 — 새 탭 이동을 미리 알려
  예약 흐름 중 예기치 않은 이탈감 완화
- 로고 대체용 단일 문자(T/C)에 aria-hidden="true" — 스크린리더가 의미 없는
  낱글자를 중복 낭독하지 않도록 정리
- tsc --noEmit / RestaurantDetail.test.tsx 5/5 통과 (회귀 없음)

Refs #545
2026-07-27 13:54:03 +09:00
joungmin
fd4ab18fbf [QA] #545 해외 식당 예약 버튼 숨김 — 회귀 테스트 추가 및 검증 통과
RestaurantDetail.tsx의 isKoreaRestaurant() 게이트가 설계서 인수조건과
일치함을 확인. 국내/해외/URL-NONE/좌표없음 fallback 등 5개 경계 케이스를
자동화 RTL 테스트로 검증(수동 육안 QA를 대체). tsc 및 전체 jest 스위트
통과.

Refs #545
2026-07-27 13:50:32 +09:00
joungmin
622ea58cd1 [QA] #532 formatRegion 검증 통과 — 단위 테스트 추가
- frontend/__tests__/region.test.ts: 설계서(docs/design/532-region-display)
  엣지케이스 8종(falsy/계층조인/null토큰/빈토큰/더미제거/단일토큰/전량더미/trim) 검증
- tsc --noEmit, jest 전체 스위트 통과 확인
- 실제 dev DB region 데이터로 엣지케이스 재확인

Refs #532
2026-07-27 13:49:26 +09:00
joungmin
7bf21b5b45 docs(changelog): #545/#533/#532 설계서·Redmine 현행화 기록
Refs #545, #533, #532
2026-07-19 09:10:12 +09:00
joungmin
6754976e72 [Architect] #545 예약버튼 국내 게이트 설계서 소급 작성
- Developer 구현(5d62866) 이후 설계서 게이트 위반 보완
- isKoreaRestaurant() 적용 범위/근거를 구현과 일치하도록 문서화

Refs #545
2026-07-19 09:09:50 +09:00
joungmin
5d62866941 [Developer] #545 해외 식당 테이블링/캐치테이블 버튼 숨김
- RestaurantDetail.tsx 예약 버튼 2종을 isKoreaRestaurant() 게이트로 감쌈
- 테이블링/캐치테이블은 국내 전용 — 지도 링크와 동일 판정 기준 적용

Refs #545
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:52:41 +09:00
joungmin
7639fd009d [Developer] #533 언급 적은 식당 제외 필터 (min_mentions + video_count)
- backend: findAll 응답에 video_count(strong/unknown 링크수) + min_mentions 서버필터
  - Restaurant.videoCount, RestaurantMapper(xml/iface), Service, Controller(+cache key)
- frontend: Restaurant.video_count 타입 + 데스크톱 툴바 "언급"(2/3/5회+) 클라 필터
- 검증: min_mentions=2→116, =3→36 (위반 0). 빌드/tsc 통과
- 설계서 docs/design/533-min-mentions-filter/README.md

Refs #533
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:23:46 +09:00
joungmin
4760b5a284 [Developer] #532 지역 표시 계층화 formatRegion (한국|서울 → 한국 › 서울)
- lib/region.ts formatRegion() 신설: 파이프 분리 + 빈토큰/null/나라 더미 제거 + ' › ' 조인
- RestaurantDetail/RestaurantList 지역 표기에 적용
- buildSearchQuery(검색용 공백조인) 유지
- 설계서 docs/design/532-region-display/README.md

Refs #532

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:15:53 +09:00
joungmin
2eb16ce861 fix(map): 클러스터 expansion 후 마커 클릭 시 재묶임 방지
- selected 시 무조건 zoom 16 → 줌 18에서 마커 클릭하면 다시 16으로 줄어 클러스터링
- 현재 zoom ≥ 16이면 유지, 미만이면 16으로 (Naver/Google 둘 다)
- page.tsx의 setRegionFlyTo 호출 제거 — selected useEffect로 일원화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 10:09:22 +09:00
joungmin
14384b0c71 fix(map): NaverMap 클러스터 클릭 시 중앙 이동 — morph로 통일
- panTo(애니메이션) + setZoom(z, true)(애니메이션) 동시 호출이 서로 cancel
- 단일 식당은 줌 변화 없어서 가려졌고 클러스터는 줌 크게 변경 → 가시화
- SDK의 morph(latlng, zoom)으로 center+zoom 동시 변경 일관 처리
- flyTo / selected / cluster 클릭 모두 동일 패턴

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 09:55:51 +09:00
joungmin
250b067d87 fix(map): NaverMapView selected 변경 시 자동 panTo + zoom
- GoogleMapView에는 있던 useEffect [selected] 패턴이 NaverMapView에 누락
- 마커/클러스터/리스트 어디서 선택해도 그 식당이 중앙 + zoom 16

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 09:42:37 +09:00
joungmin
6a885c5203 docs(changelog): v0.1.63 미커밋 잡변경 정리 5건
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 19:35:24 +09:00
joungmin
a9dc1dad6a chore(dev): PM2 frontend PORT=3001 env + reviews/screenshots gitignore
- tasteby-web: PORT=3001 명시 (env 누락 → 기본 3000으로 충돌 위험)
- reviews/, screenshots/ 로컬 작업 산출물 무시

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 19:34:58 +09:00
joungmin
21eb1e9562 docs(deploy): OKE 배포 가이드 추가
- Colima(로컬 ARM64 Docker) → OCIR → OKE 파이프라인 전체 절차
- deploy.sh 사용법, kubectl context, secret 등록 흐름

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 19:34:58 +09:00
joungmin
94be5a81e6 feat(frontend): 아이콘 시스템 일관성 + Phosphor 마이그레이션
- Icon.tsx: 크기 고정 + flex center로 정렬 일관성
- cuisine-icons.ts: Tabler → Phosphor 매핑(BowlFood/FishSimple/Pizza 등)
- FoodIcon.tsx 신규: 한식 specific 자체 SVG (찌개/전골/탕)
- frontend/docs/brand-guide.md 신규: 브랜드 아이덴티티 + 디자인 시스템 정책

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 19:34:58 +09:00
joungmin
1d767bee37 feat(util): JsonUtil.normalizeEvaluation — 평문→JSON 래핑 + 300자 제한
- evaluation 컬럼이 IS JSON 제약이라 평문은 {"text":"..."}로 정규화
- parseMap이 잘못된 JSON 받았을 때 빈 Map 대신 {"text":원문}으로 보존
- PipelineService/RestaurantService에서 이미 호출 중인 유틸 — 미커밋 상태였음

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 19:34:58 +09:00
joungmin
0676a31cfd feat(admin): 사용자 어드민 권한 토글 backend 연결
- AdminUserController.updateAdmin이 호출하던 service/mapper 미커밋 상태였음
- CORS allowedMethods에 PATCH 추가 (PATCH /api/admin/users/{id}/admin)
- UserMapper.updateAdmin + XML (is_admin 0/1)
- UserService.updateAdmin (트랜잭션 + 404 가드)
- findAllWithCounts에 is_admin 컬럼 SELECT 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 19:34:58 +09:00
joungmin
1164139312 feat(map): 식당 선택 시 지도 자동 줌인/이동
- 리스트/검색결과에서 식당 클릭 → setRegionFlyTo로 그 좌표 + zoom 16
- NaverMap/GoogleMap dispatcher 둘 다 panTo + setZoom
- 마커 selected 강조(1.15× 파란 박스)와 함께 동작

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 17:29:28 +09:00
joungmin
78f7e83a0e feat(map): NaverMapView 마커에 식당명 박스 (GoogleMap 동일)
- 단순 동그라미 → 박스+화살표+식당명+cuisine 아이콘 핀
- 채널별 bg/text/border/arrow 색상
- 폐업(CLOSED_*) 회색 + 취소선, selected 강조 (1.15× + 파란박스)
- InfoWindow 제거 (식당명이 박스로 보임)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 17:13:37 +09:00
joungmin
247547c516 feat(map): NaverMapView 채널별 마커 색상
- GoogleMapView와 동일 팔레트 (8 색)
- 식당 첫 채널 기준 색상, activeChannel 있으면 우선
- getChannelColorMap 재사용 패턴 동일

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 17:09:02 +09:00
joungmin
8de8696424 perf(map): NaverMapView SDK 네이티브 마커 + InfoWindow
- React absolute div overlay → naver.maps.Marker 네이티브 교체
- 줌/팬 시 SDK GPU 최적화로 랙 해소
- 식당명 InfoWindow (마커 클릭 시 표시)
- bounds_changed → idle 이벤트로 sync 빈도 감소
- 클러스터도 네이티브 마커 (HTML 콘텐츠 숫자)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 16:58:03 +09:00
joungmin
a4de9ba87b feat(admin): #356 후속 — verifyAllAsync로 즉시 응답
- 1244 링크 백필 동기 호출 시 HTTP 25~30분 hang (ingress 5분 timeout 초과)
- @Async verifyAllAsync 추가, controller는 started/pending 즉시 응답
- 진행은 /api/admin/video-relevance/pending 폴링으로 확인

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 15:15:48 +09:00
joungmin
cf37e496d4 docs(design): #363 실 운영 fix 기록 (1~2단계 + v0.1.57 안정화)
- 배포 흐름 v0.1.51~v0.1.57 정리
- 운영 진단 확인사항 (NCLOUD 도메인 형식, Search/Maps 별개 시스템)
- NaverMapView 안정화 핵심 결정사항 (divRef 항상 마운트, ResizeObserver, try/catch)
- 후속 분리 항목 명시

Refs: #363

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:56:51 +09:00
joungmin
ce3e34938c fix(map): NaverMapView 안정화 — divRef 항상 마운트 + ResizeObserver
- 이전: ready 가드로 첫 렌더 시 ref 누락 가능 → SDK가 div 못 잡음
- 명시적 width/height + 회색 배경(시각적 로딩 표시)
- ResizeObserver + rAF로 컨테이너 0×0 → 정상 크기 시 m.refresh
- try/catch + initError로 init 실패 가시화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:38:57 +09:00
joungmin
5199475d67 revert(map): NaverMap 임시 비활성, 한국도 GoogleMap fallback
- NaverMapView 골격이 실 운영에서 지도/마커 렌더 실패
- 환경변수 비워 dispatcher가 GoogleMap fallback (회귀 0)
- NaverMapView 코드는 유지 — 후속 안정화 작업 후 재활성

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:32:54 +09:00
joungmin
bd8d82dd5d fix(stats): /api/stats/visits 500 — Mapper resultType int→long
- StatsMapper interface는 long 반환인데 XML resultType이 int
- Integer를 primitive long으로 cast 못 함 → ClassCastException → 500

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:26:15 +09:00
joungmin
bc83923261 fix(map): NaverMap 인증 파라미터 ncpClientId → ncpKeyId
- NCLOUD 신 정책: ncpKeyId 사용 (navermaps/maps.js.ncp 공식)
- 인증 200/Failed 진짜 원인 — 도메인 등록 정확했으나 파라미터 차이
- Refs: navermaps/maps.js.ncp

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 10:03:09 +09:00
joungmin
f17ba9e37a feat(map): #363 메인 지도 SDK 국내(네이버)/해외(구글) 분기
- MapView dispatcher: NAVER 키 + KR bbox 좌표 → NaverMapView
- NaverMapView 신규 (네이버 v3 직접 wrapper, Supercluster 재사용)
- GoogleMapView 신규 (기존 MapView 내용 rename)
- MapView.types.ts 공용 타입 + isKoreaCoord 헬퍼
- Dockerfile/deploy.sh: NEXT_PUBLIC_NAVER_MAP_CLIENT_ID build-arg
- 키 미설정 시 GoogleMap fallback (회귀 0)

Refs: #363

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 06:25:47 +09:00
joungmin
7789671fbc feat(map): 식당 상세 지도 링크 국내/해외 분기 (1단계)
- 좌표 기반 한국 판정 (KR bbox 33~38.7°N, 124~132°E)
- 국내: 네이버 지도(/p/search/) primary + Google Maps 보조
- 해외: Google Maps 단독
- 좌표 없으면 region 첫 토큰 fallback

2단계(메인 지도 탭 SDK 분기)는 별도 후속.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 05:59:24 +09:00
joungmin
c5b0216a37 fix(catchtable): URL 패턴을 /ct/shop/, /ct/dining/으로 교정
- 실제 캐치테이블은 app.catchtable.co.kr/ct/shop/... 형식
- 옛 /shop/, /dining/ 패턴은 contains 매칭 실패 → 첫 회차 1044건 전부 미발견
- 패턴 교정 후 NONE 해제 + 재실행 필요

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 01:35:18 +09:00
joungmin
40e448fe95 fix(search): WebSearchService HTTP timeout 추가 (connect 5s, request 15s)
- 특정 검색에서 무한 hang → backend virtual thread 점유로 후속 벌크 작업 중단
- Naver/DDG 둘 다 timeout 적용
- 타임아웃 시 HttpTimeoutException → 호출자(bulk)에서 notfound 안전 처리

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:52:31 +09:00
joungmin
8a21646031 fix(admin): bulk-tabling/catchtable SSE timeout 10분 → 3시간
- 대량 백필(700+건 ≈ 100분) 시 10분 SSE timeout으로 중간 끊김
- emit() 실패 시 마지막 cache.flush + complete 누락 → 3시간으로 확장

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:08:55 +09:00
joungmin
52090057de fix(restaurant): #357 후속 — tabling-url validation에 www. 호스트 허용
- Naver/DDG 결과가 www.tabling.co.kr 형태인데 PUT validation에서 거부됨
- bulk-tabling SSE는 validation 없이 통과 — 불일치 해소
- catchtable은 이미 app/www 둘 다 허용 (기존)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 21:02:32 +09:00
joungmin
d73947444f feat(backend): #359 1단계 — google_place_id 중복 조회 API
- GET /api/admin/restaurants/duplicates/place-id (어드민 전용)
- 그룹별 식당 목록 + video/review/memo 카운트 동봉
- Mapper: findDuplicatePlaceIdRows + Service 그룹핑
- 정리/병합 + UNIQUE 제약은 데이터 위험 분리 위해 후속 PR로

Refs: #359 (조회 단계 완료)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:32:40 +09:00
joungmin
c1050f3abd feat(backend): #358 RestaurantUpdateDTO + @Valid 표준화
- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable)
- @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 필드 자체가 화이트리스트)

Refs: #358 (close)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:20:51 +09:00
joungmin
a504bf8ee5 feat(backend): #357 DDG → Naver Search 정식 API + DDG 폴백
- WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG)
- RestaurantController.searchTabling/searchCatchtable 내부 호출 교체
- 인라인 DDG 80줄 제거, 미사용 import 정리
- app.naver.client-id/secret 추가 (env: NAVER_CLIENT_ID/SECRET)
- k8s secrets template에 NAVER 키 항목

Refs: #357 (close)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:16:14 +09:00
joungmin
f1164b63c5 docs(design): #357 정식 검색 API 전환 설계서 (Architect)
- WebSearchService 신규: Naver Search webkr.json 우선, DDG 폴백
- searchTabling/searchCatchtable 내부 호출만 교체 (시그니처 유지)
- application.yml + k8s secrets에 NAVER_CLIENT_ID/SECRET 추가

Refs: #357

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 20:11:52 +09:00
57 changed files with 4858 additions and 612 deletions

4
.gitignore vendored
View File

@@ -20,3 +20,7 @@ k8s/secrets.yaml
backend/cookies.txt
backend-java/cookies.txt
**/cookies.txt
# 작업 산출물 (로컬 전용)
reviews/
screenshots/

View File

@@ -4,8 +4,263 @@
---
## 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)

View File

@@ -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);

View File

@@ -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 토글.
*/

View File

@@ -40,9 +40,11 @@ public class AdminVideoRelevanceController {
@PostMapping("/all")
public Map<String, Object> verifyAll(@RequestParam(defaultValue = "10") int batchSize) {
var admin = AuthUtil.requireAdmin();
log.info("[ADMIN] {} triggered video-relevance verifyAll(batchSize={})", admin.getSubject(), batchSize);
int processed = relevanceService.verifyAll(batchSize);
return Map.of("processed", processed);
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")

View File

@@ -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;
}
@@ -90,30 +87,14 @@ public class RestaurantController {
return r;
}
// #332 — Restaurant 업데이트 화이트리스트 (SQL updateFields의 컬럼 가드와 1:1).
// 허용되지 않은 키는 무시(silent drop). DTO 도입은 후속 작업.
private static final java.util.Set<String> ALLOWED_UPDATE_FIELDS = java.util.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"
);
@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");
// #332 — 입력 body를 허용 키만 통과시킨 가변 Map으로 정규화
Map<String, Object> sanitized = new java.util.LinkedHashMap<>();
for (var e : body.entrySet()) {
if (ALLOWED_UPDATE_FIELDS.contains(e.getKey())) {
sanitized.put(e.getKey(), e.getValue());
} else {
log.debug("Ignoring non-whitelisted update field: {}", e.getKey());
}
}
// #358 — DTO → Map (null 제외). 화이트리스트는 DTO 필드 자체로 표현.
Map<String, Object> sanitized = dto.toFieldMap();
// Re-geocode if name or address changed
String newName = (String) sanitized.get("name");
@@ -195,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 {
@@ -272,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();
@@ -326,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 {
@@ -430,95 +414,20 @@ public class RestaurantController {
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/"
);
}

View File

@@ -39,4 +39,7 @@ public class Restaurant {
// Transient enrichment fields
private List<String> channels;
private List<String> foodsMentioned;
// #533 — 의미있는(strong/unknown) 영상 언급 수
private Integer videoCount;
}

View File

@@ -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;
}
}

View File

@@ -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 표시 갱신
@@ -39,6 +40,9 @@ public interface RestaurantMapper {
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,

View File

@@ -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);
}

View File

@@ -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;
}
@@ -111,6 +113,23 @@ public class RestaurantService {
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);
}

View File

@@ -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");
}
}
}

View File

@@ -63,6 +63,16 @@ public class VideoRelevanceService {
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;

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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')) &gt;= #{minMentions}
</if>
</where>
ORDER BY r.updated_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
@@ -353,4 +360,21 @@
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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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/

View 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 최신 입점 식당 미반영 가능성 — 백필 주기 후속.
- 결과 캐시(같은 식당 재호출) — 후속.

View 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 — 후속.

View 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).

View 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에 도입.

View 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) 통과.

View 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 (위 서브쿼리) &gt;= #{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` 포함 확인.
- 프론트 토글로 목록/지도가 줄어드는지.

View 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 통과.

View File

@@ -0,0 +1,110 @@
# 모두의 창업 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 완성 |
| `v5.md` | 심사 기준에 맞춰 재정렬 |
| `v6.md` | **현재 제출안**. Q5·Q11 완성 |
| `q5-video-script.md` | 소개 영상 촬영 대본 (85초 9컷) |
## 버저닝 규칙
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 |
| v6 | 2026-09-15 | Q11 공개 소개문 작성(517자), Q5 촬영 대본 별도 문서로 작성 |
| v5 | 2026-09-15 | 심사 기준 확인 후 재정렬. Q4-1을 "2개월 내 MVP" 축으로 재구성(이미 운영 중임을 전면에), Q3-2에서 광고·캠페인 수익원 삭제, Q4-2·Q8 동반 수정 |
| 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 수익 창출 | 765 | 2,000 | ✅ 광고 삭제, 크리에이터 축으로 단일화 |
| Q4-1 사업화 계획 | 1,100 | 2,000 | ✅ v5에서 "2개월 내 MVP" 축으로 재구성 |
| Q4-2 멘토 도움 | 888 | 2,000 | ✅ |
| Q5 소개 영상 | 대본 완성 | - | ⬜ 촬영·편집만 남음 (`q5-video-script.md`) |
| Q6 사업 분야 | 선택 기준 | - | ⬜ 드롭다운 실물 확인 |
| Q7 창업 여부 | 판단표 | - | ⬜ 법적 사실 확인 |
| Q8 1차 대비 개선점 | 1,171 | - | ✅ |
| Q9 본인 역량 | 916 | 2,000 | 🟡 경력·배경 한 단락 추가 여지 |
| Q10 팀원 수 | - | - | ✅ 팀원 없음 |
| Q11 자랑하기 | 517 | - | ✅ v6 완성 / 스크린샷 1장 필요 |
| 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건 정리로 재정의함**.
## 심사 기준 (2026-09-15 확인)
- 주최: **중소벤처기업부 대국민 창업 오디션**. 일반/기술트랙 4,000명 + 로컬트랙 1,000명 선발
- 진출 혜택: 창업활동자금 200만 원 + 책임멘토링 + AI 바우처. 이후 라운드별 MVP 제작비(최대 1,000만)→사업화자금(최대 1억)→우승 상금 5억
- 심사 3축: **도전의 진정성 / 사회적 가치(독창성·차별성) / 아이디어 구체성(BM 현실성·단계별 로드맵)**
- 거의 모든 멘토기관 공통 항목: **"창업활동자금과 AI 솔루션을 활용해 2개월 내 MVP 제작이 가능한 수준인가"**
- 함의: 이 단계는 수익모델의 정교함을 다투는 자리가 아니다. **이미 운영 중인 MVP가 최대 강점**이므로 Q4-1·Q9에 힘을 싣고, 선택 문항인 Q3-2는 원칙만 짧게.
- 출처: modoo.or.kr, 멘토기관 80여 곳 평가지표 정리 자료
## 수익모델 결정 (v5)
- **하지 않는 것**: 검색·추천 순위 판매, 노출형 광고 — 유명 큐레이터 추천이 강점인 서비스에 붙이면 Q3-1에서 비판한 기존 서비스와 같아진다
- **축**: 크리에이터 파트너십 (채널 공식 페이지 + 성과 배분). 이미 영상에서 추천된 가게를 다루므로 추천의 순수성이 유지되고, 크리에이터에게 홍보 동기가 생긴다
- **조건부**: 예약·주문 제휴 수수료. 현재 DB에 테이블링 225곳·캐치테이블 142곳 링크가 있으나 **외부 서비스용 제휴 프로그램 존재가 확인되지 않음** → 확인 전까지 수익원으로 단정하지 않는다
## 참고 자산
- `docs/ir/bunkershot-2026-06-29/` — 6월 벙커샷 IR 자료 (피치덱, 1pager, 예상 QA, 시연 시나리오). Q5 영상 대본·Q3 소재로 재활용 가능
- `screenshots/` — 서비스 스크린샷 (사진 첨부용, 전체 합산 10장 제한)

View File

@@ -0,0 +1,27 @@
# Q5 소개 영상 대본 (85초)
> 제출물은 유튜브 URL 하나. 이 문서는 **촬영용**이다.
> 촬영 원칙: Tasteby 실제 화면과 직접 만든 도식만 사용한다. 타인의 유튜브 영상·썸네일·음원은 넣지 않는다.
> 권장 방식: 화면 녹화(QuickTime) + 자막. 목소리 녹음이 부담되면 **자막만으로도 성립**하도록 문장을 짧게 썼다.
## 컷 구성
| # | 시간 | 화면 | 자막 / 내레이션 |
|---|------|------|-----------------|
| 1 | 0:00–0:08 | 유튜브 앱에서 맛집 영상 목록을 빠르게 스크롤. 재생바를 앞뒤로 끄는 모습 | "그 영상에서 봤던 그 국밥집,<br>이름이 뭐였더라." |
| 2 | 0:08–0:15 | 영상 자막을 멈춰 세우고 가게 이름을 메모앱에 옮겨 적는 손 | "매번 영상을 되감고,<br>받아 적고, 지도에 옮겼습니다." |
| 3 | 0:15–0:22 | 화면 전환 → Tasteby 지도. 핀이 한꺼번에 뜨는 장면 | "그래서 만들었습니다.<br>**Tasteby**" |
| 4 | 0:22–0:32 | 채널 필터에서 특정 채널 선택 → 지도의 핀이 그 채널 것만 남는 장면 | "내가 믿는 채널로 먼저 좁힙니다.<br>떡볶이가 먹고 싶으면 분식을 다루는 채널로." |
| 5 | 0:32–0:45 | 식당 마커 클릭 → 상세 시트. 유튜버 평가와 언급 메뉴, 원본 영상 링크를 천천히 보여줌 | "그 사람이 **어떤 영상에서 뭐라고 했는지** 그대로 남습니다.<br>의심되면 원본으로 돌아가면 됩니다." |
| 6 | 0:45–0:55 | 찜 버튼 누르기 → 비공개 메모에 한 줄 적기 | "다녀온 뒤엔 나만 보는 메모로 남깁니다.<br>공개 리뷰가 아니라, 다음 식사를 위한 기록입니다." |
| 7 | 0:55–1:05 | 지역 필터를 일본으로 → 도쿄 지도에 핀이 뜨는 장면 | "여행지에서도 똑같습니다.<br>낯선 리뷰를 처음부터 읽는 대신,<br>믿는 사람의 선택을 그대로 꺼냅니다." |
| 8 | 1:05–1:15 | 언급 횟수 필터를 3회 이상으로 → 목록이 확 줄어드는 장면 | "여러 번 언급된 집만 보는 것도 가능합니다.<br>한 번 스쳐 간 곳과 진짜 추천을 구분합니다." |
| 9 | 1:15–1:25 | 직접 만든 숫자 카드 (채널 6 / 영상 909 / 식당 500) → 마지막에 도메인 | "채널 6곳, 영상 909편에서<br>식당 500곳을 이미 모았습니다.<br>혼자 만들어 운영 중입니다.<br>**www.tasteby.net**" |
## 촬영 메모
- **컷 5가 핵심이다.** 다른 서비스와 갈리는 지점이므로 여기에 시간을 가장 많이 준다. 별점이 아니라 "누가, 어디서, 뭐라고 했는지"가 보이는 화면을 크게.
- 컷 1~2는 스마트폰 촬영으로 충분하다. 손으로 되감고 받아 적는 장면이 있어야 컷 3의 해방감이 산다.
- 유튜브 화면을 그대로 쓰기 어렵다면, 컷 1~2는 **직접 만든 도식**(영상 아이콘 → 물음표 → 메모장)으로 대체해도 메시지는 유지된다.
- 배경음악은 저작권이 자유로운 음원만 사용한다(유튜브 오디오 보관함).
- 마지막 컷의 숫자는 **제출일 기준으로 다시 확인**해서 넣는다.

View 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은 선택이지만 각각 **가점**과 **공개 노출** 효과가 있어 실질적으로 작성 권장.

View 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/` 디렉토리 기존 자산 활용 가능 여부 점검.

View 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장은 예비로 남긴다. 모든 화면은 제출일 기준 실제 동작·표현과 일치해야 하며, 원본 영상 캡처·타인 음식 사진은 사용권을 확인하지 못하면 사용하지 않는다.

View 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장은 예비로 남긴다. 모든 화면은 제출일 기준 실제 동작·표현과 일치해야 하며, 원본 영상 캡처·타인 음식 사진은 사용권을 확인하지 못하면 사용하지 않는다.

View 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장은 예비로 남긴다. 모든 화면은 제출일 기준 실제 동작·표현과 일치해야 하며, 원본 영상 캡처·타인 음식 사진은 사용권을 확인하지 못하면 사용하지 않는다.

View File

@@ -0,0 +1,195 @@
# 모두의 창업 2차 지원서 — v5
> 작성일: 2026-09-15
> 상태: **심사 기준에 맞춰 재정렬한 판.** `제출 전 확정` 표기 항목만 사실 확인 뒤 확정한다.
## v4 → v5 변경 요약
- **Q4-1 재구성**: 심사 공통 항목이 "창업활동자금과 AI 솔루션을 활용해 **2개월 내 MVP 제작이 가능한 수준인가**"임을 확인 → 0~3/4~6/7~12개월 구조를 **"이미 운영 중 + 2개월 안에 하는 일"** 중심으로 재편. Tasteby는 이 항목에서 "만들 수 있다"가 아니라 "이미 만들어 운영 중"으로 답할 수 있는 드문 경우
- **Q3-2 축소**: 광고·캠페인 상품 수익원 **삭제**. 유명 큐레이터의 추천이 강점인 서비스에 노출 판매를 붙이면 Q3-1에서 비판한 기존 서비스와 같아지고 차별성이 무너짐 → "지금은 매출이 아니라 사용을 검증하는 단계"로 줄이고, 수익화 축은 크리에이터 파트너십으로 단일화
- **Q4-2·Q8도 동반 수정**: 광고 상품 검증을 전제한 서술을 걷어내고 크리에이터 수익 배분 설계로 교체 (문서 전체에서 "노출을 팔지 않는다"가 일관되게 읽히도록)
- 예약·주문 제휴는 **조건부**로만 남김 (테이블링·캐치테이블의 외부 제휴 프로그램 존재가 확인되지 않음 — 확인 전에는 수익원으로 단정하지 않는다)
### 심사 기준 (확인 결과, 2026-09-15)
- 주최: 중소벤처기업부 대국민 창업 오디션. 일반/기술트랙 4,000명 + 로컬트랙 1,000명 선발, 진출 시 창업활동자금 200만 원 + 책임멘토링 + AI 바우처
- 심사 3축: **도전의 진정성 / 사회적 가치(독창성·차별성) / 아이디어 구체성(BM 현실성·단계별 로드맵)**
- 공통 항목: **2개월 내 MVP 제작 가능 수준인가**
- 출처: modoo.or.kr, 멘토기관 80여 곳 평가지표 정리 자료
## 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를 찾는 이유는 신뢰하는 사람이 고른 집을 보기 위해서입니다. 그 화면에 돈을 낸 식당이 섞이는 순간, 옆에 있는 진짜 추천까지 의심받습니다. 기존 맛집 서비스가 잃은 것이 정확히 그 신뢰이므로, 노출을 파는 방식은 처음부터 배제합니다.
수익의 축은 **크리에이터 파트너십**입니다. 미식 크리에이터에게 채널 공식 페이지를 제공해 영상 속 식당이 검색 가능한 아카이브로 남게 하고, 그 페이지에서 발생한 성과를 크리에이터와 나눕니다. 크리에이터가 이미 영상에서 추천한 가게를 다루는 구조이므로 추천의 순수성이 유지되고, 크리에이터가 수익을 얻으면 Tasteby를 알릴 이유도 함께 생깁니다. 광고와는 방향이 반대입니다.
사용자가 스스로 예약이나 주문을 원할 때 연결하는 제휴 수수료는 다음 단계의 선택지로 둡니다. 현재 서비스에는 예약 링크가 연결된 식당이 이미 300곳 이상 있지만, 예약 플랫폼의 제휴 조건을 확인하기 전까지는 수익원으로 단정하지 않겠습니다.
12개월 안에 큰 매출을 약속드리지는 않겠습니다. 이 기간의 목표는 크리에이터 3팀과 실제 파트너십을 맺어 수익 배분 구조를 한 번 돌려 보는 것이고, 본격적인 수익화는 사용자가 반복해서 돌아온다는 증거가 쌓인 뒤의 일입니다.
## Q4-1. 아이디어를 어떻게 사업화 하실 계획이신가요? (필수 / 2,000자)
**Tasteby는 기획안이 아니라 이미 동작하는 서비스입니다.** www.tasteby.net 에서 운영 중이며, 2026년 9월 기준 등록 채널 6곳, 수집 영상 909편에서 추출해 좌표까지 연결한 식당 500곳이 들어 있습니다. 그중 일본 28곳을 비롯한 해외 식당이 60여 곳이고, 예약 링크가 연결된 식당도 300곳이 넘습니다. 큐레이터·지역·메뉴로 좁혀 찾는 탐색, 지도 기반 발견, 회원·찜·공개 리뷰·비공개 메모까지 사용자가 한 바퀴 도는 흐름이 모두 구현돼 있습니다. 저는 이 전부를 Spring Boot와 Next.js로 직접 설계하고 구현해 클라우드에 배포했습니다.
그래서 저에게 2개월은 무언가를 처음 만드는 기간이 아니라, **이미 만든 것을 사람들 앞에 내놓는 기간**입니다.
**2개월 안에 하는 일은 세 가지입니다.** 첫째, 데이터의 정확도를 끝까지 올리겠습니다. 현재 LLM 검증을 거치지 않은 영상-식당 연결이 1,071건 남아 있습니다. AI 솔루션을 활용해 이를 모두 검증하고, 잘못 연결된 항목을 정리해 출처가 확인된 식당만 노출하겠습니다. 둘째, 채널을 10곳으로 늘려 식당 1,500곳 규모를 만들되 정확도 기준을 통과한 것만 셈에 넣겠습니다. 셋째, 아직 시작하지 않은 공개 사용자 모집을 열고, `발견 → 원본 확인 → 후보 저장 → 재방문`이 실제로 이어지는지 계측하겠습니다. 2개월 뒤에 제가 보여드릴 것은 기능 시연이 아니라, **검증된 데이터 위에서 사람들이 실제로 어떻게 움직였는가** 입니다.
**그 다음은 신뢰를 파트너십으로 넓히는 단계입니다.** 미식 크리에이터에게 오래된 영상이 검색 가능한 아카이브로 남는다는 가치를 제안해 6개월 안에 3팀과 협력하겠습니다. 이때 필요한 계약·저작권·표기 기준은 책임멘토의 도움을 받고자 합니다. 12개월 시점에는 이미 쌓여 있는 일본 데이터를 바탕으로 여행 사용 맥락을 검증하고, 찜한 후보를 동행과 함께 고르는 공유 기능을 더하겠습니다.
가장 큰 위험은 기술이 아니라 신뢰입니다. 원본 링크를 반드시 보존하고, 협찬과 정정 상태를 관리하며, 사용자의 비공개 메모는 어떤 경우에도 광고나 외부 판매에 쓰지 않겠습니다. "더 많은 식당"이 아니라 "다시 쓰이는 신뢰"를 증명하는 것이 Tasteby의 사업화입니다.
> 제출 전 사실 확인: 실측치(채널 6·영상 909·식당 500·미검증 1,071·해외 60여·예약링크 300여)는 2026-09-15 기준. 제출일에 재조회해 갱신한다.
## Q4-2. 책임멘토를 통해 어떤 도움을 받고 싶은가요? (선택 / 2,000자)
저는 Tasteby의 제품과 기술 구현은 직접 추진해 왔지만, 다음 단계에서 필요한 것은 개발 조언보다 **신뢰를 훼손하지 않는 시장 진입과 계약의 기준**입니다. 책임멘토에게 네 가지 도움을 받고 싶습니다.
첫째, 크리에이터 파트너십의 실무입니다. Tasteby는 타인의 콘텐츠를 대신 소비시키는 서비스가 아니라 원본으로 다시 연결하는 서비스여야 합니다. 큐레이터·매니저에게 어떤 가치 제안을 해야 하는지, 출처·초상·인용·협찬 표기를 어떤 계약과 운영 절차로 정리해야 하는지 조언을 받고 싶습니다.
둘째, 신뢰를 해치지 않는 수익 구조의 설계입니다. 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장은 예비로 남긴다. 모든 화면은 제출일 기준 실제 동작·표현과 일치해야 하며, 원본 영상 캡처·타인 음식 사진은 사용권을 확인하지 못하면 사용하지 않는다.

View File

@@ -0,0 +1,207 @@
# 모두의 창업 2차 지원서 — v6
> 작성일: 2026-09-15
> 상태: **Q5·Q11까지 채운 판.** 남은 것은 Q6(드롭다운 실물)·Q7(사업자 상태)·사진 8장뿐이다.
## v5 → v6 변경 요약
- **Q11 완성**: 홈페이지 공개용 소개문 작성. 심사 문서 톤이 아니라 일반 독자가 읽고 좋아요를 누를 톤으로 — 문제 공감 → 서비스 설명 → 사람이 기준이라는 차별점 → 현재 규모 → 링크
- **Q5 대본 작성**: `q5-video-script.md`에 85초 컷 구성(9컷). 화면·자막·시간을 표로 정리했고, 목소리 녹음 없이 자막만으로도 성립하도록 문장을 짧게 씀
## v4 → v5 변경 요약
- **Q4-1 재구성**: 심사 공통 항목이 "창업활동자금과 AI 솔루션을 활용해 **2개월 내 MVP 제작이 가능한 수준인가**"임을 확인 → 0~3/4~6/7~12개월 구조를 **"이미 운영 중 + 2개월 안에 하는 일"** 중심으로 재편. Tasteby는 이 항목에서 "만들 수 있다"가 아니라 "이미 만들어 운영 중"으로 답할 수 있는 드문 경우
- **Q3-2 축소**: 광고·캠페인 상품 수익원 **삭제**. 유명 큐레이터의 추천이 강점인 서비스에 노출 판매를 붙이면 Q3-1에서 비판한 기존 서비스와 같아지고 차별성이 무너짐 → "지금은 매출이 아니라 사용을 검증하는 단계"로 줄이고, 수익화 축은 크리에이터 파트너십으로 단일화
- **Q4-2·Q8도 동반 수정**: 광고 상품 검증을 전제한 서술을 걷어내고 크리에이터 수익 배분 설계로 교체 (문서 전체에서 "노출을 팔지 않는다"가 일관되게 읽히도록)
- 예약·주문 제휴는 **조건부**로만 남김 (테이블링·캐치테이블의 외부 제휴 프로그램 존재가 확인되지 않음 — 확인 전에는 수익원으로 단정하지 않는다)
### 심사 기준 (확인 결과, 2026-09-15)
- 주최: 중소벤처기업부 대국민 창업 오디션. 일반/기술트랙 4,000명 + 로컬트랙 1,000명 선발, 진출 시 창업활동자금 200만 원 + 책임멘토링 + AI 바우처
- 심사 3축: **도전의 진정성 / 사회적 가치(독창성·차별성) / 아이디어 구체성(BM 현실성·단계별 로드맵)**
- 공통 항목: **2개월 내 MVP 제작 가능 수준인가**
- 출처: modoo.or.kr, 멘토기관 80여 곳 평가지표 정리 자료
## 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를 찾는 이유는 신뢰하는 사람이 고른 집을 보기 위해서입니다. 그 화면에 돈을 낸 식당이 섞이는 순간, 옆에 있는 진짜 추천까지 의심받습니다. 기존 맛집 서비스가 잃은 것이 정확히 그 신뢰이므로, 노출을 파는 방식은 처음부터 배제합니다.
수익의 축은 **크리에이터 파트너십**입니다. 미식 크리에이터에게 채널 공식 페이지를 제공해 영상 속 식당이 검색 가능한 아카이브로 남게 하고, 그 페이지에서 발생한 성과를 크리에이터와 나눕니다. 크리에이터가 이미 영상에서 추천한 가게를 다루는 구조이므로 추천의 순수성이 유지되고, 크리에이터가 수익을 얻으면 Tasteby를 알릴 이유도 함께 생깁니다. 광고와는 방향이 반대입니다.
사용자가 스스로 예약이나 주문을 원할 때 연결하는 제휴 수수료는 다음 단계의 선택지로 둡니다. 현재 서비스에는 예약 링크가 연결된 식당이 이미 300곳 이상 있지만, 예약 플랫폼의 제휴 조건을 확인하기 전까지는 수익원으로 단정하지 않겠습니다.
12개월 안에 큰 매출을 약속드리지는 않겠습니다. 이 기간의 목표는 크리에이터 3팀과 실제 파트너십을 맺어 수익 배분 구조를 한 번 돌려 보는 것이고, 본격적인 수익화는 사용자가 반복해서 돌아온다는 증거가 쌓인 뒤의 일입니다.
## Q4-1. 아이디어를 어떻게 사업화 하실 계획이신가요? (필수 / 2,000자)
**Tasteby는 기획안이 아니라 이미 동작하는 서비스입니다.** www.tasteby.net 에서 운영 중이며, 2026년 9월 기준 등록 채널 6곳, 수집 영상 909편에서 추출해 좌표까지 연결한 식당 500곳이 들어 있습니다. 그중 일본 28곳을 비롯한 해외 식당이 60여 곳이고, 예약 링크가 연결된 식당도 300곳이 넘습니다. 큐레이터·지역·메뉴로 좁혀 찾는 탐색, 지도 기반 발견, 회원·찜·공개 리뷰·비공개 메모까지 사용자가 한 바퀴 도는 흐름이 모두 구현돼 있습니다. 저는 이 전부를 Spring Boot와 Next.js로 직접 설계하고 구현해 클라우드에 배포했습니다.
그래서 저에게 2개월은 무언가를 처음 만드는 기간이 아니라, **이미 만든 것을 사람들 앞에 내놓는 기간**입니다.
**2개월 안에 하는 일은 세 가지입니다.** 첫째, 데이터의 정확도를 끝까지 올리겠습니다. 현재 LLM 검증을 거치지 않은 영상-식당 연결이 1,071건 남아 있습니다. AI 솔루션을 활용해 이를 모두 검증하고, 잘못 연결된 항목을 정리해 출처가 확인된 식당만 노출하겠습니다. 둘째, 채널을 10곳으로 늘려 식당 1,500곳 규모를 만들되 정확도 기준을 통과한 것만 셈에 넣겠습니다. 셋째, 아직 시작하지 않은 공개 사용자 모집을 열고, `발견 → 원본 확인 → 후보 저장 → 재방문`이 실제로 이어지는지 계측하겠습니다. 2개월 뒤에 제가 보여드릴 것은 기능 시연이 아니라, **검증된 데이터 위에서 사람들이 실제로 어떻게 움직였는가** 입니다.
**그 다음은 신뢰를 파트너십으로 넓히는 단계입니다.** 미식 크리에이터에게 오래된 영상이 검색 가능한 아카이브로 남는다는 가치를 제안해 6개월 안에 3팀과 협력하겠습니다. 이때 필요한 계약·저작권·표기 기준은 책임멘토의 도움을 받고자 합니다. 12개월 시점에는 이미 쌓여 있는 일본 데이터를 바탕으로 여행 사용 맥락을 검증하고, 찜한 후보를 동행과 함께 고르는 공유 기능을 더하겠습니다.
가장 큰 위험은 기술이 아니라 신뢰입니다. 원본 링크를 반드시 보존하고, 협찬과 정정 상태를 관리하며, 사용자의 비공개 메모는 어떤 경우에도 광고나 외부 판매에 쓰지 않겠습니다. "더 많은 식당"이 아니라 "다시 쓰이는 신뢰"를 증명하는 것이 Tasteby의 사업화입니다.
> 제출 전 사실 확인: 실측치(채널 6·영상 909·식당 500·미검증 1,071·해외 60여·예약링크 300여)는 2026-09-15 기준. 제출일에 재조회해 갱신한다.
## Q4-2. 책임멘토를 통해 어떤 도움을 받고 싶은가요? (선택 / 2,000자)
저는 Tasteby의 제품과 기술 구현은 직접 추진해 왔지만, 다음 단계에서 필요한 것은 개발 조언보다 **신뢰를 훼손하지 않는 시장 진입과 계약의 기준**입니다. 책임멘토에게 네 가지 도움을 받고 싶습니다.
첫째, 크리에이터 파트너십의 실무입니다. Tasteby는 타인의 콘텐츠를 대신 소비시키는 서비스가 아니라 원본으로 다시 연결하는 서비스여야 합니다. 큐레이터·매니저에게 어떤 가치 제안을 해야 하는지, 출처·초상·인용·협찬 표기를 어떤 계약과 운영 절차로 정리해야 하는지 조언을 받고 싶습니다.
둘째, 신뢰를 해치지 않는 수익 구조의 설계입니다. 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개월 검증 목표와 멘토링 요청
> **촬영 대본: `q5-video-script.md`** (85초 9컷, 화면·자막·시간 표)
> 제출 전 입력: `[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**는 내가 믿는 미식 유튜버들이 다녀간 가게를 AI가 영상에서 찾아내 지도 위에 모아줍니다. 가게를 누르면 그 사람이 어떤 영상에서 뭐라고 했는지 그대로 나오고, 궁금하면 원본 영상으로 바로 돌아갈 수 있습니다.
별점 평균이 아니라 **사람**이 기준입니다. 오늘 떡볶이가 당기면 분식만 파고든 채널이 검증한 집으로, 특별한 날이면 파인다이닝을 다루는 채널이 고른 집으로 갑니다.
지금 채널 6곳, 영상 909편에서 찾아낸 식당 500곳이 올라가 있습니다. 일본 가게도 조금씩 쌓이는 중입니다. 기획부터 개발, 배포까지 혼자 만들었고, 지금도 매주 고치고 있습니다.
**www.tasteby.net** — 가입 없이 바로 둘러보실 수 있습니다.
> 게시 전: 대표 스크린샷 1장을 함께 올린다. 숫자는 게시일 기준으로 재확인한다.
## 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
View 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> # 복원
```

View File

@@ -26,6 +26,7 @@ module.exports = {
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
script: "npm",
args: "run dev",
env: { PORT: 3001 },
},
],
};

View File

@@ -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 ──

View 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();
});
});

View 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);
});
});

View 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("한국 › 서울");
});
});

View 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)
```

View File

@@ -15,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";
@@ -196,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);
@@ -248,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;
@@ -271,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[]>(() => {
@@ -366,6 +369,7 @@ export default function Home() {
setChannelFilter("");
setCuisineFilter("");
setPriceFilter("");
setMinMentions(0);
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
@@ -385,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(() => {
@@ -475,6 +481,7 @@ export default function Home() {
setChannelFilter("");
setCuisineFilter("");
setPriceFilter("");
setMinMentions(0);
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
@@ -816,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="음식 필터 초기화"
>
@@ -886,7 +907,7 @@ export default function Home() {
)}
</div>
{/* 필터 전체 해제 */}
{(channelFilter || cuisineFilter || priceFilter || countryFilter) && (
{(channelFilter || cuisineFilter || priceFilter || countryFilter || minMentions > 0) && (
<button
onClick={() => {
setChannelFilter("");
@@ -895,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"
@@ -915,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 }); },
@@ -1139,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 }); },
@@ -1251,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 }); },

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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;
}

View 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;
}

View 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch] as string));
}

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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}` : ""}`);
},

View File

@@ -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";
}

View 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;
}

View 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(" › ");
}

View File

@@ -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>"