Skip to content

feat: 콘텐츠 관리 - 사전 클래스 작성 및 콘텐츠 등록#111

Merged
plzslp merged 33 commits into
devfrom
feature/5-content-posting
Jun 24, 2026
Merged

feat: 콘텐츠 관리 - 사전 클래스 작성 및 콘텐츠 등록#111
plzslp merged 33 commits into
devfrom
feature/5-content-posting

Conversation

@plzslp

@plzslp plzslp commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

관련 이슈

작업 내용

  • 콘텐츠 타입, 제목, 설명, 태그 목록을 입력하여 콘텐츠를 등록합니다. (어드민 전용)
  • Content 엔티티, Tag 엔티티, ContentTag 엔티티, ContentStats 엔티티 작성
  • ContentCreateRequest, ContentResponse DTO 클래스 작성
  • Mapper, Repository 클래스 작성
  • 콘텐츠 생성 Controller, Service 로직을 작성하고 테스트 코드를 작성했습니다.
  • schema.sql V3, V4가 추가되었습니다. (contents 테이블 필드 수정)

변경 사항

  • build.gradle의 Swagger UI 버전을 현재 스프링 부트 버전에 맞게 수정했습니다.
  • schema.sql V3, V4가 추가되었습니다. (contents 테이블 필드 수정)

테스트 내용

  • 테스트 코드 작성
  • 로컬 테스트 완료
  • API 테스트 완료
  • 기타 테스트 완료

체크리스트

  • 코드 컨벤션을 지켰습니다.
  • 불필요한 코드를 제거했습니다.
  • 주석이 필요한 부분에 추가했습니다.
  • 관련 문서를 수정했습니다.
  • 리뷰어가 이해하기 쉽도록 작성했습니다.

리뷰 포인트

  • 원래는 사전 클래스 작성 이후 PR을 올리려 했으나, PR이 남아있어서 콘텐츠 등록 작업까지 이어서 진행하다보니 작업량이 많아졌습니다. 리뷰하시는 분 힘드시겠지만 잘 부탁드리겠습니다.

스크린샷 / 참고 자료

  • 필요한 경우 첨부해주세요.

🔗 관련 이슈

  • close #97
  • close #5
  • close #100

📝 작업 내용

기존에는 “콘텐츠 생성 요청 → DB 저장 → 태그 매핑 → 통계 초기화 → 응답 반환”까지 이어지는 계층(도메인/DTO/매퍼/리포지토리/컨트롤러/서비스)과 그에 대한 테스트 기준이 없어, 기능을 실제로 완성하기 위한 기반이 부족했습니다. 이 PR은 콘텐츠 관리의 프리퀴지트 모델을 먼저 정리한 뒤, 관리자 전용 콘텐츠 등록 API와 end-to-end 데이터 흐름(태그/통계 포함)을 구현하고 테스트로 보완하는 것을 목표로 했습니다.

주요 변경사항

  1. 도메인 모델을 JPA 엔티티로 분리해 책임을 고정

    • Content, Tag, ContentTag(조인/복합키), ContentStats(1:1 통계)로 역할을 나누고 연관관계를 명확히 했습니다.
    • Contenttype/source는 문자열 대신 ContentType, ContentSource enum으로 정합성을 맞추고(EnumType.STRING), metadata는 JSONB 저장을 위해 String + @JdbcTypeCode로 변경했습니다.
    • Content에서는 기존 updatedAt를 제거하고, 통계 갱신 시각은 ContentStats.updatedAt으로 일원화했습니다.
    • contents 테이블에 source + external_id 유니크 제약을 두어 외부/관리자 소스 데이터의 중복 가능성을 제한했습니다.
  2. 멀티파트 기반 API 계약을 확정하고, 서비스에서 “생성→태그 정규화→매핑→통계”를 한 번에 처리

    • /api/contents POSTContentController에서 제공하고, ContentCreateRequest(JSON 파트)와 thumbnail(선택 파트)을 함께 받도록 계약을 구성했습니다.
    • Swagger 문서화를 위해 ContentApi를 추가하고, 요청 파트(request, thumbnail)의 노출을 숨기도록 설계했습니다.
    • ContentService.create()는 다음 흐름으로 동작합니다.
      • Content.createByAdmin(...)으로 콘텐츠 먼저 저장
      • 요청 태그를 trim → 소문자화 → distinct로 정규화하고, 정규화 후 공백/빈 값만 남으면 즉시 입력 오류로 중단
      • TagRepository.findByNameIn(...)로 기존 태그를 재사용하고, 없으면 Tag.create(...)로 생성 후 저장
      • Content.addTag()를 통해 ContentTag 연관(복합키) 구성
      • ContentStats.create(content)로 통계 레코드를 초기화해 저장
      • ContentMapperContentResponse(태그명/평균평점 계산 포함) 생성
    • 썸네일은 현재 로직에서 실제 저장/URL 반영이 수행되지 않고(입력 수신만 존재), TODO 트레이드오프가 남아 있습니다.
  3. 예외/에러코드 체계를 확장하고, 컨트롤러/서비스 테스트로 계약을 고정

    • 콘텐츠/태그 관련 예외를 도메인별로 추가했습니다.
      • 콘텐츠: ContentException 계층, InvalidContentTypeException, InvalidContentSourceException, ContentNotFoundException
      • 태그: TagException 계층, InvalidTagNameException
    • 전역 ErrorCodeINVALID_TAG_NAME을 추가해 400 응답 메시지 체계를 보강했습니다.
    • ContentControllerTest, ContentServiceTest를 추가/확장해:
      • 정상 생성(썸네일 null 포함), 입력 검증 실패(타입/제목/태그), 서비스 예외 시 500 매핑 등을 커버했습니다.
    • 정량 지표(추가/변경된 테스트 케이스 수, 총 라인 수)는 이번 PR에서 정확히 집계하지 않음으로 처리합니다. 다만 테스트 파일은 2개(컨트롤러/서비스) 추가되었습니다.

📊 변경 효과 요약 (가능한 경우에만)

  • 추가된 API/서비스 테스트: 2개 파일 (ContentControllerTest, ContentServiceTest)
  • 추가된 DB 마이그레이션: 2개 (V3, V4)
  • Swagger UI 문서 호환성: springdoc-openapi-starter-webmvc-ui 버전 업데이트 (2.6.0 → 2.8.8)
  • 콘텐츠 관리 관련 핵심 새 구성요소: 엔티티 4종 + DTO 2종 + 매퍼/리포지토리/서비스/컨트롤러/예외/에러코드 확장

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Content 엔티티, Tag, ContentTag, ContentStats, ContentType/ContentSource 열거형을 새로 정의하여 콘텐츠 도메인 기반을 구성합니다. Content는 admin/external 두 팩토리로 생성 규칙을 분리하고, addTag()로 중복 제거 연관을 지원합니다. 예외 계층(ContentException, TagException 및 각 특화 예외), DTO/Mapper, Repository, OpenAPI 계약을 구현한 후, multipart POST 엔드포인트(/api/contents)를 제공하는 ContentController와 태그 정규화·저장 로직을 담당하는 ContentService를 추가합니다. 데이터베이스 마이그레이션 2건과 기존 Watcher 테스트 갱신이 포함되며, springdoc 의존성을 2.8.8로 업데이트합니다.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes


