# Tasteby 작업 기록

> 작업 내용, 이슈, 해결 방법을 기록하는 문서. 커밋/배포 시 참고용.

---

## 2026-07-27

### 🛑 [Reviewer] #533 반려 — 전역 "전체보기" 버튼이 언급수 필터를 초기화하지 않음
- 파일: `frontend/src/app/page.tsx:910`(노출 조건), `912-919`(onClick 초기화 목록)
- 문제: 전역 필터 초기화 버튼("전체보기")의 노출 조건과 초기화 로직에 `minMentions`가 누락됨
  - 언급수 필터만 켜져 있으면 버튼 자체가 노출되지 않음(그룹 로컬 X 버튼으로는 초기화 가능)
  - 다른 필터와 언급수 필터를 함께 켠 상태에서 "전체보기"를 눌러도 언급수 필터는 계속 적용된 채로 남아, 사용자가 전체 초기화됐다고 오인할 수 있음
- 권고: 910행 조건에 `|| minMentions > 0` 추가, 912-919행에 `setMinMentions(0)` 추가. "내위치" 토글 초기화 목록도 함께 점검 권고
- 코어 필터 로직(설계서 일치, 경계값, 보안)은 문제 없음 — 이 결함만으로 반려
- 06-Reviewer → 03-Developer 로 반려

### 🎨 [Designer] #533 UX 검토 — 코드 변경 없음
- 데스크톱 툴바 "언급" 셀렉트가 기존 필터(장르/가격/나라)와 동일한 패턴(플레이스홀더/강조색/그룹 X 초기화/tooltip)을 따름을 확인, 문구 변경 불필요
- 전역 "전체보기" 버튼 결함 발견 → 동작 변경이 필요해 Designer 범위 밖으로 판단, Reviewer 단계로 이관
- 05-Designer → 06-Reviewer 로 전진 (코드 변경 없음)

### ✅ [QA] #533 언급 적은 식당 제외 필터 — 검증 통과
- 설계서(`docs/design/533-min-mentions-filter/README.md`)와 구현(commit 7639fd0) 일치 확인
  - 백엔드: `RestaurantMapper.xml findAll` 스칼라 서브쿼리(`video_count`) + `min_mentions` 필터, `DISTINCT` 조합에도 중복 없음 확인
  - 프론트: `page.tsx` `filteredRestaurants`에서 `video_count` 가드 적용, 데스크톱 툴바 "언급" 셀렉트(2/3/5회+)
