h-lab/docs/superpowers/plans/2026-05-30-discovery-page.md

27 KiB

발굴(Discovery) 전용 페이지 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: 수집한 영상 중 "재가공할 떡상 후보"를 필터·정렬로 빠르게 골라내는 발굴 전용 페이지(/discover)를 추가한다.

Architecture: 기존 ChannelVideo + 큐레이션 API 위에 발굴 조회 쿼리 1개·서비스 메서드 1개·엔드포인트 1개·페이지 1개·사이드바 링크 1개를 더한다. 행별 액션(상태변경·북마크)은 기존 엔드포인트를 재사용한다. 새 외부 호출·새 데이터 없음(비용 0).

Tech Stack: Spring Boot 3.4 / Java 21 / Spring Data JPA(JPQL) / Thymeleaf(layout-dialect) / 바닐라 JS(fetch).

검증 방식 주의: 이 프로젝트는 테스트 프레임워크가 없고(src/test 없음) 원격 PostgreSQL에 연결된다. 따라서 각 태스크의 검증은 단위테스트 대신 컴파일 + 기동 + 수동 확인으로 한다. JAVA_HOME 은 D:\Development\app\JDK\jdk-21.0.5.


File Structure

  • 수정 src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.javadiscover(...) JPQL 쿼리 추가.
  • 수정 src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.javadiscover(...) 메서드 추가(파라미터 정규화·기본값·limit 캡·nullsLast 정렬).
  • 수정 src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.javaGET /discover 엔드포인트 추가.
  • 수정 src/main/java/com/hlab/yanalyst/web/WebController.javaGET /discover 페이지 라우트 추가.
  • 생성 src/main/resources/templates/discover.html — 발굴 페이지(필터 바 + 결과 테이블 + 행 액션 + 영상 모달).
  • 수정 src/main/resources/templates/layout/sidebar.html — "발굴" 네비 링크 추가.

Task 1: 발굴 조회 쿼리 (Repository)

Files:

  • Modify: src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java

  • Step 1: discover 쿼리 메서드 추가

search(...) 메서드 정의 바로 뒤(파일 내 마지막 메서드 다음, 닫는 } 앞)에 아래를 추가한다:

    /**
     * 발굴(Discovery) 조회. EXCLUDED 는 항상 제외. null/false 조건은 무시.
     * 정렬·개수 제한은 호출부의 Pageable(sort + size)로 처리한다.
     * @param publishedAfter  이 시각 이후 업로드분만 (null이면 전체)
     * @param minRatio        viewsPerSubRatio 하한 (null이면 미적용; null 배율 행은 자동 제외)
     * @param shortsOnly      true면 Shorts만
     * @param source          CHANNEL | SEARCH (null이면 전체)
     * @param unprocessedOnly true면 상태가 NEW/REVIEWING 인 것만
     */
    @Query("select v from ChannelVideo v where "
            + "v.interestStatus <> 'EXCLUDED' and "
            + "(:publishedAfter is null or v.publishedAt >= :publishedAfter) and "
            + "(:minRatio is null or v.viewsPerSubRatio >= :minRatio) and "
            + "(:source is null or v.source = :source) and "
            + "(:shortsOnly = false or v.isShorts = true) and "
            + "(:unprocessedOnly = false or v.interestStatus in ('NEW','REVIEWING'))")
    java.util.List<ChannelVideo> discover(@Param("publishedAfter") java.time.LocalDateTime publishedAfter,
                                          @Param("minRatio") java.math.BigDecimal minRatio,
                                          @Param("source") String source,
                                          @Param("shortsOnly") boolean shortsOnly,
                                          @Param("unprocessedOnly") boolean unprocessedOnly,
                                          org.springframework.data.domain.Pageable pageable);
  • Step 2: 컴파일로 검증

Run: $env:JAVA_HOME="D:\Development\app\JDK\jdk-21.0.5"; .\gradlew.bat compileJava --console=plain Expected: BUILD SUCCESSFUL (deprecated API 경고는 무시).

  • Step 3: 커밋
