Skip to content

[Feat] AI 해시태그 추출 및 여행 계획 목록에 박/일, 해시태그 표시#51

Merged
toychip merged 9 commits intomainfrom
feat/#50-ai-hashtag-extraction
Mar 30, 2026
Merged

[Feat] AI 해시태그 추출 및 여행 계획 목록에 박/일, 해시태그 표시#51
toychip merged 9 commits intomainfrom
feat/#50-ai-hashtag-extraction

Conversation

@toychip
Copy link
Copy Markdown
Contributor

@toychip toychip commented Mar 27, 2026

관련 이슈


변경 내용

AI 해시태그 자동 추출

  • Gemini AI 프롬프트에 해시태그 추출 룰 추가 (사전 정의 태그 목록 기반, 최대 3개)
  • Hashtag 테이블 (name UK) + 중간 테이블 (video_analysis_task_hashtag, 복합 UK) N:M 설계
  • 분석 완료 시 기존 해시태그 조회 → 없으면 생성 → 중간 테이블 매핑 (SELECT 1회 + INSERT 2회)
  • VideoAnalysisTaskHashtag 도메인 모델에서 ID 생성 담당

여행 계획 목록 응답 확장

  • nights(박), days(일): MAX(day) 서브쿼리로 계산, nights = days - 1
  • hashtags: 배치 IN절 쿼리 1회로 일괄 조회 후 Map 매핑 (N+1 방지, 총 쿼리 2회)
  • 반환 전용 DTO (TaskHashtagMapping, TaskHashtagRow) 사용, Map 직접 반환 제거

쿼리 최적화

  • 목록 조회: 기존 쿼리에 MAX(day) 서브쿼리만 추가 (추가 쿼리 없음)
  • 해시태그 조회: video_analysis_task_hashtag JOIN hashtag WHERE task_id IN (...) 배치 1회
  • UK의 leftmost prefix가 video_analysis_task_id 단독 쿼리도 커버하므로 중복 인덱스 미생성

체크리스트

  • Ktlint
  • 테스트 통과 여부

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 여행 계획에 해시태그 자동 추출·연결 지원 추가
    • 여행 요약에 일수(days)·밤수(nights) 정보 표시 추가
  • 개선사항

    • 여행 응답에 해시태그 및 기간 정보 포함으로 더 풍부한 정보 제공
    • AI 분석 결과에서 해시태그 출력 규격 추가(최대 3개)
    • 오류 응답에 일관된 코드(code) 필드 추가 및 인증 실패 시 코드 포함 응답 제공

@toychip toychip linked an issue Mar 27, 2026 that may be closed by this pull request
14 tasks
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

📝 Walkthrough

Walkthrough

TripPlanService가 TripPlan 조회 시 각 요약에 대해 days/nights를 계산하고, 관련 videoAnalysisTaskId들로부터 해시태그를 일괄 조회해 각 TripPlanSummary에 붙여 반환하도록 해시태그 도메인/영속성 포트 및 저장/조회 어댑터를 추가했습니다.

Changes

