h-lab/docs/superpowers/plans/2026-06-25-recommended-channel-discovery.md
hehihoho3@gmail.com ee4d556851 docs(plan): 추천 채널 발굴 구현 계획(6 태스크)
RecommendedChannel 엔티티 → DiscoveryRanker(TDD) → ChannelDiscoveryService(검색+upsert)
→ 스케줄러 → REST → /recommend 페이지. 기존 검색/쿼터 가드 재사용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:31:10 +09:00

30 KiB

추천 채널 발굴 (떡상 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<T>/@RestController 패턴.

Global Constraints

  • 빌드/실행: JAVA_HOME=D:/Development/app/JDK/jdk-21.0.5, ./gradlew.bat, 포트 8088.
  • API 응답은 global/common/ApiResponse<T>(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<RecommendedChannel, Long>: Optional<RecommendedChannel> findByChannelId(String channelId), List<RecommendedChannel> findByStatusOrderByRatioDesc(String status), boolean existsByChannelIdAndStatus(String channelId, String status).
  • Step 1: 엔티티 작성

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 작성
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<RecommendedChannel, Long> {
    Optional<RecommendedChannel> findByChannelId(String channelId);
    List<RecommendedChannel> 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: 커밋
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<Candidate> rank(List<YoutubeSearchResultDto> items, long maxSubscribers, double minRatio, java.util.Set<String> excludeChannelIds).
  • Step 1: 실패 테스트 작성

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<YoutubeSearchResultDto> 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<DiscoveryRanker.Candidate> 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<YoutubeSearchResultDto> 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<DiscoveryRanker.Candidate> 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 구현
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<Candidate> rank(List<YoutubeSearchResultDto> items, long maxSubscribers,
                                       double minRatio, Set<String> excludeChannelIds) {
        Map<String, Candidate> 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<Candidate> 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: 커밋
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<YoutubeSearchResultDto>. YoutubeSearchCondition 필드: setRegions(List<String>), 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<String,Object> runDiscovery() — 검색→랭킹→upsert 후 요약 반환.
  • Step 1: ChannelRepository 에 존재여부 메서드 보강(없으면)

src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java 에 다음이 없으면 추가:

    boolean existsByChannelId(String channelId);
  • Step 2: 설정 추가application.ymlhlab: 블록 아래에 병합
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: 서비스 구현
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<String, Object> runDiscovery() {
        List<String> regions = Arrays.stream(regionsCsv.split(","))
                .map(String::trim).filter(s -> !s.isBlank()).toList();

        List<YoutubeSearchResultDto> all = new ArrayList<>();
        List<String> 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<String> exclude = new HashSet<>();
        for (var rc : recommendedChannelRepository.findByStatusOrderByRatioDesc("EXCLUDED")) exclude.add(rc.getChannelId());

        List<DiscoveryRanker.Candidate> 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<String, Object> 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: 커밋
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):

    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: 커밋
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-channelsApiResponse<List<RecommendedChannel>>(status=NEW, 배율 desc).
    • POST /api/recommended-channels/{id}/registerApiResponse<Void>(Channel 생성 + status=REGISTERED).
    • POST /api/recommended-channels/{id}/excludeApiResponse<Void>(status=EXCLUDED).
    • POST /api/recommended-channels/runApiResponse<Map<String,Object>>(수동 발굴).
  • Step 1: 컨트롤러 작성

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<RecommendedChannel>> list() {
        return ApiResponse.ok(repository.findByStatusOrderByRatioDesc("NEW"));
    }

    @PostMapping("/{id}/register")
    @Operation(summary = "내 채널 등록", description = "추천 채널을 내 채널로 등록하고 REGISTERED 처리")
    @Transactional
    public ApiResponse<Void> 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<Void> 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<Map<String, Object>> 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: 커밋
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() 메서드 옆에 동일 패턴)

    @GetMapping("/recommend")
    public String recommend(org.springframework.ui.Model model) {
        model.addAttribute("currentPage", "recommend");
        return "recommend";
    }
  • Step 2: 사이드바 메뉴 추가sidebar.html 의 발굴(/discover) 항목 바로 아래에 삽입
            <a th:href="@{/recommend}" class="nav-item" th:classappend="${currentPage == 'recommend'} ? 'active'">
                <i data-lucide="sparkles" class="nav-icon"></i><span class="nav-text">추천 채널</span>
            </a>
  • Step 3: 페이지 작성 (기존 discover.html 의 layout/base 패턴 따름)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
    layout:decorate="~{layout/base}">
<head><title>h-lab - 추천 채널</title></head>
<body>
    <div layout:fragment="content">
        <div class="page-header">
            <div><h1>추천 채널</h1><p class="sub">지역(KR·JP·US) 인기 Shorts에서 자동 발굴한 떡상 채널</p></div>
            <div class="actions">
                <button class="btn btn-secondary" onclick="runDiscovery()"><i data-lucide="refresh-cw" style="width:15px;"></i> 지금 발굴</button>
            </div>
        </div>

        <div id="status" class="text-sm mb-3" style="display:none;"></div>
        <div id="grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:1rem;"></div>

        <style>
            .rc-card { background:var(--surface); border:1px solid var(--glass-border); border-radius:var(--radius-lg); overflow:hidden; }
            .rc-thumb { width:100%; aspect-ratio:16/9; object-fit:cover; background:#000; }
            .rc-body { padding:12px; }
            .rc-ratio { display:inline-block; background:#ef4444; color:#fff; font-weight:800; font-size:0.8rem; padding:2px 8px; border-radius:999px; }
            .rc-actions { display:flex; gap:8px; margin-top:10px; }
        </style>

        <script th:inline="javascript">
            /*<![CDATA[*/
            const API = '/api/recommended-channels';
            async function api(url, opts){ const r=await fetch(url,opts); const j=await r.json().catch(()=>({})); if(!r.ok||(j&&j.success===false)) throw new Error((j&&j.message)||('HTTP '+r.status)); return j.data; }
            function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
            function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); }

            async function load(){
                const grid=document.getElementById('grid');
                try {
                    const list=await api(API);
                    if(!list.length){ grid.innerHTML='<div class="text-muted">아직 발굴된 추천 채널이 없습니다. ‘지금 발굴’을 눌러보세요.</div>'; return; }
                    grid.innerHTML=list.map(c=>
                        '<div class="rc-card" id="rc-'+c.id+'">'
                        + '<a href="https://www.youtube.com/watch?v='+esc(c.topVideoId)+'" target="_blank"><img class="rc-thumb" src="'+esc(c.thumbnailUrl)+'" loading="lazy"></a>'
                        + '<div class="rc-body">'
                        +   '<div class="font-bold" style="line-height:1.4;">'+esc(c.channelTitle)+'</div>'
                        +   '<div class="text-sm text-muted">👤 '+fmt(c.subscriberCount)+'명 · '+esc(c.region||'')+'</div>'
                        +   '<div class="mt-2"><span class="rc-ratio">🔥 배율 '+(c.ratio?Number(c.ratio).toFixed(1):'-')+'x</span> <span class="text-sm text-muted">👁 '+fmt(c.topVideoViewCount)+'</span></div>'
                        +   '<div class="text-sm text-muted mt-1" style="line-height:1.4;">'+esc(c.topVideoTitle||'')+'</div>'
                        +   '<div class="rc-actions">'
                        +     '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>'
                        +     '<button class="btn btn-secondary px-3 py-2" onclick="exclude('+c.id+')"><i data-lucide="x" style="width:14px;"></i> 제외</button>'
                        +   '</div>'
                        + '</div></div>').join('');
                    if(window.lucide) lucide.createIcons();
                } catch(e){ grid.innerHTML='<div style="color:#f87171;">불러오기 실패: '+esc(e.message)+'</div>'; }
            }
            async function register(id){ try{ await api(API+'/'+id+'/register',{method:'POST'}); document.getElementById('rc-'+id)?.remove(); }catch(e){ alert('등록 실패: '+e.message); } }
            async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); document.getElementById('rc-'+id)?.remove(); }catch(e){ alert('제외 실패: '+e.message); } }
            async function runDiscovery(){
                const s=document.getElementById('status'); s.style.display='block'; s.style.color='#facc15'; s.textContent='발굴 중… (지역별 Shorts 검색, 잠시 걸립니다)';
                try{ const r=await api(API+'/run',{method:'POST'}); s.style.color='#4ade80'; s.textContent='발굴 완료 · 신규 '+(r.saved??0)+'개 · 지역 '+((r.regions||[]).join(','))+''; await load(); }
                catch(e){ s.style.color='#f87171'; s.textContent='발굴 실패: '+e.message; }
            }
            load();
            /*]]>*/
        </script>
    </div>
</body>
</html>
  • Step 4: 실행 검증

Run(앱 기동):

JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat bootRun --console=plain   # 백그라운드
  • 브라우저/curlhttp://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: 커밋

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 없음.