git add src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java
git commit -m "feat(discover): add ChannelVideo discovery query"

Task 2: 발굴 서비스 메서드 (Service)

Files:

  • Modify: src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java

  • Step 1: discover 메서드 추가

findOutperformers(...) 메서드 정의 바로 뒤에 아래를 추가한다. (이미 import 된 Sort, StringUtils, PageRequest(org.springframework.data.domain.PageRequest 는 풀패키지로 사용), BigDecimal 사용)

    /**
     * 발굴(Discovery) 조회 — 필터 + 정렬 + 개수 제한.
     * @param periodDays      최근 N일만 (null/0이면 전체)
     * @param minRatio        viewsPerSubRatio 하한 (null이면 미적용)
     * @param shortsOnly      Shorts만
     * @param source          CHANNEL | SEARCH (빈값이면 전체)
     * @param unprocessedOnly true면 NEW/REVIEWING 만
     * @param sortBy          정렬 필드(ALLOWED_SORT, 기본 viewsPerSubRatio 내림차순, null은 항상 마지막)
     * @param limit           최대 개수(기본 100, 최대 200)
     */
    public List<ChannelVideo> discover(Integer periodDays, java.math.BigDecimal minRatio,
                                       boolean shortsOnly, String source, boolean unprocessedOnly,
                                       String sortBy, Integer limit) {
        java.time.LocalDateTime publishedAfter =
                (periodDays == null || periodDays <= 0) ? null : java.time.LocalDateTime.now().minusDays(periodDays);
        String sourceFilter = StringUtils.hasText(source) ? source : null;
        String sortField = StringUtils.hasText(sortBy) && ALLOWED_SORT.contains(sortBy) ? sortBy : "viewsPerSubRatio";
        int size = (limit == null || limit <= 0) ? 100 : Math.min(limit, 200);
        Sort sort = Sort.by(Sort.Order.desc(sortField).nullsLast());
        return channelVideoRepository.discover(publishedAfter, minRatio, sourceFilter, shortsOnly, unprocessedOnly,
                org.springframework.data.domain.PageRequest.of(0, size, sort));
    }
  • Step 2: 컴파일로 검증

Run: $env: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/ChannelVideoCurationService.java
git commit -m "feat(discover): add discover() service method with defaults and limit cap"

Task 3: 발굴 엔드포인트 (Controller)

Files:

  • Modify: src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java

  • Step 1: GET /discover 추가

/outperformers 핸들러(outperformers(...)) 바로 뒤에 아래를 추가한다:

    @GetMapping("/discover")
    @Operation(summary = "발굴(Discovery) 조회",
            description = "필터: periodDays(최근 N일), minRatio(배율 하한), shortsOnly, source(CHANNEL|SEARCH), "
                    + "unprocessedOnly(NEW/REVIEWING만). 정렬 sortBy(기본 viewsPerSubRatio↓), limit(기본100). "
                    + "EXCLUDED 는 항상 제외.")
    public ApiResponse<List<ChannelVideo>> discover(
            @RequestParam(required = false) Integer periodDays,
            @RequestParam(required = false) java.math.BigDecimal minRatio,
            @RequestParam(defaultValue = "false") boolean shortsOnly,
            @RequestParam(required = false) String source,
            @RequestParam(defaultValue = "false") boolean unprocessedOnly,
            @RequestParam(required = false) String sortBy,
            @RequestParam(required = false) Integer limit) {
        return ApiResponse.ok(curationService.discover(periodDays, minRatio, shortsOnly, source, unprocessedOnly, sortBy, limit));
    }
  • Step 2: 컴파일로 검증

Run: $env: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/ChannelVideoCurationController.java
git commit -m "feat(discover): add GET /api/v1/channel-videos/discover endpoint"

Task 4: 페이지 라우트 (WebController)

