# 소재 발굴 피드 (Source Feed) 설계 작성일: 2026-07-31 ## 배경 내 채널 `clipOut-log`(UCVJZ3_z7dqpUvaEgaWRltuA)는 한국 웹예능·토크쇼의 명장면을 잘라 올리는 쇼츠 전문 채널이다. 최근 50개 업로드 분석 결과 소재의 단위는 **"웹예능 에피소드 안의 한 순간"**이며, 반복 등장하는 소스는 유퀴즈·핑계고·미미미누·아는형님·살롱드립·짠한형 신동엽·요정식탁·어쩌다사장· 워크맨·입만열면·차린건쥐뿔도없지만이다. 기존 발굴(`/discover`, `/recommend`)은 **조회수순 떡상 채널 발굴**이라 목적이 다르다. 이 문서는 **"이런 소재를 최신순으로"** 를 위한 별도 피드를 정의한다. ## 목표 1. **선점** — 소스(웹예능 공식채널)의 신규 롱폼 업로드를 최신순으로 받아, 아직 아무도 안 자른 구간을 먼저 클립화 2. **추격** — 경쟁 예능짤 쇼츠 채널의 신규 쇼츠를 최신순으로 보며 반응 검증된 소재·제목·썸네일 벤치마킹 범위 밖(후속): 자막 기반 "터지는 구간" 자동 제안(2단계). ## 결정 사항 | 항목 | 결정 | 이유 | |---|---|---| | 탭 구성 | 소재 원본 / 경쟁 쇼츠 2탭 | 선점과 추격을 같이 보되 성격이 달라 액션이 다름 | | 소스 탭 대상 | **롱폼만** (`durationSec > 65`) | 공식계정이 올린 쇼츠는 이미 잘린 결과물이라 소재 가치 없음 | | 경쟁 탭 대상 | 쇼츠만 (`isShorts = true`) | 벤치마킹 대상 | | 시드 확보 | 자동 후보 → 수동 승인(하이브리드) | 오탐 방지 + 자동 편재 | | 피드 깊이 | 1단계(영상 목록)까지 | YAGNI. 구간 제안은 별도 과제 | | 구현 방식 | 기존 채널 동기화 파이프라인 재사용 | 쿼터 60배 저렴, 재가공 스튜디오와 즉시 연결 | ## 아키텍처 ### 데이터 모델 `Channel`에 역할 컬럼 하나만 추가한다. ```java @Column(length = 10) private String role = "MY"; // MY | SOURCE | RIVAL ``` `ddl-auto: update`는 기존 행을 NULL로 남기므로, 조회는 전부 `COALESCE(role,'MY')` 의미로 처리하고 앱 부팅 시 `role IS NULL → 'MY'` 1회 백필을 수행한다(프로젝트에 마이그레이션 파일이 없는 관례에 맞춤). `ChannelVideo`는 컬럼 추가 없음. 기존 컬럼을 그대로 쓴다. | 용도 | 기존 컬럼 | |---|---| | 피드 구분 | `source` — 기존 `CHANNEL`/`SEARCH` 에 `SOURCE`/`RIVAL` 값 추가 | | 최신순 정렬 | `publishedAt` | | 롱폼/쇼츠 구분 | `durationSec`, `isShorts` (`VideoMetrics.isShorts` = 65초 이하) | | 경쟁 떡상 배지 | `viewsPerHour` | | 이미 손댄 소재 | `interestStatus`, `bookmarked` | | 시드 역분석 재료 | `hashtags` | `RecommendedChannel`에 `roleHint`(SOURCE/RIVAL), `seedKeyword` 추가 — 승인 UI를 재사용하기 위함. ### 수집 `FeedCollectionService` 신설. 기존 `ChannelService`의 uploads 플레이리스트 동기화를 재사용한다. - 주기 **3시간** (`hlab.feed.cron`). 소재 선점은 업로드 후 몇 시간이 승부라 일 1회로는 늦음 - 채널당 uploads **첫 페이지 50개**만 조회, `publishedAt`이 최근 N일(기본 14) 이내인 것만 upsert - 소스 채널 → 롱폼만 저장(`source='SOURCE'`), 경쟁 채널 → 쇼츠만 저장(`source='RIVAL'`) - `YoutubeQuotaGuard.tryConsume` 로 채널 단위 가드. 채널당 추정 2 units(playlistItems 1 + videos 1) - 연속 3회 실패한 채널은 `feedFailCount >= 3` 로 자동 스킵(UI에서 초기화 가능) 기존 일일 채널 수집(`ScheduledCollectionService.runChannelCollection`)은 **role=MY 만** 대상으로 좁힌다. 백필 후 기존 채널은 전부 MY이므로 동작 변화 없음. **수집함 격리** — `/collection`, `/discover`, 떡상 후보 쿼리는 `source` 를 명시하지 않은 경우 `CHANNEL`/`SEARCH` 만 대상으로 한다. 피드 영상이 기존 화면을 덮지 않는다. ### 화면 `/feed` 정렬은 `publishedAt DESC` 고정(정렬 셀렉터 없음). **[소재 원본] 탭** — `source='SOURCE'` - 카드: 썸네일 / 프로그램명 / 제목 / 상대시간 / 재생시간 / 조회수 - 배지: `골든타임`(24시간 이내), `공식클립`(65초~15분) / `풀에피`(15분+), `작업함`(interestStatus != NEW) - 액션: 재가공(`/rework/{id}`), 숨김(EXCLUDED), 북마크 **[경쟁 쇼츠] 탭** — `source='RIVAL'` - 배지: `떡상중`(viewsPerHour 상위), `골든타임` - 액션: YouTube 열기, 북마크, 숨김 (남의 쇼츠는 재가공 대상이 아님) 공통 필터: 프로그램(채널) 선택 / 길이 / 기간(24h·3일·7일·14일) / 작업한 것 숨기기 ### 시드 발굴 **소스 시드** — `SeedSuggestService` 1. `role=MY` 영상의 `hashtags`·제목에서 해시태그 빈도 집계(`HashtagExtractor`, 순수 로직) 2. 상위 키워드마다 `search.list type=channel&q=<키워드>` 조회 (100 units/건이라 **수동 실행 버튼**) 3. `RecommendedChannel(roleHint=SOURCE, status=NEW)` upsert → `/recommend`에서 승인 **경쟁 시드** — 기존 `ChannelDiscoveryService` 결과를 `roleHint=RIVAL` 로 승인 등록 **수동 등록** — `POST /api/feed/seeds {url, role}` ### API | 메서드 | 경로 | 설명 | |---|---|---| | GET | `/api/feed` | `tab=SOURCE\|RIVAL`, `days`, `channelId`, `lengthBucket`, `hideWorked` | | GET | `/api/feed/programs` | 필터용 채널 목록(피드에 영상이 있는 채널) | | POST | `/api/feed/collect` | 수동 수집 1회 | | GET | `/api/feed/seeds` | 등록된 시드 채널 목록 | | POST | `/api/feed/seeds` | 수동 시드 등록 `{url, role}` | | DELETE | `/api/feed/seeds/{id}` | 시드 해제(role → MY 로 되돌리지 않고 채널 삭제) | | POST | `/api/feed/seeds/{id}/reset-failure` | 실패 카운터 초기화 | | POST | `/api/feed/seeds/suggest` | 해시태그 역분석 → 소스 후보 발굴 | ### UX 원칙 (ui-ux-pro-max 적용) 기존 Editorial(almanac) 디자인 시스템을 그대로 따른다. 스킬이 제안한 OLED/핑크 팔레트는 **일관성 규칙(§4 consistency)에 따라 채택하지 않는다.** 적용하는 것은 규칙 쪽이다. - 아이콘은 Lucide SVG만 사용(§4 no-emoji-icons) — 배지에 이모지 금지 - 터치 타겟 ≥44px, 카드 액션 간 8px 이상 간격(§2) - 썸네일에 `aspect-ratio` + `width/height` 지정으로 CLS 방지, `loading="lazy"`(§3) - 로딩은 스켈레톤(기존 `.skeleton` 재사용), 빈 상태·에러 상태 각각 안내 문구 + 복구 액션(§8) - 애니메이션 150–300ms, `prefers-reduced-motion` 존중(§7) - 탭은 `role="tablist"` + 키보드 좌우 이동, 현재 탭 `aria-selected`(§1, §9) - 상태를 색으로만 전달하지 않음 — 배지에 텍스트 라벨 병기(§1 color-not-only) - 필터 상태는 URL 쿼리에 반영해 새로고침·뒤로가기에서 보존(§9 state-preservation) ### 실패 처리 - 쿼터 소진 → 남은 채널 스킵, 요약에 `skippedByQuota` 보고 - 채널 uploads 플레이리스트 없음/삭제 → 로그 후 스킵, `feedFailCount++` - 개별 영상 파싱 실패 → 해당 영상만 건너뜀 ### 테스트 `src/test` 의 `DiscoveryRankerTest` 선례에 맞춰 **순수 로직만** 단위 테스트한다. - `HashtagExtractorTest` — 해시태그 파싱·빈도 집계·불용어 제외 - `FeedBadgesTest` — 골든타임/길이버킷/떡상 판정 경계값