Reviewer 결과 17 PASS / 1 REJECT (#267 admin 권한 critical). - 17개 설계서를 Draft → Approved로 갱신 - #267(backend-user)은 critical 결함으로 06-Reviewer 유지 - 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 124건 백로그 반영 (critical 3 / major 46 / minor 75) - docs/README.md에 18개 설계서 인덱스 추가 - CHANGELOG.md 2026-06-15 섹션 추가 Refs: #266 #268-#283 (현행화 완료) #267 (대기) #289-#305 (백로그)
설계서: 백엔드 - 식당 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책임).
- 식당 신규 등록 전용 엔드포인트(POST) — 등록은 추출기 파이프라인(
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 비동기.
- Oracle 23ai:
- 제약
- 목록 limit 최대 500으로 캡.
- 벌크 SSE 타임아웃 600초, 각 검색 사이 2~5초 랜덤 딜레이.
- 캐시 key 패턴:
restaurants:…,restaurant:{id},restaurant_videos:{id}. name200바이트,address500바이트 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(PKid, 후보 키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 }.
- 목록 query:
- 출력
- 목록/상세:
Restaurant(Jackson SNAKE_CASE 직렬화). - 영상 링크:
List<Map>—foods_mentioned/evaluation/guests는 파싱 후 객체. - SSE 이벤트 타입:
start | processing | done | notfound | error | complete.
- 목록/상세:
- 경계 검증
name/addressUTF-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. 목록 조회 (캐시 적용)
- limit > 500이면 500으로 캡.
CacheService.makeKey("restaurants", l=…, o=…, c=…, r=…, ch=…)생성.- Redis HIT → 역직렬화 반환(역직렬화 실패는 무시 후 미스 처리).
- MISS →
findAll호출 →enrichRestaurants로 채널/메뉴 채움 → 캐시 set.
B. 수정(PUT /{id}) — 조건부 재지오코딩
- 관리자 확인 → 404 가드.
- body의
name/address가 기존과 다르면geocodeRestaurant호출. - 결과 좌표/
google_place_id/rating/phone/business_status/formatted_address보강. formatted_address에서parseRegionFromAddress로region재계산.Mapper.updateFields(id, body)부분 업데이트 →cache.flush()→ 재조회 응답.
C. 삭제(DELETE /{id})
- 순서:
deleteVectors → deleteReviews → deleteFavorites → deleteVideoRestaurants → deleteRestaurant(외래 무결성 보호).@Transactional로 원자성.
D. 벌크 테이블링/캐치테이블 (SSE)
- 관리자 확인.
SseEmitter(timeout=600s)생성. - 가상 스레드에서:
findWithoutTabling()→ for-each. emit("processing")→searchTabling(name)→ DDG HTML 검색 → 결과 5개 이내 추출.- 결과 있고
isNameSimilar(name, top.title) == true면update(tabling_url)+emit("done"). - 결과 없거나 유사도 불충분 →
update("NONE")+emit("notfound"). - 예외 →
emit("error"). - 각 검색 후
Thread.sleep(2000~5000ms)랜덤 딜레이. - 전체 완료 →
cache.flush()→emit("complete")→emitter.complete().
E. Upsert (upsert)
google_place_id우선 매칭 →findIdByPlaceId.- 없으면
findIdByName. - name/address UTF-8 트렁케이션, Number 필드 안전 변환.
- 기존 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). findByIdnull: 일반 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 테스트
bulkTabling0건/일부 매칭/불일치/에러 시나리오 —@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) + 워커로 분리해 재개 가능하게 만들지.
- 이름 유사도 알고리즘을 한국어 특화(자모, 초성)로 교체할지.
- 캐시 키 그룹별 부분 무효화 도입 여부.