# 추천 채널 발굴 (떡상 Shorts) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 지역(KR,JP,US) 인기 Shorts 를 주기적으로 검색해 "작은 구독자·고배율" 떡상 채널을 발굴하고, `/recommend` 전용 페이지에서 등록/제외할 수 있게 한다. **Architecture:** 기존 `YoutubeSearchService`(지역별 Shorts 검색)와 `YoutubeQuotaGuard`(쿼터)를 재사용. 순수 랭킹 로직(`DiscoveryRanker`)으로 채널별 최고 배율 집계·필터 후 `RecommendedChannel` 에 upsert. 스케줄러가 일1회 실행, 전용 페이지 + REST 로 노출. **Tech Stack:** Spring Boot 3.4 / Java 21, JPA(PostgreSQL, ddl-auto:update), Lombok, Thymeleaf(SSR), 기존 `ApiResponse`/`@RestController` 패턴. ## Global Constraints - 빌드/실행: `JAVA_HOME=D:/Development/app/JDK/jdk-21.0.5`, `./gradlew.bat`, 포트 8088. - API 응답은 `global/common/ApiResponse`(`ApiResponse.ok(...)`)로 감싼다. - 엔티티는 Lombok + `@CreationTimestamp`/`@UpdateTimestamp`. 스키마는 ddl-auto:update(마이그레이션 없음). - 배율(ratio) 정의 = `viewCount / subscriberCount`(구독자 > 0 일 때만). - 단위테스트는 `src/test/java/com/hlab/yanalyst/...`, JUnit5 + AssertJ(spring-boot-starter-test). --- ### Task 1: `RecommendedChannel` 엔티티 + Repository **Files:** - Create: `src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java` - Create: `src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java` **Interfaces:** - Produces: - `RecommendedChannel` 엔티티(필드: id, channelId, channelTitle, thumbnailUrl, subscriberCount, status, topVideoId, topVideoTitle, topVideoViewCount, ratio, region, discoveredAt, updatedAt). - `RecommendedChannelRepository extends JpaRepository`: `Optional findByChannelId(String channelId)`, `List findByStatusOrderByRatioDesc(String status)`, `boolean existsByChannelIdAndStatus(String channelId, String status)`. - [ ] **Step 1: 엔티티 작성** ```java package com.hlab.yanalyst.domain.channel; import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; /** 자동 발굴된 추천 채널(떡상 Shorts 기반). status: NEW|REGISTERED|EXCLUDED. */ @Entity @Table(name = "recommended_channels") @Getter @Setter @NoArgsConstructor public class RecommendedChannel { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, unique = true) private String channelId; private String channelTitle; @Column(length = 2083) private String thumbnailUrl; // 대표 떡상 영상의 썸네일 private Long subscriberCount; @Column(nullable = false) private String status = "NEW"; // 대표(최고 배율) 영상 private String topVideoId; @Column(length = 500) private String topVideoTitle; private Long topVideoViewCount; private Double ratio; // topVideo viewCount / subscriberCount private String region; // 발견 지역(KR/JP/US) @CreationTimestamp @Column(updatable = false) private LocalDateTime discoveredAt; @UpdateTimestamp private LocalDateTime updatedAt; } ``` - [ ] **Step 2: Repository 작성** ```java package com.hlab.yanalyst.domain.channel; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import java.util.Optional; public interface RecommendedChannelRepository extends JpaRepository { Optional findByChannelId(String channelId); List findByStatusOrderByRatioDesc(String status); boolean existsByChannelIdAndStatus(String channelId, String status); } ``` - [ ] **Step 3: 컴파일 확인** Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat compileJava --console=plain` Expected: `BUILD SUCCESSFUL` - [ ] **Step 4: 커밋** ```bash git add src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java \ src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java git commit -m "feat(discover): RecommendedChannel 엔티티+Repository 추가" ``` --- ### Task 2: `DiscoveryRanker` 순수 랭킹 로직 (TDD) **Files:** - Create: `src/main/java/com/hlab/yanalyst/domain/channel/DiscoveryRanker.java` - Test: `src/test/java/com/hlab/yanalyst/domain/channel/DiscoveryRankerTest.java` **Interfaces:** - Consumes: `com.hlab.yanalyst.web.dto.YoutubeSearchResultDto` (getter: `getChannelId()`, `getChannelTitle()`, `getThumbnailUrl()`, `getSubscriberCount():Long`, `getViewCount():Long`, `getVideoId()`, `getTitle()`, `getChannelCountry()`). - Produces: - `DiscoveryRanker.Candidate` record(channelId, channelTitle, thumbnailUrl, subscriberCount, topVideoId, topVideoTitle, topVideoViewCount, ratio(double), region). - `static List rank(List items, long maxSubscribers, double minRatio, java.util.Set excludeChannelIds)`. - [ ] **Step 1: 실패 테스트 작성** ```java package com.hlab.yanalyst.domain.channel; import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto; import org.junit.jupiter.api.Test; import java.util.List; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; class DiscoveryRankerTest { private YoutubeSearchResultDto item(String ch, long subs, String vid, long views) { YoutubeSearchResultDto d = new YoutubeSearchResultDto(); d.setChannelId(ch); d.setChannelTitle(ch + "_title"); d.setThumbnailUrl("thumb_" + vid); d.setSubscriberCount(subs); d.setVideoId(vid); d.setTitle("v_" + vid); d.setViewCount(views); d.setChannelCountry("KR"); return d; } @Test void rank_채널별_최고배율_선정_필터_정렬() { List items = List.of( item("A", 1000, "a1", 50000), // ratio 50 (작은채널·고배율) item("A", 1000, "a2", 10000), // ratio 10 (A의 비대표) item("B", 5_000_000, "b1", 60_000_000), // subs 과다 → 제외(maxSubs) item("C", 2000, "c1", 4000), // ratio 2 → minRatio 미달 제외 item("D", 1000, "d1", 30000) // ratio 30 ); List out = DiscoveryRanker.rank(items, 100_000, 5.0, Set.of()); // A(50), D(30) 만 남고 배율 내림차순. A는 최고배율 영상 a1 선택. assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("A", "D"); assertThat(out.get(0).topVideoId()).isEqualTo("a1"); assertThat(out.get(0).ratio()).isEqualTo(50.0); } @Test void rank_제외채널_및_구독자0_제거() { List items = List.of( item("A", 1000, "a1", 50000), // ratio 50 이지만 제외목록 item("E", 0, "e1", 9999), // subs 0 → 배율 계산 불가 제외 item("F", 1000, "f1", 20000) // ratio 20 ); List out = DiscoveryRanker.rank(items, 100_000, 5.0, Set.of("A")); assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("F"); } } ``` - [ ] **Step 2: 테스트 실패 확인** Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.DiscoveryRankerTest" --console=plain` Expected: FAIL — `DiscoveryRanker` 클래스 없음(cannot find symbol). - [ ] **Step 3: `DiscoveryRanker` 구현** ```java package com.hlab.yanalyst.domain.channel; import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; /** 검색된 Shorts 목록 → 채널별 최고 배율 후보 집계·필터·정렬(순수 로직). */ public final class DiscoveryRanker { private DiscoveryRanker() {} public record Candidate(String channelId, String channelTitle, String thumbnailUrl, Long subscriberCount, String topVideoId, String topVideoTitle, Long topVideoViewCount, double ratio, String region) {} public static List rank(List items, long maxSubscribers, double minRatio, Set excludeChannelIds) { Map best = new LinkedHashMap<>(); for (YoutubeSearchResultDto it : items) { String ch = it.getChannelId(); Long subs = it.getSubscriberCount(); Long views = it.getViewCount(); if (ch == null || subs == null || subs <= 0 || views == null) continue; if (excludeChannelIds.contains(ch)) continue; if (subs > maxSubscribers) continue; double ratio = (double) views / subs; if (ratio < minRatio) continue; Candidate prev = best.get(ch); if (prev == null || ratio > prev.ratio()) { best.put(ch, new Candidate(ch, it.getChannelTitle(), it.getThumbnailUrl(), subs, it.getVideoId(), it.getTitle(), views, ratio, it.getChannelCountry())); } } List out = new ArrayList<>(best.values()); out.sort(Comparator.comparingDouble(Candidate::ratio).reversed()); return out; } } ``` - [ ] **Step 4: 테스트 통과 확인** Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.DiscoveryRankerTest" --console=plain` Expected: PASS - [ ] **Step 5: 커밋** ```bash git add src/main/java/com/hlab/yanalyst/domain/channel/DiscoveryRanker.java \ src/test/java/com/hlab/yanalyst/domain/channel/DiscoveryRankerTest.java git commit -m "feat(discover): 채널 발굴 랭킹 로직 DiscoveryRanker + 단위테스트" ``` --- ### Task 3: `ChannelDiscoveryService` (검색 재사용 + upsert) + 설정 **Files:** - Create: `src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java` - Modify: `src/main/resources/application.yml` (discovery 설정 추가) **Interfaces:** - Consumes: - `YoutubeSearchService.searchYoutubeVideos(YoutubeSearchCondition)` → `YoutubeSearchPageDto.getItems():List`. `YoutubeSearchCondition` 필드: `setRegions(List)`, `setFormat("SHORTS")`, `setKeyword(String)`, `setPeriodDays(Integer)`. - `YoutubeQuotaGuard.remaining():long`, `tryConsume(long):boolean`. - `ChannelRepository`(기존) — 등록된 channelId 제외용. 메서드 `existsByChannelId(String)` 가 없으면 이 태스크에서 추가. - `RecommendedChannelRepository`(Task 1), `DiscoveryRanker.rank(...)`(Task 2). - Produces: - `Map runDiscovery()` — 검색→랭킹→upsert 후 요약 반환. - [ ] **Step 1: `ChannelRepository` 에 존재여부 메서드 보강(없으면)** `src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java` 에 다음이 없으면 추가: ```java boolean existsByChannelId(String channelId); ``` - [ ] **Step 2: 설정 추가** — `application.yml` 의 `hlab:` 블록 아래에 병합 ```yaml hlab: scheduler: channel-discovery: enabled: ${CHANNEL_DISCOVERY_ENABLED:true} cron: ${CHANNEL_DISCOVERY_CRON:0 30 4 * * *} # 매일 04:30 discovery: regions: ${DISCOVERY_REGIONS:KR,JP,US} max-subscribers: ${DISCOVERY_MAX_SUBS:100000} min-ratio: ${DISCOVERY_MIN_RATIO:5.0} period-days: ${DISCOVERY_PERIOD_DAYS:14} # 최근 N일 영상 대상 top-n: ${DISCOVERY_TOP_N:30} ``` > 주의: 기존 `hlab.scheduler.*`, `hlab.youtube.*` 키와 같은 `hlab:` 트리에 병합할 것(중복 `hlab:` 매핑 금지). - [ ] **Step 3: 서비스 구현** ```java package com.hlab.yanalyst.domain.channel; import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard; import com.hlab.yanalyst.service.YoutubeSearchService; import com.hlab.yanalyst.web.dto.YoutubeSearchCondition; import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; /** 지역 인기 Shorts 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */ @Slf4j @Service @RequiredArgsConstructor public class ChannelDiscoveryService { /** search.list 1회 추정 쿼터(units). */ private static final long SEARCH_QUOTA = 100; private final YoutubeSearchService youtubeSearchService; private final YoutubeQuotaGuard quotaGuard; private final ChannelRepository channelRepository; private final RecommendedChannelRepository recommendedChannelRepository; @Value("${hlab.discovery.regions:KR,JP,US}") private String regionsCsv; @Value("${hlab.discovery.max-subscribers:100000}") private long maxSubscribers; @Value("${hlab.discovery.min-ratio:5.0}") private double minRatio; @Value("${hlab.discovery.period-days:14}") private int periodDays; @Transactional public Map runDiscovery() { List regions = Arrays.stream(regionsCsv.split(",")) .map(String::trim).filter(s -> !s.isBlank()).toList(); List all = new ArrayList<>(); List searchedRegions = new ArrayList<>(); for (String region : regions) { if (!quotaGuard.tryConsume(SEARCH_QUOTA)) { log.warn("[Discovery] 쿼터 예산 소진 — 지역 {} 이후 건너뜀 (잔여 {})", region, quotaGuard.remaining()); break; } try { YoutubeSearchCondition cond = new YoutubeSearchCondition(); cond.setRegions(List.of(region)); cond.setFormat("SHORTS"); cond.setPeriodDays(periodDays); // 광범위 검색: 키워드 없이 인기 Shorts. (구현 시 q 없이도 동작하는지 확인, // 필요하면 region 인기영상으로 보강 — 스펙 §4.2) var page = youtubeSearchService.searchYoutubeVideos(cond); if (page != null && page.getItems() != null) all.addAll(page.getItems()); searchedRegions.add(region); } catch (Exception e) { log.error("[Discovery] 지역 {} 검색 실패 — 건너뜀", region, e); } } // 이미 등록/제외한 채널 제외 Set exclude = new HashSet<>(); for (var rc : recommendedChannelRepository.findByStatusOrderByRatioDesc("EXCLUDED")) exclude.add(rc.getChannelId()); List ranked = DiscoveryRanker.rank(all, maxSubscribers, minRatio, exclude); int saved = 0; for (DiscoveryRanker.Candidate c : ranked) { if (channelRepository.existsByChannelId(c.channelId())) continue; // 이미 내 채널 RecommendedChannel rc = recommendedChannelRepository.findByChannelId(c.channelId()) .orElseGet(RecommendedChannel::new); if ("EXCLUDED".equals(rc.getStatus()) || "REGISTERED".equals(rc.getStatus())) continue; // 더 좋은(높은 배율) 메트릭일 때만 갱신 if (rc.getId() != null && rc.getRatio() != null && c.ratio() <= rc.getRatio()) continue; rc.setChannelId(c.channelId()); rc.setChannelTitle(c.channelTitle()); rc.setThumbnailUrl(c.thumbnailUrl()); rc.setSubscriberCount(c.subscriberCount()); rc.setStatus("NEW"); rc.setTopVideoId(c.topVideoId()); rc.setTopVideoTitle(c.topVideoTitle()); rc.setTopVideoViewCount(c.topVideoViewCount()); rc.setRatio(c.ratio()); rc.setRegion(c.region()); recommendedChannelRepository.save(rc); saved++; } Map summary = new LinkedHashMap<>(); summary.put("regions", searchedRegions); summary.put("candidates", ranked.size()); summary.put("saved", saved); summary.put("quotaRemaining", quotaGuard.remaining()); log.info("[Discovery] 완료: {}", summary); return summary; } } ``` - [ ] **Step 4: 컴파일 확인 + 기존 단위테스트 통과** Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.DiscoveryRankerTest" --console=plain` Expected: `BUILD SUCCESSFUL`(컴파일 + 테스트). 컴파일 에러 시 `YoutubeSearchCondition` setter/`YoutubeSearchPageDto.getItems()` 시그니처 재확인. - [ ] **Step 5: 커밋** ```bash git add src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java \ src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java \ src/main/resources/application.yml git commit -m "feat(discover): ChannelDiscoveryService(검색→떡상 채널 upsert) + 설정" ``` --- ### Task 4: 스케줄러 연결 **Files:** - Modify: `src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java` **Interfaces:** - Consumes: `ChannelDiscoveryService.runDiscovery()`(Task 3). - [ ] **Step 1: 의존성 주입 + 스케줄 메서드 추가** `ScheduledCollectionService` 의 final 필드에 추가(생성자는 @RequiredArgsConstructor): ```java private final com.hlab.yanalyst.domain.channel.ChannelDiscoveryService channelDiscoveryService; @org.springframework.beans.factory.annotation.Value("${hlab.scheduler.channel-discovery.enabled:true}") private boolean discoveryEnabled; @org.springframework.scheduling.annotation.Scheduled(cron = "${hlab.scheduler.channel-discovery.cron:0 30 4 * * *}") public void scheduledDiscovery() { if (!discoveryEnabled) { log.info("[Scheduler] 추천 채널 발굴 비활성화됨"); return; } var result = channelDiscoveryService.runDiscovery(); log.info("[Scheduler] 추천 채널 발굴 완료: {}", result); } ``` - [ ] **Step 2: 컴파일 확인** Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat compileJava --console=plain` Expected: `BUILD SUCCESSFUL` - [ ] **Step 3: 커밋** ```bash git add src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java git commit -m "feat(discover): 추천 채널 발굴 일별 스케줄 연결" ``` --- ### Task 5: `RecommendedChannelController` (목록/등록/제외/수동실행) **Files:** - Create: `src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java` **Interfaces:** - Consumes: `RecommendedChannelRepository`(Task1), `ChannelDiscoveryService.runDiscovery()`(Task3), `ChannelService.saveChannelFromUrl(String)`(기존) — URL `https://www.youtube.com/channel/{channelId}`. - Produces(REST): - `GET /api/recommended-channels` → `ApiResponse>`(status=NEW, 배율 desc). - `POST /api/recommended-channels/{id}/register` → `ApiResponse`(Channel 생성 + status=REGISTERED). - `POST /api/recommended-channels/{id}/exclude` → `ApiResponse`(status=EXCLUDED). - `POST /api/recommended-channels/run` → `ApiResponse>`(수동 발굴). - [ ] **Step 1: 컨트롤러 작성** ```java package com.hlab.yanalyst.domain.channel; import com.hlab.yanalyst.global.common.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/recommended-channels") @RequiredArgsConstructor @Tag(name = "Recommended Channel API", description = "자동 발굴된 추천 채널(떡상 Shorts)") public class RecommendedChannelController { private final RecommendedChannelRepository repository; private final ChannelDiscoveryService discoveryService; private final ChannelService channelService; @GetMapping @Operation(summary = "추천 채널 목록", description = "status=NEW 를 배율 내림차순으로 반환") public ApiResponse> list() { return ApiResponse.ok(repository.findByStatusOrderByRatioDesc("NEW")); } @PostMapping("/{id}/register") @Operation(summary = "내 채널 등록", description = "추천 채널을 내 채널로 등록하고 REGISTERED 처리") @Transactional public ApiResponse register(@PathVariable Long id) { RecommendedChannel rc = repository.findById(id) .orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id)); channelService.saveChannelFromUrl("https://www.youtube.com/channel/" + rc.getChannelId()); rc.setStatus("REGISTERED"); repository.save(rc); return ApiResponse.ok(null); } @PostMapping("/{id}/exclude") @Operation(summary = "추천 제외", description = "다음 발굴에서 다시 뜨지 않게 EXCLUDED 처리") @Transactional public ApiResponse exclude(@PathVariable Long id) { RecommendedChannel rc = repository.findById(id) .orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id)); rc.setStatus("EXCLUDED"); repository.save(rc); return ApiResponse.ok(null); } @PostMapping("/run") @Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행") public ApiResponse> run() { return ApiResponse.ok(discoveryService.runDiscovery()); } } ``` - [ ] **Step 2: 컴파일 확인** Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat compileJava --console=plain` Expected: `BUILD SUCCESSFUL` - [ ] **Step 3: 커밋** ```bash git add src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java git commit -m "feat(discover): 추천 채널 REST(목록/등록/제외/수동실행)" ``` --- ### Task 6: `/recommend` 전용 페이지 + 라우트 + 사이드바 메뉴 **Files:** - Create: `src/main/resources/templates/recommend.html` - Modify: `src/main/java/com/hlab/yanalyst/web/WebController.java` (라우트 추가) - Modify: `src/main/resources/templates/layout/sidebar.html` (메뉴 추가) **Interfaces:** - Consumes(JS fetch): `GET /api/recommended-channels`, `POST /{id}/register`, `POST /{id}/exclude`, `POST /run`. - [ ] **Step 1: WebController 라우트 추가** (`discover()` 메서드 옆에 동일 패턴) ```java @GetMapping("/recommend") public String recommend(org.springframework.ui.Model model) { model.addAttribute("currentPage", "recommend"); return "recommend"; } ``` - [ ] **Step 2: 사이드바 메뉴 추가** — `sidebar.html` 의 발굴(`/discover`) 항목 바로 아래에 삽입 ```html 추천 채널 ``` - [ ] **Step 3: 페이지 작성** (기존 `discover.html` 의 layout/base 패턴 따름) ```html h-lab - 추천 채널
``` - [ ] **Step 4: 실행 검증** Run(앱 기동): ```bash JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat bootRun --console=plain # 백그라운드 ``` - 브라우저/`curl`로 `http://localhost:8088/recommend` 200, 사이드바에 "추천 채널" 노출 확인. - `curl -X POST http://localhost:8088/api/recommended-channels/run` → `{success:true, data:{regions,candidates,saved,...}}` 확인(쿼터/검색 동작). 결과가 빈약하면 스펙 §4.2(검색 API 보강) 적용. - [ ] **Step 5: 커밋** ```bash git add src/main/resources/templates/recommend.html \ src/main/java/com/hlab/yanalyst/web/WebController.java \ src/main/resources/templates/layout/sidebar.html git commit -m "feat(discover): 추천 채널 전용 페이지 /recommend + 사이드바 메뉴" ``` --- ## Self-Review - **Spec coverage:** §3 흐름→Task3/4, §4.1 엔티티→Task1, 랭킹/필터/dedup→Task2/3, §4.4 REST→Task5, §4.5 페이지/메뉴→Task6, §5 설정→Task3, §6 쿼터→Task3(quotaGuard), §8 테스트→Task2. 누락 없음. - **검색 API 리스크(§4.2):** Task3/Task6 Step4 에 "결과 빈약 시 보강" 명시. 키워드 없는 search.list 가 빈 결과면 `YoutubeSearchService` 에 region 인기영상 경로 추가가 후속 필요(플랜 외 보강 포인트로 표시). - **Type 일관성:** `DiscoveryRanker.Candidate` accessor(record) 명칭이 Task2 정의와 Task3 사용처 일치(channelId(), ratio() 등). `RecommendedChannel` setter 명칭이 Task1 필드와 일치. - **Placeholder:** 모든 코드 step 실제 코드 포함. TBD 없음.