<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->

# 설계서: 백엔드 - 식당 CRUD (#268)

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [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=&region=&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<String>`, `foodsMentioned: List<String>`.
- **저장 테이블**
  - `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<String,Object>` (name/address/cuisine_type/price_range/website/phone/tabling_url 등).
  - 권한 변경 / URL 저장: `{ tabling_url | catchtable_url: string }`.
- **출력**
  - 목록/상세: `Restaurant`(Jackson SNAKE_CASE 직렬화).
  - 영상 링크: `List<Map>` — `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<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel)` | 페이징/필터 | 식당 리스트 | DB 예외 → 500 | **복잡** (조인 enrich) |
| `findWithoutTabling` | tabling_url 미연결 식당 | `List<Restaurant> findWithoutTabling()` | 없음 | 리스트 | DB 예외 | 단순 |
| `findWithoutCatchtable` | catchtable_url 미연결 식당 | `List<Restaurant> 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<Map<String,Object>> findVideoLinks(String restaurantId)` | restaurantId | foods/eval/guests 파싱된 리스트 | DB 예외 | 단순 |
| `update` | 임의 필드 부분 업데이트 | `void update(String id, Map<String,Object> fields)` | id, fields | void | DB 예외 | 단순 |
| `delete` | 식당 및 종속 데이터 일괄 삭제 | `void delete(String id)` | id | void | DB 예외 → 롤백 | **복잡** (5개 테이블 캐스케이드) |
| `upsert` | place_id/name으로 기존 매칭, 없으면 INSERT | `String upsert(Map<String,Object> data)` | 추출 결과 | restaurantId | DB 예외 | **복잡** (분기 + 트렁케이션) |
| `linkVideoRestaurant` | 영상-식당 N:M 링크 + JSON 직렬화 | `void linkVideoRestaurant(String videoId, String restaurantId, List<String> foods, String evaluation, List<String> 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<Map<String,Object>> findForRemapCuisine()` | 없음 | 행 리스트 | DB 예외 | 단순 |
| `findForRemapFoods` | 재분류 대상 조회 | `List<Map<String,Object>> findForRemapFoods()` | 없음 | 행 리스트 | DB 예외 | 단순 |

> private: `enrichRestaurants`, `truncateBytes` — 표 외 처리.

### RestaurantController (public)

| 함수 | 책임/엔드포인트 | 시그니처 | 권한 | 출력 | 에러 | 복잡? |
|------|------|----------|------|------|------|-------|
| 생성자 | DI | `RestaurantController(...)` | — | 인스턴스 | 없음 | 단순 |
| `list` | `GET /api/restaurants` (캐시) | `List<Restaurant> 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<Map> 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<Map> 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<Map> 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<String,Object>`**: 타입 안전성 낮음, 임의 컬럼 업데이트 허용.
  - 대안: 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) + 워커로 분리해 재개 가능하게 만들지.
- 이름 유사도 알고리즘을 한국어 특화(자모, 초성)로 교체할지.
- 캐시 키 그룹별 부분 무효화 도입 여부.