Files:

  • Modify: src/main/java/com/hlab/yanalyst/web/WebController.java

  • Step 1: /discover 라우트 추가

board(...) 핸들러 바로 뒤에 아래를 추가한다:

    @GetMapping("/discover")
    public String discover(Model model) {
        model.addAttribute("currentPage", "discover");
        return "discover";
    }
  • Step 2: 컴파일로 검증

Run: $env: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/web/WebController.java
git commit -m "feat(discover): add /discover page route"

Task 5: 발굴 페이지 템플릿 (discover.html)

Files:

  • Create: src/main/resources/templates/discover.html

  • Step 1: 템플릿 작성

아래 내용 그대로 새 파일로 생성한다:

<!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">
        <header class="mb-4">
            <h1 class="text-2xl font-bold mb-2">발굴 (Discovery)</h1>
            <p class="text-muted">수집한 영상 중 재가공할 떡상 후보를 빠르게 골라냅니다. (제외 처리한 영상은 숨겨집니다)</p>
        </header>

        <!-- 필터 바 -->
        <div class="card mb-4">
            <div style="display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end;">
                <div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
                    <label class="text-sm text-muted">기간</label>
                    <select id="fPeriod" onchange="loadVideos()" class="filter-sel">
                        <option value="">전체</option>
                        <option value="7">최근 7일</option>
                        <option value="14">최근 14일</option>
                        <option value="30">최근 30일</option>
                    </select>
                </div>
                <div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
                    <label class="text-sm text-muted">최소 배율</label>
                    <select id="fMinRatio" onchange="loadVideos()" class="filter-sel">
                        <option value="">전체</option>
                        <option value="2">≥ 2x</option>
                        <option value="3">≥ 3x</option>
                        <option value="5">≥ 5x</option>
                    </select>
                </div>
                <div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
                    <label class="text-sm text-muted">출처</label>
                    <select id="fSource" onchange="loadVideos()" class="filter-sel">
                        <option value="">전체</option>
                        <option value="CHANNEL">채널 수집</option>
                        <option value="SEARCH">검색 수집</option>
                    </select>
                </div>
                <div style="display:flex; flex-direction:column; gap:4px; min-width:160px;">
                    <label class="text-sm text-muted">정렬</label>
                    <select id="fSort" onchange="loadVideos()" class="filter-sel">
                        <option value="viewsPerSubRatio">배율(조회/구독) ↓</option>
                        <option value="viewsPerHour">시간당 조회수 ↓</option>
                        <option value="viewCount">조회수 ↓</option>
                        <option value="publishedAt">최신순 ↓</option>
                    </select>
                </div>
                <label class="flex items-center gap-2 cursor-pointer" style="height:42px;">
                    <input type="checkbox" id="fShorts" onchange="loadVideos()"> <span class="text-sm">Shorts만</span>
                </label>
                <label class="flex items-center gap-2 cursor-pointer" style="height:42px;">
                    <input type="checkbox" id="fUnprocessed" onchange="loadVideos()"> <span class="text-sm">미처리만(NEW/검토중)</span>
                </label>
                <button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="loadVideos()">
                    <i data-lucide="refresh-cw" style="width:16px;"></i> 새로고침
                </button>
                <span id="resultCount" class="text-sm text-muted" style="margin-left:auto; height:42px; display:flex; align-items:center;"></span>
            </div>
        </div>

        <!-- 결과 테이블 -->
        <div class="card p-0" style="overflow-x:auto;">
            <table class="w-full" style="border-collapse:collapse; text-align:left;">
                <thead style="background:rgba(255,255,255,0.02); border-bottom:1px solid var(--glass-border);">
                    <tr>
                        <th class="p-3 text-sm font-bold text-muted">썸네일</th>
                        <th class="p-3 text-sm font-bold text-muted">제목</th>
                        <th class="p-3 text-sm font-bold text-muted">채널</th>
                        <th class="p-3 text-sm font-bold text-muted">구독자</th>
                        <th class="p-3 text-sm font-bold text-muted">조회수</th>
                        <th class="p-3 text-sm font-bold text-muted">시간당</th>
                        <th class="p-3 text-sm font-bold text-muted">배율</th>
                        <th class="p-3 text-sm font-bold text-muted">업로드</th>
                        <th class="p-3 text-sm font-bold text-muted">상태</th>
                        <th class="p-3 text-sm font-bold text-muted">관리</th>
                    </tr>
                </thead>
                <tbody id="resultBody">
                    <tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>
                </tbody>
            </table>
        </div>

        <!-- 영상 모달 -->
        <div id="videoModal" style="display:none; position:fixed; z-index:1000; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.85); align-items:center; justify-content:center;" onclick="if(event.target===this) closeVideoModal()">
            <div style="position:relative; width:95%; max-width:900px; background:var(--glass-bg,#1e1e2d); border-radius:12px; padding:16px; border:1px solid var(--glass-border);">
                <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
                    <h3 class="text-lg font-bold truncate pr-4" id="modalTitle">Video</h3>
                    <button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">&times;</button>
                </div>
                <div id="iframeContainer" style="width:100%; aspect-ratio:9/16; max-height:70vh; border-radius:8px; overflow:hidden; background:#000; margin:0 auto;"></div>
            </div>
        </div>

        <style>
            .filter-sel { padding:8px; height:42px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:white; outline:none; }
            .filter-sel option { background:#1e1e2d; color:white; }
            .row-sel { padding:4px 6px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:6px; color:white; font-size:0.8rem; outline:none; }
            .row-sel option { background:#1e1e2d; color:white; }
        </style>

        <script th:inline="javascript">
            /*<![CDATA[*/
            const API = '/api/v1/channel-videos';

            const STATUS_LABEL = { NEW:'NEW', REVIEWING:'검토중', TARGET:'작업대상', DONE:'완료', EXCLUDED:'제외' };
            const STATUS_KEYS = ['NEW','REVIEWING','TARGET','DONE','EXCLUDED'];

            function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
            function fmt(n){ return (n==null) ? '-' : Number(n).toLocaleString(); }
            function fmtDate(s){ return s ? String(s).substring(0,10) : '-'; }

            async function api(url, opts){
                const res = await fetch(url, opts);
                const json = await res.json().catch(()=>({}));
                if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status));
                return json.data;
            }

            async function loadVideos(){
                const body = document.getElementById('resultBody');
                body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>';
                const p = new URLSearchParams();
                const period = document.getElementById('fPeriod').value;
                const minRatio = document.getElementById('fMinRatio').value;
                const src = document.getElementById('fSource').value;
                if(period) p.set('periodDays', period);
                if(minRatio) p.set('minRatio', minRatio);
                if(src) p.set('source', src);
                if(document.getElementById('fShorts').checked) p.set('shortsOnly','true');
                if(document.getElementById('fUnprocessed').checked) p.set('unprocessedOnly','true');
                p.set('sortBy', document.getElementById('fSort').value);
                p.set('limit', '100');
                let data;
                try { data = await api(API + '/discover?' + p.toString()); }
                catch(e){
                    body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-danger">불러오기 실패: '+esc(e.message)+'</td></tr>';
                    return;
                }
                renderRows(data || []);
            }

            function ratioBadge(r){
                if(r==null) return '<span class="text-muted">-</span>';
                const v = Number(r);
                let color = '#10b981';
                if(v>=10) color = '#ef4444'; else if(v>=2) color = '#f59e0b';
                return `<span style="color:${color}; font-weight:bold;">${v.toFixed(1)}x</span>`;
            }

            function renderRows(list){
                const body = document.getElementById('resultBody');
                document.getElementById('resultCount').textContent = list.length + '건';
                if(list.length===0){
                    body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">조건에 맞는 발굴 후보가 없습니다.</td></tr>';
                    return;
                }
                body.innerHTML = list.map(v=>{
                    const statusOpts = STATUS_KEYS.map(k=>
                        `<option value="${k}" ${v.interestStatus===k?'selected':''}>${STATUS_LABEL[k]}</option>`).join('');
                    const star = v.bookmarked ? '#f59e0b' : 'var(--text-muted)';
                    const shortsBadge = v.isShorts ? '<span style="font-size:0.65rem; background:#7C3AED33; color:#a78bfa; padding:1px 5px; border-radius:4px; margin-left:4px;">Shorts</span>' : '';
                    return `<tr style="border-bottom:1px solid var(--glass-border);">
                        <td class="p-3">
                            <div style="position:relative; cursor:pointer; width:96px;" data-vid="${v.videoId}" data-title="${esc(v.title)}" onclick="openVideoModal(this.dataset.vid, this.dataset.title)">
                                <img src="${esc(v.thumbnailUrl)}" style="width:96px; height:54px; object-fit:cover; border-radius:6px;">
                            </div>
                        </td>
                        <td class="p-3" style="max-width:280px;">
                            <div class="font-bold text-sm" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">
                                <a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank" class="hover:underline text-white">${esc(v.title)}</a>${shortsBadge}
                            </div>
                        </td>
                        <td class="p-3 text-sm text-muted" style="max-width:130px;">
                            <a href="${v.ytChannelId?('https://www.youtube.com/channel/'+v.ytChannelId):'#'}" target="_blank" class="hover:underline truncate" style="color:#e2e8f0;">${esc(v.channelTitle||'-')}</a>
                        </td>
                        <td class="p-3 text-sm">${fmt(v.subscriberCount)}</td>
                        <td class="p-3 text-sm">${fmt(v.viewCount)}</td>
                        <td class="p-3 text-sm">${fmt(v.viewsPerHour)}</td>
                        <td class="p-3 text-sm">${ratioBadge(v.viewsPerSubRatio)}</td>
                        <td class="p-3 text-sm text-muted">${fmtDate(v.publishedAt)}</td>
                        <td class="p-3"><select class="row-sel" onchange="setStatus(${v.id}, this.value)">${statusOpts}</select></td>
                        <td class="p-3">
                            <div class="flex items-center gap-1">
                                <a class="btn btn-primary p-2" title="재가공" href="/rework/${v.id}"><i data-lucide="wand-2" style="width:15px;"></i></a>
                                <button class="btn btn-secondary p-2" title="북마크" onclick="toggleBookmark(${v.id}, ${!!v.bookmarked})"><i data-lucide="star" style="width:15px; color:${star};"></i></button>
                            </div>
                        </td>
                    </tr>`;
                }).join('');
                if(window.lucide) lucide.createIcons();
            }

            async function setStatus(id, value){
                try { await api(API+'/'+id+'/status', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({status:value})}); }
                catch(e){ alert('상태 변경 실패: '+e.message); }
            }
            async function toggleBookmark(id, current){
                try { await api(API+'/'+id+'/bookmark', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({bookmarked: !current})}); loadVideos(); }
                catch(e){ alert('북마크 실패: '+e.message); }
            }

            function openVideoModal(vid, title){
                document.getElementById('modalTitle').textContent = title;
                document.getElementById('iframeContainer').innerHTML = '<iframe width="100%" height="100%" src="https://www.youtube.com/embed/'+vid+'?autoplay=1" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>';
                document.getElementById('videoModal').style.display = 'flex';
            }
            function closeVideoModal(){
                document.getElementById('iframeContainer').innerHTML = '';
                document.getElementById('videoModal').style.display = 'none';
            }

            loadVideos();
            /*]]>*/
        </script>
    </div>
