Files
joungmin 80b553ec19 docs: 현행화 17개 설계서 Approved + 후속 이슈 백로그 등록
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 (백로그)
2026-06-15 11:08:18 +09:00
..

설계서: 백엔드 - 식당 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=&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 기록, 진행 상황을 이벤트로 스트리밍한다.
  • upsertgoogle_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바이트로 잘라 저장.
    • evaluationJsonUtil.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에서 parseRegionFromAddressregion 재계산.
  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) == trueupdate(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 직렬화, evaluationJsonUtil.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) + 워커로 분리해 재개 가능하게 만들지.
  • 이름 유사도 알고리즘을 한국어 특화(자모, 초성)로 교체할지.
  • 캐시 키 그룹별 부분 무효화 도입 여부.