<!-- 기능 설계서. design/273-backend-channel/README.md
     작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->

# 설계서: 백엔드 - 채널 관리 (#273)

> **상태**: Approved <!-- 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 정책 의존).