Cohort / File(s) Summary
도메인 엔터티
linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Hashtag.kt, linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTaskHashtag.kt
Hashtag, VideoAnalysisTaskHashtag 도메인 모델 추가 (factory 포함).
도메인 서비스 / 유스케이스
linktrip-application/src/main/kotlin/com/linktrip/application/domain/trip/TripPlanService.kt, linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisResultSaver.kt, linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListener.kt
TripPlanService가 days/nights 계산 및 해시태그 매핑 추가; VideoAnalysisResultSaver가 해시태그 저장 로직(중복 확인, 생성, task-hashtag 저장) 추가; 이벤트 리스너가 hashtags 전달하도록 변경.
포트 / DTO (애플리케이션 계층)
linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/HashtagPersistencePort.kt, linktrip-application/src/main/kotlin/com/linktrip/application/port/input/TripPlanUseCase.kt, linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/TripPlanPersistencePort.kt
HashtagPersistencePort 인터페이스와 TaskHashtagMapping 추가; TripPlanSummary에 days, nights, hashtags 필드 추가; TripPlanSummaryRow에 days 필드 추가.
HTTP 입력/출력 DTO 및 어댑터
linktrip-input-http/src/main/kotlin/.../TripPlanResponse.kt, linktrip-output-http/src/main/kotlin/.../VideoAnalyzeAdapter.kt, linktrip-output-http/src/main/kotlin/.../AiApiResponse.kt
TripPlanSummaryResponse에 days, nights, hashtags 매핑 추가; AI 프롬프트에 hashtags 스키마/규칙 추가 및 AiApiResponse에 hashtags 필드(정규화 로직) 추가.
영속성 어댑터 / JPA 엔티티 / 레포지토리
linktrip-output-persistence/mysql/.../adapter/HashtagPersistenceAdapter.kt, .../adapter/TripPlanPersistenceAdapter.kt, .../entity/HashtagEntity.kt, .../entity/VideoAnalysisTaskHashtagEntity.kt, .../repository/HashtagJpaRepository.kt, .../repository/VideoAnalysisTaskHashtagJpaRepository.kt, .../repository/VideoAnalysisTaskHashtagQuerydslRepository.kt, .../repository/TripPlanQuerydslRepository.kt, .../repository/dto/TaskHashtagRow.kt, .../repository/dto/TripPlanSummaryWithVideo.kt
HashtagPersistenceAdapter 추가(Hashtag/TaskHashtag CRUD, task->hashtag 조회), JPA 엔티티(HashtagEntity, VideoAnalysisTaskHashtagEntity) 및 관련 JpaRepository/QueryDSL 레포지토리 추가; TripPlanQuerydslRepository에 MAX(day) 서브쿼리로 days 계산 추가 및 TripPlanSummaryWithVideo에 days 필드 추가.
테스트 업데이트
linktrip-application/src/test/kotlin/.../TripPlanServiceTest.kt, .../VideoAnalysisResultSaverTest.kt, .../VideoAnalyzeEventListenerTest.kt
테스트에 HashtagPersistencePort mock 추가 및 expectations/fixtures에 days/hashtags 반영.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Service as TripPlanService
    participant TripRepo as TripPlanPersistencePort
    participant HashtagPort as HashtagPersistencePort

    Client->>Service: getTripPlans(memberId, pageRequest)
    Service->>TripRepo: findSummariesByMemberId(memberId, pageRequest)
    TripRepo-->>Service: Page<TripPlanSummaryRow>(tripPlanRows with days)
    Service->>Service: map rows -> TripPlanSummary (compute nights = max(days-1,0))
    Service->>HashtagPort: findHashtagNamesByVideoAnalysisTaskIds(list of taskIds)
    HashtagPort-->>Service: List<TaskHashtagMapping> (taskId -> hashtagName)
    Service->>Service: group mappings by taskId -> Set<String>
    Service-->>Client: Page<TripPlanSummary> (with days, nights, hashtags)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~35 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 AI 해시태그 추출 및 여행 계획 목록에 박/일, 해시태그 표시라는 핵심 기능 변경을 명확하고 구체적으로 반영하고 있습니다.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#50-ai-hashtag-extraction

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.

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

Caution

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

⚠️ Outside diff range comments (1)
linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt (1)

156-191: ⚠️ Potential issue | 🟠 Major

GetTripPlans 테스트에서 hashtagPort mock stubbing 누락 - NPE 발생 가능

getTripPlans 메서드는 hashtagPort.findHashtagNamesByVideoAnalysisTaskIds()를 호출하지만, 해당 테스트들에서 이 mock이 stubbing되어 있지 않습니다. Mockito의 기본 동작으로 null이 반환되어 line 86-88의 groupBy 호출 시 NPE가 발생할 수 있습니다.

또한, TripPlanService에서 enrichment하는 nights, days, hashtags 필드에 대한 assertion이 없어 새로운 기능의 정확성을 검증하지 못합니다.