- dev API 실측 검증: `min_mentions=0/1/2/3/5/100` 전 구간에서 경계 위반 0건 (=2→116개, =3→36개, =5→5개, =100→0개, 음수/0은 미적용과 동일)
- `cuisine`/`channel` 등 기존 필터와 조합 시에도 회귀 없음 확인
- 회귀 테스트 추가: `passesMinMentions()`을 `frontend/src/lib/filters.ts`로 추출(동작 변경 없음, 테스트 가능성 확보) + `frontend/__tests__/filters.test.ts`(6케이스: 미적용/음수/미만/경계값/초과/null·undefined)
- 백엔드 빌드, `tsc --noEmit`, 프론트 전체 jest 스위트(6 suites, 32 tests) 통과
- 갭(참고, 반려 사유 아님): `min_mentions` 필터 UI가 데스크톱 툴바에만 있고 모바일 FilterSheet에는 미적용 — 설계서에서 서버사이드 일원화(#535)로 이미 분리한 범위, 모바일 UI 적용은 #535 진행 시 함께 처리 권고
- 04-QA → 05-Designer 로 전진

### 🎨 [Designer] #532 지역 표시 빈 배지 방지 — 렌더 가드 정리
- 문제: `RestaurantList.tsx`/`RestaurantDetail.tsx`가 원본 `region` 문자열(예: `"|"`, `"한국|"`)의 truthy 여부로 표시 여부를 결정 → 전 토큰이 더미/빈 값이어서 `formatRegion()` 결과가 `""`이 되는 경우에도 빈 지역 배지/줄이 렌더되어 레이아웃에 불필요한 간격이 남을 수 있었음(dev 데이터에서는 미발견, 이론적 엣지케이스)
- 해결: 표시 가드를 `r.region &&` → `formatRegion(r.region) &&` 로 변경(두 파일 모두). `formatRegion()` 로직/시그니처는 변경 없음 — 호출부 조건만 정리
- `tsc --noEmit`, 프론트 전체 jest 스위트(5 suites, 26 tests) 통과
- 05-Designer → 06-Reviewer 로 전진

### ✅ [QA] #545 해외 식당 예약 버튼 숨김 — 검증 통과
- 설계서(`docs/design/545-hide-foreign-reservation-buttons/README.md`) 인수조건 대비 구현(`RestaurantDetail.tsx`) 일치 확인
  - 테이블링/캐치테이블 버튼 2곳 모두 `isKoreaRestaurant(restaurant) &&` 게이트 적용됨 (지도 링크 분기와 동일 판정 기준 재사용)
- 회귀 테스트 추가: `frontend/__tests__/RestaurantDetail.test.tsx` (5케이스)
  - 국내 좌표(서울) → 버튼 노출, 해외 좌표(방콕, 대표 발견 케이스) → 버튼 숨김
  - 해외+URL 없음(도쿄) → 숨김 유지, 좌표 없음+region null → fallback으로 국내 간주(기존 동작 유지)
  - 예약 URL `"NONE"` 문자열 → 국내 식당이어도 버튼 없음(기존 게이트 유지, 회귀 없음)
- `tsc --noEmit` 통과, 프론트 전체 jest 스위트(5 suites, 26 tests) 통과
- 04-QA → 05-Designer 로 전진

### ✅ [QA] #532 지역 표시 계층화 — 검증 통과
- `formatRegion()` 설계서(docs/design/532-region-display/README.md) 명세대로 구현됨을 확인
- 단위 테스트 추가: `frontend/__tests__/region.test.ts` (falsy, 계층 조인, `null`/빈 토큰/더미 `나라` 제거, 단일 토큰, 전량 더미, trim 8케이스) — 전부 통과
- `tsc --noEmit` 통과, 프론트 전체 jest 스위트(4 suites, 21 tests) 통과
- 실제 dev DB 데이터(`region: "한국|서울|"` 등)로 엣지케이스 재확인 — 설계서 예시와 일치
- 04-QA → 05-Designer 로 전진

## 2026-07-19

### 📋 설계서/Redmine 현행화 — #545/#533/#532 파이프라인 정지 점검
- 발견: #545/#533/#532 세 이슈가 Developer 구현 완료(2026-06-28) 이후 04-QA 단계에서 3주간 정지 확인
- #545: Architect 단계 없이 구현되어 설계서 게이트 위반 확인 → `docs/design/545-hide-foreign-reservation-buttons/README.md` 소급 작성
- #533, #532: 기존 설계서가 실제 구현과 일치함을 재확인 (수정 없음)
- Redmine 3건에 현재 상태 저널 노트 반영 (잔여: 육안 QA)
- git/Redmine/설계서 3자 정합성 점검 — git 클린, 대응 커밋 확인 완료

## 2026-06-28

### 🐛 해외 식당에 테이블링/캐치테이블 예약 버튼 노출 수정 (#545)
- 문제: 국내 전용 예약 서비스 버튼이 해외 식당 상세에도 노출 (URL만 있으면 렌더)
- 해결: RestaurantDetail.tsx 두 예약 버튼을 `isKoreaRestaurant()`로 게이트 (지도 링크와 동일 기준)
- 후속(옵션): 백엔드 예약 URL 검색/벌크가 해외 식당 제외

### ✨ 언급 적은(관련도 낮은) 식당 제외 필터 (#533)
- 신호: "의미있는 언급 수" = video_restaurants 중 relevance ∈ (strong, unknown) 개수 (weak/incidental 제외)
- 백엔드: 식당 목록 응답에 `video_count` 추가 + `min_mentions` 서버 필터 파라미터
  - RestaurantMapper.xml findAll: 스칼라 서브쿼리 video_count + minMentions 필터
  - Restaurant.videoCount 필드 / Service·Controller 스레드 / 캐시키 `mm=` 포함
  - 검증: min_mentions=2 → 116개(전부 ≥2, 위반 0), =3 → 36개
- 프론트: Restaurant.video_count 타입 + 데스크톱 툴바 "언급" 필터(2/3/5회+) 클라이언트 필터
- 후속: 모바일 FilterSheet 적용 + 서버사이드 일원화(#535)
- 설계서: `docs/design/533-min-mentions-filter/README.md`

### 🐛 지역 표시 계층화 — "한국|서울" → "한국 › 서울" (#532)
- 문제: 식당 상세/리스트가 region 원본(`한국|서울|강남구`, 파이프 노출)을 그대로 표시
- 해결: 공용 유틸 `lib/region.ts` `formatRegion()` 신설 — 빈토큰/`null`/더미(`나라`) 제거 후 ` › ` 계층 조인
- 적용: `RestaurantDetail.tsx`(지역 표기), `RestaurantList.tsx`(카드 지역)
- 검색 쿼리용 `buildSearchQuery`(공백 조인)는 유지 — 외부 지도 검색 호환
- 설계서: `docs/design/532-region-display/README.md`

## 2026-06-17

### 🎯 클러스터 expansion 후 마커 클릭 시 재묶임 방지 (v0.1.66)
- 기존: selected 시 무조건 zoom 16 → 클러스터 expansion(18)에서 마커 클릭하면 다시 16으로 줄어 재묶임
- 변경: 현재 zoom ≥ 16이면 유지, 미만이면 16으로 (NaverMap morph + GoogleMap setZoom 조건부)
- page.tsx의 setRegionFlyTo 호출 제거 — selected useEffect로 일원화 (zoom 16 강제 회피)

### 🐛 NaverMap 클러스터 클릭 시 중앙 이동 fix (v0.1.65)
- panTo(애니메이션) + setZoom(z, true)(애니메이션) 두 애니메이션 동시 호출이 서로 cancel
- 단일 식당은 줌 변화 없어서 안 보였고, 클러스터는 줌 크게 변경 → 충돌 가시화
- NaverMap SDK의 `morph(latlng, zoom)` 한 메서드로 통일 — center+zoom 동시 변경

### 🎯 NaverMapView selected 자동 panTo + zoom (v0.1.64)
- 마커/클러스터/리스트 어디서 선택해도 그 식당이 화면 중앙으로 + zoom 16
- GoogleMapView에는 이미 있던 useEffect [selected] 패턴을 동일하게 추가

## 2026-06-16

### 🧹 미커밋 잡변경 정리 + 5개 분리 commit (v0.1.63)
- backend: 어드민 권한 토글 service/mapper/CORS PATCH 미커밋분 적용
- backend: JsonUtil.normalizeEvaluation 정식 commit (PipelineService/RestaurantService 이미 호출 중)
- frontend: Phosphor 아이콘 마이그레이션 + FoodIcon 신규 + brand-guide.md
- docs: oke-deploy-howto.md 신규
- chore: ecosystem PORT=3001 + reviews/screenshots gitignore

### 🎯 식당 선택 시 지도 자동 줌인/이동 (v0.1.62)
- 리스트 / 검색결과에서 식당 클릭 → setRegionFlyTo로 그 식당 좌표 + zoom 16
- 지도가 선택 식당으로 panTo + zoom — NaverMap/GoogleMap 둘 다
- 마커의 selected 강조(1.15× + 파란 박스)와 함께 동작

### 🏷️ NaverMapView 마커에 식당명 박스 (v0.1.61)
- 단순 동그라미 → GoogleMapView와 동일 핀 디자인(박스+화살표+식당명+cuisine 아이콘)
- 채널별 배경/테두리/화살표 색상, 폐업(business_status CLOSED_*) 표시 회색 + 취소선
- selected 식당 강조 (1.15× scale + 파란 박스), zIndex 1000
- InfoWindow 제거 (식당명 자체가 박스로 보이므로 불필요)

### 🎨 NaverMapView 채널별 마커 색상 (v0.1.60)
- GoogleMapView와 동일 팔레트 (amber/blue/green/pink/purple/red/teal/yellow)
- 식당의 첫 채널 기준 색상, activeChannel 있으면 그 채널 우선

### ⚡ NaverMapView SDK 네이티브 마커 + InfoWindow (v0.1.59)
- 마커를 React `absolute div` overlay → `naver.maps.Marker` 네이티브로 교체
  - 줌/팬 시 SDK가 GPU 최적화, 매 frame React 리렌더링 없음 → 랙 해소
- 식당명 InfoWindow 추가 (마커 클릭 시 표시)
- bounds_changed → idle 이벤트로 sync (줌/팬 중 발화 빈도 ↓)
- 클러스터도 네이티브 마커 (HTML 콘텐츠로 숫자 표시)

### 🗺️ NaverMapView 안정화 + 재활성 (v0.1.57)
- divRef 항상 마운트 (이전: ready 가드로 첫 렌더 ref 누락 가능)
- 명시적 width/height + 회색 배경(시각적 로딩 표시)
- ResizeObserver + requestAnimationFrame으로 컨테이너 0×0 → 정상 크기 시 refresh
- try/catch + initError state로 init 실패 가시화
- Naver 키 재활성

### ⏪ NaverMap 임시 비활성, 한국도 GoogleMap fallback (v0.1.55)
- NaverMapView 골격이 실 운영에서 지도/마커 렌더 실패 (정확한 원인 추후 진단)
- NEXT_PUBLIC_NAVER_MAP_CLIENT_ID 빈 값으로 dispatcher가 GoogleMap fallback (회귀 0)
- NaverMapView 코드는 유지 — 안정화 후 환경변수 채우면 재활성

### 🐛 /api/stats/visits 500 — StatsMapper resultType int → long (v0.1.54)
- StatsMapper interface는 `long` 반환인데 XML resultType이 `int` → Integer를 long에 cast 실패
- ClassCastException: Integer → Long. resultType만 long으로 교정

### 🐛 NaverMap 인증 파라미터 ncpClientId → ncpKeyId (v0.1.53)
- NCLOUD 신 정책: `ncpKeyId` 사용 (옛 `ncpClientId`는 NAVER Developers용)
- 인증 200/Failed의 진짜 원인 — 도메인 등록은 정확했으나 파라미터 이름 차이로 키 인식 실패
- 새 NCLOUD Maps Client ID(`fg01bipxbo`)로 prod 재빌드
- 참고: https://github.com/navermaps/maps.js.ncp/blob/master/index.html

### 🗺️ #363 메인 지도 SDK 국내(네이버)/해외(구글) 분기 (v0.1.52)
- MapView를 dispatcher로 전환: 좌표가 KR bbox + NAVER_MAP_CLIENT_ID 설정 시 NaverMapView, 그 외 GoogleMapView
- NaverMapView 신규 (네이버 v3 직접 wrapper, Supercluster 재사용, 마커/클러스터/flyTo)
- GoogleMapView 신규 (기존 MapView 내용 rename)
- MapView.types.ts 공용 (MapBounds/FlyTo/MapViewProps + isKoreaCoord)
- Dockerfile + deploy.sh: NEXT_PUBLIC_NAVER_MAP_CLIENT_ID build-arg 추가
- 키 미설정 시 GoogleMap fallback (회귀 0)
- 설계서: docs/design/363-map-sdk-branch/README.md
- Refs: #363

### 🗺️ 식당 상세 지도 링크 국내/해외 분기 (v0.1.51)
- 좌표 기반 한국 판정 (WGS84 KR bbox 33~38.7°N, 124~132°E)
- 국내: 네이버 지도 primary + Google Maps 보조 (네이버 URL은 신 도메인 /p/search/)
- 해외: Google Maps 단독
- 좌표 없으면 region 첫 토큰 fallback (구 데이터 호환)
- frontend-only 배포

## 2026-06-15

### 🐛 캐치테이블 URL 패턴 수정 (v0.1.50)
- 실제 catchtable URL은 `app.catchtable.co.kr/ct/shop/...` 형식 (옛 `/shop/`, `/dining/`은 매칭 실패)
- 첫 회차(v0.1.49) 캐치테이블 벌크 결과 1044건 전부 미발견(매핑 0%)의 원인
- 패턴을 `catchtable.co.kr/ct/shop/`, `catchtable.co.kr/ct/dining/`로 교정 후 NONE 해제 + 재실행

### 🐛 WebSearchService HTTP timeout 추가 (v0.1.49)
- 벌크 백필 중 특정 검색에서 무한 hang → backend executor virtual thread 점유로 후속 작업 중단 (90건 처리 후 멈춤)
- connectTimeout=5s + request timeout=15s (Naver/DDG 둘 다)
- 해당 식당은 HttpTimeoutException → notfound로 안전 처리

### ⏱️ bulk-tabling/catchtable SSE timeout 10분 → 3시간 (v0.1.48)
- 대량 백필(724건 ≈ 100분) 시 10분 SSE timeout으로 중간 끊김 → 3시간으로 확장
- 백엔드 작업은 virtual thread로 별도 진행됐지만 emit() 예외로 마지막 cache.flush + complete 누락이슈 해소

### 🐛 #357 후속 — tabling-url validation에 www. 호스트 허용 (v0.1.47)
- Naver/DDG 결과가 `https://www.tabling.co.kr/...` 형태인데 #290 validation은 `tabling.co.kr/`만 허용 → 단건 매핑 PUT 거부
- bulk-tabling SSE는 validation 없이 service.update 직접 호출이라 통과 → 단일/벌크 불일치
- `www.tabling.co.kr` prefix도 허용 (catchtable은 이미 app/www 둘 다 허용)
- 시연 등록: bbq 부천은하마을점 → BBQ 치킨 부천은하마을점

### 🔍 #359 1단계 — google_place_id 중복 조회 API (v0.1.46)
- GET /api/admin/restaurants/duplicates/place-id (어드민 전용)
- 응답: 그룹별 식당 + video/review/memo 카운트 (병합 의사결정 자료)
- 정리/병합 + UNIQUE 제약은 별도 PR (데이터 위험 분리)
- 설계서: docs/design/359a-duplicate-place-id-view/README.md
- Refs: #359 (조회 단계 완료, 후속 분리 유지)

### 📋 #358 RestaurantUpdateDTO + @Valid 표준화 (v0.1.45)
- dto/RestaurantUpdateDTO record 신규 (15 필드, 모두 nullable)
- Bean Validation: @Size/@Pattern(URL or NONE)/@DecimalMin·Max/@Min·Max
- RestaurantController.update 시그니처 Map → @Valid DTO 교체
- toFieldMap()으로 null 제외 후 기존 Service.update 호출 (회귀 0)
- #332 ALLOWED_UPDATE_FIELDS Set 제거 (DTO 필드 자체가 화이트리스트)
- 설계서: docs/design/358-restaurant-update-dto/README.md
- Refs: #358 (close)

### 🔎 #357 DDG → Naver Search 정식 API + DDG 폴백 (v0.1.44)
- WebSearchService 신규 (Naver webkr.json 우선, 키 미설정/실패 시 DDG 폴백)
- RestaurantController.searchTabling/searchCatchtable 내부 호출 교체, DDG 인라인 80줄 제거
- application.yml: app.naver.client-id/secret (NAVER_CLIENT_ID/SECRET 환경변수)
- k8s/secrets.yaml.template에 NAVER_CLIENT_ID/SECRET 항목 추가
- 미사용 import 정리 (HttpClient/URI/URLEncoder/Pattern 등 RestaurantController에서)
- 설계서: docs/design/357-web-search-api/README.md
- Refs: #357 (close)

### 🎯 #356 영상-식당 관련도 LLM 평가 (v0.1.43)
- DB: video_restaurants 컬럼 추가 (relevance/relevance_reason/relevance_evaluated_at) + idx_vr_relevance
- VideoRelevanceService 신규 (#322 RestaurantVerifyService 패턴 모방, @Async verifyAsync/verify/verifyAll)
- PipelineService.processExtract — linkVideoRestaurant 후 verifyAsync(linkId) 자동 트리거
- GET /api/restaurants/{id}/videos: 기본 strong/unknown만 응답 (안전 기본값), ?include_weak=true 시 모두
- AdminVideoRelevanceController 신규 (pending/all/{id}/evaluate/{id} PATCH)
- 응답 매핑: relevance, relevance_reason 필드 동봉
- 기존 1244 링크는 'unknown' 시작 → 어드민 백필로 점진 평가
- 설계서: docs/design/356-video-relevance-llm/README.md
- Refs: #356 (close)

### 🧹 #351 admin SSE 6곳 consumeSseStream 통일 (v0.1.42)
- VideosPanel 4곳(bulkTranscript/Extract, rebuildVectors, remapCuisine, remapFoods)
- RestaurantsPanel 2곳(bulkTabling, bulkCatchtable)
- response.body?.getReader 직접 호출 0건 (lib/admin-utils.ts의 consumeSseStream 활용)
- 149줄 삭제 → 74줄 압축, npm test 13/13 통과
- Refs: #351 (close)

### 🧪 #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns (v0.1.40)
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers 도입
- next/jest 자동 SWC 통합, jest.config.ts + jest.setup.ts (setupFilesAfterEnv)
- npm scripts: test, test:watch
- 샘플 테스트 3개 13/13 통과: i18n/config(5), Stars(5), admin-utils(4)
- MyReviewsList: role=tablist/tab/aria-selected/aria-controls/tabIndex + tabpanel
- next.config.ts remotePatterns: Google avatar + YouTube thumbnail/avatar
- 후속: 전체 컴포넌트 테스트 확장, 백엔드 JUnit, E2E(Playwright), CI 통합
- 설계서: docs/design/343-frontend-test-infra/README.md
- Refs: #343 (close)

### 🔤 #348 isNameSimilar 한국어 자모 + Sørensen-Dice (v0.1.38)
- HangulSimilarity 유틸 신규 (Unicode NFD 분해 + bigram Sørensen-Dice)
- RestaurantController.isNameSimilar 교체, 임계값 0.45
- 짧은 한국어 이름 매칭 정확도 향상 (예: "스타벅스 강남" vs "스타벅스 강남점")
- 후속 분리: #357(DDG→정식 API), #358(DTO+@Valid), #359(UNIQUE+데이터 정리)
- 설계서: docs/design/348-name-similarity/README.md
- Refs: #348 (close)

### 🌐 #352 i18n 뼈대 ko/en/ja/es (v0.1.37)
- next-intl 5.x 도입
- src/i18n/{config,LocaleProvider} + src/messages/{ko,en,ja,es}.json (30 키)
- LanguageSwitcher 컴포넌트 (헤더, ARIA listbox, 44px, 국기+네이티브명)
- localStorage tasteby_locale + 브라우저 언어 감지 + ko fallback
- 설계서: docs/design/352-i18n-skeleton/README.md
- 미적용: URL 라우팅 i18n, SEO meta, 사용자 콘텐츠 번역, 어드민(한국어 유지)
- Refs: #352 (close)

### 🧹 #329 admin/page.tsx 분리 (v0.1.35→v0.1.36 운영 반영)
- page.tsx 2817 → 107 LOC (탭 라우팅 + 헤더만)
- _panels/{Channels,Videos,Restaurants,Users,Daemon}Panel.tsx 5개 분리
- localStorage.getItem 10곳 → getAdminToken() (admin-utils.ts)
- SSE 통일은 후속 #351 분리
- 설계서: docs/design/329-admin-split/README.md
- Refs: #329 (close)

### ⚡ #331 VectorService batchUpdate (v0.1.34)
- saveRestaurantVectors: N+1 단건 INSERT → 단일 jdbc.batchUpdate(SqlParameterSource[])
- UUID 인라인 변환 제거 → IdGenerator.newId() 공통화
- 현재 N=1이지만 chunk 분할 도입 시 효과 본격화
- 설계서: docs/design/331-vector-batch-insert/README.md
- Refs: #331 (close)

### ⚡ #326 parseJson 단일 패스 (v0.1.33)
- OciGenAiService.parseJson 잘린 배열 복구를 brace depth counter 단일 패스로 교체
- 이전 O(N²) + Jackson 예외 양산 → O(N) + 명시적 에러 경로
- 문자열/escape 처리 정확
- 설계서: docs/design/326-parsejson-optimization/README.md
- Refs: #326 (close)

### 🛡️ #332 Restaurant PUT 화이트리스트 명시 (v0.1.32)
- ALLOWED_UPDATE_FIELDS set으로 PUT /api/restaurants/{id} body 필터
- 허용 외 키 silent drop + DEBUG 로그
- sanitized.isEmpty()면 200 + no-op
- 후속 분리: #348 (DDG → 정식 API, isNameSimilar 한국어, DTO 표준화)
- Refs: #332 (close)

### 🛡️ #337 통계 봇 필터 + 레이트리밋 (v0.1.31)
- BotDetector: UA 정규식 (bot|crawler|spider|slurp|scrap|fetch|monitor|preview|lighthouse)
- RateLimitService: Redis SET NX EX(60s) 패턴, fail-open (의존성 최소화)
- StatsController.recordVisit: X-Forwarded-For 우선 IP + 봇/IP 가드
- 응답: {ok, counted:bool} — 차단도 200 (사용자 페이지 지장 X)
- application.yml: app.rate-limit.visit-window-seconds (기본 60)
- 운영 검증: Googlebot/Mozilla/즉시 재호출 인수조건 모두 충족
- 설계서: docs/design/337-stats-bot-ratelimit/README.md
- Refs: #337 (close)

### 🔒 #335 데몬 분산 락 ShedLock+Redis (v0.1.30)
- shedlock-spring 5.16.0 + shedlock-provider-redis-spring
- @EnableSchedulerLock(defaultLockAtMostFor=PT15M)
- DaemonScheduler.run: @SchedulerLock(name="daemon-runner", lockAtMostFor=PT15M, lockAtLeastFor=PT30S)
- ShedLockConfig: RedisLockProvider Bean (in-cluster Redis 재사용)
- 멀티 파드(RollingUpdate) + dev/prod ATP 공유 환경에서 데몬 중복 실행 차단
- 설계서: docs/design/335-daemon-distributed-lock/README.md
- Refs: #335 (close)

### 💾 #336 캐시 SCAN/UNLINK + 자동 복구 + 에러 메트릭 (v0.1.29)
- CacheService.flush: redis.keys() 블로킹 → SCAN cursor + UNLINK 논블로킹 (500 batch)
- @Scheduled(30s) checkHealth: Redis ping → disabled 자동 토글 (재기동 시 자동 복구)
- AtomicLong errorCount + volatile lastError + 로그 throttle (n==1 또는 n%100==0)
- GET /api/admin/cache/stats: disabled/errorCount/lastError 노출 (admin only)
- 설계서: docs/design/336-cache-scan-recovery/README.md
- Refs: #336 (close)

### 🔧 P5-2 작은 후속 (v0.1.26)
- #338: /api/version 신규 (HealthController + permitAll), application.yml app.build.{version,commit} env 주입 준비
- #320: findRegionFromCoords 거리 보정 (유클리드 → cos(lat) 가중치)
- #340: MapView 클러스터/마커/범례에 role/aria-label
- #333: ChannelController cache.flush() → cache.del("channels") (다른 모듈 캐시 보존)
- Refs: #338 #320 #340 #333 (close)

### 🧹 P5-1 작은 후속 묶음 (v0.1.24)
- #325: ThreadLocalRandom 통일, rebuildVectors not_implemented 이벤트, getTranscript JavaDoc 명세
- #319: buildSearchQuery 헬퍼 + fn-doc(BottomSheet snap 정책)
- #344: --z-bottom-sheet/--z-filter-sheet/--z-modal CSS 변수 + LoginMenu zIndex 99999 → var(--z-modal)
- Refs: #319 #325 #344 (close)

### ⭐ P4-4 별점 공통화 + 로그인 모달 접근성 (v0.1.23)
- #281: 공통 Stars 컴포넌트 (0.5단위 절반 채우기), StarSelector role=radiogroup + 44px + 반쪽 별 ⯨, try/catch + alert
- #283: LoginMenu에 useEscapeKey/useFocusTrap/useBodyScrollLock 훅 적용, role=dialog/aria-modal/aria-labelledby, onError 인라인 alert
- MyReviewsList: Math.round → Stars (0.5단위 정확 렌더)
- 후속 분리: #343(next/image, ARIA Tabs, 테스트), #344(z-index 토큰, i18n)
- Refs: #281 #283 (close)

### 🔐 P4-3 인증 메시지 + 지도 접근성 (v0.1.22)
- #266: Google verifier 실패 메시지 고정 + log.warn (정보 누출 차단)
- #278: boundsTimerRef cleanup, '내 위치' 44px + aria-label, dead code 제거
- #277: 결함 모두 후속(#338) — deep health/version/테스트는 별도
- 후속 분리: #338(deep health), #339(브랜드 토큰화/마커 ARIA), #340(다중 audience)
- Refs: #266 #277 #278 (close)

### ⚙️ P4-2 데몬/캐시/통계 결함 (v0.1.21)
- #275: updateConfig 가드(1+ 정수), Scheduler try-finally updateLastX, GET config admin-only
- #276: ping try-with-resources + ConnectionFactory null 가드, makeKey null 가드
- #274: SiteVisitStats int → long, recordVisit DataIntegrityViolationException 1회 재시도
- 후속 분리: #335 (분산락), #336 (SCAN/자동복구), #337 (봇/레이트리밋)
- Refs: #275 #276 #274 (close)

### 🧱 P4-1 백엔드 CRUD 결함 (v0.1.20)
- #294: MemoService/ReviewService 동시성 DuplicateKeyException 가드, rating 0~5 검증, getAvgRating NVL
- #295: 유니크 충돌 typed exception, channel_id "UC..." 형식 명시 분기, findByChannelId 컬럼 보완, body null 가드
- #290: @PreDestroy executor shutdown, 캐시 silent → log.warn + cache.del, tabling/catchtable URL 스킴 화이트리스트
- 후속 분리: #332(#290), #333(#295), #334(#294) — DTO/DDG/세분화/테스트
- Refs: #290 #294 #295 (close)

### 🔍 #293 검색/벡터 결함 7건 (v0.1.19)
- SearchController: q 빈값 400 가드 (`%%` 응답 폭발 차단)
- SearchService: LIKE 와일드카드 escape (%, _, \), hybrid 모드에서 sem 결과에도 채널 부착
- SearchService: ObjectMapper/TypeReference static 재사용, 알 수 없는 mode warn 로그
- SearchService: maxDistance를 @Value("${app.search.max-distance:0.57}") 외부화 (env SEARCH_MAX_DISTANCE)
- SearchMapper.xml: LIKE 절에 ESCAPE '\' 추가
- VectorService: embeddings null/empty 가드 (NPE 차단)
- 후속 분리: #331 (batch insert + 테스트)
- Refs: #293 (close)

### 🛠 #304+#323 어드민 LLM 검증 UI + 공통 유틸 (v0.1.18)
- 신규 frontend/src/lib/admin-utils.ts:
  - getAdminToken / authHeaders / consumeSseStream
- api.ts: Restaurant 타입에 hidden/hidden_reason/verified_at + verify/setRestaurantHidden API 4개
- RestaurantsPanel:
  - 헤더: "미검증 N건 + LLM 검증" 버튼
  - 테이블: 검증 컬럼 (숨김/OK/미검증 배지 + 클릭으로 토글)
- colSpan 7로 수정
- 후속 분리: #329 (admin 전체 파일 분리 + localStorage/SSE 11+곳 통일)
- Refs: #304 #323 #322 (close)

### 🔧 #291+#292 백엔드 결함 일괄 수정 (v0.1.17)
- ExtractorService: transcript null/blank 가드 (NPE 방지)
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이 (SSE/사용자 가시성)
- PipelineService: geocode 실패 시 좌표/place_id/주소 컬럼을 data에 put하지 않아 upsert COALESCE 보존 의도 명확화
- GeocodingService.parseRegionFromAddress: 빈 토큰을 region 문자열에서 제거 ('한국||구' 깨짐 방지)
- VideoService.saveVideosBatch: @Transactional 추가 → batch insert 원자성
- .gitignore: backend-java/cookies.txt 및 **/cookies.txt
- 후속 분리: #325 (#291 잔여 MINOR), #326 (parseJson 최적화 + #292 MINOR)
- Refs: #291 #292 (close)

### 🧹 #322 LLM 검증으로 잘못된/프랜차이즈 식당 자동 숨김 (v0.1.16)
- DB 마이그레이션: restaurants에 hidden(NUMBER(1)), hidden_reason(VARCHAR2(120)), verified_at(TIMESTAMP) + idx_restaurants_hidden
- 도메인/Mapper/Service 확장: includeHidden 옵션, updateVerification, findUnverified 등
- 신규 RestaurantVerifyService:
  - verifyAsync (신규 등록 자동 검증)
  - verifyAll (백필, 식당당 200ms sleep)
  - parseVerifyResponse (안전 기본값: 파싱 실패 시 valid=true → hidden 유지)
- PipelineService.processExtract 끝에 verifyAsync(restId) 자동 호출
- AdminRestaurantController 신규 (requireAdmin):
  - GET /api/admin/restaurants/verify/pending
  - POST /api/admin/restaurants/verify/all?batchSize=10
  - POST /api/admin/restaurants/{id}/verify
  - PATCH /api/admin/restaurants/{id}/hidden
- 어드민 UI는 후속 #323으로 분리
- Refs: #322 (close)

### 📺 #291 publishedAfter 페이징 조기 종료 버그 (v0.1.15) + dev/prod 데몬 분리
- YouTubeService.fetchChannelVideos: stopPaging 플래그로 조기 종료 정확화 → 백필 효율 + YouTube API quota 절약
- DaemonScheduler에 app.daemon.enabled (env DAEMON_ENABLED) 플래그
- dev/prod가 같은 Oracle ATP를 공유하는 환경에서 dev DAEMON_ENABLED=false로 중복 폴링 차단
- Refs: #291 #275 #321

### ♿ #301+#302 모달 접근성 + race condition + 필터 상태 동기화 (v0.1.14)
- 공통 훅 `frontend/src/lib/hooks/useModalA11y.ts` 신규 (useEscapeKey, useFocusTrap, useBodyScrollLock)
- BottomSheet/FilterSheet: role='dialog', aria-modal, aria-label/labelledby, ESC 닫기, focus trap
- RestaurantDetail: useEffect cancelled 플래그로 restaurant.id 변경 시 race condition 차단
- page.tsx: `exitSearchMode` 헬퍼 → 검색결과 모드에서 필터 변경 시 자동 검색 모드 해제 + 원본 재로드
- 후속 분리: #319 (BottomSheet 매직넘버/UX), #320 (필터 정밀도/접근성/테스트)
- Refs: #301 #302 (close)

### 🔧 #316 — backend resource request 재산정 + RollingUpdate 정책 복귀
- **변경 전**: cpu 500m/1, mem 768Mi/1536Mi, strategy maxSurge=0/maxUnavailable=1 (임시 패치)
- **변경 후**: cpu 300m/800m, mem 512Mi/1024Mi, strategy 25%/25% (기본 복귀)
- **근거**: 실측 idle 0.7% CPU, RSS ~305 MB. peak 30-40% 추정 안에서 안전.
- **검증**: rollout 후 노드 잔여 330m → 다음 배포 시 두 Pod 공존 가능, 무중단 RollingUpdate 회복.
- **다운타임**: 이번 1회 ~25초 (구 Pod 500m 점유 해제 위해 강제 종료). 다음 배포부터 0초.
- **설계서**: `docs/design/316-backend-resource-rightsize/README.md` (Approved).
- Refs: #316 (close)

### 🏗 OKE 인프라 — 노드 다운사이징 + LB 정리
- **Orphan Classic LB 삭제**: 132.226.175.247 (100Mbps shape, OKEclusterName 태그만 남고 DNS/Service 참조 없음) → 비용 절감
- **노드풀 교체 (블루-그린)**: `pool1` (2 노드 × 2 OCPU / 8 GB) → `pool2` (2 노드 × 1 OCPU / 6 GB)
  - 사유: ARM64 Always Free 쿼터 변경 (4 OCPU/24 GB → 2 OCPU/12 GB)
  - 절차: 새 노드풀 생성 → 기존 노드 cordon + drain → 기존 노드풀 삭제 → 무중단 확인
- **backend Deployment strategy 임시 패치**: `maxSurge: 25% → 0`, `maxUnavailable: 25% → 1`
  - 노드당 1 OCPU 환경에서 backend(500m 요청) 두 Pod 공존 불가 → rollingUpdate 데드락 회피
  - **⚠️ 다음 배포 시 ~30초 다운타임** 발생. 후속 이슈에서 resource request 재산정 권고.

### 🚀 운영 배포 v0.1.13
- 보안 핫픽스 #267 배포 (백엔드만)
- OCIR push + kubectl rolling update + git tag v0.1.13 완료
- 검증: `Anonymous /api/admin/users → 403`, `Bad-token → 403`, `정상 동작 영향 없음`

### 🔴 보안 핫픽스 #267 — AdminUserController GET 4종 권한 우회
- `listUsers`, `userFavorites`, `userReviews`, `userMemos`가 인증만 요구하고 admin 검사를 하지 않아 일반 사용자 토큰으로 전체 사용자 목록 및 타인 활동 조회 가능했음
- 4개 메서드 첫 줄에 `AuthUtil.requireAdmin()` 추가 → non-admin 호출 시 403
- 설계서 §3 인수조건에 `/api/admin/users/**` 권한 강제 항목 추가
- Refs: #267 (현행화 Reviewer 반려 → Developer 수정 → 다시 통과)

### ch-bootstrap 적용 (페르소나 파이프라인 + Design-First)
- Redmine 8단계 페르소나 큐(`01-Planner` ~ `09-Done`) + 9개 카테고리 자동 생성
- Design-First 게이트(설계서 없으면 코드 작성 금지) 도입
- `.claude/agents/` 8개 페르소나 + `.claude/workflows/persona-pipeline.js`
- 안전-최대 권한 정책(`.claude/settings.json`)
- `docs/{design,adr,pipeline}/` 골격 + `scripts/enqueue.sh`
- 기존 Tasteby 고유 규칙(존댓말, CHANGELOG, 디자인 패턴, CORS, PM2)은 `CLAUDE.md` 0/7/8장으로 보존
- Redmine 프로젝트 description + Wiki 4페이지(Overview/Dev-Env/Prod-Env/Deploy) 작성

### tasteby 기존 18개 기능 Design-First 현행화
- 백엔드 12개(auth/user/restaurant/video/extract-pipeline/search/review-memo/channel/stats/daemon/cache/health) + 프론트 6개(map/restaurant-detail/filter/review-memo/admin/login)
- 각 기능별 `docs/design/<issue>-<slug>/README.md` 12개 섹션 채움 (총 3,830줄)
- 추적성: 각 설계서가 구현 파일/Redmine 이슈/커밋 SHA와 연결됨
- **Reviewer 결과**: 17 PASS w/notes, 1 REJECT (#267 admin 권한 critical)
- 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 총 124건(critical 3 / major 46 / minor 75) 백로그 반영
- 코드 변경 없음 — 문서화 + 백로그화 전용

---

## 2026-04-04

### 코드 리뷰 스크립트 추가 + 리뷰 지적사항 반영
- `scripts/code_review.py`: 페르소나 기반 코드 리뷰 스크립트 (OpenRouter API, 프론트/백엔드/보안/아키텍처 4관점)
- `UserService.updateAdmin()`: 존재하지 않는 userId에 대해 404 응답 추가
- `AdminUserController.updateAdmin()`: 자기 자신 admin 권한 변경 차단 + 감사 로그 추가 + 응답에 변경 결과 포함
- `JsonUtil.normalizeEvaluation()`: evaluation 정규화 로직을 공통 유틸로 통합 (RestaurantService, VideoService 중복 제거)
- `RestaurantService.linkVideoRestaurant()`: evaluation 저장 시 평문→JSON 정규화 + 300자 제한

### 가격대 필터 5단계 세분화
- 기존 3단계(저렴/보통/고가) → 5단계(저렴/가성비/보통/프리미엄/럭셔리)
- `PRICE_GROUPS` 상수 수정, 정규식 패턴 세분화

### 모바일 터치 영역 개선 (44×44px 통일)
- **별점 선택기**: 0.5단위 10개 숫자 버튼(24px) → 별 아이콘 5개(44px), 탭으로 정수/반점수 전환
- **FilterSheet 닫기 버튼**: `p-1` → `p-2` (터치 영역 확대)
- **RestaurantDetail 찜 버튼**: 패딩 추가 + `touch-manipulation` 적용
- **필터 초기화 X 버튼**: 아이콘 12px → 14px + 패딩 추가

### 채널 필터 시 식당이 3개만 나오는 버그 수정
- **원인**: 전체 식당 500개만 가져와서 클라이언트 필터링 → 특정 채널 식당이 상위 500개에 일부만 포함
- **수정**: `page.tsx`에서 채널 필터 변경 시 서버에 `channel` 파라미터를 보내 서버 사이드 필터링 적용

---

## 2026-03-29

### 식당 평가(evaluation) 표시 안 되는 버그 수정
- **원인**: LLM이 추출한 evaluation이 대부분 평문 문자열로 DB에 저장되어 있었으나, 프론트에서 `evaluation.text`로 접근하여 표시되지 않음
- **수정**:
  - `JsonUtil.parseMap()`: JSON 파싱 실패 시 `{"text":"원본문자열"}`로 감싸서 반환
  - `VideoService.findDetail()`: `VideoRestaurantLink`의 evaluation 평문을 JSON 객체로 정규화

---

## 2026-03-16

### Admin 유저 관리 — 관리자 권한 토글 기능 추가
- **Backend**
  - `UserMapper.xml`: `findAllWithCounts`에 `is_admin` 컬럼 추가, `updateAdmin` 쿼리 추가
  - `UserMapper.java`: `updateAdmin()` 메서드 추가
  - `UserService.java`: `updateAdmin()` 메서드 추가
  - `AdminUserController.java`: `PATCH /api/admin/users/{userId}/admin` 엔드포인트 추가
- **Frontend**
  - `api.ts`: `updateAdminUserAdmin()` API 함수 추가, 유저 타입에 `is_admin` 필드 추가
  - `admin/page.tsx`: 유저 테이블에 "관리자" 컬럼 + ON/OFF 토글 버튼 추가

### CORS PATCH 메서드 허용
- **문제**: PATCH 요청 시 CORS preflight(OPTIONS)에서 403 차단
- **원인**: `WebConfig.java`의 `allowedMethods`에 `PATCH`가 빠져 있었음
- **해결**: `List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")` → `"PATCH"` 추가

### Icon 시스템 개선
- Material Symbols `sake` 아이콘 종횡비 문제 수정 — `width`/`height`를 `fontSize`와 동일하게 고정 + `overflow: hidden`
- 이자카야 아이콘: `sake` → `local_bar` (술잔 모양으로 변경)
- 삼겹살/돼지구이, 족발/보쌈, 돈카츠: `PiggyBank` → `food:pig` (커스텀 돼지 SVG)

### LLM 추출 프롬프트 수정
- `ExtractorService.java`: `evaluation` 필드 → "평가 내용을 100자 이내로 요약"으로 변경

### 브랜드 가이드 문서 생성
- `frontend/docs/brand-guide.md`: 브랜드 아이덴티티, 컬러, 타이포, 아이콘 정책 등 정리

### PM2 프론트엔드 포트 고정
- **문제**: `pm2 restart` 후 Next.js가 3000(Gitea 포트)으로 fallback → nginx 502
- **해결**: PM2에 `PORT=3001` 환경변수 고정하여 재등록 + `pm2 save`

---

## 2026-03-14

### 홈 탭 장르 카드 픽토그램 적용
- Phosphor Icons (`@phosphor-icons/react`) + 커스텀 SVG FoodIcon 시스템 구축
- `cuisine-icons.ts`에 `getPhosphorCuisineIcon()` 함수 추가 (46개 소분류 매핑)
- `FoodIcon.tsx` 생성 — jjigae, tteok, noodle, tempura, pig 커스텀 SVG 아이콘
- `food:` 접두어로 Phosphor vs 커스텀 SVG 분기 처리

### 지역 필터 추가 + 배포
- 홈 탭에 지역 필터 드롭다운 추가
- v0.1.11로 OKE 배포 완료

---

## 참고: 주의사항

| 항목 | 내용 |
|------|------|
| 새 HTTP 메서드 추가 시 | `WebConfig.java`의 CORS `allowedMethods`에 반드시 추가 |
| 백엔드 코드 수정 후 | `bootJar` 빌드 성공 확인 → `pm2 restart tasteby-api` |
| 프론트엔드 dev 포트 | 3001 고정 (3000은 Gitea) |
| tasteby-web 실행 방식 | `npm run dev` (standalone 아님) |