</body>

</html>
  • Step 2: 커밋
git add src/main/resources/templates/discover.html
git commit -m "feat(discover): add discovery page template"

Task 6: 사이드바 링크

Files:

  • Modify: src/main/resources/templates/layout/sidebar.html

  • Step 1: "발굴" 네비 항목 추가

"수집함" <li>(닫는 </li>)와 "보드" <li> 사이에 아래 블록을 삽입한다:

                <li>
                    <a th:href="@{/discover}" class="nav-item"
                        th:classappend="${currentPage == 'discover'} ? 'active'">
                        <i data-lucide="radar" class="nav-icon"></i>
                        <span class="nav-text">발굴</span>
                    </a>
                </li>
  • Step 2: 커밋
git add src/main/resources/templates/layout/sidebar.html
git commit -m "feat(discover): add sidebar nav link"

Task 7: 통합 검증 (기동 + 수동 확인)

Files: 없음 (검증 전용)

  • Step 1: 클린 컴파일

Run: $env:JAVA_HOME="D:\Development\app\JDK\jdk-21.0.5"; .\gradlew.bat clean compileJava --console=plain Expected: BUILD SUCCESSFUL.

  • Step 2: 기동 확인 (포트 8088 점유 시 기존 인스턴스 종료 후)

Run: $env:JAVA_HOME="D:\Development\app\JDK\jdk-21.0.5"; .\gradlew.bat bootRun --console=plain Expected: 로그에 Started HLabApplication 또는 최소한 Tomcat started on port 8088 / JPA EntityManagerFactory 정상. 빈 에러(UnsatisfiedDependency, BeanCreationException) 없음. (이미 8088을 다른 인스턴스가 점유 중이면 그 프로세스가 곧 새 코드로 재기동되었는지 확인하거나, 점유 인스턴스를 종료 후 재기동.)

  • Step 3: 수동 확인 (브라우저)
  1. http://localhost:8088/discover 접속 → 사이드바 "발굴" active, 진입 즉시 배율 내림차순으로 영상 표시.
  2. 기간/최소배율/출처/Shorts/미처리만/정렬 변경 시 목록 갱신 확인.
  3. 행에서 상태 드롭다운 변경 → 새로고침해도 유지(저장 확인). 북마크 토글 → 별 색 변경. 🪄 재가공 클릭 → /rework/{id} 이동. 썸네일 클릭 → 모달 재생.
  4. EXCLUDED 로 바꾼 영상이 새로고침 후 목록에서 사라지는지 확인.
  5. (선택) Swagger http://localhost:8088/swagger-ui.html 에서 /api/v1/channel-videos/discover 응답 확인.
  • Step 4: (이상 없으면) 마무리 커밋 / 정리

