# 설계서: 백엔드 - 채널 관리 (#273) > **상태**: Approved > **작성**: [AI] Architect · **최종수정**: 2026-06-15 > **추적성** — Redmine: #273 · 관련 ADR: 없음 > · 구현 파일: `backend-java/src/main/java/com/tasteby/service/ChannelService.java`, `backend-java/src/main/java/com/tasteby/controller/ChannelController.java` · 테스트: TBD (현재 없음) ## 1. 목적 (Why) Tasteby가 식당 정보를 수집하는 YouTube 채널을 관리(등록/수정/비활성화/스캔)하여, 추출 파이프라인의 데이터 원천을 통제 가능하게 한다. 채널은 사용자 프론트엔드의 "채널 필터" UI 데이터 소스이기도 하다. ## 2. 범위 (Scope) - **포함**: - 활성 채널 목록 조회 (공개 API, 캐시 적용) - 채널 등록 (관리자 전용) - 채널 메타데이터 수정 (description/tags/sort_order, 관리자 전용) - 채널 비활성화 (soft delete, 관리자 전용) - 채널 영상 스캔 트리거 (관리자 전용, `YouTubeService.scanChannel` 위임) - **제외 (out of scope)**: - 채널 영상 자체의 추출/요약 로직 (#270 추출 파이프라인) - 채널 통계/대시보드 (#274 통계) - YouTube API 인증/쿼터 관리 세부사항 (YouTubeService 책임) - 채널 카테고리 트리/계층화 ## 3. 인수조건 (Acceptance Criteria) - [x] `GET /api/channels` 호출 시 활성 채널 목록 반환, 캐시 hit/miss 모두 동일 결과 - [x] 관리자 외 사용자가 `POST /api/channels` 호출 시 권한 거부 (`AuthUtil.requireAdmin()` throw) - [x] 동일 `channel_id`로 중복 등록 시 HTTP 409 + "Channel already exists" (유니크 제약 `UQ_CHANNELS_CID`) - [x] `DELETE /api/channels/{channelId}` 시 `channel_id` 우선 매칭, 실패 시 DB `id`로 재시도 (양쪽 모두 실패 → 404) - [x] 채널 관련 쓰기 작업 후 `cache.flush()` 호출되어 다음 GET에서 최신 데이터 반환 ## 4. 컨텍스트 & 제약 - **DB**: Oracle 23ai. 테이블 `channels`. 유니크 제약 `UQ_CHANNELS_CID` on `channel_id`. - **외부 의존**: - `YouTubeService.scanChannel(channelId, full)` (#270 추출 파이프라인) - `CacheService` (Redis 캐시, `makeKey/getRaw/set/flush`) - **권한**: 조회(GET)는 공개, 그 외 모두 `AuthUtil.requireAdmin()`로 관리자만. - **캐시**: 목록 응답은 Redis에 JSON 직렬화 저장. 쓰기 시 flush. - **Soft delete**: 비활성화는 `active = 0` UPDATE (물리 삭제 아님). - **가정**: `channel_id`는 YouTube의 외부 ID (`UCxxxx...`). DB `id`는 32자 UUID. ## 5. 아키텍처 개요 - 모듈/파일 구조: - `controller/ChannelController.java` (5개 엔드포인트) - `service/ChannelService.java` (CRUD 비즈니스) - `service/YouTubeService.java` (스캔 위임, 외부) - `service/CacheService.java` (Redis 캐시, 외부) - `mapper/ChannelMapper.java` + XML - `domain/Channel.java` - `security/AuthUtil.java` - I/O ↔ 순수 로직 경계: Controller는 캐시 hit/miss + 권한, Service는 식별자 매칭 폴백 로직, Mapper는 SQL. ``` [Client] │ HTTP ▼ [ChannelController] ← AuthUtil.requireAdmin() (쓰기) │ cache hit? ─┐ │ ▼ │ [CacheService(Redis)] ← GET/SET/FLUSH │ miss ▼ [ChannelService] ← deactivate: channel_id → id 폴백 │ ▼ [ChannelMapper] (MyBatis XML) │ ▼ [Oracle 23ai: channels] [ChannelController.scan] → [YouTubeService.scanChannel] → (영상 수집 파이프라인) ``` ## 6. 데이터 모델 - **Channel** (`domain/Channel.java`): - `id: String` (32자 UUID, PK) - `channelId: String` (YouTube 외부 ID, 유니크) - `channelName: String` - `titleFilter: String` (정규식/포함 문자열, 영상 제목 필터) - `description: String` - `tags: String` (콤마 구분) - `sortOrder: Integer` - `videoCount: int` (조인 집계) - `lastVideoAt: String` (조인 집계) - **POST 요청 본문**: `{ channel_id, channel_name, title_filter }` - **PUT 요청 본문**: `{ description, tags, sort_order }` - **POST 응답**: `{ id, channel_id }` - **scan 응답**: `YouTubeService.scanChannel` 반환 Map (영상 수, 신규 추출 수 등) - **경계 검증**: 현재 명시적 검증 없음. `channel_id` 형식(`UC` prefix) 검증 미적용. 길이 제한은 DB 컬럼 의존. ## 7. 함수 명세 (Function Specs) | 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | |------|-----------|----------------|------|------|-----------|-------| | `ChannelService.findAllActive` | 활성 채널 목록 조회 | `List()` | 없음 | List | DB 오류 → 전파 | 단순 | | `ChannelService.create` | 채널 신규 등록 | `String(channelId, channelName, titleFilter)` | YouTube ID/이름/필터 | 생성 PK | 유니크 충돌 → SQLException | 단순 | | `ChannelService.deactivate` | soft delete (폴백) | `boolean(channelId)` | channel_id 또는 DB id | 성공 여부 | 둘 다 0행 → false | **복잡** | | `ChannelService.findByChannelId` | 단건 조회 | `Channel(channelId)` | channel_id | Channel or null | 없음 | 단순 | | `ChannelService.update` | 메타데이터 부분 갱신 | `void(id, description?, tags?, sortOrder?)` | DB id + 필드 | 없음 | DB 오류 → 전파 | 단순 | | `ChannelController.list` | 캐시 우선 목록 응답 | `List()` | 없음 | List | 캐시 파싱 실패 → 무시, DB 조회 | 단순 | | `ChannelController.create` | 등록 + 캐시 flush | `Map(body)` | body | `{id, channel_id}` | 유니크 → 409, 그 외 → 전파 | **복잡** | | `ChannelController.scan` | 채널 스캔 트리거 | `Map(channelId, full)` | channelId, full | 스캔 결과 Map | 미존재 → 404 | **복잡** | | `ChannelController.update` | 메타 갱신 + flush | `Map(id, body)` | id, body | `{ok:true}` | 권한 → 403 | 단순 | | `ChannelController.delete` | 비활성화 + flush | `Map(channelId)` | channelId | `{ok:true}` | 미존재 → 404 | 단순 | > `deactivate` (이중 매칭 폴백), `create` (충돌 → 메시지 파싱), `scan` (외부 위임)은 복잡. fn 설계서 후보. ## 8. 흐름 / 알고리즘 1. **목록 조회 (캐시)**: ``` key = cache.makeKey("channels") if cache.getRaw(key) != null: try return objectMapper.readValue(cached) catch: fall through result = mapper.findAllActive() cache.set(key, result) return result ``` 2. **채널 등록**: 관리자 검증 → IdGenerator.newId() → INSERT → `cache.flush()` → `{id, channel_id}` 응답. SQL 예외의 message에 `UQ_CHANNELS_CID` 포함되면 409로 매핑. 3. **비활성화 폴백**: - `mapper.deactivateByChannelId(channelId)` 시도 → - 0행이면 `mapper.deactivateById(channelId)` 시도 → - 둘 다 0행 → false → 404. - 이유: 운영자가 YouTube ID 또는 DB UUID 중 어느 것으로도 비활성화 가능. 4. **스캔 트리거**: 관리자 검증 → `YouTubeService.scanChannel(channelId, full)` 호출 → null 응답 시 404 → 성공 시 `cache.flush()` (영상 추가로 채널 메타 변동 가능성) → 결과 반환. 5. **메타 갱신**: PUT body의 sort_order는 Number → int 변환. `tags`, `description`, `sort_order` 부분 갱신. ## 9. 엣지케이스 & 에러 처리 - **캐시 직렬화 깨짐**: `objectMapper.readValue` 실패 시 catch 무시 → DB 폴백. 안전한 기본값. - **유니크 충돌 감지**: 예외 메시지에 `UQ_CHANNELS_CID` 문자열 의존. DB 제약명이 바뀌면 감지 실패 → 500. → 향후 SQLState 또는 DataIntegrityViolationException 기반 매핑 권장. - **deactivate 이중 시도**: 동일 channel_id가 DB id와 우연히 충돌하면 의도치 않은 행 비활성화 가능 (UUID 충돌 확률 매우 낮음). - **scan 미존재 채널**: `YouTubeService`가 null 반환 → 404. YouTube API 자체 장애는 상위로 전파 (현재 별도 매핑 없음). - **권한 누락**: `AuthUtil.requireAdmin()` 예외 → 403. - **빈 본문 / 필수값 누락**: `body.get("channel_id")` 가 null → INSERT 시 NOT NULL 제약 위반 → 500. → 명시적 400 매핑 권장. - **캐시 flush 실패**: Redis 다운 시 예외 전파. 운영 안전성 위해 try-catch + WARN 로깅 검토. ## 10. 테스트 계획 - **단위** - `ChannelService.deactivate`: by-channelId 성공 → true / by-channelId 실패 + by-id 성공 → true / 둘 다 실패 → false - `ChannelController.list`: 캐시 hit 시 ObjectMapper 호출, miss 시 mapper 호출 - `ChannelController.create`: 유니크 메시지 포함 예외 → 409 - `ChannelController.scan`: YouTubeService null → 404 - **통합** - 채널 등록 후 GET 목록에 반영 (캐시 flush 검증) - 중복 channel_id 등록 시 409 - 비관리자 인증으로 POST 시 403 - update 후 sort_order 반영 + 캐시 무효화 - **모킹**: `YouTubeService`, `CacheService` 모킹. Redis는 embedded-redis 또는 testcontainers. - 현재 테스트 디렉토리 없음 → TBD. ## 11. 리스크 & 대안 검토 - **유니크 충돌 감지를 메시지 문자열로 판정**: 깨지기 쉬움. - 대안 A: Spring의 `DuplicateKeyException` catch → 깔끔. - 대안 B: 사전 SELECT 후 INSERT → 경합 시 여전히 위험. - 트레이드오프: 현 방식은 빠르지만 fragile. ADR 후보. - **deactivate 폴백 패턴**: 유연성 vs 명확성. - 대안: 별도 엔드포인트 (`/by-id`, `/by-yt-id`)로 분리. 운영 UI 합의 필요. - **캐시 정책**: 전체 flush vs 키 단위 invalidate. - 현재 flush는 다른 모듈(예: 식당 목록)까지 영향. 채널 키만 무효화하도록 개선 가능. - **스캔의 동기 호출**: 대량 영상 채널은 응답 지연 가능. - 대안: 비동기 큐 + 작업 상태 폴링 (#275 데몬과 통합). ## 12. 미해결 질문 (Open Questions) - 채널 활성화 복구(reactivate) API가 필요한지? 현재는 DB 직접 수정만 가능. - `title_filter`는 정규식인지 단순 contains인지 명세 부재 — 코드 확인 필요. - 채널 단위 권한 (소유자 개념)을 도입할지? 현재는 글로벌 관리자만. - 스캔 작업의 진행률/실패 재시도 정책 — 데몬(#275)과 통합 범위. - 캐시 TTL 설정값 (현재 코드에 명시 없음, CacheService 정책 의존).