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:
170
docs/design/273-backend-channel/README.md
Normal file
170
docs/design/273-backend-channel/README.md
Normal 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 정책 의존).
|
||||
Reference in New Issue
Block a user