추가 변경이 없으면 커밋할 것 없음. 기동 로그 파일 등 임시 산출물은 build/(gitignore) 에 두거나 삭제.


Self-Review 결과

  • 스펙 커버리지: 백엔드(쿼리 T1 / 서비스 T2 / 엔드포인트 T3), 프런트(라우트 T4 / 페이지 T5 / 사이드바 T6), 검증(T7) — 스펙의 5개 컴포넌트 + 기본값 + 에러/빈 상태 + 검증방법 모두 태스크에 매핑됨.
  • 기본값 일치: 서비스 기본 sortField=viewsPerSubRatio, UI 기본 정렬 옵션도 viewsPerSubRatio 첫번째. unprocessed/shorts 기본 off, 기간/배율 기본 전체 — 스펙과 일치.
  • 타입/시그니처 일치: discover 시그니처가 Controller→Service→Repository 3계층에서 동일(파라미터 순서: periodDays/minRatio/shortsOnly/source/unprocessedOnly/sortBy/limit → 서비스 → publishedAfter/minRatio/source/shortsOnly/unprocessedOnly/Pageable). EXCLUDED 제외는 Repository에서 처리. ALLOWED_SORT/Sort/StringUtils 는 서비스에 이미 존재.
  • 플레이스홀더: 없음(모든 스텝에 실제 코드/명령 포함).
  • null 정렬: Sort.Order.desc(field).nullsLast() 로 배율 null 행이 상단에 뜨지 않게 처리(Postgres 기본 NULLS FIRST 회피).