🛡️ 제안된 수정
 `@Nested`
 inner class GetTripPlans {
     `@Test`
     fun `결과가 size보다 많으면_hasNext가 true이고 nextCursor가 마지막 항목의 createdAt이다`() {
         val rows = (1..3).map { createSummaryRow("p$it") }
         whenever(planPort.findSummariesByMemberId("m1", null, 3)).thenReturn(rows)
+        whenever(hashtagPort.findHashtagNamesByVideoAnalysisTaskIds(any())).thenReturn(emptyList())

         val result = service.getTripPlans("m1", null, 2)

         assertTrue(result.hasNext)
         assertEquals(2, result.items.size)
         assertEquals(result.items.last().tripPlan.createdAt.toString(), result.nextCursor)
+        // Verify enriched fields
+        assertEquals(3, result.items[0].days)
+        assertEquals(2, result.items[0].nights) // maxOf(3 - 1, 0)
     }

다른 GetTripPlans 테스트들에도 동일하게 hashtagPort stubbing을 추가해야 합니다.

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

In
`@linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt`
around lines 156 - 191, The tests in GetTripPlans are missing stubbing for
hashtagPort.findHashtagNamesByVideoAnalysisTaskIds which
TripPlanService.getTripPlans calls (causing possible NPE); update each test in
TripPlanServiceTest:GetTripPlans to stub
hashtagPort.findHashtagNamesByVideoAnalysisTaskIds(...) to return an appropriate
map or empty map matching the created summary rows, and add assertions verifying
the enriched fields nights, days, and hashtags on the returned items to validate
the new enrichment logic.
🧹 Nitpick comments (3)
linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Hashtag.kt (1)

13-17: name 파라미터 검증 고려

HashtagEntity에서 name 컬럼의 길이가 50자로 제한되어 있지만, 도메인 레벨에서는 검증이 없습니다. 빈 문자열이나 50자 초과 문자열이 전달될 경우 persistence 레이어에서 예외가 발생할 수 있습니다.

💡 도메인 레벨 검증 추가 제안
 companion object {
     fun create(name: String): Hashtag {
+        require(name.isNotBlank()) { "Hashtag name must not be blank" }
+        require(name.length <= 50) { "Hashtag name must not exceed 50 characters" }
         return Hashtag(
             id = IdGenerator.generate(),
             name = name,
         )
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Hashtag.kt`
around lines 13 - 17, The Hashtag.create factory currently accepts any string
but the persistence layer limits HashtagEntity.name to 50 chars, so add
domain-level validation in Hashtag.create (or a private validateName helper used
by the Hashtag constructor) to trim input, reject blank/empty names and reject
names longer than 50 characters; when invalid, throw a clear runtime exception
(e.g., IllegalArgumentException or a DomainValidationException) referencing the
failing value so callers fail fast before hitting the persistence layer.
linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisResultSaverTest.kt (1)

21-22: 해시태그 저장 로직에 대한 테스트 커버리지 추가를 권장합니다.

HashtagPersistencePort 목 주입이 추가되었지만, 해시태그가 실제로 저장되는 시나리오에 대한 테스트가 없습니다. 해시태그 저장 로직의 검증을 위해 별도 테스트 케이스 추가를 고려해 주세요.

💡 테스트 케이스 예시
`@Test`
fun `해시태그가 포함된 분석 결과 저장 시_해시태그가 조회되고 새 해시태그가 저장된다`() {
    // given
    val items = listOf(/* ... */)
    val hashtags = listOf("서울여행", "맛집탐방")
    
    whenever(hashtagPersistencePort.findByNames(hashtags))
        .thenReturn(emptyList())
    
    // when
    saver.save("summary-1", items, hashtags = hashtags)
    
    // then
    verify(hashtagPersistencePort).findByNames(hashtags)
    verify(hashtagPersistencePort).saveAll(any())
    verify(hashtagPersistencePort).saveAllTaskHashtags(any())
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisResultSaverTest.kt`
around lines 21 - 22, Add a unit test in VideoAnalysisResultSaverTest that
verifies the hashtag persistence interactions: when calling saver.save(...) with
a non-empty hashtags list, mock HashtagPersistencePort.findByNames to return an
empty list (or partial existing tags) and assert that findByNames was invoked
with the provided names and that saveAll and saveAllTaskHashtags on
HashtagPersistencePort were called accordingly; reference the
HashtagPersistencePort mock, the saver.save(...) invocation, and verify(...)
calls to validate the save flow.
linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/HashtagPersistenceAdapter.kt (1)

23-24: Unique constraint 위반 가능성 - 동시성 고려 필요

saveAll은 단순 JPA saveAll을 사용하며, 상위 레이어(VideoAnalysisResultSaver.saveHashtags)에서 find-then-create 패턴을 사용합니다. 동일한 해시태그를 포함한 두 영상이 동시에 분석될 경우, 둘 다 findByNames에서 빈 결과를 받고 동시에 insert를 시도하여 uk_hashtag_name 위반이 발생할 수 있습니다.

현재 single-server 환경에서는 발생 확률이 낮지만, 향후 확장 시 고려가 필요합니다. Based on learnings, 이 프로젝트는 replication 도입 시 ShedLock을 사용할 계획이므로 현재 단계에서는 허용 가능합니다.

♻️ 향후 개선 제안: INSERT ON DUPLICATE KEY UPDATE 또는 재시도 로직
// Option 1: Native query with ON DUPLICATE KEY
`@Query`("""
    INSERT INTO hashtag (id, name, created_at, updated_at, deleted)
    VALUES (:id, :name, NOW(), NOW(), false)
    ON DUPLICATE KEY UPDATE id = id
""", nativeQuery = true)
fun insertIgnore(`@Param`("id") id: String, `@Param`("name") name: String)

// Option 2: Catch DataIntegrityViolationException and retry
override fun saveAll(hashtags: List<Hashtag>): List<Hashtag> {
    return try {
        hashtagJpaRepository.saveAll(hashtags.map { HashtagEntity.from(it) }).map { it.toDomain() }
    } catch (e: DataIntegrityViolationException) {
        // Re-fetch existing and return
        findByNames(hashtags.map { it.name })
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/HashtagPersistenceAdapter.kt`
around lines 23 - 24, saveAll currently delegates to
hashtagJpaRepository.saveAll and can throw a unique-constraint violation
(uk_hashtag_name) under concurrent find-then-create flows invoked from
VideoAnalysisResultSaver.saveHashtags; fix by making saveAll resilient to that
race: wrap the saveAll call in a try/catch for DataIntegrityViolationException
(or the appropriate Spring exception) and on catch re-query the persisted
hashtags via findByNames(names) and return the merged results, or replace
saveAll with a native "insert ignore / ON DUPLICATE KEY UPDATE" insert method on
hashtagJpaRepository to perform idempotent inserts; reference:
HashtagPersistenceAdapter.saveAll, VideoAnalysisResultSaver.saveHashtags,
hashtagJpaRepository, findByNames, uk_hashtag_name.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisResultSaver.kt`:
- Around line 36-56: In saveHashtags, deduplicate incoming hashtagNames first,
then avoid the non-atomic find-then-create race by attempting to create missing
Hashtag entities (using Hashtag.create and hashtagPersistencePort.saveAll) but
if saveAll fails with a unique constraint/duplicate-key error, catch that
specific exception, re-query existing hashtags via
hashtagPersistencePort.findByNames for the full deduplicated set, and rebuild
the taskHashtags mapping from the union of existing and newly saved hashtags
before calling hashtagPersistencePort.saveAllTaskHashtags; ensure you reference
saveHashtags, hashtagPersistencePort.findByNames, Hashtag.create,
hashtagPersistencePort.saveAll and saveAllTaskHashtags when applying these
changes.

In
`@linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt`:
- Around line 7-9: 현재 import 블록에 사용되지 않는 TaskHashtagMapping import가 포함되어 있고
import들이 알파벳(lexicographic) 순으로 정렬되어 있지 않습니다; TripPlanServiceTest의 import 목록에서
TaskHashtagMapping을 제거하고 나머지 import들(예: HashtagPersistencePort,
TravelItineraryItemPersistencePort)을 lexicographic 순서로 정렬되도록 재정렬하세요.

---

Outside diff comments:
In
`@linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt`:
- Around line 156-191: The tests in GetTripPlans are missing stubbing for
hashtagPort.findHashtagNamesByVideoAnalysisTaskIds which
TripPlanService.getTripPlans calls (causing possible NPE); update each test in
TripPlanServiceTest:GetTripPlans to stub
hashtagPort.findHashtagNamesByVideoAnalysisTaskIds(...) to return an appropriate
map or empty map matching the created summary rows, and add assertions verifying
the enriched fields nights, days, and hashtags on the returned items to validate
the new enrichment logic.

---

Nitpick comments:
In
`@linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Hashtag.kt`:
- Around line 13-17: The Hashtag.create factory currently accepts any string but
the persistence layer limits HashtagEntity.name to 50 chars, so add domain-level
validation in Hashtag.create (or a private validateName helper used by the
Hashtag constructor) to trim input, reject blank/empty names and reject names
longer than 50 characters; when invalid, throw a clear runtime exception (e.g.,
IllegalArgumentException or a DomainValidationException) referencing the failing
value so callers fail fast before hitting the persistence layer.

In
`@linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisResultSaverTest.kt`:
- Around line 21-22: Add a unit test in VideoAnalysisResultSaverTest that
verifies the hashtag persistence interactions: when calling saver.save(...) with
a non-empty hashtags list, mock HashtagPersistencePort.findByNames to return an
empty list (or partial existing tags) and assert that findByNames was invoked
with the provided names and that saveAll and saveAllTaskHashtags on
HashtagPersistencePort were called accordingly; reference the
HashtagPersistencePort mock, the saver.save(...) invocation, and verify(...)
calls to validate the save flow.

In
`@linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/HashtagPersistenceAdapter.kt`:
- Around line 23-24: saveAll currently delegates to hashtagJpaRepository.saveAll
and can throw a unique-constraint violation (uk_hashtag_name) under concurrent
find-then-create flows invoked from VideoAnalysisResultSaver.saveHashtags; fix
by making saveAll resilient to that race: wrap the saveAll call in a try/catch
for DataIntegrityViolationException (or the appropriate Spring exception) and on
catch re-query the persisted hashtags via findByNames(names) and return the
merged results, or replace saveAll with a native "insert ignore / ON DUPLICATE
KEY UPDATE" insert method on hashtagJpaRepository to perform idempotent inserts;
reference: HashtagPersistenceAdapter.saveAll,
VideoAnalysisResultSaver.saveHashtags, hashtagJpaRepository, findByNames,
uk_hashtag_name.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2bcc6aae-834f-4133-916d-4c0cd64d7eeb

📥 Commits

Reviewing files that changed from the base of the PR and between 8a5a2d7 and b962cf4.

📒 Files selected for processing (25)
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/trip/TripPlanService.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Hashtag.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisResult.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisResultSaver.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTaskHashtag.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListener.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/input/TripPlanUseCase.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/HashtagPersistencePort.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/TripPlanPersistencePort.kt
  • linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt
  • linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisResultSaverTest.kt
  • linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListenerTest.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/TripPlanResponse.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/dto/AiApiResponse.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/HashtagPersistenceAdapter.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/TripPlanPersistenceAdapter.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/HashtagEntity.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/VideoAnalysisTaskHashtagEntity.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/HashtagJpaRepository.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/TripPlanQuerydslRepository.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/VideoAnalysisTaskHashtagJpaRepository.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/VideoAnalysisTaskHashtagQuerydslRepository.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/dto/TaskHashtagRow.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/dto/TripPlanSummaryWithVideo.kt
📜 Review details
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2026-03-18T01:07:53.575Z
Learnt from: toychip
Repo: Link-Trip/BackEnd PR: 24
File: linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt:16-28
Timestamp: 2026-03-18T01:07:53.575Z
Learning: Apply the same deferred concurrent-write policy to all Kotlin persistence adapters in the MySQL adapter package: ensure writes are coordinated on a single server with ShedLock considered for the replication phase. For YouTubeVideoPersistenceAdapter.kt (and other adapters in this directory), verify that concurrent writes are serialized or properly guarded, document the policy in code comments, and ensure CI checks or deployment gating will catch any regression where multiple instances might attempt a conflicting write during replication.

Applied to files:

  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/TripPlanPersistenceAdapter.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/HashtagPersistenceAdapter.kt
📚 Learning: 2026-03-18T01:08:05.661Z
Learnt from: toychip
Repo: Link-Trip/BackEnd PR: 24
File: linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt:16-28
Timestamp: 2026-03-18T01:08:05.661Z
Learning: Similarly, `linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeChannelPersistenceAdapter.kt` likely has the same deferred concurrent-write policy (single server, ShedLock planned for replication phase).

Applied to files:

  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/VideoAnalysisTaskHashtagJpaRepository.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/dto/TripPlanSummaryWithVideo.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/TripPlanQuerydslRepository.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/VideoAnalysisTaskHashtagQuerydslRepository.kt
📚 Learning: 2026-03-18T01:08:05.661Z
Learnt from: toychip
Repo: Link-Trip/BackEnd PR: 24
File: linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt:16-28
Timestamp: 2026-03-18T01:08:05.661Z
Learning: In `linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt`, the concurrent-write safety (duplicate videoId in batch, race condition on uk_youtube_video_video_id) is intentionally deferred. The maintainer (toychip) confirmed the system is currently single-server, so this is not a concern yet. When replication is introduced in the future, ShedLock will be used for distributed locking to address this. At that point, in-batch videoId deduplication should also be applied.

Applied to files:

  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/VideoAnalysisTaskHashtagJpaRepository.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/dto/TripPlanSummaryWithVideo.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/VideoAnalysisTaskHashtagQuerydslRepository.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/HashtagPersistenceAdapter.kt
🪛 GitHub Actions: CI - Pull request
linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt

[error] 3-3: ktlint: Imports must be ordered in lexicographic order without any empty lines in-between with "java", "javax", "kotlin" and aliases in the end


[error] 9-9: ktlint: Unused import

🔇 Additional comments (23)
linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisResult.kt (1)

10-10: LGTM!

AI 분석 결과에 해시태그 필드가 적절하게 추가되었습니다. AI 요약에 따르면 upstream에서 정규화(trim, empty 제거, 최대 3개 제한)가 처리되므로 이 레벨에서는 추가 검증이 필요하지 않습니다.

linktrip-application/src/main/kotlin/com/linktrip/application/port/input/TripPlanUseCase.kt (1)

55-62: LGTM!

TripPlanSummarynights, days, hashtags 필드가 적절하게 추가되었습니다. hashtagsSet<String>을 사용하여 중복을 방지한 것이 좋습니다.

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTaskHashtag.kt (1)

6-24: LGTM!

VideoAnalysisTaskHashtag 도메인 모델이 Hashtag와 일관된 패턴으로 잘 구현되었습니다. N:M 관계를 위한 중간 테이블 도메인 모델로서 적절합니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/VideoAnalysisTaskHashtagEntity.kt (1)

10-37: LGTM!

JPA 엔티티가 적절하게 구현되었습니다. (video_analysis_task_id, hashtag_id) 복합 유니크 제약조건이 중복 매핑을 방지합니다. PR 목표에 언급된 대로 UK의 leftmost prefix가 video_analysis_task_id 단독 쿼리를 커버하므로 별도 인덱스가 불필요합니다.

toDomain() 메서드가 없지만, 현재 조회 로직이 QueryDSL을 통해 직접 해시태그 이름을 조회하는 방식이라면 필요하지 않을 수 있습니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/VideoAnalysisTaskHashtagJpaRepository.kt (1)

6-6: LGTM!

기본적인 JPA Repository 인터페이스입니다. 학습된 내용에 따르면 동시성 이슈는 현재 단일 서버 환경에서 의도적으로 미뤄진 상태이며, 향후 ShedLock을 통해 처리될 예정입니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/HashtagEntity.kt (1)

10-42: LGTM!

HashtagEntity가 잘 구현되었습니다. toDomain()에서 createdAt/updatedAt을 포함하고, from()에서는 JPA 감사(auditing)가 타임스탬프를 처리하므로 생략한 것이 적절합니다. uk_hashtag_name 유니크 제약조건으로 해시태그 이름 중복이 방지됩니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/HashtagJpaRepository.kt (1)

6-8: 승인됩니다!

findByNameIn 파생 쿼리가 배치 조회를 위해 적절하게 정의되었습니다. 호출부(VideoAnalysisResultSaver.save)에서 @Transactional로 감싸져 있으며, 빈 리스트는 이미 라인 40에서 if (hashtagNames.isEmpty()) return으로 필터링되고 있어 find → insert 패턴의 원자성이 보장됩니다.

linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/HashtagPersistencePort.kt (1)

6-19: LGTM! 깔끔한 포트 인터페이스 설계입니다.

헥사고날 아키텍처에 맞게 잘 분리된 포트 인터페이스입니다. TaskHashtagMapping DTO를 동일 파일에 정의하여 응집도를 높인 점도 좋습니다.

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListener.kt (1)

57-57: LGTM!

분석 결과의 해시태그를 저장 로직에 전달하는 변경이 기존 패턴과 일관되게 구현되었습니다.

linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/TripPlanPersistencePort.kt (1)

31-36: LGTM!

TripPlanSummaryRowdays 필드 추가가 적절합니다. 여행 일정의 총 일수를 표현하기 위한 확장으로, 쿼리 레이어에서 계산된 값을 전달받는 구조입니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/TripPlanPersistenceAdapter.kt (1)

41-48: LGTM!

days 필드 매핑이 기존 패턴을 따르고 있으며, 타입이 일치하여 별도 변환 없이 직접 할당됩니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/dto/TaskHashtagRow.kt (1)

1-6: LGTM!

QueryDSL 프로젝션용 DTO로 적절히 정의되었습니다. HashtagPersistenceAdapter에서 TaskHashtagMapping으로 매핑되는 구조와 필드명이 일치합니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/dto/TripPlanSummaryWithVideo.kt (1)

5-10: LGTM!

QueryDSL 프로젝션 DTO에 days 필드가 적절히 추가되었습니다. 쿼리 레이어에서 계산된 일수를 전달받는 구조입니다.

linktrip-output-http/src/main/kotlin/com/linktrip/output/http/dto/AiApiResponse.kt (2)

16-16: LGTM!

AI 응답에서 해시태그 필드를 nullable로 처리하여 하위 호환성을 유지합니다.


42-42: 방어적 파싱 로직이 잘 구현되었습니다.

trim(), 빈 문자열 필터링, 최대 3개 제한, null 시 빈 리스트 반환까지 모든 엣지 케이스가 처리되어 있습니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/TripPlanQuerydslRepository.kt (1)

36-43: LGTM!

maxDaySubQuery의 구현이 올바릅니다. videoAnalysisTaskId로 상관 조인하고 삭제되지 않은 일정 항목만 필터링하여 최대 day 값을 계산합니다. 기본값 1로 fallback하는 것도 적절합니다.

linktrip-application/src/main/kotlin/com/linktrip/application/domain/trip/TripPlanService.kt (1)

83-101: LGTM! N+1 문제를 방지하는 효율적인 배치 조회 구현

해시태그 조회를 videoAnalysisTaskIds로 한 번에 배치 조회하고, groupBy로 Map 변환 후 각 summary에 매핑하는 패턴이 올바릅니다. nights 계산 로직 maxOf(days - 1, 0)도 1일 여행(0박)을 올바르게 처리합니다.

linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt (1)

168-177: LGTM! 해시태그 추출 규칙이 명확하게 정의됨

사전 정의된 태그 목록 기반으로 최대 3개까지 선택하도록 규칙이 잘 구성되어 있습니다. AiApiResponse.toDomain()에서 null 처리 및 trim/filter/take(3) 로직과 일관성 있게 동작합니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/TripPlanResponse.kt (1)

14-16: LGTM!

TripPlanSummary의 새로운 필드들(nights, days, hashtags)이 응답 DTO에 올바르게 매핑되었습니다.

linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListenerTest.kt (1)

48-91: LGTM!

해시태그 기능 추가에 따른 테스트 업데이트가 완벽합니다. VideoAnalysisResulthashtags 필드가 추가되었고, save() 호출 검증에 해시태그 파라미터가 포함되어 있습니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/VideoAnalysisTaskHashtagQuerydslRepository.kt (1)

17-32: LGTM! 효율적인 배치 조회 구현

빈 리스트 guard와 IN 절을 사용한 배치 조회가 올바르게 구현되었습니다. Projections.constructor를 사용한 DTO 직접 프로젝션도 적절합니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/HashtagPersistenceAdapter.kt (1)

30-36: LGTM!

findHashtagNamesByVideoAnalysisTaskIds 구현이 QueryDSL 리포지토리에 위임하고 결과를 TaskHashtagMapping DTO로 변환하는 패턴이 적절합니다.

linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisResultSaver.kt (1)

16-34: 해시태그 저장 흐름 연결은 깔끔합니다.

save()에서 일정/비용/상태 업데이트 뒤 해시태그 저장을 같은 트랜잭션으로 묶은 점은 일관성 측면에서 좋습니다.

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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/ExceptionAdvice.kt`:
- Line 72: Replace hardcoded response codes in ExceptionAdvice with the
canonical ExceptionCode enum values: change any occurrences like
"BAD_REQUEST_VALIDATION", "NOT_FOUND_RESOURCE", "BAD_GATEWAY_EXTERNAL_API" to
use ExceptionCode.BAD_REQUEST_VALIDATION (or the corresponding enum constants)
when building the response object in the ExceptionAdvice handlers (e.g.,
validation, not-found and external API error handlers). Also add the three enum
constants BAD_REQUEST_VALIDATION, NOT_FOUND_RESOURCE, and
BAD_GATEWAY_EXTERNAL_API to the ExceptionCode enum so the handlers reference a
single source of truth rather than literal strings. Ensure the response uses the
enum constant (or its code accessor) consistently so clients receive the
standardized code.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e3c14a90-302c-40a0-af05-2017eef89e07

📥 Commits

Reviewing files that changed from the base of the PR and between b962cf4 and d5a217a.

📒 Files selected for processing (5)
  • linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt
  • linktrip-common/src/main/kotlin/com/linktrip/common/exception/LinktripException.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/ExceptionAdvice.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/ExceptionResponse.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/JwtAuthenticationFilter.kt
✅ Files skipped from review due to trivial changes (1)
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/ExceptionResponse.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: 자동 검증 (ktlint + test)
🔇 Additional comments (3)
linktrip-common/src/main/kotlin/com/linktrip/common/exception/LinktripException.kt (1)

4-4: exceptionCode 보존 추가는 적절합니다.

예외 인스턴스에 원본 ExceptionCode를 유지해서 상위 레이어에서 코드 값을 안정적으로 재사용할 수 있습니다.

Also applies to: 13-13, 21-21

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/JwtAuthenticationFilter.kt (1)

42-42: 인증 실패 응답 코드 매핑이 일관적입니다.

ExceptionCode.UNAUTHORIZED_AUTHENTICATION_FAILED.name 사용으로 응답 스키마 일관성이 좋아졌습니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/ExceptionAdvice.kt (1)

38-38: LinktripException 경로의 코드 매핑은 올바릅니다.

e.exceptionCode.name 사용으로 예외 코드와 응답 코드 간 불일치 위험이 줄었습니다.

@toychip toychip merged commit af43145 into main Mar 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] AI 해시태그 추출 및 여행 계획 목록에 박/일, 해시태그 표시

1 participant