# 설계서: 백엔드 - 식당 CRUD (#268) > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-15 > **추적성** — Redmine: #268 · 관련 ADR: 없음 > · 구현 파일: `backend-java/src/main/java/com/tasteby/service/RestaurantService.java`, `backend-java/src/main/java/com/tasteby/controller/RestaurantController.java` · 테스트: TBD (현재 없음) ## 1. 목적 (Why) 사용자에게 식당 목록/상세를 빠르게 제공하고, 관리자에게는 식당 정보를 안전하게 수정/삭제하며 외부 예약 채널(테이블링·캐치테이블) URL을 자동/수동으로 연결할 수 있도록 한다. 추출기 파이프라인이 호출하는 식당 upsert 및 영상-식당 링크 생성의 단일 책임 지점을 제공한다. ## 2. 범위 (Scope) - **포함** - 목록/상세 조회 (`GET /api/restaurants`, `GET /api/restaurants/{id}`) — Redis 캐시. - 식당 수정/삭제 (관리자 전용) — 이름·주소 변경 시 재지오코딩. - 식당별 영상 연결 조회 (`GET /api/restaurants/{id}/videos`). - 테이블링/캐치테이블 단건 검색, 미연결 목록, SSE 벌크 자동 연결, URL 저장, 초기화. - 추출기 파이프라인이 호출하는 upsert(`upsert`), 영상-식당 링크(`linkVideoRestaurant`), 분류 보정(`updateCuisineType`, `updateFoodsMentioned`). - **제외 (out of scope)** - 식당 신규 등록 전용 엔드포인트(POST) — 등록은 추출기 파이프라인(`upsert`) 경유. - YouTube 자막/메타 추출, 지오코딩 자체 로직 (각 서비스 책임). - 즐겨찾기·리뷰·메모 CRUD. - 식당 검색(이름/지역/메뉴 키워드 검색 API) — 본 설계서 미포함. - 벡터 임베딩 생성 (`VectorService` 책임). ## 3. 인수조건 (Acceptance Criteria) - [ ] `GET /api/restaurants?limit=&offset=&cuisine=®ion=&channel=` 결과는 캐시되며, `channels`/`foodsMentioned`가 채워진 `Restaurant` 리스트를 반환한다. - [ ] `PUT /api/restaurants/{id}`에서 `name` 또는 `address`가 변경된 경우 Geocoding을 재호출하여 좌표·`google_place_id`·rating 등을 갱신한다. - [ ] `DELETE /api/restaurants/{id}`는 `tasteby_restaurants`와 함께 벡터/리뷰/즐겨찾기/영상 링크를 모두 삭제한다. - [ ] 관리자 미인증 사용자가 PUT/DELETE/관리자 엔드포인트 호출 시 403/401을 반환한다. - [ ] `POST /api/restaurants/bulk-tabling`(SSE) 호출 시 미연결 식당에 대해 DuckDuckGo로 검색 → 유사도 ≥ 0.4면 URL 저장, 아니면 `NONE` 기록, 진행 상황을 이벤트로 스트리밍한다. - [ ] `upsert`는 `google_place_id` 또는 동일 `name`이 있으면 UPDATE, 없으면 신규 ID로 INSERT 한다. ## 4. 컨텍스트 & 제약 - **의존성** - Oracle 23ai: `tasteby_restaurants`, `video_restaurant_links`, `tasteby_restaurant_vectors`, `user_favorites`, `user_reviews`. - MyBatis `RestaurantMapper`. - `CacheService` (Redis) — 목록/상세/영상 캐시 + 변경 시 `flush()`. - `GeocodingService` — 이름/주소 → 좌표/place_id/주소/평점. - 외부 HTTP: `html.duckduckgo.com` (테이블링/캐치테이블 검색). - `AuthUtil.requireAdmin()`. - 가상 스레드 풀(`Executors.newVirtualThreadPerTaskExecutor()`) — SSE 비동기. - **제약** - 목록 limit 최대 500으로 캡. - 벌크 SSE 타임아웃 600초, 각 검색 사이 2~5초 랜덤 딜레이. - 캐시 key 패턴: `restaurants:…`, `restaurant:{id}`, `restaurant_videos:{id}`. - `name` 200바이트, `address` 500바이트 UTF-8 트렁케이션. - 외부 검색은 비공식 스크래핑(DDG HTML) — Rate limit/봇 차단 가능성. - 권한: 조회는 익명 허용, 수정/삭제/외부 검색/벌크는 관리자. - **가정** - `RestaurantMapper`의 동적 SQL이 `cuisine/region/channel` 필터를 지원. - `evaluation` 필드는 `JsonUtil.normalizeEvaluation`으로 300자 제한 + JSON 래핑. ## 5. 아키텍처 개요 - **모듈/파일** - `controller/RestaurantController.java` — HTTP/SSE. - `service/RestaurantService.java` — 도메인 로직 + enrichment. - `mapper/RestaurantMapper`(MyBatis) — SQL. - `service/GeocodingService`, `service/CacheService` — 외부 협력. - `util/JsonUtil`, `util/IdGenerator` — 공통. - **데이터 흐름** ``` [Client] │ GET /api/restaurants?… ▼ RestaurantController.list ├─ CacheService.getRaw(key) ── hit ─▶ deserialize └─ miss ─▶ RestaurantService.findAll ├─ RestaurantMapper.findAll(limit,offset,cuisine,region,channel) └─ enrichRestaurants ├─ findChannelsByRestaurantIds └─ findFoodsByRestaurantIds (JsonUtil.parseStringList) ▼ CacheService.set(key, result) [Admin] PUT /api/restaurants/{id} ▼ AuthUtil.requireAdmin ▼ RestaurantService.findById (404) ▼ if name/address changed → GeocodingService.geocodeRestaurant → body 보강 ▼ RestaurantService.update → Mapper.updateFields ▼ CacheService.flush → findById → 응답 [Admin] POST /api/restaurants/bulk-tabling (SSE) ▼ findWithoutTabling → for each: searchTabling(name) ─▶ DDG HTML (외부 I/O) → isNameSimilar? YES: update tabling_url NO : update 'NONE' → emit event, sleep 2~5s ▼ cache.flush, complete [Extractor pipeline] ▼ RestaurantService.upsert(map) ├─ findIdByPlaceId / findIdByName └─ insertRestaurant or updateRestaurant ▼ linkVideoRestaurant(videoId, restaurantId, foods, eval, guests) ``` - **I/O ↔ 순수 로직 경계** - I/O: MyBatis, Redis, GeocodingService, DDG HTTP. - 순수: `enrichRestaurants` 매핑, `truncateBytes`, `isNameSimilar`, `normalize`, `extractDdgUrl`. ## 6. 데이터 모델 - **`Restaurant`** (`domain/Restaurant.java`) - `id, name, address, region, latitude(Double), longitude(Double), cuisineType, priceRange, phone, website, googlePlaceId, tablingUrl, catchtableUrl, businessStatus, rating(Double), ratingCount(Integer), updatedAt(Date)`. - Transient: `channels: List`, `foodsMentioned: List`. - **저장 테이블** - `tasteby_restaurants` (PK `id`, 후보 키 `google_place_id` / 동명 폴백). - `video_restaurant_links` (`id PK`, `video_id`, `restaurant_id`, `foods_mentioned CLOB`, `evaluation CLOB`, `guests CLOB`). - 부속: `tasteby_restaurant_vectors`, `user_favorites`, `user_reviews` (DELETE 캐스케이드 수동). - **입력** - 목록 query: `limit(=100,≤500)`, `offset(=0)`, `cuisine?`, `region?`, `channel?`. - 수정 body: 자유 형 `Map` (name/address/cuisine_type/price_range/website/phone/tabling_url 등). - 권한 변경 / URL 저장: `{ tabling_url | catchtable_url: string }`. - **출력** - 목록/상세: `Restaurant`(Jackson SNAKE_CASE 직렬화). - 영상 링크: `List` — `foods_mentioned/evaluation/guests`는 파싱 후 객체. - SSE 이벤트 타입: `start | processing | done | notfound | error | complete`. - **경계 검증** - `name`/`address` UTF-8 200/500바이트로 잘라 저장. - `evaluation`은 `JsonUtil.normalizeEvaluation`(평문 → JSON, 300자 제한). - `latitude/longitude/rating/rating_count`는 Number → primitive 변환 시 null 안전. ## 7. 함수 명세 (Function Specs) ### RestaurantService (public) | 함수 | 책임 | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? | |------|------|----------|------|------|-----------|-------| | `RestaurantService(RestaurantMapper)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 | | `findAll` | 필터+페이징 목록 + 채널/메뉴 enrich | `List findAll(int limit, int offset, String cuisine, String region, String channel)` | 페이징/필터 | 식당 리스트 | DB 예외 → 500 | **복잡** (조인 enrich) | | `findWithoutTabling` | tabling_url 미연결 식당 | `List findWithoutTabling()` | 없음 | 리스트 | DB 예외 | 단순 | | `findWithoutCatchtable` | catchtable_url 미연결 식당 | `List findWithoutCatchtable()` | 없음 | 리스트 | DB 예외 | 단순 | | `resetTablingUrls` | 모든 tabling_url 초기화 | `void resetTablingUrls()` | 없음 | void | DB 예외 | 단순 | | `resetCatchtableUrls` | 모든 catchtable_url 초기화 | `void resetCatchtableUrls()` | 없음 | void | DB 예외 | 단순 | | `findById` | 단건 조회 + enrich | `Restaurant findById(String id)` | id | `Restaurant`/null | DB 예외 | 단순 | | `findVideoLinks` | 영상-식당 링크 + JSON 파싱 | `List> findVideoLinks(String restaurantId)` | restaurantId | foods/eval/guests 파싱된 리스트 | DB 예외 | 단순 | | `update` | 임의 필드 부분 업데이트 | `void update(String id, Map fields)` | id, fields | void | DB 예외 | 단순 | | `delete` | 식당 및 종속 데이터 일괄 삭제 | `void delete(String id)` | id | void | DB 예외 → 롤백 | **복잡** (5개 테이블 캐스케이드) | | `upsert` | place_id/name으로 기존 매칭, 없으면 INSERT | `String upsert(Map data)` | 추출 결과 | restaurantId | DB 예외 | **복잡** (분기 + 트렁케이션) | | `linkVideoRestaurant` | 영상-식당 N:M 링크 + JSON 직렬화 | `void linkVideoRestaurant(String videoId, String restaurantId, List foods, String evaluation, List guests)` | 5개 | void | DB 예외 | 단순 | | `updateCuisineType` | 분류 보정 | `void updateCuisineType(String id, String cuisineType)` | id, type | void | DB 예외 | 단순 | | `updateFoodsMentioned` | 메뉴 목록 보정 | `void updateFoodsMentioned(String id, String foods)` | id, foods | void | DB 예외 | 단순 | | `findForRemapCuisine` | 재분류 대상 조회 | `List> findForRemapCuisine()` | 없음 | 행 리스트 | DB 예외 | 단순 | | `findForRemapFoods` | 재분류 대상 조회 | `List> findForRemapFoods()` | 없음 | 행 리스트 | DB 예외 | 단순 | > private: `enrichRestaurants`, `truncateBytes` — 표 외 처리. ### RestaurantController (public) | 함수 | 책임/엔드포인트 | 시그니처 | 권한 | 출력 | 에러 | 복잡? | |------|------|----------|------|------|------|-------| | 생성자 | DI | `RestaurantController(...)` | — | 인스턴스 | 없음 | 단순 | | `list` | `GET /api/restaurants` (캐시) | `List list(int limit=100, int offset=0, String cuisine?, String region?, String channel?)` | 익명 | 목록 | 캐시 역직렬화 실패 시 silent fallback | **복잡** (캐시 미스/히트 분기) | | `get` | `GET /{id}` (캐시) | `Restaurant get(String id)` | 익명 | `Restaurant` | 미존재 → 404 | 단순 | | `update` | `PUT /{id}` (조건부 재지오코딩) | `Map update(String id, Map body)` | admin | `{ok, restaurant}` | 404 / 권한 | **복잡** (지오코딩 분기 + cache flush) | | `delete` | `DELETE /{id}` | `Map delete(String id)` | admin | `{ok}` | 404 / 권한 | 단순 | | `tablingSearch` | `GET /{id}/tabling-search` | `List tablingSearch(String id)` | admin | DDG 결과 | 404 / 502 | **복잡** (외부 I/O) | | `tablingPending` | `GET /tabling-pending` | `Map tablingPending()` | admin | `{count, restaurants[]}` | 권한 | 단순 | | `bulkTabling` | `POST /bulk-tabling` (SSE) | `SseEmitter bulkTabling()` | admin | SSE 스트림 | per-item error 이벤트, 최종 complete | **복잡** (장기 비동기 + 외부 I/O + 상태 전이) | | `setTablingUrl` | `PUT /{id}/tabling-url` | `Map setTablingUrl(String id, Map body)` | admin | `{ok}` | 404 / 권한 | 단순 | | `resetTabling` | `DELETE /reset-tabling` | `Map resetTabling()` | admin | `{ok}` | 권한 | 단순 | | `resetCatchtable` | `DELETE /reset-catchtable` | `Map resetCatchtable()` | admin | `{ok}` | 권한 | 단순 | | `catchtableSearch` | `GET /{id}/catchtable-search` | `List catchtableSearch(String id)` | admin | DDG 결과 | 404 / 502 | **복잡** | | `catchtablePending` | `GET /catchtable-pending` | `Map catchtablePending()` | admin | `{count,…}` | 권한 | 단순 | | `bulkCatchtable` | `POST /bulk-catchtable` (SSE) | `SseEmitter bulkCatchtable()` | admin | SSE 스트림 | 동상 | **복잡** | | `setCatchtableUrl` | `PUT /{id}/catchtable-url` | `Map setCatchtableUrl(String id, Map body)` | admin | `{ok}` | 404 / 권한 | 단순 | | `videos` | `GET /{id}/videos` (캐시) | `List videos(String id)` | 익명 | 영상 링크 | 404 | 단순 | > private 유틸: `searchDuckDuckGo`, `extractDdgUrl`, `searchTabling`, `searchCatchtable`, `isNameSimilar`, `normalize`, `emit` — 표 외. 외부 I/O 동반. ## 8. 흐름 / 알고리즘 **A. 목록 조회 (캐시 적용)** 1. limit > 500이면 500으로 캡. 2. `CacheService.makeKey("restaurants", l=…, o=…, c=…, r=…, ch=…)` 생성. 3. Redis HIT → 역직렬화 반환(역직렬화 실패는 무시 후 미스 처리). 4. MISS → `findAll` 호출 → `enrichRestaurants`로 채널/메뉴 채움 → 캐시 set. **B. 수정(`PUT /{id}`) — 조건부 재지오코딩** 1. 관리자 확인 → 404 가드. 2. body의 `name`/`address`가 기존과 다르면 `geocodeRestaurant` 호출. 3. 결과 좌표/`google_place_id`/`rating`/`phone`/`business_status`/`formatted_address` 보강. 4. `formatted_address`에서 `parseRegionFromAddress`로 `region` 재계산. 5. `Mapper.updateFields(id, body)` 부분 업데이트 → `cache.flush()` → 재조회 응답. **C. 삭제(`DELETE /{id}`)** - 순서: `deleteVectors → deleteReviews → deleteFavorites → deleteVideoRestaurants → deleteRestaurant` (외래 무결성 보호). `@Transactional`로 원자성. **D. 벌크 테이블링/캐치테이블 (SSE)** 1. 관리자 확인. `SseEmitter(timeout=600s)` 생성. 2. 가상 스레드에서: `findWithoutTabling()` → for-each. 3. `emit("processing")` → `searchTabling(name)` → DDG HTML 검색 → 결과 5개 이내 추출. 4. 결과 있고 `isNameSimilar(name, top.title) == true`면 `update(tabling_url)` + `emit("done")`. 5. 결과 없거나 유사도 불충분 → `update("NONE")` + `emit("notfound")`. 6. 예외 → `emit("error")`. 7. 각 검색 후 `Thread.sleep(2000~5000ms)` 랜덤 딜레이. 8. 전체 완료 → `cache.flush()` → `emit("complete")` → `emitter.complete()`. **E. Upsert (`upsert`)** 1. `google_place_id` 우선 매칭 → `findIdByPlaceId`. 2. 없으면 `findIdByName`. 3. name/address UTF-8 트렁케이션, Number 필드 안전 변환. 4. 기존 ID 있으면 UPDATE, 없으면 새 `IdGenerator.newId()`로 INSERT. 반환: restaurantId. **F. 영상-식당 링크 (`linkVideoRestaurant`)** - `foods/guests` → JSON 직렬화, `evaluation` → `JsonUtil.normalizeEvaluation` 후 INSERT. **G. 이름 유사도 (`isNameSimilar`)** - normalize: 공백·구두점·괄호 제거, lowercase. - 포함 관계 또는 문자 집합 Jaccard-like 비율 ≥ 0.4. ## 9. 엣지케이스 & 에러 처리 - **limit > 500**: 500으로 강제 캡. - **캐시 역직렬화 실패**: 무시하고 DB로 폴백(catch `Exception ignored`). - **`findById` null**: 일반 GET/PUT/DELETE에서 404. - **수정 시 지오코딩 실패(null 반환)**: body 그대로 update — 좌표 미갱신 허용. - **삭제 캐스케이드 부분 실패**: 트랜잭션 롤백. - **upsert place_id 동일·다른 이름**: place_id로 매칭 → UPDATE. - **DDG 검색 결과 0건/이름 불일치**: `tabling_url='NONE'`(검색 다시 시도 방지 sentinel). - **DDG HTTP 실패/예외**: 단건은 502, 벌크는 per-item error 이벤트. - **벌크 SSE 클라이언트 단절**: `emitter.send` 예외 catch → 디버그 로그, 작업 진행. - **레이트리밋/봇 차단**: 2~5초 랜덤 딜레이 + User-Agent 위장. 차단 발생 시 대량 'NONE' 기록 위험 → 운영자 모니터링 필요. - **이름 트렁케이션 손실**: UTF-8 200/500 바이트로 잘라 멀티바이트 안전. - **evaluation 평문 입력**: `normalizeEvaluation`이 JSON 래핑 + 300자 제한. - **안전한 기본값**: 외부 I/O 실패 시 DB 변경 없음(검색 단건의 경우). 벌크는 진행하면서 실패 이벤트 emit. ## 10. 테스트 계획 - **현 상태**: 자동화 테스트 없음 (TBD). - **단위 테스트** (Mockito) - `RestaurantService.upsert` - place_id 매칭 → UPDATE 경로. - name 매칭 → UPDATE. - 미매칭 → INSERT + 새 ID. - name 250바이트 → 200바이트 트렁케이션. - `RestaurantService.delete` - 5개 mapper delete 순서 호출 검증. - `RestaurantService.enrichRestaurants` (private이지만 `findAll` 경유) - 채널/메뉴 매핑 정확성, null 처리. - `RestaurantController.isNameSimilar` (정적·private) - 포함/제외/유사도 경계 0.4. - **통합 테스트** (`@SpringBootTest` + MockMvc) - `GET /api/restaurants` 캐시 HIT/MISS 동작 (Redis embedded 또는 Testcontainers). - `PUT /{id}` 이름/주소 변경 시 GeocodingService 호출 검증 (`@MockBean`). - `DELETE /{id}` 트랜잭션 롤백 (예외 주입). - 관리자 권한 가드 401/403. - **SSE 테스트** - `bulkTabling` 0건/일부 매칭/불일치/에러 시나리오 — `@MockBean`으로 DDG 결과 stub. - 진행 이벤트 순서 검증. - **모킹 전략** - `httpClient`(DDG)는 인스턴스 추출 가능하도록 리팩토링 후 `@MockBean` (현재 static — 테스트 가능성 낮음, 향후 개선). - `CacheService`/`GeocodingService`는 `@MockBean`. ## 11. 리스크 & 대안 검토 - **DDG HTML 스크래핑** - 장점: API 키 불필요, 즉시 사용. - 위험: HTML 구조 변경/봇 차단 시 대량 `NONE` 마킹 → 실 데이터 손상 가능. - 대안: 테이블링/캐치테이블 비공식 API 직접 호출, 또는 검색 API(Bing/Naver) 도입 — 비용·약관 검토 필요. - **캐시 무효화 전략**: 변경 시 전체 `flush()`. - 장점: 단순. - 단점: 무관한 키도 일괄 무효화. 트래픽 큰 시점에 부담. - 대안: 키 prefix 기반 부분 삭제(`scan + del`). - **`PUT /{id}` body가 `Map`**: 타입 안전성 낮음, 임의 컬럼 업데이트 허용. - 대안: DTO + 화이트리스트. 보안/감사 향상. - **벌크 SSE 600초 타임아웃**: 식당 수가 많을 경우 부족. 청크 분할/재개 기능 미지원. - **이름 유사도 임계값 0.4**: 한글 짧은 이름에서 오탐 가능. 향후 ngram·자모 분해 기반 알고리즘 검토. - **upsert의 동명 매칭**: place_id 없는 데이터에서 동명이체 식당이 합쳐질 수 있음 — 추출기 단계에서 place_id 보장 필요. - **트랜잭션 경계**: `delete`는 트랜잭션, `upsert`/`update`는 메서드 단위 트랜잭션 없음(MyBatis 단일 SQL이므로 영향 작음). ## 12. 미해결 질문 (Open Questions) - `region` 필터 값 컨벤션(`"한국|서울|강남구"`)을 enum/마스터 테이블로 표준화할지. - DDG 스크래핑을 정식 검색 API로 대체할 시점/예산. - `tabling_url = 'NONE'` sentinel을 별도 컬럼/플래그로 분리할지(현재 URL 컬럼에 의미 오버로드). - 관리자 수정 PUT을 화이트리스트 DTO로 강제할지. - 벌크 SSE 작업을 큐(Redis Streams) + 워커로 분리해 재개 가능하게 만들지. - 이름 유사도 알고리즘을 한국어 특화(자모, 초성)로 교체할지. - 캐시 키 그룹별 부분 무효화 도입 여부.