주요 리뷰 포인트 및 개선 고려사항

1. Content 팩토리 메서드: 책임 분리의 명확화

현재 설계의 강점:

  • createByAdmin() vs createByExternalSource() 이원화로 생성 규칙이 명확하게 분리됨
  • ✅ admin 콘텐츠는 externalId 자동 생성, 외부 소스는 검증 강제로 각자의 책임을 수행
  • ✅ 팩토리 선택으로 의도를 코드에 드러냄

향후 확장성 관점:

현재는 두 가지 경로가 명확하지만, 만약 파트너별 규칙(예: "넷플릭스 콘텐츠는 특정 타임존 적용", "유튜브 콘텐츠는 메타데이터 형식 다름")이 추가된다면 선형적 증가 위험이 있습니다.

개선 제안:

  • 현재 단계에서는 이 설계가 최적입니다
  • 다만 메서드 Javadoc을 구체화하면 향후 개발자의 선택이 명확해집니다:
/**
 * ADMIN이 직접 등록하는 콘텐츠용 팩토리.
 * - source는 항상 ADMIN으로 설정됨 (변경 불가)
 * - externalId는 UUID 기반으로 자동 생성됨
 * 
 * 외부 API(TMDB, 스포츠DB 등)에서 주입된 콘텐츠는
 * createByExternalSource()를 사용하세요.
 */
public static Content createByAdmin(ContentType type, String title, String description)

향후 요구사항이 증가하면 그때 빌더 패턴 도입을 검토하면 충분합니다.


2. ContentTag 복합 키 생성: 불완전한 상태의 위험

현재 코드:

public static ContentTag create(Content content, Tag tag) {
    ContentTagId id = new ContentTagId();  // ← no-arg 생성
    // ... 이후 setter로 필드 채움
    contentTag.setId(id);
    contentTag.setContent(content);
    contentTag.setTag(tag);
    return contentTag;
}

우려사항:

  • new ContentTagId()로 생성한 직후 contentId/tagId 필드가 설정될 때까지 객체가 불완전한 상태
  • 현재는 Hibernate의 동적 프록시와 reflection이 유연하게 처리하지만, 향후 불변성(immutable) 설계로 마이그레이션할 때 리팩토링 비용이 증가함

더 견고한 방식:

이미 ContentTagId(UUID contentId, UUID tagId) 생성자가 있다면, 이를 활용하세요:

public static ContentTag create(Content content, Tag tag) {
    ContentTagId id = new ContentTagId(content.getId(), tag.getId());
    ContentTag contentTag = new ContentTag();
    contentTag.setId(id);
    contentTag.setContent(content);
    contentTag.setTag(tag);
    return contentTag;
}

이렇게 하면 CompleteTagId가 생성되는 순간부터 모든 필드가 설정된 상태가 되어, 중간 상태를 걱정할 필요가 없습니다.


3. Content.addTag() 중복 검사: 이원적 판정의 부작용

현재 로직:

boolean isDuplicate = contentTags.stream().anyMatch(ct -> {
    if (ct.getTag().getId() != null) {
        return ct.getTag().getId().equals(tag.getId());  // id로 비교
    } else {
        return ct.getTag().getName().equals(tag.getName());  // name으로 비교
    }
});

문제점:
판정 기준이 Tag.id 존재 여부에 의존하므로, id 생성 타이밍이 달라지면 같은 논리적 태그가 중복으로 인식될 수 있습니다.

잠재적 시나리오:

  1. Tag A(id=null, name="music") 생성, 미저장
  2. content.addTag(A) → name 비교, 추가됨
  3. Tag A 저장 (id 할당)
  4. 같은 Tag A를 content.addTag(A) 다시 호출 → 이제 id로 비교되어 다른 객체로 인식 가능

개선 선택지:

선택지 1: 항상 ID로 비교 (권장)

boolean isDuplicate = contentTags.stream()
    .anyMatch(ct -> ct.getTag().getId() != null && 
                    ct.getTag().getId().equals(tag.getId()));
  • 장점: ID가 유일하므로 정확함
  • 단점: 저장되지 않은 Tag는 추가 불가능 (하지만 현재 로직에서 태그는 항상 저장 후 추가)

선택지 2: 항상 Name으로 비교

boolean isDuplicate = contentTags.stream()
    .anyMatch(ct -> ct.getTag().getName().equalsIgnoreCase(tag.getName()));
  • 장점: name이 태그의 비즈니스 정체성이므로 직관적
  • 단점: 향후 태그 id 변경 시나리오 미처리

선택지 3: 현재 방식 유지 + 명문화

현재 코드는 실제로 안전합니다. 왜냐하면 ContentService.create()에서 항상 tagRepository.save()로 태그를 저장 후content.addTag()를 호출하기 때문입니다.

다만 Javadoc을 명확히 하면 좋습니다:

/**
 * 태그를 콘텐츠에 추가합니다.
 * 
 * 중복 검사 규칙:
 * - Tag.id가 할당되었으면 id로 중복 판정
 * - Tag.id가 null이면 name으로 중복 판정
 * 
 * 주의: 기존 콘텐츠의 태그는 반드시 저장된 상태여야 합니다.
 * (즉, 항상 id가 할당된 상태에서 호출될 것을 전제)
 */
public void addTag(Tag tag) { ... }

권장사항:
지금은 동작하지만, 코드 리뷰 중에 "항상 저장된 태그만 추가하는가?" 를 명시하고, 향후 리팩토링 시 선택지 1(항상 ID 비교)로 단순화하세요.


4. ContentService.create(): thumbnail 파라미터의 역할 불명

현재 상황:

public ContentResponse create(ContentCreateRequest request, MultipartFile thumbnail) {
    // ... 콘텐츠 및 태그 처리
    
    // TODO: thumbnail 업로드 및 URL 설정
    
    return contentMapper.toDto(content, content.getContentTags(), stats);
}

