Skip to content

[OT-152][FEAT]: 플레이리스트(홈&재생목록) API 및 추천 엔진 로직 구현#95

Merged
yubin012 merged 10 commits intodevelopfrom
OT-152-feature/recommend-logic-playlist
Mar 4, 2026
Merged

[OT-152][FEAT]: 플레이리스트(홈&재생목록) API 및 추천 엔진 로직 구현#95
yubin012 merged 10 commits intodevelopfrom
OT-152-feature/recommend-logic-playlist

Conversation

@yubin012
Copy link
Copy Markdown
Contributor

@yubin012 yubin012 commented Mar 3, 2026

📝 작업 내용

이번 PR에서 작업한 내용을 적어주세요

  • 플레이리스트 API RESTful 엔드포인트 분리

  • 단일 API로 처리되던 로직을 목적에 맞게 6개의 직관적인 엔드포인트로 분리 (/recommend, /tags/top, /tags/{tagId}, /trending, /history, /bookmarks)

  • 홈 화면 Top 3 태그 전용 응답 DTO (TopTagPlaylistResponse) 구현

  • 프론트엔드의 화면 렌더링 편의성 및 네트워크 호출 최적화를 위해, 응답 최상단에 카테고리와 태그 메타데이터(CategoryInfo, TagInfo)를 포함하여 반환하도록 설계

  • excludeMediaId를 활용한 도메인별 셔플(Shuffle) 전략 세분화

기본 excludeMediaId 전략

  • 🏠 홈 화면: 유저가 지금 보고 있는 영상이 없으므로, 프론트엔드가 excludeMediaId를 보내지 않습니다. (null)

  • 🎬 상세 페이지: 유저가 이미 특정 영상을 누르고 들어왔으므로, 중복 추천을 막기 위해 프론트엔드가 excludeMediaId=152처럼 값을 무조건 보냅니다.

셔플 전략

  1. 추천 및 취향 태그 영역: 홈 화면(excludeMediaId 미포함)일 경우 디스커버리 UX를 위해 콘텐츠 무작위 셔플. 단, 유저의 Top 3 태그 순위 자체는 고정.

  2. 인기 차트 및 시청 이력 영역: 정합성이 중요하므로 셔플 로직을 완벽히 배제하고 점수순/최신순 고정 정렬 유지.




개인화 추천 엔진 아키텍처 설계

1. 다중 가중치 합산 기반의 점수(Score) 산출 로직 구현

  • 유저의 '선호 태그', '시청 이력', '좋아요' 등의 행동 데이터를 수집하여 각각의 가중치를 부여하고 합산하는 추천 알고리즘을 구현했습니다.

    • 행동별 가중치 설계
    • 유저의 행동마다 '의도의 크기'가 다르기 때문에 서로 다른 점수(가중치)를 부여합니다. (점수 비율은 기획에 따라 유연하게 조정 가능합니다.)
    1. 명시적 호감 (좋아요/북마크): +2점 (유저가 직접 버튼을 누른 확실한 선호도)
    2. 최근 감상 호감 (시청 이력): +3점 (실시간성을 반영하기 위해 시청이력을 좋아요보다 더 높게 측정)
    3. 기본 취향 (온보딩 태그): +5점 (기본적으로 깔고 가는 베이스 점수)

-> 자바의 Map 을 활용해 최종 점수를 계산합니다.
-> DB에 복잡한 집계 연산을 위임하지 않고, 자바의 Map<TagId, Score> 구조를 활용해 최종 점수를 계산합니다.

Map의 Key 속성을 활용하여 태그 중복 누적 계산을 방지함과 동시에, Map.merge() 메서드를 통해 같은 태그를 가진 여러 콘텐츠가 발견되더라도 하나의 태그 Key에 대해 점수가 안전하고 빠르게 누적 합산되도록 구현했습니다.

2. 최신 트렌드 반영 및 OOM(메모리 초과) 방지

  • 유저의 시청 이력이 수천 개 쌓일 경우를 대비하여, 리포지토리 조회 시 Pageable(limit=100)을 걸어 최근 행동 100개만 점수에 반영하도록 설계했습니다.
    이를 통해 서버 메모리를 보호하고, 유저의 '현재 취향'을 가장 민감하게 캐치합니다.

3. QueryDSL CaseBuilder를 활용한 DB 레벨의 연산 최적화

  • 자바 서버 메모리로 모든 미디어 데이터를 가져와서 점수를 매기고 정렬하면 대규모 트래픽에서 병목이 발생합니다.
  • 이를 해결하기 위해 QueryDSL의 CaseBuilder를 통해 계산식을 SQL 구문으로 치환, DB 내부에서 가중치를 합산하고 정렬(ORDER BY)까지 끝낸 후 필요한 데이터만 가져오도록 성능을 극대화했습니다.

📷 스크린샷

image

위 빨간 4개의 박스영역은 홈화면에서 사용되며
총 6개의 모든 API 는 홈화면 뿐만 아니라 재생목록에서도 재사용됩니다. 단(excluedMediaId 와 함께 사용함)

+) 검색 API 도 swagger 에 표시함. 단 검색 결과에서 상세 페이지로 진입 시 Recommend 전략으로 대체되어 재생목록에는 추천 리스트가 뜨도록 함.

프론트 호출 방식: GET /playlists/search?excludeMediaId=152&page=1&size=20
위처럼 호출하면 재생목록은 서버 내부에서 추천 리스트로 대체할 수 있게함!

https://www.notion.so/O-T-3182753972b780d58b34c459197bf64b
자세한 API 병렬 호출 방식은 위 노션에 새로 업데이트 해두었습니다.

향후 고도화 전략

  • Redis 캐싱 도입: 지금은 새로고침할 때마다 점수를 계산합니다. 나중에는 PlaylistPreferenceService가 계산한 Map<Long, Integer> 점수표를 Redis에 하루 단위로 캐싱해두면 DB 부하를 획기적으로 줄일 수 있습니다.
  • 시간 감쇠(Time Decay) 알고리즘: 지금은 어제 누른 좋아요와 3달 전에 누른 좋아요가 똑같이 +2점입니다. 나중에는 날짜별 가중치(예: 1달 지나면 0.5배 적용)를 곱해주어 더 정교한 트렌드 추적이 가능합니다.
  • Spring Batch 연동: 헤비 유저의 경우 점수 계산이 무거워질 수 있으므로, 유저 활동이 적은 새벽 시간에 배치(Batch)를 돌려 미리 추천 리스트 풀을 만들어두는 방식으로 고도화할 수 있습니다.

→ 어떻게 생각하시는지... 이제 MVP 2차...라고 할 수 있나 암튼 선택과 집중을 할 때 입니다...

☑️ 체크 리스트

체크 리스트를 확인해주세요

  • 테스트는 잘 통과했나요?
  • 충돌을 해결했나요?
  • 이슈는 등록했나요?
  • 라벨은 등록했나요?

#️⃣ 연관된 이슈

