diff --git a/docs/design/266-backend-auth/README.md b/docs/design/266-backend-auth/README.md new file mode 100644 index 0000000..4d11265 --- /dev/null +++ b/docs/design/266-backend-auth/README.md @@ -0,0 +1,173 @@ + + +# 설계서: 백엔드 - 인증/로그인 (#266) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #266 · 관련 ADR: 없음 +> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/AuthService.java`, `backend-java/src/main/java/com/tasteby/controller/AuthController.java` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +Tasteby 사용자가 Google 계정으로 1탭 로그인하여 즐겨찾기/리뷰/메모 등 개인화 기능을 사용할 수 있도록 한다. 자체 가입/비밀번호 운영 부담을 제거하고 검증된 ID 토큰 기반으로 안전한 세션 토큰(JWT)을 발급한다. + +## 2. 범위 (Scope) +- **포함** + - Google OAuth ID Token 검증 후 사용자 조회/생성(Upsert) → 자체 JWT 발급 (`POST /api/auth/google`). + - 현재 로그인 사용자 정보 반환 (`GET /api/auth/me`). + - Google 검증 실패/사용자 미존재 시 표준 HTTP 에러 매핑. +- **제외 (out of scope)** + - 자체 ID/PW 회원가입·비밀번호 재설정. + - Apple/Kakao/Naver 등 추가 소셜 로그인. + - 리프레시 토큰, 토큰 회수(blacklist). + - 로그아웃 처리(클라이언트 토큰 삭제로 처리). + - 권한 부여(role) 변경 — 사용자 관리(#267) 책임. + +## 3. 인수조건 (Acceptance Criteria) +- [ ] 유효한 Google ID Token으로 `POST /api/auth/google` 호출 시 `access_token`(JWT)과 `user` 객체를 반환한다. +- [ ] 신규 Google 계정 첫 로그인 시 `tasteby_users` 행이 생성되고, 재로그인 시 `last_login_at`이 갱신된다. +- [ ] Google ID Token이 위조/만료/오디언스 불일치인 경우 HTTP 401을 반환한다. +- [ ] 발급된 JWT를 `Authorization: Bearer ...`로 `GET /api/auth/me` 호출 시 본인 정보를 반환한다. +- [ ] JWT의 sub가 존재하지 않는 사용자 ID인 경우 `GET /api/auth/me`는 HTTP 404를 반환한다. + +## 4. 컨텍스트 & 제약 +- **의존성** + - `google-api-client`의 `GoogleIdTokenVerifier`(`NetHttpTransport` + `GsonFactory`). + - `UserService` → `UserMapper`(MyBatis) → Oracle 23ai (`tasteby_users`). + - 자체 `JwtTokenProvider` (HMAC 서명 가정), `AuthUtil`(SecurityContext에서 userId 추출). +- **제약** + - Google Client ID는 `app.google.client-id` 프로퍼티로 단일 audience로 고정. 모바일/웹 다중 클라이언트 ID는 현 시점 미지원. + - JWT 만료/서명 정책은 `JwtTokenProvider`에서 관리(본 설계서 범위 외). + - CORS는 `WebConfig`에서 `POST`/`GET` 허용 필요(이미 적용). + - 모든 외부 호출은 동기 HTTP, 실패 시 401로 합쳐서 반환. +- **가정** + - Google ID Token의 `sub`는 영구 고유 식별자이며, 동일 사용자가 이메일을 바꾸어도 `(provider='google', providerId=sub)`로 식별 가능. + - 사용자 객체의 `nickname`/`avatarUrl`은 최초 생성 시 Google payload 값을 그대로 저장(이후 사용자 편집은 본 기능 범위 외). + +## 5. 아키텍처 개요 +- **모듈/파일** + - `controller/AuthController.java` — HTTP 진입점 (thin). + - `service/AuthService.java` — Google 검증 + JWT 발급 오케스트레이션. + - `service/UserService.java#findOrCreate/findById` — DB 조회/upsert. + - `security/JwtTokenProvider`, `security/AuthUtil` — 토큰 생성 / SecurityContext 추출. +- **데이터 흐름** + +``` +[Client] + │ POST /api/auth/google { id_token } + ▼ +AuthController.loginGoogle + │ + ▼ +AuthService.loginGoogle + ├─ GoogleIdTokenVerifier.verify(idToken) ── (외부 I/O: Google 공개키 검증) + ├─ UserService.findOrCreate(provider, sub, email, name, picture) + │ └─ UserMapper.findByProviderAndProviderId / insert / updateLastLogin + └─ JwtTokenProvider.createToken(userMap) + │ + ▼ +{ access_token, user } + + +[Client] GET /api/auth/me (Authorization: Bearer ) + ▼ +AuthController.me → AuthUtil.getUserId() → AuthService.getCurrentUser + ▼ UserService.findById → UserMapper.findById + ▼ +UserInfo +``` + +- **I/O ↔ 순수 로직 경계** + - I/O: Google 토큰 검증, DB 조회/저장. + - 순수: payload → `UserInfo` 매핑, `Map` 클레임 빌드. + +## 6. 데이터 모델 +- **입력** + - `POST /api/auth/google` body: `{ "id_token": string(JWT, Google issued) }`. + - `GET /api/auth/me`: 헤더 `Authorization: Bearer `. +- **출력** + - `loginGoogle`: `{ access_token: string, user: UserInfo }`. + - `me`: `UserInfo`. +- **`UserInfo`(domain/UserInfo.java)** + - `id: String(32 hex)`, `email: String`, `nickname: String`, `avatarUrl: String`, `admin: boolean (@JsonProperty("is_admin"))`, `provider: String`, `providerId: String`, `createdAt: String`, `favoriteCount/reviewCount/memoCount: int`. +- **저장(`tasteby_users`)** + - PK: `id` (`IdGenerator.newId()`, 32-char uppercase hex), 유니크 가정: `(provider, provider_id)`. + - `is_admin NUMBER`(0/1), `last_login_at TIMESTAMP`. +- **경계 검증** + - `id_token` 비어있거나 null → Google verifier가 `null` 리턴 → 401. + - JWT 클레임 내 `email`/`nickname`이 null이면 빈 문자열로 정규화. + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------|------|------|-----------|-------| +| `AuthService(UserService, JwtTokenProvider, String)` | 의존성 주입 및 GoogleIdTokenVerifier 초기화 | `AuthService(UserService userService, JwtTokenProvider jwtProvider, @Value("${app.google.client-id}") String googleClientId)` | DI 빈, client-id | 인스턴스 | 프로퍼티 누락 시 빈 생성 실패 | 단순 | +| `AuthService.loginGoogle` | Google ID Token 검증 → 사용자 upsert → JWT 발급 | `Map loginGoogle(String idTokenString)` | Google id_token 문자열 | `{ access_token, user }` | 검증 실패/예외 → 401 `ResponseStatusException` | **복잡** (외부 I/O + DB upsert + 토큰 발급) | +| `AuthService.getCurrentUser` | JWT sub로 사용자 조회 | `UserInfo getCurrentUser(String userId)` | userId | `UserInfo` | 미존재 → 404 | 단순 | +| `AuthController(AuthService)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 | +| `AuthController.loginGoogle` | `/api/auth/google` 엔드포인트 | `Map loginGoogle(@RequestBody Map body)` | body.id_token | `{ access_token, user }` | AuthService 예외 위임 | 단순 | +| `AuthController.me` | `/api/auth/me` 엔드포인트 | `UserInfo me()` | (헤더에서 userId 자동 추출) | `UserInfo` | 인증 실패 → 401, 미존재 → 404 | 단순 | + +> 복잡 표시 함수(`loginGoogle`)는 흐름이 8장에 상세 기술되어 있어 별도 `fn-loginGoogle.md`는 생략 가능. + +## 8. 흐름 / 알고리즘 +**시나리오 A — Google 로그인** +1. 클라이언트가 Google Identity Services로 ID Token을 발급받아 `POST /api/auth/google {id_token}` 호출. +2. `AuthService`가 `GoogleIdTokenVerifier.verify`로 서명/만료/aud 검증. null이면 401. +3. payload에서 `sub`, `email`, `name`, `picture` 추출. +4. `UserService.findOrCreate("google", sub, email, name, picture)` 호출. + - 기존 유저: `updateLastLogin` 후 최신 사용자 반환. + - 신규 유저: `IdGenerator.newId()`로 PK 발급 → insert → 재조회 반환. +5. `UserInfo`의 핵심 필드를 `Map`으로 패키징하여 `JwtTokenProvider.createToken` 호출. +6. `{ access_token, user }` 응답. + +**시나리오 B — 현재 사용자 조회 (`/api/auth/me`)** +1. Spring Security 필터가 Bearer 토큰을 검증해 `SecurityContext`에 principal(userId) 설정. +2. `AuthUtil.getUserId()`로 sub 추출. +3. `AuthService.getCurrentUser` → `UserService.findById` → `UserMapper.findById`. +4. 없으면 404, 있으면 `UserInfo` 반환. + +## 9. 엣지케이스 & 에러 처리 +- **id_token이 null/공백**: Verifier가 null 또는 예외 발생 → 401 "Invalid Google token". +- **Google 공개키 조회 실패(네트워크/타임아웃)**: catch-all로 401에 메시지 포함. 재시도/백오프 없음(클라이언트가 재시도). +- **audience 불일치**: Verifier가 null 반환 → 401. +- **신규 사용자 insert 중 충돌**: 트랜잭션(`@Transactional`)으로 묶여 있으며, (provider, provider_id) 유니크 위반 시 예외 발생 → 상위에서 500 변환(전역 예외 처리에 의존). 동시 첫 로그인은 드물어 별도 재시도 없음. +- **`findById` race**: insert 직후 즉시 재조회 — 동일 트랜잭션 가시성 가정. +- **JWT 클레임 내 email/nickname null**: 빈 문자열로 정규화 후 토큰에 포함. +- **`/api/auth/me`에서 sub가 존재하지 않는 ID(사용자 삭제 등)**: 404. +- **안전한 기본값**: 어떤 실패든 401/404로 매핑, 500은 예외적. + +## 10. 테스트 계획 +- **현 상태**: 자동화 테스트 없음 (TBD). +- **추가 권장 단위 테스트** (Mockito 기반) + - `AuthService.loginGoogle` + - 유효 토큰 → `UserService.findOrCreate` 호출 + `JwtTokenProvider.createToken` 결과 포함. + - Verifier null 반환 → 401. + - Verifier 예외 → 401. + - email/nickname null payload → 토큰 클레임에 빈 문자열. + - `AuthService.getCurrentUser` + - 존재 → `UserInfo` 반환. + - 미존재 → 404. +- **통합 테스트** (`@SpringBootTest` + MockMvc) + - `POST /api/auth/google` happy path (Google verifier 모킹). + - `GET /api/auth/me` 인증 헤더 유효/무효. +- **모킹 전략**: `GoogleIdTokenVerifier`는 `@MockBean`으로 교체. JWT는 실제 `JwtTokenProvider` 사용해 round-trip 검증. + +## 11. 리스크 & 대안 검토 +- **선택**: Google 단일 IdP + 자체 단기 JWT. + - 장점: 구현 단순, 비밀번호 미관리, 즉시 사용 가능. + - 단점: 리프레시 토큰 없음 → 만료 시 재로그인 필요. +- **대안 1**: Spring Security OAuth2 Client + 세션 쿠키. + - 트레이드오프: 백엔드 세션 저장소 추가, SPA-친화 낮음. 현재 거부. +- **대안 2**: 리프레시 토큰 + 회수 리스트(Redis). + - 트레이드오프: 복잡도 ↑. 향후 필요 시 도입. +- **되돌리기 어려운 결정**: `(provider, provider_id)` 식별 스키마. → 변경 시 ADR 필요. +- **보안 리스크** + - JWT 시크릿 유출 시 위조 가능. 시크릿은 `k8s/secrets.yaml`로 관리. + - audience 단일 — 모바일/웹 client_id 분리 시 verifier 다중 audience 지원 필요. + +## 12. 미해결 질문 (Open Questions) +- 리프레시 토큰을 도입할 것인가, 단기 만료 + 재로그인을 유지할 것인가? +- 사용자 닉네임/프로필 사진을 매 로그인마다 Google 값으로 덮어쓸지(현 코드는 첫 생성만 반영) 여부. +- 어드민 권한 부여 정책(최초 가입자 admin, 환경변수 화이트리스트 등)을 어디서 결정할지. +- 멀티 클라이언트(웹/iOS/Android)별 Google Client ID 분리 시점. +- 토큰 만료/서명 알고리즘(HS256 → RS256 전환) 시점. diff --git a/docs/design/267-backend-user/README.md b/docs/design/267-backend-user/README.md new file mode 100644 index 0000000..6b8dfa2 --- /dev/null +++ b/docs/design/267-backend-user/README.md @@ -0,0 +1,204 @@ + + +# 설계서: 백엔드 - 사용자 관리 (#267) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #267 · 관련 ADR: 없음 +> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/UserService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminUserController.java`, `backend-java/src/main/java/com/tasteby/mapper/UserMapper.java`, `backend-java/src/main/resources/mybatis/mapper/UserMapper.xml` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +관리자(`is_admin=1`)가 가입 사용자 목록과 각 사용자의 활동(즐겨찾기·리뷰·메모)을 조회하고, 관리자 권한을 부여/회수할 수 있도록 한다. 또한 인증(#266) 흐름에서 호출되는 사용자 upsert/조회의 단일 책임 지점을 제공한다. + +## 2. 범위 (Scope) +- **포함** + - 사용자 upsert/조회 도메인 서비스 (`UserService.findOrCreate`, `findById`). + - 관리자 전용 사용자 목록 조회(페이징, 활동 카운트 포함) — `GET /api/admin/users`. + - 사용자별 즐겨찾기/리뷰/메모 조회 — `GET /api/admin/users/{userId}/{favorites|reviews|memos}`. + - 관리자 권한 토글 — `PATCH /api/admin/users/{userId}/admin`. +- **제외 (out of scope)** + - 회원 탈퇴/익명화, 개인정보 수정 API. + - 일반 사용자가 자기 프로필을 수정하는 API. + - 사용자 자체 검색(이름/이메일). + - 즐겨찾기/리뷰/메모의 CRUD (각 도메인 서비스 책임). + - Google 토큰 검증 (#266 책임). + +## 3. 인수조건 (Acceptance Criteria) +- [ ] 관리자 토큰으로 `GET /api/admin/users?limit=&offset=` 호출 시 `{ users:[…], total:n }` 구조와 각 사용자의 `favoriteCount/reviewCount/memoCount`가 포함된다. +- [ ] `findOrCreate`는 `(provider, providerId)`로 기존 사용자가 있으면 `last_login_at`만 갱신하고, 없으면 신규 PK로 INSERT한다. +- [ ] `PATCH /api/admin/users/{userId}/admin {admin: true|false}` 호출 시 자기 자신의 권한을 변경하면 400을 반환한다. +- [ ] 존재하지 않는 사용자 ID로 `updateAdmin` 호출 시 404를 반환한다. +- [ ] 관리자 권한 변경 후 응답에 `{ success:true, user_id, is_admin }`이 포함되고, 서버 로그에 변경자/대상/값이 기록된다. + +## 4. 컨텍스트 & 제약 +- **의존성** + - Oracle 23ai 테이블: `tasteby_users`, `user_favorites`, `user_reviews`, `user_memos`. + - MyBatis (`UserMapper.xml`) — resultMap으로 UPPERCASE 컬럼 → 도메인 매핑. + - `ReviewService`, `MemoService` — 사용자별 활동 조회 위임. + - `AuthUtil.requireAdmin()` — JWT의 admin 클레임 검사. +- **제약** + - 모든 `/api/admin/**` 엔드포인트는 관리자만 호출 가능. + - 페이징 기본 `limit=50, offset=0`. 상한 강제 없음(향후 캡 고려). + - `is_admin`은 Oracle NUMBER(0/1) → Java boolean (`@JsonProperty("is_admin")`). + - 트랜잭션: upsert 및 권한 변경은 `@Transactional`. +- **가정** + - `UserInfo.id`는 `IdGenerator.newId()` 32-char hex로 사전 발급. + - `findAllWithCounts`는 LEFT JOIN으로 활동 미존재 시 0 반환. + +## 5. 아키텍처 개요 +- **모듈/파일** + - `controller/AdminUserController.java` — 관리자 전용 엔드포인트. + - `service/UserService.java` — upsert·조회·권한 변경. + - `mapper/UserMapper.java` + `resources/mybatis/mapper/UserMapper.xml` — SQL 매핑. + - `domain/UserInfo.java` — Lombok DTO. +- **데이터 흐름** + +``` +[Admin Client] + │ GET /api/admin/users?limit&offset (Bearer JWT, is_admin=true) + ▼ +AdminUserController.listUsers + ├─ UserService.findAllWithCounts(limit, offset) + │ └─ UserMapper.xml: SELECT u.* + LEFT JOIN (fav/rev/memo COUNT) + └─ UserService.countAll + ▼ +{ users:[UserInfo], total } + +[Admin Client] + │ PATCH /api/admin/users/{id}/admin {admin} + ▼ +AdminUserController.updateAdmin + ├─ AuthUtil.requireAdmin() (자기 자신 변경 거부) + └─ UserService.updateAdmin → UserMapper.updateAdmin + ▼ +{ success, user_id, is_admin } + +[AuthService (#266)] + ▼ UserService.findOrCreate + ├─ findByProviderAndProviderId → updateLastLogin + └─ insert + findById +``` + +- **I/O ↔ 순수 로직 경계** + - I/O: MyBatis Mapper(DB). + - 순수: 권한 변경 시 자기 자신 검사, boolean→int 변환. + +## 6. 데이터 모델 +- **`UserInfo`** (`domain/UserInfo.java`) + - `id: String`, `email: String`, `nickname: String`, `avatarUrl: String`, `admin: boolean(@JsonProperty("is_admin"))`, `provider: String`, `providerId: String`, `createdAt: String`, `favoriteCount/reviewCount/memoCount: int`. +- **저장(`tasteby_users`)** + - `id PK(32)`, `provider VARCHAR`, `provider_id VARCHAR`, `email`, `nickname`, `avatar_url`, `is_admin NUMBER(1)`, `created_at TIMESTAMP`, `last_login_at TIMESTAMP`. + - 유니크(가정): `(provider, provider_id)`. +- **입력** + - `GET /api/admin/users`: query `limit:int(=50)`, `offset:int(=0)`. + - `PATCH /api/admin/users/{userId}/admin`: body `{ admin: boolean }`. +- **출력** + - 목록: `{ users: UserInfo[], total: int }`. + - 사용자 즐겨찾기/리뷰/메모: `Restaurant[]`, `Review[]`, `Memo[]`. + - 권한 변경: `{ success:true, user_id, is_admin }`. +- **경계 검증** + - `limit/offset` 정수, 음수 가드 없음 — 호출자 신뢰. Oracle FETCH NEXT가 0/음수 시 결과 0건. + - body.admin이 null → `Boolean.TRUE.equals(null)` = false 처리. + +## 7. 함수 명세 (Function Specs) + +### UserService (public) + +| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------|------|------|-----------|-------| +| `UserService(UserMapper)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 | +| `findOrCreate` | provider+providerId로 사용자 upsert + 마지막 로그인 갱신 | `UserInfo findOrCreate(String provider, String providerId, String email, String nickname, String avatarUrl)` | 5개 문자열 | `UserInfo` | DB 예외 → 500 | **복잡** (분기 + 트랜잭션) | +| `findById` | PK로 단건 조회 | `UserInfo findById(String userId)` | userId | `UserInfo` or null | DB 예외 → 500 | 단순 | +| `findAllWithCounts` | 활동 카운트 포함 페이징 목록 | `List findAllWithCounts(int limit, int offset)` | 페이징 | 사용자 리스트 | DB 예외 → 500 | 단순 | +| `countAll` | 전체 사용자 수 | `int countAll()` | 없음 | int | DB 예외 → 500 | 단순 | +| `updateAdmin` | 관리자 플래그 변경 | `void updateAdmin(String userId, boolean admin)` | userId, boolean | void | 미존재 → 404 | 단순 | + +### UserMapper (public, MyBatis) + +| 함수 | 책임 | 시그니처 | 출력 | +|------|------|----------|------| +| `findByProviderAndProviderId` | provider+providerId 조회 | `UserInfo findByProviderAndProviderId(String provider, String providerId)` | `UserInfo`/null | +| `updateLastLogin` | last_login_at = SYSTIMESTAMP | `void updateLastLogin(String id)` | void | +| `insert` | 신규 사용자 INSERT | `void insert(UserInfo user)` | void | +| `findById` | PK 조회 | `UserInfo findById(String id)` | `UserInfo`/null | +| `findAllWithCounts` | 활동 COUNT 조인 페이징 | `List findAllWithCounts(int limit, int offset)` | 목록 | +| `countAll` | 전체 카운트 | `int countAll()` | int | +| `updateAdmin` | `is_admin` UPDATE | `int updateAdmin(String id, int admin)` | 영향 행 수 | + +### AdminUserController (public) + +| 함수 | 책임 | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? | +|------|------|----------|------|------|-----------|-------| +| `AdminUserController(...)` | DI | 생성자 | DI 빈 | 인스턴스 | 없음 | 단순 | +| `listUsers` | `GET /api/admin/users` | `Map listUsers(int limit=50, int offset=0)` | 페이징 | `{users,total}` | 인증 실패 → 401/403 | 단순 | +| `userFavorites` | `GET …/{userId}/favorites` | `List userFavorites(String userId)` | userId | 즐겨찾기 식당 | 인증/위임 | 단순 | +| `userReviews` | `GET …/{userId}/reviews` | `List userReviews(String userId)` | userId | 리뷰 100건 | 인증/위임 | 단순 | +| `userMemos` | `GET …/{userId}/memos` | `List userMemos(String userId)` | userId | 메모 | 인증/위임 | 단순 | +| `updateAdmin` | `PATCH …/{userId}/admin` | `Map updateAdmin(String userId, Map body)` | userId, `{admin}` | `{success,user_id,is_admin}` | 자기 자신 → 400, 미존재 → 404 | **복잡** (정책 분기) | + +## 8. 흐름 / 알고리즘 +**A. Upsert (`findOrCreate`)** +1. `findByProviderAndProviderId(provider, providerId)` 호출. +2. 존재 → `updateLastLogin(id)` → `findById(id)` 반환. +3. 미존재 → `IdGenerator.newId()`로 PK 발급 → `UserInfo` 빌드 → `insert` → `findById(newId)` 반환. +4. 전체 트랜잭션(`@Transactional`)으로 묶여 부분 실패 시 롤백. + +**B. 관리자 목록 (`listUsers`)** +1. 컨트롤러에서 limit/offset 기본값 적용. +2. `findAllWithCounts`로 `LEFT JOIN ( user_favorites|user_reviews|user_memos GROUP BY user_id )` + `ORDER BY created_at DESC OFFSET ? FETCH NEXT ?`. +3. `countAll`로 전체 수 합산하여 `{ users, total }` 반환. + +**C. 권한 변경 (`updateAdmin`)** +1. `AuthUtil.requireAdmin()` — JWT의 admin 클레임 미보유 시 403. +2. `userId == currentUser.subject` → 400 ("자기 자신의 관리자 권한은 변경할 수 없습니다"). +3. body.admin → boolean (null=false). +4. `UserService.updateAdmin` → Mapper에서 `UPDATE … WHERE id = ?`. +5. 영향 행 0 → 404, 1 → 감사 로그 `[ADMIN] User {} set admin={} for user {}` 출력 후 성공 응답. + +## 9. 엣지케이스 & 에러 처리 +- **자기 자신 권한 변경**: 명시적으로 400으로 차단(마지막 관리자 사고 방지). +- **존재하지 않는 사용자 권한 변경**: Mapper 영향 행 0 → 404. +- **음수 limit/offset**: Oracle은 OFFSET 0 ROWS FETCH NEXT 음수 시 결과 0건. 클라이언트 신뢰. +- **활동 카운트 0**: `NVL(…, 0)`으로 0으로 노출. +- **`findById` race(인증 직후 삭제)**: null 반환 → 호출자(AuthService)에서 404. +- **email/nickname null** (Google에서 일부 제공 안 함): 컬럼 NULL 허용 가정. UI에서 빈 값 처리. +- **권한 변경 동시성**: 트랜잭션 + 단일 UPDATE이므로 마지막 쓰기 승리(last-write-wins). 감사 로그로 추적. +- **안전한 기본값**: 권한 변경 실패 시 변경 없음. + +## 10. 테스트 계획 +- **현 상태**: 자동화 테스트 없음 (TBD). +- **단위 테스트** (Mockito) + - `UserService.findOrCreate` + - 기존 사용자 → `updateLastLogin` + `findById` 호출 검증. + - 신규 사용자 → `insert` + `findById(newId)` 호출 검증. + - `UserService.updateAdmin` + - 영향 행 1 → 정상. + - 영향 행 0 → 404 예외. +- **컨트롤러 통합 테스트** (MockMvc + `@MockBean`) + - `GET /api/admin/users` 정상/페이징 파라미터. + - `PATCH /admin` 자기 자신 → 400, 미존재 → 404, 정상 → 200 + 응답 구조. + - 비-관리자 토큰 → 403. +- **Mapper 통합** (`@MybatisTest` + Testcontainers Oracle) + - `findByProviderAndProviderId` 일치/미일치. + - `findAllWithCounts` 활동 카운트 정확성. +- **모킹 전략**: `AuthUtil` 정적 메서드는 `Mockito.mockStatic`으로 stub. + +## 11. 리스크 & 대안 검토 +- **선택**: 관리자 전용 엔드포인트 분리 + 일반 사용자용 프로필 API 없음. + - 장점: 권한 경계 단순. + - 단점: 일반 사용자가 자기 프로필 수정 시 별도 API 신설 필요. +- **대안 1**: `is_admin` 외 RBAC(role 테이블). + - 트레이드오프: 복잡도 ↑. 현 규모에서는 과설계. +- **대안 2**: 활동 카운트를 캐시/뷰로 분리. + - 트레이드오프: 사용자 수 증가 시 JOIN 비용 ↑ — 그때 도입. +- **되돌리기 어려운 결정**: `is_admin` boolean → 다중 역할로 확장 시 마이그레이션 필요. +- **운영 리스크** + - 마지막 관리자 권한 회수: 자기 자신 차단으로 부분 보호. 다른 관리자가 회수하면 무관리자 상태 가능 → 향후 "최소 1명 admin 유지" 가드 고려. + - 사용자 페이징에 상한 없음 → 큰 limit으로 메모리 압박 가능. + +## 12. 미해결 질문 (Open Questions) +- 일반 사용자가 자기 프로필(닉네임/아바타)을 수정하는 API를 어디에 둘 것인지(`UserController` 신설 vs `/api/auth/me PATCH`). +- 사용자 검색(이메일/닉네임 부분 일치)을 관리자 화면에 추가할 것인지. +- "최소 1명 admin 유지" 가드를 도입할지, 운영 정책으로만 둘지. +- 활동 카운트 페이지 캐싱 TTL을 도입할지(현재 Redis 캐시 미적용). +- 회원 탈퇴/익명화 정책과 개인정보 보관기간. diff --git a/docs/design/268-backend-restaurant/README.md b/docs/design/268-backend-restaurant/README.md new file mode 100644 index 0000000..7a531cd --- /dev/null +++ b/docs/design/268-backend-restaurant/README.md @@ -0,0 +1,277 @@ + + +# 설계서: 백엔드 - 식당 CRUD (#268) + +> **상태**: Draft +> **작성**: [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) + 워커로 분리해 재개 가능하게 만들지. +- 이름 유사도 알고리즘을 한국어 특화(자모, 초성)로 교체할지. +- 캐시 키 그룹별 부분 무효화 도입 여부. diff --git a/docs/design/269-backend-video/README.md b/docs/design/269-backend-video/README.md new file mode 100644 index 0000000..ee6e50e --- /dev/null +++ b/docs/design/269-backend-video/README.md @@ -0,0 +1,214 @@ + + +# 설계서: 백엔드 - 영상 관리 + SSE (#269) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #269 · 관련 ADR: 없음 +> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/VideoService.java`, `backend-java/src/main/java/com/tasteby/service/YouTubeService.java`, `backend-java/src/main/java/com/tasteby/controller/VideoController.java`, `backend-java/src/main/java/com/tasteby/controller/VideoSseController.java` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +유튜브 채널의 영상 메타데이터를 스캔·저장하고, 자막(transcript)을 확보하며, 식당 추출 파이프라인 진입까지의 영상 생명주기를 관리한다. 다건 처리 진행 상황은 SSE 로 실시간 스트리밍하여 운영자가 어드민에서 모니터링한다. + +## 2. 범위 (Scope) +- **포함**: + - 영상 목록/상세 조회, 제목 수정, 상태(`pending|processing|done|skip|no_transcript|error`) 변경, 삭제. + - 영상-식당 링크 단건 삭제 + 고아 식당/벡터/리뷰/즐겨찾기 정리. + - YouTube Data API v3 기반 채널 스캔 (PlaylistItems 우선, Search API 폴백, Shorts 60초 이하 필터). + - 자막 확보: Playwright Headed 브라우저(쿠키 로드, 광고 스킵, 한국어 우선) → 실패 시 `youtube-transcript-api` 폴백. + - 운영자가 브라우저 확장 등으로 수집한 transcript 업로드. + - SSE 스트림: `bulk-transcript`, `bulk-extract`, `remap-cuisine`, `remap-foods`, `rebuild-vectors`. + - 단건 추출 트리거 (`POST /api/videos/{id}/extract`)와 수동 식당 추가 (`/restaurants/manual`). +- **제외 (out of scope)**: + - LLM 기반 식당 추출 본체와 Geocoding (→ #270). + - 검색/벡터 추천 질의 (→ #271). + - 채널 마스터 CRUD (→ #273). + - 프론트엔드 어드민 UI (→ #282). + +## 3. 인수조건 (Acceptance Criteria) +- [ ] `GET /api/videos?status=pending` 호출 시 상태별 영상 목록을 반환하고, 상세 (`GET /api/videos/{id}`)에는 transcript 와 식당 링크 배열이 포함된다 (`evaluation` 은 `JsonUtil.normalizeEvaluation` 으로 정규화). +- [ ] `DELETE /api/videos/{id}` 가 단일 트랜잭션으로 벡터·리뷰·즐겨찾기·식당·링크·영상 순으로 정리해 고아 레코드를 남기지 않는다. +- [ ] 채널 스캔(`YouTubeService.scanChannel`)은 PlaylistItems API 로 전체 업로드를 페이징하며, `publishedAfter` 이후 영상만 가져오고 Shorts(60초 이하)를 제거한 뒤 `saveVideosBatch` 로 중복 없이 저장한다. +- [ ] `POST /api/videos/{id}/fetch-transcript` 가 브라우저 → API 순으로 자막을 시도하고, 성공 시 길이/소스(`browser`/`manual (ko)`/`generated (en)` 등)를 응답에 포함한다. +- [ ] `POST /api/videos/bulk-transcript` SSE 가 `start → processing → done|skip|error → api_pass → complete` 이벤트 시퀀스를 JSON 으로 송출하고, 30분 타임아웃 + 3~8초 랜덤 딜레이로 봇 탐지를 회피한다. +- [ ] 모든 admin 엔드포인트는 `AuthUtil.requireAdmin()` 가드를 통과해야 하며 캐시 변경 후 `CacheService.flush()` 가 호출된다. + +## 4. 컨텍스트 & 제약 +- **의존성**: + - DB: Oracle 23ai (videos, video_restaurants, restaurants, restaurant_vectors, reviews, favorites). + - 외부 API: YouTube Data API v3 (`app.google.youtube-api-key`). + - 자막 라이브러리: `io.github.thoroldvix.api.YoutubeTranscriptApi` + Playwright Chromium. + - 내부 서비스: `PipelineService`, `ExtractorService`, `RestaurantService`, `GeocodingService`, `OciGenAiService`, `CacheService` (`#270`/`#271`/`#276`). +- **제약**: + - YouTube API quota (PlaylistItems 1 unit/페이지, Search 100 unit/페이지 → 우선 PlaylistItems 사용). + - Playwright Headed 모드는 Mac mini Dev 환경 가정 (`pm2 tasteby-api`); 헤드리스 환경(OKE prod) 미지원이므로 SSE bulk-transcript 는 dev 에서만 사용한다. + - SSE Emitter 타임아웃: transcript 30 분, extract/remap 10 분. + - LLM 호출 비용 → bulk 작업 시 3~8초 랜덤 딜레이로 호출량 제어. + - transcript CLOB 저장, `MyBatis ClobTypeHandler` 로 매핑. +- **가정**: + - 영상 ID(`videos.id`)는 32-char UUID(`IdGenerator.newId()`), `video_id` 는 YouTube 11자 ID. + - admin 권한 사용자만 모든 mutation/SSE 를 호출한다. + - 운영자는 `cookies.txt` 를 백엔드 작업 디렉토리에 두어 Playwright 로그인을 우회한다. + +## 5. 아키텍처 개요 +- 모듈/파일: + - `controller/VideoController.java` — 동기 CRUD/단건 작업. + - `controller/VideoSseController.java` — SSE 다건 작업 (Virtual Thread executor). + - `service/VideoService.java` — Mapper 위임 + transcript/evaluation 정규화. + - `service/YouTubeService.java` — YouTube API + Playwright + transcript-api. + - `mapper/VideoMapper.java` (+ `mybatis/mapper/VideoMapper.xml`) — DB 접근. + - 도메인: `VideoSummary`, `VideoDetail`, `VideoRestaurantLink`. +- I/O ↔ 순수 로직 경계: + - **I/O**: YouTube REST 호출, Playwright 브라우저, DB INSERT/UPDATE, SSE emit. + - **순수 로직**: `parseDuration`, `filterShorts` 필터 조건, `evaluation` JSON 정규화 (`JsonUtil.normalizeEvaluation`), 페이지네이션 중단 조건(`publishedAfter` 이전 발견 시 break). + +``` +[Admin UI] --HTTP--> VideoController ---> VideoService ---> VideoMapper ---> Oracle + \---> YouTubeService --(WebClient)--> YouTube Data API v3 + --(Playwright)--> youtube.com + --(transcript-api)--> timedtext + +[Admin UI] --SSE--> VideoSseController --(VirtualThread)--> { + YouTubeService.createBrowserSession + getTranscriptWithPage + PipelineService.processExtract (#270) + OciGenAiService.chat (cuisine/foods remap) + RestaurantService.update* (#268) + } --emit JSON event--> Admin UI + +[Pipeline scan] cron/daemon --> YouTubeService.scanAllChannels + --> ChannelService + VideoService.saveVideosBatch +``` + +## 6. 데이터 모델 +- **입력**: + - `POST /{id}/fetch-transcript` 쿼리: `mode ∈ {auto, manual, generated}`. + - `POST /{id}/upload-transcript` body: `{ text: string(≥1), source?: string }`. + - `POST /{id}/extract` body(옵션): `{ prompt?: string }`. + - `POST /{videoId}/restaurants/manual` body: `{ name(필수), address?, region?, cuisine_type?, price_range?, foods_mentioned?: string|string[], guests?: string|string[], evaluation?: string }`. + - `PUT /{videoId}/restaurants/{restaurantId}` body: 위 필드 + 이름/주소 변경 시 재-geocode. + - SSE body: `{ ids?: string[] }` (없으면 전체 pending). +- **출력**: + - `VideoSummary` 목록 (id, videoId, title, url, status, publishedAt, channelName, hasTranscript, hasLlm, restaurantCount, matchedCount). + - `VideoDetail` = summary + `transcript`(CLOB) + `restaurants: VideoRestaurantLink[]`. + - `VideoRestaurantLink`: restaurantId, name, address, cuisineType, priceRange, region, foodsMentioned(@JsonRawValue JSON), evaluation(@JsonRawValue JSON), guests(@JsonRawValue JSON), googlePlaceId, lat/lng, `hasLocation` 파생. + - SSE 이벤트 공통 키: `type ∈ {start, processing, done, skip, error, api_pass, wait, batch_done, retry, complete}`. +- **저장**: + - `videos(id PK, channel_id FK, video_id, title, url, published_at, status, transcript_text CLOB, llm_response CLOB)`. + - `video_restaurants(video_id, restaurant_id, foods_mentioned IS JSON, evaluation IS JSON, guests IS JSON)` — `evaluation` 컬럼은 DB CHECK `IS JSON` 제약. +- **검증 규칙**: + - `title`, `text` blank 금지 → 400. + - `evaluation` 문자열은 JSON 리터럴(`{`/`"` 시작)이 아니면 `JsonUtil.toJson` 으로 문자열 래핑. + - transcript 8000자 초과는 ExtractorService 가 머리/꼬리만 남기고 절단(`#270`). + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `VideoController.list` | 상태별 영상 목록 | `list(String status)` | status (옵션) | `List` | 없음 (빈 배열) | 단순 | +| `VideoController.detail` | 영상 상세 + 식당 링크 | `detail(String id)` | id | `VideoDetail` | 404 NotFound | 단순 | +| `VideoController.updateTitle` | 제목 수정 | `updateTitle(id, body)` | id, title | `{ok}` | 400 blank, 403 admin | 단순 | +| `VideoController.skip` | 영상 skip 처리 | `skip(id)` | id | `{ok}` | 403 | 단순 | +| `VideoController.delete` | 영상 + 종속 cascade 삭제 | `delete(id)` | id | `{ok}` | 403, TX rollback | **복잡** | +| `VideoController.deleteVideoRestaurant` | 영상-식당 링크 + 고아 정리 | `deleteVideoRestaurant(videoId, restaurantId)` | id 2종 | `{ok}` | 403 | **복잡** | +| `VideoController.fetchTranscript` | 자막 자동 수집(browser→api) | `fetchTranscript(id, mode)` | id, mode | `{ok,length,source}` | 400 자막없음, 404 | **복잡** | +| `VideoController.uploadTranscript` | 외부 수집 자막 저장 | `uploadTranscript(id, body)` | id, text | `{ok,length,source}` | 400, 404 | 단순 | +| `VideoController.getExtractPrompt` | LLM 추출 프롬프트 조회 | `getExtractPrompt()` | — | `{prompt}` | 없음 | 단순 | +| `VideoController.extract` | 단건 LLM 추출 실행 | `extract(id, body)` | id, prompt? | `{ok,count}` | 400 transcript 없음 | **복잡** | +| `VideoController.bulkExtractPending` | 추출 대상 영상 목록 | `bulkExtractPending()` | — | `{count,videos}` | 없음 | 단순 | +| `VideoController.bulkTranscriptPending` | 자막 미보유 영상 목록 | `bulkTranscriptPending()` | — | `{count,videos}` | 없음 | 단순 | +| `VideoController.addManualRestaurant` | 수동 식당 추가 + geocode + 링크 | `addManualRestaurant(videoId, body)` | videoId, body | `{ok, restaurant_id}` | 400 name 없음 | **복잡** | +| `VideoController.updateVideoRestaurant` | 링크/식당 필드 수정 + 재-geocode | `updateVideoRestaurant(videoId, restaurantId, body)` | id 2종, fields | `{ok}` | 403 | **복잡** | +| `VideoSseController.bulkTranscript` | SSE 다건 자막 (browser→api 2pass) | `bulkTranscript(body)` | ids? | `SseEmitter` | emit error/skip | **복잡** | +| `VideoSseController.bulkExtract` | SSE 다건 LLM 추출 | `bulkExtract(body)` | ids? | `SseEmitter` | emit error | **복잡** | +| `VideoSseController.remapCuisine` | cuisine_type 재분류 (배치+retry) | `remapCuisine()` | — | `SseEmitter` | LLM 실패 시 retry | **복잡** | +| `VideoSseController.remapFoods` | foods_mentioned 재생성 | `remapFoods()` | — | `SseEmitter` | LLM 실패 시 retry | **복잡** | +| `VideoSseController.rebuildVectors` | 벡터 재생성 자리 | `rebuildVectors()` | — | `SseEmitter` | TODO 비어있음 | 단순 | +| `VideoSseController.process` | 동기 N건 파이프라인 | `process(limit)` | limit (≤?) | `{count}` | 없음 | 단순 | +| `VideoService.findDetail` | 상세 + 링크 + evaluation 정규화 | `findDetail(id)` | id | `VideoDetail|null` | null 가능 | 단순 | +| `VideoService.delete` | 트랜잭션 6단계 정리 | `delete(id)` | id | void | TX rollback | **복잡** | +| `VideoService.deleteVideoRestaurant` | 링크 + 고아 cleanup | `deleteVideoRestaurant(...)` | id 2종 | void | TX rollback | **복잡** | +| `VideoService.saveVideosBatch` | 중복 제외 신규 영상 일괄 insert | `saveVideosBatch(channelId, videos)` | dbId, list | 저장 건수 | 부분 실패 시 catch 없음 | 단순 | +| `VideoService.findPendingVideos` | pending 상태 영상 N개 | `findPendingVideos(limit)` | limit | `List` | 없음 | 단순 | +| `VideoService.findVideosForBulkExtract` | transcript 보유/추출 미실행 | `findVideosForBulkExtract()` | — | `List` (CLOB 읽음) | CLOB read 실패 | 단순 | +| `VideoService.findVideosWithoutTranscript` | 자막 미보유 영상 | `findVideosWithoutTranscript()` | — | `List` | 없음 | 단순 | +| `VideoService.updateTranscript` / `updateStatus` / `updateTitle` / `updateVideoFields` | 컬럼 갱신 | — | id + values | void | 없음 | 단순 | +| `VideoService.updateVideoRestaurantFields` | foods/evaluation/guests JSON 갱신 | — | ids + 3 JSON | void | DB JSON 제약 위반 시 throw | 단순 | +| `YouTubeService.fetchChannelVideos` | 업로드 플레이리스트 페이징 | `(channelId, after, excludeShorts)` | params | `List` | 예외 시 Search 폴백 | **복잡** | +| `YouTubeService.fetchChannelVideosViaSearch` | Search API 폴백 | 동일 | params | `List` | 파싱 실패 시 break | **복잡** | +| `YouTubeService.filterShorts` | 50개씩 duration 조회 후 60초↑ 필터 | `(videos)` | list | filtered list | 배치 실패 시 default 61 (포함) | **복잡** | +| `YouTubeService.parseDuration` | ISO8601 → 초 | `(dur)` | `PT#H#M#S` | int | regex unmatch → 0 | 단순 | +| `YouTubeService.scanChannel` | 채널 단건 스캔 + 저장 | `(channelId, full)` | id, full | `{total_fetched,new_videos,filtered}` | 채널 미존재 → null | **복잡** | +| `YouTubeService.scanAllChannels` | 활성 채널 전부 스캔 | `()` | — | int | 채널별 예외 catch+log | **복잡** | +| `YouTubeService.getTranscript` | 브라우저 → API 자막 | `(videoId, mode)` | id, mode | `TranscriptResult|null` | null 반환 | **복잡** | +| `YouTubeService.getTranscriptApi` | thoroldvix API 호출 | `(videoId, mode)` | id, mode | `TranscriptResult|null` | 예외 시 null | **복잡** | +| `YouTubeService.getTranscriptWithPage` | 기존 Page 재사용 | `(page, videoId)` | page, id | 동일 | 동일 | **복잡** | +| `YouTubeService.createBrowserSession` | Playwright+Browser+Page lifecycle | `()` | — | `BrowserSession` (`AutoCloseable`) | 실패 시 throw | **복잡** | +| `YouTubeService.fetchTranscriptFromPage` | 페이지 조작 + 세그먼트 스크롤 수집 | `(page, videoId)` | page, id | result|null | 다단계 catch | **복잡** | +| `YouTubeService.skipAds` / `selectKorean` / `loadCookies` | 페이지 보조 동작 | — | page | void | 무시 가능 | 단순 | + +> 복잡 표시 함수는 외부 I/O + 다단계 폴백/상태기계 포함. 별도 `fn-*.md` 가 필요한 경우 우선순위는 `fetchTranscriptFromPage`, `bulkTranscript`, `delete`(영상 cascade), `scanChannel`. + +## 8. 흐름 / 알고리즘 +1. **채널 스캔 (daemon/cron):** + `scanAllChannels` → 각 채널에 `scanChannel(false)` → `fetchChannelVideos(channelId, latestPublishedAt, true)`. PlaylistItems(UC→UU) 50건 페이지 반복, `publishedAfter` 이전 항목 발견 시 즉시 중단. titleFilter + 기존 video_id 셋 비교 후 `saveVideosBatch` 로 신규만 insert. +2. **단건 자막 수집:** + `getTranscript` → `getTranscriptBrowser` (Playwright headed, cookies.txt 로드, `--disable-blink-features=AutomationControlled`). `skipAds` (광고 스킵/음소거/끝 이동), 더보기 클릭, "스크립트 표시" 버튼 탐색(aria-label → text → engagement panel), 세그먼트 0→폴링 10회×1.5s, `selectKorean` 시도, 컨테이너 스크롤 50회로 전체 수집. 실패 시 `getTranscriptApi` (manual→generated, ko→en). +3. **단건 추출:** + `VideoController.extract` → transcript 검증 → `PipelineService.processExtract(video, transcript, prompt)` 호출(상세 흐름 #270). 결과 식당 수 응답. +4. **SSE bulk-transcript:** + 대상 결정(ids vs 전체) → `start{total}` emit → Pass1: 단일 `BrowserSession` 으로 순회, 각 영상 `processing{method=browser}` → `done` 또는 `skip` 후 `apiNeeded` 누적, 3~8s 랜덤 sleep. Pass2: `api_pass{count}` → 실패분만 `getTranscriptApi`, 결과에 따라 `done`/`error`+`status=no_transcript`. 최종 `complete{success,failed}`. +5. **SSE bulk-extract:** + 대상 `findVideosForBulkExtract` (transcript 있고 추출 미실행) → 영상별 3~8s `wait` → `processExtract` → `done{restaurants}` / `error`. 총 결과 > 0 이면 `cache.flush()`. +6. **SSE remap-cuisine / remap-foods:** + 대상 식당을 BATCH(20/15)로 묶어 LLM 일괄 분류 호출 → 결과 매핑 후 누락 식당은 `missed` 로 격리. 누락 항목은 size 5 배치로 최대 3회 retry. 각 단계마다 `batch_done`/`retry`/`complete` emit, 종료 시 `cache.flush()`. +7. **단일 영상 삭제 cascade:** `deleteVectorsByVideoOnly` → `deleteReviewsByVideoOnly` → `deleteFavoritesByVideoOnly` → `deleteRestaurantsByVideoOnly` → `deleteVideoRestaurants` → `deleteVideo` (단일 `@Transactional`). + +## 9. 엣지케이스 & 에러 처리 +- **PlaylistItems 실패**: try/catch → Search API 폴백. Search 도 실패하면 빈 리스트 → 신규 0. +- **publishedAfter 이전 영상 발견**: 업로드 재생목록은 시간 역순이므로 즉시 nextPage=null 로 페이지 종료 (불필요 호출 차단). +- **Shorts duration API 실패**: 해당 배치의 모든 video 는 `default=61` (포함) 으로 처리해 누락 방지. +- **transcript 없음**: 단건은 400, bulk Pass2 실패 시 status=`no_transcript`, error emit. +- **YouTube 봇 탐지**: Pass1 에서 cookies.txt 로드 + headed + 3~8s 랜덤 지연 + navigator.webdriver=false 마스킹. +- **광고 무한 루프**: `skipAds` 최대 30회 (≈30s) 후 강제 진행. +- **세그먼트 미수신**: 1.5s × 10회 폴링 후 0이면 빈 응답 → API 폴백. +- **CLOB 직렬화**: `JsonUtil.readClob` 으로 안전 변환, `@JsonRawValue` 로 JSON 컬럼은 원형 유지. +- **evaluation 형식 깨짐**: `JsonUtil.normalizeEvaluation` 으로 평문→JSON 문자열 래핑 + 300자 제한. +- **SSE 클라이언트 중단**: `emit` 내부 `Exception` 은 debug 로그만 남기고 emitter 종료. timeout(30/10분) 초과 시 자동 종료. +- **LLM 응답 누락**: remap 시 `CuisineTypes.isValid` 가 false 이면 missed 로 옮겨 retry, 끝까지 실패하면 그대로 노출 (`missed` 카운트). +- **DB IS JSON 제약**: `evaluation` 문자열 → `{`/`"` 검사 후 `JsonUtil.toJson` 래핑. +- **고아 데이터 차단**: 영상 삭제와 링크 단건 삭제 모두 `cleanupOrphan*` 호출. +- **안전 기본값**: YouTube API 통째 실패 시 빈 결과, transcript 실패 시 상태만 변경, LLM/Geocoding 실패는 식당 미생성으로 종결 (DB 손상 차단). + +## 10. 테스트 계획 +- **단위(JUnit5 + Mockito) — VideoService** + - `delete` 호출 순서: 6개 mapper 메서드 호출 검증 (벡터→리뷰→즐겨찾기→식당→링크→영상). + - `findDetail` null/빈 restaurants 케이스 normalizeEvaluation 호출 검증. + - `saveVideosBatch` 중복 비율 (existing set hit 시 0, 미스 시 새 ID 생성). +- **단위 — YouTubeService** + - `parseDuration` 경계값 (`PT60S=60`, `PT1M1S=61`, `PT1H=3600`, 빈 문자열=0, 오작동 입력=0). + - `filterShorts`: duration map 60 이하 제외, 누락 ID 는 기본 61 (포함). + - `fetchChannelVideos` 페이징 중단 (publishedAfter 이전 발견 즉시 break). + - `getTranscriptApi` mode 분기 (manual/generated/auto). +- **통합 (Spring + WireMock/MockWebServer)** + - YouTube API 모킹 → `scanChannel` 가 신규 N개 저장. + - LLM 모킹 → SSE `bulkExtract` 가 start/processing/done/complete 시퀀스 emit. + - `bulkTranscript` 는 Playwright 모킹이 어려우므로 `getTranscriptWithPage` 를 Mockito 로 대체. +- **E2E (수동 dev)** + - Playwright headed transcript 수집 1건 / bulk 10건. + - `DELETE /api/videos/{id}` 후 식당/링크/벡터 카운트 0 확인 (SQL). +- **인수조건 매핑**: AC1↔detail unit, AC2↔delete cascade unit+SQL, AC3↔scanChannel 통합, AC4↔fetchTranscript 통합, AC5↔bulkTranscript E2E, AC6↔모든 admin 엔드포인트 403 unit. +- **모킹/드라이런**: YouTube/Google API → MockWebServer, `OciGenAiService.chat` → Mockito stub (고정 JSON 반환). + +## 11. 리스크 & 대안 검토 +- **Playwright Headed (선택)**: ko 자막 정확도/봇 탐지 회피 우수. 단, Mac mini Dev 환경 의존. 대안: youtube-transcript-api (제한 많음), Whisper STT (비용/시간). → 운영(OKE)에서는 사용 안 함, 자막은 dev 에서 사전 확보. +- **SSE (선택)**: 30분 작업 진행 표시 단순. 대안: WebSocket(과한 양방향), 폴링(부정확). 트레이드오프: 한 작업이 emitter 1개 점유 → 동시 다발 사용 시 메모리 압박 (현재 admin 단독 사용 가정). +- **LLM 재분류 단일 트랜잭션 없음**: 결과 즉시 update + missed 별도 retry → 부분 성공 허용. 대안: 전부 임시 테이블 stage → 검토 후 swap (운영 부담 증가). 현재 데이터 양 < 수천 식당이라 부분 적용 수용. +- **video cascade 삭제**: 향후 ON DELETE CASCADE FK 적용 시 mapper 6단계 → 1단계로 단순화 가능 → **ADR 후보** (`adr/0001-video-cascade.md`). +- **transcript CLOB 크기**: 8000자 truncate 는 ExtractorService 가 담당, DB 는 CLOB 그대로 보관. + +## 12. 미해결 질문 (Open Questions) +- `rebuildVectors` SSE 가 TODO 상태 — 전 식당 벡터 재계산 시 OCI GenAI 호출 비용/시간 산정 필요. +- `scanAllChannels` 일정 (daemon 주기? cron?) 은 #275 에서 확정 예정. +- `bulkTranscript` 가 Playwright 헤드모드를 요구해 prod 미지원 — 헤드리스 우회/Whisper STT 도입 여부. +- `evaluation`/`foods_mentioned` JSON 스키마 표준화 (현재 문자열/배열 혼재). +- Search API 폴백 quota 초과 시 사용자 메시지 (UI 표시) 부재. diff --git a/docs/design/270-backend-extract-pipeline/README.md b/docs/design/270-backend-extract-pipeline/README.md new file mode 100644 index 0000000..97fee48 --- /dev/null +++ b/docs/design/270-backend-extract-pipeline/README.md @@ -0,0 +1,247 @@ + + +# 설계서: 백엔드 - 영상→식당 추출 파이프라인 (LLM+Geocoding) (#270) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #270 · 관련 ADR: 없음 +> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/ExtractorService.java`, `backend-java/src/main/java/com/tasteby/service/PipelineService.java`, `backend-java/src/main/java/com/tasteby/service/OciGenAiService.java`, `backend-java/src/main/java/com/tasteby/service/GeocodingService.java` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +유튜브 영상 자막에서 식당 정보를 LLM 으로 구조화하고, Google Maps 로 좌표/메타데이터를 보강한 뒤 DB+벡터 인덱스에 저장하여 지도/검색이 즉시 노출되도록 한다. 운영자가 단건/대량 모두 동일한 멱등 파이프라인을 호출할 수 있어야 한다. + +## 2. 범위 (Scope) +- **포함**: + - LLM 프롬프트 정의(`ExtractorService.EXTRACT_PROMPT`) — 7개 필드 추출, `CuisineTypes` 표준 카테고리 강제, 한국어 응답 강제. + - OCI Generative AI (Cohere/Llama 계열) Chat & Embed 호출 + 결과 JSON 견고 파싱 (마크다운 블록 제거, 트레일링 콤마 제거, 부분 array 복구). + - Google Maps Places Text Search → Place Details → Geocoding API 폴백. + - 한국어 주소 → `나라|시/도|구/군` 형식 region 파싱. + - 추출 결과로 식당 upsert + 영상↔식당 링크 + 벡터 임베딩 저장. + - 파이프라인 상태 전이: `pending → processing → done/error`. +- **제외 (out of scope)**: + - 자막 확보(`YouTubeService`) — #269. + - 검색/추천 질의 (`VectorService.searchSimilar`) — #271. + - 식당 CRUD/병합 — #268. + - 어드민 UI 트리거 화면 — #282. + +## 3. 인수조건 (Acceptance Criteria) +- [ ] `ExtractorService.extractRestaurants(title, transcript, prompt?)` 는 transcript 8000자 초과 시 머리 7000 + 꼬리 1000 으로 절단하고, LLM 응답이 JSON array/object/빈값 어떤 형태든 `List` 으로 정규화한다. +- [ ] `PipelineService.processExtract` 는 추출된 각 식당에 대해 (a) Geocoding → (b) `RestaurantService.upsert` → (c) `linkVideoRestaurant` → (d) `VectorService.saveRestaurantVectors` 순으로 실행하고, 0건이어도 영상 상태를 `done` 으로 갱신한다. +- [ ] `OciGenAiService.parseJson` 은 ```json``` 코드 블록, 트레일링 콤마, 잘린 array 를 자동 복구하며, 끝내 실패하면 `RuntimeException("JSON parse failed: ...")` 을 던진다. +- [ ] `GeocodingService.geocodeRestaurant` 는 Places Text Search 성공 시 phone/website 까지 채워 반환하고, 실패 시 Geocoding API 로 폴백하며, 둘 다 실패하면 `null` 을 반환한다 (식당은 좌표 없이 저장됨). +- [ ] `evaluation` 필드는 항상 DB 의 `IS JSON` 제약을 통과하도록 JSON 문자열 리터럴(`"..."`) 또는 객체 JSON 으로 변환된 뒤 저장된다. +- [ ] `processVideo` 가 자막 미존재 시 status=`done` 으로 종결하고, 예외 발생 시 `error` 로 마킹하여 다음 daemon 실행을 차단하지 않는다. + +## 4. 컨텍스트 & 제약 +- **의존성**: + - OCI Generative AI Inference SDK (`com.oracle.bmc.generativeaiinference`) — `~/.oci/config` 기반 인증, compartment/endpoint/model 은 `app.oci.*` 프로퍼티. + - Google Maps Platform — `app.google.maps-api-key` (Places + Geocoding 동일 키). + - Oracle 23ai (`restaurants`, `video_restaurants`, `restaurant_vectors` VECTOR 컬럼). + - 내부: `YouTubeService` (transcript), `RestaurantService.upsert/linkVideoRestaurant`, `VectorService.saveRestaurantVectors`, `VideoService.updateVideoFields`, `CacheService.flush`. + - 유틸: `CuisineTypes.CUISINE_LIST_TEXT`, `JsonUtil.toJson`. +- **제약**: + - OCI Chat `maxTokens=8192`, `temperature=0.0`, Embed batch 최대 96. + - Google Maps 호출당 10초 타임아웃, 일일 quota/요금 관리 필요. + - transcript 8000자 절단(LLM context 한계 회피). + - `restaurant_vectors.embedding` 은 Oracle VECTOR(float[]), `MapSqlParameterSource` 로 직접 바인딩 (#271 VectorService). + - LLM 응답이 비결정적이므로 cuisine_type 검증/재맵핑은 사후 워크플로(`remap-cuisine` SSE)에 의존. +- **가정**: + - 영상 1건당 식당 평균 1~5개, 전체 transcript 평균 ~3000자. + - OCI 인증이 없으면 (`PostConstruct` 경고) chat/embed 호출은 `IllegalStateException` → 추출 파이프라인 전체 실패 (`processVideo` 가 `error` 로 마킹). + - Google 한국어(`language=ko`) 결과를 신뢰; 해외 식당은 Places 결과 그대로 사용. + +## 5. 아키텍처 개요 +- 모듈/파일: + - `service/ExtractorService.java` — 프롬프트 + LLM 호출 + 결과 정규화. + - `service/PipelineService.java` — 워크플로 오케스트레이션 + 상태 전이. + - `service/OciGenAiService.java` — OCI GenAI SDK 어댑터 (chat/embed/JSON 복구). + - `service/GeocodingService.java` — Google Maps WebClient 클라이언트 + 주소 region 파싱. + - 협력: `YouTubeService` (#269), `RestaurantService` (#268), `VectorService` (#271), `VideoService` (#269), `CacheService` (#276), `util/CuisineTypes`, `util/JsonUtil`. +- I/O ↔ 순수 로직 경계: + - **I/O**: OCI GenAI 호출, Google Maps HTTP, DB 쓰기, transcript 호출. + - **순수 로직**: `EXTRACT_PROMPT` 합성, transcript 절단, `parseJson` 복구 로직, `parseRegionFromAddress`, `VectorService.buildChunks`, evaluation JSON 정규화. + +``` +┌─────────────┐ ┌──────────────────┐ +│ daemon/cron │ ─────▶ │ PipelineService │ +└─────────────┘ │ processVideo() │ + └────────┬─────────┘ + │ 1. transcript + ▼ + ┌──────────────────┐ + │ YouTubeService │ (#269) + └────────┬─────────┘ + │ 2. LLM extract + ▼ + ┌──────────────────┐ chat(prompt, 8192) + │ ExtractorService │───────────────────────▶ OCI GenAI + └────────┬─────────┘ parseJson(raw) (Chat model) + │ List> + ▼ + ┌──────────── PipelineService.processExtract ────────────┐ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌──────────────────┐ + │ GeocodingService │──HTTP──▶ Google Maps │ ExtractorService │ + │ placesTextSearch │ Places + Geocode │ (재사용 가능) │ + │ → placeDetails │ └──────────────────┘ + │ → geocode(폴백) │ + └────────┬─────────┘ + │ {lat,lng,formatted_address,phone,...} + ▼ + ┌──────────────────────────┐ + │ RestaurantService.upsert │──▶ Oracle restaurants + │ + linkVideoRestaurant │──▶ Oracle video_restaurants (IS JSON) + └────────┬─────────────────┘ + │ restId + ▼ + ┌──────────────────────────────┐ embedTexts(chunks) + │ VectorService.saveRestVectors│───────────────────────▶ OCI GenAI Embed + │ buildChunks(...) │ → Oracle restaurant_vectors + └────────┬─────────────────────┘ + │ count + ▼ + videoService.updateVideoFields(status=done, llmRaw) + │ + ▼ + cacheService.flush() +``` + +## 6. 데이터 모델 +- **입력 (LLM 프롬프트 출력 = 파이프라인 입력)**: + ```jsonc + [{ + "name": "string (필수)", + "address": "string|null", + "region": "나라|시/도|구/군 (string|null)", + "cuisine_type": "CuisineTypes.CUISINE_LIST_TEXT 중 하나", + "price_range": "string|null", + "foods_mentioned": ["string", ...] // 최대 10, 한글 + "evaluation": "string ≤ 100자", + "guests": ["string", ...] + }] + ``` +- **중간 데이터 (Geocoding 결과)**: + ```jsonc + { + "latitude": double, + "longitude": double, + "formatted_address": "string", + "google_place_id": "string", + "business_status": "OPERATIONAL|CLOSED_TEMPORARILY|...", + "rating": double, + "rating_count": int, + "phone": "string", + "website": "string" + } + ``` +- **저장 구조**: + - `restaurants`: name, address(=geo.formatted_address || LLM.address), region(LLM 우선), latitude/longitude, cuisine_type, price_range, google_place_id, phone, website, business_status, rating, rating_count. + - `video_restaurants(video_id, restaurant_id, foods_mentioned IS JSON, evaluation IS JSON, guests IS JSON)`. + - `restaurant_vectors(id, restaurant_id, chunk_text CLOB, embedding VECTOR)` — `VectorService.buildChunks` 결과(name/region/cuisine/foods/evaluation/price/video_title)를 한 chunk 로 임베딩. + - `videos.status, transcript_text CLOB, llm_response CLOB`. +- **검증 규칙**: + - `name`이 null 인 식당 항목은 skip. + - transcript > 8000 → 절단. + - evaluation: 객체→`JsonUtil.toJson`, 문자열→`JsonUtil.toJson(s)` (DB IS JSON 통과 보장). + - cuisine_type 표준 목록 위반은 저장은 허용하되 `remap-cuisine` SSE 로 사후 보정. + - `transcript_text` is blank → ExtractorService 호출 전 단건 API 가 400 반환 (#269). + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `ExtractorService.getPrompt` | 기본 프롬프트 반환 | `String getPrompt()` | — | EXTRACT_PROMPT | 없음 | 단순 | +| `ExtractorService.extractRestaurants` | LLM 추출 + JSON 정규화 | `ExtractionResult extractRestaurants(title, transcript, prompt?)` | title, transcript, prompt | `{restaurants: List, rawResponse}` | catch 후 빈 결과 + log | **복잡** | +| `PipelineService.processVideo` | 단건 영상 end-to-end | `int processVideo(Map)` | video map | 식당 수 | 예외 → status=error | **복잡** | +| `PipelineService.processExtract` | 기존 transcript 로 LLM+저장 | `int processExtract(video, transcript, prompt?)` | 동일 | 식당 수 | 부분 실패 catch (vector save) | **복잡** | +| `PipelineService.processPending` | N건 일괄 처리 | `int processPending(int limit)` | limit | 총 식당 수 | 빈 결과시 0 | 단순 | +| `PipelineService.updateVideoStatus` (private) | 상태/transcript/llm 갱신 | `void(...)` | id, status, ... | void | DB 예외 throw | 단순 | +| `OciGenAiService.init` (PostConstruct) | OCI 클라이언트 초기화 | `void init()` | — | void | 인증 실패 시 log warn | 단순 | +| `OciGenAiService.destroy` (PreDestroy) | 클라이언트 종료 | `void destroy()` | — | void | 없음 | 단순 | +| `OciGenAiService.chat` | LLM Chat 호출 | `String chat(prompt, maxTokens)` | prompt, max | text | SDK 예외, null client | **복잡** | +| `OciGenAiService.embedTexts` | 텍스트 임베딩 (96 배치) | `List> embedTexts(texts)` | texts | 임베딩 매트릭스 | SDK 예외 | **복잡** | +| `OciGenAiService.embedBatch` (private) | 단일 배치 임베딩 | `(texts)` | ≤96 texts | 임베딩 | SDK 예외 | 단순 | +| `OciGenAiService.parseJson` | LLM 응답 견고 파싱 | `Object parseJson(String raw)` | raw text | List/Map/scalar | 부분 복구 후 throw | **복잡** | +| `GeocodingService.geocodeRestaurant` | Places → Geocoding 폴백 | `Map geocodeRestaurant(name, address)` | name+addr | geo map\|null | 두 단계 모두 catch | **복잡** | +| `GeocodingService.placesTextSearch` (private) | Places Text Search | `(query)` | string | map\|null | 4xx/5xx catch | **복잡** | +| `GeocodingService.placeDetails` (private) | phone/website 보강 | `(placeId)` | string | map\|null | catch | 단순 | +| `GeocodingService.geocode` (private) | Geocoding API | `(query)` | string | map\|null | catch | 단순 | +| `GeocodingService.parseRegionFromAddress` (static) | 한국 주소 → region 코드 | `(address)` | string | `한국\|시\|구` 또는 null | 빈 입력 → null | 단순 | + +> 복잡 표시 함수: 모두 외부 I/O + 다단계 복구 경로. `parseJson` 과 `processExtract` 는 동작 다이어그램 별도 작성 권장. + +## 8. 흐름 / 알고리즘 +1. **transcript 확보 (단건 daemon 경로)**: `processVideo` → `updateVideoStatus(processing)` → `YouTubeService.getTranscript(videoId, "auto")`. null/blank → `done` 마킹 후 0 반환. +2. **transcript 컨텍스트 정리**: `ExtractorService.extractRestaurants` 입장에서 길이>8000 이면 `head(0,7000) + "...(중략)..." + tail(len-1000)` 으로 가운데를 잘라낸다. +3. **프롬프트 합성**: `customPrompt ?: EXTRACT_PROMPT` 에 `{title}`, `{transcript}` 단순 치환. `CUISINE_LIST_TEXT` 는 컴파일 타임에 포맷됨. +4. **LLM 호출**: `OciGenAiService.chat(prompt, 8192)` — `GenericChatRequest(temperature=0.0)` 으로 `UserMessage(TextContent)` 전송. 응답에서 `GenericChatResponse.choices[0].message.content[0].text` 추출 후 trim. +5. **JSON 복구**: `parseJson` 절차 + 1) ` ```(json)? ... ``` ` 제거. + 2) `, (?=[}\]])` 트레일링 콤마 제거. + 3) `mapper.readValue` 1차 시도. + 4) 실패 + `[`로 시작하면 인덱스 스캔으로 객체 단위 점진 파싱 → 최대한 많이 복구. 마지막에도 0건이면 `RuntimeException`. +6. **결과 정규화**: List → 그대로, Map → 단건 List 로 감쌈, 그 외 → 빈 List + raw 반환. +7. **식당 단위 후처리 (processExtract for-each)**: + a) `name == null` skip. + b) `geocodeRestaurant(name, address)` → Places Text Search (language=ko, type=restaurant) 1순위 결과 → `place/details` 로 phone/website 보강. 실패 시 Geocoding API 폴백, 그것도 실패 시 null. + c) `data` 빌드: geo 우선 (`formatted_address`, lat/lng, place_id, business_status, rating, rating_count, phone, website), 나머지는 LLM 값. + d) `RestaurantService.upsert(data)` → restId. + e) `evaluation` 정규화 (Map→JSON, String→JSON 리터럴) 후 `linkVideoRestaurant(videoDbId, restId, foods, evaluationJson, guests)`. + f) `VectorService.buildChunks(name, restData, videoTitle)` → 한 줄로 합쳐진 단일 chunk → `saveRestaurantVectors` (Embed batch, INSERT VECTOR). 실패 시 warn 만. + g) `count++` 로그. +8. **종료**: `updateVideoStatus(done, null, rawResponse)`. `processPending` 호출자는 총합>0 이면 `cache.flush()`. +9. **상태 전이**: `pending → processing(transcript 도착) → done` (LLM 결과 0/N) | `error` (예외) | `skip`(운영 수동, #269). +10. **Geocoding 한국 주소 region 파싱**: `parseRegionFromAddress` 가 토큰 단위로 "대한민국|특별/광역/도|구/군/시" 추출, 결과는 `한국|서울|강남구` 형식. 해외 식당은 LLM 이 직접 region 을 지정하므로 보조적 용도. + +## 9. 엣지케이스 & 에러 처리 +- **OCI 인증 미설정**: PostConstruct 가 warn 후 chatClient/embedClient null → 호출 시 `IllegalStateException` → `processExtract` 가 try/catch 없이 호출 스택을 상위(`processVideo`)로 전파, 상태 `error`. +- **LLM JSON 파싱 완전 실패**: `parseJson` throw → `extractRestaurants` catch → `ExtractionResult(empty, "")`. `processExtract` 는 0건 종결 + `done`. +- **트레일링 콤마/마크다운**: 자동 sanitize. +- **잘린 array (`maxTokens` 도달)**: 부분 복구 후 사용; 잘린 항목은 폐기. +- **식당 이름 누락**: skip (저장 안 함). +- **Geocoding 모두 실패**: 식당은 `latitude/longitude=null` 로 저장 → 지도 노출 제외, 검색은 가능. +- **evaluation 형식 다양성**: Map/String 모두 처리, null 그대로 통과. +- **transcript blank**: `processVideo` 가 self-check 후 `done` 반환 (recall=0 허용). +- **Vector 저장 실패**: warn 만 (식당은 이미 저장됨, 추후 `rebuild-vectors` SSE 로 복구 — #269). +- **OCI Embed 96 한도**: 자동 분할 호출. +- **Google API rate limit (`OVER_QUERY_LIMIT`)**: status != OK 면 null 반환 → 좌표 없이 저장. +- **place details 실패**: phone/website 누락만, 좌표는 유지. +- **temperature=0.0** 이지만 모델 비결정성 일부 잔존 → 같은 영상 재실행 시 결과 약간 다를 수 있음 (멱등 보장은 `upsert` 키 = google_place_id/name+address 조합에 의존, #268). +- **안전 기본값**: 외부 I/O 실패 시 전 항목 폐기 대신 부분 저장 (좌표 없는 식당이라도 유지) — 운영자가 어드민에서 수동 보정 가능. + +## 10. 테스트 계획 +- **단위 — ExtractorService** + - transcript 절단 임계 (7999 → 그대로 / 8001 → head+중략+tail). + - LLM 응답 케이스: 정상 array / 단일 object / `[]` / 깨진 JSON → 각각 정상 List / 단건 List / 빈 List / 빈 List + log. +- **단위 — OciGenAiService.parseJson** + - ` ```json [...] ``` `, 트레일링 콤마, 잘린 array, 완전 비-JSON → 시나리오별 검증. +- **단위 — GeocodingService** + - Places OK + details OK → 전 필드 채움. + - Places ZERO_RESULTS → Geocoding 폴백 호출 검증 (WireMock). + - 둘 다 실패 → null. + - `parseRegionFromAddress`: 서울특별시/경기도/광역시/특별자치시/외국 주소 → 각각 기대 region 또는 null. +- **단위 — PipelineService.processExtract** + - 식당 N개 mock 추출 → upsert N회, linkVideoRestaurant N회, saveRestaurantVectors N회 호출 검증. + - vector save 예외 → 식당은 저장, warn 로그. + - name=null 항목은 skip (count 미증가). +- **통합 (Spring + WireMock)**: Google Maps 모킹 + OCI mock → `processPending(3)` 실행 후 videos.status, video_restaurants, restaurant_vectors 행수 검증. +- **드라이런**: prod 호출 비용 차단을 위해 `app.oci.*` 미설정 시 chat/embed 가 즉시 throw → `processVideo` 가 status=`error` 마킹하고 다음 영상으로 진행 (`processPending` 루프). +- **인수조건 매핑**: AC1↔ExtractorService 단위, AC2↔processExtract 통합, AC3↔parseJson 단위, AC4↔GeocodingService 단위, AC5↔evaluation 정규화 단위, AC6↔processVideo 단위. + +## 11. 리스크 & 대안 검토 +- **OCI GenAI 단일 벤더 잠금**: 대안 OpenAI/Anthropic. 트레이드오프: OCI 는 동일 테넌시 내 IAM 통합/내한권 결제. → **ADR 후보** (`adr/0002-llm-provider.md`). +- **transcript 절단 (선택)**: 8000자 hard cut. 대안: 청크 + map-reduce 요약 (지연/비용↑) 또는 더 큰 context 모델. 현재 영상 평균 < 8000자라 단순 cut 채택. +- **Geocoding Places vs Geocoding 폴백 순서**: Places 가 phone/rating 까지 주므로 1순위. 대안: 카카오/네이버 로컬 API (한국 정확도↑) — 향후 옵션. +- **벡터 chunk 1개/식당**: 검색 정확도 vs 비용 트레이드오프. 대안: 메뉴별 분할 chunk → 임베딩 수 N배, FETCH 시 중복 제거 필요. 현재 토픽이 좁아 단일 chunk 유지. +- **temperature=0.0**: 재현성↑. 대안: 약간 ↑ 시 다양한 메뉴 추출 가능 — 일관성 우선. +- **evaluation JSON 강제**: DB CHECK 제약을 만족시키는 가장 단순한 방법 (JSON 리터럴 wrap). 향후 정형화(`{summary, rating, ...}`) 이전 가능. +- **부분 실패 허용**: 식당 일부만 저장되는 시나리오 수용 → 운영자 검토 비용. 대안: 전부 임시 영역 → 검토 후 swap (구현 복잡). + +## 12. 미해결 질문 (Open Questions) +- `cuisine_type` 표준 목록 위반 비율이 얼마나 되는가? 사전 검증(`CuisineTypes.isValid`) 후 자동 폴백을 LLM 단계에서 적용할지. +- transcript 8000자 cut 대신 슬라이딩 윈도우 multi-pass 요약 도입 여부 (비용/정확도 검토). +- Geocoding 결과 중 `business_status=CLOSED_*` 인 식당의 처리 정책 (자동 제외 vs 표시). +- 영상에 동일 식당이 중복 언급될 때 upsert 키와 link 중복 방지 (현재 `RestaurantService.upsert` 키 정책에 의존). +- Embed cosine 임계(`maxDistance`)는 #271 에서 0.57 — 학습 데이터 누적 후 재조정 필요. +- 다국어 영상 (예: 일본 식당) 의 region 파싱 강건성 (현재 한국 주소 패턴 위주). diff --git a/docs/design/271-backend-search/README.md b/docs/design/271-backend-search/README.md new file mode 100644 index 0000000..5abdc11 --- /dev/null +++ b/docs/design/271-backend-search/README.md @@ -0,0 +1,201 @@ + + +# 설계서: 백엔드 - 검색/벡터 추천 (#271) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #271 · 관련 ADR: 없음 +> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/SearchService.java`, `backend-java/src/main/java/com/tasteby/service/VectorService.java`, `backend-java/src/main/java/com/tasteby/controller/SearchController.java` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +사용자가 식당명/메뉴/지역 키워드로 빠르게 후보를 찾고, 의미 기반 추천(예: "혼술하기 좋은 이자카야")도 받을 수 있도록 키워드 SQL + Oracle 23ai VECTOR 코사인 거리 검색을 동일 엔드포인트로 제공한다. + +## 2. 범위 (Scope) +- **포함**: + - `GET /api/search?q=&mode=&limit=` 단일 엔드포인트. + - 모드: `keyword`(기본, LIKE), `semantic`(벡터), `hybrid`(키워드 + 벡터 union). + - 결과 캐싱 (`CacheService`, key = `search:q=..:m=..:l=..`). + - 채널명 부착(`attachChannels`) — 검색 결과에 어떤 채널들이 다뤘는지 표시. + - 벡터 인덱스 운영용 API: 추출 파이프라인에서 호출되는 `saveRestaurantVectors`, 검색용 `searchSimilar`. +- **제외 (out of scope)**: + - 식당 상세/지도 노출 — #268/#278. + - 벡터 재생성 SSE (`rebuild-vectors`) — #269 (TODO). + - 사용자별 개인화 추천/로그. + - 채널 마스터 데이터 — #273. + - 임베딩 모델 학습/튜닝. + +## 3. 인수조건 (Acceptance Criteria) +- [ ] `GET /api/search?q=족발&mode=keyword&limit=20` 이 `restaurants.name/foods_mentioned/...` 에 `%족발%` LIKE 매칭된 식당을 최대 limit(상한 100)개 반환하고, 각 결과의 `channels` 배열에 출연 채널명이 채워진다. +- [ ] `mode=semantic` 호출 시 OCI Embed 로 쿼리 임베딩 → `VECTOR_DISTANCE(... COSINE)` 로 `maxDistance ≤ 0.57` 인 chunk 상위 `max(30, limit*3)` 개를 가져와, restaurant_id 중복 제거 후 좌표 있는 식당만 limit 개 반환한다. +- [ ] `mode=hybrid` 는 키워드 결과 우선 + 의미 결과를 뒤에 union 하며, 동일 식당 중복 제거 후 limit 로 컷한다. +- [ ] 동일 (q, mode, limit) 두 번째 호출은 Redis 캐시에서 즉시 반환 (DB/OCI 호출 0회). +- [ ] semantic 호출 중 OCI 실패 시 keyword 결과로 자동 폴백하며 500 을 던지지 않는다. +- [ ] `VectorService.saveRestaurantVectors` 가 chunks 리스트를 96개 단위 배치 임베딩 후 한 INSERT/chunk 로 Oracle VECTOR 컬럼에 저장한다. + +## 4. 컨텍스트 & 제약 +- **의존성**: + - Oracle 23ai VECTOR 타입 + `VECTOR_DISTANCE(..., COSINE)` 함수. + - `OciGenAiService.embedTexts` (Cohere/embed-v4 등, 96 배치). + - `RestaurantService.findById` — semantic 결과 1차 행 조회. + - `CacheService` (Redis) — 직렬화는 Jackson ObjectMapper(local 인스턴스). + - `SearchMapper.keywordSearch`, `SearchMapper.findChannelsByRestaurantIds`. +- **제약**: + - `limit ≤ 100` (Controller 가드). + - 임베딩 비용 → 동일 쿼리 캐시 hit 시 0 호출, miss 시 1 호출. + - VECTOR 컬럼 바인딩은 `NamedParameterJdbcTemplate + float[]` 직접 바인딩 (MyBatis 미지원이라 JDBC 사용). + - hybrid mode 는 union 후 limit 만 적용 — 가중치 랭킹은 미구현 (단순 keyword 우선). +- **가정**: + - 검색 빈도는 식당 추출보다 훨씬 잦지만 임베딩 호출은 캐시로 대부분 흡수된다. + - 식당 1건당 vector chunk 1개 (`VectorService.buildChunks`) — 좌표 없는 식당은 semantic 결과에서 자동 제거. + - cosine distance 임계 0.57 은 운영 관측치 기반 (조정 가능). + +## 5. 아키텍처 개요 +- 모듈/파일: + - `controller/SearchController.java` — REST 엔드포인트, limit clamp. + - `service/SearchService.java` — 모드 분기, 캐시, 채널 부착. + - `service/VectorService.java` — 임베딩 + Oracle VECTOR 검색/저장 (JDBC). + - `mapper/SearchMapper.java` (+ XML) — `keywordSearch`, `findChannelsByRestaurantIds`. + - 협력: `OciGenAiService` (#270), `RestaurantService` (#268), `CacheService` (#276). +- I/O ↔ 순수 로직 경계: + - **I/O**: Oracle (LIKE + VECTOR), Redis 캐시, OCI Embed. + - **순수 로직**: 모드 분기, 중복 제거(LinkedHashSet), keyword 우선 union, `buildChunks` 텍스트 합성, 좌표 필터(`r.getLatitude() != null`). + +``` + ┌────────────────────────┐ +GET /api/search?q=&mode=&limit= │ SearchController │ +────────────────────────────────▶ search(q, mode, limit) │ + │ limit = min(limit,100) │ + └──────────┬─────────────┘ + │ + ▼ + ┌──────────────────────────────────┐ + │ SearchService.search │ + │ cache.get("search:q=..:m=..") │ hit ──▶ return List + └──────────┬───────────────────────┘ miss + ┌────────────┬─────────┴────────────┬─────────────┐ + ▼ ▼ ▼ ▼ + keywordSearch semanticSearch hybrid: cache.set + │ │ kw + sem union │ + ▼ ▼ │ + SearchMapper VectorService.searchSimilar │ + LIKE %q% ├── OciGenAiService.embedTexts(query) │ + attachChannels │ → float[] qvec │ + ├── jdbc.query VECTOR_DISTANCE(... COSINE) │ + │ ≤0.57, ORDER BY dist FETCH FIRST k │ + └── RestaurantService.findById(rid) [coord!=null] + │ + ▼ + ◀────────────── Response +``` + +## 6. 데이터 모델 +- **입력**: + - 쿼리 파라미터: `q: string (필수)`, `mode ∈ {keyword, semantic, hybrid}`(기본 keyword), `limit: int (기본 20, 상한 100)`. +- **출력**: `List` — id, name, address, region, latitude, longitude, cuisineType, priceRange, phone, website, googlePlaceId, businessStatus, rating, ratingCount, updatedAt, `channels: string[]` (검색 결과에만 채움), `foodsMentioned` (옵션). +- **벡터 인덱스 저장 (`restaurant_vectors`)**: + - `id` 32자 UUID(hex upper), `restaurant_id` FK, `chunk_text` CLOB, `embedding` VECTOR. + - chunk 본문 예시: + ``` + 식당: + 지역: + 음식 종류: + 메뉴: a, b, c + 평가: + 가격대: + 영상: + ``` +- **캐시 키**: `search:q=:m=:l=` (`CacheService.makeKey`). 값은 `List` Jackson JSON. +- **검증 규칙**: + - `q` 가 비어 있으면 Controller 가드(현재 미존재) — 빈 문자열은 `%%` LIKE 로 모든 식당 매칭되므로 클라이언트에서 빈 쿼리 차단 권장. (Open Question 참조) + - limit > 100 → 100 으로 clamp. + - semantic 결과는 `latitude != null` 인 식당만 — 지도 노출 가능한 후보로 한정. + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `SearchController.search` | REST 엔드포인트 + limit clamp | `List search(q, mode, limit)` | q, mode, limit | List | 없음 | 단순 | +| `SearchService.search` | 모드 분기 + 캐싱 | `List search(q, mode, limit)` | 동일 | 동일 | 캐시 직렬화 catch | **복잡** | +| `SearchService.keywordSearch` (private) | LIKE 검색 + 채널 부착 | `(q, limit)` | %q% pattern | List | 빈 결과 빈 list | 단순 | +| `SearchService.semanticSearch` (private) | 벡터 검색 + 식당 조회 | `(q, limit)` | q, limit | List | catch → keyword 폴백 | **복잡** | +| `SearchService.attachChannels` (private) | restaurant_id ↔ 채널명 부착 | `(restaurants)` | List | void (mutate) | 매핑 누락 시 빈 list | 단순 | +| `VectorService.searchSimilar` | OCI embed + Oracle VECTOR 질의 | `(query, topK, maxDistance)` | text, k, dist | `List` (restaurant_id, chunk_text, distance) | embed 실패 throw | **복잡** | +| `VectorService.saveRestaurantVectors` | chunk 배열 임베딩 + INSERT | `(restaurantId, chunks)` | rid, chunks | void | chunk 별 update 예외 throw | **복잡** | +| `VectorService.buildChunks` (static) | 식당 데이터 → 임베딩 텍스트 | `(name, data, videoTitle)` | name + Map + title | `List`(1) | 없음 | 단순 | + +> 복잡 표시 함수는 외부 I/O + 폴백 또는 비-MyBatis 경로(JDBC + VECTOR 바인딩). 별도 `fn-*.md` 우선순위: `searchSimilar`, `semanticSearch`. + +## 8. 흐름 / 알고리즘 +1. **요청 진입**: Controller 가 `limit > 100` 이면 100 으로 clamp → `SearchService.search(q, mode, limit)` 호출. +2. **캐시 조회**: key=`search:q=:m=:l=` → `cache.getRaw` hit 시 Jackson 역직렬화 후 즉시 반환. 직렬화 예외는 무시하고 본 로직 진행. +3. **모드 분기**: + - `keyword` (default): `SearchMapper.keywordSearch("%q%", limit)` → 결과에 `attachChannels` 적용. + - `semantic`: `VectorService.searchSimilar(q, max(30, limit*3), 0.57)` → 결과의 `restaurant_id` 를 `LinkedHashSet` 으로 중복 제거 → `restaurantService.findById(rid)` 로 행 조회, `latitude != null` 인 것만 limit 개까지 누적. + - `hybrid`: keyword 결과 + semantic 결과를 순서대로 union (`HashSet seen` 으로 중복 제거), limit 초과시 subList(0, limit). (현재 채널 부착은 keyword 결과에만 적용됨.) +4. **벡터 검색 내부 (`searchSimilar`)**: + a) `OciGenAiService.embedTexts([query])` → `List>` → 첫 임베딩을 `float[]` 로 변환. + b) SQL: + ```sql + SELECT rv.restaurant_id, rv.chunk_text, + VECTOR_DISTANCE(rv.embedding, :qvec, COSINE) AS dist + FROM restaurant_vectors rv + WHERE VECTOR_DISTANCE(rv.embedding, :qvec2, COSINE) <= :max_dist + ORDER BY dist + FETCH FIRST :k ROWS ONLY + ``` + `:qvec`/`:qvec2` 동일 배열 두 번 바인딩 (SELECT 와 WHERE 에 각각 사용). + c) RowMapper: `RESTAURANT_ID`, `CHUNK_TEXT`(CLOB → `JsonUtil.readClob`), `DIST`. +5. **캐시 저장**: 최종 결과를 `cache.set(key, result)` (Jackson JSON 직렬화, CacheService 가 TTL 관리 — #276). +6. **벡터 저장 (`saveRestaurantVectors`)**: chunks 빈 리스트면 즉시 종료. `embedTexts(chunks)` 후 chunks.size 만큼 반복하며 각 row 에 새 UUID + 변환된 float[] + chunk_text 를 `INSERT` (단건 update). 예외는 호출자(`PipelineService`) 에서 warn 처리. +7. **채널 부착**: `SearchMapper.findChannelsByRestaurantIds(ids)` → row 의 `restaurant_id`(소문자 또는 `RESTAURANT_ID` 대문자) 두 키 모두 지원 → `Map>` 구성 → 각 Restaurant 의 `channels` 필드 set (없으면 빈 리스트). + +## 9. 엣지케이스 & 에러 처리 +- **빈 쿼리**: `q=""` → keyword 는 모든 식당 매칭(LIKE `%%`), semantic 은 의미 약함. 현재 Controller 가드 없음 → Open Questions 참조. +- **특수문자/와일드카드**: q 에 `%`/`_` 가 있으면 LIKE 부작용. 현재 escape 미적용 (Open Question). +- **OCI Embed 미설정**: `IllegalStateException` → `semanticSearch` catch → keyword 폴백, 사용자에겐 200 응답. +- **임베딩 빈 결과**: `searchSimilar` 가 빈 list 반환 → semantic 결과 0 → `keywordSearch` 폴백은 발생하지 않음 (현재 코드: semantic 0이면 빈 결과 반환 가능). hybrid 모드는 키워드 결과로 채워짐. +- **좌표 없는 식당**: semantic 결과에서 자동 제외 (지도 무관 검색 시 누락 가능 — 의도). +- **캐시 직렬화 실패**: getRaw 후 `mapper.readValue` 예외는 무시 후 DB 재조회. +- **VECTOR 거리 임계 미달**: `WHERE dist <= 0.57` 로 0건 가능 → 빈 list. 임계 낮추거나 키워드 사용 권장. +- **채널 매핑 row 키 대소문자 차이**: row.getOrDefault 로 두 케이스 모두 처리 — Oracle 컬럼 대문자/lowerKeys 미적용 시 대비. +- **CLOB chunk_text**: `JsonUtil.readClob` 으로 안전 변환. +- **DB 연결 실패**: `jdbc.query` 예외 → SearchService 의 `semanticSearch` catch → keyword 폴백. +- **부분 결과**: hybrid 에서 keyword 만 결과가 있고 semantic 이 throw 시, keyword 결과만 반환되도록 catch 위치는 `semanticSearch` 내부 → 그대로 빈 리스트가 hybrid union 의 절반으로 사용됨 (장애 격리). +- **안전 기본값**: 모든 외부 실패는 keyword 결과 또는 빈 list 로 수렴; 500 응답을 피한다. + +## 10. 테스트 계획 +- **단위 — SearchService** + - 캐시 hit → mapper/vector 미호출, 결과 동일. + - 캐시 miss + keyword 모드 → `keywordSearch` 1회, `attachChannels` 호출. + - semantic 모드 → vector 결과 K*3, dedup, 좌표 없는 식당 제외 검증. + - hybrid 중복 제거 순서 (kw 우선) 검증. + - vector 예외 → keyword 폴백. +- **단위 — VectorService** + - `buildChunks` 입력 누락 필드 (region/cuisine/foods 등) 가 출력에서 자연스럽게 생략. + - `saveRestaurantVectors` empty chunks → no-op. +- **통합 (Spring + Testcontainers Oracle 또는 in-memory mock)** + - `restaurant_vectors` 에 샘플 데이터 삽입 → `searchSimilar("족발", 10, 0.57)` 거리 정렬 검증. + - `keywordSearch` LIKE 매칭 + 채널 부착. +- **계약 테스트** + - `GET /api/search?q=&limit=200` → limit clamp 100. + - mode 오타 → default(keyword). +- **드라이런/모킹**: OCI Embed → 고정 vector 반환 stub. Oracle VECTOR 함수 모킹 어려움 → Testcontainers 23ai (free profile) 사용 권장. +- **인수조건 매핑**: AC1↔keyword 통합, AC2↔searchSimilar 통합, AC3↔hybrid 단위, AC4↔캐시 단위, AC5↔폴백 단위, AC6↔saveRestaurantVectors 통합. + +## 11. 리스크 & 대안 검토 +- **Oracle 23ai VECTOR (선택)**: DB 내장이라 별도 인프라 불필요. 대안: pgvector, Pinecone, Qdrant — 운영 부담↑. → 트레이드오프: 23ai 라이선스/리전 의존. +- **NamedParameterJdbcTemplate 직접 사용**: MyBatis 가 VECTOR 직렬화 미지원 → JDBC 가 가장 단순. 대안: TypeHandler 작성 (구현 비용). 현 단계에서 단일 메서드라 직접 JDBC 유지. +- **단일 chunk/식당**: 비용 절감 + 단순. 대안: 메뉴/리뷰/장르 분리 chunk → recall↑, 비용/저장↑. 후속 ADR 후보. +- **hybrid union 단순 합치기**: 가중치 랭킹 부재 → semantic 일치 식당이 뒤에 묻힘. 대안: RRF (Reciprocal Rank Fusion) 또는 distance/score 정규화 후 정렬. +- **maxDistance=0.57 하드코딩**: 운영 관측치 변동 시 코드 수정 필요. 대안: 환경변수/설정으로 빼기. +- **캐시 무효화**: 식당/링크 변경 시 `CacheService.flush()` 전체 플러시 (현재 정책) → 콜드스타트 비용. 대안: 키 prefix 별 무효화. +- **빈 쿼리 가드 부재**: 운영 사고 시 모든 식당 반환 → 응답 크기 폭발. 트레이드오프: 가드 추가 (저비용). + +## 12. 미해결 질문 (Open Questions) +- 빈 쿼리/공백 쿼리는 400 반환할지, 인기 식당 fallback 으로 응답할지. +- LIKE 와일드카드(`%`/`_`) escape 정책. +- hybrid 모드 랭킹 알고리즘 (RRF 도입 여부, semantic 가중치). +- semantic 모드에서 좌표 없는 식당도 노출할지 (검색 결과 vs 지도 마커 분리). +- 임계 `maxDistance=0.57` 의 모니터링/튜닝 방법 (사용자 클릭률 로그 필요). +- `restaurant_vectors` 중복(같은 식당 여러 chunk) 정책 — 현재 `saveRestaurantVectors` 가 추가만 함, 재추출 시 누적될 가능성. +- 검색 결과에 `foods_mentioned`/거리/평점 동시 노출 방식 (#278 와 합의 필요). diff --git a/docs/design/272-backend-review-memo/README.md b/docs/design/272-backend-review-memo/README.md new file mode 100644 index 0000000..417a950 --- /dev/null +++ b/docs/design/272-backend-review-memo/README.md @@ -0,0 +1,153 @@ + + +# 설계서: 백엔드 - 리뷰/메모 (#272) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #272 · 관련 ADR: 없음 +> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/ReviewService.java`, `backend-java/src/main/java/com/tasteby/service/MemoService.java`, `backend-java/src/main/java/com/tasteby/controller/ReviewController.java`, `backend-java/src/main/java/com/tasteby/controller/MemoController.java` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +사용자가 식당에 대한 공개 리뷰(평점/방문일/텍스트)와 개인 메모(비공개 평점/기록)를 남기고, 즐겨찾기로 관심 식당을 관리하도록 한다. 식당 상세 페이지의 사회적 신뢰도(평균 평점, 리뷰 수) 및 마이페이지의 개인화 콘텐츠 핵심을 제공한다. + +## 2. 범위 (Scope) +- **포함**: + - 식당별 리뷰 목록/평균 평점/리뷰 수 조회 + - 리뷰 생성/수정/삭제 (본인 글만) + - 식당별 개인 메모 단건 조회/upsert/삭제 (사용자×식당 유니크) + - 즐겨찾기 토글/상태 조회/내 즐겨찾기 목록 + - 내 리뷰/내 메모 목록 +- **제외 (out of scope)**: + - 리뷰 이미지 첨부, 좋아요/신고 등 사회적 상호작용 + - 리뷰 기반 추천/랭킹 로직 + - 댓글, 대댓글 + - 메모 공개 전환 (private only) + +## 3. 인수조건 (Acceptance Criteria) +- [x] `GET /api/restaurants/{id}/reviews?limit&offset` 호출 시 `reviews[]` + `avg_rating` + `review_count` 동시 반환 +- [x] 인증된 사용자만 `POST /api/restaurants/{id}/reviews`로 리뷰 작성 가능, 응답은 HTTP 201 + 생성된 Review +- [x] 작성자 본인이 아닌 `PUT/DELETE /api/reviews/{id}` 시도 시 HTTP 404 ("Review not found or not yours") +- [x] `POST /api/restaurants/{id}/memo` 동일 (user_id, restaurant_id) 재호출 시 INSERT가 아닌 UPDATE (upsert), 단건 보장 +- [x] `POST /api/restaurants/{id}/favorite` 호출 시 기존 레코드 존재 → 삭제(false), 미존재 → 삽입(true) 토글 동작 + +## 4. 컨텍스트 & 제약 +- **DB**: Oracle 23ai. 테이블 `reviews`, `memos`, `favorites`. ID는 32자 UUID(`IdGenerator.newId()`). +- **MyBatis**: `ReviewMapper`, `MemoMapper` XML (`src/main/resources/mybatis/mapper/`). resultMap으로 UPPERCASE 컬럼 → camelCase 매핑. +- **권한**: 모든 쓰기 엔드포인트는 `AuthUtil.getUserId()`로 인증된 사용자 필요 (Spring Security 필터). 관리자 권한은 불필요. +- **트랜잭션**: `create`, `upsert`, `toggleFavorite`는 `@Transactional` 명시. +- **유니크 제약**: `memos`는 `(user_id, restaurant_id)` 유니크. `favorites`도 동일하게 1쌍 1행. +- **반환 포맷**: Jackson SNAKE_CASE (`review_text`, `visited_at`, `avg_rating`, `review_count`). +- **가정**: `restaurants.id`는 사전에 존재 (FK 참조). `visited_at`은 ISO-8601 (`YYYY-MM-DD`) 문자열. + +## 5. 아키텍처 개요 +- 모듈/파일 구조: + - `controller/ReviewController.java` (REST 엔드포인트, 8개) + - `controller/MemoController.java` (REST 엔드포인트, 4개) + - `service/ReviewService.java` (리뷰 + 즐겨찾기 비즈니스 로직) + - `service/MemoService.java` (메모 upsert 로직) + - `mapper/ReviewMapper.java` + XML, `mapper/MemoMapper.java` + XML + - `domain/Review.java`, `domain/Memo.java` + - `security/AuthUtil.java` (사용자 ID 추출) +- I/O ↔ 순수 로직 경계: Controller는 입력 파싱 + 인증, Service는 트랜잭션·도메인 규칙, Mapper는 SQL I/O. + +``` +[Client] + │ HTTP (JSON) + ▼ +[ReviewController | MemoController] ← AuthUtil.getUserId() + │ DTO/Map 파싱, LocalDate 변환 + ▼ +[ReviewService | MemoService] ← @Transactional, upsert/토글 분기 + │ IdGenerator.newId(), JsonUtil.lowerKeys() + ▼ +[ReviewMapper | MemoMapper] (MyBatis XML) + │ SQL + ▼ +[Oracle 23ai: reviews / memos / favorites] +``` + +## 6. 데이터 모델 +- **Review** (`domain/Review.java`): `id`, `userId`, `restaurantId`, `rating(double)`, `reviewText`, `visitedAt(String)`, `createdAt`, `updatedAt`, `userNickname`, `userAvatarUrl`, `restaurantName`. +- **Memo** (`domain/Memo.java`): `id`, `userId`, `restaurantId`, `rating(Double, nullable)`, `memoText`, `visitedAt(String)`, `createdAt`, `updatedAt`, `restaurantName`. +- **avg_rating 응답** (`Map`): `{ avg_rating: double, review_count: int }` — null 시 기본값 `{0.0, 0}`. +- **favorite 응답**: `{ favorited: boolean }`. +- **경계 검증**: + - `rating`: 0.0 ~ 5.0 권장 (DB CHECK 권장, 현 구현은 검증 없음 — 향후 ADR 검토). + - `reviewText` / `memoText`: 길이 제한은 DB 컬럼 길이에 위임 (현재 명시적 검증 없음). + - `visitedAt`: `LocalDate.parse` 실패 시 `DateTimeParseException` 전파 → 400. + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `ReviewService.findByRestaurant` | 식당별 리뷰 페이지 조회 | `List(restaurantId, limit, offset)` | 식당ID, 페이지 | List | DB 오류 → 전파 | 단순 | +| `ReviewService.getAvgRating` | 평균 평점/리뷰 수 집계 | `Map(restaurantId)` | 식당ID | `{avg_rating, review_count}` | null 시 기본값 | 단순 | +| `ReviewService.create` | 신규 리뷰 작성 | `Review(userId, restaurantId, rating, text, visitedAt)` | 사용자/식당/평점 | 생성된 Review | DB 제약 위반 → 전파 | 단순 | +| `ReviewService.update` | 본인 리뷰 수정 | `boolean(reviewId, userId, rating?, text?, visitedAt?)` | ID + 부분 필드 | 성공 여부 | 0행 → false | 단순 | +| `ReviewService.delete` | 본인 리뷰 삭제 | `boolean(reviewId, userId)` | ID, 사용자 | 성공 여부 | 0행 → false | 단순 | +| `ReviewService.findByUser` | 내 리뷰 목록 | `List(userId, limit, offset)` | 사용자, 페이지 | List | DB 오류 → 전파 | 단순 | +| `ReviewService.isFavorited` | 즐겨찾기 여부 | `boolean(userId, restaurantId)` | 사용자/식당 | true/false | DB 오류 → 전파 | 단순 | +| `ReviewService.toggleFavorite` | 즐겨찾기 토글 | `boolean(userId, restaurantId)` | 사용자/식당 | 토글 후 상태 | 동시성 시 유니크 충돌 가능 | **복잡** | +| `ReviewService.getUserFavorites` | 내 즐겨찾기 식당 목록 | `List(userId)` | 사용자 | List | DB 오류 → 전파 | 단순 | +| `MemoService.findByUserAndRestaurant` | 메모 단건 조회 | `Memo(userId, restaurantId)` | 사용자/식당 | Memo or null | 없음 | 단순 | +| `MemoService.upsert` | 메모 신규/갱신 | `Memo(userId, restaurantId, rating?, text, visitedAt?)` | 사용자/식당/내용 | 저장된 Memo | 동시성 시 유니크 충돌 가능 | **복잡** | +| `MemoService.delete` | 메모 삭제 | `boolean(userId, restaurantId)` | 사용자/식당 | 성공 여부 | 0행 → false | 단순 | +| `MemoService.findByUser` | 내 메모 목록 | `List(userId)` | 사용자 | List | DB 오류 → 전파 | 단순 | +| `ReviewController.*` | REST 어댑팅 | `@RestController` | HTTP | JSON | 401/404/400 | 단순 | +| `MemoController.*` | REST 어댑팅 | `@RestController` | HTTP | JSON | 401/404/400 | 단순 | + +> 복잡 표시된 `toggleFavorite`, `upsert`는 분기 + 동시성 + 트랜잭션 경계 존재. 별도 fn 설계서 권장. + +## 8. 흐름 / 알고리즘 +1. **리뷰 작성**: AuthUtil.getUserId() → IdGenerator.newId() → INSERT → findById로 재조회하여 반환. +2. **평균 평점 조회**: `mapper.getAvgRating` → null 체크 → `JsonUtil.lowerKeys()`로 키 소문자화 → 응답 머지. +3. **메모 upsert**: + - 사전 SELECT (user_id, restaurant_id) → + - 존재하면 UPDATE, 미존재하면 INSERT (IdGenerator로 새 ID) → + - 최종 SELECT 후 반환. +4. **즐겨찾기 토글**: + - `findFavoriteId(userId, restaurantId)` → + - 존재하면 DELETE → false 반환, 미존재하면 INSERT → true 반환. +5. **권한 검증 (수정/삭제)**: WHERE 절에 `user_id = ?`를 함께 포함하여 본인 행만 영향. 영향행 0이면 NOT_FOUND (의도된 모호화: 권한/존재 동시 처리). + +## 9. 엣지케이스 & 에러 처리 +- **타인 리뷰 수정/삭제 시도**: WHERE 사용자 ID 불일치 → 0행 영향 → HTTP 404. 권한 누설 방지. +- **존재하지 않는 식당 ID로 리뷰 작성**: FK 제약 위반 → SQLException → 500 (현재 별도 매핑 없음, 향후 400 매핑 검토). +- **rating 음수/범위 초과**: 현재 미검증, DB에 그대로 저장. → Bean Validation 추가 권장. +- **메모 동시 upsert 경합**: 양쪽 트랜잭션이 SELECT에서 미존재 판정 → 둘 다 INSERT → 유니크 제약 위반. → 한쪽 500 전파. +- **즐겨찾기 동시 토글**: 동일 패턴, 유니크 충돌 가능. 트랜잭션 격리 SERIALIZABLE 또는 unique upsert 재시도 권장. +- **빈 텍스트/null 리뷰**: 현재 허용. 공백 정규화 미적용. +- **visited_at 파싱 실패**: `DateTimeParseException` → Spring 기본 400 응답. +- **인증 누락**: `AuthUtil.getUserId()`가 401 throw (필터 단계에서 차단 가정). + +## 10. 테스트 계획 +- **단위** + - `ReviewService.toggleFavorite` 기존 존재/미존재 분기 (Mapper 모킹) + - `ReviewService.getAvgRating` null 반환 시 기본값 처리 + - `MemoService.upsert` 신규 INSERT vs UPDATE 분기 + - `ReviewService.update/delete` 0행 시 false 반환 +- **통합 (MyBatis + Testcontainers Oracle 또는 H2 Oracle mode)** + - 리뷰 작성 → 평균 평점이 (기존 평균 × N + 새 평점)/(N+1) 일치 + - 메모 동일 (user, restaurant) 재요청 시 행 수 1 유지, 내용만 갱신 + - 즐겨찾기 토글 두 번 호출 → 원상 복귀 (행 수 0) + - 타 사용자 ID로 update 시 404 +- **API**: MockMvc로 권한/페이지네이션/응답 키(snake_case) 검증. +- 현재 테스트 디렉토리 없음 → TBD. + +## 11. 리스크 & 대안 검토 +- **선택**: upsert/토글을 애플리케이션 레벨 SELECT → IF로 분기. + - 대안 A: Oracle `MERGE` 문 단일 SQL → 동시성 안전. + - 대안 B: 유니크 충돌 시 재시도 루프 → 코드 복잡도. + - 트레이드오프: 현재 방식은 명확하지만 경합 시 500. 다중 사용자 동시성이 낮은 현 단계에서 수용 가능. 트래픽 증가 시 MERGE 전환 ADR 후보. +- **권한 검증**: WHERE 절에 user_id 포함 vs 사전 SELECT 검증. + - 현재(WHERE 포함)는 1쿼리로 처리 + 권한/미존재 모호화. 단점: 감사 로그용 구분 어려움. +- **rating 검증 부재**: Bean Validation (`@Min(0) @Max(5)`) 도입 권장 — 별도 작업 분리. +- **N+1 가능성**: 리뷰 목록에서 `user_nickname/avatar_url`을 join으로 fetch (XML 조인 가정). 다국어/대량 사용자 시 캐시 검토. + +## 12. 미해결 질문 (Open Questions) +- 리뷰 작성 시 평점 범위 검증을 서비스 레벨로 끌어올릴지, DB CHECK 제약으로 위임할지? +- 리뷰 이미지/사진 첨부 도입 시 별도 테이블 + 스토리지 정책 (#TBD). +- 같은 사용자가 한 식당에 리뷰를 여러 개 작성 가능? (현재 무제한) 정책 결정 필요. +- 즐겨찾기/메모를 단일 "내 식당" 개념으로 통합할지, 분리 유지할지? +- 리뷰 신고/모더레이션 워크플로 도입 시 status 컬럼 + 관리자 UI 필요. diff --git a/docs/design/273-backend-channel/README.md b/docs/design/273-backend-channel/README.md new file mode 100644 index 0000000..4a71dd5 --- /dev/null +++ b/docs/design/273-backend-channel/README.md @@ -0,0 +1,170 @@ + + +# 설계서: 백엔드 - 채널 관리 (#273) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #273 · 관련 ADR: 없음 +> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/ChannelService.java`, `backend-java/src/main/java/com/tasteby/controller/ChannelController.java` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +Tasteby가 식당 정보를 수집하는 YouTube 채널을 관리(등록/수정/비활성화/스캔)하여, 추출 파이프라인의 데이터 원천을 통제 가능하게 한다. 채널은 사용자 프론트엔드의 "채널 필터" UI 데이터 소스이기도 하다. + +## 2. 범위 (Scope) +- **포함**: + - 활성 채널 목록 조회 (공개 API, 캐시 적용) + - 채널 등록 (관리자 전용) + - 채널 메타데이터 수정 (description/tags/sort_order, 관리자 전용) + - 채널 비활성화 (soft delete, 관리자 전용) + - 채널 영상 스캔 트리거 (관리자 전용, `YouTubeService.scanChannel` 위임) +- **제외 (out of scope)**: + - 채널 영상 자체의 추출/요약 로직 (#270 추출 파이프라인) + - 채널 통계/대시보드 (#274 통계) + - YouTube API 인증/쿼터 관리 세부사항 (YouTubeService 책임) + - 채널 카테고리 트리/계층화 + +## 3. 인수조건 (Acceptance Criteria) +- [x] `GET /api/channels` 호출 시 활성 채널 목록 반환, 캐시 hit/miss 모두 동일 결과 +- [x] 관리자 외 사용자가 `POST /api/channels` 호출 시 권한 거부 (`AuthUtil.requireAdmin()` throw) +- [x] 동일 `channel_id`로 중복 등록 시 HTTP 409 + "Channel already exists" (유니크 제약 `UQ_CHANNELS_CID`) +- [x] `DELETE /api/channels/{channelId}` 시 `channel_id` 우선 매칭, 실패 시 DB `id`로 재시도 (양쪽 모두 실패 → 404) +- [x] 채널 관련 쓰기 작업 후 `cache.flush()` 호출되어 다음 GET에서 최신 데이터 반환 + +## 4. 컨텍스트 & 제약 +- **DB**: Oracle 23ai. 테이블 `channels`. 유니크 제약 `UQ_CHANNELS_CID` on `channel_id`. +- **외부 의존**: + - `YouTubeService.scanChannel(channelId, full)` (#270 추출 파이프라인) + - `CacheService` (Redis 캐시, `makeKey/getRaw/set/flush`) +- **권한**: 조회(GET)는 공개, 그 외 모두 `AuthUtil.requireAdmin()`로 관리자만. +- **캐시**: 목록 응답은 Redis에 JSON 직렬화 저장. 쓰기 시 flush. +- **Soft delete**: 비활성화는 `active = 0` UPDATE (물리 삭제 아님). +- **가정**: `channel_id`는 YouTube의 외부 ID (`UCxxxx...`). DB `id`는 32자 UUID. + +## 5. 아키텍처 개요 +- 모듈/파일 구조: + - `controller/ChannelController.java` (5개 엔드포인트) + - `service/ChannelService.java` (CRUD 비즈니스) + - `service/YouTubeService.java` (스캔 위임, 외부) + - `service/CacheService.java` (Redis 캐시, 외부) + - `mapper/ChannelMapper.java` + XML + - `domain/Channel.java` + - `security/AuthUtil.java` +- I/O ↔ 순수 로직 경계: Controller는 캐시 hit/miss + 권한, Service는 식별자 매칭 폴백 로직, Mapper는 SQL. + +``` +[Client] + │ HTTP + ▼ +[ChannelController] ← AuthUtil.requireAdmin() (쓰기) + │ cache hit? ─┐ + │ ▼ + │ [CacheService(Redis)] ← GET/SET/FLUSH + │ miss + ▼ +[ChannelService] ← deactivate: channel_id → id 폴백 + │ + ▼ +[ChannelMapper] (MyBatis XML) + │ + ▼ +[Oracle 23ai: channels] + +[ChannelController.scan] → [YouTubeService.scanChannel] → (영상 수집 파이프라인) +``` + +## 6. 데이터 모델 +- **Channel** (`domain/Channel.java`): + - `id: String` (32자 UUID, PK) + - `channelId: String` (YouTube 외부 ID, 유니크) + - `channelName: String` + - `titleFilter: String` (정규식/포함 문자열, 영상 제목 필터) + - `description: String` + - `tags: String` (콤마 구분) + - `sortOrder: Integer` + - `videoCount: int` (조인 집계) + - `lastVideoAt: String` (조인 집계) +- **POST 요청 본문**: `{ channel_id, channel_name, title_filter }` +- **PUT 요청 본문**: `{ description, tags, sort_order }` +- **POST 응답**: `{ id, channel_id }` +- **scan 응답**: `YouTubeService.scanChannel` 반환 Map (영상 수, 신규 추출 수 등) +- **경계 검증**: 현재 명시적 검증 없음. `channel_id` 형식(`UC` prefix) 검증 미적용. 길이 제한은 DB 컬럼 의존. + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `ChannelService.findAllActive` | 활성 채널 목록 조회 | `List()` | 없음 | List | DB 오류 → 전파 | 단순 | +| `ChannelService.create` | 채널 신규 등록 | `String(channelId, channelName, titleFilter)` | YouTube ID/이름/필터 | 생성 PK | 유니크 충돌 → SQLException | 단순 | +| `ChannelService.deactivate` | soft delete (폴백) | `boolean(channelId)` | channel_id 또는 DB id | 성공 여부 | 둘 다 0행 → false | **복잡** | +| `ChannelService.findByChannelId` | 단건 조회 | `Channel(channelId)` | channel_id | Channel or null | 없음 | 단순 | +| `ChannelService.update` | 메타데이터 부분 갱신 | `void(id, description?, tags?, sortOrder?)` | DB id + 필드 | 없음 | DB 오류 → 전파 | 단순 | +| `ChannelController.list` | 캐시 우선 목록 응답 | `List()` | 없음 | List | 캐시 파싱 실패 → 무시, DB 조회 | 단순 | +| `ChannelController.create` | 등록 + 캐시 flush | `Map(body)` | body | `{id, channel_id}` | 유니크 → 409, 그 외 → 전파 | **복잡** | +| `ChannelController.scan` | 채널 스캔 트리거 | `Map(channelId, full)` | channelId, full | 스캔 결과 Map | 미존재 → 404 | **복잡** | +| `ChannelController.update` | 메타 갱신 + flush | `Map(id, body)` | id, body | `{ok:true}` | 권한 → 403 | 단순 | +| `ChannelController.delete` | 비활성화 + flush | `Map(channelId)` | channelId | `{ok:true}` | 미존재 → 404 | 단순 | + +> `deactivate` (이중 매칭 폴백), `create` (충돌 → 메시지 파싱), `scan` (외부 위임)은 복잡. fn 설계서 후보. + +## 8. 흐름 / 알고리즘 +1. **목록 조회 (캐시)**: + ``` + key = cache.makeKey("channels") + if cache.getRaw(key) != null: + try return objectMapper.readValue(cached) + catch: fall through + result = mapper.findAllActive() + cache.set(key, result) + return result + ``` +2. **채널 등록**: 관리자 검증 → IdGenerator.newId() → INSERT → `cache.flush()` → `{id, channel_id}` 응답. SQL 예외의 message에 `UQ_CHANNELS_CID` 포함되면 409로 매핑. +3. **비활성화 폴백**: + - `mapper.deactivateByChannelId(channelId)` 시도 → + - 0행이면 `mapper.deactivateById(channelId)` 시도 → + - 둘 다 0행 → false → 404. + - 이유: 운영자가 YouTube ID 또는 DB UUID 중 어느 것으로도 비활성화 가능. +4. **스캔 트리거**: 관리자 검증 → `YouTubeService.scanChannel(channelId, full)` 호출 → null 응답 시 404 → 성공 시 `cache.flush()` (영상 추가로 채널 메타 변동 가능성) → 결과 반환. +5. **메타 갱신**: PUT body의 sort_order는 Number → int 변환. `tags`, `description`, `sort_order` 부분 갱신. + +## 9. 엣지케이스 & 에러 처리 +- **캐시 직렬화 깨짐**: `objectMapper.readValue` 실패 시 catch 무시 → DB 폴백. 안전한 기본값. +- **유니크 충돌 감지**: 예외 메시지에 `UQ_CHANNELS_CID` 문자열 의존. DB 제약명이 바뀌면 감지 실패 → 500. → 향후 SQLState 또는 DataIntegrityViolationException 기반 매핑 권장. +- **deactivate 이중 시도**: 동일 channel_id가 DB id와 우연히 충돌하면 의도치 않은 행 비활성화 가능 (UUID 충돌 확률 매우 낮음). +- **scan 미존재 채널**: `YouTubeService`가 null 반환 → 404. YouTube API 자체 장애는 상위로 전파 (현재 별도 매핑 없음). +- **권한 누락**: `AuthUtil.requireAdmin()` 예외 → 403. +- **빈 본문 / 필수값 누락**: `body.get("channel_id")` 가 null → INSERT 시 NOT NULL 제약 위반 → 500. → 명시적 400 매핑 권장. +- **캐시 flush 실패**: Redis 다운 시 예외 전파. 운영 안전성 위해 try-catch + WARN 로깅 검토. + +## 10. 테스트 계획 +- **단위** + - `ChannelService.deactivate`: by-channelId 성공 → true / by-channelId 실패 + by-id 성공 → true / 둘 다 실패 → false + - `ChannelController.list`: 캐시 hit 시 ObjectMapper 호출, miss 시 mapper 호출 + - `ChannelController.create`: 유니크 메시지 포함 예외 → 409 + - `ChannelController.scan`: YouTubeService null → 404 +- **통합** + - 채널 등록 후 GET 목록에 반영 (캐시 flush 검증) + - 중복 channel_id 등록 시 409 + - 비관리자 인증으로 POST 시 403 + - update 후 sort_order 반영 + 캐시 무효화 +- **모킹**: `YouTubeService`, `CacheService` 모킹. Redis는 embedded-redis 또는 testcontainers. +- 현재 테스트 디렉토리 없음 → TBD. + +## 11. 리스크 & 대안 검토 +- **유니크 충돌 감지를 메시지 문자열로 판정**: 깨지기 쉬움. + - 대안 A: Spring의 `DuplicateKeyException` catch → 깔끔. + - 대안 B: 사전 SELECT 후 INSERT → 경합 시 여전히 위험. + - 트레이드오프: 현 방식은 빠르지만 fragile. ADR 후보. +- **deactivate 폴백 패턴**: 유연성 vs 명확성. + - 대안: 별도 엔드포인트 (`/by-id`, `/by-yt-id`)로 분리. 운영 UI 합의 필요. +- **캐시 정책**: 전체 flush vs 키 단위 invalidate. + - 현재 flush는 다른 모듈(예: 식당 목록)까지 영향. 채널 키만 무효화하도록 개선 가능. +- **스캔의 동기 호출**: 대량 영상 채널은 응답 지연 가능. + - 대안: 비동기 큐 + 작업 상태 폴링 (#275 데몬과 통합). + +## 12. 미해결 질문 (Open Questions) +- 채널 활성화 복구(reactivate) API가 필요한지? 현재는 DB 직접 수정만 가능. +- `title_filter`는 정규식인지 단순 contains인지 명세 부재 — 코드 확인 필요. +- 채널 단위 권한 (소유자 개념)을 도입할지? 현재는 글로벌 관리자만. +- 스캔 작업의 진행률/실패 재시도 정책 — 데몬(#275)과 통합 범위. +- 캐시 TTL 설정값 (현재 코드에 명시 없음, CacheService 정책 의존). diff --git a/docs/design/274-backend-stats/README.md b/docs/design/274-backend-stats/README.md new file mode 100644 index 0000000..732bb0a --- /dev/null +++ b/docs/design/274-backend-stats/README.md @@ -0,0 +1,137 @@ + + +# 설계서: 백엔드 - 통계/대시보드 (#274) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #274 · 관련 ADR: 없음 +> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/StatsService.java`, `backend-java/src/main/java/com/tasteby/controller/StatsController.java` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +사이트 방문 트래픽을 일별/누적으로 집계하여, 운영자가 서비스 사용량을 즉시 확인 가능한 가벼운 대시보드 데이터를 제공한다. 풋터/관리자 페이지의 "오늘/총 방문수" 표시에 사용된다. + +## 2. 범위 (Scope) +- **포함**: + - 사이트 방문 1건 기록 (`POST /api/stats/visit`) + - 오늘 방문수 + 누적 방문수 조회 (`GET /api/stats/visits`) +- **제외 (out of scope)**: + - 사용자별/세션별 추적, 유니크 방문자(UU) 집계 + - 페이지별 PV, 체류 시간, 이탈률 + - 트래픽 소스/UTM, 외부 분석 도구 연동(GA, Mixpanel 등) + - 채널/식당/리뷰 등 도메인 통계 (#273, #272 영역) + - 시계열 차트, 기간 필터 조회 + +## 3. 인수조건 (Acceptance Criteria) +- [x] `POST /api/stats/visit` 호출 시 DB의 오늘자 카운터가 1 증가하고 `{ok:true}` 반환 +- [x] `GET /api/stats/visits`는 `{today, total}` (모두 int) 반환 +- [x] 인증 없이 호출 가능 (퍼블릭 엔드포인트) +- [x] 데이터 없으면 `today=0, total=0` 반환 (DB null 안전) +- [x] 동일 사용자 다중 호출 시 호출 횟수만큼 카운터 증가 (PV 카운트 모델) + +## 4. 컨텍스트 & 제약 +- **DB**: Oracle 23ai. 테이블 `site_visit_stats` (가정: 일자 PK + count 컬럼 또는 카운터 테이블). +- **MyBatis**: `StatsMapper` (`recordVisit`, `getTodayVisits`, `getTotalVisits`). +- **권한**: 공개 엔드포인트. 인증 불필요. 어뷰즈 방어 없음 (현재). +- **성능**: 매 페이지 로드마다 `POST /api/stats/visit` 호출 → DB write QPS 비례 증가. +- **트랜잭션**: `recordVisit`는 단일 INSERT/UPDATE, 자동 커밋 또는 Spring 기본 트랜잭션. +- **가정**: + - 오늘 일자 기준은 DB 서버 시간(`SYSDATE` / `CURRENT_DATE`). + - 카운터는 일별 행 upsert 또는 단일 incremental 행. + - 누적은 합계 집계 또는 별도 단일 행. + +## 5. 아키텍처 개요 +- 모듈/파일 구조: + - `controller/StatsController.java` (2개 엔드포인트) + - `service/StatsService.java` (얇은 위임) + - `mapper/StatsMapper.java` + XML + - `domain/SiteVisitStats.java` +- I/O ↔ 순수 로직 경계: 비즈니스 로직 거의 없음. Service는 단순 Mapper 위임 + 빌더로 응답 객체 조립. + +``` +[Browser/Client] + │ POST /api/stats/visit (페이지 로드 시) + ▼ +[StatsController] + │ statsService.recordVisit() + ▼ +[StatsService] ── statsService.getVisits() (대시보드 GET) + │ + ▼ +[StatsMapper] (MyBatis XML) + │ INSERT/UPDATE | SELECT today/total + ▼ +[Oracle 23ai: site_visit_stats] +``` + +## 6. 데이터 모델 +- **SiteVisitStats** (`domain/SiteVisitStats.java`): + - `today: int` — 오늘 방문수 + - `total: int` — 누적 방문수 +- **응답 JSON**: `{"today": 123, "total": 456789}` +- **DB 테이블 (추정)**: `site_visit_stats(visit_date DATE PK, count NUMBER)` 또는 단일 row 카운터. +- **경계 검증**: + - 음수 카운트 불가 (DB CHECK 권장). + - 오버플로: int 범위(2^31-1) → 누적이 21억 도달 시 long으로 확장 필요. + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `StatsService.recordVisit` | 방문 1건 기록 | `void()` | 없음 | 없음 | DB 오류 → 전파 | 단순 | +| `StatsService.getVisits` | 오늘/누적 집계 조회 | `SiteVisitStats()` | 없음 | SiteVisitStats(today,total) | DB 오류 → 전파 | 단순 | +| `StatsController.recordVisit` | REST: 방문 기록 | `Map()` | 없음 | `{ok:true}` | 500 가능 | 단순 | +| `StatsController.getVisits` | REST: 방문 조회 | `SiteVisitStats()` | 없음 | SiteVisitStats | 500 가능 | 단순 | + +> 모두 단순. 별도 fn 설계서 불필요. + +## 8. 흐름 / 알고리즘 +1. **방문 기록**: + - Client(브라우저/Next.js) → 페이지 마운트 시 `POST /api/stats/visit` 1회 호출. + - Controller → Service → Mapper.recordVisit() → DB. + - SQL (가정): `MERGE INTO site_visit_stats USING dual ON (visit_date = TRUNC(SYSDATE)) WHEN MATCHED THEN UPDATE SET count = count + 1 WHEN NOT MATCHED THEN INSERT (visit_date, count) VALUES (TRUNC(SYSDATE), 1)` + - 응답 `{ok:true}`. +2. **집계 조회**: + - Controller → Service.getVisits() → + - Mapper.getTodayVisits() (오늘 일자 행 SELECT) + + - Mapper.getTotalVisits() (SUM 또는 누적 행 SELECT) → + - Lombok Builder로 `SiteVisitStats` 조립 → 응답. + +## 9. 엣지케이스 & 에러 처리 +- **자정 경계 race**: Mapper가 `TRUNC(SYSDATE)`를 사용한다고 가정 → DB 시간대 일관. 서버 시간대(`Asia/Seoul`) 설정 의존. +- **첫 호출/빈 DB**: 오늘 행이 없으면 `getTodayVisits` 0 또는 null 반환 → int 변환 시 NPE 위험. → Mapper SQL에서 `NVL(SUM(count), 0)` 권장. +- **봇/크롤러 트래픽**: 인플레이션 가능. UA/IP 필터 부재. +- **사용자 빠른 새로고침 어뷰즈**: 동일 IP 다중 호출 모두 +1. 레이트 리밋 없음. +- **DB 다운**: 방문 기록/조회 모두 500 전파. 사이트 페이지 로드는 fire-and-forget이면 무영향, 동기 대기면 UX 저하. +- **MERGE 동시 INSERT 경합**: 자정 직후 두 트랜잭션 동시 INSERT → 유니크 충돌 시 한쪽 500. 재시도 권장. +- **카운터 오버플로**: int 한계 → 향후 long 마이그레이션 + DB NUMBER 확장. + +## 10. 테스트 계획 +- **단위** + - `StatsService.getVisits`: Mapper 모킹, today=10, total=100 → 빌드된 객체 검증. + - `StatsService.recordVisit`: Mapper 호출 1회 검증. +- **통합** + - 신규 DB 상태에서 POST 3회 → GET 시 today=3, total=3 + - 다음 날(시뮬레이션) POST 1회 → today=1, total=4 + - 빈 DB 상태에서 GET → `{0, 0}` (null 안전) +- **부하**: 초당 100회 POST 지속 → 응답 시간 100ms 이하 (성능 SLA). +- **모킹**: H2 또는 Testcontainers Oracle. 자정 경계는 JVM 시간 모킹 또는 DB 시퀀스 가짜 주입. +- 현재 테스트 디렉토리 없음 → TBD. + +## 11. 리스크 & 대안 검토 +- **선택**: 모든 페이지 로드마다 동기 POST 1건 + DB INCR. + - 대안 A: Redis INCR + 주기 flush → DB write 폭주 방지, 정확도 약간 손실. + - 대안 B: 로그 기반 집계 (Nginx access log → 배치) → 실시간성 손실. + - 대안 C: 외부 분석(GA) 연동 → 운영 부담 감소, 데이터 주권 손실. + - 트레이드오프: 현 트래픽 규모에서 DB 직접 카운트가 단순/충분. 1000 QPS 도달 시 Redis 캐시 ADR 검토. +- **유니크 방문자(UU) 미지원**: 현 PV 모델 한계. 쿠키/세션 ID 도입 시 schema 확장 필요. +- **봇 필터링 부재**: User-Agent 블랙리스트 또는 robots.txt 준수 봇 제외 로직 후속 작업. +- **시간대 의존**: DB 서버 TZ가 KST가 아니면 "오늘" 정의가 사용자 인식과 불일치. 서버 TZ 명시 필요. + +## 12. 미해결 질문 (Open Questions) +- 봇/크롤러 트래픽 필터링 정책 (UA 화이트리스트? Cloudflare 통계 활용?). +- 유니크 방문자(UU) 메트릭이 필요한지? 필요하면 쿠키 기반 vs IP+UA 해시. +- 통계 데이터 보존 기간/롤업 정책 (예: 90일 이전은 월 단위 압축). +- 인증된 사용자만의 활성 사용자(DAU/MAU) 지표 도입 시점. +- 어뷰즈 방어(레이트 리밋) 추가 필요 — Bucket4j 또는 Nginx 단에서 처리? +- 관리자 대시보드 UI에서 필요한 차원(채널별 트래픽, 디바이스 등) 범위 확정. diff --git a/docs/design/275-backend-daemon/README.md b/docs/design/275-backend-daemon/README.md new file mode 100644 index 0000000..f812b0d --- /dev/null +++ b/docs/design/275-backend-daemon/README.md @@ -0,0 +1,193 @@ + + +# 설계서: 백엔드 - 데몬/스케줄러 (#275) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #275 · 관련 ADR: 없음 +> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java`, `backend-java/src/main/java/com/tasteby/service/DaemonConfigService.java`, `backend-java/src/main/java/com/tasteby/controller/DaemonController.java` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +YouTube 채널을 주기적으로 스캔해 새 영상을 발견하고, 대기 중 영상을 LLM 파이프라인으로 자동 처리해 음식점 데이터를 무인 운영으로 적재한다. 운영자가 어드민에서 실행 여부/주기를 토글할 수 있어야 한다. + +## 2. 범위 (Scope) +- **포함**: + - Spring `@Scheduled` 기반 30초 주기 워커(`DaemonScheduler.run`). + - 채널 스캔(`scanAllChannels`) · 영상 처리(`processPending`) 두 작업의 토글/주기/배치 한도 관리. + - 마지막 실행 시각(`last_scan_at`, `last_process_at`) 기록. + - 어드민 REST API: `GET /api/daemon/config`, `PUT /api/daemon/config`. + - 새 영상/식당 생성 시 Redis 캐시 자동 무효화 트리거. +- **제외 (out of scope)**: + - 채널 스캔 로직 자체(`YouTubeService.scanAllChannels`는 별도 설계). + - 파이프라인 처리 알고리즘(`PipelineService.processPending`는 별도 설계). + - 분산 락 / 멀티 인스턴스 동시 실행 방지. + - 즉시 실행(run-now) API · 진행 상태 스트리밍. + +## 3. 인수조건 (Acceptance Criteria) +- [x] `DaemonScheduler.run()`은 `@Scheduled(fixedDelay = 30_000)`로 30초 간격 호출된다. +- [x] `scan_enabled = true`이고 `last_scan_at + scan_interval_min` 이 경과한 경우에만 `scanAllChannels`가 실행된다. +- [x] `process_enabled = true`이고 `last_process_at + process_interval_min` 이 경과한 경우에만 `processPending(processLimit)` 가 실행된다. +- [x] 신규 영상/식당이 1건 이상 생기면 `CacheService.flush()` 가 호출된다. +- [x] `GET /api/daemon/config` 는 인증 없이 현재 설정을 반환한다(없으면 빈 빌더 객체). +- [x] `PUT /api/daemon/config` 는 `AuthUtil.requireAdmin()` 통과 시에만 부분 갱신을 수행하고 `{ok:true}` 를 반환한다. +- [x] 작업 중 예외가 발생해도 스케줄러 스레드는 죽지 않고 에러 로그만 남긴다. + +## 4. 컨텍스트 & 제약 +- **의존성**: + - `YouTubeService` (채널 스캔), `PipelineService` (LLM 추출 파이프라인), `CacheService` (Redis flush), `DaemonConfigMapper` (Oracle 23ai `daemon_config` 테이블). + - Spring Boot 3.3.5 `spring-context` 스케줄러 — `TastebyApplication` 에 `@EnableScheduling` 부착. +- **제약**: + - 단일 PM2(`tasteby-api`) / Prod에서는 OKE 백엔드 파드 2개 운영 — **현재 분산 락 없음**. 동일 작업이 양쪽 파드에서 동시에 돌 수 있음(11장 리스크 참조). + - LLM 호출은 비용이 발생하므로 `processLimit` 으로 배치당 처리량을 제한. + - DB 미가용 시 `getConfig()` 가 예외 → null 반환 → 사이클 스킵. +- **가정**: + - `daemon_config` 테이블은 id=1 단일 레코드(싱글톤 설정 패턴). + - 30초 폴링 비용은 무시 가능. + - `Date`/Oracle TIMESTAMP 비교는 JVM 기본 타임존과 무관하게 `Instant` 변환 후 비교. + +## 5. 아키텍처 개요 +- **모듈**: + - `DaemonScheduler` — 30초 워커, 두 작업의 게이트 판정. + - `DaemonConfigService` — 설정 CRUD, 마지막 실행 시각 갱신 (Mapper 위임). + - `DaemonController` — 어드민 REST 진입점. + - `DaemonConfigMapper(.xml)` — Oracle `daemon_config` 매핑. +- **경계**: + - I/O: Mapper(DB), `YouTubeService`(YouTube Data API), `PipelineService`(LLM/외부 API), `CacheService`(Redis). + - 순수 로직: "마지막 실행 + 주기 < now" 게이트 판정(테스트 가능). 현재는 `run()` 내부에 인라인. + +``` + ┌─────────────────────────┐ + │ Spring Scheduler │ fixedDelay=30s + └──────────┬──────────────┘ + ▼ + ┌─────────────────────────┐ ┌────────────────────┐ + │ DaemonScheduler.run() │─────▶│ DaemonConfigService│──▶ Oracle (daemon_config) + └──────────┬──────────────┘ └────────────────────┘ + │ + ┌──────────┴────────────────────┐ + ▼ ▼ + scan_enabled & 주기 경과? process_enabled & 주기 경과? + │ │ + ▼ ▼ + YouTubeService PipelineService + .scanAllChannels() .processPending(limit) + │ newVideos>0 │ restaurants>0 + ▼ ▼ + CacheService.flush() CacheService.flush() + │ │ + ▼ ▼ + updateLastScan() updateLastProcess() + + 관리자 ──HTTP──▶ DaemonController ──▶ DaemonConfigService ──▶ Mapper + GET /api/daemon/config (공개) + PUT /api/daemon/config (admin only) +``` + +## 6. 데이터 모델 +**`DaemonConfig` (도메인, `daemon_config` 테이블 매핑, 싱글톤 row id=1)** + +| 필드 | 타입 | 컬럼 | 기본/규칙 | +|------|------|------|-----------| +| `id` | int | `id` | 항상 1 | +| `scanEnabled` | boolean | `scan_enabled` (NUMERIC) | false 시 스캔 스킵 | +| `scanIntervalMin` | int | `scan_interval_min` | 분 단위, 양수 가정 (검증 없음) | +| `processEnabled` | boolean | `process_enabled` (NUMERIC) | false 시 처리 스킵 | +| `processIntervalMin` | int | `process_interval_min` | 분 단위 | +| `processLimit` | int | `process_limit` | 한 사이클당 최대 처리 영상 수 | +| `lastScanAt` | Date | `last_scan_at` | NULL 이면 즉시 첫 실행 | +| `lastProcessAt` | Date | `last_process_at` | NULL 이면 즉시 첫 실행 | +| `updatedAt` | Date | `updated_at` | `SYSTIMESTAMP` 자동 | + +**`PUT /api/daemon/config` 요청 바디(부분 갱신, key 존재 시에만 반영)** + +```json +{ + "scan_enabled": true, + "scan_interval_min": 60, + "process_enabled": true, + "process_interval_min": 5, + "process_limit": 10 +} +``` + +- 경계 검증: 현재 명시적 범위 검사 없음 — `Number.intValue()` 캐스팅만. 음수/0 입력 시 사이클이 즉시 통과해 폭주 가능(9장 참조). + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `DaemonScheduler.run` | 30초마다 게이트 판정 후 스캔/처리 실행 | `void run()` | (없음, 스케줄러 트리거) | void | 모든 예외 catch → ERROR 로그, 사이클 종료 | **복잡** | +| `DaemonScheduler.getConfig` | 설정 안전 조회(null 허용) | `DaemonConfig getConfig()` | - | `DaemonConfig | null` | DB 오류 시 DEBUG 로그, null 반환 | 단순 | +| `DaemonConfigService.getConfig` | 현재 설정 조회 | `DaemonConfig getConfig()` | - | `DaemonConfig` | Mapper 예외 전파 | 단순 | +| `DaemonConfigService.updateConfig` | 본문 키 존재 필드만 부분 갱신 | `void updateConfig(Map body)` | JSON body | void | `ClassCastException`(Number 변환), 현재 row=null 이면 no-op | 단순 | +| `DaemonConfigService.updateLastScan` | 마지막 스캔 시각 갱신 | `void updateLastScan()` | - | void | DB 예외 전파 | 단순 | +| `DaemonConfigService.updateLastProcess` | 마지막 처리 시각 갱신 | `void updateLastProcess()` | - | void | DB 예외 전파 | 단순 | +| `DaemonController.getConfig` | `GET /api/daemon/config` | `DaemonConfig getConfig()` | - | DaemonConfig (없으면 빈 빌더) | - | 단순 | +| `DaemonController.updateConfig` | `PUT /api/daemon/config` (admin) | `Map updateConfig(@RequestBody Map)` | JSON body | `{ok:true}` | `AuthUtil.requireAdmin()` 실패 → 401/403 | 단순 | + +> `run()` 은 분기·외부 I/O·시간 비교가 결합되어 **복잡** — 향후 `fn-daemon-run.md` 분리 후보. + +## 8. 흐름 / 알고리즘 +**주기**: `@Scheduled(fixedDelay = 30_000)` — 직전 실행 종료 후 30초 대기. cron 미사용. + +**한 사이클 알고리즘 (`run()`)** +1. `daemonConfigService.getConfig()` 호출. 예외/`null` 이면 사이클 종료. +2. **스캔 게이트**: + - `config.scanEnabled == true` AND (`lastScanAt == null` OR `now > lastScanAt + scanIntervalMin`) + - 통과 시: `youTubeService.scanAllChannels()` → 새 영상 수 반환. + - `updateLastScan()` 호출(시각 갱신). + - 새 영상 > 0 이면 `cacheService.flush()`. +3. **처리 게이트**: + - `config.processEnabled == true` AND (`lastProcessAt == null` OR `now > lastProcessAt + processIntervalMin`) + - 통과 시: `pipelineService.processPending(processLimit)` → 추출된 식당 수 반환. + - `updateLastProcess()` 호출. + - 식당 > 0 이면 `cacheService.flush()`. +4. 사이클 내 어떤 예외든 잡아 ERROR 로그만 남기고 종료(스케줄러 스레드 보호). + +**시간 비교**: `Date → Instant → plus(minutes, ChronoUnit.MINUTES)`. UTC 기반이므로 타임존 무관. + +## 9. 엣지케이스 & 에러 처리 +- **DB 연결 실패**: `getConfig()` 가 예외 → `DEBUG` 로그, 다음 사이클 재시도. 작업 중단됨(안전 기본값). +- **설정 row 부재**: `mapper.getConfig()` null → 사이클 스킵. `updateConfig()` 도 no-op. +- **`PUT` 본문 타입 오류**: `(Number) body.get(...)` 캐스트 실패 시 `ClassCastException`. 전역 예외 핸들러가 없으면 500. (향후 `@Valid` DTO 도입 필요) +- **0/음수 주기**: `lastX + 0min` → 항상 게이트 통과 → 매 30초마다 스캔/처리 반복(폭주). 현재 입력 검증 없음 — **운영 리스크**. +- **`scanAllChannels` / `processPending` 장시간 수행**: `fixedDelay` 라 이전 실행 끝나야 다음 사이클 — 자연스러운 백프레셔. +- **Redis 다운**: `CacheService.flush()` 가 내부적으로 `disabled` 처리 → no-op. +- **멀티 파드(OKE Prod)**: 분산 락 없음 — 동일 스캔/처리가 양쪽에서 동시에 돌면 API 쿼터 2배·중복 LLM 호출 발생 가능. 현재 미해결. +- **시각 갱신 실패**: `updateLastX` 가 예외 → catch 로 사이클 종료. 다음 사이클에서 같은 작업 재실행될 수 있음. + +## 10. 테스트 계획 +현재 자동 테스트 없음(TBD). 권장 케이스: +- **Unit (DaemonScheduler)**: + - 게이트 판정: `scanEnabled=false` 시 `scanAllChannels` 호출 안 됨. + - 주기 미경과 시 호출 안 됨 / 경과 시 호출 됨 (시간 모킹). + - 새 영상 0 → `flush` 미호출 / >0 → `flush` 호출. + - `getConfig` 예외 → `run()` 이 예외 누출 없이 종료. +- **Unit (DaemonConfigService.updateConfig)**: + - 존재 키만 반영(부분 갱신) — 빠진 키는 기존 값 보존. + - 잘못된 타입 입력 → 명확한 에러. + - `current==null` 시 no-op. +- **Integration (DaemonController)**: + - `GET /api/daemon/config` 200 + 본문. + - `PUT` 비관리자 → 403, 관리자 → 200/`{ok:true}` + DB 반영 확인. +- **모킹**: `YouTubeService`, `PipelineService`, `CacheService`, `DaemonConfigMapper` Mockito. 시간은 `Clock` 주입으로 결정론화 권장. + +## 11. 리스크 & 대안 검토 +- **선택**: Spring `@Scheduled(fixedDelay)` + DB 싱글톤 설정 row. + - 장점: 추가 인프라 무. 어드민 UI에서 즉시 토글. + - 단점: 멀티 인스턴스 동시 실행 제어 불가. +- **대안**: + - Quartz Cluster Mode + DB 잠금 — 동시 실행 방지 가능하지만 의존성 증가. + - Redis `SET NX EX` 분산 락 — 가벼움. 본 시스템에 이미 Redis 있으므로 유력한 후속 옵션. + - K8s `CronJob` — 파드 수명 짧음/장기 작업 부적합, 어드민 토글 불가. +- **트레이드오프**: 현재는 dev 단일 인스턴스 운영. Prod 다중 파드에서는 한쪽 파드만 `scanEnabled=true` 로 두는 운영 우회가 가능. +- **되돌리기 어려운 결정 없음** — 분산 락 도입은 ADR 분리 후 추가 가능. + +## 12. 미해결 질문 (Open Questions) +- 멀티 파드에서 중복 실행 방지 전략(Redis 분산 락 vs ShedLock)을 어느 시점에 도입할 것인가? +- `scanIntervalMin`, `processIntervalMin`, `processLimit` 의 허용 범위(최솟값/최댓값) 정책은? +- 즉시 실행(run-now) API 와 진행률 조회 API 가 필요한가? +- `scanAllChannels` 가 매우 오래 걸릴 때 타임아웃/취소가 필요한가? +- 작업 실패 알림(Slack/Email) 채널이 필요한가? +- 작업 이력(스캔/처리 결과 로그 테이블) 보관 정책은? diff --git a/docs/design/276-backend-cache/README.md b/docs/design/276-backend-cache/README.md new file mode 100644 index 0000000..1b66299 --- /dev/null +++ b/docs/design/276-backend-cache/README.md @@ -0,0 +1,174 @@ + + +# 설계서: 백엔드 - 캐시 관리 (#276) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #276 · 관련 ADR: 없음 +> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/CacheService.java`, `backend-java/src/main/java/com/tasteby/controller/AdminCacheController.java` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +LLM/YouTube 응답·식당 목록 등 비용이 큰 조회 결과를 Redis에 캐싱해 응답 속도와 외부 API 비용을 줄이고, Redis 미가용 시에도 서비스가 정상 동작하도록 graceful degradation 한다. 데이터 갱신 시 관리자가 즉시 캐시를 무효화할 수 있어야 한다. + +## 2. 범위 (Scope) +- **포함**: + - Redis 기반 문자열/JSON 키-값 캐시(`CacheService`). + - 공통 키 prefix(`tasteby:`) 및 키 빌더(`makeKey`). + - 객체 ↔ JSON 직렬화/역직렬화(Jackson `ObjectMapper`). + - TTL 설정(`app.cache.ttl-seconds`, 기본 600초). + - Redis 미가용 자동 감지 → 캐시 비활성(`disabled` 플래그). + - 관리자 캐시 일괄 삭제: `POST /api/admin/cache-flush`. + - 데몬에서 신규 데이터 발생 시 자동 flush 트리거(호출처: `DaemonScheduler`). +- **제외 (out of scope)**: + - 캐시 이용 정책(어떤 조회를 캐싱할지)은 호출 서비스 책임. + - 키별 개별 삭제 API. + - 캐시 히트율/지표 수집. + - 캐시 워밍/사전 적재. + - 분산 캐시 클러스터 토폴로지. + +## 3. 인수조건 (Acceptance Criteria) +- [x] 애플리케이션 기동 시 Redis 에 `PING` 을 보내 연결 가능 여부를 로그로 남긴다. +- [x] Redis 미가용이면 `disabled=true` 로 전환되고 이후 모든 캐시 호출이 no-op 가 된다(null 반환 또는 무시). +- [x] `makeKey(parts...)` 는 `tasteby:` prefix + `:` 조인 키를 반환한다. +- [x] `get(key, type)` 은 값이 없거나 비활성이면 `null`, 있으면 Jackson 으로 역직렬화한 객체를 반환한다. +- [x] `getRaw(key)` 는 원시 문자열을 반환한다. +- [x] `set(key, value)` 는 JSON 직렬화 후 TTL(`app.cache.ttl-seconds`) 로 저장한다. +- [x] `flush()` 는 `tasteby:*` 패턴의 모든 키를 삭제한다. +- [x] `POST /api/admin/cache-flush` 는 `requireAdmin()` 통과 시에만 `flush()` 를 호출하고 `{ok:true}` 를 반환한다. +- [x] Jackson 직렬화/역직렬화·Redis 통신 에러는 DEBUG 로그만 남기고 호출자에게 예외를 던지지 않는다. + +## 4. 컨텍스트 & 제약 +- **의존성**: + - Spring Data Redis `StringRedisTemplate` (Lettuce 클라이언트). + - Jackson `ObjectMapper` (전역 빈, SNAKE_CASE 네이밍). + - `AuthUtil.requireAdmin()` — 관리자 검증. + - 환경: dev=로컬 Redis(brew), prod=OKE in-cluster Redis. +- **제약**: + - `KEYS tasteby:*` 는 Redis O(N) 명령 — 키가 매우 많아지면 블로킹 위험. 본 서비스는 캐시 규모가 작아 허용. + - TTL 기본 10분 — 응답 신선도와 비용의 균형. + - `disabled` 는 기동 시 한 번만 결정 — Redis 가 런타임 중 살아나도 자동 복구 안 됨. +- **가정**: + - 캐시는 휘발성 — 손실되어도 DB로부터 재계산 가능. + - 모든 캐시 키는 `tasteby:` prefix 사용을 강제(공유 Redis 안전). + - 캐시 값은 JSON 직렬화 가능한 POJO. + +## 5. 아키텍처 개요 +- **모듈**: + - `CacheService` — 캐시 게이트웨이(직렬화 + 키 관리 + 가용성 체크). + - `AdminCacheController` — 관리자 flush 엔드포인트. +- **경계**: + - I/O: Redis(`StringRedisTemplate`), 호출 서비스 ↔ DB. + - 순수 로직: 키 빌더, JSON 직렬화(라이브러리 위임). + +``` + ┌────────────┐ ┌─────────────┐ + │ 호출 서비스 │───▶│ CacheService│──── get / set / flush ────▶ Redis (tasteby:*) + └────────────┘ │ (disabled?)│ TTL=app.cache.ttl-seconds + ▲ ▲ │ fallback │ + │ │ null └──────┬──────┘ + │ │ │ 직렬화/역직렬화 + │ │ ▼ + │ │ ObjectMapper (Jackson) + │ │ + │ └ DB 폴백 (호출 서비스 책임) + │ + ┌────┴────────────┐ + │ DaemonScheduler │ (신규 데이터 시 자동 flush) + └─────────────────┘ + + 관리자 ──POST /api/admin/cache-flush──▶ AdminCacheController ──▶ requireAdmin() ──▶ flush() +``` + +## 6. 데이터 모델 +- **키 스키마**: `tasteby:::...` — `makeKey(parts...)` 로만 생성. 호출자가 자유롭게 정의. +- **값 스키마**: UTF-8 문자열. `set()` 은 Jackson 직렬화 결과(JSON), `get(_, type)` 은 동일 타입으로 역직렬화. `getRaw()` 는 원시 문자열. +- **TTL**: `Duration ofSeconds(app.cache.ttl-seconds)`, 기본 600초. +- **저장소 가용성 플래그**: `boolean disabled` — `final` 아님, 기동 시 결정. +- **응답 객체**: flush 엔드포인트는 `{"ok": true}` (`Map`). +- **경계 검증**: + - `key` null/empty 체크 없음(호출자 책임). + - 값 크기 상한 검증 없음(Redis 기본 한도에 의존). + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `CacheService(constructor)` | Redis ping 후 가용성 결정 | `CacheService(StringRedisTemplate, ObjectMapper, int ttlSeconds)` | DI 빈 + TTL | 인스턴스 | ping 실패 시 WARN + `disabled=true` | 단순 | +| `makeKey` | 표준 prefix 키 생성 | `String makeKey(String... parts)` | varargs | `tasteby:a:b:...` | - | 단순 | +| `get` | 타입 지정 캐시 조회 | ` T get(String key, Class type)` | key, type | T 또는 null | 비활성/예외 시 null, DEBUG 로그 | 단순 | +| `getRaw` | 원시 문자열 조회 | `String getRaw(String key)` | key | String 또는 null | 비활성/예외 시 null | 단순 | +| `set` | JSON 직렬화 + TTL 저장 | `void set(String key, Object value)` | key, value | void | 비활성/직렬화 실패 시 무시(DEBUG) | 단순 | +| `flush` | `tasteby:*` 일괄 삭제 | `void flush()` | - | void | 비활성/예외 시 무시(DEBUG) | 단순 | +| `AdminCacheController.flushCache` | `POST /api/admin/cache-flush` | `Map flushCache()` | - | `{ok:true}` | `requireAdmin()` 실패 → 401/403 | 단순 | + +## 8. 흐름 / 알고리즘 +**기동 시(Construct)**: +1. `StringRedisTemplate.getConnectionFactory().getConnection().ping()` 호출. +2. 성공 → `disabled=false`, "Redis connected" 로그. +3. 실패 → `disabled=true`, WARN 로그 — 이후 모든 호출 no-op. + +**`get(key, type)`**: +1. `disabled` 이면 즉시 `null`. +2. `redis.opsForValue().get(key)` → `null` 이면 `null` 반환. +3. `mapper.readValue(val, type)` 으로 역직렬화 → 반환. +4. 어떤 예외든 잡아 DEBUG 로그 → `null` 반환. + +**`set(key, value)`**: +1. `disabled` 이면 종료. +2. `mapper.writeValueAsString(value)` → JSON. +3. `redis.opsForValue().set(key, json, ttl)` — TTL 적용. +4. `JsonProcessingException` DEBUG 로그. + +**`flush()`**: +1. `disabled` 이면 종료. +2. `redis.keys("tasteby:*")` → 키 셋. +3. 비어있지 않으면 `redis.delete(keys)` 일괄 삭제. +4. INFO 로그 "Cache flushed". + +**자동 flush 트리거**: `DaemonScheduler` 가 스캔/처리 후 신규 건이 있을 때 호출. + +**관리자 flush**: `POST /api/admin/cache-flush` → `requireAdmin()` → `flush()`. + +## 9. 엣지케이스 & 에러 처리 +- **Redis 다운(기동)**: ping 실패 → `disabled=true`. 모든 호출 안전하게 no-op. 서비스는 DB 직조회로 동작 지속. +- **Redis 런타임 중 다운**: `disabled` 가 false 인 상태로 예외 → DEBUG 로그 + `null` 반환. 호출자는 DB 폴백. (자동 복구 미구현) +- **`KEYS` 명령 비용**: 키 개수 폭증 시 블로킹 — 향후 `SCAN` 으로 교체 검토. +- **직렬화 실패**: `JsonProcessingException` DEBUG 로그만 — 값 저장 안 됨. 호출자는 일관성 가정 불가(다음 `get` 시 miss). +- **역직렬화 실패**: 타입 변경 후 잔존 키 → DEBUG 로그 + null → 호출자가 재계산. (배포 시 한 번 flush 권장) +- **부분 prefix 충돌**: `tasteby:` 외 prefix 사용 시 `flush()` 가 삭제하지 않음 — 호출자가 `makeKey()` 만 사용하도록 컨벤션 준수. +- **비관리자 flush 호출**: `AuthUtil.requireAdmin()` 가 예외 → 글로벌 예외 핸들러가 401/403 반환. +- **동시 flush + set**: race condition 으로 직후 set 만 살아남을 수 있음. 캐시 정합성이 휘발성이라 허용. + +## 10. 테스트 계획 +현재 자동 테스트 없음(TBD). 권장 케이스: +- **Unit (CacheService, embedded Redis 또는 Testcontainers)**: + - `makeKey("a","b")` → `tasteby:a:b`. + - `set` → `get` 라운드트립 동등성. + - TTL 적용 확인(짧은 TTL 주입 후 expire 대기). + - `flush()` 후 `get` → null. + - Redis 비활성 시 모든 호출 no-op 보장. + - 잘못된 JSON 값 → `get` null 반환, 예외 누출 없음. +- **Integration (AdminCacheController)**: + - 비관리자 401/403. + - 관리자 200 + Redis 에 키 없어짐. +- **모킹**: `StringRedisTemplate` Mockito 또는 Testcontainers Redis. `AuthUtil` 정적 호출은 MockedStatic. + +## 11. 리스크 & 대안 검토 +- **선택**: 단일 Redis + StringRedisTemplate + JSON 문자열 저장. + - 장점: 단순, 디버깅 쉬움(redis-cli `GET` 가능), 어떤 POJO 도 캐싱. + - 단점: 직렬화/역직렬화 오버헤드, 타입 안전성 약함. +- **대안**: + - Spring `@Cacheable`/`CacheManager` — 선언적이지만 동적 키/조건이 어렵고 graceful degradation 처리가 까다로움. + - Caffeine 로컬 캐시 — JVM 내라 빠르지만 멀티 파드 간 일관성 깨짐. + - Protobuf/MsgPack 바이너리 — 성능↑이나 운영 가시성↓. +- **트레이드오프**: 현재 규모(개인 운영, 트래픽 적음)에서 단순함이 우선. +- **되돌리기 어려운 결정 없음** — 키 스키마만 일관 유지하면 내부 구현은 교체 가능. + +## 12. 미해결 질문 (Open Questions) +- 런타임 중 Redis 가 복구되면 `disabled` 를 자동 해제할지(주기적 ping 헬스체크)? +- `KEYS` 를 `SCAN` 으로 교체할 시점은 언제인가(키 개수 기준)? +- 키별 TTL 차등 지정(짧은/긴 TTL)이 필요한가? +- 캐시 히트율 지표(Micrometer)를 도입할 것인가? +- 관리자용 키별 삭제/조회 API 가 필요한가? +- 배포 시 자동 flush 훅(애플리케이션 기동 또는 마이그레이션 후)이 필요한가? diff --git a/docs/design/277-backend-health/README.md b/docs/design/277-backend-health/README.md new file mode 100644 index 0000000..34ed2aa --- /dev/null +++ b/docs/design/277-backend-health/README.md @@ -0,0 +1,118 @@ + + +# 설계서: 백엔드 - Health/모니터링 (#277) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #277 · 관련 ADR: 없음 +> · 구현 파일: `backend-java/src/main/java/com/tasteby/controller/HealthController.java` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +PM2(dev) / Kubernetes (OKE prod) / Nginx Ingress 가 백엔드 프로세스가 살아있는지 판단할 수 있는 경량 HTTP 엔드포인트를 제공해, 장애 시 재기동·트래픽 차단을 자동화한다. + +## 2. 범위 (Scope) +- **포함**: + - `GET /api/health` — 항상 `200 OK` 와 `{"status":"ok"}` 를 반환하는 liveness 체크 엔드포인트. + - 인증 없이 호출 가능(공개). +- **제외 (out of scope)**: + - DB/Redis/외부 API 의존성 상태를 포함하는 deep health(readiness) 체크. + - Spring Boot Actuator (`/actuator/health`) 활성화. + - 메트릭(Micrometer/Prometheus) 노출. + - 분산 트레이싱·로그 수집. + - 알림(Slack/Email/PagerDuty) 통합. + +## 3. 인수조건 (Acceptance Criteria) +- [x] `GET /api/health` 호출 시 HTTP 200 과 본문 `{"status":"ok"}` 를 반환한다. +- [x] 인증 없이 누구나 호출 가능하다(`WebConfig` CORS 허용 범위 내). +- [x] 응답 본문은 `Content-Type: application/json` 으로 직렬화된다(Spring 기본). +- [x] 외부 의존성(DB/Redis/LLM)이 다운되어도 본 엔드포인트는 영향을 받지 않는다(순수 인메모리 응답). + +## 4. 컨텍스트 & 제약 +- **의존성**: + - Spring Web (`@RestController`, `@GetMapping`). + - 외부 의존성 없음 — 컨트롤러 자체가 무상태 상수 응답. + - 호출자: PM2 헬스(현재는 미사용), Kubernetes liveness/readiness probe(향후), Nginx upstream check(현재는 미사용), Uptime 모니터링. +- **제약**: + - 응답 시간 < 50ms 가정. 어떤 비용 있는 작업도 포함하지 않아야 함. + - 본 엔드포인트가 200 을 반환한다고 해서 "서비스 정상" 을 의미하지 않음(프로세스 생존만 보장). +- **가정**: + - 프로세스가 응답할 수 있으면 JVM/Tomcat 이 살아있다는 신호로 충분. + - 인증 미들웨어(Spring Security/필터)는 `/api/health` 를 통과시킨다(현재 인증 강제 없음). + +## 5. 아키텍처 개요 +- **모듈**: + - `HealthController` — 단일 `@RestController` 클래스, 메서드 1개. +- **경계**: + - I/O: HTTP 입출력만. DB/Redis/LLM 호출 없음. + - 순수 로직: 상수 `Map` 반환 — 테스트 자체가 거의 불필요한 수준. + +``` + Probe/Monitor ──HTTP GET /api/health──▶ Spring DispatcherServlet + │ + ▼ + HealthController.health() + │ + ▼ + Map.of("status","ok") → JSON 200 +``` + +## 6. 데이터 모델 +- 단순 응답 객체: `Map` 의 불변 단일 엔트리. + +```json +{ "status": "ok" } +``` + +- 입력 파라미터/바디 없음. +- 상태 코드: 항상 200. 다른 코드 없음. +- 경계 검증: 해당 없음. + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `HealthController.health` | liveness 응답 반환 | `Map health()` | 없음 | `{"status":"ok"}` | 이론상 없음(JVM/Tomcat 죽으면 연결 실패) | 단순 | + +## 8. 흐름 / 알고리즘 +1. 클라이언트(K8s probe / 모니터)가 `GET /api/health` 호출. +2. Spring DispatcherServlet 라우팅 → `HealthController.health()` 호출. +3. 메서드는 `Map.of("status","ok")` 상수 반환. +4. Jackson 이 JSON 직렬화 → HTTP 200 응답. + +cron 표현/주기: 본 컨트롤러 자체는 스케줄러가 아님(외부 probe 가 주기적으로 호출). 운영 권장 주기: K8s liveness 10s, readiness 5s. + +## 9. 엣지케이스 & 에러 처리 +- **JVM 데드락/OOM**: 응답 없음 → probe 가 타임아웃 → 비정상 판정 → 재기동(외부 메커니즘이 처리, 본 엔드포인트가 의도한 시나리오). +- **요청 폭주(DDoS)**: 본 메서드는 매우 가볍지만 인증/레이트리밋이 없음. Nginx Ingress 단에서 처리 가정. +- **DB/Redis 다운**: 본 엔드포인트는 영향 없음(의도된 동작). 단, 운영자가 "서비스 정상"으로 오인할 수 있는 한계 — 별도 readiness 필요(11장 참조). +- **잘못된 HTTP 메서드**: `POST /api/health` 등은 405 (Spring 기본). +- **CORS**: `WebConfig` 의 허용 origin 에 따라 브라우저 접근 제한될 수 있음(서버-서버 probe 에는 영향 없음). + +## 10. 테스트 계획 +현재 자동 테스트 없음(TBD). 권장 케이스: +- **Unit/Slice (`@WebMvcTest(HealthController.class)`)**: + - `GET /api/health` → 200 + 본문 `{"status":"ok"}`. + - `POST /api/health` → 405. +- **Smoke**: + - 운영/dev 배포 후 `curl -sf https://www.tasteby.net/api/health` 가 0 종료 코드. +- **모킹**: 불필요 — 의존성 없음. + +## 11. 리스크 & 대안 검토 +- **선택**: 자체 컨트롤러 1개 + 상수 응답. + - 장점: 의존성 0, 인증/필터 우회 명확, K8s probe 와 1:1 매핑 쉬움. + - 단점: deep health(DB/Redis 연결 확인) 부재 → "프로세스는 살아있지만 서비스 불가" 상황 감지 못 함. +- **대안**: + - **Spring Boot Actuator (`/actuator/health`)** — DB/Redis HealthIndicator 자동 구성, 표준화. 보안 노출 범위 제어 필요. → readiness 용도로 향후 도입 후보. + - **K8s readinessProbe 별도 엔드포인트** (`/api/health/ready`) — DB ping, Redis ping 결과 합산. 본 설계의 자연스러운 확장. + - **외부 모니터링(UptimeRobot/Healthchecks.io)** — HTTP 200 만으로 충분히 활용 가능. +- **트레이드오프**: 현재는 운영자 1인 / 트래픽 적음 → 최소 구현이 합리적. Prod 안정화 단계에서 Actuator + readiness 분리 권장. +- **되돌리기 어려운 결정 없음**. + +## 12. 미해결 질문 (Open Questions) +- DB/Redis 연결을 검사하는 readiness 엔드포인트(`/api/health/ready`)를 언제 도입할 것인가? +- Spring Boot Actuator 를 켜고 노출 범위(어떤 엔드포인트, 어떤 인증)를 어떻게 제어할 것인가? +- Prometheus 메트릭/Grafana 대시보드를 운영할 것인가(현재는 PM2/kubectl 로그 의존)? +- K8s probe 구성(initialDelay, periodSeconds, failureThreshold) 표준값은? +- 헬스 응답에 빌드 버전/커밋 SHA를 포함할 것인가(`/api/version` 분리 vs 통합)? +- 본 엔드포인트도 인증/IP 제한 대상으로 둘 필요가 있는가(공개 권장)? diff --git a/docs/design/278-frontend-map/README.md b/docs/design/278-frontend-map/README.md new file mode 100644 index 0000000..47e822b --- /dev/null +++ b/docs/design/278-frontend-map/README.md @@ -0,0 +1,240 @@ + + +# 설계서: 프론트 - 지도 뷰 (#278) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #278 · 관련 ADR: 없음 +> · 구현 파일: `frontend/src/components/MapView.tsx`, `frontend/src/app/page.tsx` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +YouTube 영상에서 추출된 식당들을 사용자의 위치 컨텍스트와 함께 지도 위에 시각화해, "내 주변·관심 지역의 맛집"을 한눈에 탐색할 수 있게 한다. + +## 2. 범위 (Scope) +- **포함** + - Google Maps (vis.gl/react-google-maps) 기반 지도 렌더링. + - Supercluster 를 이용한 마커 클러스터링 (줌 레벨/영역 기반). + - 개별 마커: 식당 이름 라벨 + 음식 카테고리 아이콘 + 채널 색상 (`CHANNEL_COLORS` 8색 팔레트 순환). + - InfoWindow: 식당명, 별점, 카테고리, 주소, 가격, 전화, "상세 보기" 버튼. + - 카메라 이벤트: `idle` → bounds·zoom 추적, `onCameraChanged` → 150ms 디바운스 후 부모로 bounds 전달. + - `flyTo` prop 으로 지역/검색/내 위치로 지도 이동, `selected` 변경 시 자동 pan & zoom 16. + - "내 위치" 버튼 (`onMyLocation`) 및 채널 색상 범례 (좌하단). + - 폐업/임시휴업 상태 시각 표시 (취소선, 회색조). +- **제외 (out of scope)** + - 지도 위 직접 편집 (마커 드래그, 추가 등). + - 경로 검색·길 안내·StreetView. + - 식당 검색 / 필터 로직 자체 (#280 필터 시스템 참조). + - 식당 상세 시트의 본문 (#279 참조). + - 지오코딩 / 주소 → 좌표 변환 (백엔드 책임). + +## 3. 인수조건 (Acceptance Criteria) +- [x] `restaurants` 배열이 주어지면 좌표 기반으로 마커가 렌더링된다. +- [x] 줌 아웃 시 근접 마커들이 카운트가 표시된 원형 클러스터로 묶인다 (`radius=60`, `maxZoom=16`, `minPoints=2`). +- [x] 클러스터 클릭 시 `getClusterExpansionZoom()` 결과로 확대 (최대 18) 및 pan. +- [x] 개별 마커 클릭 시 InfoWindow 가 열리고 `onSelectRestaurant` 가 호출된다. +- [x] InfoWindow "상세 보기" 클릭 시 `onSelectRestaurant` 가 재호출되어 부모가 상세 시트를 연다. +- [x] `selected` prop 변경 시 해당 좌표로 pan 하고 zoom 16 으로 변경하며 InfoWindow 자동 오픈. +- [x] `flyTo` prop 변경 시 해당 좌표로 pan, `zoom` 이 지정되면 변경. +- [x] 카메라 이동(`idle`) 시 bounds·zoom 이 갱신되어 클러스터가 재계산된다. +- [x] `onBoundsChanged` 는 150ms 디바운스 후 호출된다. +- [x] 채널이 2개 이상 있는 데이터셋에서 채널별 색상이 일관되게 부여되고 좌하단 범례가 표시된다. +- [x] `activeChannel` 이 지정되면 해당 채널 색상으로 마커 렌더, 범례도 단일 채널만 노출. +- [x] `business_status === "CLOSED_PERMANENTLY"` 면 마커가 회색·취소선, opacity 0.5. +- [x] `onMyLocation` 콜백이 제공되면 우상단 버튼 노출 (44px 미만이지만 36×36 + 충분한 패딩). +- [x] API 키 부재(`NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` 미설정) 시 빈 키로 APIProvider 가 초기화된다 (지도 미로드). + +## 4. 컨텍스트 & 제약 +- **런타임**: Next.js 16 (App Router) / TypeScript / "use client" 컴포넌트. +- **외부 의존성** + - `@vis.gl/react-google-maps` — `APIProvider`, `Map`, `AdvancedMarker`, `InfoWindow`, `useMap`. + - `supercluster` — 점군 클러스터링. + - Google Maps JS API (mapId=`tasteby-map`, colorScheme=`LIGHT`). + - `@/lib/cuisine-icons` `getCuisineIcon` — Material Symbols 코드. + - `@/components/Icon` — Material Symbols Rounded 렌더러. +- **데이터 컨트랙트**: `Restaurant` (`/lib/api.ts`) — `id, name, latitude, longitude, channels[]?, business_status, rating, rating_count, cuisine_type, address, price_range, phone`. +- **UI/UX 제약** + - Tailwind, `brand-*` 색상 토큰 (오렌지 #E8720C 컬러 직접 사용 포함). + - 모바일 터치 영역 가이드 44×44 px — 단, 지도 위 컨트롤(내 위치 36×36, 마커 라벨)은 일반 룰 예외로 작게 유지 (정보 밀도 우선). + - 모바일 "내주변" 탭은 지도 전용 (목록 없음, BottomSheet 로 상세). + - 데스크탑 기본 `viewMode = "map"` (768px 이상), 모바일은 `"list"` 기본 / "내주변" 시 지도. +- **성능** + - `useMemo` 로 supercluster 인덱스, points, channelColors 캐시. + - `setTimeout` 150ms 디바운스로 `onBoundsChanged` 호출 최소화. + - `idle` 리스너 cleanup 등록 (`google.maps.event.removeListener`). +- **가정** + - 좌표는 백엔드에서 정제되어 (lat ∈ [-90,90], lng ∈ [-180,180]) 들어온다. + - 1회 페치 결과는 최대 500개 (`limit: 500`, page.tsx). + - Google Maps API 키는 `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` 환경변수. + +## 5. 아키텍처 개요 +- **모듈/파일 구조** + - `MapView.tsx` (404 LOC) + - 상수: `SEOUL_CENTER`, `API_KEY`, `CHANNEL_COLORS[8]`. + - 헬퍼: `getChannelColorMap()`, `getClusterSize()`. + - 훅: `useSupercluster()` — index, getClusters, getExpansionZoom. + - 컴포넌트: `MapContent` (지도 내부 — `useMap` 컨텍스트 필요), `MapView` (default export, `APIProvider` 래퍼). + - `app/page.tsx` (소비자) + - 상태: `restaurants`, `selected`, `mapBounds`, `regionFlyTo`, `channelFilter`, `viewMode`, `mobileTab`, `userLoc`. + - 핸들러: `handleBoundsChanged`, `handleSelectRestaurant`, `handleMyLocation`, `computeFlyTo`, `findRegionFromCoords`. +- **I/O ↔ 순수 로직 경계** + - **I/O**: Google Maps API 호출 (`map.panTo`, `setZoom`, `getBounds`), Geolocation API, `setTimeout`/`addListener`. + - **순수**: `getChannelColorMap`, `getClusterSize`, `computeFlyTo`, `findRegionFromCoords`, supercluster 점군 변환·클러스터링 — 입력만으로 결정적. + +``` + ┌────────────────────────┐ + │ app/page.tsx (Home) │ + │ state: restaurants, │ + │ selected, mapBounds, │ + │ regionFlyTo, ... │ + └──────────┬─────────────┘ + │ props + ▼ + ┌─────────────────────────────────────┐ + │ │ + │ └─ APIProvider (Google Maps SDK) │ + │ └─ │ + │ └─ │ + │ ├─ useSupercluster() │ + │ ├─ clusters[] = getClus.. │ + │ ├─ AdvancedMarker (cluster│ + │ │ | individual) │ + │ └─ InfoWindow │ + └─────────────────────────────────────┘ + │ + ▼ + user click marker → onSelectRestaurant(r) + map idle → bounds/zoom 갱신 → clusters 재계산 + onCameraChanged → 150ms debounce → onBoundsChanged +``` + +## 6. 데이터 모델 + +```ts +// props +interface MapViewProps { + restaurants: Restaurant[]; // 좌표 포함, 필수 + selected?: Restaurant | null; // 선택된 식당 — 자동 pan/zoom + onSelectRestaurant?: (r: Restaurant) => void; // 마커/상세보기 클릭 + onBoundsChanged?: (b: MapBounds) => void; // 150ms 디바운스 + flyTo?: FlyTo | null; // 외부에서 지도 이동 요청 + onMyLocation?: () => void; // 우상단 버튼 콜백 + activeChannel?: string; // 채널 필터 active 표시 +} + +export interface MapBounds { + north: number; south: number; east: number; west: number; +} +export interface FlyTo { lat: number; lng: number; zoom?: number; } + +// 내부 +type RestaurantProps = { restaurant: Restaurant }; +type RestaurantFeature = Supercluster.PointFeature; +type ChannelColor = { bg: string; text: string; border: string; arrow: string }; +``` + +- **경계 검증** + - `r.latitude`, `r.longitude` 필수 (NaN 입력 시 supercluster 가 무시). + - `flyTo.zoom` 미지정 시 기존 줌 유지. + - `restaurants` 가 빈 배열이면 클러스터·마커 모두 렌더링되지 않으나 지도 자체는 정상 표시. + - `API_KEY` 빈 문자열 허용 (Google SDK 측에서 에러 표시). + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `getChannelColorMap` | 등장 채널을 8색 팔레트에 순환 매핑 | `(restaurants: Restaurant[]) => Record` | 식당 배열 | 채널명→색상 객체 | 채널 없으면 빈 객체 | 단순 | +| `getClusterSize` | 클러스터 카운트→픽셀 크기 (36/42/48/54) | `(count: number) => number` | 양의 정수 | 픽셀 | 음수/NaN 시 36 | 단순 | +| `useSupercluster` | supercluster 인덱스 + 조회 헬퍼 캐시 | `(restaurants: Restaurant[]) => { getClusters, getExpansionZoom, index }` | 식당 배열 | 인덱스/함수들 | `getClusterExpansionZoom` 예외 시 17 반환 | **복잡** (메모이즈+ref) | +| `MapContent` | 지도 내부 — 카메라 이벤트, 마커/InfoWindow 렌더 | `(props: Omit) => JSX` | props | JSX | `useMap()` null 가드 | **복잡** (3개 useEffect, 상태 4개) | +| `MapView` | 외부 래퍼 — APIProvider, 채널 범례, 내 위치 버튼 | `(props: MapViewProps) => JSX` | props | JSX | API 키 부재 시 빈 지도 | **복잡** (디바운스 타이머) | +| `handleMarkerClick` | 마커 클릭 → InfoWindow + 부모 알림 | `(r: Restaurant) => void` | 식당 | void | — | 단순 | +| `handleClusterClick` | 클러스터 클릭 → 확장 줌으로 이동 | `(clusterId, lng, lat) => void` | id, 좌표 | void | map null 시 no-op | 단순 | +| `handleCameraChanged` | onCameraChanged 디바운스 후 onBoundsChanged 호출 | `(ev: CameraChangedEvent) => void` | bounds 이벤트 | void | onBoundsChanged 미제공 시 no-op | 단순 | +| `computeFlyTo` (page.tsx) | 식당 집합 → 중심점·줌 산출 | `(rests: Restaurant[]) => FlyTo \| null` | 식당 배열 | FlyTo | 빈 배열 → null | 단순 | +| `findRegionFromCoords` (page.tsx) | 좌표 → 최근접 country/city 추정 | `(lat, lng, rests) => {country,city} \| null` | 좌표·식당 | 지역 | 매칭 없으면 null | 단순 | + +> 복잡 표시 함수는 향후 `fn-.md` 분리 후보. 현재는 본 문서로 일괄 관리. + +## 8. 흐름 / 알고리즘 + +### 8.1 초기 마운트 +1. `Home` 마운트 → 데스크탑이면 `viewMode = "map"`. Geolocation 으로 `userLoc` 갱신. +2. `api.getRestaurants({ limit: 500 })` → `restaurants` 세팅. +3. `` 마운트 → `` 가 Google SDK 로드. +4. `` 의 `defaultCenter = SEOUL_CENTER`, `defaultZoom = 13` 으로 초기 카메라 결정. +5. `MapContent` 내 `useEffect`: `map.addListener("idle", ...)` 등록. 초기 bounds·zoom 즉시 1회 세팅. +6. `useSupercluster` 가 points→ index 빌드. `clusters = getClusters(bounds, floor(zoom))`. + +### 8.2 사용자 상호작용 +- **마커 클릭**: `handleMarkerClick(r)` → `setInfoTarget(r)` → `onSelectRestaurant(r)` → 부모 `setSelected, setShowDetail(true)`. +- **클러스터 클릭**: `handleClusterClick(cluster_id, lng, lat)` → `getExpansionZoom(id)` → `map.panTo` + `setZoom(min(expansion,18))` → 다음 `idle` 에서 클러스터 재계산. +- **카메라 이동**: `onCameraChanged` → 150ms 디바운스 → `onBoundsChanged({north,south,east,west})` → 부모가 `setMapBounds` → "내위치" 필터 적용 시 `filteredRestaurants` 재계산. +- **외부 선택**(목록 클릭): `selected` 변경 → `useEffect` 가 `panTo + setZoom(16) + setInfoTarget(selected)`. +- **flyTo**(지역 필터, 검색, 내 위치): `flyTo` 변경 → `useEffect` 가 `panTo + setZoom(flyTo.zoom)`. + +### 8.3 클러스터링 알고리즘 +- supercluster: 점들을 KD-tree로 인덱싱 → 줌 레벨별 그리드 (radius=60px) 내 점들을 클러스터로 병합. +- `getClusters([west,south,east,north], floor(zoom))` → `Cluster` (cluster=true, point_count) 또는 `Point` (cluster=false, restaurant) feature 반환. +- 클러스터는 점수에 따라 크기 (36/42/48/54px) 결정. + +### 8.4 채널 색상 부여 +- `getChannelColorMap`: 등장 채널을 Set 으로 수집 → 8색 팔레트 순환 매핑. +- 개별 마커는 `activeChannel ∈ r.channels` 이면 해당 채널, 아니면 `r.channels[0]` 색상. +- `selected` 마커는 파란색 (#2563eb) 오버라이드. + +## 9. 엣지케이스 & 에러 처리 + +| 경계 | 처리 | +|------|------| +| 빈 식당 배열 | 마커·클러스터 0개, 지도만 표시. 범례 미노출. | +| `r.channels` undefined | 기본 색상 `CHANNEL_COLORS[0]` (amber). | +| `getClusterExpansionZoom` 예외 (id 만료) | catch → 17 반환. | +| `flyTo` null | `useEffect` 가 early-return (`!map \|\| !flyTo`). | +| `selected` null | pan/zoom 실행 안 함. `infoTarget` 은 사용자가 닫기 전까지 유지. | +| `onBoundsChanged` 미제공 | `handleCameraChanged` early-return — 디바운스도 등록 안 함. | +| 폐업 (`CLOSED_PERMANENTLY`) | bg=`#f3f4f6`, text=`#9ca3af`, opacity=0.5, text-decoration=line-through. | +| 임시휴업 (`CLOSED_TEMPORARILY`) | InfoWindow 에 노란 뱃지만 표시 (마커 자체는 정상). | +| API 키 부재 | APIProvider 가 빈 키로 초기화 — 지도가 로드되지 않고 콘솔 경고. UI 깨지지 않음. | +| 모바일 InfoWindow 가독성 | InfoWindow 에 `colorScheme: "light"` 명시로 다크모드에서도 흰 배경 보장. | +| 카메라 idle 리스너 누수 | 컴포넌트 언마운트 시 `google.maps.event.removeListener(listener)` 호출. | +| 디바운스 타이머 누수 | `boundsTimerRef.current` 가 다음 호출 시 clear, 컴포넌트 언마운트 미처리 (허용 — 콜백만 비활성). | + +## 10. 테스트 계획 +- **현재 자동화 테스트 없음 (TBD)** — 수동 QA 시나리오: + - [Unit·예상] `getChannelColorMap`: 채널 8개·9개 입력 시 색상 순환 매핑 검증. + - [Unit·예상] `getClusterSize`: 0, 9, 10, 49, 50, 99, 100 입력별 36/36/42/42/48/48/54 검증. + - [Unit·예상] `computeFlyTo`: 분산도(spread) 임계값별 zoom 계산. + - [Integration·예상] React Testing Library + Google Maps mock 으로 마커 렌더링 개수, 클러스터 카운트, onSelectRestaurant 호출 검증. +- **수동 QA** + - 데스크탑 768px+ 진입 시 지도 모드 기본. + - 클러스터 클릭 → 확대 → 개별 마커 분리. + - 식당 목록 클릭 → 지도가 해당 좌표로 이동·줌 16. + - 영역 필터 ON → 카메라 이동 시 100ms 후 식당 수 변경. + - 채널 필터 적용 → 단일 채널 색상만 범례 표시. + - 폐업 식당 — 라벨 회색·취소선 확인. + - 모바일 "내주변" 탭 — 지도 전체, 좌상단 "내 주변 N개" 배지. +- **모킹/드라이런** + - Google Maps SDK: jsdom 환경 한계로 e2e (Playwright) 권장. + - Geolocation: `navigator.geolocation` mock 으로 success/error 분기 검증. + +## 11. 리스크 & 대안 검토 +- **선택**: `@vis.gl/react-google-maps` + supercluster. + - 장점: React 공식 권장, 선언적 마커/이벤트, 트리쉐이킹 가능. + - 단점: vendor lock-in (Google), 키 비용. +- **대안 검토** + - **Mapbox GL JS / MapLibre**: 더 빠른 벡터 타일, GL 클러스터 빌트인. 단, 한국 지도 디테일이 Google 만 못함. + - **Naver Maps / Kakao Maps**: 국내 디테일 강점. 단, 해외 식당(일본·유럽) 미지원. + - **자체 Canvas/Deck.gl**: 풀 컨트롤. 단, 개발 비용·유지보수 부담 큼. +- **트레이드오프**: 글로벌 식당 데이터를 다루는 서비스 특성상 Google Maps 유지. 단, Places/Geocoding 호출은 백엔드에서만 처리해 키 노출/비용 통제. +- **되돌리기 어려운 결정**: mapId, AdvancedMarker 의존 — 추후 변경 시 마커 렌더링 전반 재작성 필요. ADR 후보. + +## 12. 미해결 질문 (Open Questions) +- 500개 limit 이상 데이터를 보여주려면 viewport 기반 페이지네이션 (bounds-aware 페치)로 전환 필요? +- supercluster `radius=60`/`maxZoom=16` 값은 어떤 데이터셋 규모를 가정? 채택 근거 ADR 필요. +- `activeChannel` 외 채널은 회색으로 dim 처리할 것인지 (현재는 색상 그대로 유지)? +- InfoWindow 모바일 가독성 — BottomSheet 와 중복 UI 인데, 모바일에서는 InfoWindow 를 끄는 게 옳은지? +- 다크모드 시 mapId 별도 (다크 스타일) 적용 여부 — 현재 `colorScheme="LIGHT"` 고정. +- "내 위치" 버튼이 36×36 으로 44px 가이드라인 미달 — 패딩 확대 또는 BottomNav 통합 필요한지? +- 좌표 동일 식당 (지점 다수) 표시 정책 미정 — 클러스터로만 표현되어 개별 식별 어려움. diff --git a/docs/design/279-frontend-restaurant-detail/README.md b/docs/design/279-frontend-restaurant-detail/README.md new file mode 100644 index 0000000..03f0c6e --- /dev/null +++ b/docs/design/279-frontend-restaurant-detail/README.md @@ -0,0 +1,286 @@ + + +# 설계서: 프론트 - 식당 상세 시트 (#279) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #279 · 관련 ADR: 없음 +> · 구현 파일: `frontend/src/components/RestaurantDetail.tsx`, `frontend/src/components/BottomSheet.tsx`, `frontend/src/components/RestaurantList.tsx` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +식당 한 곳에 대한 메타 정보(위치·평점·예약)와 컨텍스트(관련 YouTube 영상, 음식 태그, 평가, 게스트)를 한 화면에서 빠르게 제공해, 탐색→방문 결정 전환을 돕는다. 모바일에서는 지도와 공존 가능하도록 BottomSheet 로 띄운다. + +## 2. 범위 (Scope) +- **포함** + - `RestaurantDetail`: 식당 메타 + 관련 영상 + 찜 토글 + 리뷰/메모 섹션 마운트. + - `BottomSheet`: 모바일 전용 3-snap (PEEK 40% / HALF 55% / FULL 92%) 드래그 시트, 백드롭 클릭/플릭으로 닫기. + - `RestaurantList`: 식당 카드 리스트 (3줄 레이아웃 — 이름/지역/별점 · 카테고리/가격/채널 · 음식 태그). + - 데스크탑: 사이드바 내 inline 상세. 모바일: BottomSheet 내 상세. + - 외부 링크: Google Maps, 네이버 지도(국내만), 테이블링, 캐치테이블, 전화걸기. + - 로그인 시 찜 토글 (`POST /favorites/{id}/toggle`). +- **제외 (out of scope)** + - 리뷰·메모 작성 UI 자체 (각각 `ReviewSection`, `MemoSection` — #281). + - 지도 마커·인포윈도우 (#278). + - 검색·필터 UI (#280). + - 결제·예약 트랜잭션 (외부 링크로 위임). + +## 3. 인수조건 (Acceptance Criteria) +### RestaurantDetail +- [x] `restaurant.id` 변경 시 `api.getRestaurantVideos(id)` 호출, 로딩 스켈레톤 표시 후 비디오 렌더링. +- [x] 로그인 토큰 있을 때만 `api.getFavoriteStatus(id)` 호출 후 찜 하트 색상 결정. +- [x] 비로그인 사용자에게는 찜 버튼 자체가 숨겨진다. +- [x] 찜 토글 중에는 버튼 disabled, API 응답으로 상태 동기화. +- [x] 평점 있을 때 별 (`★`) × round(rating) + 별점 숫자 + 카운트 표시. +- [x] 영업 상태 뱃지: `CLOSED_PERMANENTLY` → 빨간 "폐업", `CLOSED_TEMPORARILY` → 노란 "임시휴업". +- [x] `google_place_id` 있을 때 Google Maps 외부 검색 링크 노출. 한국 지역이거나 region 없으면 네이버 지도 링크 동시 노출. +- [x] `tabling_url`, `catchtable_url` 값이 "NONE" 이 아니면 각각 컬러 풀폭 CTA 버튼 노출. +- [x] 비디오 없으면 "관련 영상이 없습니다", 있으면 각 비디오에 채널 뱃지·발행일·제목 링크·`foods_mentioned` 태그·`evaluation.text`·`guests` 표시. +- [x] 비디오 1개 이상이면 하단에 크리에이터 응원 문구 박스 표시. +- [x] 우상단 X 버튼 클릭 시 `onClose` 호출. + +### BottomSheet +- [x] `open=true` 시 PEEK(40vh) 로 열림. `open=false` 면 null 반환 (DOM 미존재). +- [x] 핸들 또는 시트 영역 터치 드래그로 높이 변경, 종료 시 PEEK/HALF/FULL 중 최근접 스냅. +- [x] 빠른 하향 플릭 (velocity > 0.5) 이고 HALF 미만이면 `onClose` 호출. +- [x] PEEK*0.6 = 24vh 이하로 드래그되면 `onClose` 호출. +- [x] FULL 상태에서 컨텐츠 스크롤 중일 때 (scrollTop > 0) 터치 인터셉트하지 않음 → 컨텐츠 스크롤 우선. +- [x] 백드롭 (검은 반투명) 클릭 시 닫힘. 백드롭 투명도는 높이 비례 (`Math.min(1, (height-0.2)*2)`). +- [x] 데스크탑(md+) 에서는 `md:hidden` 으로 숨김. + +### RestaurantList +- [x] `loading=true` 시 `RestaurantListSkeleton`. +- [x] 빈 배열이면 "표시할 식당이 없습니다". +- [x] 카드 클릭 시 `onSelect(r)`. +- [x] `selectedId === r.id` 면 brand-50 배경 + brand-300 보더 하이라이트. +- [x] `keyPrefix` 로 데스크탑(`d-`)/모바일(`m-`) 키 충돌 방지. +- [x] `foods_mentioned` 가 5개 초과면 5개 + "+N" 표시. + +## 4. 컨텍스트 & 제약 +- **런타임**: Next.js 16 App Router, TypeScript, "use client". +- **외부 의존성** + - `@/lib/api`: `api.getRestaurantVideos`, `api.getFavoriteStatus`, `api.toggleFavorite`, `getToken()`. + - 자식 컴포넌트: `ReviewSection`, `MemoSection`, `RestaurantDetailSkeleton`, `RestaurantListSkeleton`. + - `@/lib/cuisine-icons` `getCuisineIcon` (Material Symbols 매핑). + - `@/components/Icon` (Material Symbols Rounded). +- **데이터 컨트랙트** + - `Restaurant` (id, name, rating, rating_count, cuisine_type, address, region, price_range, phone, business_status, google_place_id, tabling_url, catchtable_url, channels[], foods_mentioned[]). + - `VideoLink` (video_id, title, url, published_at, foods_mentioned[], evaluation: Record, guests[], channel_name, channel_id). +- **UI/UX 제약** + - Tailwind, `brand-*` 색상 토큰 (favorited = rose-500). + - 모바일 터치 영역 ≥ 44×44 px — 찜 버튼 (`p-1.5 -m-1.5`) 으로 패딩 확장, 카드 전체가 버튼. + - 다크모드 지원 (`dark:` 변형 클래스). + - 모바일 BottomSheet `bg-surface/85 backdrop-blur-xl` (Saffron 디자인 시스템). +- **성능** + - 비디오 페치는 `restaurant.id` 변경 시에만 (의존성 배열). + - BottomSheet 드래그는 transition 비활성 (`dragging ? "none" : "0.3s"`). + - 클로즈 애니메이션 없음 (즉시 unmount) — 단점이지만 단순성 우선. +- **가정** + - 백엔드는 `evaluation` 을 `{ text: "..." }` 형태로 정규화 (`JsonUtil.normalizeEvaluation`, 300자 제한, 평문→래핑). + - `getToken()` 은 동기 함수, localStorage 또는 메모리 토큰 반환. + - 외부 링크 URL "NONE" 문자열은 백엔드 명시적 미존재 마커. + +## 5. 아키텍처 개요 +- **모듈/파일 구조** + - `RestaurantDetail.tsx` (265 LOC) + - 상태: `videos`, `loading`, `favorited`, `favLoading`. + - 자식: ``, ``. + - `BottomSheet.tsx` (117 LOC) + - 상수: `SNAP_POINTS = { PEEK:0.4, HALF:0.55, FULL:0.92 }`, `VELOCITY_THRESHOLD = 0.5`. + - 상태: `height`, `dragging`, ref `dragState`. + - `RestaurantList.tsx` (104 LOC) — stateless 표현형 컴포넌트. +- **데이터 흐름** + +``` + ┌──────────────── page.tsx ─────────────────┐ + │ state: selected, showDetail │ + │ handlers: handleSelectRestaurant, │ + │ handleCloseDetail │ + └───────┬──────────────────────────┬────────┘ + │ desktop sidebar │ mobile + ▼ ▼ + ┌─────────────────┐ ┌─────────────────────────┐ + │ │ │ │ void; +} + +// BottomSheet props +interface BottomSheetProps { + open: boolean; + onClose: () => void; + children: React.ReactNode; +} + +// 내부 상태 +type DragState = { + startY: number; startH: number; lastY: number; lastTime: number; +}; + +// RestaurantList props +interface RestaurantListProps { + restaurants: Restaurant[]; + selectedId?: string; + onSelect: (r: Restaurant) => void; + loading?: boolean; + keyPrefix?: string; +} + +// API 응답 (api.ts) +export interface VideoLink { + video_id: string; title: string; url: string; + published_at: string | null; + foods_mentioned: string[]; + evaluation: Record; // { text: "..." } 형태 기대 + guests: string[]; + channel_name: string | null; channel_id: string | null; +} +``` + +- **경계 검증** + - `restaurant.id` (UUID 32자) — 비어 있으면 API 호출 실패 — `setVideos([])` 로 안전 기본값. + - `evaluation` 키 `text` 만 사용. 다른 키는 무시. + - `tabling_url === "NONE"` / `null` / `""` 모두 미노출 처리 (`!== "NONE"` + 진릿값). + - `foods_mentioned` 가 5개 초과 시 잘라내고 "+N" 표시. + - BottomSheet height 클램프: `[0.1, 0.92]`. + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `RestaurantDetail` | 식당 메타+영상+찜+리뷰/메모 마운트 | `(props: RestaurantDetailProps) => JSX` | restaurant, onClose | JSX | 비디오 페치 실패 시 빈 배열, 찜 페치 실패 시 false 유지 | **복잡** (2개 useEffect, 4개 상태) | +| `useEffect (load videos)` | id 변경 시 비디오 페치 | `() => void` (deps: [restaurant.id]) | id | setVideos | catch → `[]` | 단순 | +| `useEffect (load favorite)` | 토큰 있을 때 찜 상태 페치 | `() => void` (deps: [restaurant.id]) | id, token | setFavorited | catch → 무시 | 단순 | +| `handleToggleFavorite` | 찜 토글 → 서버 응답으로 상태 동기화 | `async () => void` | — | void | 토큰 없으면 early-return, API 에러 catch | 단순 | +| `BottomSheet` | 3-snap 모바일 바텀 시트 | `(props: BottomSheetProps) => JSX \| null` | open, onClose, children | JSX/null | open=false → null | **복잡** (3 터치 핸들러, 드래그 상태기계) | +| `snapTo` | 드래그 종료 위치+속도 → 최근접 스냅/close | `(h: number, velocity: number) => void` | 높이비율, 속도 | void | h < PEEK*0.6 → close. velocity>0.5 & h void` | 터치 | void | scrollTop>0 & FULL 근접 시 무시 | 단순 | +| `onTouchMove` | 델타Y → 높이 비율 갱신 | `(e: TouchEvent) => void` | 터치 | void | dragging=false 시 early-return | 단순 | +| `onTouchEnd` | 드래그 종료, velocity 계산 후 snapTo | `() => void` | — | void | dragging=false 시 early-return | 단순 | +| `RestaurantList` | 식당 카드 리스트 렌더 | `(props: RestaurantListProps) => JSX` | restaurants, ... | JSX | loading → Skeleton, 빈 배열 → 안내 문구 | 단순 | + +> 복잡 표시 함수는 fn-도큐먼트 분리 후보. 특히 `BottomSheet` 드래그 상태기계는 ADR/fn-doc 가치 있음. + +## 8. 흐름 / 알고리즘 + +### 8.1 데스크탑 상세 표시 흐름 +1. 사용자가 사이드바 카드 (또는 지도 마커) 클릭. +2. `handleSelectRestaurant(r)` → `setSelected(r)`, `setShowDetail(true)`. +3. 사이드바 컨텐츠가 `RestaurantList` → `RestaurantDetail` 로 교체. +4. `RestaurantDetail` 마운트 → 비디오/찜 페치. +5. X 또는 다른 카드 클릭 시 `onClose` / 새로운 selected 로 전환. + +### 8.2 모바일 상세 표시 흐름 (BottomSheet) +1. 카드/마커 클릭 → `showDetail=true, selected=r`. +2. `` 마운트 → `useEffect` 가 `setHeight(0.4)` (PEEK). +3. 사용자 핸들 드래그 → `onTouchMove` 가 height 라이브 업데이트. +4. 터치 종료 → `onTouchEnd` 가 velocity 계산: + ``` + dt = (now - lastTime)/1000 || 0.1 + dy = (startY - lastY) / vh + velocity = -dy / dt // 양수 = 하향 + ``` +5. `snapTo(height, velocity)`: + - `velocity > 0.5 && height < 0.55` → onClose + - `height < 0.24` (PEEK*0.6) → onClose + - else → {0.4, 0.55, 0.92} 중 최근접 스냅 (setHeight) +6. FULL 상태 + 컨텐츠 스크롤 중이면 `onTouchStart` 가 드래그 시작을 막아 컨텐츠 스크롤이 우선. + +### 8.3 찜 토글 흐름 +1. `getToken()` 있으면 마운트 시 `getFavoriteStatus(id)` 호출. +2. 응답 `{ favorited: bool }` → setFavorited. +3. 사용자가 하트 클릭 → `favLoading=true` → `toggleFavorite(id)` → 응답으로 favorited 갱신 → `favLoading=false`. +4. 토큰 없으면 버튼 렌더되지 않아 클릭 자체 불가능. + +### 8.4 영상 카드 렌더링 +- `videos.map((v) => …)`: + - 채널 뱃지 (있을 때) + 발행일 (slice(0,10)). + - 제목 링크 (외부 새 탭, `noopener noreferrer`). + - `foods_mentioned` 태그 (brand-50 배경). + - `evaluation.text` 본문. + - `guests.length>0` 시 "게스트: A, B". +- `videos.length>0` 시 "구독·좋아요 응원" 안내 박스 출력. + +## 9. 엣지케이스 & 에러 처리 + +| 경계 | 처리 | +|------|------| +| 비디오 페치 실패 | catch → `setVideos([])` → "관련 영상이 없습니다" 메시지 | +| 찜 상태 페치 실패 | catch → 무시 (favorited=false 유지) | +| 토글 API 실패 | catch → 상태 변경 없음, `favLoading=false` | +| 평점 null | 별점 row 미노출 | +| 주소·전화·가격 null | 각 row 미노출 (단축 렌더링) | +| `tabling_url === "NONE"` | CTA 미노출 (`url !== "NONE"` && truthy 가드) | +| `evaluation` 형식 비표준 (`{text:undef}`) | `v.evaluation?.text` optional chain → undefined → falsy 미렌더 | +| `foods_mentioned` 빈 배열 | 태그 row 미렌더 (`v.foods_mentioned?.length > 0`) | +| BottomSheet open 토글 시 컨텐츠 깜빡임 | 닫힐 때 즉시 unmount (애니메이션 X) — 단순성 우선 | +| 드래그 중 빠른 시간차 (dt=0) | `dt = ... || 0.1` 가드로 NaN 방지 | +| `vh` 변동 (모바일 주소창) | `window.innerHeight` 매 터치마다 재조회 → 안전 | +| 데스크탑에서 BottomSheet 노출 | `md:hidden` 클래스로 차단 | +| Region 한국이 아닌데 google_place_id 있음 | 네이버 링크 미노출 (`region.split("|")[0] === "한국"` 가드) | +| 토큰 만료 (401) | 토글/페치 catch 만 처리 — UI 는 변경 안 함 (재로그인 유도는 미구현) | + +## 10. 테스트 계획 +- **현재 자동화 테스트 없음 (TBD)** — 수동 QA + 향후 RTL+Jest 후보: + - [Unit·예상] `snapTo(0.45, 0)` → setHeight(0.4) (PEEK 가 더 가까움). + - [Unit·예상] `snapTo(0.5, 0)` → setHeight(0.55) (HALF). + - [Unit·예상] `snapTo(0.3, 0.6)` → onClose (빠른 하향 + HALF 미만). + - [Unit·예상] `snapTo(0.2, 0)` → onClose (PEEK*0.6 미만). + - [Unit·예상] `RestaurantList`: loading 시 Skeleton, 빈 배열 시 안내, selectedId 일치 시 하이라이트. +- **수동 QA** + - 비로그인: 찜 하트 미노출. + - 로그인 후 토글: 하트 색 즉시 변화. + - 영상 0개 식당 진입 → "관련 영상이 없습니다". + - 폐업 식당 진입 → 빨간 뱃지. + - 모바일 BottomSheet — 핸들 드래그, 백드롭 클릭, 빠른 플릭, FULL 컨텐츠 스크롤. + - 외부 링크 — 새 탭 열림, referrer 차단. +- **모킹/드라이런** + - `api.*` 함수는 MSW 또는 jest mock 으로 대체. + - `getToken()` 모킹으로 비로그인 분기 검증. + - 터치 이벤트는 `@testing-library/user-event` 또는 Playwright. + +## 11. 리스크 & 대안 검토 +- **선택 1**: BottomSheet 를 직접 구현. + - 장점: 의존성 0, 디자인 자유, 번들 사이즈 최소. + - 단점: 접근성 (포커스 트랩, ESC 키 닫기, ARIA) 미흡, 키보드 사용자 배려 부족. + - **대안**: `react-spring-bottom-sheet`, `@radix-ui/react-dialog`, `vaul`. 추후 접근성 요구가 강해지면 vaul 로 마이그레이션 검토. +- **선택 2**: RestaurantDetail 이 자체적으로 API 페치. + - 장점: 컴포넌트 자급자족, 캐싱은 브라우저에 위임. + - 단점: 동일 식당 반복 진입 시 매번 페치, prefetch 어려움. + - **대안**: React Query/SWR 도입 (캐싱·재시도). 사용량 증가 시 ADR. +- **선택 3**: 외부 링크 (Google/네이버/테이블링/캐치테이블) 직접 노출. + - 장점: 사용자 친숙, 백엔드 부담 없음. + - 단점: 트래픽이 외부로 빠짐, 전환 추적 어려움. +- **되돌리기 어려운 결정**: BottomSheet 스냅포인트 0.4/0.55/0.92 — 변경 시 사용자 근육 기억 교란. ADR 후보. + +## 12. 미해결 질문 (Open Questions) +- 데스크탑에서도 모바일과 동일한 시트 UX 제공 여부 (현재 inline sidebar 만). +- 비디오 카드 클릭 시 임베드 플레이어 인-앱 재생할지, 새 탭만 유지할지? +- 찜 외에 "방문 예정", "재방문" 같은 다중 상태 지원할지? +- evaluation 점수(1-5)도 함께 보여줄지 (현재 text 만 노출). +- 영상이 10개 이상인 경우 "더보기" 페이지네이션 필요? +- ESC 키, 백버튼(모바일 안드로이드)으로 BottomSheet 닫기 — 구현 필요? +- 동일 식당 (지점 다수) 통합 표시 정책. +- 다국어 (영어/일본어 식당 이름) i18n 처리 미정. diff --git a/docs/design/280-frontend-filter/README.md b/docs/design/280-frontend-filter/README.md new file mode 100644 index 0000000..39ab8bb --- /dev/null +++ b/docs/design/280-frontend-filter/README.md @@ -0,0 +1,354 @@ + + +# 설계서: 프론트 - 필터 시스템 (FilterSheet + SearchBar) (#280) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #280 · 관련 ADR: 없음 +> · 구현 파일: `frontend/src/components/FilterSheet.tsx`, `frontend/src/components/SearchBar.tsx`, 호출부: `frontend/src/app/page.tsx` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +500+ 식당 데이터에서 사용자가 원하는 조건(채널·음식 장르·가격대·지역·지도 영역·내 주변)으로 빠르게 좁혀가도록, 모바일·데스크탑 모두 일관된 필터 UX 를 제공한다. + +## 2. 범위 (Scope) +- **포함** + - **검색**: `SearchBar` — 단일 입력, 엔터/제출 시 `hybrid` 모드로 `api.search` 호출. + - **채널 필터**: 가로 스크롤 카드 (서버 사이드 — `getRestaurants({channel})`). + - **음식 장르 필터**: 6개 카테고리 × 다중 아이템 (`CUISINE_TAXONOMY`), 카테고리만 또는 카테고리|아이템 형태. + - **가격대 필터**: 5단계 그룹 (저렴/가성비/보통/프리미엄/럭셔리), 정규식 매칭. + - **지역 필터**: 3-level 캐스케이드 (country → city → district), pipe-delimited region 파싱 + 자동 지도 fly-to. + - **지도 영역 필터** (`boundsFilterOn`): 카메라 bounds 또는 내 위치 4km 반경. + - **내 위치 토글**: Geolocation 으로 userLoc 갱신 + fly-to. + - **데스크탑 필터바**: native `` (커스텀 X) — 접근성 무료. + - `touch-manipulation` 으로 더블탭 줌 비활성. +- **상태 동기화 규칙** (page.tsx) + - 검색 → 필터 모두 초기화. + - "내위치" ON → 필터(음식/가격/지역) 초기화. + - 음식/가격/지역 ON → "내위치" 해제 (서로 배타적). + - 지역 country 변경 → city/district 초기화 + boundsFilter 해제. + - city 변경 → district 초기화. +- **가정** + - 백엔드는 region 문자열을 일관된 포맷으로 저장. + - cuisine_type 의 카테고리 부분은 `CUISINE_TAXONOMY` 와 100% 일치. + - price_range 의 표기는 정규식 5종에 의해 거의 모두 매칭됨 (미스매치 시 미표시 — 안전 기본값). + +## 5. 아키텍처 개요 +- **모듈/파일 구조** + - `SearchBar.tsx` (40 LOC): 입력 + 제출. + - `FilterSheet.tsx` (112 LOC): 모바일 바텀시트 옵션 선택기. + - `page.tsx` (1513 LOC, 일부): + - 상수: `CUISINE_TAXONOMY`, `PRICE_GROUPS`. + - 헬퍼: `matchCuisineFilter`, `matchPriceGroup`, `parseRegion`, `buildRegionTree`, `computeFlyTo`, `findRegionFromCoords`. + - 상태: 8개 필터 상태 (`channelFilter`, `cuisineFilter`, `priceFilter`, `countryFilter`, `cityFilter`, `districtFilter`, `boundsFilterOn`, `isSearchResult`). + - 파생: `regionTree`, `countries`, `cities`, `districts`, `filteredRestaurants` (useMemo). + - 핸들러: `handleSearch`, `handleCountryChange`, `handleCityChange`, `handleDistrictChange`, `handleReset`, `handleMyLocation`. + - FilterSheet 마운트 (홈/리스트 탭에서 채널/시/도/구/장르/가격). + +- **I/O ↔ 순수 경계** + - **I/O**: `api.search`, `api.getRestaurants`, `navigator.geolocation`, `window.innerWidth`, body 스타일 변경. + - **순수**: `matchCuisineFilter`, `matchPriceGroup`, `parseRegion`, `buildRegionTree`, `computeFlyTo`, `findRegionFromCoords`, `filteredRestaurants` 계산. + +``` + ┌─────────────────────────────────────┐ + │ page.tsx (Home) │ + │ filter state (8 fields) │ + └────────┬────────────────────┬───────┘ + │ │ + ┌──────────────┴────┐ ┌─────────┴───────────┐ + ▼ ▼ ▼ ▼ + ┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐ + │ │ │ filter pills │ │ filtered list / │ + │ onSearch ─────┼──▶ │ (mobile) │ │ MapView │ + └────────────────┘ │ channel cards │ │ (consumes │ + │ native select │ │ filteredRest..) │ + │ (desktop) │ └──────────────────┘ + └──┬──────────────┘ + │ click + ▼ + ┌──────────────────┐ + │ │ + │ open/onChange │ + │ (mobile only) │ + └──────────────────┘ + + applyFilters: + restaurants ─▶ channel? (server) ─▶ cuisine? ─▶ price? ─▶ region? ─▶ bounds? + ─▶ sort(distance asc, rating desc) ─▶ filteredRestaurants +``` + +## 6. 데이터 모델 + +```ts +// SearchBar +interface SearchBarProps { + onSearch: (query: string, mode: "keyword" | "semantic" | "hybrid") => void; + isLoading?: boolean; +} + +// FilterSheet +export interface FilterOption { + label: string; + value: string; + group?: string; // 그룹 헤더로 표시 (sticky) +} +interface FilterSheetProps { + open: boolean; + onClose: () => void; + title: string; + options: FilterOption[]; + value: string; + onChange: (value: string) => void; +} + +// page.tsx 필터 상태 +type FilterState = { + channelFilter: string; // 채널 이름. 서버에 전달 + cuisineFilter: string; // "한식" 또는 "한식|국밥/해장국" + priceFilter: string; // PRICE_GROUPS.label + countryFilter: string; // "한국", "일본", ... + cityFilter: string; // "서울특별시", ... + districtFilter: string; // "강남구", ... + boundsFilterOn: boolean; + isSearchResult: boolean; // 검색 결과 모드 (다른 필터 무시) +}; + +// 분류 체계 +const CUISINE_TAXONOMY: { category: string; items: string[] }[] = [ + { category: "한식", items: ["백반/한정식", "국밥/해장국", ...] }, + { category: "일식", items: ["스시/오마카세", ...] }, + { category: "중식", items: ["중화요리", ...] }, + { category: "양식", items: ["파스타/이탈리안", ...] }, + { category: "아시아", items: ["베트남", ...] }, + { category: "기타", items: ["치킨", "카페/디저트", ...] }, +]; + +const PRICE_GROUPS: { label: string; test: (p: string) => boolean }[] = [ + { label: "저렴 (~5천원)", test: (p) => /저렴|착한|[3-5]천원대?$|^\d천원$/.test(p) }, + { label: "가성비 (5천~1만원)", test: (p) => /가성비|만원 이하|[6-9]천원|^1만원대$|^[5-9],?\d{3}원/.test(p) }, + { label: "보통 (1~3만원)", test: (p) => /[1-2]만원대|1-[23]만|.../.test(p) }, + { label: "프리미엄 (3~5만원)", test: (p) => /[3-4]만원대?|.../.test(p) }, + { label: "럭셔리 (5만원~)", test: (p) => /[5-9]만원|고가|10만원|.../.test(p) }, +]; +``` + +- **경계 검증** + - `query.trim()` 빈 문자열 차단. + - `region` 파싱: pipe 가 없으면 country 만, "나라" 더미 값은 무시. + - 검색 모드는 `"keyword" | "semantic" | "hybrid"` 만 (현재 hybrid 만 사용). + - 필터 값은 모두 string (빈 문자열 = 해제). + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `SearchBar` | 검색 입력 + 제출 폼 | `(props: SearchBarProps) => JSX` | onSearch, isLoading | JSX | 빈 쿼리 무시 | 단순 | +| `handleSubmit` (SearchBar) | submit 이벤트 → onSearch | `(e: FormEvent) => void` | event | void | trim 후 빈 문자열이면 미호출 | 단순 | +| `FilterSheet` | 모바일 옵션 선택 바텀시트 | `(props: FilterSheetProps) => JSX \| null` | props | JSX/null | open=false → null | **복잡** (body lock effect, 그룹화) | +| `handleSelect` (FilterSheet) | 옵션 선택 → onChange + onClose | `(v: string) => void` | value | void | — | 단순 | +| `matchCuisineFilter` | 식당 cuisine 이 필터에 매치되는지 | `(cuisineType: string\|null, filter: string) => boolean` | 식당 타입, 필터 값 | bool | null 입력 시 false | 단순 | +| `matchPriceGroup` | 식당 price 가 그룹 정규식에 매치되는지 | `(priceRange: string\|null, group: string) => boolean` | 가격 문자열, 그룹 라벨 | bool | null 입력/그룹 미발견 시 false | 단순 | +| `parseRegion` | "나라\|시\|구" 파싱 | `(region: string\|null) => {country,city,district}\|null` | region | 객체/null | null 입력 시 null | 단순 | +| `buildRegionTree` | 식당들로부터 3-level 트리 구성 | `(restaurants: Restaurant[]) => Map>>` | 식당 배열 | 중첩 Map | "나라" 더미 제외 | 단순 | +| `computeFlyTo` | 식당 집합 → centroid + spread→zoom | `(rests: Restaurant[]) => FlyTo\|null` | 식당 배열 | FlyTo | 빈 배열 → null | 단순 | +| `findRegionFromCoords` | 사용자 좌표 → 최근접 country/city | `(lat, lng, rests) => {country,city}\|null` | 좌표·식당 | 객체/null | 매칭 없으면 null | 단순 | +| `handleSearch` | 검색 → 결과 세팅 + 필터 리셋 + fly-to | `async (q, mode) => void` | query, mode | void | API 에러 catch | **복잡** (여러 상태 동시 갱신) | +| `handleCountryChange` | 나라 변경 → city/district 리셋 + fly-to | `(country: string) => void` | country | void | "" 이면 flyTo=null | 단순 | +| `handleCityChange` | 시/도 변경 → district 리셋 + fly-to | `(city: string) => void` | city | void | "" 이면 country 레벨 fly-to | 단순 | +| `handleDistrictChange` | 구/군 변경 → fly-to | `(district: string) => void` | district | void | "" 이면 city 레벨 fly-to | 단순 | +| `handleReset` | 모든 필터 + 결과 초기화 | `() => void` | — | void | API 에러 catch | 단순 | +| `filteredRestaurants` | 모든 필터 적용 + 정렬 | useMemo | 8개 필터 + restaurants + userLoc | Restaurant[] | 검색 결과면 다른 필터 무시 | **복잡** (다중 조건 + 정렬) | + +> 복잡 표시 함수는 fn-도큐먼트 분리 후보. 특히 `filteredRestaurants` 정책은 비즈니스 규칙 문서화 가치 있음. + +## 8. 흐름 / 알고리즘 + +### 8.1 검색 흐름 +1. 사용자가 `SearchBar` 에 입력 → 엔터. +2. `handleSubmit` 가 `query.trim()` 검증 → `onSearch(trimmed, "hybrid")`. +3. `handleSearch`: + - `setLoading(true)`. + - `await api.search(query, "hybrid")`. + - `setRestaurants(results)`, `setIsSearchResult(true)`. + - 모든 필터 초기화 (channel, cuisine, price, country, city, district, bounds). + - `computeFlyTo(results)` 로 지도 이동. +4. `filteredRestaurants` 가 검색 모드 분기를 타고 정렬만 적용. + +### 8.2 필터 적용 흐름 (클라이언트) +``` +for each r in restaurants: + if channelFilter && !r.channels.includes(channelFilter): continue + if cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter): continue + if priceFilter && !matchPriceGroup(r.price_range, priceFilter): continue + if countryFilter: + p = parseRegion(r.region) + if !p || p.country !== countryFilter: continue + if cityFilter && p.city !== cityFilter: continue + if districtFilter && p.district !== districtFilter: continue + if boundsFilterOn: + if mapBounds: box test (north/south/east/west) + else: radius² ≤ 0.0013 (~4km, 위경도 제곱 합) + pass + +sort by (distance to userLoc asc, rating desc) +``` + +### 8.3 채널 필터 흐름 (서버) +1. 채널 카드 클릭 → `setChannelFilter(ch.channel_name)` (또는 동일 클릭 시 ""). +2. `useEffect([channelFilter])` 가 `api.getRestaurants({ channel })` 재호출. +3. `restaurants` 가 채널-매칭 데이터로 교체. +4. 이후 클라이언트 필터 체인 적용. + +### 8.4 지역 캐스케이드 + fly-to +1. `regionTree = buildRegionTree(restaurants)` (useMemo). +2. `countries`, `cities`, `districts` 가 현재 선택에 따라 파생. +3. 사용자가 `country` 선택 → `handleCountryChange`: + - cityFilter, districtFilter 리셋. + - boundsFilter 해제. + - 해당 country 식당들의 centroid + spread → `regionFlyTo`. +4. 모바일 홈 탭은 pill 버튼 → `setOpenSheet("country")` → `` 마운트. +5. FilterSheet 옵션 선택 → `handleSelect("한국")` → `onChange("한국")` (= `handleCountryChange`) + 시트 닫힘. + +### 8.5 내 위치 토글 +1. 클릭 → `boundsFilterOn = !boundsFilterOn`. +2. ON 시: 다른 필터 모두 초기화 + Geolocation → `userLoc`, `regionFlyTo({lat,lng,zoom:15})`. +3. OFF 시: 단순 토글, 다른 필터 변화 없음. +4. Geolocation 실패 → 기본 좌표 `(37.498, 127.0276)` 강남역 부근. + +### 8.6 FilterSheet 동작 +1. `open=true` → `useEffect` 가 `document.body.style.overflow = "hidden"`. +2. options 를 `group` 필드로 reduce → 그룹 헤더 (sticky) + 옵션 리스트. +3. 옵션 클릭 → `handleSelect(v)` → onChange + onClose. +4. 백드롭/X 클릭 → onClose. +5. 언마운트 시 body 스크롤 복원. + +## 9. 엣지케이스 & 에러 처리 + +| 경계 | 처리 | +|------|------| +| 빈 검색어 | `query.trim()` 가드, onSearch 미호출 | +| 검색 API 실패 | `try/catch` 로 console.error, loading=false 보장 (finally) | +| 채널 필터 ON 상태에서 검색 | 검색 진입 시 channelFilter="" 로 강제 초기화 | +| `region` 가 null | parseRegion → null → 지역 필터 적용 시 결과 0 | +| `region.country = "나라"` (더미) | regionTree 빌드/findRegionFromCoords 에서 제외 | +| `cuisine_type` null | matchCuisineFilter → false | +| `price_range` 비정형 | 5개 정규식 모두 매칭 실패 → false (해당 식당 미표시) | +| Geolocation 거부 | `() => {}` 빈 콜백 + 기본 좌표 사용 | +| Geolocation 5초 타임아웃 | timeout 옵션, 실패 콜백 동일 | +| mapBounds null + boundsFilterOn ON | userLoc 기준 radius² ≤ 0.0013 (~4km) 적용 | +| filteredRestaurants 빈 배열 | RestaurantList 가 "표시할 식당이 없습니다" 표시 | +| FilterSheet body lock 해제 누락 | `useEffect cleanup` 에서 `overflow=""` 복원 | +| 데스크탑에서 FilterSheet 노출 | `md:hidden` 으로 차단 (그러나 데스크탑은 native select 사용) | +| 동일 채널 재클릭 | toggle off (channelFilter="") | +| `regionFlyTo` null | MapView 의 effect 가 early-return | +| 검색 모드 + 사용자가 필터 클릭 | 현재 정책: 사용자가 필터를 직접 변경하지 않는 한 검색 결과 유지 (isSearchResult 가 true 인 동안). 필터 클릭 자체로는 isSearchResult 해제 안 됨 — 잠재 UX 이슈 (미해결 질문 참조). | + +## 10. 테스트 계획 +- **현재 자동화 테스트 없음 (TBD)** — 수동 QA + 향후 RTL+Jest 후보: + - [Unit·예상] `matchCuisineFilter("한식|국밥/해장국", "한식")` → true. + - [Unit·예상] `matchCuisineFilter("일식|라멘", "한식")` → false. + - [Unit·예상] `matchPriceGroup("8천원대", "가성비 (5천~1만원)")` → true. + - [Unit·예상] `parseRegion("한국|서울|강남구")` → `{country:"한국", city:"서울", district:"강남구"}`. + - [Unit·예상] `parseRegion(null)` → null. + - [Unit·예상] `buildRegionTree` 가 "나라" 더미를 제외하는지. + - [Unit·예상] `computeFlyTo` 가 spread 분기별로 적정 zoom 반환. + - [Integration·예상] FilterSheet: 옵션 클릭 → onChange + onClose 호출 검증. + - [Integration·예상] SearchBar: 엔터 → onSearch("query","hybrid") 호출. +- **수동 QA** + - 채널 클릭 → 서버 재페치 → 결과 변경 확인. + - 음식 필터 → 클라이언트 필터링 동작 (네트워크 호출 없음). + - 가격 필터 → 정규식 매칭 정확성 (다양한 표현 샘플로). + - 지역 캐스케이드: 나라 변경 → 시 옵션 갱신 → 구 옵션 갱신. + - 모바일 FilterSheet: 백드롭 클릭 닫힘, body 스크롤 잠금 확인. + - "내위치 ON" → 다른 필터 자동 해제. + - 검색 → 필터 자동 해제. + - 리셋 (홈 더블탭) → 초기 상태 복원. +- **모킹/드라이런** + - `api.search`, `api.getRestaurants` MSW mock. + - `navigator.geolocation` mock 으로 success/error 분기. + - `window.matchMedia` 모바일/데스크탑 분기. + +## 11. 리스크 & 대안 검토 +- **선택 1**: 클라이언트 사이드 필터 (채널 제외). + - 장점: 즉시 반응, 서버 호출 절감, 정렬·다중 조건 결합 유연. + - 단점: 500+ 데이터 전제 (브라우저 메모리에 다 들고 있어야), 더 큰 카탈로그로 확장 시 한계. + - **대안**: 모든 필터를 서버 쿼리 파라미터화. 트레이드오프: 즉시성↓, 확장성↑. +- **선택 2**: 가격대 정규식 매칭. + - 장점: 자유 텍스트도 흡수. + - 단점: 오탐/미스 매치 가능, 유지보수 비용 (새 표기 추가 시 정규식 갱신). + - **대안**: 백엔드가 `price_min`, `price_max` 정수 컬럼을 정규화해 제공 → 클라는 비교만. ADR 후보. +- **선택 3**: 모바일은 pill+BottomSheet, 데스크탑은 native select. + - 장점: 플랫폼 친화적, 접근성 무료 (select). + - 단점: 코드 이원화, 디자인 일관성 약함. + - **대안**: 양쪽 모두 커스텀 콤보박스 (Radix Select) — 일관성 ↑, 번들 ↑. +- **선택 4**: pipe-delimited region 문자열. + - 장점: DB 한 컬럼으로 처리 가능. + - 단점: 파싱 의존, i18n 약함, 깊이 변경 어려움. + - **대안**: country/city/district 정규화 테이블 + FK. 변경 시 ADR. +- **되돌리기 어려운 결정**: `CUISINE_TAXONOMY` 고정 (백엔드 cuisine_type 표기와 결합). 변경 시 데이터 마이그레이션 필요. + +## 12. 미해결 질문 (Open Questions) +- 검색 결과 상태에서 필터를 다시 적용하면 검색 모드를 자동 해제할지, 검색 결과 내 재필터링할지? +- semantic / keyword 검색 모드 토글 UI 가 필요한지 (현재 hybrid 고정)? +- 가격 정규식이 놓치는 케이스의 모니터링·로깅 방안? +- 다국가 (일본/유럽) 데이터 비중이 늘면 지역 트리 깊이/표기가 달라질 가능성 — 데이터 모델 변경 필요한지? +- "내위치" 반경 4km 의 근거 — 도시 vs 시골 데이터 밀도 차이 무시? +- FilterSheet 의 키보드 접근성 (Tab/ESC) 추가 필요? +- 다중 선택 (예: 한식 OR 일식) 지원 필요? 현재는 모두 단일 선택. +- 리셋 후 채널 카드 가로 스크롤 위치도 좌측 초기화해야 하는지 (현재 그대로 유지). +- `findRegionFromCoords` 의 centroid 거리 산식이 유클리드 (평면) — 적도/극지에서 왜곡. Haversine 으로 교체 검토. diff --git a/docs/design/281-frontend-review-memo/README.md b/docs/design/281-frontend-review-memo/README.md new file mode 100644 index 0000000..bc57061 --- /dev/null +++ b/docs/design/281-frontend-review-memo/README.md @@ -0,0 +1,211 @@ + + +# 설계서: 프론트 - 리뷰/메모 UI (#281) + +> **상태**: Draft +> **작성**: [AI] Architect · **최종수정**: 2026-06-15 +> **추적성** — Redmine: #281 · 관련 ADR: 없음 +> · 구현 파일: `frontend/src/components/ReviewSection.tsx`, `frontend/src/components/MemoSection.tsx`, `frontend/src/components/MyReviewsList.tsx` · 테스트: TBD (현재 없음) + +## 1. 목적 (Why) +식당 상세에서 사용자가 별점/방문기록을 공개(리뷰) 또는 비공개(메모)로 남기고, 마이페이지에서 자신의 기록을 한눈에 회람할 수 있게 하여 "한번 가본 곳을 다시 찾는 사용자 경험"을 완성한다. + +## 2. 범위 (Scope) +- **포함**: + - 식당 상세 화면 리뷰 섹션: 평균 별점·리뷰 수, 리뷰 목록, 본인 리뷰 작성/수정/삭제, 별점 하프 단위 선택 + - 식당 상세 화면 메모 섹션: 본인만 보이는 비공개 메모, 업서트(upsert) 저장, 삭제 + - 내 기록 리스트: 작성한 리뷰/메모를 탭으로 분리 표시, 항목 클릭 시 상세로 이동 콜백 호출 + - 로그인 상태 분기 (비로그인 시 작성 버튼 미노출 / 메모 섹션 자체 비노출) +- **제외 (out of scope)**: + - 리뷰/메모 데이터 API·DB 스키마 (백엔드 #28x) + - 좋아요/댓글/신고 기능 + - 사진 첨부, 마크다운 렌더링 + - 무한 스크롤 / 페이지네이션 (현재는 한 식당당 전체 fetch) + +## 3. 인수조건 (이미 구현된 동작 기준) +- [x] 로그인 사용자는 식당당 본인 리뷰가 없을 때 "리뷰 작성" 버튼이 노출되고, 이미 작성한 경우 노출되지 않는다. +- [x] 별점은 0.5 단위로 선택 가능 (별을 다시 누르면 0.5점 차감). +- [x] 리뷰 작성/수정 시 별점·리뷰 텍스트·방문일을 입력하고 저장하면 목록이 즉시 갱신된다. +- [x] 본인 리뷰에만 수정/삭제 버튼이 노출되고, 삭제는 confirm 후 실행된다. +- [x] 평균 별점은 소수 1자리, 리뷰 수와 함께 표시되며, 리뷰가 0개일 때는 평균 영역이 숨겨진다. +- [x] 메모는 본인에게만 보이며 "비공개" 뱃지가 표시된다. +- [x] 메모는 식당당 1건의 upsert(저장/수정 동일 API)로 동작한다. +- [x] 내 기록 리스트는 `리뷰` / `메모` 탭 전환을 지원하며, 항목 수를 탭 라벨에 표시한다. +- [x] 내 기록 항목 클릭 시 `onSelectRestaurant(restaurantId)` 콜백이 호출된다. +- [x] 로딩 중에는 스켈레톤 UI가 표시된다. + +## 4. 컨텍스트 & 제약 +- **프레임워크**: Next.js 16 App Router, `"use client"` 컴포넌트 +- **언어/타입**: TypeScript (strict), React 함수형 + Hooks +- **스타일**: Tailwind CSS + Saffron 디자인 토큰(`bg-brand-500`, `border-brand-200`, `bg-brand-50/30` 등), Pretendard 폰트 +- **상태/인증**: `@/lib/auth-context`의 `useAuth()`로 `user` 객체 취득. `user`가 null이면 작성/수정/삭제 진입점 비노출 (메모 섹션은 전체 null 반환) +- **데이터 호출**: `@/lib/api`의 `api.getReviews / createReview / updateReview / deleteReview / getMemo / upsertMemo / deleteMemo` (Bearer 토큰은 api 레이어 책임) +- **아이콘**: `@/components/Icon` (Tabler 매핑) — `edit_note`, `rate_review`, `add`, `close` +- **터치 영역**: 별 버튼에 `p-1.5 touch-manipulation` 적용, 모바일 44px 목표 가이드 준수 +- **가정**: 리뷰 응답은 `{ reviews, avg_rating, review_count }` 형태. 본인 식별은 `review.user_id === user.id`. + +## 5. 아키텍처 개요 +- 모듈/파일: + - `ReviewSection.tsx` — 식당 상세 임베드용 공개 리뷰 섹션 (목록 + 폼) + - `MemoSection.tsx` — 식당 상세 임베드용 비공개 메모 섹션 (단일 항목 + 폼) + - `MyReviewsList.tsx` — 마이페이지/사이드패널용 내 기록 통합 리스트 (탭) + - 내부 헬퍼: `StarDisplay`, `StarSelector`, `ReviewForm` (ReviewSection 내부) + +``` +[RestaurantDetail] + ├─ + │ ├─ useAuth() ── user + │ ├─ useEffect → api.getReviews ─→ { reviews, avg_rating, review_count } + │ ├─ ReviewForm (작성/수정) + │ │ └─ api.createReview / updateReview + │ └─ api.deleteReview + │ + └─ + ├─ useAuth() ── user (null이면 return null) + ├─ useEffect → api.getMemo ─→ Memo | null + ├─ form upsert → api.upsertMemo + └─ api.deleteMemo + +[Page sidebar] + └─ + ├─ tab state (reviews | memos) + └─ onSelectRestaurant(restaurantId) → 부모가 상세 열기 +``` + +- **I/O ↔ 순수 로직 경계**: + - I/O: `api.*` 호출, `confirm()` 다이얼로그, `localStorage` 토큰 (api 레이어 내부) + - 순수: `StarDisplay`/`StarSelector` 렌더 로직, 평균 별점 반올림 (`Math.round(avgRating * 2) / 2`), 본인 리뷰 판별, 탭 분기 + +## 6. 데이터 모델 +TypeScript 타입 (구현에서 import 또는 정의): + +```ts +// frontend/src/lib/api.ts (외부) +interface Review { + id: string; + user_id: string; + user_nickname: string | null; + user_avatar_url: string | null; + rating: number; // 0.5 단위 (0.5 ~ 5) + review_text: string | null; + visited_at: string | null; // 'YYYY-MM-DD' + created_at: string; // ISO +} + +interface Memo { + id: string; + rating: number | null; + memo_text: string | null; + visited_at: string | null; + created_at: string; +} + +// MyReviewsList 내부 확장 +interface MyReview extends Review { restaurant_id: string; restaurant_name: string | null; } +interface MyMemo extends Memo { restaurant_id: string; restaurant_name: string | null; } + +// 폼 페이로드 +type ReviewPayload = { rating: number; review_text?: string; visited_at?: string }; +type MemoPayload = { rating: number; memo_text?: string; visited_at?: string }; +``` + +- **경계 검증**: + - `rating`: 0.5 ~ 5.0, 0.5 단위 (UI에서만 제한; 백엔드 검증 가정) + - `review_text` / `memo_text`: 빈 문자열이면 `undefined`로 전송 + - `visited_at`: ``이 보장하는 YYYY-MM-DD 또는 `undefined` + - 본인 판별: 클라이언트의 `user.id` 비교 — 신뢰 경계는 백엔드(JWT)에 있음 + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | +|------|-----------|----------------|------|------|-----------|-------| +| `ReviewSection` | 식당 리뷰 섹션 컨테이너 | `({ restaurantId }) => JSX` | `restaurantId: string` | JSX | API 실패 시 `setReviews([])` | **복잡** (I/O+상태) | +| `MemoSection` | 식당 메모 섹션 컨테이너 | `({ restaurantId }) => JSX` | `restaurantId: string` | JSX \| null | API 실패 시 `setMemo(null)` | **복잡** | +| `MyReviewsList` | 내 기록 통합 탭 리스트 | `(props) => JSX` | `reviews, memos, onClose, onSelectRestaurant` | JSX | 부모가 fetch 책임 | 단순 | +| `StarDisplay` | 별점 읽기 전용 표시 | `({ rating }) => JSX` | `rating: number` | JSX (5개 span) | 없음 | 단순 | +| `StarSelector` | 별점 선택 (0.5 단위) | `({ value, onChange }) => JSX` | `value: number, onChange: (v) => void` | JSX | 없음 | 단순 | +| `ReviewForm` | 리뷰 입력 폼 | `({ initial*, onSubmit, onCancel, submitLabel }) => JSX` | 초기값 + 콜백 | JSX | submit 중 disabled | 단순 | +| `loadReviews` | 리뷰 목록 조회 후 상태 갱신 | `useCallback(() => void)` | `restaurantId` | void (setState) | catch → 빈 배열 | 단순 | +| `loadMemo` | 본인 메모 조회 | `useCallback(() => void)` | `restaurantId, user` | void (setState) | catch → null | 단순 | +| `handleCreate` | 리뷰 생성 핸들러 | `(data) => Promise` | `ReviewPayload` | void | upstream throw | 단순 | +| `handleUpdate` | 리뷰 수정 핸들러 | `(reviewId, data) => Promise` | id + payload | void | upstream throw | 단순 | +| `handleDelete` | 리뷰 삭제 (confirm) | `(reviewId) => Promise` | id | void | confirm 취소 시 no-op | 단순 | +| `handleSubmit` (Memo) | 메모 upsert | `(e) => Promise` | FormEvent | void | finally로 submitting 해제 | 단순 | +| `handleDelete` (Memo) | 메모 삭제 (confirm) | `() => Promise` | - | void | confirm 취소 시 no-op | 단순 | +| `startEdit` (Memo) | 메모 편집 폼 초기화 | `() => void` | - | void | - | 단순 | + +> 복잡 기준: ReviewSection/MemoSection은 외부 I/O + 사용자 인증 분기 + 폼 상태기계가 결합되어 통합 테스트 가치가 높음. + +## 8. 흐름 / 알고리즘 +**① 리뷰 작성 (신규)** +1. `useEffect`에서 `loadReviews()` 호출 → 목록·평균·카운트 세팅 +2. `user && !myReview && !showForm` → "리뷰 작성" 버튼 노출 +3. 버튼 클릭 → `setShowForm(true)` +4. `ReviewForm` 마운트, `initialDate = today (YYYY-MM-DD)` +5. submit → `handleCreate({rating, review_text?, visited_at?})` → `api.createReview` → `setShowForm(false)` → `loadReviews()` + +**② 리뷰 수정** +1. 본인 리뷰 카드의 "수정" → `setEditingId(review.id)` +2. 해당 카드 내부에서 `ReviewForm`이 초기값(기존 별점/텍스트/방문일) 채워 렌더 +3. submit → `handleUpdate(id, data)` → `api.updateReview` → `setEditingId(null)` → reload + +**③ 리뷰 삭제** +1. "삭제" → `confirm("리뷰를 삭제하시겠습니까?")` +2. OK → `api.deleteReview` → reload + +**④ 메모 upsert** +1. `loadMemo()` (로그인 시) → `Memo | null` +2. 메모 없음 → "메모 작성" 점선 버튼 → `startEdit()` (기본값 3점, 오늘) +3. 메모 있음 → 카드 표시 + "수정"/"삭제" +4. 폼 submit → `api.upsertMemo` → 응답을 `setMemo`로 즉시 반영 + +**⑤ 별점 토글 (StarSelector)** +- `onChange(value === v ? v - 0.5 : v)` — 같은 별 재클릭 시 0.5점 차감 + +**⑥ 내 기록 리스트** +1. 부모가 `reviews`, `memos` 로딩 → props 주입 +2. 탭 클릭 → `setTab("reviews" | "memos")` +3. 항목 클릭 → `onSelectRestaurant(id)` 호출 (부모가 상세 시트/모달 오픈) + +## 9. 엣지케이스 & 에러 처리 +- **비로그인**: `ReviewSection`은 목록만 표시 (작성 버튼 숨김), `MemoSection`은 `return null` +- **리뷰 0개**: "아직 리뷰가 없습니다" 안내, 평균 영역 숨김 +- **`avgRating === null`**: 평균/카운트 영역 비노출 (null 가드) +- **API 실패**: `getReviews` → `setReviews([])`, `getMemo` → `setMemo(null)` (조용한 실패; 사용자 알림 없음) +- **create/update/delete 실패**: 현재 try/catch 없음 → 호출자(상위) catch 또는 unhandled rejection. 개선 여지로 토스트 도입 필요 (미해결 질문 참조) +- **본인 리뷰가 이미 있는데 다른 사용자로 로그인 전환**: `user.id` 비교로 자동 분기 (백엔드가 중복 차단 가정) +- **avatar_url 누락**: `` 자체를 조건부 렌더, alt만 빈 문자열 +- **방문일 미입력**: `visited_at: undefined`로 전송 → 백엔드 nullable +- **별점 0점 제출**: 현재 0점은 UI상 표시 안 됨(0이면 0.5도 아님). 폼은 `initialRating = 3` 기본값으로 0 제출 회피 +- **긴 텍스트**: `