docs/design: tasteby 18개 기능 현행화 설계서 추가

- 백엔드 12개: auth/user/restaurant/video/extract-pipeline/search/review-memo/channel/stats/daemon/cache/health
- 프론트 6개: map/restaurant-detail/filter/review-memo/admin/login
- 12개 섹션 전 항목 채움 (목적/범위/인수조건/제약/아키텍처/데이터모델/함수명세표/흐름/엣지/테스트/리스크/미해결)
- 추적성 헤더에 구현 파일 경로 명시, 테스트는 TBD (현재 없음)
- 코드 변경 없음 — 기존 구현의 설계 문서화

Refs: #266 #267 #268 #269 #270 #271 #272 #273 #274 #275 #276 #277 #278 #279 #280 #281 #282 #283
This commit is contained in:
joungmin
2026-06-15 10:48:50 +09:00
parent c78f928a2d
commit e97a36a8d9
18 changed files with 3830 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 인증/로그인 (#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 <jwt>)
AuthController.me → AuthUtil.getUserId() → AuthService.getCurrentUser
▼ UserService.findById → UserMapper.findById
UserInfo
```
- **I/O ↔ 순수 로직 경계**
- I/O: Google 토큰 검증, DB 조회/저장.
- 순수: payload → `UserInfo` 매핑, `Map<String,Object>` 클레임 빌드.
## 6. 데이터 모델
- **입력**
- `POST /api/auth/google` body: `{ "id_token": string(JWT, Google issued) }`.
- `GET /api/auth/me`: 헤더 `Authorization: Bearer <accessToken>`.
- **출력**
- `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<String,Object> 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<String,Object> loginGoogle(@RequestBody Map<String,String> 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 전환) 시점.

View File

@@ -0,0 +1,204 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 사용자 관리 (#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<UserInfo> 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<UserInfo> 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<String,Object> listUsers(int limit=50, int offset=0)` | 페이징 | `{users,total}` | 인증 실패 → 401/403 | 단순 |
| `userFavorites` | `GET …/{userId}/favorites` | `List<Restaurant> userFavorites(String userId)` | userId | 즐겨찾기 식당 | 인증/위임 | 단순 |
| `userReviews` | `GET …/{userId}/reviews` | `List<Review> userReviews(String userId)` | userId | 리뷰 100건 | 인증/위임 | 단순 |
| `userMemos` | `GET …/{userId}/memos` | `List<Memo> userMemos(String userId)` | userId | 메모 | 인증/위임 | 단순 |
| `updateAdmin` | `PATCH …/{userId}/admin` | `Map<String,Object> updateAdmin(String userId, Map<String,Boolean> 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 캐시 미적용).
- 회원 탈퇴/익명화 정책과 개인정보 보관기간.

View File

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

View File

@@ -0,0 +1,214 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 영상 관리 + SSE (#269)
> **상태**: Draft <!-- Draft | Approved | Superseded -->
> **작성**: [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<VideoSummary>` | 없음 (빈 배열) | 단순 |
| `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<Map>` | 없음 | 단순 |
| `VideoService.findVideosForBulkExtract` | transcript 보유/추출 미실행 | `findVideosForBulkExtract()` | — | `List<Map>` (CLOB 읽음) | CLOB read 실패 | 단순 |
| `VideoService.findVideosWithoutTranscript` | 자막 미보유 영상 | `findVideosWithoutTranscript()` | — | `List<Map>` | 없음 | 단순 |
| `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<Map>` | 예외 시 Search 폴백 | **복잡** |
| `YouTubeService.fetchChannelVideosViaSearch` | Search API 폴백 | 동일 | params | `List<Map>` | 파싱 실패 시 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 표시) 부재.

View File

@@ -0,0 +1,247 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 영상→식당 추출 파이프라인 (LLM+Geocoding) (#270)
> **상태**: Draft <!-- Draft | Approved | Superseded -->
> **작성**: [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<Map>` 으로 정규화한다.
- [ ] `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<Map<String,Object>>
┌──────────── 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<Map>, rawResponse}` | catch 후 빈 결과 + log | **복잡** |
| `PipelineService.processVideo` | 단건 영상 end-to-end | `int processVideo(Map<String,Object>)` | 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<List<Double>> 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 파싱 강건성 (현재 한국 주소 패턴 위주).

View File

@@ -0,0 +1,201 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 검색/벡터 추천 (#271)
> **상태**: Draft <!-- Draft | Approved | Superseded -->
> **작성**: [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<Restaurant>
└──────────┬───────────────────────┘ 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<Restaurant>` — 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 본문 예시:
```
식당: <name>
지역: <region>
음식 종류: <cuisine_type>
메뉴: a, b, c
평가: <evaluation>
가격대: <price>
영상: <video_title>
```
- **캐시 키**: `search:q=<q>:m=<mode>:l=<limit>` (`CacheService.makeKey`). 값은 `List<Restaurant>` Jackson JSON.
- **검증 규칙**:
- `q` 가 비어 있으면 Controller 가드(현재 미존재) — 빈 문자열은 `%%` LIKE 로 모든 식당 매칭되므로 클라이언트에서 빈 쿼리 차단 권장. (Open Question 참조)
- limit > 100 → 100 으로 clamp.
- semantic 결과는 `latitude != null` 인 식당만 — 지도 노출 가능한 후보로 한정.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `SearchController.search` | REST 엔드포인트 + limit clamp | `List<Restaurant> search(q, mode, limit)` | q, mode, limit | List<Restaurant> | 없음 | 단순 |
| `SearchService.search` | 모드 분기 + 캐싱 | `List<Restaurant> search(q, mode, limit)` | 동일 | 동일 | 캐시 직렬화 catch | **복잡** |
| `SearchService.keywordSearch` (private) | LIKE 검색 + 채널 부착 | `(q, limit)` | %q% pattern | List<Restaurant> | 빈 결과 빈 list | 단순 |
| `SearchService.semanticSearch` (private) | 벡터 검색 + 식당 조회 | `(q, limit)` | q, limit | List<Restaurant> | 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<Map>` (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<String>`(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=<q>:m=<mode>:l=<limit>` → `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<List<Double>>` → 첫 임베딩을 `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<restId, List<channelName>>` 구성 → 각 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 와 합의 필요).

View File

@@ -0,0 +1,153 @@
<!-- 기능 설계서. design/272-backend-review-memo/README.md
작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 리뷰/메모 (#272)
> **상태**: Draft <!-- Draft | Approved | Superseded -->
> **작성**: [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<Review>(restaurantId, limit, offset)` | 식당ID, 페이지 | List<Review> | DB 오류 → 전파 | 단순 |
| `ReviewService.getAvgRating` | 평균 평점/리뷰 수 집계 | `Map<String,Object>(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<Review>(userId, limit, offset)` | 사용자, 페이지 | List<Review> | DB 오류 → 전파 | 단순 |
| `ReviewService.isFavorited` | 즐겨찾기 여부 | `boolean(userId, restaurantId)` | 사용자/식당 | true/false | DB 오류 → 전파 | 단순 |
| `ReviewService.toggleFavorite` | 즐겨찾기 토글 | `boolean(userId, restaurantId)` | 사용자/식당 | 토글 후 상태 | 동시성 시 유니크 충돌 가능 | **복잡** |
| `ReviewService.getUserFavorites` | 내 즐겨찾기 식당 목록 | `List<Restaurant>(userId)` | 사용자 | List<Restaurant> | 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<Memo>(userId)` | 사용자 | List<Memo> | 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 필요.

View File

@@ -0,0 +1,170 @@
<!-- 기능 설계서. design/273-backend-channel/README.md
작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 채널 관리 (#273)
> **상태**: Draft <!-- Draft | Approved | Superseded -->
> **작성**: [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<Channel>()` | 없음 | List<Channel> | 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<Channel>()` | 없음 | List<Channel> | 캐시 파싱 실패 → 무시, 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 정책 의존).

View File

@@ -0,0 +1,137 @@
<!-- 기능 설계서. design/274-backend-stats/README.md
작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 통계/대시보드 (#274)
> **상태**: Draft <!-- Draft | Approved | Superseded -->
> **작성**: [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<String,Object>()` | 없음 | `{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에서 필요한 차원(채널별 트래픽, 디바이스 등) 범위 확정.

View File

@@ -0,0 +1,193 @@
<!-- 기능 설계서 — 백엔드 데몬/스케줄러.
작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 데몬/스케줄러 (#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<String,Object> 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<String,Object> 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) 채널이 필요한가?
- 작업 이력(스캔/처리 결과 로그 테이블) 보관 정책은?

View File

@@ -0,0 +1,174 @@
<!-- 기능 설계서 — 백엔드 캐시 관리.
작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 캐시 관리 (#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:<part1>:<part2>:...``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<String,Object>`).
- **경계 검증**:
- `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> T get(String key, Class<T> 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<String,Object> 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 훅(애플리케이션 기동 또는 마이그레이션 후)이 필요한가?

View File

@@ -0,0 +1,118 @@
<!-- 기능 설계서 — 백엔드 Health/모니터링.
작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 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<String, String>` 의 불변 단일 엔트리.
```json
{ "status": "ok" }
```
- 입력 파라미터/바디 없음.
- 상태 코드: 항상 200. 다른 코드 없음.
- 경계 검증: 해당 없음.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `HealthController.health` | liveness 응답 반환 | `Map<String,String> 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 제한 대상으로 둘 필요가 있는가(공개 권장)?

View File

@@ -0,0 +1,240 @@
<!-- 기능 설계서. 구현된 코드를 바탕으로 역설계 (reverse-engineering) -->
# 설계서: 프론트 - 지도 뷰 (#278)
> **상태**: Draft <!-- Draft | Approved | Superseded -->
> **작성**: [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
┌─────────────────────────────────────┐
│ <MapView restaurants selected │
│ onSelectRestaurant onBoundsChanged│
│ flyTo onMyLocation activeChannel> │
│ └─ APIProvider (Google Maps SDK) │
│ └─ <Map onCameraChanged> │
│ └─ <MapContent useMap()> │
│ ├─ 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<RestaurantProps>;
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<string, ChannelColor>` | 식당 배열 | 채널명→색상 객체 | 채널 없으면 빈 객체 | 단순 |
| `getClusterSize` | 클러스터 카운트→픽셀 크기 (36/42/48/54) | `(count: number) => number` | 양의 정수 | 픽셀 | 음수/NaN 시 36 | 단순 |
| `useSupercluster` | supercluster 인덱스 + 조회 헬퍼 캐시 | `(restaurants: Restaurant[]) => { getClusters, getExpansionZoom, index }` | 식당 배열 | 인덱스/함수들 | `getClusterExpansionZoom` 예외 시 17 반환 | **복잡** (메모이즈+ref) |
| `MapContent` | 지도 내부 — 카메라 이벤트, 마커/InfoWindow 렌더 | `(props: Omit<MapViewProps,"onMyLocation"\|"onBoundsChanged">) => 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-<name>.md` 분리 후보. 현재는 본 문서로 일괄 관리.
## 8. 흐름 / 알고리즘
### 8.1 초기 마운트
1. `Home` 마운트 → 데스크탑이면 `viewMode = "map"`. Geolocation 으로 `userLoc` 갱신.
2. `api.getRestaurants({ limit: 500 })``restaurants` 세팅.
3. `<MapView>` 마운트 → `<APIProvider>` 가 Google SDK 로드.
4. `<Map>``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 통합 필요한지?
- 좌표 동일 식당 (지점 다수) 표시 정책 미정 — 클러스터로만 표현되어 개별 식별 어려움.

View File

@@ -0,0 +1,286 @@
<!-- 기능 설계서. 구현된 코드를 바탕으로 역설계 (reverse-engineering) -->
# 설계서: 프론트 - 식당 상세 시트 (#279)
> **상태**: Draft <!-- Draft | Approved | Superseded -->
> **작성**: [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<string,string>, 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`.
- 자식: `<ReviewSection>`, `<MemoSection>`.
- `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
▼ ▼
┌─────────────────┐ ┌─────────────────────────┐
│ <RestaurantList │ │ <BottomSheet open onClose│
│ restaurants │ │ ┌─────────────────────┐│
│ onSelect …> │ │ │ <RestaurantDetail ││
└────────┬────────┘ │ │ restaurant onClose││
│ │ └─────────────────────┘│
▼ └─────────────────────────┘
┌─────────────────┐
│ <RestaurantDetail (inline, no sheet) │
│ ├─ useEffect: getRestaurantVideos(id) │
│ ├─ useEffect: getFavoriteStatus(id) if auth │
│ ├─ ReviewSection │
│ └─ MemoSection │
└────────────────────────────────────────────────
```
- **I/O ↔ 순수 경계**
- **I/O**: 4개 API 콜 (`getRestaurantVideos`, `getFavoriteStatus`, `toggleFavorite`, 외부 링크), Touch 이벤트, `window.innerHeight`, `Date.now()`.
- **순수**: BottomSheet 의 `snapTo` 로직 (입력 height·velocity → 최근접 스냅 또는 close), 카드 렌더링.
## 6. 데이터 모델
```ts
// RestaurantDetail props
interface RestaurantDetailProps {
restaurant: Restaurant;
onClose: () => 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<string, string>; // { 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<HALF → close | **복잡** (분기 결정) |
| `onTouchStart` | 드래그 시작점 기록, 컨텐츠 스크롤 인터셉트 방지 | `(e: TouchEvent) => 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. `<BottomSheet open=true>` 마운트 → `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 처리 미정.

View File

@@ -0,0 +1,354 @@
<!-- 기능 설계서. 구현된 코드를 바탕으로 역설계 (reverse-engineering) -->
# 설계서: 프론트 - 필터 시스템 (FilterSheet + SearchBar) (#280)
> **상태**: Draft <!-- Draft | Approved | Superseded -->
> **작성**: [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 `<select>` 그룹 (음식/지역) + 토글 버튼.
- **모바일 필터바**: 홈 탭은 장르 가로 스크롤 카드, 그 외는 pill 버튼 → `FilterSheet` 바텀시트.
- **FilterSheet**: 그룹화 옵션, "전체"(초기화) 항목, 체크 표시, 외부 클릭/X 닫기, body 스크롤 잠금.
- **제외 (out of scope)**
- 클라이언트 측 전체 인덱스 (검색은 서버 위임).
- 정렬 옵션 UI (현재 자동: 거리 → 평점 내림차순).
- 즐겨찾기/방문 기록을 필터로 사용 (별도 탭).
- 영상·채널 관리 (#274).
## 3. 인수조건 (Acceptance Criteria)
### SearchBar
- [x] 빈 문자열 제출은 무시 (`query.trim()` 가드).
- [x] 제출 시 `onSearch(trimmed, "hybrid")` 호출.
- [x] `isLoading=true` 면 우측 회전 스피너 표시.
- [x] `key={resetCount}` 로 리셋 시 입력 초기화.
### FilterSheet
- [x] `open=false` 면 null 반환.
- [x] `open=true` 면 body 스크롤 잠금 (`overflow=hidden`), 언마운트 시 복원.
- [x] 데스크탑(md+) 에서는 `md:hidden` 으로 숨김.
- [x] 옵션을 `group` 필드로 그룹화하여 sticky 헤더로 구분.
- [x] 맨 위 "전체" 항목 클릭 시 `onChange("")` + 닫힘.
- [x] 옵션 클릭 시 `onChange(value)` + 자동 닫힘.
- [x] 현재 `value` 와 일치하는 옵션은 brand-50 배경 + 체크 아이콘.
- [x] 백드롭 클릭 시 `onClose`.
### Filter 로직 (page.tsx)
- [x] 채널 필터 변경 시 서버에 `channel` 파라미터로 재페치.
- [x] 음식/가격/지역 필터는 클라이언트 사이드 `filteredRestaurants` 계산.
- [x] `matchCuisineFilter`: `"한식"``cuisine_type.startsWith("한식")`. `"한식|국밥/해장국"` → 정확 일치.
- [x] `matchPriceGroup`: 5개 정규식 중 하나로 매칭.
- [x] 지역: `country → city → district` 모두 일치해야 통과.
- [x] `boundsFilterOn` ON: 지도 bounds 있으면 box, 없으면 userLoc 기준 ~4km 반경.
- [x] 검색 결과 (`isSearchResult=true`) 면 다른 필터 모두 무시 + 거리/평점 정렬만 적용.
- [x] 결과는 항상 (거리 오름차순, 평점 내림차순) 정렬.
- [x] 검색 시 모든 필터 자동 초기화.
- [x] "내위치 ON" 시 다른 모든 필터 자동 초기화.
- [x] 지역 변경 시 해당 식당들의 centroid 로 자동 fly-to (`computeFlyTo`).
## 4. 컨텍스트 & 제약
- **런타임**: Next.js 16 (App Router), TypeScript, "use client".
- **외부 의존성**
- `@/lib/api`: `api.search(query, mode)`, `api.getRestaurants({channel})`.
- `@/components/Icon` (Material Symbols).
- `@phosphor-icons/react` (홈 탭 장르 카드).
- `@/components/FoodIcon` (커스텀 음식 아이콘).
- **데이터 컨트랙트**
- `Restaurant.region`: `"나라|시|구"` pipe-delimited (예: `"한국|서울특별시|강남구"`).
- `Restaurant.cuisine_type`: `"한식|국밥/해장국"` 형태 또는 카테고리만.
- `Restaurant.price_range`: 자유 문자열 (정규식으로 그룹 매칭).
- `FilterOption`: `{ label, value, group? }`.
- **UI/UX 제약**
- Tailwind, brand-50/100/300/500/600/700/900 토큰.
- 모바일 터치 영역 ≥ 44×44 px — pill 버튼 `py-1.5 px-3`, FilterSheet 옵션 `py-3` (~48px).
- 다크모드 지원.
- 모바일 바텀시트 max-height = 70vh, `pb-safe` (iOS 노치).
- 데스크탑은 native `<select>` (커스텀 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) │
└────────┬────────────────────┬───────┘
│ │
┌──────────────┴────┐ ┌─────────┴───────────┐
▼ ▼ ▼ ▼
┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ <SearchBar> │ │ filter pills │ │ filtered list / │
│ onSearch ─────┼──▶ │ (mobile) │ │ MapView │
└────────────────┘ │ channel cards │ │ (consumes │
│ native select │ │ filteredRest..) │
│ (desktop) │ └──────────────────┘
└──┬──────────────┘
│ click
┌──────────────────┐
│ <FilterSheet> │
│ 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<string,Map<string,Set<string>>>` | 식당 배열 | 중첩 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")``<FilterSheet>` 마운트.
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 으로 교체 검토.

View File

@@ -0,0 +1,211 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지. -->
# 설계서: 프론트 - 리뷰/메모 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]
├─ <ReviewSection restaurantId>
│ ├─ useAuth() ── user
│ ├─ useEffect → api.getReviews ─→ { reviews, avg_rating, review_count }
│ ├─ ReviewForm (작성/수정)
│ │ └─ api.createReview / updateReview
│ └─ api.deleteReview
└─ <MemoSection restaurantId>
├─ useAuth() ── user (null이면 return null)
├─ useEffect → api.getMemo ─→ Memo | null
├─ form upsert → api.upsertMemo
└─ api.deleteMemo
[Page sidebar]
└─ <MyReviewsList reviews memos onSelectRestaurant onClose>
├─ 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`: `<input type="date">`이 보장하는 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<void>` | `ReviewPayload` | void | upstream throw | 단순 |
| `handleUpdate` | 리뷰 수정 핸들러 | `(reviewId, data) => Promise<void>` | id + payload | void | upstream throw | 단순 |
| `handleDelete` | 리뷰 삭제 (confirm) | `(reviewId) => Promise<void>` | id | void | confirm 취소 시 no-op | 단순 |
| `handleSubmit` (Memo) | 메모 upsert | `(e) => Promise<void>` | FormEvent | void | finally로 submitting 해제 | 단순 |
| `handleDelete` (Memo) | 메모 삭제 (confirm) | `() => Promise<void>` | - | 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 누락**: `<img>` 자체를 조건부 렌더, alt만 빈 문자열
- **방문일 미입력**: `visited_at: undefined`로 전송 → 백엔드 nullable
- **별점 0점 제출**: 현재 0점은 UI상 표시 안 됨(0이면 0.5도 아님). 폼은 `initialRating = 3` 기본값으로 0 제출 회피
- **긴 텍스트**: `<textarea rows={3} resize-none>`로 시각적 제한, 백엔드 길이 검증에 의존
- **`MyReviewsList`에서 restaurant_name = null**: "알 수 없는 식당"으로 표시
## 10. 테스트 계획
현재 자동화 테스트 없음 (TBD). 도입 시 권장:
- **단위 (Vitest + React Testing Library)**:
1. `StarSelector` — 같은 별 재클릭 → 0.5 토글 (인수조건 별점 0.5 단위)
2. `StarDisplay``rating=3.5` 입력 시 3개 노랑 + 1개 반쪽 표현 검증
3. `ReviewForm` — 빈 텍스트 submit 시 `review_text: undefined`로 전달되는지
4. `ReviewSection``user`가 본인 리뷰를 이미 가진 경우 "리뷰 작성" 버튼 미노출
5. `MemoSection``user === null``null` 반환
6. `MyReviewsList` — 탭 전환 후 빈 상태 메시지 / 항목 클릭 콜백 호출
- **통합 (Playwright/MSW)**:
- `getReviews` mock → 작성→수정→삭제 사이클 e2e
- `upsertMemo` mock → 첫 작성과 재저장이 동일 엔드포인트로 가는지
- **모킹 전략**: `@/lib/api`를 모듈 모킹, `@/lib/auth-context` Provider를 테스트용으로 래핑
## 11. 리스크 & 대안 검토
- **상태 동기화**: 작성 후 매번 `loadReviews()` 전체 재요청 → 단순하지만 네트워크 낭비. 대안: 낙관적 업데이트(optimistic) — 채택 안 함 (단일 식당 N=작아 비용 낮음)
- **에러 사일런스**: `catch(() => setReviews([]))`는 네트워크 오류와 "리뷰 0개"를 시각적으로 구별 불가. 대안: 에러 상태 분리. 현 단계 미채택 — UX 단순화 우선
- **별점 정밀도**: 0.5 단위는 클라이언트에서만 강제. 백엔드가 임의 소수를 받으면 부정확한 평균이 가능 → 백엔드 검증 의존
- **메모 1건 가정**: 현재 식당당 1메모. 다중 메모(시간순 일기)로 확장 시 데이터 모델 변경 필요 → ADR 후보
- **`MyReviewsList` 부모 fetch 의존**: 컴포넌트 자체는 stateless하여 재사용 쉬움. 대안인 self-fetch는 화면 컨텍스트별 캐시 충돌 우려로 미채택
- **접근성**: 별 버튼에 `title`만 제공, `aria-label` 없음 → 스크린리더 개선 여지
- **본인 식별을 클라이언트가 함**: 보안 경계는 백엔드. 클라이언트는 UX 분기만 — 안전
## 12. 미해결 질문 (Open Questions)
- 리뷰/메모 작성·수정·삭제 실패 시 사용자에게 어떻게 알릴까? (현재 토스트 없음 — alert? 인라인 메시지?)
- 비로그인 사용자에게도 "리뷰 작성" 버튼을 노출하고 클릭 시 로그인 유도하는 게 좋은가?
- 사진 첨부/메뉴별 평점 등 확장 요구가 들어올 때 데이터 모델 변경 범위는?
- 평균 별점을 백엔드 캐시(Redis)에 미리 저장 vs 매 요청 집계 — 트래픽 임계점은?
- `MyReviewsList`의 정렬 기준(최근 작성 vs 최근 방문)을 사용자 선택으로 제공할 필요가 있는가?

View File

@@ -0,0 +1,279 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지. -->
# 설계서: 프론트 - 어드민 페이지 (#282)
> **상태**: Draft
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #282 · 관련 ADR: 없음
> · 구현 파일: `frontend/src/app/admin/page.tsx` · 테스트: TBD (현재 없음)
## 1. 목적 (Why)
운영자가 채널 등록·스캔, 영상 자막/LLM 추출, 식당 정보 보정 및 예약처(테이블링/캐치테이블) 연결, 유저 권한 관리, 백그라운드 데몬 스케줄을 **단일 페이지**에서 일관된 패턴으로 다룰 수 있게 한다.
## 2. 범위 (Scope)
- **포함**:
- 5개 탭: 채널 / 영상 / 식당 / 유저 / 데몬
- 채널: 추가·수정(설명/태그/순서)·삭제·증분 스캔·전체 스캔
- 영상: 채널·상태·제목 필터, 정렬, 페이지네이션, 행 선택, 상세 패널, 단건/벌크 자막 수집, 단건/벌크 LLM 추출(SSE 스트리밍 진행률), 식당 인라인 편집/수동 추가, 벡터 재생성, 음식종류/메뉴태그 재분류
- 식당: 검색·정렬·페이지네이션, 인라인 편집(주소/지역/좌표 등), 테이블링/캐치테이블 단건·벌크 검색·연결·해제·전부 초기화, 연결된 영상 목록
- 유저: 페이지네이션, 관리자 토글, 선택 유저의 찜/리뷰/메모 패널
- 데몬: 스캔/처리 스케줄 활성화·주기 설정, 수동 실행, 마지막 실행시각 표시
- Redis 캐시 플러시(헤더), 관리자/비관리자 모드(읽기 전용 뱃지)
- **제외 (out of scope)**:
- 어드민 권한 부여 정책 / OAuth 흐름 (LoginMenu, 백엔드)
- 백엔드 API 스키마 / SSE 이벤트 명세 (각 백엔드 설계서)
- 다국어, 다크모드 별도 디자인 (브랜드 토큰 기본 적용)
## 3. 인수조건 (이미 구현된 동작 기준)
- [x] 비로그인 사용자는 "로그인이 필요합니다" 안내가 표시된다.
- [x] 로그인했지만 `is_admin !== true`이면 "읽기 전용" 뱃지가 헤더에 표시되고, 모든 변경 액션 버튼이 숨겨지고 입력 필드가 `disabled`된다.
- [x] 헤더의 캐시 초기화 버튼은 관리자에게만 보이고 confirm 후 `api.flushCache()` 호출.
- [x] **채널 탭**: ID/이름/필터 입력으로 추가, 행 클릭으로 설명/태그/순서 인라인 편집, 채널별 "스캔"/"전체 스캔" 결과를 인라인 표시.
- [x] **영상 탭**: 채널·상태·제목 필터, 4개 키 정렬 토글(↕/↑/↓), 페이지당 15개 페이지네이션, 체크박스 다중 선택, 행 클릭 시 상세 패널 오픈/토글.
- [x] 영상 상세: 자막 자동/수동/생성됨 모드 토글, 자막 수동 가져오기, 프롬프트 표시·복사, 추출된 식당 인라인 편집/삭제/수동 추가, 제목 인라인 수정.
- [x] 벌크 자막/LLM/벡터/음식종류/메뉴태그 작업은 SSE 스트리밍 진행률 카드로 실시간 표시되고, 완료 시 목록을 재로드한다.
- [x] **식당 탭**: 이름 검색, 6개 키 정렬, 페이지네이션, 행 선택 상세 패널, 9개 필드 인라인 편집, 테이블링/캐치테이블 검색·연결·해제, 연결된 영상 목록.
- [x] 벌크 테이블링/캐치테이블 연결은 진행률 막대(선형)로 표시되고 완료 시 alert.
- [x] **유저 탭**: 20명 페이지네이션, 관리자 ON/OFF 토글, 유저 선택 시 찜/리뷰/메모 3분할 패널.
- [x] **데몬 탭**: 스캔/처리 enable+주기 설정, 처리 건수(1~50), 수동 실행, 결과 메시지 색상 분기(성공/실패).
## 4. 컨텍스트 & 제약
- **프레임워크**: Next.js 16 App Router, Client Component (`"use client"`)
- **인증**: `useAuth()``user`, `isLoading`, `user.is_admin`. 토큰은 `localStorage["tasteby_token"]`에서 직접 읽어 `Authorization: Bearer` 헤더 부착 (SSE/벌크 fetch 호출 시).
- **데이터 호출**:
- 일반 CRUD: `@/lib/api` 래퍼
- SSE 스트리밍: `fetch(... POST ...)` + `ReadableStream.getReader()` + `TextDecoder` + `data: ` 라인 파싱
- **스타일**: Tailwind + Saffron 토큰 (`bg-brand-*`, `bg-surface`, `text-brand-*`), 색상 분기로 작업 종류 구분 (자막=brand, LLM=purple, 벡터=teal, 음식분류=amber, 메뉴태그=brand, 테이블링=brand, 캐치테이블=violet)
- **제약**:
- SSE 처리가 컴포넌트 내 인라인으로 작성되어 있어 코드량 큼 (2,742 LOC)
- 페이지네이션은 클라이언트 측 (전체 list fetch 후 slice). 데이터 증가 시 서버 페이징 필요
- 영상/식당 필터링도 클라이언트 측 (`Array.filter`)
- **CORS**: `WebConfig`의 allowedMethods에 DELETE/POST가 포함되어야 함 (이미 포함됨)
- **가정**: 백엔드가 일관된 SSE 이벤트 (`processing`, `done`, `error`, `complete`, `wait`)를 보낸다.
## 5. 아키텍처 개요
- 파일 구조 (단일 파일 내부 패널 분할):
- `AdminPage` (export default) — 탭 상태 + 헤더 + 인증 가드 + 패널 라우팅
- `CacheFlushButton` — 헤더용 캐시 플러시
- `ChannelsPanel` — 채널 탭
- `VideosPanel` — 영상 탭 + 인라인 상세 + 다수 SSE 핸들러
- `RestaurantsPanel` — 식당 탭 + 인라인 상세 + 예약처 연결
- `UsersPanel` — 유저 탭 + 상세 패널
- `DaemonPanel` — 데몬 탭
- 외부 의존:
- `@/lib/api` (Channel, Video, VideoDetail, VideoLink, Restaurant, DaemonConfig, getAdminUsers* 등)
- `@/lib/auth-context` (`useAuth`)
```
┌──────────────────────── AdminPage ────────────────────────┐
│ useAuth() → user, isLoading │
│ isAdmin = user?.is_admin === true │
│ │
│ [Header] logo | "Admin" | 읽기전용? | 캐시초기화? | 메인↗ │
│ [Nav] channels videos restaurants users daemon │
│ │
│ <main> │
│ tab==='channels' → ChannelsPanel(isAdmin) │
│ tab==='videos' → VideosPanel(isAdmin) │
│ tab==='restaurants'→ RestaurantsPanel(isAdmin) │
│ tab==='users' → UsersPanel() │
│ tab==='daemon' → DaemonPanel(isAdmin) │
└──────────────────────────────────────────────────────────┘
SSE 패턴 (벌크/벡터/리맵 공통):
fetch(POST endpoint, Authorization)
→ resp.body.getReader()
→ loop: read → decode → split("\n") → "data: ".substring
→ JSON.parse → ev.type 분기
processing | wait | done | error | complete
→ setBulkProgress({...})
```
- **I/O ↔ 순수 로직 경계**:
- I/O: 모든 `api.*`, `fetch`, `localStorage`, `confirm/alert`, SSE 스트림
- 순수: 필터/정렬/페이지 슬라이스 (`filteredVideos`, `sortedVideos`, `pagedVideos`), 정렬 토글, `toggleSelectAll`, `sortIcon`, `statusColor` 매핑
## 6. 데이터 모델
타입 (in-file 또는 `@/lib/api`):
```ts
type Tab = "channels" | "videos" | "restaurants" | "users" | "daemon";
interface Channel {
id: string; channel_id: string; channel_name: string;
title_filter: string | null; description: string | null;
tags: string | null; sort_order: number | null; video_count: number;
}
type VideoStatus = "pending" | "processing" | "done" | "error" | "skip";
interface Video {
id: string; channel_name: string; title: string; status: VideoStatus;
has_transcript: boolean; has_llm: boolean;
restaurant_count: number; matched_count: number;
published_at: string | null;
}
interface VideoDetail extends Video {
transcript: string | null; llm_response: string | null;
restaurants: ExtractedRestaurant[];
prompt?: string;
}
interface Restaurant {
id: string; name: string; address: string | null; region: string | null;
cuisine_type: string | null; price_range: string | null;
phone: string | null; website: string | null;
latitude: number | null; longitude: number | null;
rating: number | null; rating_count: number | null;
business_status: "OPERATIONAL" | "CLOSED_TEMPORARILY" | "CLOSED_PERMANENTLY" | null;
google_place_id: string | null;
tabling_url: string | null; // "NONE" = 검색 완료 결과 없음
catchtable_url: string | null;
}
interface AdminUser {
id: string; email: string | null; nickname: string | null; avatar_url: string | null;
is_admin: boolean; provider: string | null; created_at: string | null;
favorite_count: number; review_count: number; memo_count: number;
}
interface DaemonConfig {
scan_enabled: boolean; scan_interval_min: number;
process_enabled: boolean; process_interval_min: number; process_limit: number;
last_scan_at: string | null; last_process_at: string | null; updated_at: string | null;
}
// SSE 진행률 상태
type BulkProgress = { label: string; total: number; current: number; currentTitle: string;
results: { title: string; detail: string; error?: boolean }[]; waiting?: number };
type VectorProgress = { phase: string; current: number; total: number; name?: string };
type RemapProgress = { current: number; total: number; updated: number };
```
- **경계 검증**:
- 채널 추가: `newId.trim() && newName.trim()` 필수
- 데몬 `process_limit`: 1~50 (input min/max), 음수 방지
- 인라인 편집 좌표/숫자: input type=text → 백엔드 파싱 의존
- 페이지 인덱스: `Math.max(0, ...)` / `Math.min(totalPages-1, ...)`로 클램프
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `AdminPage` | 탭 라우팅 + 인증 가드 | `() => JSX` | - | JSX | isLoading/!user 분기 | **복잡** |
| `CacheFlushButton` | Redis 캐시 플러시 | `() => JSX` | - | JSX | `alert` | 단순 |
| `ChannelsPanel` | 채널 CRUD + 스캔 | `({ isAdmin }) => JSX` | `isAdmin` | JSX | catch+alert | **복잡** |
| `handleAdd` (Ch) | 채널 생성 | `() => Promise<void>` | state | void | alert(e.message) | 단순 |
| `handleSaveChannel` | 채널 메타 저장 | `(id) => Promise<void>` | id | void | alert | 단순 |
| `handleDelete` (Ch) | 채널 삭제 (confirm) | `(id, name) => Promise<void>` | id, name | void | alert | 단순 |
| `handleScan` | 채널 스캔(증분/전체) | `(channelId, full?) => Promise<void>` | id, full | void (scanResult map) | 인라인 메시지 | 단순 |
| `VideosPanel` | 영상 목록·필터·정렬·페이지·상세·벌크 | `({ isAdmin }) => JSX` | isAdmin | JSX | 다중 catch | **복잡** |
| `handleSelectVideo` | 상세 토글/로드 | `(v) => Promise<void>` | Video | void | alert | 단순 |
| `handleProcess` | 대기 영상 일괄 처리 | `() => Promise<void>` | - | void | 인라인 메시지 | 단순 |
| `startBulkStream` | 자막/LLM 벌크 SSE 처리 | `(mode, ids?) => Promise<void>` | `"transcript"\|"extract"`, ids? | void | network/parse fail | **복잡** |
| `startRebuildVectors` | 전체 벡터 재생성 SSE | `() => Promise<void>` | - | void | alert | **복잡** |
| `startRemapCuisine` | 음식 종류 재분류 SSE | `() => Promise<void>` | - | void | alert | **복잡** |
| `startRemapFoods` | 메뉴 태그 재생성 SSE | `() => Promise<void>` | - | void | alert | **복잡** |
| `handleSort` (V) | 정렬 키/방향 토글 | `(key) => void` | VideoSortKey | void | - | 단순 |
| `toggleSelect` / `toggleSelectAll` | 행 선택 관리 | `(id?) => void` | id | void | - | 단순 |
| `handleBulkSkip` / `handleBulkDelete` | 선택 행 일괄 처리 | `() => Promise<void>` | - | void | 실패 카운트 alert | 단순 |
| `RestaurantsPanel` | 식당 CRUD + 예약처 연결 | `({ isAdmin }) => JSX` | isAdmin | JSX | alert | **복잡** |
| `handleSelect` (R) | 식당 상세 로드/폼 prefill | `(r) => void` | Restaurant | void | - | 단순 |
| `handleSave` (R) | 식당 업데이트 | `() => Promise<void>` | editForm | void | alert | 단순 |
| `handleDelete` (R) | 식당 삭제 (confirm) | `() => Promise<void>` | - | void | alert | 단순 |
| 벌크 테이블링/캐치테이블 | 미연결 식당 일괄 검색 SSE | inline async | - | void | alert | **복잡** |
| `UsersPanel` | 유저 목록 + 상세 | `() => JSX` | - | JSX | console.error | 단순 |
| `loadUsers` | 페이지별 유저 fetch | `(p) => Promise<void>` | page | void | console.error | 단순 |
| `handleSelectUser` | 유저 상세(찜/리뷰/메모) 병렬 로드 | `(u) => Promise<void>` | AdminUser | void | console.error | 단순 |
| `DaemonPanel` | 데몬 설정/수동 실행 | `({ isAdmin }) => JSX` | isAdmin | JSX | result 메시지 | 단순 |
| `handleSave` (D) | 설정 저장 | `() => Promise<void>` | state | void | result 메시지 | 단순 |
| `handleRunScan` / `handleRunProcess` | 수동 실행 | `() => Promise<void>` | - | void | result 메시지 | 단순 |
> 복잡 기준: SSE 5종(`startBulkStream`, `startRebuildVectors`, `startRemapCuisine`, `startRemapFoods`, 벌크 예약처) 및 각 Panel은 외부 I/O+상태기계+분기 다수 → 별도 `fn-*.md` 설계서 후보.
## 8. 흐름 / 알고리즘
**① 페이지 부트스트랩**
1. `useAuth()` 로딩 중 → "로딩 중..."
2. `!user` → 로그인 안내 + 메인 링크
3. `tab` 상태(`"channels"` 기본) → 해당 패널 렌더, `isAdmin` 전파
**② 영상 벌크 처리 (SSE 패턴 전형)**
```
1. 선택 ids 또는 pending count 확인
2. confirm → setRunning(true), setBulkProgress(초기값)
3. fetch(POST /api/videos/bulk-{transcript|extract}, Authorization)
4. while (chunk = await reader.read()) {
buf += decode; lines = buf.split("\n"); buf = pop
for line of lines if line.startsWith("data: "):
ev = JSON.parse(line.slice(6))
switch (ev.type) {
processing → current=index+1, currentTitle
wait → waiting=delay
done → results.push({title, detail})
error → results.push({title, detail, error:true})
complete → setRunning(false), load()
}
}
5. finally: setRunning(false), load()
```
**③ 정렬/필터/페이지 (영상·식당 공통 패턴)**
- `filtered = videos.filter(predicate)``sorted = [...filtered].sort``paged = sorted.slice(page*perPage, (page+1)*perPage)`
- 정렬: `sortKey` 동일 시 방향 토글, 다르면 새 키+asc=true
**④ 유저 상세**
- 행 클릭 → 같은 유저면 닫기, 아니면 `Promise.all([favorites, reviews, memos])` 병렬 로드 → 3분할 그리드
**⑤ 데몬 수동 실행**
- 결과 메시지에 "실패"/"API" 포함 여부로 빨강/초록 색상 분기
## 9. 엣지케이스 & 에러 처리
- **권한 없음(읽기 전용)**: 모든 변경 액션 버튼 미렌더 + 입력 `disabled`. 단, 헤더에 "읽기 전용" 뱃지 노출하여 모드를 명시.
- **isLoading 동안 빠른 클릭**: 페이지 자체가 로딩 화면이라 차단됨.
- **SSE 도중 네트워크 끊김**: `while` 루프 종료 → finally에서 `setRunning(false)` + `load()`. 진행률은 마지막 값에 멈춤 (재시도는 사용자 수동).
- **SSE 부분 라인**: `buf`에 미완료 라인 보관, 다음 chunk와 합쳐서 파싱 (`buf = lines.pop()`).
- **JSON.parse 실패**: 개별 라인 try/catch → 무시하고 계속.
- **pending.count === 0**: alert 후 조기 종료.
- **bulk 중복 실행 방지**: 같은 또는 충돌 작업 버튼들에 `disabled={... || ...}` 다중 조건.
- **확인 다이얼로그 취소**: 모든 파괴적 액션은 `confirm()` 우선.
- **테이블링 URL "NONE"**: 검색 완료-결과없음 의미. UI에서 별도 텍스트로 처리.
- **알 수 없는 유저**: `nickname || email || "?"` → 첫글자 대문자 폴백.
- **페이지 인덱스 boundary**: `Math.max(0, p-1)` / `Math.min(totalPages-1, p+1)`.
- **선택 행 삭제 실패 누적**: 카운트하여 마지막에 `${failed}개 삭제 실패` alert.
- **인라인 편집 중 데이터 새로고침**: `setEditingRestIdx(null)` 등으로 리셋.
- **alert/confirm 의존**: 모바일 어드민에서는 alert UX가 거칠지만 어드민 한정으로 허용.
## 10. 테스트 계획
현재 자동화 테스트 없음 (TBD). 권장 구성:
- **단위 (Vitest + RTL)**:
1. `AdminPage` 인증 가드 — `user=null` / `user.is_admin=false` 분기 렌더
2. `ChannelsPanel.handleAdd` — 빈 입력 차단, 정상 호출 시 reload
3. `VideosPanel` 필터/정렬/페이징 순수 로직 (`filteredVideos`/`sortedVideos`/`pagedVideos`) 추출 후 테스트
4. `toggleSelectAll` — 전체 선택/해제 토글
5. `statusColor` 매핑
- **통합 (MSW + 가짜 SSE)**:
- `startBulkStream("transcript")` — SSE 라인 5종 시퀀스 주입 → 진행률 상태 전이 검증
- 부분 라인 분할(`buf.split("\n")`) 케이스
- 권한 없는 사용자에 대해 변경 API 호출이 시도되지 않는지
- **E2E (Playwright)**:
- 관리자 로그인 → 채널 추가 → 스캔 → 영상 상태 변화 확인
- 식당 상세에서 좌표 수정 후 저장 → 목록 갱신
- **드라이런 전략**: `process.env.NEXT_PUBLIC_API_URL`을 MSW로 가로채 SSE를 ReadableStream으로 모킹.
## 11. 리스크 & 대안 검토
- **단일 파일 2,742 LOC**: 가독성·테스트성 저하. 대안: 탭별 파일 분리(`admin/_components/*Panel.tsx`). 채택 보류 (안정성 우선), ADR 후보.
- **클라이언트 측 필터/정렬/페이징**: 데이터 증가 시 메모리·렌더 비용. 대안: 서버 페이징·정렬. 영상/식당이 수만 건 도달 시 전환 필수.
- **SSE 코드 중복**: 5개 핸들러가 동일 패턴. 대안: `useSSEStream(endpoint, onEvent)` 커스텀 훅. 추후 리팩토링 권장.
- **`localStorage` 직접 접근**: api 레이어 외 4곳에서 토큰을 직접 읽음. 대안: `api.fetchStream()` 헬퍼 도입.
- **alert/confirm UX**: 일관된 토스트/모달 시스템 부재. 어드민 한정이므로 유지.
- **권한 분기 누락 위험**: `isAdmin` 가드를 모든 액션 버튼에 수동으로 분산 → 신규 액션 추가 시 가드 누락 가능. 대안: `<AdminGate>` 래퍼 컴포넌트.
- **타입 캐스팅**: `(res as Record<string, unknown>).filtered` 등 ad-hoc 캐스팅 → 응답 스키마를 타입으로 명세하는 것이 안전.
- **에러 swallowing**: 다수의 `catch { /* ignore */ }` — 운영자 디버깅 어려움. 콘솔 로깅 보강 권장.
## 12. 미해결 질문 (Open Questions)
- 어드민 페이지를 탭별 라우트(`/admin/channels`, `/admin/videos`...)로 쪼개야 하는가? (북마크/딥링크 측면)
- SSE 도중 페이지를 떠나면 진행률이 손실됨 — 서버 측 작업 상태 폴링 API가 필요한가?
- 벌크 작업 동시 실행을 허용해야 할 시나리오가 있는가? (현재는 상호배타)
- 캐치테이블/테이블링 외 추가 예약처(망고플레이트 등)가 들어올 때 어드민 UI 패턴은?
- 권한 모델 확장(편집자/뷰어 등 다단계)이 필요한가? 현재는 admin/non-admin 이진.
- 사용자 삭제·일괄 차단 등 운영 기능은 별도 설계서로 분리할 것인가?
- 어드민 활동 감사 로그(누가 무엇을 언제 변경했는지) 표시는?

View File

@@ -0,0 +1,199 @@
<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지. -->
# 설계서: 프론트 - 로그인 메뉴 (#283)
> **상태**: Draft
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #283 · 관련 ADR: 없음
> · 구현 파일: `frontend/src/components/LoginMenu.tsx` · 테스트: TBD (현재 없음)
## 1. 목적 (Why)
헤더에서 비로그인 사용자가 한 번의 클릭으로 Google 소셜 로그인 모달을 띄워 가입/로그인을 완료할 수 있도록, 다른 UI(지도·바텀시트)와 겹치지 않는 **z-index 안전한 모달** 진입점을 제공한다.
## 2. 범위 (Scope)
- **포함**:
- "로그인" 버튼 (헤더용)
- 클릭 시 `<body>`에 Portal로 마운트되는 모달 (백드롭 + 카드)
- Google 로그인 위젯(`@react-oauth/google`)의 콜백을 부모에 전달
- 백드롭 클릭 / "✕" 버튼으로 닫기
- 다크모드 색상 토큰 대응
- **제외 (out of scope)**:
- 토큰 저장(`localStorage`), 백엔드 검증, 세션 갱신 — 부모(`onGoogleSuccess`)의 책임
- 카카오/네이버/이메일 로그인 등 추가 프로바이더
- 로그아웃 UI (별도 컴포넌트)
- GoogleOAuthProvider 설정 (`_app`/`layout.tsx`에서 처리)
## 3. 인수조건 (이미 구현된 동작 기준)
- [x] "로그인" 버튼이 헤더 스타일(보더+호버 brand 색)로 노출된다.
- [x] 클릭 시 모달이 열리고, 화면 전체를 덮는 백드롭(`bg-black/40 backdrop-blur-sm`)이 깔린다.
- [x] 모달은 `createPortal``document.body`에 마운트되어 부모의 stacking context 영향을 받지 않는다.
- [x] 모달 z-index는 `99999`로 다른 모든 오버레이(지도, 바텀시트)보다 위에 위치한다.
- [x] 모달 안에 "소셜 계정으로 간편 로그인" 안내와 Google 로그인 버튼(`size=large, width=260`)이 표시된다.
- [x] Google 로그인 성공 시 `credential`을 부모 `onGoogleSuccess(credential)`로 전달하고 모달을 닫는다.
- [x] Google 로그인 실패 시 `console.error("Google login failed")` 로깅(UX는 위젯이 처리).
- [x] 백드롭 영역(자식이 아닌 본인 클릭)에서만 모달이 닫힌다 (`e.target === e.currentTarget`).
- [x] 우상단 "✕" 버튼으로 모달을 닫을 수 있다.
- [x] 다크모드 토큰(`dark:text-gray-300`, `dark:border-gray-600`, `dark:hover:border-brand-500`)으로 색이 적응한다.
## 4. 컨텍스트 & 제약
- **프레임워크**: Next.js 16 App Router, Client Component (`"use client"`)
- **라이브러리**:
- `@react-oauth/google``<GoogleLogin>` 위젯 사용, 상위 어딘가에 `<GoogleOAuthProvider clientId>` 마운트 필요
- `react-dom``createPortal`
- **스타일**: Tailwind, Saffron 디자인 토큰 — `bg-surface`, `text-brand-600`, `border-brand-400`
- **z-index 제약**: 지도/바텀시트/Drawer 등 기존 오버레이가 다수 존재 → Portal + inline style `z-index: 99999` 사용 (Tailwind 클래스가 아닌 인라인으로 적용해 명시적 우선순위 보장)
- **SSR 안전성**: `createPortal``document.body`를 참조하므로 `"use client"` 필수. SSR 단계에서는 `open=false` 초기 상태로 모달 미렌더 → 안전.
- **가정**:
- 부모는 `onGoogleSuccess`에서 백엔드 `/api/auth/google` 호출 및 토큰 저장을 책임진다.
- GoogleOAuthProvider clientId는 환경 변수(`NEXT_PUBLIC_GOOGLE_CLIENT_ID`)로 주입된다.
## 5. 아키텍처 개요
- 파일:
- `LoginMenu.tsx` — 단일 컴포넌트
- 외부:
- 부모: 헤더 (예: `Header.tsx`, `page.tsx`) — `onGoogleSuccess` 콜백 제공
- 상위: `<GoogleOAuthProvider>` 마운트 (layout)
```
[Header]
└─ <LoginMenu onGoogleSuccess={handleGoogle}>
│ state: open: boolean
├─ <button "로그인" onClick={() => setOpen(true)}>
└─ open && createPortal(
<Backdrop onClick={closeIfBackdrop}>
<Card>
<Header (제목 + ✕)>
<안내 텍스트>
<GoogleLogin
onSuccess={(res) => {
if (res.credential) {
onGoogleSuccess(res.credential)
setOpen(false)
}
}}
onError={console.error}>
</Card>
</Backdrop>,
document.body
)
흐름:
user → click 로그인
→ open=true
→ Portal 모달 마운트
→ Google 위젯 → OAuth popup/iframe
→ onSuccess(credential)
→ 부모 핸들러 (백엔드 검증/토큰 저장)
→ setOpen(false) → Portal 언마운트
```
- **I/O ↔ 순수 로직 경계**:
- I/O: Google OAuth (위젯 내부), `document.body` 접근, 부모 콜백
- 순수: `open` 상태기계(open/close), 백드롭 영역 판정 (`e.target === e.currentTarget`)
## 6. 데이터 모델
타입:
```ts
interface LoginMenuProps {
onGoogleSuccess: (credential: string) => void;
}
// @react-oauth/google
interface CredentialResponse {
credential?: string; // Google ID Token (JWT)
select_by?: string;
clientId?: string;
}
// 내부 상태
type LocalState = { open: boolean };
```
- **경계 검증**:
- `res.credential`이 falsy(undefined/empty)면 부모 콜백을 호출하지 않음 — 빈 토큰 전파 방지.
- `credential`은 Google ID Token(JWT) 문자열. 검증은 백엔드(서명·aud·exp) 책임.
- 모달 백드롭 클릭 판정: `e.target === e.currentTarget` — 자식(카드) 클릭은 닫지 않음.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `LoginMenu` | 로그인 트리거 + 모달 렌더 | `({ onGoogleSuccess }) => JSX` | `LoginMenuProps` | JSX (button + portal) | onError → console.error | 단순 |
| `setOpen(true)` (onClick) | 모달 오픈 | inline | MouseEvent | void | - | 단순 |
| `closeIfBackdrop` | 백드롭 클릭 시 닫기 | inline `(e) => void` | MouseEvent | void | 자식 클릭이면 no-op | 단순 |
| `setOpen(false)` (✕ onClick) | 모달 강제 닫기 | inline | MouseEvent | void | - | 단순 |
| `GoogleLogin.onSuccess` | 토큰 추출 → 부모 콜백 → 닫기 | inline `(res) => void` | `CredentialResponse` | void | credential 없으면 무시 | 단순 |
| `GoogleLogin.onError` | 실패 로깅 | inline `() => void` | - | void | console.error | 단순 |
> 모든 함수가 단순. 외부 I/O(Google OAuth)는 위젯이 캡슐화하므로 추가 fn-*.md 불필요.
## 8. 흐름 / 알고리즘
**① 모달 오픈**
1. 사용자가 "로그인" 버튼 클릭 → `setOpen(true)`
2. 리렌더에서 `open === true``createPortal(...)` 실행 → `<body>` 마지막 자식으로 모달 마운트
**② Google 로그인 성공**
1. `<GoogleLogin>` 위젯 내부에서 Google OAuth 진행
2. 위젯이 `onSuccess({ credential })` 호출
3. `if (res.credential)` 가드
4. `onGoogleSuccess(credential)` — 부모가 백엔드 `/api/auth/google` 호출 등 처리
5. `setOpen(false)` → Portal 언마운트
**③ 모달 닫기 경로**
- "✕" 버튼: `onClick={() => setOpen(false)}`
- 백드롭 클릭: `onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}`
- 카드 내부 클릭: 이벤트가 백드롭까지 버블링되더라도 `e.target !== currentTarget`이므로 닫히지 않음 ✅
**④ 실패**
- `onError`: 위젯이 호출 → `console.error("Google login failed")` 만 수행. 모달은 그대로 열려 있어 재시도 가능.
## 9. 엣지케이스 & 에러 처리
- **`res.credential` 없음**: 사용자가 OAuth 동의를 거부하거나 Google이 credential 없이 응답하는 경우 — 콜백/닫기 모두 skip하여 모달 유지.
- **GoogleOAuthProvider 미설정**: 위젯이 렌더되지 않거나 콘솔 에러. 사용자에게는 빈 영역으로 보일 수 있음 → 상위 layout 설정 필수 (배포 체크리스트 항목).
- **`document.body` 미존재 (SSR)**: `open` 초기값 `false` → 초기 렌더에서 `createPortal` 호출 안 함. 클라이언트 hydration 후에만 모달 가능.
- **모달 열린 상태에서 라우트 이동**: 컴포넌트 언마운트 → Portal도 자동 제거.
- **다중 모달 z-index 충돌**: 다른 모달들이 99999 이하라면 항상 위. 동일/상위 z-index 사용 모달이 있으면 디자인 가이드로 합의 필요.
- **백드롭 위에서 텍스트 드래그**: `e.target === e.currentTarget` 판정으로 의도치 않은 닫힘 방지.
- **백드롭 클릭으로 닫지 않게 하려면**: 향후 옵션화 필요 (현재는 항상 닫힘).
- **ESC 키로 닫기**: 미구현 (개선 여지 — 미해결 질문 참조).
- **Focus trap**: 미구현. 접근성 측면 약점.
- **연속 클릭으로 다중 OAuth 팝업**: 위젯 자체 가드에 의존. LoginMenu 차원에서는 별도 디바운스 없음.
## 10. 테스트 계획
현재 자동화 테스트 없음 (TBD). 권장:
- **단위 (Vitest + RTL)**:
1. 초기 렌더에서 모달 비노출 (`queryByText("소셜 계정으로 간편 로그인")` → null)
2. "로그인" 클릭 → 모달 노출
3. "✕" 클릭 → 모달 닫힘
4. 카드 내부 클릭 → 모달 유지 (백드롭 가드 검증)
5. 백드롭 클릭 → 모달 닫힘
6. `GoogleLogin.onSuccess` mock 호출 시 `onGoogleSuccess(credential)` 전달 + 모달 닫힘
7. `credential = undefined`인 응답 → 콜백 미호출 + 모달 유지
- **통합**:
- `@react-oauth/google` 모듈 모킹 → 위젯 자리에 더미 버튼 렌더, 클릭 시 onSuccess 트리거
- Portal target(`document.body`) 검증
- **E2E (Playwright)**:
- 실제 Google OAuth는 외부 의존이라 staging만 — 통상 모킹/스킵
- **드라이런 전략**: clientId를 테스트 환경 변수로 분리, `GoogleOAuthProvider`를 테스트 wrapper로 대체.
## 11. 리스크 & 대안 검토
- **Google 단일 프로바이더 의존**: 카카오/네이버 확장 시 다중 위젯 배치 필요 → 컴포넌트 분리(`<SocialLoginButtons>`)가 자연스러움. 현 단계는 단순성 우선.
- **인라인 `z-index: 99999`**: 디자인 시스템 z-index 토큰 부재. 대안: `--z-modal: 99999` CSS 변수 + 클래스. 현재 인라인 사용은 의도된 안전장치.
- **`@react-oauth/google` 의존**: 라이브러리 미유지 시 직접 Google Identity Services 스크립트 로드 필요. 폴백 계획은 미정.
- **접근성**: focus trap, ESC 종료, ARIA(`role="dialog"`, `aria-modal`) 미적용 → 단기 개선 후보.
- **다국어**: "로그인", "소셜 계정으로 간편 로그인" 하드코딩 한국어. i18n 도입 시 추출 필요.
- **백드롭 닫기 비활성 옵션 부재**: 일부 화면(중단 위험 작업 중)에서 강제로 열어둘 수 없음. props로 옵션화 권장.
- **부모 콜백의 비동기 실패 처리 누락**: `onGoogleSuccess`가 throw해도 `setOpen(false)`가 먼저 실행되어 사용자 피드백 사라짐. 대안: `await onGoogleSuccess()` 후 분기.
## 12. 미해결 질문 (Open Questions)
- ESC 키 / focus trap / ARIA 속성을 도입할 일정·우선순위는?
- 추가 OAuth 프로바이더(카카오/네이버/Apple) 도입 계획이 있는가? 있다면 위젯 통합 패턴은?
- 로그인 후 어떤 화면으로 리다이렉트할지 (현재는 모달만 닫고 위치 유지) — 명시적 라우팅이 필요한 화면이 있는가?
- 이미 로그인된 상태에서 `<LoginMenu>`가 헤더에 노출되는 경우는? (현재 상위에서 조건부 렌더 가정)
- 모바일에서 모달 카드 위치/사이즈가 적절한가? (현재 `max-w-xs`)
- 로그인 실패 사용자에게 인라인 에러 메시지를 보여줘야 하는가? (현재 console.error만)
- Google ID Token을 부모로 직접 넘기는 대신, `LoginMenu` 안에서 백엔드 호출까지 묶는 것이 더 응집도가 높을까?