ex) #88
closes #88

💬 리뷰 요구사항

리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요

  1. 추천 엔진 로직
  2. 향후 고도화 전략
    부분 위주로 코드와 함께 읽어주시면 감사하겠습니다.

Summary by CodeRabbit

Release Notes

  • New Features
    • 새로운 플레이리스트 엔드포인트 추가: 추천, 트렌딩, 시청기록, 북마크, 태그별, 검색 (/playlists/**)
  • Changes
    • 콘텐츠 상세/시리즈 응답에 seriesMediaId 및 duration 필드 추가
    • 플레이리스트 응답에 thumbnailUrl 추가
  • Improvements
    • API 경로/파라미터명 개선: contentsId → mediaId
  • Breaking Changes
    • 기존 ContentListElement DTO 및 기존 콘텐츠 플레이리스트 엔드포인트 제거

@yubin012 yubin012 self-assigned this Mar 3, 2026
@yubin012 yubin012 added the feat 새로운 기능 구현 label Mar 3, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 3, 2026

Walkthrough

미디어 중심 조회로 API를 재설계하고 플레이리스트 기능을 별도 모듈로 분리했습니다. 전략 패턴 기반의 추천/인기/히스토리/북마크/태그 플로우와 태그 가중치 기반 개인화 로직(PreferenceService, 전략 구현체, 리포지토리 쿼리)이 추가되었고, 기존 ContentListElement DTO 및 콘텐츠 내장 플레이리스트 엔드포인트가 제거되었습니다.

Changes

Cohort / File(s) Summary
컨텐츠 엔드포인트 변경
apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java, .../ContentsController.java, .../ContentsService.java, .../dto/ContentsDetailResponse.java
경로 변수 contentsIdmediaId로 변경, getContentPlayList 엔드포인트 및 ContentListElement 제거, 상세 응답 DTO에 seriesMediaId/duration 추가 및 id 매핑을 media 기반으로 변경
플레이리스트 신규 API/컨트롤러
apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java, .../PlaylistController.java
새로운 /playlists 엔드포인트(추천/태그/인기/히스토리/북마크/검색 등) 추가, 파라미터 및 유효성 검증, 응답 래핑
플레이리스트 서비스 및 전략 패턴
apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylisStrategytService.java, .../PlaylistService.java, .../dto/request/PlaylistCondition.java
조건 기반 전략 선택 로직 추가, 전략 맵/키 결정, PlaylistCondition 확장(index, mediaType 등)
전략 구현체
apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/...
Recommend/Tag/Trending/History/Bookmark 각 전략 구현체 추가(각각 @Component로 등록) 및 공통 인터페이스 PlaylistStrategy에 getPlaylist 메서드 추가
추천·선호 서비스
apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java
온보딩 선호 태그, 최근 재생, 좋아요 기반 태그 점수 집계 로직 추가(TopTags, TotalTagScores)
플레이리스트 DTO 확장
apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java, .../TopTagPlaylistResponse.java
PlaylistResponse에 thumbnailUrl 추가, TopTagPlaylistResponse 신규 DTO 추가
도메인 리포지토리 확장
modules/domain/src/main/java/com/ott/domain/.../MediaRepositoryCustom.java, .../MediaRepositoryImpl.java, .../ContentsRepository.java, .../SeriesRepository.java
미디어 기반 조회 메서드로 전환(예: findByMediaId...), 플레이리스트용 쿼리 다수 추가(Trending/History/Bookmark/Tag/Recommend 등)
관련 리포지토리 쿼리 추가
modules/domain/.../PlaybackRepository.java, .../LikesRepository.java, .../PreferredTagRepository.java, .../MediaTagRepository.java
최근 재생/좋아요 미디어 ID와 선호 태그 ID, 미디어→태그 매핑 조회용 메서드 추가
에러 코드 및 예외 처리
modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java, .../GlobalExceptionHandler.java
B 코드 재정렬 및 신규 에러(예: INVALID_PLAYLIST_SOURCE, STRATEGY_NOT_FOUND 등) 추가, ConstraintViolationException 핸들러 추가
설정
apps/api-user/src/main/resources/application.yml
springdoc.api-docs.version: OPENAPI_3_0 추가

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant PlaylistController
    participant PlaylistService
    participant Strategy as PlaylistStrategy
    participant MediaRepo as MediaRepository
    participant PrefService as PlaylistPreferenceService

    Client->>PlaylistController: GET /playlists/recommend (excludeMediaId,page,size)
    PlaylistController->>PlaylistService: getPlaylists(condition, pageable)
    PlaylistService->>PlaylistService: determineStrategyKey(condition)
    PlaylistService->>Strategy: getPlaylist(condition, pageable)

    alt Recommend
        Strategy->>PrefService: getTotalTagScores(memberId)
        PrefService-->>Strategy: tagScores
        Strategy->>MediaRepo: findRecommendedMedias(tagScores, excludeMediaId, limit, offset)
    else Other
        Strategy->>MediaRepo: findTrendingPlaylists / findHistoryPlaylists / findBookmarkedPlaylists / findPlaylistsByTag
    end

    MediaRepo-->>Strategy: Page<Media>
    Strategy-->>PlaylistService: Page<Media>
    PlaylistService->>PlaylistService: to PlaylistResponse, build PageResponse
    PlaylistService-->>PlaylistController: PageResponse<PlaylistResponse>
    PlaylistController-->>Client: SuccessResponse<PageResponse<PlaylistResponse>>
Loading
sequenceDiagram
    participant Home as HomeScreen
    participant Controller as PlaylistController
    participant Service as PlaylistService
    participant TagStrategy as TagPlaylistStrategy
    participant PrefService as PlaylistPreferenceService
    participant MediaRepo as MediaRepository

    Home->>Controller: GET /playlists/tags/top (index=0,page,size)
    Controller->>Service: getTopTagPlaylists(condition(index=0), pageable)
    Service->>TagStrategy: getPlaylist(condition, pageable)
    TagStrategy->>PrefService: getTopTags(memberId)
    PrefService-->>TagStrategy: topTags
    TagStrategy->>MediaRepo: findPlaylistsByTag(topTagId, excludeMediaId, limit)
    MediaRepo-->>TagStrategy: Page<Media>
    TagStrategy-->>Service: Page<Media>
    Service->>PrefService: getTopTags(memberId) (for metadata)
    Service-->>Controller: TopTagPlaylistResponse(category, tag, medias)
    Controller-->>Home: SuccessResponse<TopTagPlaylistResponse>
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

refactor

Suggested reviewers

  • phonil
  • marulog
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.51% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 플레이리스트 API 및 추천 엔진 구현이라는 주요 변경사항을 명확하게 설명하고 있으며, 코드 변경사항과 직접적으로 관련되어 있습니다.
Linked Issues check ✅ Passed PR이 #88의 모든 핵심 요구사항을 충족합니다: PlaylistCondition DTO 생성, 6개 API 엔드포인트 분리, Strategy Pattern 구현, QueryDSL 쿼리 개발, 태그 기반 개인화 로직 구현, excludeMediaId 처리 등이 모두 이루어졌습니다.
Out of Scope Changes check ✅ Passed 몇 가지 부가적 변경사항(ContentListElement 삭제, ErrorCode 재구조화, application.yml OpenAPI 설정)이 있지만, 모두 플레이리스트 API 구현을 지원하는 범위 내의 변경사항입니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch OT-152-feature/recommend-logic-playlist

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@yubin012 yubin012 requested a review from marulog March 3, 2026 06:48
@yubin012 yubin012 changed the title [FEAT]: 플레이리스트(홈&재생목록) API 및 추천 엔진 로직 구현 [OT-152][FEAT]: 플레이리스트(홈&재생목록) API 및 추천 엔진 로직 구현 Mar 3, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java (1)

16-16: ⚠️ Potential issue | 🟡 Minor

사용하지 않는 import를 제거해 주세요.

io.micrometer.core.ipc.http.HttpSender.Response는 이 파일에서 사용되지 않습니다.

🧹 사용하지 않는 import 제거
-import io.micrometer.core.ipc.http.HttpSender.Response;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java`
at line 16, Remove the unused import line "import
io.micrometer.core.ipc.http.HttpSender.Response;" from SeriesController.java;
locate the import section at the top of the file (look for the SeriesController
class and its imports) and delete that specific import so there are no unused
imports referenced by the SeriesController class.
🧹 Nitpick comments (8)
apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java (2)

61-71: 리스트 변수명 suffix 규칙을 맞춰주세요.

topTagIds, allTags는 List 타입이므로 topTagIdList, allTagList처럼 List suffix를 붙이는 편이 프로젝트 규칙과 일치합니다.

As per coding guidelines, Collection variable names with List suffix.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java`
around lines 61 - 71, Rename the List variables to follow the project's "List"
suffix convention: change topTagIds to topTagIdList and allTags to allTagList,
update the declaration where tagScores is processed (the stream producing
topTagIds), update any subsequent references (the isEmpty() check, and usages
like tagRepository.findAll() result handling), and return
allTagList.stream().limit(3).collect(...) so all references (including
topTagIdList and allTagList) are consistently renamed.

68-71: fallback에서 전체 태그 로드+셔플은 트래픽 증가 시 비용이 큽니다.

매 요청마다 findAll()shuffle 하면 태그 수가 늘수록 부담이 커집니다. 활성 태그 기준으로 DB에서 3개만 가져오는 방식(전용 쿼리/페이지 제한)으로 바꾸는 것을 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java`
around lines 68 - 71, The fallback currently loads all tags and shuffles them
(topTagIds.isEmpty() -> tagRepository.findAll() + Collections.shuffle), which is
expensive; change PlaylistPreferenceService to query only three active tags
directly from the database instead of findAll(), e.g. add a repository method
(like TagRepository.findTop3ByActiveTrue() or a `@Query` with LIMIT 3 / ORDER BY
RAND()/random function or use PageRequest.of(0,3)) and call that method in the
fallback branch so only three rows are returned, avoiding in-memory shuffle and
full-table reads.
modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java (1)

57-67: 주석 처리된 이전 쿼리 블록은 제거하는 편이 좋습니다.

현재 구현과 무관한 과거 쿼리가 길게 남아 있어 추후 유지보수 시 혼동 포인트가 됩니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java`
around lines 57 - 67, Remove the legacy commented JPQL block and the unused
method signature from ContentsRepository to avoid confusion: delete the
commented `@Query`(...) and the commented Optional<Contents>
findByIdAndStatusAndMedia_PublicStatus(...) lines inside the class
(ContentsRepository) so only active code remains; if you need to preserve the
snippet for history, rely on VCS instead of leaving it inline.
modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java (1)

17-28: 주석 처리된 코드 제거를 고려해 주세요.

새로운 findByMediaIdAndStatusAndPublicStatus 메서드 구현은 올바르며 PR 목표에 맞게 mediaId 기반 조회로 전환되었습니다. 다만, 17-22번 라인의 주석 처리된 코드는 버전 관리 시스템에서 이력을 추적할 수 있으므로 제거하는 것이 깔끔합니다.

♻️ 주석 처리된 코드 제거 제안
 public interface SeriesRepository extends JpaRepository<Series, Long>, SeriesRepositoryCustom {
 
-        // Optional<Series> findByIdAndStatusAndMedia_PublicStatus(Long id, Status
-        // status, PublicStatus publicStatus);
-        // `@Query`("SELECT s FROM Series s JOIN FETCH s.media m WHERE s.id = :id AND s.status = :status AND m.publicStatus = :publicStatus")
-        // Optional<Series> findByIdWithMedia(`@Param`("id") Long id,
-        //                 `@Param`("status") Status status,
-        //                 `@Param`("publicStatus") PublicStatus publicStatus);
-
         `@Query`("SELECT s FROM Series s JOIN FETCH s.media m WHERE m.id = :mediaId AND s.status = :status AND m.publicStatus = :publicStatus")
         Optional<Series> findByMediaIdAndStatusAndPublicStatus(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java`
around lines 17 - 28, Remove the commented-out legacy methods from
SeriesRepository (the commented Optional<Series>
findByIdAndStatusAndMedia_PublicStatus and the `@Query` findByIdWithMedia block)
since the new method findByMediaIdAndStatusAndPublicStatus replaces them; keep
the new `@Query-based` findByMediaIdAndStatusAndPublicStatus implementation
intact, then run a quick compile/IDE cleanup to drop any now-unused imports or
warnings and verify no other code references the removed method names.
apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java (1)

35-39: Swagger 파라미터 설명 업데이트를 권장합니다.

path 변수가 seriesId에서 mediaId로 변경되었지만, @Parameter description은 여전히 "시리즈 ID"로 되어 있습니다. API 사용자의 혼란을 방지하기 위해 설명을 명확히 업데이트하는 것을 권장합니다.

📝 Swagger 설명 업데이트 제안
-        `@Parameter`(description = "시리즈 ID", required = true, example = "1") `@PathVariable`("mediaId") Long mediaId,
+        `@Parameter`(description = "시리즈 미디어 ID", required = true, example = "1") `@PathVariable`("mediaId") Long mediaId,

55번 라인의 getSeriesContents 메서드에도 동일하게 적용해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java`
around lines 35 - 39, The `@Parameter` description for the path variable has stale
text: update the description on the getSeriesDetail method (and similarly on
getSeriesContents) so it matches the actual path variable name and meaning
(mediaId) — e.g., change the `@Parameter`(description = "시리즈 ID", ...) tied to
`@PathVariable`("mediaId") in getSeriesDetail and the matching annotation in
getSeriesContents to a clear description like "미디어 ID" or "series media ID" to
avoid confusion in Swagger.
apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java (1)

63-63: 컬렉션 변수명에 List 접미사를 맞춰주세요.

topTagstopTagList로 변경하면 가이드라인과 일관성이 맞습니다.

As per coding guidelines "Collection variable names with List suffix".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java`
at line 63, Rename the collection variable topTags to topTagList in
PlaylistService where you assign from
preferenceService.getTopTags(condition.getMemberId()); update all usages in the
same method/class to the new name (topTagList) to follow the "List" suffix
guideline and avoid compile errors; ensure imports/closures and any related
local references are adjusted accordingly.
apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java (1)

40-40: 컬렉션 변수명은 List 접미사로 통일해 주세요.

topTags 대신 topTagList처럼 타입이 드러나는 이름이 가이드라인과 일치합니다.

As per coding guidelines "Collection variable names with List suffix".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java`
at line 40, Rename the collection variable topTags in TagPlaylistStrategy to
follow the guideline using a List suffix (e.g., topTagList): update the
declaration and all usages where
preferenceService.getTopTags(condition.getMemberId()) is assigned/used so the
variable name change is consistent throughout the method/class (references in
any loops, streams, or method calls must be updated).
modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java (1)

142-143: 리스트 변수명은 List 접미사로 통일해 주세요.

content 대신 contentList/mediaList처럼 컬렉션 타입이 드러나는 이름으로 맞추는 것이 좋습니다.

As per coding guidelines "Collection variable names with List suffix".

Also applies to: 166-167, 192-193, 219-220

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java`
around lines 142 - 143, In MediaRepositoryImpl, rename collection variables that
use the generic name content to include a List suffix (e.g., content ->
contentList or mediaList) so they follow the "Collection variable names with
List suffix" guideline; update all occurrences and usages within the same
method(s) (including the other instances noted around lines 166-167, 192-193,
219-220) to the new names to preserve compilation and behavior, and ensure any
related variable declarations, returns, or local references are renamed
consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java`:
- Around line 22-25: PlaylistController currently lacks explicit role-based
access control; add Spring Security annotations to enforce "USER/EDITOR/ADMIN"
checks for all playlist endpoints by annotating the controller or each API
method (e.g., on class PlaylistController or methods from PlaylistApi) with an
appropriate authorization expression such as
`@PreAuthorize`("hasAnyRole('USER','EDITOR','ADMIN')") or method-level
`@RolesAllowed` values, ensuring sensitive endpoints are limited to those roles
and adjusting expressions per endpoint if stricter roles are required.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java`:
- Around line 74-75: The call in PlaylistPreferenceService that returns
tagRepository.findAllById(topTagIds) can return entities in DB order and break
the topTagIds ranking; change it to fetch the tags by IDs (using
tagRepository.findAllById or a custom query) and then reorder the resulting
List<Tag> to match the original topTagIds sequence before returning — e.g.,
build a Map<Id, Tag> from the fetched results and produce a List by iterating
topTagIds and looking up each id (ensure null-safety for missing ids). This
preserves the computed rank order (topTagIds) without relying on DB return
ordering.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java`:
- Around line 68-70: The metadata assembly currently uses condition.getIndex()
without checking for negative values, which can throw when calling
topTags.get(index); in PlaylistService update the guard to validate index is
within bounds by ensuring condition.getIndex() != null && condition.getIndex()
>= 0 && condition.getIndex() < topTags.size() before accessing topTags, so the
Tag targetTag = topTags.get(condition.getIndex()) call is only executed for
valid indices.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java`:
- Around line 37-57: The code in TagPlaylistStrategy leaves targetTagId null
when both tagId and index are absent and does not check for negative index
values; update the logic in the block that uses
preferenceService.getTopTags(condition.getMemberId()) so you first handle the
case where both condition.getTagId()/targetTagId and condition.getIndex() are
null by returning Page.empty(pageable), then validate condition.getIndex() is >=
0 before using it; when index is out of bounds or negative also return
Page.empty(pageable). Keep the existing behavior of setting targetTagId =
topTags.get(condition.getIndex()).getId() only after the new non-negative and
bounds checks so mediaRepository.findMediasByTagId is never called with a
null/invalid tag id.

In
`@modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java`:
- Around line 46-53: The ErrorCode enum has reassigned code values for existing
constants (e.g., CONTENT_NOT_FOUND, SERIES_NOT_FOUND, CATEGORY_NOT_FOUND,
TAG_NOT_FOUND, MEDIA_NOT_FOUND, COMMENT_NOT_FOUND, BOOKMARK_NOT_FOUND) which
will break external contracts; revert those constants to their original code
strings and preserve their HttpStatus/message, and if you need new error
conditions add new enum entries with new unique codes in a separate code range
instead of changing existing codes so clients and monitoring rules remain
stable.

In
`@modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java`:
- Around line 24-31: 현재 findRecentTagIdsByMemberId 쿼리는 Likes와 MediaTag를 조인한 뒤
Pageable을 적용해 "조인된 태그 행 100개"를 제한하므로 미디어당 태그 수에 의해 좋아요 샘플이 편향됩니다; 대신 2단계로 변경하세요:
먼저 LikesRepository에 최근 좋아요 100건만 조회하는 메서드(예: findRecentLikesMediaIdsByMemberId
or findRecentLikesByMemberId)를 추가해 memberId와 status로 ORDER BY l.createdDate DESC
+ Pageable(limit 100)로 media.id만 반환하고, 그 결과 mediaId 리스트를 사용해 MediaTag에서 WHERE
media.id IN (:mediaIds)로 태그들을 조회해 최종 tag id 목록을 반환하도록 로직을 분리(즉,
findRecentTagIdsByMemberId는 제거하거나 호출 흐름을 바꿔)하세요.

In
`@modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java`:
- Around line 26-40: The repository methods lack a mediaType filter so
unintended media types can leak into playlist/medias queries; update the
signatures of findTrendingPlaylists, findHistoryPlaylists,
findBookmarkedPlaylists, findPlaylistsByTag, findMediasByTagId, and
findRecommendedMedias to accept a mediaType parameter (e.g., MediaType mediaType
or an equivalent enum/int) and propagate that parameter into the implementation
and any PlaylistCondition construction so the repository layer can filter by
mediaType; ensure callers (service/condition builders) are updated to supply the
mediaType when invoking these methods.

In
`@modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java`:
- Around line 180-188: The countQuery used when building the Page (the
JPAQuery<Long> named countQuery passed to PageableExecutionUtils.getPage) is
missing the same filters and joins applied to the content query (notably the
isActiveAndPublic predicate and any join conditions used for
history/bookmark/tag methods), causing totalElements to be inflated; fix each
method (the content query and its corresponding countQuery in
MediaRepositoryImpl where playback, memberId, excludeMediaId and excludeId are
used) by applying the identical where predicates and joins (reuse the same
isActiveAndPublic(), excludeId(...) and any join-building logic) when
constructing countQuery so the count reflects the same filtered dataset as the
content query.
- Around line 264-271: The empty tagScores fallback currently returns a query
that can re-include the media being viewed; update the branch in
MediaRepositoryImpl where tagScores.isEmpty() is handled to also apply the
excludeMediaId filter — i.e., add a where condition excluding
media.id.eq(excludeMediaId) (guarded for null/absent excludeMediaId or use .ne
when present) to the queryFactory.selectFrom(media) chain so the returned
results never include the excluded media id.

In
`@modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java`:
- Around line 19-28: The current findRecentTagIdsByMemberId query limits JOIN
rows (tags) with Pageable instead of limiting Playback records first; change to
a two-step approach: add a method like
findRecentPlaybackIdsByMemberId(`@Param`("memberId") Long memberId,
`@Param`("status") Status status, Pageable pageable) that returns List<Long>
playbackIds (select p.id ordered by p.modifiedDate desc), then change/replace
findRecentTagIdsByMemberId to accept a List<Long> playbackIds and query tag ids
by joining MediaTag mt to Playback p with p.id IN :playbackIds (e.g., SELECT
mt.tag.id FROM MediaTag mt JOIN Playback p ON p.contents.media.id = mt.media.id
WHERE p.id IN :playbackIds) so you reliably limit to the most recent 100
Playback records before expanding their tags.

---

Outside diff comments:
In
`@apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java`:
- Line 16: Remove the unused import line "import
io.micrometer.core.ipc.http.HttpSender.Response;" from SeriesController.java;
locate the import section at the top of the file (look for the SeriesController
class and its imports) and delete that specific import so there are no unused
imports referenced by the SeriesController class.

---

Nitpick comments:
In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java`:
- Around line 61-71: Rename the List variables to follow the project's "List"
suffix convention: change topTagIds to topTagIdList and allTags to allTagList,
update the declaration where tagScores is processed (the stream producing
topTagIds), update any subsequent references (the isEmpty() check, and usages
like tagRepository.findAll() result handling), and return
allTagList.stream().limit(3).collect(...) so all references (including
topTagIdList and allTagList) are consistently renamed.
- Around line 68-71: The fallback currently loads all tags and shuffles them
(topTagIds.isEmpty() -> tagRepository.findAll() + Collections.shuffle), which is
expensive; change PlaylistPreferenceService to query only three active tags
directly from the database instead of findAll(), e.g. add a repository method
(like TagRepository.findTop3ByActiveTrue() or a `@Query` with LIMIT 3 / ORDER BY
RAND()/random function or use PageRequest.of(0,3)) and call that method in the
fallback branch so only three rows are returned, avoiding in-memory shuffle and
full-table reads.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java`:
- Line 63: Rename the collection variable topTags to topTagList in
PlaylistService where you assign from
preferenceService.getTopTags(condition.getMemberId()); update all usages in the
same method/class to the new name (topTagList) to follow the "List" suffix
guideline and avoid compile errors; ensure imports/closures and any related
local references are adjusted accordingly.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java`:
- Line 40: Rename the collection variable topTags in TagPlaylistStrategy to
follow the guideline using a List suffix (e.g., topTagList): update the
declaration and all usages where
preferenceService.getTopTags(condition.getMemberId()) is assigned/used so the
variable name change is consistent throughout the method/class (references in
any loops, streams, or method calls must be updated).

In
`@apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java`:
- Around line 35-39: The `@Parameter` description for the path variable has stale
text: update the description on the getSeriesDetail method (and similarly on
getSeriesContents) so it matches the actual path variable name and meaning
(mediaId) — e.g., change the `@Parameter`(description = "시리즈 ID", ...) tied to
`@PathVariable`("mediaId") in getSeriesDetail and the matching annotation in
getSeriesContents to a clear description like "미디어 ID" or "series media ID" to
avoid confusion in Swagger.

In
`@modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java`:
- Around line 57-67: Remove the legacy commented JPQL block and the unused
method signature from ContentsRepository to avoid confusion: delete the
commented `@Query`(...) and the commented Optional<Contents>
findByIdAndStatusAndMedia_PublicStatus(...) lines inside the class
(ContentsRepository) so only active code remains; if you need to preserve the
snippet for history, rely on VCS instead of leaving it inline.

In
`@modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java`:
- Around line 142-143: In MediaRepositoryImpl, rename collection variables that
use the generic name content to include a List suffix (e.g., content ->
contentList or mediaList) so they follow the "Collection variable names with
List suffix" guideline; update all occurrences and usages within the same
method(s) (including the other instances noted around lines 166-167, 192-193,
219-220) to the new names to preserve compilation and behavior, and ensure any
related variable declarations, returns, or local references are renamed
consistently.

In
`@modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java`:
- Around line 17-28: Remove the commented-out legacy methods from
SeriesRepository (the commented Optional<Series>
findByIdAndStatusAndMedia_PublicStatus and the `@Query` findByIdWithMedia block)
since the new method findByMediaIdAndStatusAndPublicStatus replaces them; keep
the new `@Query-based` findByMediaIdAndStatusAndPublicStatus implementation
intact, then run a quick compile/IDE cleanup to drop any now-unused imports or
warnings and verify no other code references the removed method names.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 716722f and 29a8052.

📒 Files selected for processing (34)
  • apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java
  • apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsApi.java
  • apps/api-user/src/main/java/com/ott/api_user/content/controller/ContentsController.java
  • apps/api-user/src/main/java/com/ott/api_user/content/dto/ContentsDetailResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/content/service/ContentsService.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/dto/request/PlaylistCondition.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/PlaylistResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/dto/response/TopTagPlaylistResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistService.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/BookmarkPlaylistStrategy.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/HistoryPlaylistStrategy.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/PlaylistStrategy.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/RecommendPlaylistStrategy.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TrendingPlaylistStrategy.java
  • apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesApi.java
  • apps/api-user/src/main/java/com/ott/api_user/series/controller/SeriesController.java
  • apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesContentsResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/series/dto/SeriesDetailResponse.java
  • apps/api-user/src/main/java/com/ott/api_user/series/service/SeriesService.java
  • apps/api-user/src/main/resources/application.yml
  • modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java
  • modules/domain/src/main/java/com/ott/domain/contents/repository/ContentsRepository.java
  • modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java
  • modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepository.java
  • modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java
  • modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java
  • modules/domain/src/main/java/com/ott/domain/member/repository/MemberRepository.java
  • modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java
  • modules/domain/src/main/java/com/ott/domain/preferred_tag/repository/PreferredTagRepository.java
  • modules/domain/src/main/java/com/ott/domain/series/repository/SeriesRepository.java
💤 Files with no reviewable changes (1)
  • apps/api-user/src/main/java/com/ott/api_user/common/dto/ContentListElement.java

Comment on lines +46 to 53
CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B101", "콘텐츠를 찾을 수 없습니다"),
SERIES_NOT_FOUND(HttpStatus.NOT_FOUND, "B102", "시리즈를 찾을 수 없습니다"),
CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "B103", "카테고리를 찾을 수 없습니다"),
TAG_NOT_FOUND(HttpStatus.NOT_FOUND, "B104", "태그를 찾을 수 없습니다"),
MEDIA_NOT_FOUND(HttpStatus.NOT_FOUND, "B105", "미디어를 찾을 수 없습니다"),
COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "B106", "댓글을 찾을 수 없습니다"),
BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B107", "북마크를 찾을 수 없습니다"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

기존 에러 상수의 code 값 변경은 외부 계약을 깨뜨릴 수 있습니다.

CONTENT_NOT_FOUND, SERIES_NOT_FOUND 같은 기존 상수의 code를 재배정하면, 클라이언트 분기 로직과 운영 모니터링 룰이 깨질 수 있습니다. 기존 상수는 기존 code를 유지하고, 신규 케이스만 새 코드 대역으로 추가하는 방식이 더 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/common-web/src/main/java/com/ott/common/web/exception/ErrorCode.java`
around lines 46 - 53, The ErrorCode enum has reassigned code values for existing
constants (e.g., CONTENT_NOT_FOUND, SERIES_NOT_FOUND, CATEGORY_NOT_FOUND,
TAG_NOT_FOUND, MEDIA_NOT_FOUND, COMMENT_NOT_FOUND, BOOKMARK_NOT_FOUND) which
will break external contracts; revert those constants to their original code
strings and preserve their HttpStatus/message, and if you need new error
conditions add new enum entries with new unique codes in a separate code range
instead of changing existing codes so clients and monitoring rules remain
stable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 클라쪽에서 에러 코드 체계를 맞춰서(아직 진행전이니) 작성하면 되므로 무시하겠습니다

Comment on lines +26 to +40
Page<Media> findTrendingPlaylists(Long excludeMediaId, Pageable pageable);

// 시청 이력 조회 (최근 시청 순)
Page<Media> findHistoryPlaylists(Long memberId, Long excludeMediaId, Pageable pageable);

// 북마크 목록 조회 (최근 찜한 순)
Page<Media> findBookmarkedPlaylists(Long memberId, Long excludeMediaId, Pageable pageable);

// 특정 태그 기반 미디어 목록 조회
Page<Media> findPlaylistsByTag(Long tagId, Long excludeMediaId, Pageable pageable);

Page<Media> findMediaListByMediaTypeAndSearchWordAndPublicStatus(Pageable pageable, MediaType mediaType,
String searchWord, PublicStatus publicStatus);

Page<Media> findMediaListByMediaTypeAndSearchWordAndPublicStatusAndUploaderId(Pageable pageable,
MediaType mediaType, String searchWord, PublicStatus publicStatus, Long uploaderId);
List<Media> findMediasByTagId(Long tagId, Long excludeMediaId, int limit , long offset);

Page<Media> findOriginMediaListBySearchWord(Pageable pageable, String searchWord);
}
List<Media> findRecommendedMedias(Map<Long, Integer> tagScores, Long excludeMediaId, int limit, long offset);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

플레이리스트 조회 시 mediaType 필터를 전달할 수 없는 시그니처입니다.

PlaylistCondition 기반 재사용 구조라면 저장소 계층까지 mediaType이 내려가야 하는데, 현재 신규 메서드들은 excludeMediaId만 받아 타입 필터를 강제할 수 없습니다. 이 상태면 홈/상세/피드 간 공용 로직에서 의도치 않은 미디어 타입이 섞일 수 있습니다.

♻️ 시그니처 정리 제안
- Page<Media> findTrendingPlaylists(Long excludeMediaId, Pageable pageable);
+ Page<Media> findTrendingPlaylists(MediaType mediaType, Long excludeMediaId, Pageable pageable);

- Page<Media> findHistoryPlaylists(Long memberId, Long excludeMediaId, Pageable pageable);
+ Page<Media> findHistoryPlaylists(Long memberId, MediaType mediaType, Long excludeMediaId, Pageable pageable);

- Page<Media> findBookmarkedPlaylists(Long memberId, Long excludeMediaId, Pageable pageable);
+ Page<Media> findBookmarkedPlaylists(Long memberId, MediaType mediaType, Long excludeMediaId, Pageable pageable);

- Page<Media> findPlaylistsByTag(Long tagId, Long excludeMediaId, Pageable pageable);
+ Page<Media> findPlaylistsByTag(Long tagId, MediaType mediaType, Long excludeMediaId, Pageable pageable);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryCustom.java`
around lines 26 - 40, The repository methods lack a mediaType filter so
unintended media types can leak into playlist/medias queries; update the
signatures of findTrendingPlaylists, findHistoryPlaylists,
findBookmarkedPlaylists, findPlaylistsByTag, findMediasByTagId, and
findRecommendedMedias to accept a mediaType parameter (e.g., MediaType mediaType
or an equivalent enum/int) and propagate that parameter into the implementation
and any PlaylistCondition construction so the repository layer can filter by
mediaType; ensure callers (service/condition builders) are updated to supply the
mediaType when invoking these methods.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거는,,,, 한번에 처리할게요,,, 모든 조회 코드에 미디어 타입 처리를 안해놨네요 대참사

Copy link
Copy Markdown
Collaborator

@marulog marulog left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

모든 DTO에 type 값 넣어주시면 감사하겠습니다.

Copy link
Copy Markdown
Collaborator

@marulog marulog left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니다!

@GetMapping
ResponseEntity<SuccessResponse<PageResponse<PlaylistResponse>>> getPlaylists(
@Parameter(description = "플레이리스트 조회 조건 (source 필수)", required = true) PlaylistCondition condition,
@Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") Integer page,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

min, max나 positive 검증이 필요해 보입니다

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 api 코드에 @Vaild, @postive, @max 처리 해놓았습니다.
다마, 현재 글로벌 핸들러 코드에 이에 대한 에러를 담당하는 핸들러가 없어서
ConstraintViolationException 메서드를 추가해놓았습니다

Copy link
Copy Markdown
Collaborator

@marulog marulog left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니다 :)

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java (1)

22-25: ⚠️ Potential issue | 🟠 Major

컨트롤러 레벨 역할 기반 접근 제어가 누락되어 있습니다.

플레이리스트 API 전반에 USER/EDITOR/ADMIN 권한 체크를 명시적으로 적용해 주세요.

🔒 수정 예시
+import org.springframework.security.access.prepost.PreAuthorize;
@@
 `@RestController`
 `@RequiredArgsConstructor`
 `@RequestMapping`("/playlists") 
+@PreAuthorize("hasAnyRole('USER','EDITOR','ADMIN')")
 public class PlaylistController implements PlaylistApi {

As per coding guidelines "Strict USER/EDITOR/ADMIN authorization checks".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java`
around lines 22 - 25, Add controller-level role-based access to
PlaylistController by annotating the class (PlaylistController) so all endpoints
implementing PlaylistApi require one of roles USER, EDITOR, or ADMIN; update the
class annotations to use your project's security annotation (e.g.,
`@PreAuthorize`("hasAnyRole('USER','EDITOR','ADMIN')") or
`@RolesAllowed`({"USER","EDITOR","ADMIN"}") depending on configured security) and
ensure the necessary security imports are added so every method in
PlaylistController enforces the USER/EDITOR/ADMIN check.
apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java (1)

45-105: ⚠️ Potential issue | 🟠 Major

ID 파라미터의 양수 검증이 누락되어 잘못된 입력이 통과할 수 있습니다.

tagIdexcludeMediaId@Positive를 붙여 음수/0 ID를 초기에 차단하는 것이 안전합니다.

🔧 수정 예시
-    ResponseEntity<SuccessResponse<PageResponse<PlaylistResponse>>> getTagPlaylists(
-            `@Parameter`(description = "태그 ID", required = true) `@PathVariable`(value = "tagId") Long tagId,
+    ResponseEntity<SuccessResponse<PageResponse<PlaylistResponse>>> getTagPlaylists(
+            `@Positive`
+            `@Parameter`(description = "태그 ID", required = true) `@PathVariable`(value = "tagId") Long tagId,

-            `@Parameter`(description = "현재 영상 ID") `@RequestParam`(value = "excludeMediaId", required = false) Long excludeMediaId,
+            `@Positive`
+            `@Parameter`(description = "현재 영상 ID") `@RequestParam`(value = "excludeMediaId", required = false) Long excludeMediaId,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java`
around lines 45 - 105, Add `@Positive` validation to ID params so negative/zero
IDs are rejected: annotate the tagId parameter in getTagPlaylists (the
`@PathVariable` Long tagId) with `@Positive`, and annotate all excludeMediaId
`@RequestParam` parameters (seen in getTopTagPlaylists, getTagPlaylists,
getTrendingPlaylists, getHistoryPlaylists, getBookmarkPlaylists,
getSearchPlaylists) with `@Positive` (or `@PositiveOrZero` if zero is allowed) to
enforce input validation; update imports if needed and ensure validation
annotations are applied consistently across these controller method signatures.
🧹 Nitpick comments (4)
modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java (1)

94-100: ConstraintViolationException 핸들러 추가는 적절합니다.

@Validated 애노테이션 기반 메서드 파라미터 검증 실패 시 발생하는 예외를 처리하여 400 응답을 반환하는 것은 올바른 접근입니다.

다만, MethodArgumentNotValidException 핸들러(Line 36)가 ex.getBindingResult()를 사용하여 구조화된 필드별 에러 정보를 제공하는 것과 달리, 이 핸들러는 ex.getMessage()만 사용합니다. 클라이언트에게 더 유용한 에러 응답을 제공하려면 getConstraintViolations()를 활용하여 필드별 에러 정보를 추출하는 것을 고려해 보세요.

♻️ 구조화된 에러 응답을 위한 개선 예시
 `@ExceptionHandler`(ConstraintViolationException.class)
 protected ResponseEntity<ErrorResponse> handleConstraintViolationException(ConstraintViolationException ex) {
     log.warn("ConstraintViolationException: {}", ex.getMessage());
-    // C001 에러 코드를 사용하여 400 Bad Request 응답 생성
-    ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT, ex.getMessage());
+    ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT, ex.getConstraintViolations());
     return ResponseEntity.badRequest().body(response);
 }

이 방식을 사용하려면 ErrorResponse.of() 메서드에 Set<ConstraintViolation<?>> 파라미터를 받는 오버로드가 필요합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java`
around lines 94 - 100, The current
handleConstraintViolationException(ConstraintViolationException ex) only logs
ex.getMessage() and returns ErrorResponse.of(ErrorCode.INVALID_INPUT,
ex.getMessage()); update it to extract structured field-level errors from
ex.getConstraintViolations() and pass them into the response; add or use an
overloaded ErrorResponse.of(ErrorCode, Set<ConstraintViolation<?>>) (or map the
violations to a simple field->message map/list) and call that from
handleConstraintViolationException so the client receives per-field error
details (use ConstraintViolation<?>.getPropertyPath() and .getMessage() to build
the structured payload).
modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java (2)

142-152: 컬렉션 변수명은 List 접미사로 통일하는 것이 좋습니다.

List<Media> 타입 변수 contentmediaList처럼 컬렉션임이 드러나는 이름으로 맞추면 가독성과 규칙 일관성이 좋아집니다.

♻️ 제안 수정안
-                List<Media> content = queryFactory
+                List<Media> mediaList = queryFactory
                                 .selectFrom(media)
                                 ...
-                return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
+                return PageableExecutionUtils.getPage(mediaList, pageable, countQuery::fetchOne);

As per coding guidelines, "Collection variable names with List suffix".

Also applies to: 166-178, 194-206, 223-235

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java`
around lines 142 - 152, Rename the collection variable named content to
mediaList in MediaRepositoryImpl for all QueryDSL query blocks that build
List<Media> via queryFactory.selectFrom(media) (i.e., replace local variable
content with mediaList in the query block, subsequent uses, and any
return/assignment that references it); apply the same rename to the other
similar List<Media> variables flagged (the other queryFactory.selectFrom(media)
blocks) so collection names consistently end with List and update any dependent
code in the same method to use mediaList.

292-300: 추천 쿼리의 태그 스캔 범위를 줄이는 최적화를 권장합니다.

현재는 mediaTag 전체 조인 후 CASE 합산을 수행하므로 트래픽 증가 시 비용이 커질 수 있습니다. tagScores의 키 집합으로 태그 범위를 먼저 좁히는 편이 유리합니다.

⚡ 제안 수정안
+                List<Long> preferredTagIdList = List.copyOf(tagScores.keySet());
                 return queryFactory.selectFrom(media)
                                 .join(mediaTag).on(mediaTag.media.id.eq(media.id))
                                 .where(
                                                 media.status.eq(Status.ACTIVE),
                                                 media.publicStatus.eq(PublicStatus.PUBLIC),
+                                                mediaTag.tag.id.in(preferredTagIdList),
                                                 excludeMediaId != null ? media.id.ne(excludeMediaId) : null)
                                 .groupBy(media.id)
                                 .orderBy(scoreExpression.sum().desc(), media.id.desc())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java`
around lines 292 - 300, The current query in MediaRepositoryImpl scans all
mediaTag rows then sums CASE expressions (scoreExpression); narrow that scan by
pre-filtering mediaTag to only tags present in tagScores: compute the set/list
of tag keys from tagScores.keySet(), and change the join condition on mediaTag
(the join(mediaTag).on(...)) to include mediaTag.tag.name.in(tagKeys) (or return
early if tagKeys is empty) so the database only examines relevant tag rows
before grouping and ordering by scoreExpression.sum().desc().
apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java (1)

74-79: Fallback에서 전체 태그 로딩은 트래픽 증가 시 비용이 큽니다.

findAll()+shuffle 대신 DB에서 랜덤 3건만 조회하거나, 캐시된 후보군에서 샘플링하는 방식으로 바꾸는 것을 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java`
around lines 74 - 79, The fallback branch in PlaylistPreferenceService currently
loads all tags with tagRepository.findAll() and shuffles them (when
topTagIds.isEmpty()), which is expensive; replace this with either (A) a
repository-level random sampling method (e.g., add TagRepository method like
findRandomTags(int limit) implemented with a DB-side random/order-by-rand or
TABLESAMPLE query to return exactly 3 rows) or (B) sample 3 tags from a cached
candidate pool (e.g., cachedTagPool or a new in-memory/cached list refreshed
periodically) and return those; update the PlaylistPreferenceService fallback to
call the new TagRepository.findRandomTags(3) or the cache sampler instead of
findAll()+shuffle.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylisStrategytService.java`:
- Around line 59-70: getTopTagPlaylistWithMetadata currently calls
getPlaylists(condition, pageable) before computing the target tag, so the
condition.tagId is never set and the TAG strategy query can be wrong; update the
method (getTopTagPlaylistWithMetadata) to first retrieve topTags via
preferenceService.getTopTags(condition.getMemberId()), check
condition.getIndex() bounds, call condition.setTagId(targetTag.getId()) when a
targetTag exists, then call getPlaylists(condition, pageable) to obtain
mediaPage; keep existing variables TopTagPlaylistResponse.CategoryInfo and
TagInfo logic after mediaPage and ensure you handle null/empty topTags safely.

In
`@modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java`:
- Around line 16-19: The JPQL in MediaTagRepository's findTagIdsByMediaIds is
comparing mt.media (an entity) to a List<Long>, causing a runtime type error;
update the query to use mt.media.id in the WHERE IN clause (i.e., compare
mt.media.id to :mediaIds) so the parameter List<Long> matches the field type.

---

Duplicate comments:
In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java`:
- Around line 45-105: Add `@Positive` validation to ID params so negative/zero IDs
are rejected: annotate the tagId parameter in getTagPlaylists (the `@PathVariable`
Long tagId) with `@Positive`, and annotate all excludeMediaId `@RequestParam`
parameters (seen in getTopTagPlaylists, getTagPlaylists, getTrendingPlaylists,
getHistoryPlaylists, getBookmarkPlaylists, getSearchPlaylists) with `@Positive`
(or `@PositiveOrZero` if zero is allowed) to enforce input validation; update
imports if needed and ensure validation annotations are applied consistently
across these controller method signatures.

In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java`:
- Around line 22-25: Add controller-level role-based access to
PlaylistController by annotating the class (PlaylistController) so all endpoints
implementing PlaylistApi require one of roles USER, EDITOR, or ADMIN; update the
class annotations to use your project's security annotation (e.g.,
`@PreAuthorize`("hasAnyRole('USER','EDITOR','ADMIN')") or
`@RolesAllowed`({"USER","EDITOR","ADMIN"}") depending on configured security) and
ensure the necessary security imports are added so every method in
PlaylistController enforces the USER/EDITOR/ADMIN check.

---

Nitpick comments:
In
`@apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java`:
- Around line 74-79: The fallback branch in PlaylistPreferenceService currently
loads all tags with tagRepository.findAll() and shuffles them (when
topTagIds.isEmpty()), which is expensive; replace this with either (A) a
repository-level random sampling method (e.g., add TagRepository method like
findRandomTags(int limit) implemented with a DB-side random/order-by-rand or
TABLESAMPLE query to return exactly 3 rows) or (B) sample 3 tags from a cached
candidate pool (e.g., cachedTagPool or a new in-memory/cached list refreshed
periodically) and return those; update the PlaylistPreferenceService fallback to
call the new TagRepository.findRandomTags(3) or the cache sampler instead of
findAll()+shuffle.

In
`@modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java`:
- Around line 94-100: The current
handleConstraintViolationException(ConstraintViolationException ex) only logs
ex.getMessage() and returns ErrorResponse.of(ErrorCode.INVALID_INPUT,
ex.getMessage()); update it to extract structured field-level errors from
ex.getConstraintViolations() and pass them into the response; add or use an
overloaded ErrorResponse.of(ErrorCode, Set<ConstraintViolation<?>>) (or map the
violations to a simple field->message map/list) and call that from
handleConstraintViolationException so the client receives per-field error
details (use ConstraintViolation<?>.getPropertyPath() and .getMessage() to build
the structured payload).

In
`@modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java`:
- Around line 142-152: Rename the collection variable named content to mediaList
in MediaRepositoryImpl for all QueryDSL query blocks that build List<Media> via
queryFactory.selectFrom(media) (i.e., replace local variable content with
mediaList in the query block, subsequent uses, and any return/assignment that
references it); apply the same rename to the other similar List<Media> variables
flagged (the other queryFactory.selectFrom(media) blocks) so collection names
consistently end with List and update any dependent code in the same method to
use mediaList.
- Around line 292-300: The current query in MediaRepositoryImpl scans all
mediaTag rows then sums CASE expressions (scoreExpression); narrow that scan by
pre-filtering mediaTag to only tags present in tagScores: compute the set/list
of tag keys from tagScores.keySet(), and change the join condition on mediaTag
(the join(mediaTag).on(...)) to include mediaTag.tag.name.in(tagKeys) (or return
early if tagKeys is empty) so the database only examines relevant tag rows
before grouping and ordering by scoreExpression.sum().desc().

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1b3e9de3-7124-455b-824b-c49a8a9a5b2d

📥 Commits

Reviewing files that changed from the base of the PR and between 29a8052 and c829ac8.

📒 Files selected for processing (10)
  • apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistApi.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/controller/PlaylistController.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylisStrategytService.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/PlaylistPreferenceService.java
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java
  • modules/common-web/src/main/java/com/ott/common/web/exception/GlobalExceptionHandler.java
  • modules/domain/src/main/java/com/ott/domain/likes/repository/LikesRepository.java
  • modules/domain/src/main/java/com/ott/domain/media/repository/MediaRepositoryImpl.java
  • modules/domain/src/main/java/com/ott/domain/media_tag/repository/MediaTagRepository.java
  • modules/domain/src/main/java/com/ott/domain/playback/repository/PlaybackRepository.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api-user/src/main/java/com/ott/api_user/playlist/service/strategy/TagPlaylistStrategy.java

@yubin012 yubin012 merged commit bc10288 into develop Mar 4, 2026
1 check passed
@phonil phonil deleted the OT-152-feature/recommend-logic-playlist branch April 4, 2026 09:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 새로운 기능 구현

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OT-152] [FEAT]: 홈/상세 페이지 맞춤형 추천 플레이리스트 분리 및 태그 기반(Rule-based) 개인화 로직 구현

2 participants