명확히 해야 할 점:

  1. 이것이 의도적 설계인가? (향후 PR에서 구현)

    • 그렇다면 TODO를 구체화하세요:
      // TODO: (`#XXX`) S3/로컬 파일 저장 및 URL 설정
      // - MultipartFile 유효성 검증 (크기, MIME 타입)
      // - S3 업로드 또는 로컬 저장소에 저장
      // - presigned URL 또는 직접 URL 생성
      // - Content.thumbnailUrl에 URL 설정
  2. 현재 단계에서 처리해야 하나?

    • PR 범위를 명확히 하세요: "메타데이터 등록만 하고, 파일은 향후"라면 문제없음
    • 다만 API 계약의 @RequestPart(required=false)로 선택사항임을 명시했으므로 현재 설계가 일관됨
  3. 테스트 관점:

    • ContentControllerTestthumbnail을 multipart로 전송하지만 서비스에서 처리되지 않음
    • 향후 구현 시 테스트를 미리 설계하세요:
      `@Test`
      void create_with_thumbnail_upload() {
          // ... verify(fileUploadService).upload(thumbnail);
      }

평가:
현재는 기능적으로 문제없습니다. (thumbnail은 선택사항) 다만 향후 개발자를 위해 TODO를 구체화하면 좋습니다.


5. 태그 정규화 로직: 도메인 규칙인가, 입력 처리인가?

현재 설계:

ContentService.create()에서 수행:

List<String> normalizedTags = request.getTags().stream()
    .map(String::trim)
    .map(String::toLowerCase)
    .distinct()
    .collect(toList());

설계 의도 검토:

  • 이것이 도메인 규칙인가? (태그는 항상 소문자, 공백 제거)
  • 아니면 요청 처리 편의성인가? (사용자 입력을 정리)

문제점:

  1. 같은 정규화 로직이 여러 곳에서 필요할 때 분산될 가능성
  2. Tag.create(String name)과의 일관성 부족: 생성 시 유효성 검사만 하고, 정규화는 Service에서 수행

더 나은 설계:

방식 1: 도메인 규칙으로 격상 (권장)

public class Tag {
    public static String normalize(String name) {
        return name != null ? name.trim().toLowerCase() : "";
    }
    
    public static Tag create(String name) {
        String normalized = normalize(name);
        if (normalized.isBlank() || normalized.length() > 50) {
            throw new InvalidTagNameException(...);
        }
        return new Tag(normalized);  // 정규화된 name 저장
    }
}

// ContentService에서:
List<String> tagNames = request.getTags().stream()
    .map(Tag::normalize)
    .distinct()
    .collect(toList());

방식 2: 유틸 클래스 분리

public class TagNormalizer {
    public static String normalize(String name) {
        return name != null ? name.trim().toLowerCase() : "";
    }
}

방식 3: 현재 유지

  • 현재도 기능적으로 문제없습니다
  • 다만 "태그 정규화가 항상 필수인가?"를 팀 내에서 합의하세요

권장사항:
코드 리뷰 중에 "태그 정규화(소문자, 공백 제거)는 항상 필수인가?" 를 확인하고, "Yes"라면 방식 1로 리팩토링하세요. 이렇게 하면 향후 다른 서비스에서도 태그를 생성할 때 같은 규칙을 자동으로 적용할 수 있습니다.


6. ContentMapper null 안전성: 명문화의 가치

현재 구현:

default List<String> toTagNames(List<ContentTag> contentTags) {
    return contentTags == null ? Collections.emptyList() : ...;
}

default double toAverageRating(ContentStats stats) {
    return stats == null ? 0.0 : ...;  // ← `@OneToOne`(optional=false)인데 null 체크?
}

설계 평가:

  • contentTags: @OneToMany → null 가능성 낮지만 방어 프로그래밍은 좋음
  • stats: @OneToOne(optional=false) → null 불가능하지만 미래 유연성 관점에서 긍정적

개선:

의도를 Javadoc으로 명문화하면 향후 리팩토링이 수월합니다:

/**
 * 통계에서 평균 평점을 계산합니다.
 * 
 * `@param` stats 콘텐츠 통계 (선택사항, 방어 프로그래밍)
 * `@return` 평균 평점. stats 미존재 또는 리뷰 0개이면 0.0
 */
default double toAverageRating(ContentStats stats) {
    if (stats == null || stats.getReviewCount() == 0) {
        return 0.0;
    }
    return (double) stats.getRatingSum() / stats.getReviewCount();
}

평가:
현재 코드는 매우 견고합니다. null 방어가 명시적이므로 향후 타입 변경도 쉽습니다.


7. 테스트 커버리지: 충실하나, 엔티티 경계 케이스 보충 가능

현재 테스트의 강점:

  • ✅ Happy path: 썸네일 포함/미포함
  • ✅ 검증 실패: type/title/tags 각각의 에러 메시지 검증
  • ✅ 서비스 예외 처리
  • ✅ 태그 저장: 기존/신규/중복 횟수 verify
  • ✅ 정규화: trim/toLowerCase 적용 확인

보충할 가치 있는 경계 케이스:

  1. ContentType 유효성

    `@Test`
    void create_with_invalid_content_type() {
        // type="invalid" → 400 INVALID_INPUT 또는 invalid value 에러
    }
  2. Tag 길이 제약

    `@Test`
    void create_with_tag_exceeding_max_length() {
        // tag가 51자 이상 → ContentServiceTest에서 InvalidTagNameException 검증
    }
  3. Content.addTag() 엔티티 레벨 테스트

    `@Test`
    void addTag_prevents_duplicate_by_id() {
        Content content = Content.createByAdmin(...);
        Tag tag = ... // 저장된 tag
        content.addTag(tag);
        content.addTag(tag);  // 같은 id
        
        assertThat(content.getContentTags()).hasSize(1);  // 중복 제거 검증
    }

평가:
통합 테스트와 주요 에러는 충분히 커버됩니다. 엔티티 로직(addTag, Tag.create)의 경계 케이스는 유닛 테스트로 보충할 가치가 있습니다.


종합 의견

이 PR의 주요 강점

  1. 아키텍처가 명확함: 두 팩토리 메서드로 생성 규칙을 깔끔하게 분리
  2. 테스트가 충실함: happy path에서 경계 케이스까지 주요 시나리오 커버
  3. 도메인 설계가 견고함: 복합 키, 일대다 관계, 통계 엔티티 모두 적절히 구성
  4. 예외 처리가 일관됨: 도메인별 예외(ContentException, TagException) 계층화

현 단계에서 개선할 여지

우선순위 항목 현재 상태 개선 제안
낮음 ContentTag 복합 키 생성 no-arg 후 setter new ContentTagId(id1, id2) 활용
낮음 Content.addTag() 중복 검사 이원적 판정 Javadoc 명문화 (현재 안전)
낮음 태그 정규화 로직 Service 레벨 향후 Tag.normalize() 검토
낮음 thumbnail TODO 미구현 TODO 구체화 (현재 범위 외)
낮음 Mapper null 체크 명시적 Javadoc으로 의도 표현
매우 낮음 테스트 경계 케이스 통합 테스트 주 엔티티 유닛 테스트 선택사항

최종 평가

이 PR은 매우 건실한 기초를 제공합니다. 설계가 명확하고, 위의 개선 항목들은 모두 선택사항입니다. 긴급한 버그나 구조적 결함은 없으며, 오히려 좋은 학습 기회가 될 수 있는 코드입니다.

코드 리뷰 중에 위의 고려사항들을 팀과 논의하면서 설계 의도를 공유하면, 향후 유지보수 시 같은 결정을 일관되게 적용할 수 있을 것입니다.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.27% 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
Title check ✅ Passed 제목 '콘텐츠 관리 - 사전 클래스 작성 및 콘텐츠 등록'은 PR의 핵심 변경사항(엔티티/DTO 등 사전 클래스와 콘텐츠 등록 기능 구현)을 명확하게 요약하고 있습니다.
Description check ✅ Passed PR 설명이 템플릿 구조를 따르며, 관련 이슈, 작업 내용, 변경 사항, 테스트 현황을 구체적으로 기술하고 있습니다.
Linked Issues check ✅ Passed 이 PR은 연결된 세 이슈(#97, #5, #100)의 모든 요구사항을 충족합니다. Content, Tag, ContentTag, ContentStats 엔티티 생성[#97], 콘텐츠 등록 엔드포인트 및 비즈니스 로직 구현[#5], 컨트롤러/서비스 테스트 코드 작성[#100]이 완료되었습니다.
Out of Scope Changes check ✅ Passed 모든 변경사항(엔티티, DTO, 컨트롤러, 서비스, 마이그레이션, 테스트)이 콘텐츠 관리 및 등록 기능 구현이라는 명확한 범위 내에 있습니다.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/5-content-posting

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 14

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java`:
- Around line 27-36: The postContent method in ContentController is missing
authorization checks despite being documented as an admin-only endpoint. Add the
`@PreAuthorize`("hasRole('ADMIN')") annotation to the postContent method to
enforce admin-only access at the method level. Additionally, update the
SecurityConfig class to add path-based authorization rules that restrict access
to the /api/contents/** endpoints to users with the ADMIN role, ensuring that
unauthorized requests are rejected before reaching the controller. This
dual-layer approach (path-based and method-level) prevents security gaps when
new endpoints are added.

In `@src/main/java/com/codeit/team5/mopl/content/entity/Content.java`:
- Around line 68-79: The externalId field is declared with nullable = false but
the create() factory method assigns null to externalId when the ContentSource is
not ADMIN (for TMDB and SPORTS_DB sources), which violates the database
constraint. To fix this, modify the create() method signature to accept an
externalId parameter and use the provided value for non-ADMIN sources, or
alternatively create a separate factory method such as createAdminContent() that
generates a random UUID for the ADMIN source while requiring externalId to be
passed explicitly for external sources like TMDB and SPORTS_DB. Either approach
ensures all Content instances have a non-null externalId that satisfies both the
database constraint and the uk_contents_source_external_id unique constraint.
- Around line 81-98: The addTag() method's duplicate detection logic only uses
reference equality when Tag IDs are null, which allows Tags with the same name
but different object instances to be added as duplicates. Enhance the duplicate
check in the anyMatch() stream operation to also compare Tag names when IDs are
null, so that two Tag instances with identical names are correctly identified as
duplicates regardless of whether they have been persisted yet. Update the
condition to check name equality (using equals()) alongside the reference
equality check when both Tags have null IDs.

In `@src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java`:
- Around line 34-35: The `ratingSum` field in the `ContentStats` class is
declared as `double`, which can accumulate floating-point precision errors when
many ratings are aggregated together. Evaluate whether your application's scale
and precision requirements justify changing this field: if you expect large
numbers of ratings to be accumulated, consider using an integer-based
accumulation approach by storing ratings as integers (for example, multiplying
by 10 to preserve one decimal place) and converting back when calculating
averages, or alternatively use `BigDecimal` for arbitrary precision. If the
current use case involves a limited dataset where precision loss is negligible,
you may keep the `double` type, but document this decision. Update the
`ratingSum` field type and adjust any related calculation logic in methods that
update or retrieve this value accordingly.
- Around line 40-41: The updatedAt field in the ContentStats class is not
automatically refreshed when the entity is updated, only when it's first
created. Add a `@PreUpdate` annotated method (a JPA lifecycle callback) to the
ContentStats class that automatically sets updatedAt to Instant.now() whenever
the entity is persisted or updated. This ensures that every time statistics
change, the updatedAt timestamp is refreshed without requiring manual updates in
each method that modifies the statistics.

In `@src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java`:
- Around line 38-44: The create() method in ContentTag is manually assigning the
id field using new ContentTagId(content.getId(), tag.getId()), which conflicts
with the `@MapsId` annotation pattern that is meant to automatically map the IDs
from the associated content and tag entities. Remove the manual id assignment
line and only set the content and tag fields, allowing Hibernate to
automatically populate the ContentTagId through the `@MapsId` mechanism when the
entity is persisted. This aligns with the standard JPA pattern and ensures
single responsibility for ID management.

In
`@src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentTypeException.java`:
- Around line 7-9: The InvalidContentTypeException constructor receives a value
parameter but does not pass it to the parent constructor, resulting in loss of
the specific invalid value information in error responses. Modify the
constructor to pass the value parameter as a detail message to the parent class
constructor call via super(), using the appropriate parent constructor signature
that accepts both ErrorCode and detailMessage parameters to preserve the invalid
content type value for debugging and user feedback.

In `@src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java`:
- Around line 27-30: The toAverageRating method in ContentMapper has a precision
loss issue where integer division may occur if getRatingSum() returns an integer
type. To fix this, cast at least one operand to double in the division operation
on line 29 where stats.getRatingSum() is divided by stats.getReviewCount(),
ensuring floating-point division is performed instead of integer division. This
will preserve decimal values like 2.5 instead of truncating to 2.0.

In `@src/main/java/com/codeit/team5/mopl/content/service/ContentService.java`:
- Line 39: The method in the ContentService class is accepting a MultipartFile
thumbnail parameter but the actual storage upload logic is missing, with only a
TODO comment present at line 39. Either implement the complete thumbnail upload
functionality by integrating with your storage solution (S3, local filesystem,
etc.) to handle the MultipartFile thumbnail parameter and set the thumbnailUrl
in the ContentResponse, or remove the thumbnail parameter from the method
signature and the TODO comment if this feature is not yet ready for this PR.
Ensure consistency between the API contract and the actual implementation.
- Line 41: The distinct() operation on request.tags().stream() at line 41 does
not normalize tag names, allowing duplicate tags like "Action", "action", and "
Action " to be treated as separate tags. Before calling distinct() in the stream
pipeline, add map operations to normalize the tag names by trimming whitespace
with String::trim and converting to lowercase with String::toLowerCase. This
ensures semantically identical tags are recognized as duplicates regardless of
case or surrounding whitespace, improving data consistency for tag search and
filtering operations.
- Around line 41-45: The tag processing in ContentService uses stream().map()
with individual findByName() calls for each tag, causing an N+1 query problem
where each tag lookup executes a separate SELECT query. To fix this, add a
findByNameIn(List<String> names) method to TagRepository that fetches all tags
matching the given names in a single query using an IN clause, then refactor the
stream processing to first collect all tag names, call findByNameIn once to get
existing tags, filter for missing tags, save them in batch, and finally add all
tags to the content. This reduces the query count from N individual selects plus
M inserts to just one select and one batch insert operation.

In `@src/main/java/com/codeit/team5/mopl/tag/entity/Tag.java`:
- Around line 27-31: Add parameter validation to the create() method in the Tag
class to check the name parameter before creating the Tag object. The validation
should check that name is not null, not empty, and does not exceed 50 characters
in length. If any of these conditions fail, throw an appropriate exception (such
as IllegalArgumentException) with a clear message that describes the validation
requirement. This ensures invalid data is rejected at the domain layer rather
than failing later at the database constraint level, providing clearer error
messages and easier debugging.

In
`@src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java`:
- Line 98: Remove the TODO comment at line 98 in the ContentControllerTest class
that mentions the missing thumbnail processing logic. Instead of leaving inline
TODO comments, create a separate issue in your issue tracking system to track
the implementation of the thumbnail processing feature. This approach keeps the
codebase clean and ensures future work items are properly prioritized and
managed in a centralized location rather than scattered as comments throughout
the code.

In `@src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java`:
- Line 153: The issue is that `thenReturn(any())` is incorrect Mockito usage
since `any()` is an ArgumentMatcher and cannot be used as a return value. In the
`when(contentMapper.toDto(any(Content.class), any(),
any(ContentStats.class))).thenReturn(any())` statement, replace
`thenReturn(any())` with either `thenReturn(null)` if the return value is not
used by the test, or with `thenReturn()` followed by creating an actual response
object that represents what the mocked method should return. Apply this same fix
to the identical pattern that appears on lines 177 and 201 as well.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f0d50dad-0c54-4c93-a7d0-3c7503c262ce

📥 Commits

Reviewing files that changed from the base of the PR and between 33e0c39 and ed94d85.

📒 Files selected for processing (23)
  • build.gradle
  • src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java
  • src/main/java/com/codeit/team5/mopl/content/controller/api/ContentApi.java
  • src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java
  • src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java
  • src/main/java/com/codeit/team5/mopl/content/entity/Content.java
  • src/main/java/com/codeit/team5/mopl/content/entity/ContentSource.java
  • src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java
  • src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java
  • src/main/java/com/codeit/team5/mopl/content/entity/ContentType.java
  • src/main/java/com/codeit/team5/mopl/content/exception/ContentException.java
  • src/main/java/com/codeit/team5/mopl/content/exception/ContentNotFoundException.java
  • src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentTypeException.java
  • src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java
  • src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java
  • src/main/java/com/codeit/team5/mopl/content/repository/ContentStatsRepository.java
  • src/main/java/com/codeit/team5/mopl/content/service/ContentService.java
  • src/main/java/com/codeit/team5/mopl/tag/entity/Tag.java
  • src/main/java/com/codeit/team5/mopl/tag/repository/TagRepository.java
  • src/main/resources/db/migration/V3__modify_watcher_count_type.sql
  • src/main/resources/db/migration/V4__add_admin_content_source.sql
  • src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java
  • src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java

Comment thread src/main/java/com/codeit/team5/mopl/content/entity/Content.java Outdated
Comment thread src/main/java/com/codeit/team5/mopl/content/entity/Content.java
Comment thread src/main/java/com/codeit/team5/mopl/content/service/ContentService.java Outdated
Comment thread src/main/java/com/codeit/team5/mopl/content/service/ContentService.java Outdated
Comment thread src/main/java/com/codeit/team5/mopl/tag/entity/Tag.java
Comment thread src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java Outdated
@plzslp plzslp force-pushed the feature/5-content-posting branch from ed94d85 to fe9fcfc Compare June 24, 2026 01:38

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

♻️ Duplicate comments (1)
src/main/java/com/codeit/team5/mopl/content/service/ContentService.java (1)

41-41: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

썸네일 파라미터가 계약에 포함됐지만 실제 반영 로직이 없습니다.

현재는 업로드 파일을 받아도 저장/URL 매핑이 없어 응답 thumbnailUrl이 항상 비게 됩니다.
선택지는 두 가지입니다:

  • 이번 PR에서 구현 완료: 스펙과 동작 일치(장점), 구현/연동 비용 큼(단점)
  • API 계약에서 임시 제외: 릴리즈 리스크 축소(장점), 기능 범위 축소 합의 필요(단점)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/codeit/team5/mopl/content/service/ContentService.java` at
line 41, The TODO comment in ContentService indicates that thumbnail storage
upload and URL reflection functionality is incomplete, yet the thumbnailUrl
parameter is part of the API contract, resulting in empty thumbnail URLs in
responses. To fix this, you must either: (1) implement the complete thumbnail
storage upload logic by capturing the uploaded file, storing it to the
designated storage service, retrieving the generated URL, and mapping it to the
thumbnailUrl field in the response object, or (2) remove the thumbnailUrl
parameter from the API contract/DTO if deferring this feature to a future
release. Coordinate with your team to decide which approach aligns with the
sprint goals, then either complete the ContentService implementation or remove
the thumbnail-related fields from the API specification and relevant DTOs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java`:
- Around line 18-20: The `tags` field in the ContentCreateRequest DTO only
validates the list itself but not individual tag elements, allowing
whitespace-only tags like ["   "] to pass validation. This breaks the API
contract when the service trims and removes empty tags. Add element-level
validation by annotating each String element in the List with `@NotBlank` to
ensure each tag is not null, empty, or just whitespace. This separates
validation responsibility at the DTO layer from normalization logic that should
happen in the service layer, preventing the API contract violation.

In `@src/main/java/com/codeit/team5/mopl/content/entity/Content.java`:
- Around line 78-88: The createByExternalSource() method does not validate that
the source parameter is a valid external source (not ADMIN) and that the
externalId parameter is not null or blank, which violates the database schema
constraints and delays error detection until persistence time. Add guard clauses
at the beginning of createByExternalSource() to validate that source is
appropriate for external sources (excluding ADMIN or other internal sources) and
that externalId is not null and not blank, throwing an appropriate exception
(such as IllegalArgumentException) if either constraint is violated to fail fast
and clearly indicate the problem at the call site.

In `@src/main/java/com/codeit/team5/mopl/content/service/ContentService.java`:
- Around line 43-48: After the tag normalization stream operations that create
the tagNames list (which includes trim, filter empty, lowercase, and distinct
operations), add validation to ensure that tagNames is not empty. If tagNames is
empty after normalization, throw an appropriate exception immediately to prevent
content creation with zero tags. This validation should occur in the service
layer right after the tagNames assignment to enforce the domain rule that
content must have at least one valid tag, even if the input initially appears to
contain tags before normalization.

In
`@src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java`:
- Around line 192-216: Add a new test method to validate that tags containing
only whitespace strings (e.g., List.of("   ")) are properly rejected. Create a
separate test similar to postContent_emptyTags_returnsBadRequest but with a
ContentCreateRequest containing whitespace-only tags instead of an empty list,
and verify it returns the same 400 bad request response with the appropriate
error message about empty tag lists. This ensures the validation catches both
completely empty tag lists and lists where tags normalize to empty after
whitespace trimming.

In `@src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java`:
- Around line 101-133: The test method create_withoutThumbnail_success expects
content creation to succeed with a null thumbnail, but this conflicts with the
PR requirement that thumbnail should be a mandatory input field. Either update
the requirement documentation to explicitly mark thumbnail as optional, or
refactor this test to verify that null thumbnail fails with an appropriate
exception or validation error. If choosing the latter, update the
ContentCreateRequest validation logic, the contentService.create method, and
modify the test expectations to verify the failure behavior instead of success.
- Around line 159-205: Add a new test method following the pattern of
create_newTag_savesTag and create_duplicateTags_savesOnce that validates tag
normalization behavior. The test should create a ContentCreateRequest with tags
containing leading/trailing whitespace and mixed case characters (for example, "
액션 " and "액션"), set up the mocks similar to existing tests, and then verify that
the tagRepository.save method is called only once with the normalized tag. This
ensures that the trim and lowercase normalization logic in the content service
is properly tested and prevents regressions.

---

Duplicate comments:
In `@src/main/java/com/codeit/team5/mopl/content/service/ContentService.java`:
- Line 41: The TODO comment in ContentService indicates that thumbnail storage
upload and URL reflection functionality is incomplete, yet the thumbnailUrl
parameter is part of the API contract, resulting in empty thumbnail URLs in
responses. To fix this, you must either: (1) implement the complete thumbnail
storage upload logic by capturing the uploaded file, storing it to the
designated storage service, retrieving the generated URL, and mapping it to the
thumbnailUrl field in the response object, or (2) remove the thumbnailUrl
parameter from the API contract/DTO if deferring this feature to a future
release. Coordinate with your team to decide which approach aligns with the
sprint goals, then either complete the ContentService implementation or remove
the thumbnail-related fields from the API specification and relevant DTOs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b1ccfa9c-3e10-49ca-b040-583d9175438e

📥 Commits

Reviewing files that changed from the base of the PR and between ed94d85 and fe9fcfc.

📒 Files selected for processing (28)
  • build.gradle
  • src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java
  • src/main/java/com/codeit/team5/mopl/content/controller/api/ContentApi.java
  • src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java
  • src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java
  • src/main/java/com/codeit/team5/mopl/content/entity/Content.java
  • src/main/java/com/codeit/team5/mopl/content/entity/ContentSource.java
  • src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java
  • src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java
  • src/main/java/com/codeit/team5/mopl/content/entity/ContentType.java
  • src/main/java/com/codeit/team5/mopl/content/exception/ContentException.java
  • src/main/java/com/codeit/team5/mopl/content/exception/ContentNotFoundException.java
  • src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentTypeException.java
  • src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java
  • src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java
  • src/main/java/com/codeit/team5/mopl/content/repository/ContentStatsRepository.java
  • src/main/java/com/codeit/team5/mopl/content/service/ContentService.java
  • src/main/java/com/codeit/team5/mopl/global/exception/ErrorCode.java
  • src/main/java/com/codeit/team5/mopl/tag/entity/Tag.java
  • src/main/java/com/codeit/team5/mopl/tag/exception/InvalidTagNameException.java
  • src/main/java/com/codeit/team5/mopl/tag/exception/TagException.java
  • src/main/java/com/codeit/team5/mopl/tag/repository/TagRepository.java
  • src/main/resources/db/migration/V3__modify_watcher_count_type.sql
  • src/main/resources/db/migration/V4__add_admin_content_source.sql
  • src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java
  • src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java
  • src/test/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepositoryTest.java
  • src/test/java/com/codeit/team5/mopl/watcher/service/WatchingSessionServiceTest.java

Comment thread src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java Outdated
Comment thread src/main/java/com/codeit/team5/mopl/content/entity/Content.java

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java (1)

13-14: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

title 길이 검증을 DTO에 추가해 DB 컬럼 제약과 맞춰 주세요.

현재는 @NotBlank만 있어 500자 초과 제목이 검증을 통과할 수 있고, 이후 DB 저장 단계에서 실패합니다. 입력 계약을 API 단계에서 명확히 막는 게 장애/500 전파를 줄이는 데 유리합니다.

선택지는 두 가지입니다.

  • 옵션 1 (권장): DTO에 @Size(max = 500) 추가 → 가장 빠르고 일관된 400 응답.
  • 옵션 2: 엔티티 팩토리에서 길이 검증 → 도메인 불변식 강도는 높지만, API 입력 계약 가시성은 상대적으로 낮아짐.
🔧 제안 diff
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotEmpty;
 import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
 import java.util.List;

 public record ContentCreateRequest(
         `@NotNull`(message = "콘텐츠 타입은 필수입니다.")
         ContentType type,

         `@NotBlank`(message = "제목은 필수입니다.")
+        `@Size`(max = 500, message = "제목은 500자 이하여야 합니다.")
         String title,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java`
around lines 13 - 14, The title field in the ContentCreateRequest DTO currently
only validates that it is not blank, but does not enforce the maximum length
constraint that exists in the database schema. Add the `@Size`(max = 500)
annotation to the title field (alongside the existing `@NotBlank` annotation) to
validate the length constraint at the API input layer. This will ensure that
titles exceeding 500 characters are rejected with a 400 Bad Request response
before reaching the database layer, preventing cascading failures.
♻️ Duplicate comments (1)
src/main/java/com/codeit/team5/mopl/content/entity/Content.java (1)

81-86: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

createByExternalSource()에서 source == null을 즉시 차단해 주세요.

Line 81 조건은 ADMIN만 막고 있어서 source == null이 통과됩니다. 이 경우 Line 60의 nullable = false 제약과 충돌해 저장 시점 예외로 늦게 터지고, API 레이어에서는 500으로 번질 수 있습니다.

선택지는 두 가지입니다.

  • 옵션 1 (권장): 현재 가드에서 null까지 함께 검증 → 도메인 예외로 빠르게 실패.
  • 옵션 2: Objects.requireNonNull(source) + 기존 ADMIN 체크 분리 → NPE 계열 예외가 섞여 예외 일관성은 떨어짐.
🔧 제안 diff
 public static Content createByExternalSource(ContentType type, String title, String description,
         ContentSource source, String externalId, Instant releasedAt, String metadata) {
-    if (source == ContentSource.ADMIN) {
+    if (source == null || source == ContentSource.ADMIN) {
         throw new InvalidContentSourceException("외부 소스 생성에 ADMIN 소스는 사용할 수 없습니다.");
     }
     if (externalId == null || externalId.isBlank()) {
         throw new InvalidContentSourceException("외부 소스 콘텐츠는 externalId가 필수입니다.");
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/codeit/team5/mopl/content/entity/Content.java` around lines
81 - 86, In the createByExternalSource() method, the current validation at line
81 only checks if source equals ContentSource.ADMIN but does not check if source
itself is null. Add a null check for the source parameter to validate that it is
both not null and not ADMIN before the method proceeds, ensuring the domain
exception is thrown early during validation rather than later at database save
time due to the nullable = false constraint violation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java`:
- Around line 13-14: The title field in the ContentCreateRequest DTO currently
only validates that it is not blank, but does not enforce the maximum length
constraint that exists in the database schema. Add the `@Size`(max = 500)
annotation to the title field (alongside the existing `@NotBlank` annotation) to
validate the length constraint at the API input layer. This will ensure that
titles exceeding 500 characters are rejected with a 400 Bad Request response
before reaching the database layer, preventing cascading failures.

---

Duplicate comments:
In `@src/main/java/com/codeit/team5/mopl/content/entity/Content.java`:
- Around line 81-86: In the createByExternalSource() method, the current
validation at line 81 only checks if source equals ContentSource.ADMIN but does
not check if source itself is null. Add a null check for the source parameter to
validate that it is both not null and not ADMIN before the method proceeds,
ensuring the domain exception is thrown early during validation rather than
later at database save time due to the nullable = false constraint violation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a7d476a6-d829-4c67-bd76-31e841ec9e0a

📥 Commits

Reviewing files that changed from the base of the PR and between fe9fcfc and f1a34cc.

📒 Files selected for processing (6)
  • src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java
  • src/main/java/com/codeit/team5/mopl/content/entity/Content.java
  • src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentSourceException.java
  • src/main/java/com/codeit/team5/mopl/content/service/ContentService.java
  • src/main/java/com/codeit/team5/mopl/global/exception/ErrorCode.java
  • src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java

@plzslp plzslp requested a review from metDaisy June 24, 2026 02:16
@plzslp plzslp changed the title [Feat] 콘텐츠 관리 - 사전 클래스 작성 및 콘텐츠 등록 feat: 콘텐츠 관리 - 사전 클래스 작성 및 콘텐츠 등록 Jun 24, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
src/main/java/com/codeit/team5/mopl/content/entity/Content.java (1)

84-93: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

외부 ID 정규화 누락으로 동일 콘텐츠 중복 적재 위험이 있습니다.

Line 84-85에서 blank 검증은 하고 있지만, Line 92에서 externalId를 정규화 없이 저장해 "tt123"" tt123 "가 다른 값으로 들어갈 수 있습니다. 현재 Line 29-33의 (source, external_id) 유니크 제약이 있어도 공백 변형은 막지 못해 연동 데이터 무결성이 깨질 수 있습니다.

선택지는 두 가지입니다.

  • 옵션 A (권장): 팩토리에서 trim() 후 저장
    장점: 변경 범위가 작고 즉시 효과가 큼.
    단점: 대소문자/포맷 규칙까지는 별도 합의가 필요.
  • 옵션 B: 외부 ID 전용 값 객체 도입
    장점: 정규화/검증 규칙을 강하게 일원화 가능.
    단점: 적용 범위가 넓어 PR 범위를 키움.
🔧 최소 변경 제안
     if (externalId == null || externalId.isBlank()) {
         throw new InvalidContentSourceException("외부 소스 콘텐츠는 externalId가 필수입니다.");
     }
+    String normalizedExternalId = externalId.trim();
+    if (normalizedExternalId.isEmpty()) {
+        throw new InvalidContentSourceException("외부 소스 콘텐츠는 externalId가 필수입니다.");
+    }
     Content content = new Content();
     content.type = type;
     content.title = title;
     content.description = description;
     content.source = source;
-    content.externalId = externalId;
+    content.externalId = normalizedExternalId;
     content.releasedAt = releasedAt;
     content.metadata = metadata;
     return content;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/codeit/team5/mopl/content/entity/Content.java` around lines
84 - 93, The externalId is being stored without normalization, allowing
whitespace variations like "tt123" and " tt123 " to be treated as different
values and create duplicates despite the unique constraint. Normalize the
externalId by calling trim() on the parameter before assigning it to
content.externalId in the Content factory method to ensure consistent storage
and prevent data integrity issues from external source integration.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/main/java/com/codeit/team5/mopl/content/entity/Content.java`:
- Around line 84-93: The externalId is being stored without normalization,
allowing whitespace variations like "tt123" and " tt123 " to be treated as
different values and create duplicates despite the unique constraint. Normalize
the externalId by calling trim() on the parameter before assigning it to
content.externalId in the Content factory method to ensure consistent storage
and prevent data integrity issues from external source integration.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b6a08dfa-91d0-455d-bf47-93320d687d74

📥 Commits

Reviewing files that changed from the base of the PR and between f1a34cc and 2c7cbd5.

📒 Files selected for processing (2)
  • src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java
  • src/main/java/com/codeit/team5/mopl/content/entity/Content.java

metDaisy
metDaisy previously approved these changes Jun 24, 2026
Comment thread src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java
Comment thread src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java Outdated
Comment thread src/main/java/com/codeit/team5/mopl/content/service/ContentService.java Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@build.gradle`:
- Line 31: The springdoc-openapi-starter-webmvc-ui dependency at version 2.8.8
has compatibility issues with Spring Boot 3.5.15, causing potential
NoSuchMethodError exceptions and may include outdated security patches in
dependent libraries. Update the version number in the
springdoc-openapi-starter-webmvc-ui dependency declaration in build.gradle from
2.8.8 to 2.8.17, which provides full Spring Boot 3.5.x compatibility and
includes the latest security patches for all transitive dependencies.

In
`@src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java`:
- Around line 27-36: The request log in the postContent method currently only
logs a simple entry message without any parameter details, making debugging and
monitoring difficult. Enhance the log.info statement to include useful debugging
information by adding parameters such as the content type and title from the
request object, and whether a thumbnail file was provided. Modify the log.info
call to use a parametrized format string that includes request.type(),
request.title(), and a check for thumbnail != null to provide better visibility
into what content is being created.

In `@src/main/java/com/codeit/team5/mopl/content/entity/ContentType.java`:
- Around line 23-31: The from() method in ContentType enum throws
InvalidContentTypeException during deserialization, but Jackson wraps it in
JsonMappingException which Spring then wraps in HttpMessageNotReadableException.
Since GlobalExceptionHandler only handles BusinessException and
MethodArgumentNotValidException, this results in a 500 error instead of the
expected 400. Add a new handler method in GlobalExceptionHandler that catches
HttpMessageNotReadableException, inspects the cause chain for BusinessException
to extract the appropriate ErrorCode, and returns a 400 response with
INVALID_INPUT when no BusinessException is found. Additionally, add a test case
to ContentControllerTest that sends an invalid enum value (not just missing the
field) to verify the correct error response is returned.

In
`@src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentTypeException.java`:
- Around line 7-9: The InvalidContentTypeException constructor currently uses
the generic INVALID_INPUT error code, which is inconsistent with similar domain
exceptions like InvalidContentSourceException (uses INVALID_CONTENT_SOURCE) and
InvalidTagNameException (uses INVALID_TAG_NAME). To fix this, first add a new
INVALID_CONTENT_TYPE error code to the ErrorCode enum in the Content section,
then update the InvalidContentTypeException constructor to use this new specific
error code instead of INVALID_INPUT. This ensures all content domain validation
exceptions follow a consistent pattern with specific, self-documenting error
codes.

In `@src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java`:
- Around line 21-26: Based on the review comment, the current implementation in
the toTagNames method is already correct and efficient. The method properly uses
Collections.emptyList() for the null case (an immutable singleton that avoids
unnecessary object creation) and .toList() for the stream case (which also
returns an immutable list). Both approaches are consistent and memory-efficient,
so no code changes are required. The current implementation follows good
practices and aligns with your team's coding standards.

In `@src/main/java/com/codeit/team5/mopl/content/service/ContentService.java`:
- Around line 56-65: The tag creation logic has a race condition where multiple
concurrent requests can try to create the same new tag, causing a unique
constraint violation when saveAll is called on the newTags list. Fix this by
wrapping the tagRepository.saveAll(newTags) call in a try-catch block to handle
integrity constraint exceptions, then when an exception is caught, re-query
tagRepository.findByNameIn() for the tags that failed to insert and add them to
the existingTags map so the operation completes successfully instead of failing
with a 500 error.

In `@src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java`:
- Around line 80-81: The test mocks in ContentServiceTest are set up with
tagRepository.save() stubs, but the ContentService implementation uses
tagRepository.saveAll() for batch tag operations. Update all mock setups (at
lines 80-81, 97-99, 158-159, 173-175, 182-183, 198-199, 206-207) to replace
when(tagRepository.save(any(Tag.class))) with
when(tagRepository.saveAll(any(List.class))) to match the actual service
contract. Consider using ArgumentCaptor with List<Tag> type to capture and
verify the exact tags and counts being saved in each scenario (new tags vs.
duplicate tags), ensuring the test validates the actual batch save behavior
rather than individual saves.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 18b613d1-6307-41f6-a56a-b9ab87b2328d

📥 Commits

Reviewing files that changed from the base of the PR and between 2c7cbd5 and 6556afb.

📒 Files selected for processing (29)
  • build.gradle
  • src/main/java/com/codeit/team5/mopl/content/controller/ContentController.java
  • src/main/java/com/codeit/team5/mopl/content/controller/api/ContentApi.java
  • src/main/java/com/codeit/team5/mopl/content/dto/request/ContentCreateRequest.java
  • src/main/java/com/codeit/team5/mopl/content/dto/response/ContentResponse.java
  • src/main/java/com/codeit/team5/mopl/content/entity/Content.java
  • src/main/java/com/codeit/team5/mopl/content/entity/ContentSource.java
  • src/main/java/com/codeit/team5/mopl/content/entity/ContentStats.java
  • src/main/java/com/codeit/team5/mopl/content/entity/ContentTag.java
  • src/main/java/com/codeit/team5/mopl/content/entity/ContentType.java
  • src/main/java/com/codeit/team5/mopl/content/exception/ContentException.java
  • src/main/java/com/codeit/team5/mopl/content/exception/ContentNotFoundException.java
  • src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentSourceException.java
  • src/main/java/com/codeit/team5/mopl/content/exception/InvalidContentTypeException.java
  • src/main/java/com/codeit/team5/mopl/content/mapper/ContentMapper.java
  • src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java
  • src/main/java/com/codeit/team5/mopl/content/repository/ContentStatsRepository.java
  • src/main/java/com/codeit/team5/mopl/content/service/ContentService.java
  • src/main/java/com/codeit/team5/mopl/global/exception/ErrorCode.java
  • src/main/java/com/codeit/team5/mopl/tag/entity/Tag.java
  • src/main/java/com/codeit/team5/mopl/tag/exception/InvalidTagNameException.java
  • src/main/java/com/codeit/team5/mopl/tag/exception/TagException.java
  • src/main/java/com/codeit/team5/mopl/tag/repository/TagRepository.java
  • src/main/resources/db/migration/V3__modify_watcher_count_type.sql
  • src/main/resources/db/migration/V4__add_admin_content_source.sql
  • src/test/java/com/codeit/team5/mopl/content/controller/ContentControllerTest.java
  • src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java
  • src/test/java/com/codeit/team5/mopl/watcher/repository/WatchingSessionRepositoryTest.java
  • src/test/java/com/codeit/team5/mopl/watcher/service/WatchingSessionServiceTest.java
💤 Files with no reviewable changes (1)
  • src/main/java/com/codeit/team5/mopl/content/repository/ContentRepository.java

Comment thread build.gradle Outdated
Comment thread src/main/java/com/codeit/team5/mopl/content/entity/ContentType.java
Comment thread src/test/java/com/codeit/team5/mopl/content/service/ContentServiceTest.java Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants