From aa9fca57a14809730a21b83fa588c8cdc79d9e95 Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:45:18 +0900 Subject: [PATCH 01/29] =?UTF-8?q?feat:=20API=20quota=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80=20(ApiType?= =?UTF-8?q?=20/=20ApiCallCount=20/=20ApiDailyCostAlert=20/=20ApiCostBreakd?= =?UTF-8?q?own)=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/domain/quota/ApiCallCount.kt | 31 +++++++++++++++++++ .../domain/quota/ApiCostBreakdown.kt | 19 ++++++++++++ .../domain/quota/ApiDailyCostAlert.kt | 29 +++++++++++++++++ .../application/domain/quota/ApiType.kt | 16 ++++++++++ 4 files changed, 95 insertions(+) create mode 100644 linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCount.kt create mode 100644 linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCostBreakdown.kt create mode 100644 linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiDailyCostAlert.kt create mode 100644 linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiType.kt diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCount.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCount.kt new file mode 100644 index 0000000..a2ff8cf --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCount.kt @@ -0,0 +1,31 @@ +package com.linktrip.application.domain.quota + +import com.linktrip.application.domain.common.IdGenerator +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * 특정 [apiType] 의 [callDate] 일자 호출 누적 카운트. + * UPSERT 의 UPDATE 경로에서는 SQL 측 `call_count + 1` 로 누적되며 [callCount] 값은 사용되지 않는다. + */ +data class ApiCallCount( + val id: String, + val apiType: ApiType, + val callDate: LocalDate, + val callCount: Long, + val createdAt: LocalDateTime = LocalDateTime.now(), + val updatedAt: LocalDateTime = LocalDateTime.now(), +) { + companion object { + fun create( + apiType: ApiType, + callDate: LocalDate, + ): ApiCallCount = + ApiCallCount( + id = IdGenerator.generate(), + apiType = apiType, + callDate = callDate, + callCount = 1L, + ) + } +} diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCostBreakdown.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCostBreakdown.kt new file mode 100644 index 0000000..42b6b08 --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCostBreakdown.kt @@ -0,0 +1,19 @@ +package com.linktrip.application.domain.quota + +/** + * 특정 일자의 API 별 누적 비용 (KRW) 분해. + * 합계 / 0원 제외 필터 등 표현/판정 로직을 도메인에 포함. + */ +data class ApiCostBreakdown( + val items: List, +) { + val totalKrw: Long = items.sumOf { it.costKrw } + + /** 비용 0원인 API 는 제외 (Discord 메시지에 노출할 항목만). */ + fun nonZero(): List = items.filter { it.costKrw > 0L } +} + +data class ApiCostItem( + val apiType: ApiType, + val costKrw: Long, +) diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiDailyCostAlert.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiDailyCostAlert.kt new file mode 100644 index 0000000..bf01b11 --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiDailyCostAlert.kt @@ -0,0 +1,29 @@ +package com.linktrip.application.domain.quota + +import com.linktrip.application.domain.common.IdGenerator +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * 일자별로 마지막으로 발송한 비용 알림 임계값 (KRW). + * 같은 임계값 구간에 대해 알림이 두 번 나가지 않게 추적하는 용도. + */ +data class ApiDailyCostAlert( + val id: String, + val alertDate: LocalDate, + val lastSentThresholdKrw: Long, + val createdAt: LocalDateTime = LocalDateTime.now(), + val updatedAt: LocalDateTime = LocalDateTime.now(), +) { + companion object { + fun create( + alertDate: LocalDate, + lastSentThresholdKrw: Long, + ): ApiDailyCostAlert = + ApiDailyCostAlert( + id = IdGenerator.generate(), + alertDate = alertDate, + lastSentThresholdKrw = lastSentThresholdKrw, + ) + } +} diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiType.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiType.kt new file mode 100644 index 0000000..4f639c4 --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiType.kt @@ -0,0 +1,16 @@ +package com.linktrip.application.domain.quota + +/** + * 비용 통제가 필요한 외부 API 종류. + * 새 외부 API 도입 시 enum 값 + properties 의 daily-quota / cost-per-call 항목 추가. + */ +enum class ApiType { + /** Google Vertex AI Gemini — 토큰 기반 종량제 */ + GEMINI, + + /** YouTube Data API v3 — 일일 quota */ + YOUTUBE_DATA, + + /** Google Places Text Search — 호출당 과금 */ + GOOGLE_PLACES, +} From 76b0b48ce8403eb337f5e85e7cba06ab824827f8 Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:45:24 +0900 Subject: [PATCH 02/29] =?UTF-8?q?feat:=20API=20quota/cost=20output=20port?= =?UTF-8?q?=20=EC=A0=95=EC=9D=98=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../quota/ApiCallCountPersistencePort.kt | 20 +++++++++++++++++++ .../port/output/quota/ApiCostPolicyPort.kt | 11 ++++++++++ .../quota/ApiDailyCostAlertPersistencePort.kt | 14 +++++++++++++ .../port/output/quota/ApiQuotaPolicyPort.kt | 11 ++++++++++ 4 files changed, 56 insertions(+) create mode 100644 linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCallCountPersistencePort.kt create mode 100644 linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCostPolicyPort.kt create mode 100644 linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiDailyCostAlertPersistencePort.kt create mode 100644 linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiQuotaPolicyPort.kt diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCallCountPersistencePort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCallCountPersistencePort.kt new file mode 100644 index 0000000..7a616f0 --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCallCountPersistencePort.kt @@ -0,0 +1,20 @@ +package com.linktrip.application.port.output.quota + +import com.linktrip.application.domain.quota.ApiCallCount +import com.linktrip.application.domain.quota.ApiType +import java.time.LocalDate + +/** + * 일별 API 호출 카운트 영속화 포트. + * 구현체는 (api_type, call_date) 단일 row 를 유지하며 호출 횟수를 atomic 하게 +1 한다. + */ +interface ApiCallCountPersistencePort { + fun increment(apiCallCount: ApiCallCount) + + fun findByApiTypeAndDate( + apiType: ApiType, + date: LocalDate, + ): ApiCallCount? + + fun findAllByDate(date: LocalDate): List +} diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCostPolicyPort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCostPolicyPort.kt new file mode 100644 index 0000000..95fd70e --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCostPolicyPort.kt @@ -0,0 +1,11 @@ +package com.linktrip.application.port.output.quota + +import com.linktrip.application.domain.quota.ApiType + +/** + * API 별 호출 1회당 추정 비용 (KRW). yml 등 외부 소스에서 제공. + * 0 또는 null = 비용 추적 비활성. + */ +interface ApiCostPolicyPort { + fun perCallKrw(apiType: ApiType): Long +} diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiDailyCostAlertPersistencePort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiDailyCostAlertPersistencePort.kt new file mode 100644 index 0000000..8d42dc7 --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiDailyCostAlertPersistencePort.kt @@ -0,0 +1,14 @@ +package com.linktrip.application.port.output.quota + +import com.linktrip.application.domain.quota.ApiDailyCostAlert +import java.time.LocalDate + +/** + * 일자별로 마지막으로 알림 발송한 누적 비용 임계값을 추적한다. + * 같은 임계값(예: 5000원) 에 대해 알림이 한 번만 발송되도록 함. + */ +interface ApiDailyCostAlertPersistencePort { + fun findLastSentThresholdKrw(date: LocalDate): Long? + + fun upsert(alert: ApiDailyCostAlert) +} diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiQuotaPolicyPort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiQuotaPolicyPort.kt new file mode 100644 index 0000000..08af801 --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiQuotaPolicyPort.kt @@ -0,0 +1,11 @@ +package com.linktrip.application.port.output.quota + +import com.linktrip.application.domain.quota.ApiType + +/** + * API 별 일일 호출 한도 정책 (yml 등 외부 소스에서 제공). + * null = 한도 미설정 = 가드 비활성. + */ +interface ApiQuotaPolicyPort { + fun dailyLimit(apiType: ApiType): Long? +} From 38543c7747f49b8b63614dafdbc1d9691f04cbdd Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:45:28 +0900 Subject: [PATCH 03/29] =?UTF-8?q?feat:=20=EB=B9=84=EC=9A=A9=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20NotificationPort.sendCostAlert=20=EC=8B=9C=EA=B7=B8?= =?UTF-8?q?=EB=8B=88=EC=B2=98=20=EC=A0=95=EC=9D=98=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notification/CostAlertEvent.kt | 13 +++++++++++++ .../port/output/notification/NotificationPort.kt | 3 +++ 2 files changed, 16 insertions(+) create mode 100644 linktrip-application/src/main/kotlin/com/linktrip/application/domain/notification/CostAlertEvent.kt diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/notification/CostAlertEvent.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/notification/CostAlertEvent.kt new file mode 100644 index 0000000..bedcbcc --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/notification/CostAlertEvent.kt @@ -0,0 +1,13 @@ +package com.linktrip.application.domain.notification + +import com.linktrip.application.domain.quota.ApiCostBreakdown +import java.time.LocalDate + +/** + * 외부 API 누적 비용이 임계값(1000원 단위)을 새로 넘었을 때 발송되는 알림 이벤트. + */ +data class CostAlertEvent( + val date: LocalDate, + val thresholdKrw: Long, + val breakdown: ApiCostBreakdown, +) diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/notification/NotificationPort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/notification/NotificationPort.kt index c50163c..afd31d7 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/notification/NotificationPort.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/notification/NotificationPort.kt @@ -1,5 +1,6 @@ package com.linktrip.application.port.output.notification +import com.linktrip.application.domain.notification.CostAlertEvent import com.linktrip.application.domain.notification.ExceptionAlertEvent /** @@ -8,4 +9,6 @@ import com.linktrip.application.domain.notification.ExceptionAlertEvent */ interface NotificationPort { fun sendExceptionAlert(event: ExceptionAlertEvent) + + fun sendCostAlert(event: CostAlertEvent) } From 0f3f9acc60155cd51b266c126cbc6cb7ccea6d21 Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:45:33 +0900 Subject: [PATCH 04/29] =?UTF-8?q?feat:=20API=20=EC=9D=BC=EC=9D=BC=20?= =?UTF-8?q?=ED=95=9C=EB=8F=84=20=EA=B0=80=EB=93=9C=20/=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/quota/ApiCallCounterService.kt | 78 +++++++++++++++++++ .../domain/quota/ApiQuotaGuardService.kt | 32 ++++++++ 2 files changed, 110 insertions(+) create mode 100644 linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCounterService.kt create mode 100644 linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardService.kt diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCounterService.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCounterService.kt new file mode 100644 index 0000000..7dc045f --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCounterService.kt @@ -0,0 +1,78 @@ +package com.linktrip.application.domain.quota + +import com.linktrip.application.domain.notification.CostAlertEvent +import com.linktrip.application.port.output.notification.NotificationPort +import com.linktrip.application.port.output.quota.ApiCallCountPersistencePort +import com.linktrip.application.port.output.quota.ApiCostPolicyPort +import com.linktrip.application.port.output.quota.ApiDailyCostAlertPersistencePort +import mu.KotlinLogging +import org.springframework.stereotype.Service +import java.time.LocalDate + +private val logger = KotlinLogging.logger {} + +/** + * 외부 API 호출 성공 시의 부수 처리 (카운트 적재 + 누적 비용 임계값 알림) 를 담당한다. + * + * 서비스 레이어에서 호출 직후 한 줄로 호출: + * ``` + * val r = port.callApi(...) + * apiCallCounterService.recordSuccess(ApiType.X) + * ``` + * + * 실패 처리 정책: + * - **카운트 실패는 그대로 throw**. 카운트가 실제 호출 수와 어긋나면 가드가 한도 초과를 감지 못 해 비용 폭주 위험. + * 본 PR 의 핵심 가치 (비용 보호) 를 지키려면 fail-loud 가 맞다. + * - **알림 실패는 로그만**. 알림이 늦어져도 가드는 정상 작동하므로 비용 보호에는 영향 없음. + */ +@Service +class ApiCallCounterService( + private val countPort: ApiCallCountPersistencePort, + private val costPolicyPort: ApiCostPolicyPort, + private val alertPort: ApiDailyCostAlertPersistencePort, + private val notificationPort: NotificationPort, +) { + fun recordSuccess(apiType: ApiType) { + countPort.increment(ApiCallCount.create(apiType, LocalDate.now())) + + try { + notifyIfCostThresholdCrossed() + } catch (e: Exception) { + logger.warn(e) { "비용 알림 체크 실패: $apiType" } + } + } + + private fun notifyIfCostThresholdCrossed() { + val today = LocalDate.now() + val breakdown = computeCostBreakdown(today) + val lastSent = alertPort.findLastSentThresholdKrw(today) ?: 0L + + if (breakdown.totalKrw < lastSent + THRESHOLD_KRW) return + + val newThreshold = (breakdown.totalKrw / THRESHOLD_KRW) * THRESHOLD_KRW + notificationPort.sendCostAlert( + CostAlertEvent( + date = today, + thresholdKrw = newThreshold, + breakdown = breakdown, + ), + ) + alertPort.upsert(ApiDailyCostAlert.create(today, newThreshold)) + logger.info { "비용 임계값 알림 발송: total=${breakdown.totalKrw}원, threshold=${newThreshold}원" } + } + + private fun computeCostBreakdown(date: LocalDate): ApiCostBreakdown { + val countByApi = countPort.findAllByDate(date).associateBy { it.apiType } + val items = + ApiType.entries.map { apiType -> + val count = countByApi[apiType]?.callCount ?: 0L + ApiCostItem(apiType, count * costPolicyPort.perCallKrw(apiType)) + } + return ApiCostBreakdown(items) + } + + companion object { + /** 알림 임계값 단위 (KRW). 1000원마다 한 번 발송. */ + private const val THRESHOLD_KRW = 1000L + } +} diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardService.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardService.kt new file mode 100644 index 0000000..ff4d55a --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardService.kt @@ -0,0 +1,32 @@ +package com.linktrip.application.domain.quota + +import com.linktrip.application.port.output.quota.ApiCallCountPersistencePort +import com.linktrip.application.port.output.quota.ApiQuotaPolicyPort +import mu.KotlinLogging +import org.springframework.stereotype.Service +import java.time.LocalDate + +private val logger = KotlinLogging.logger {} + +/** + * 외부 API 일일 호출 한도 가드. + * 서비스 레이어에서 호출 직전 [isExceeded] 로 체크, 큐 컨슈머는 [isAnyApiExceeded] 로 dequeue 차단. + * 자정 지나면 [LocalDate.now] 가 바뀌어 자동 해제. + */ +@Service +class ApiQuotaGuardService( + private val countPort: ApiCallCountPersistencePort, + private val policyPort: ApiQuotaPolicyPort, +) { + fun isAnyApiExceeded(): Boolean = ApiType.entries.any { isExceeded(it) } + + fun isExceeded(apiType: ApiType): Boolean { + val limit = policyPort.dailyLimit(apiType) ?: return false + val current = countPort.findByApiTypeAndDate(apiType, LocalDate.now())?.callCount ?: 0L + val exceeded = current >= limit + if (exceeded) { + logger.warn { "$apiType 일일 한도 초과: 현재=$current / 한도=$limit" } + } + return exceeded + } +} From 3ea5dd2a70c59e3d03b49a476937b0451de41c36 Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:45:41 +0900 Subject: [PATCH 05/29] =?UTF-8?q?feat:=20API=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=20/=20=EB=B9=84=EC=9A=A9=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=B6=94=EC=A0=81=20JPA=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mysql/entity/ApiCallCountEntity.kt | 56 +++++++++++++++++++ .../mysql/entity/ApiDailyCostAlertEntity.kt | 48 ++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiCallCountEntity.kt create mode 100644 linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiDailyCostAlertEntity.kt diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiCallCountEntity.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiCallCountEntity.kt new file mode 100644 index 0000000..8b0b299 --- /dev/null +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiCallCountEntity.kt @@ -0,0 +1,56 @@ +package com.linktrip.output.persistence.mysql.entity + +import com.linktrip.application.domain.quota.ApiCallCount +import com.linktrip.application.domain.quota.ApiType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.time.LocalDate + +@Entity +@Table( + name = "api_call_count", + uniqueConstraints = [ + UniqueConstraint(name = "uk_api_call_count_type_date", columnNames = ["api_type", "call_date"]), + ], + indexes = [ + Index(name = "idx_api_call_count_date_type", columnList = "call_date, api_type"), + ], +) +class ApiCallCountEntity( + @Id + @Column(length = 36) + val id: String, + @Enumerated(EnumType.STRING) + @Column(name = "api_type", nullable = false, length = 40) + val apiType: ApiType, + @Column(name = "call_date", nullable = false) + val callDate: LocalDate, + @Column(name = "call_count", nullable = false) + var callCount: Long = 0L, +) : BaseTimeEntity() { + fun toDomain(): ApiCallCount = + ApiCallCount( + id = this.id, + apiType = this.apiType, + callDate = this.callDate, + callCount = this.callCount, + createdAt = this.createdAt, + updatedAt = this.updatedAt, + ) + + companion object { + fun from(apiCallCount: ApiCallCount): ApiCallCountEntity = + ApiCallCountEntity( + id = apiCallCount.id, + apiType = apiCallCount.apiType, + callDate = apiCallCount.callDate, + callCount = apiCallCount.callCount, + ) + } +} diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiDailyCostAlertEntity.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiDailyCostAlertEntity.kt new file mode 100644 index 0000000..8cbc059 --- /dev/null +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiDailyCostAlertEntity.kt @@ -0,0 +1,48 @@ +package com.linktrip.output.persistence.mysql.entity + +import com.linktrip.application.domain.quota.ApiDailyCostAlert +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import java.time.LocalDate + +/** + * 일자별로 마지막 발송된 비용 알림의 임계값 (KRW) 추적. + * 같은 임계값 구간에서 알림 1회만 발송되도록 함. + */ +@Entity +@Table( + name = "api_daily_cost_alert", + uniqueConstraints = [ + UniqueConstraint(name = "uk_api_daily_cost_alert_date", columnNames = ["alert_date"]), + ], +) +class ApiDailyCostAlertEntity( + @Id + @Column(length = 36) + val id: String, + @Column(name = "alert_date", nullable = false) + val alertDate: LocalDate, + @Column(name = "last_sent_threshold_krw", nullable = false) + var lastSentThresholdKrw: Long, +) : BaseTimeEntity() { + fun toDomain(): ApiDailyCostAlert = + ApiDailyCostAlert( + id = this.id, + alertDate = this.alertDate, + lastSentThresholdKrw = this.lastSentThresholdKrw, + createdAt = this.createdAt, + updatedAt = this.updatedAt, + ) + + companion object { + fun from(alert: ApiDailyCostAlert): ApiDailyCostAlertEntity = + ApiDailyCostAlertEntity( + id = alert.id, + alertDate = alert.alertDate, + lastSentThresholdKrw = alert.lastSentThresholdKrw, + ) + } +} From 4053ff5f0ecf1dd72693ae3b89c39b7b031025d2 Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:45:48 +0900 Subject: [PATCH 06/29] =?UTF-8?q?feat:=20API=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=20atomic=20+1=20Querydsl=20+=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20JpaRepository=20=EC=B6=94=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ApiCallCountJpaRepository.kt | 15 +++++++++ .../ApiCallCountQuerydslRepository.kt | 31 +++++++++++++++++++ .../ApiDailyCostAlertJpaRepository.kt | 9 ++++++ 3 files changed, 55 insertions(+) create mode 100644 linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountJpaRepository.kt create mode 100644 linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountQuerydslRepository.kt create mode 100644 linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiDailyCostAlertJpaRepository.kt diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountJpaRepository.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountJpaRepository.kt new file mode 100644 index 0000000..7e8f7f1 --- /dev/null +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountJpaRepository.kt @@ -0,0 +1,15 @@ +package com.linktrip.output.persistence.mysql.repository + +import com.linktrip.application.domain.quota.ApiType +import com.linktrip.output.persistence.mysql.entity.ApiCallCountEntity +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDate + +interface ApiCallCountJpaRepository : JpaRepository { + fun findByApiTypeAndCallDate( + apiType: ApiType, + callDate: LocalDate, + ): ApiCallCountEntity? + + fun findAllByCallDate(callDate: LocalDate): List +} diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountQuerydslRepository.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountQuerydslRepository.kt new file mode 100644 index 0000000..f3a7375 --- /dev/null +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountQuerydslRepository.kt @@ -0,0 +1,31 @@ +package com.linktrip.output.persistence.mysql.repository + +import com.linktrip.application.domain.quota.ApiType +import com.linktrip.output.persistence.mysql.entity.QApiCallCountEntity +import com.querydsl.jpa.impl.JPAQueryFactory +import org.springframework.stereotype.Repository +import java.time.LocalDate + +@Repository +class ApiCallCountQuerydslRepository( + private val queryFactory: JPAQueryFactory, +) { + private val apiCallCount = QApiCallCountEntity.apiCallCountEntity + + /** + * (apiType, callDate) row 의 callCount 를 atomic 하게 1 증가. + * row 가 없으면 0 을 반환하므로 어댑터에서 INSERT 분기로 넘어간다. + */ + fun incrementCallCount( + apiType: ApiType, + callDate: LocalDate, + ): Long = + queryFactory + .update(apiCallCount) + .set(apiCallCount.callCount, apiCallCount.callCount.add(1)) + .where( + apiCallCount.apiType.eq(apiType), + apiCallCount.callDate.eq(callDate), + ) + .execute() +} diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiDailyCostAlertJpaRepository.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiDailyCostAlertJpaRepository.kt new file mode 100644 index 0000000..0621615 --- /dev/null +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiDailyCostAlertJpaRepository.kt @@ -0,0 +1,9 @@ +package com.linktrip.output.persistence.mysql.repository + +import com.linktrip.output.persistence.mysql.entity.ApiDailyCostAlertEntity +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDate + +interface ApiDailyCostAlertJpaRepository : JpaRepository { + fun findByAlertDate(alertDate: LocalDate): ApiDailyCostAlertEntity? +} From 989f856b9f97291bf13597ee644d1e05d38073ce Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:45:54 +0900 Subject: [PATCH 07/29] =?UTF-8?q?feat:=20API=20quota/cost=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=ED=99=94=20=EC=96=B4=EB=8C=91=ED=84=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(update-first=20=ED=8C=A8=ED=84=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20lost=20update=20=EC=B0=A8=EB=8B=A8)=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/ApiCallCountPersistenceAdapter.kt | 42 +++++++++++++++++++ .../ApiDailyCostAlertPersistenceAdapter.kt | 34 +++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiCallCountPersistenceAdapter.kt create mode 100644 linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiDailyCostAlertPersistenceAdapter.kt diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiCallCountPersistenceAdapter.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiCallCountPersistenceAdapter.kt new file mode 100644 index 0000000..a2124c0 --- /dev/null +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiCallCountPersistenceAdapter.kt @@ -0,0 +1,42 @@ +package com.linktrip.output.persistence.mysql.adapter + +import com.linktrip.application.domain.quota.ApiCallCount +import com.linktrip.application.domain.quota.ApiType +import com.linktrip.application.port.output.quota.ApiCallCountPersistencePort +import com.linktrip.output.persistence.mysql.entity.ApiCallCountEntity +import com.linktrip.output.persistence.mysql.repository.ApiCallCountJpaRepository +import com.linktrip.output.persistence.mysql.repository.ApiCallCountQuerydslRepository +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +/** + * 일별 API 호출 카운트 영속화 어댑터. + * + * update-first 패턴: 먼저 atomic UPDATE +1 시도, 영향 row 가 0 이면 (그날 첫 호출) 신규 INSERT. + * - +1 은 [ApiCallCountQuerydslRepository.incrementCallCount] (QueryDSL UPDATE) 로 atomic 처리해 lost update 차단. + * - row 가 없을 때 두 트랜잭션이 동시 진입하면 한쪽이 unique 제약 위반으로 rollback 되어 fail-loud. + */ +@Component +class ApiCallCountPersistenceAdapter( + private val jpaRepository: ApiCallCountJpaRepository, + private val querydslRepository: ApiCallCountQuerydslRepository, +) : ApiCallCountPersistencePort { + @Transactional + override fun increment(apiCallCount: ApiCallCount) { + val updated = querydslRepository.incrementCallCount(apiCallCount.apiType, apiCallCount.callDate) + if (updated == 0L) { + jpaRepository.save(ApiCallCountEntity.from(apiCallCount)) + } + } + + @Transactional(readOnly = true) + override fun findByApiTypeAndDate( + apiType: ApiType, + date: LocalDate, + ): ApiCallCount? = jpaRepository.findByApiTypeAndCallDate(apiType, date)?.toDomain() + + @Transactional(readOnly = true) + override fun findAllByDate(date: LocalDate): List = + jpaRepository.findAllByCallDate(date).map { it.toDomain() } +} diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiDailyCostAlertPersistenceAdapter.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiDailyCostAlertPersistenceAdapter.kt new file mode 100644 index 0000000..2d3af6f --- /dev/null +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiDailyCostAlertPersistenceAdapter.kt @@ -0,0 +1,34 @@ +package com.linktrip.output.persistence.mysql.adapter + +import com.linktrip.application.domain.quota.ApiDailyCostAlert +import com.linktrip.application.port.output.quota.ApiDailyCostAlertPersistencePort +import com.linktrip.output.persistence.mysql.entity.ApiDailyCostAlertEntity +import com.linktrip.output.persistence.mysql.repository.ApiDailyCostAlertJpaRepository +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +/** + * 일자별 비용 알림 임계값 추적 어댑터. + * + * 알림 임계값 통과 순간에만 호출되는 저빈도 경로라 단순한 SELECT → 변경/INSERT 흐름으로 처리. + * - row 가 있으면 dirty checking 으로 lastSentThresholdKrw 갱신. + * - row 가 없으면 신규 INSERT. + */ +@Component +class ApiDailyCostAlertPersistenceAdapter( + private val jpaRepository: ApiDailyCostAlertJpaRepository, +) : ApiDailyCostAlertPersistencePort { + @Transactional(readOnly = true) + override fun findLastSentThresholdKrw(date: LocalDate): Long? = jpaRepository.findByAlertDate(date)?.lastSentThresholdKrw + + @Transactional + override fun upsert(alert: ApiDailyCostAlert) { + val existing = jpaRepository.findByAlertDate(alert.alertDate) + if (existing != null) { + existing.lastSentThresholdKrw = alert.lastSentThresholdKrw + } else { + jpaRepository.save(ApiDailyCostAlertEntity.from(alert)) + } + } +} From 36b30e001b29bfa873d0a5365149bda6431b0eb0 Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:47:57 +0900 Subject: [PATCH 08/29] =?UTF-8?q?feat:=20=EC=99=B8=EB=B6=80=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=B0=98=20API=20=ED=95=9C=EB=8F=84=20/?= =?UTF-8?q?=20=EB=B9=84=EC=9A=A9=20=EC=A0=95=EC=B1=85=20=EC=96=B4=EB=8C=91?= =?UTF-8?q?=ED=84=B0=20=EA=B5=AC=ED=98=84=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/PropertiesApiCostPolicyAdapter.kt | 18 ++++++++++++++++++ .../adapter/PropertiesApiQuotaPolicyAdapter.kt | 18 ++++++++++++++++++ .../http/config/ApiQuotaPropertiesConfig.kt | 10 ++++++++++ .../http/properties/ApiCostProperties.kt | 14 ++++++++++++++ .../http/properties/ApiQuotaProperties.kt | 14 ++++++++++++++ 5 files changed, 74 insertions(+) create mode 100644 linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiCostPolicyAdapter.kt create mode 100644 linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiQuotaPolicyAdapter.kt create mode 100644 linktrip-output-http/src/main/kotlin/com/linktrip/output/http/config/ApiQuotaPropertiesConfig.kt create mode 100644 linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiCostProperties.kt create mode 100644 linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiQuotaProperties.kt diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiCostPolicyAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiCostPolicyAdapter.kt new file mode 100644 index 0000000..6adee9c --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiCostPolicyAdapter.kt @@ -0,0 +1,18 @@ +package com.linktrip.output.http.adapter + +import com.linktrip.application.domain.quota.ApiType +import com.linktrip.application.port.output.quota.ApiCostPolicyPort +import com.linktrip.output.http.properties.ApiCostProperties +import org.springframework.stereotype.Component + +@Component +class PropertiesApiCostPolicyAdapter( + private val properties: ApiCostProperties, +) : ApiCostPolicyPort { + override fun perCallKrw(apiType: ApiType): Long = + when (apiType) { + ApiType.GEMINI -> properties.gemini + ApiType.YOUTUBE_DATA -> properties.youtubeData + ApiType.GOOGLE_PLACES -> properties.googlePlaces + } +} diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiQuotaPolicyAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiQuotaPolicyAdapter.kt new file mode 100644 index 0000000..a352764 --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiQuotaPolicyAdapter.kt @@ -0,0 +1,18 @@ +package com.linktrip.output.http.adapter + +import com.linktrip.application.domain.quota.ApiType +import com.linktrip.application.port.output.quota.ApiQuotaPolicyPort +import com.linktrip.output.http.properties.ApiQuotaProperties +import org.springframework.stereotype.Component + +@Component +class PropertiesApiQuotaPolicyAdapter( + private val properties: ApiQuotaProperties, +) : ApiQuotaPolicyPort { + override fun dailyLimit(apiType: ApiType): Long? = + when (apiType) { + ApiType.GEMINI -> properties.gemini + ApiType.YOUTUBE_DATA -> properties.youtubeData + ApiType.GOOGLE_PLACES -> properties.googlePlaces + } +} diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/config/ApiQuotaPropertiesConfig.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/config/ApiQuotaPropertiesConfig.kt new file mode 100644 index 0000000..3c16bac --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/config/ApiQuotaPropertiesConfig.kt @@ -0,0 +1,10 @@ +package com.linktrip.output.http.config + +import com.linktrip.output.http.properties.ApiCostProperties +import com.linktrip.output.http.properties.ApiQuotaProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableConfigurationProperties(ApiQuotaProperties::class, ApiCostProperties::class) +class ApiQuotaPropertiesConfig diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiCostProperties.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiCostProperties.kt new file mode 100644 index 0000000..e381a00 --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiCostProperties.kt @@ -0,0 +1,14 @@ +package com.linktrip.output.http.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * API 1회 호출당 추정 비용 (KRW). yml 의 `api.cost-per-call-krw.*`. + * 0 = 비용 추적 비활성. + */ +@ConfigurationProperties(prefix = "api.cost-per-call-krw") +data class ApiCostProperties( + val gemini: Long = 0L, + val youtubeData: Long = 0L, + val googlePlaces: Long = 0L, +) diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiQuotaProperties.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiQuotaProperties.kt new file mode 100644 index 0000000..731992c --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiQuotaProperties.kt @@ -0,0 +1,14 @@ +package com.linktrip.output.http.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * 외부 API 일일 호출 한도 (yml 의 `api.daily-quota.*`). + * null 또는 미설정 = 한도 무시. + */ +@ConfigurationProperties(prefix = "api.daily-quota") +data class ApiQuotaProperties( + val gemini: Long? = null, + val youtubeData: Long? = null, + val googlePlaces: Long? = null, +) From 0968cbd5825bb713901fb9a8a1873111fcec1d37 Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:48:13 +0900 Subject: [PATCH 09/29] =?UTF-8?q?feat:=20Discord=20=EB=B9=84=EC=9A=A9=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=A4=ED=8C=85=20=EC=B6=94=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/DiscordNotificationAdapter.kt | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/DiscordNotificationAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/DiscordNotificationAdapter.kt index f86fb0d..2b66068 100644 --- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/DiscordNotificationAdapter.kt +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/DiscordNotificationAdapter.kt @@ -1,5 +1,6 @@ package com.linktrip.output.http.adapter +import com.linktrip.application.domain.notification.CostAlertEvent import com.linktrip.application.domain.notification.ExceptionAlertEvent import com.linktrip.application.port.output.notification.NotificationPort import com.linktrip.output.http.properties.DiscordNotificationProperties @@ -33,6 +34,24 @@ class DiscordNotificationAdapter( } } + override fun sendCostAlert(event: CostAlertEvent) { + val message = buildCostMessage(event) + val payload = mapOf("content" to message) + runCatching { + discordRestClient.post() + .uri(properties.webhookUrlError) + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .toBodilessEntity() + }.onFailure { e -> + logger.warn(e) { + "디스코드 웹훅 전송 실패 " + + "(date=${event.date}, total=${event.breakdown.totalKrw}원)" + } + } + } + private fun buildMessage(event: ExceptionAlertEvent): String { val fullMessage = """ @@ -54,4 +73,29 @@ class DiscordNotificationAdapter( fullMessage.take(limit) + "\n\n...(truncated)" } } + + private fun buildCostMessage(event: CostAlertEvent): String { + val perApiLines = + event.breakdown.nonZero() + .joinToString("\n") { item -> " - ${item.apiType}: ${item.costKrw}원" } + + val fullMessage = + """ + 💸 <@${properties.mentionUserId}> + + 💰 [Linktrip 외부 API 비용 알림] + 📅 일자: ${event.date} + 💵 누적 비용: ${event.breakdown.totalKrw}원 (${event.thresholdKrw}원 임계값 도달) + + 📊 API 별: + $perApiLines + """.trimIndent() + + val limit = 1000 + return if (fullMessage.length <= limit) { + fullMessage + } else { + fullMessage.take(limit) + "\n\n...(truncated)" + } + } } From 5c2e7cea1c559ec44cc8a1b2e7e0d284e2449738 Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:48:20 +0900 Subject: [PATCH 10/29] =?UTF-8?q?feat:=20Gemini=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=A7=81=EC=A0=84=20=ED=95=9C=EB=8F=84=20=EA=B0=80=EB=93=9C=20?= =?UTF-8?q?+=20=EC=A7=81=ED=9B=84=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20?= =?UTF-8?q?=EC=99=80=EC=9D=B4=EC=96=B4=EC=97=85=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../output/http/adapter/VideoAnalyzeAdapter.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt index d5b3bfc..3b1fca4 100644 --- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt @@ -6,6 +6,9 @@ import com.google.genai.Client import com.google.genai.types.Content import com.google.genai.types.HttpOptions import com.google.genai.types.Part +import com.linktrip.application.domain.quota.ApiCallCounterService +import com.linktrip.application.domain.quota.ApiQuotaGuardService +import com.linktrip.application.domain.quota.ApiType import com.linktrip.application.domain.video.VideoAnalysisResult import com.linktrip.application.port.output.external.VideoAnalyzePort import com.linktrip.common.exception.ExceptionCode @@ -31,6 +34,8 @@ class VideoAnalyzeAdapter( private val gcpProperties: GcpProperties, private val objectMapper: ObjectMapper, private val transcriptClient: YoutubeTranscriptClient, + private val apiQuotaGuardService: ApiQuotaGuardService, + private val apiCallCounterService: ApiCallCounterService, ) : VideoAnalyzePort { private val credentials: GoogleCredentials by lazy { FileInputStream(gcpProperties.credentialsPath).use { stream -> @@ -112,6 +117,9 @@ class VideoAnalyzeAdapter( transcript: String, videoId: String, ): VideoAnalysisResult { + if (apiQuotaGuardService.isExceeded(ApiType.GEMINI)) { + throw LinktripException(ExceptionCode.BAD_GATEWAY_GEMINI) + } try { val response = client.models.generateContent( @@ -121,6 +129,7 @@ class VideoAnalyzeAdapter( ), null, ) + apiCallCounterService.recordSuccess(ApiType.GEMINI) val rawText = response.text() logger.debug { "Gemini 응답 길이: ${rawText?.length ?: 0}자" } @@ -159,6 +168,9 @@ class VideoAnalyzeAdapter( * 자막 추출 자체가 불가능한 영상에 대한 fallback 으로 와이어업할 경우에만 사용한다. */ private fun analyzeByAiVideoIngestion(youtubeUrl: String): VideoAnalysisResult { + if (apiQuotaGuardService.isExceeded(ApiType.GEMINI)) { + throw LinktripException(ExceptionCode.BAD_GATEWAY_GEMINI) + } try { val response = client.models.generateContent( @@ -169,6 +181,7 @@ class VideoAnalyzeAdapter( ), null, ) + apiCallCounterService.recordSuccess(ApiType.GEMINI) val jsonText = stripMarkdownCodeBlock(response.text()) val aiResponse = objectMapper.readValue(jsonText, AiApiResponse::class.java) From b1eddc8a7f3d2689936003680ed5e5877d5aebb2 Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:48:28 +0900 Subject: [PATCH 11/29] =?UTF-8?q?feat:=20YouTube=20Data=20API=206=EA=B0=9C?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=EC=B2=98=20=ED=95=9C=EB=8F=84=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C=20+=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20=EC=99=80?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EC=97=85=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../output/http/adapter/YouTubeAdapter.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YouTubeAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YouTubeAdapter.kt index a1f4105..fb9be7a 100644 --- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YouTubeAdapter.kt +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YouTubeAdapter.kt @@ -1,10 +1,15 @@ package com.linktrip.output.http.adapter +import com.linktrip.application.domain.quota.ApiCallCounterService +import com.linktrip.application.domain.quota.ApiQuotaGuardService +import com.linktrip.application.domain.quota.ApiType import com.linktrip.application.domain.youtube.YouTubeChannelDetail import com.linktrip.application.domain.youtube.YouTubeRecentVideo import com.linktrip.application.domain.youtube.YouTubeSearchResult import com.linktrip.application.domain.youtube.YouTubeVideoMeta import com.linktrip.application.port.output.external.YouTubePort +import com.linktrip.common.exception.ExceptionCode +import com.linktrip.common.exception.LinktripException import com.linktrip.output.http.dto.youtube.YouTubeChannelResponse import com.linktrip.output.http.dto.youtube.YouTubePlaylistItemResponse import com.linktrip.output.http.dto.youtube.YouTubeSearchResponse @@ -18,11 +23,14 @@ import org.springframework.web.client.body class YouTubeAdapter( private val youtubeProperties: YouTubeProperties, private val youtubeRestClient: RestClient, + private val apiQuotaGuardService: ApiQuotaGuardService, + private val apiCallCounterService: ApiCallCounterService, ) : YouTubePort { override fun searchVideos( query: String, maxResults: Int, ): List { + guardOrThrow() val response = youtubeRestClient.get() .uri { builder -> @@ -39,6 +47,7 @@ class YouTubeAdapter( } .retrieve() .body() + apiCallCounterService.recordSuccess(ApiType.YOUTUBE_DATA) return response?.items ?.filter { it.id.videoId != null } @@ -61,6 +70,7 @@ class YouTubeAdapter( if (videoIds.isEmpty()) return emptyList() return videoIds.chunked(MAX_IDS_PER_REQUEST).flatMap { chunk -> + guardOrThrow() val response = youtubeRestClient.get() .uri { builder -> @@ -73,6 +83,7 @@ class YouTubeAdapter( } .retrieve() .body() + apiCallCounterService.recordSuccess(ApiType.YOUTUBE_DATA) response?.items?.map { item -> YouTubeVideoMeta.create( @@ -102,6 +113,7 @@ class YouTubeAdapter( maxResults: Int, topicId: String?, ): List { + guardOrThrow() val searchResponse = youtubeRestClient.get() .uri { builder -> @@ -121,6 +133,7 @@ class YouTubeAdapter( } .retrieve() .body() + apiCallCounterService.recordSuccess(ApiType.YOUTUBE_DATA) val channelIds = searchResponse?.items @@ -134,6 +147,7 @@ class YouTubeAdapter( if (channelIds.isEmpty()) return emptyList() return channelIds.chunked(MAX_IDS_PER_REQUEST).flatMap { chunk -> + guardOrThrow() val response = youtubeRestClient.get() .uri { builder -> @@ -146,6 +160,7 @@ class YouTubeAdapter( } .retrieve() .body() + apiCallCounterService.recordSuccess(ApiType.YOUTUBE_DATA) response?.items?.map { item -> YouTubeChannelDetail( @@ -168,6 +183,7 @@ class YouTubeAdapter( ): List { val uploadsPlaylistId = channelId.replaceFirst("UC", "UU") + guardOrThrow() val response = youtubeRestClient.get() .uri { builder -> @@ -181,6 +197,7 @@ class YouTubeAdapter( } .retrieve() .body() + apiCallCounterService.recordSuccess(ApiType.YOUTUBE_DATA) val candidates = response?.items?.mapNotNull { item -> @@ -214,6 +231,7 @@ class YouTubeAdapter( if (videoIds.isEmpty()) return emptyMap() return videoIds.chunked(MAX_IDS_PER_REQUEST).flatMap { chunk -> + guardOrThrow() val response = youtubeRestClient.get() .uri { builder -> @@ -226,6 +244,7 @@ class YouTubeAdapter( } .retrieve() .body() + apiCallCounterService.recordSuccess(ApiType.YOUTUBE_DATA) response?.items?.mapNotNull { item -> val categoryId = item.snippet?.categoryId ?: return@mapNotNull null @@ -234,6 +253,12 @@ class YouTubeAdapter( }.toMap() } + private fun guardOrThrow() { + if (apiQuotaGuardService.isExceeded(ApiType.YOUTUBE_DATA)) { + throw LinktripException(ExceptionCode.BAD_GATEWAY_YOUTUBE) + } + } + companion object { private const val SEARCH_URI = "/search" private const val VIDEOS_URI = "/videos" From 435f4f4bfd5f4ef6f43f1912fb9370d33566403c Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:48:31 +0900 Subject: [PATCH 12/29] =?UTF-8?q?feat:=20Google=20Places=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=A7=81=EC=A0=84=20=ED=95=9C=EB=8F=84=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C=20+=20=EC=A7=81=ED=9B=84=20=EC=B9=B4=EC=9A=B4?= =?UTF-8?q?=ED=84=B0=20=EC=99=80=EC=9D=B4=EC=96=B4=EC=97=85=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../output/http/adapter/GooglePlacesAdapter.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/GooglePlacesAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/GooglePlacesAdapter.kt index 339cd5e..d95e442 100644 --- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/GooglePlacesAdapter.kt +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/GooglePlacesAdapter.kt @@ -1,18 +1,26 @@ package com.linktrip.output.http.adapter import com.google.auth.oauth2.GoogleCredentials +import com.linktrip.application.domain.quota.ApiCallCounterService +import com.linktrip.application.domain.quota.ApiQuotaGuardService +import com.linktrip.application.domain.quota.ApiType import com.linktrip.application.domain.video.PlaceSearchResult import com.linktrip.application.port.output.external.GooglePlacesPort +import com.linktrip.common.exception.ExceptionCode +import com.linktrip.common.exception.LinktripException import com.linktrip.output.http.properties.GcpProperties import org.springframework.http.MediaType import org.springframework.stereotype.Component import org.springframework.web.client.RestClient +import org.springframework.web.client.body import java.io.FileInputStream @Component class GooglePlacesAdapter( private val gcpProperties: GcpProperties, private val googlePlacesRestClient: RestClient, + private val apiQuotaGuardService: ApiQuotaGuardService, + private val apiCallCounterService: ApiCallCounterService, ) : GooglePlacesPort { private val credentials: GoogleCredentials by lazy { GoogleCredentials.fromStream(FileInputStream(gcpProperties.credentialsPath)) @@ -23,6 +31,10 @@ class GooglePlacesAdapter( name: String, destination: String?, ): PlaceSearchResult? { + if (apiQuotaGuardService.isExceeded(ApiType.GOOGLE_PLACES)) { + throw LinktripException(ExceptionCode.BAD_GATEWAY_GOOGLE_PLACES) + } + val query = if (destination != null) "$name $destination" else name credentials.refreshIfExpired() @@ -40,7 +52,8 @@ class GooglePlacesAdapter( ), ) .retrieve() - .body(TextSearchResponse::class.java) + .body() + apiCallCounterService.recordSuccess(ApiType.GOOGLE_PLACES) val place = response?.places?.firstOrNull() ?: return null From 6c250e2dab0464ee335d7c749b83a4be3fa7f280 Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:48:35 +0900 Subject: [PATCH 13/29] =?UTF-8?q?feat:=20=EC=98=81=EC=83=81=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=ED=81=90=20=EC=BB=A8=EC=8A=88=EB=A8=B8=20dequeue?= =?UTF-8?q?=20=EB=8B=A8=EA=B3=84=20=ED=95=9C=EB=8F=84=20=EA=B0=80=EB=93=9C?= =?UTF-8?q?=20+=2060=EC=B4=88=20=EB=B0=B1=EC=98=A4=ED=94=84=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/video/VideoAnalysisQueueConsumer.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumer.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumer.kt index e960762..67ab601 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumer.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumer.kt @@ -1,5 +1,6 @@ package com.linktrip.application.domain.video +import com.linktrip.application.domain.quota.ApiQuotaGuardService import com.linktrip.application.domain.trip.TripPlanService import com.linktrip.application.port.output.external.VideoAnalysisNotificationPort import com.linktrip.application.port.output.external.VideoAnalyzePort @@ -26,6 +27,7 @@ class VideoAnalysisQueueConsumer( private val tripPlanRequestPort: TripPlanRequestPersistencePort, private val tripPlanService: TripPlanService, private val rateLimitBucketStore: RateLimitBucketStore, + private val apiQuotaGuardService: ApiQuotaGuardService, ) { fun startConsuming() { Thread({ consumeLoop() }, "VideoAnalysisQueueConsumer").apply { @@ -38,6 +40,11 @@ class VideoAnalysisQueueConsumer( private fun consumeLoop() { while (!Thread.currentThread().isInterrupted) { try { + if (apiQuotaGuardService.isAnyApiExceeded()) { + logger.warn { "외부 API 일일 한도 초과 — ${QUOTA_BACKOFF_MS / 1000}초 대기" } + Thread.sleep(QUOTA_BACKOFF_MS) + continue + } val event = videoAnalysisQueuePort.dequeue() ?: continue processAnalysis(event) } catch (_: InterruptedException) { @@ -209,5 +216,8 @@ class VideoAnalysisQueueConsumer( companion object { private const val RATE_LIMIT_KEY = "gemini-api" + + /** API 한도 초과 시 다음 가드 체크까지 대기 시간. 자정 후 재개 지연 / 로그 노이즈 절충. */ + private const val QUOTA_BACKOFF_MS = 60_000L } } From c715e77cecc6e6542b5ea79e5d5678ef9edd519a Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:48:41 +0900 Subject: [PATCH 14/29] =?UTF-8?q?docs:=20README=20=EC=97=90=20quota=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20/=20webshare=20=EC=99=B8=EB=B6=80=20API=20?= =?UTF-8?q?/=20=EC=9D=B8=EB=A9=94=EB=AA=A8=EB=A6=AC=20=ED=81=90=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EB=B0=98=EC=98=81=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 302 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 258 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 7553299..1078162 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ | Database | MySQL | | Cache | Caffeine Cache | | Infra | AWS EC2, Docker | -| External API | YouTube Data v3, Gemini 2.5 Flash, Google Places API, Discord Webhook | +| External API | YouTube Data v3, Gemini 2.5 Flash, Google Places API, Discord Webhook, webshare proxy | --- @@ -25,12 +25,17 @@ linktrip-bootstrap ← Spring Boot 진입점, DI 조립 ├── linktrip-input-batch ← Spring Batch Job & Scheduler │ ├── linktrip-application ← 핵심 비즈니스 로직 (Port & Service) -│ ├── domain/ ← 도메인 서비스 +│ ├── domain/ ← 도메인 모델 + 도메인 서비스 +│ │ ├── video/ ← 영상 분석 도메인 +│ │ ├── youtube/ ← 영상 수집 도메인 +│ │ ├── trip/ ← 여행 계획 도메인 +│ │ ├── quota/ ← 외부 API 호출량 가드 + 카운터 + 비용 알림 +│ │ └── notification/ ← 알림 이벤트 │ ├── port/input/ ← UseCase 인터페이스 │ └── port/output/ ← Output Port 인터페이스 │ -├── linktrip-output-http ← 외부 API 어댑터 (YouTube, Gemini, Places, Discord) -├── linktrip-output-cache ← Caffeine 캐시 데코레이터 어댑터 +├── linktrip-output-http ← 외부 API 어댑터 (YouTube, Gemini, Places, Discord, webshare) +├── linktrip-output-cache ← Caffeine 캐시 데코레이터 어댑터 + 인메모리 큐 ├── linktrip-output-persistence ← MySQL JPA + QueryDSL 어댑터 └── linktrip-common ← 공통 예외, 이벤트, 설정 ``` @@ -67,10 +72,11 @@ linktrip-bootstrap ← Spring Boot 진입점, DI 조립 ▲ ▲ ▲ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ output-cache │ │ output- │ │ output-http │ - │ (Caffeine) │ │ persistence │ │ (YouTube, │ - │ │ │ (MySQL/JPA) │ │ Gemini, │ - │ @Primary │ │ @Qualifier │ │ Places, │ - └──────┬───────┘ └──────────────┘ │ Discord) │ + │ (Caffeine + │ │ persistence │ │ (YouTube, │ + │ in-memory │ │ (MySQL/JPA) │ │ Gemini, │ + │ queue) │ │ @Qualifier │ │ Places, │ + │ @Primary │ │ │ │ Discord, │ + └──────┬───────┘ └──────────────┘ │ webshare) │ │ ↑ └──────────────┘ │ delegate │ └───────────────┘ @@ -109,39 +115,132 @@ Caffeine Cache (TTL 7시간, 최대 100건) ## 영상 분석 파이프라인 +전체 흐름은 **요청 처리 (트랜잭션)** → **이벤트 발행/수신 (큐 적재)** → **백그라운드 소비 (실제 분석)** 3 단계로 분리되어 있습니다. + ``` +[ ① 요청 처리 — 트랜잭션 안 ] + POST /video/analyze { youtubeUrl } │ ▼ -┌────────────────────────────────────────────────┐ -│ VideoAnalyzeService │ -│ │ -│ URL 정규화 → 중복 확인 → Task 생성 (PENDING) │ -│ → 요청 대기열 등록 (memberId) │ -│ → Event 발행 → 202 Accepted 즉시 반환 │ -└─────────────────────┬──────────────────────────┘ - │ @Async + @TransactionalEventListener - ▼ -┌────────────────────────────────────────────────┐ -│ VideoAnalyzeEventListener │ -│ │ -│ Gemini 2.5 Flash 영상 분석 │ -│ │ │ -│ 유효한 여행 영상? │ -│ ├── No → INVALID │ -│ ▼ Yes │ -│ 일정 저장 (EAT / ATTRACTION / SHOPPING / etc.) │ -│ │ │ -│ 대기열 조회 → 각 요청자별 TripPlan 생성 │ -│ │ │ -│ Google Places API 좌표 매핑 (병렬) │ -│ │ │ -│ 완료 알림 발송 │ -└────────────────────────────────────────────────┘ - │ - ▼ +┌────────────────────────────────────────────────────────────┐ +│ VideoAnalyzeService │ +│ │ +│ URL 정규화 → 중복 확인 │ +│ ├─ COMPLETED 영상이면 → schedule 즉시 로드 + 인라인 200 │ +│ ├─ INVALID 영상이면 → 200 + status=INVALID │ +│ └─ 신규 / PENDING / FAILED │ +│ → Task 생성/전이 (PENDING) │ +│ → 요청 대기열 등록 (memberId) │ +│ → Events.raise(VideoAnalyzeEvent) ← Spring 이벤트 발행 │ +│ → 202 Accepted │ +└────────────────────────┬───────────────────────────────────┘ + │ AFTER_COMMIT (트랜잭션 커밋 후에만) + ▼ +[ ② 이벤트 수신 — @TransactionalEventListener ] + +┌────────────────────────────────────────────────────────────┐ +│ VideoAnalyzeEventListener (@Async) │ +│ │ +│ videoAnalysisQueuePort.enqueue(taskId, url, source) │ +│ → PriorityBlockingQueue 에 적재 │ +└────────────────────────┬───────────────────────────────────┘ + │ 큐에 enqueue 됨 + ▼ +[ ③ 큐 소비 — 별도 데몬 스레드 ] + +┌────────────────────────────────────────────────────────────┐ +│ VideoAnalysisQueueConsumer (단일 스레드) │ +│ │ +│ loop guard: 외부 API 일일 한도 초과 시 dequeue 차단 │ +│ │ │ +│ USER 우선순위 dequeue (BATCH 는 USER 비었을 때만) │ +│ │ │ +│ 1단계: 자막 추출 (webshare 프록시 라운드로빈) │ +│ ├── 한국 IP 우선 → JP → TW → ... 10개 sticky │ +│ ├── 429/403 시 다음 프록시로 즉시 swap │ +│ └── 모호 실패 → sentinel 영상으로 IP 차단 vs 자막 없음 │ +│ 구분 │ +│ │ │ +│ 2단계: Gemini 2.5 Flash 자막 분석 │ +│ │ │ +│ 결과 저장 (EAT / ATTRACTION / SHOPPING / TRANSPORTATION) │ +│ │ │ +│ 대기열 조회 → 각 요청자별 TripPlan 생성 │ +│ │ │ +│ Google Places 좌표 매핑 (코루틴 병렬) │ +│ │ │ +│ 완료 알림 발송 │ +└────────────────────────────────────────────────────────────┘ + │ + ▼ GET /video/{id}/schedule → 일정표 + 장소 좌표 반환 +``` + +### 이벤트 기반 비동기 흐름 — 왜 이렇게 분리? + +``` +VideoAnalyzeService VideoAnalyzeEventListener VideoAnalysisQueueConsumer +───────────────────── ───────────────────────── ─────────────────────────── +@Transactional @Async + @TransactionalEvent 데몬 스레드 (부팅 시 시작) + │ Listener(AFTER_COMMIT) │ + │ │ │ + │ Events.raise(event) ────────────────► │ │ + │ │ │ + │ ... 트랜잭션 커밋 ... │ │ + │ │ (커밋 후에야 호출됨) │ + │ 202 응답 반환 ◄───────── │ │ + │ │ + │ queuePort.enqueue() ───────► │ + │ │ + │ dequeue → 분석 + │ Gemini, Places, 알림 +``` + +**3 단계로 분리한 이유**: + +1. **`@TransactionalEventListener(AFTER_COMMIT)` — DB 커밋 후에만 큐 적재** + - `Events.raise()` 호출 시점이 아니라 **트랜잭션 커밋 직후** 이벤트가 전달됨 + - 만약 `analyzeVideo` 트랜잭션이 롤백되면 이벤트도 발행되지 않음 + - → DB 에 task 가 없는데 큐에만 좀비 이벤트가 남는 상황 방지 (consistency) + +2. **`@Async("VideoAnalyzeExecutor")` — 사용자 응답을 빠르게** + - 큐 적재 자체는 별도 스레드 풀에서 + - HTTP 응답은 `Events.raise()` 만 마치고 곧장 202 반환 (수 ms) + - 사용자는 큐 적재를 기다리지 않음 + +3. **Consumer 는 별도 데몬 스레드** + - HTTP 요청 흐름과 완전히 분리 + - `PriorityBlockingQueue.poll(1초)` 로 큐를 폴링하며 dequeue + - quota 가드가 dequeue 자체를 차단할 수 있어 비용 폭주 시점에 깨끗이 멈춤 + +**메시지 브로커 (Kafka/RabbitMQ) 대신 in-memory 큐를 쓴 이유**: +- 단일 인스턴스 운영 → 분산 큐 불필요 +- in-memory `PriorityBlockingQueue` 가 USER/BATCH 우선순위 + FIFO 정렬을 단일 자료구조로 처리 +- 부팅 시 PENDING 인 task 를 DB 에서 다시 로드해 큐에 재적재 (`ApplicationInitializer`) → 인스턴스 재시작에도 누락 없음 +- 5분마다 `VideoAnalysisRetryJob` 이 stale PENDING 을 재 enqueue → 큐 자체가 사고로 비더라도 self-healing + +### POST 응답 형태 + +`POST /video/analyze` 는 status 에 따라 응답이 달라집니다 (단일 shape `VideoAnalyzeResponse`). + +| status | HTTP | 응답 데이터 | 설명 | +|---|---|---|---| +| `COMPLETED` | 200 | 결과 인라인 (요약 / 일정 / 타임라인) | 추가 폴링 불필요 | +| `INVALID` | 200 | id / url / status 만 | 자막 없음 등 영구 종료 | +| `PENDING` / `PROCESSING` | 202 | id / url / status 만 | 클라이언트 폴링 | + +### 자막 실패 분류 (sentinel ping) +라이브러리(`youtube-transcript-api`) 가 단일 예외 타입만 던져 원인 모호 → sentinel 영상 ping 으로 분류: + +``` +자막 추출 시도 + ├─ HTTP 429/403 → ProxyYoutubeClient 가 즉시 다음 프록시 시도 + ├─ 라이브러리 TRE (모호) → sentinel ping + │ ├─ sentinel 정상 → 영상 고유 문제 (자막 없음 등) → INVALID + │ └─ sentinel 실패 → 전 프록시 차단 확정 → PENDING + └─ 자막 추출 성공 ``` ### 동시 요청 처리 @@ -159,7 +258,7 @@ EventListener → 대기열 조회 → [User A, User B] → TripPlan 각각 생 ``` 분석 완료 후 요청하는 경우: -User C: POST /analyze (영상 X) → COMPLETED 확인 → TripPlan 즉시 생성 (대기열 불필요) +User C: POST /analyze (영상 X) → COMPLETED 확인 → TripPlan 즉시 생성 + 응답에 결과 인라인 ``` ``` @@ -168,33 +267,138 @@ User C: POST /analyze (영상 X) → COMPLETED 확인 → TripPlan 즉시 생성 분석 완료 커밋과 대기열 조회 사이에 등록된 요청이 누락될 수 있음 → GET /schedule 조회 시 TripPlan이 없으면 즉시 생성 (Lazy 보완) → EXISTS 쿼리는 (member_id, video_analysis_task_id) 커버링 인덱스로 처리 - ``` ### 상태 전이 ``` PENDING ──→ COMPLETED (분석 성공) - ├──→ INVALID (여행 영상 아님) - └──→ FAILED (AI 분석 오류) ──→ 재요청 시 PENDING 복원 + ├──→ INVALID (자막 없음 / 여행 영상 아님) + ├──→ FAILED (AI 분석 오류) ──→ 재요청 시 PENDING 복원 + └──→ PENDING (IP 차단 / 일시 오류) ──→ 5분 후 재시도 배치가 픽업 +``` + +--- + +## 큐 우선순위 + +큐 한 개 안에서 사용자 요청과 시스템 배치를 분리해 사용자 응답을 보장합니다. + +### Source — 호출 출처 + +| Source | priority | 발생 | +|---|---|---| +| `USER` | 0 (높음) | `POST /video/analyze` 등 사용자 직접 요청 | +| `BATCH` | 10 (낮음) | YouTube 정기 수집 / stranded 백필 등 시스템 트리거 | + +`task.source` 컬럼에 audit 으로 영구 저장. 재시도 시 원래 priority 보존. + +### 정렬 보장 + +`PriorityBlockingQueue` + sequence tiebreaker 로 다음을 보장: + +1. **priority 순**: USER 가 항상 BATCH 보다 먼저 dequeue +2. **동일 priority 내 FIFO**: 단조 증가 sequence 로 들어온 순서 보존 + +``` +들어온 순서: USER-A, BATCH-X, USER-B, USER-C, BATCH-Y + │ │ │ │ │ + seq=1 seq=2 seq=3 seq=4 seq=5 + +dequeue 순서: + USER-A (priority 0, seq 1) ←─┐ + USER-B (priority 0, seq 3) │ USER 그룹 — FIFO + USER-C (priority 0, seq 4) ←─┘ + BATCH-X (priority 10, seq 2) ←─┐ + BATCH-Y (priority 10, seq 5) ←─┘ BATCH 그룹 — FIFO ``` --- +## 외부 API 비용 보호 -### 인기 여행 영상 +API 호출 비용 폭주를 사전 차단하기 위한 가드 + 카운터 + 알림 시스템. + +### 흐름 + +``` +어댑터 메서드 (예: VideoAnalyzeAdapter.analyzeFromTranscript) + │ + ├─ ApiQuotaGuardService.isExceeded(GEMINI) + │ ├─ true → throw LinktripException (호출 안 함) + │ └─ false → 다음 + │ + ├─ 외부 API 호출 (Gemini / YouTube / Places) + │ + └─ ApiCallCounterService.recordSuccess(GEMINI) + ├─ DB UPSERT +1 (atomic) + └─ 누적 비용 = Σ (API 별 호출 수 × 단가) + └─ 마지막 알림 임계값 + 1000원 넘었는지 체크 + └─ 넘었다면 Discord 알림 + 임계값 갱신 +``` + +### 이중 안전망 + +``` +1차: VideoAnalysisQueueConsumer 의 loop guard + ├─ dequeue 직전 isAnyApiExceeded() 체크 + └─ 초과 시 60초 sleep, 큐 자체를 멈춤 (가장 빠른 방어) + +2차: 각 어댑터의 호출 직전 가드 + └─ race / 다중 호출 경로 보호 (예: PlaceEnrich 코루틴 병렬) + +3차: 사후 카운팅 + └─ 호출 직후 DB 적재 + 비용 임계값 알림 +``` + +### 설정 (application.yml) + +```yaml +api: + daily-quota: # 호출 한도 — 초과 시 dequeue 차단 + gemini: 500 + youtube-data: 9000 + google-places: 3000 + cost-per-call-krw: # 호출당 단가 (KRW) — 누적 비용 계산용 + gemini: 50 + youtube-data: 0 + google-places: 7 +``` + +### 자정 reset + +`call_date` 컬럼이 DATE 타입이라, 자정 지나면 새 row 가 자동 생성되어 가드/임계값이 초기화됩니다. + +--- + +## 인기 영상 / 크리에이터 수집 + +### YouTube 정기 수집 ``` -YouTubeCollectJob +YouTubeCollectScheduler (매시간 cron) │ - ├── 24개 키워드 중 5개 랜덤 선택 + ├── 키워드 풀에서 region 별 batch 만큼 순차 선택 ├── YouTube Search API → 키워드당 최대 10개 영상 ├── DB 중복 체크 (videoId) ├── YouTube Videos API → 조회수, 좋아요, 영상 길이 ├── 메타데이터 태깅 (region, country, city, theme) - └── DB 저장 + @CacheEvict + ├── DB 저장 + @CacheEvict + └── 신규 영상 → BATCH 우선순위로 분석 큐 enqueue +``` + +### 미처리 영상 자동 분석 (백필) + +``` +VideoAnalysisBackfillScheduler (10분 cron, 환경별 활성화) + │ + └─ youtube_video LEFT JOIN video_analysis_task + └─ task 가 없는 (= 한 번도 분석 안 된) 영상 5건 + └─ BATCH 우선순위로 분석 큐 enqueue ``` +`@ConditionalOnProperty(batch.video-analysis-backfill.enabled=true)` — 환경별 on/off. + ### 인기 크리에이터 ``` @@ -215,6 +419,16 @@ PlaceEnrichRetryJob └── Google Places API 재검색 → 성공 시 좌표 저장 / 실패 시 count++ ``` +### PENDING 분석 재시도 + +``` +VideoAnalysisRetryJobScheduler (5분 cron) + │ + └─ status=PENDING + createdAt < now-5분 + └─ task.source 그대로 보존하여 재 enqueue + (BATCH 로 만들어진 task 가 USER 로 promote 되지 않음) +``` + --- ## API @@ -222,7 +436,7 @@ PlaceEnrichRetryJob | Method | Path | 설명 | |--------|------|------| | POST | `/api/auth/login` | 디바이스 시리얼 기반 로그인 (신규 시 자동 가입) | -| POST | `/api/video/analyze` | 영상 분석 요청 (비동기, 202) | +| POST | `/api/video/analyze` | 영상 분석 요청. COMPLETED면 결과 인라인(200) / 그 외 status 반환(202) | | GET | `/api/video/{id}/schedule` | 분석 결과 일정표 + 장소 좌표 | | GET | `/api/video/discover/category` | 인기 여행 영상 (국가/지역 필터) | | GET | `/api/video/discover/theme` | 테마별 여행 영상 (커서 페이징, 40건) | From 7f4720d016bb456a749accec574ab9dfc3a7f0c3 Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 29 Apr 2026 18:49:12 +0900 Subject: [PATCH 15/29] =?UTF-8?q?feat:=20API=20=EC=9D=BC=EC=9D=BC=20?= =?UTF-8?q?=ED=95=9C=EB=8F=84=20/=20=ED=98=B8=EC=B6=9C=EB=8B=B9=20?= =?UTF-8?q?=EB=B9=84=EC=9A=A9=20yml=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/linktrip-bootstrap/src/main/resources/application.yml b/linktrip-bootstrap/src/main/resources/application.yml index 31add0a..ee7f33e 100644 --- a/linktrip-bootstrap/src/main/resources/application.yml +++ b/linktrip-bootstrap/src/main/resources/application.yml @@ -46,3 +46,17 @@ youtube: api-key: ${YOUTUBE_API_KEY} health-check: sentinel-video-id: TVM6Nswlfbg + +api: + # 외부 API 일일 호출 한도. 초과 시 컨슈머가 dequeue 자체를 멈춰 비용 발생 호출을 사전 차단. + # null/미설정 = 무제한 (가드 비활성). + daily-quota: + gemini: ${API_DAILY_QUOTA_GEMINI:500} + youtube-data: ${API_DAILY_QUOTA_YOUTUBE_DATA:9000} + google-places: ${API_DAILY_QUOTA_GOOGLE_PLACES:3000} + # API 1회 호출당 추정 비용 (KRW). 누적 비용이 1000원 단위로 임계값 넘으면 Discord 알림. + # 단가는 공급사 가격 변동에 맞춰 운영 중 조정. + cost-per-call-krw: + gemini: ${API_COST_GEMINI_KRW:50} + youtube-data: ${API_COST_YOUTUBE_DATA_KRW:0} + google-places: ${API_COST_GOOGLE_PLACES_KRW:7} From 78a34f04d852f234e0bcfd5c65c21a62183c0dda Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 10:55:28 +0900 Subject: [PATCH 16/29] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20DDL=20=EC=B6=94=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/ddl/api_call_count.sql | 24 ++++++++++++++++ sql/ddl/api_daily_cost_alert.sql | 19 +++++++++++++ sql/ddl/hashtag.sql | 17 +++++++++++ sql/ddl/member.sql | 16 +++++++++++ sql/ddl/place.sql | 22 ++++++++++++++ sql/ddl/travel_itinerary_item.sql | 26 +++++++++++++++++ sql/ddl/trip_plan.sql | 19 +++++++++++++ sql/ddl/trip_plan_item.sql | 21 ++++++++++++++ sql/ddl/trip_plan_request.sql | 23 +++++++++++++++ sql/ddl/video_analysis_task.sql | 26 +++++++++++++++++ sql/ddl/video_analysis_task_hashtag.sql | 18 ++++++++++++ sql/ddl/video_timeline.sql | 20 +++++++++++++ sql/ddl/youtube_channel.sql | 24 ++++++++++++++++ sql/ddl/youtube_recent_video.sql | 19 +++++++++++++ sql/ddl/youtube_video.sql | 38 +++++++++++++++++++++++++ 15 files changed, 332 insertions(+) create mode 100644 sql/ddl/api_call_count.sql create mode 100644 sql/ddl/api_daily_cost_alert.sql create mode 100644 sql/ddl/hashtag.sql create mode 100644 sql/ddl/member.sql create mode 100644 sql/ddl/place.sql create mode 100644 sql/ddl/travel_itinerary_item.sql create mode 100644 sql/ddl/trip_plan.sql create mode 100644 sql/ddl/trip_plan_item.sql create mode 100644 sql/ddl/trip_plan_request.sql create mode 100644 sql/ddl/video_analysis_task.sql create mode 100644 sql/ddl/video_analysis_task_hashtag.sql create mode 100644 sql/ddl/video_timeline.sql create mode 100644 sql/ddl/youtube_channel.sql create mode 100644 sql/ddl/youtube_recent_video.sql create mode 100644 sql/ddl/youtube_video.sql diff --git a/sql/ddl/api_call_count.sql b/sql/ddl/api_call_count.sql new file mode 100644 index 0000000..ff0f55d --- /dev/null +++ b/sql/ddl/api_call_count.sql @@ -0,0 +1,24 @@ +-- ============================================================ +-- api_call_count +-- 외부 API 별 일자별 호출 누적 카운트. +-- update-first 패턴: atomic UPDATE +1 시도, 그날 첫 호출 시에만 INSERT. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `api_call_count` ( + `id` VARCHAR(36) NOT NULL, + `api_type` VARCHAR(40) NOT NULL COMMENT 'GEMINI / YOUTUBE_DATA / GOOGLE_PLACES', + `call_date` DATE NOT NULL, + `call_count` BIGINT NOT NULL DEFAULT 0, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- atomic UPSERT 의 row 식별. (api_type, call_date) 조합 중복 차단. + -- ApiCallCountQuerydslRepository.incrementCallCount 의 WHERE 조건이 사용. + UNIQUE KEY `uk_api_call_count_type_date` (`api_type`, `call_date`), + + -- ApiCallCounterService.computeCostBreakdown 의 findAllByDate(date) 가 사용. + -- call_date 가 leading column 이라 해당 일자 row 를 즉시 range scan. + KEY `idx_api_call_count_date_type` (`call_date`, `api_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/api_daily_cost_alert.sql b/sql/ddl/api_daily_cost_alert.sql new file mode 100644 index 0000000..e1286a7 --- /dev/null +++ b/sql/ddl/api_daily_cost_alert.sql @@ -0,0 +1,19 @@ +-- ============================================================ +-- api_daily_cost_alert +-- 일자별 마지막으로 발송된 비용 알림 임계값 (KRW) 추적. +-- 같은 임계값 구간에 대해 알림이 두 번 나가지 않도록 함. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `api_daily_cost_alert` ( + `id` VARCHAR(36) NOT NULL, + `alert_date` DATE NOT NULL, + `last_sent_threshold_krw` BIGINT NOT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- 1일 1 row 보장. + -- ApiDailyCostAlertJpaRepository.findByAlertDate / 어댑터의 select-then-update 에서 사용. + UNIQUE KEY `uk_api_daily_cost_alert_date` (`alert_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/hashtag.sql b/sql/ddl/hashtag.sql new file mode 100644 index 0000000..578b26e --- /dev/null +++ b/sql/ddl/hashtag.sql @@ -0,0 +1,17 @@ +-- ============================================================ +-- hashtag +-- 영상 분석 결과의 해시태그 마스터 테이블. +-- video_analysis_task_hashtag 가 (task, hashtag) 매핑을 담당. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `hashtag` ( + `id` VARCHAR(36) NOT NULL, + `name` VARCHAR(50) NOT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- 동일 이름 해시태그 중복 INSERT 방지. 신규 해시태그 등록 시 존재 여부 조회에도 사용. + UNIQUE KEY `uk_hashtag_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/member.sql b/sql/ddl/member.sql new file mode 100644 index 0000000..0cd5341 --- /dev/null +++ b/sql/ddl/member.sql @@ -0,0 +1,16 @@ +-- ============================================================ +-- member +-- 사용자 계정. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `member` ( + `id` VARCHAR(36) NOT NULL, + `serial_number` VARCHAR(255) NOT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- 외부 식별자 (디바이스 시리얼 등) 의 중복 가입 방지 + 로그인/조회 키. + UNIQUE KEY `uk_member_serial_number` (`serial_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/place.sql b/sql/ddl/place.sql new file mode 100644 index 0000000..2239c87 --- /dev/null +++ b/sql/ddl/place.sql @@ -0,0 +1,22 @@ +-- ============================================================ +-- place +-- Google Places 로 보강된 장소 정보. 한 번 INSERT 후 immutable +-- (PlaceEntity 의 @PreUpdate / @PreRemove / softDelete 모두 throw 처리). +-- ============================================================ +CREATE TABLE IF NOT EXISTS `place` ( + `id` VARCHAR(36) NOT NULL, + `name` VARCHAR(255) NOT NULL, + `google_place_id` VARCHAR(255) NOT NULL, + `address` VARCHAR(500) DEFAULT NULL, + `latitude` DOUBLE DEFAULT NULL, + `longitude` DOUBLE DEFAULT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- Google Places 의 동일 장소를 여러 row 로 저장하지 않도록 차단. + -- 신규 enrich 결과가 들어올 때 기존 row 가 있는지 검증하는 키. + UNIQUE KEY `uk_place_google_place_id` (`google_place_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/travel_itinerary_item.sql b/sql/ddl/travel_itinerary_item.sql new file mode 100644 index 0000000..67fba07 --- /dev/null +++ b/sql/ddl/travel_itinerary_item.sql @@ -0,0 +1,26 @@ +-- ============================================================ +-- travel_itinerary_item +-- 한 영상 분석 task 안의 일자/순서별 여행 아이템 (식당/관광지/이동 등). +-- ============================================================ +CREATE TABLE IF NOT EXISTS `travel_itinerary_item` ( + `id` VARCHAR(36) NOT NULL, + `video_analysis_task_id` VARCHAR(36) NOT NULL, + `day` INT NOT NULL, + `item_order` INT NOT NULL, + `category` VARCHAR(30) NOT NULL COMMENT 'ATTRACTION / EAT / SHOPPING / TRANSPORTATION_HUB / TRANSPORTATION_TRANSIT', + `name` VARCHAR(255) NOT NULL, + `description` VARCHAR(500) DEFAULT NULL, + `tips` VARCHAR(500) DEFAULT NULL, + `place_id` VARCHAR(36) DEFAULT NULL, + `place_search_count` INT NOT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- 한 task 의 미삭제 아이템을 day → item_order 순으로 조회. + -- TravelItineraryItemQuerydslRepository / TripPlanQuerydslRepository 의 + -- itinerary 조회 / 정렬 쿼리가 사용. deleted 를 키 안에 둬서 soft delete 항목 자동 제외. + KEY `idx_itinerary_item_task_deleted_day_order` (`video_analysis_task_id`, `deleted`, `day`, `item_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/trip_plan.sql b/sql/ddl/trip_plan.sql new file mode 100644 index 0000000..86e7de0 --- /dev/null +++ b/sql/ddl/trip_plan.sql @@ -0,0 +1,19 @@ +-- ============================================================ +-- trip_plan +-- 멤버가 특정 영상 분석 결과로부터 생성한 여행 계획. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `trip_plan` ( + `id` VARCHAR(36) NOT NULL, + `member_id` VARCHAR(36) NOT NULL, + `video_analysis_task_id` VARCHAR(36) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- 동일 멤버가 동일 영상에 대해 여행 계획을 두 번 생성하지 않도록 차단. + -- 멤버의 task 별 plan 존재 여부 조회 키로도 사용. + UNIQUE KEY `uk_trip_plan_member_task` (`member_id`, `video_analysis_task_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/trip_plan_item.sql b/sql/ddl/trip_plan_item.sql new file mode 100644 index 0000000..086f478 --- /dev/null +++ b/sql/ddl/trip_plan_item.sql @@ -0,0 +1,21 @@ +-- ============================================================ +-- trip_plan_item +-- 여행 계획에 속한 일자/순서별 아이템. +-- travel_itinerary_item 과 1:1 로 연결되며, 일자/순서는 trip_plan 안에서 재정렬 가능. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `trip_plan_item` ( + `id` VARCHAR(36) NOT NULL, + `trip_plan_id` VARCHAR(36) NOT NULL, + `travel_itinerary_item_id` VARCHAR(36) NOT NULL, + `day` INT NOT NULL, + `item_order` INT NOT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- 한 trip_plan 의 미삭제 아이템을 day → item_order 순으로 조회. + -- TripPlanItemQuerydslRepository / TripPlanQuerydslRepository 의 plan 상세 조회가 사용. + KEY `idx_trip_plan_item_plan_deleted_day_order` (`trip_plan_id`, `deleted`, `day`, `item_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/trip_plan_request.sql b/sql/ddl/trip_plan_request.sql new file mode 100644 index 0000000..e10e143 --- /dev/null +++ b/sql/ddl/trip_plan_request.sql @@ -0,0 +1,23 @@ +-- ============================================================ +-- trip_plan_request +-- 멤버가 특정 영상 분석 task 에 대해 여행 계획 생성을 요청한 기록. +-- 분석 완료 시점에 미처리 (processed=false) 요청을 찾아 trip_plan 으로 전환. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `trip_plan_request` ( + `id` VARCHAR(36) NOT NULL, + `member_id` VARCHAR(36) NOT NULL, + `video_analysis_task_id` VARCHAR(36) NOT NULL, + `processed` BIT(1) NOT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- 동일 멤버가 동일 task 에 중복 요청하지 않도록 차단 + 멤버별 요청 조회 키. + UNIQUE KEY `uk_trip_plan_request_member_task` (`member_id`, `video_analysis_task_id`), + + -- 분석 완료된 task 에 대한 미처리 요청 batch 조회. + -- TripPlanRequestQuerydslRepository 의 후속 처리 로직이 사용. + KEY `idx_trip_plan_request_task_processed` (`video_analysis_task_id`, `processed`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/video_analysis_task.sql b/sql/ddl/video_analysis_task.sql new file mode 100644 index 0000000..d73dd06 --- /dev/null +++ b/sql/ddl/video_analysis_task.sql @@ -0,0 +1,26 @@ +-- ============================================================ +-- video_analysis_task +-- YouTube 영상에 대한 분석 task. 큐 컨슈머가 PENDING → PROCESSING → COMPLETED/FAILED 로 전이. +-- source 는 task 생성 트리거 (USER / BATCH) 의 audit 기록 (immutable). +-- ============================================================ +CREATE TABLE IF NOT EXISTS `video_analysis_task` ( + `id` VARCHAR(36) NOT NULL, + `youtube_url` VARCHAR(512) NOT NULL, + `valid` BIT(1) NOT NULL, + `status` VARCHAR(20) NOT NULL COMMENT 'PENDING / PROCESSING / COMPLETED / FAILED / INVALID', + `source` VARCHAR(20) NOT NULL COMMENT 'USER / BATCH (audit, immutable)', + `estimated_min_cost` BIGINT DEFAULT NULL, + `estimated_max_cost` BIGINT DEFAULT NULL, + `summary` TEXT DEFAULT NULL, + `cost_basis` VARCHAR(20) DEFAULT NULL COMMENT 'ITEM_ESTIMATED / VIDEO_MENTIONED', + `destination` VARCHAR(100) DEFAULT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- 동일 YouTube URL 에 대한 분석 task 중복 생성 방지. + -- 신규 분석 요청 시 기존 task 존재 여부 조회 키로도 사용. + UNIQUE KEY `uk_video_analysis_task_youtube_url` (`youtube_url`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/video_analysis_task_hashtag.sql b/sql/ddl/video_analysis_task_hashtag.sql new file mode 100644 index 0000000..992f030 --- /dev/null +++ b/sql/ddl/video_analysis_task_hashtag.sql @@ -0,0 +1,18 @@ +-- ============================================================ +-- video_analysis_task_hashtag +-- video_analysis_task 와 hashtag 의 다대다 매핑. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `video_analysis_task_hashtag` ( + `id` VARCHAR(36) NOT NULL, + `video_analysis_task_id` VARCHAR(36) NOT NULL, + `hashtag_id` VARCHAR(36) NOT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- 동일 (task, hashtag) 매핑 중복 INSERT 차단. + -- task 별 hashtag 조회 시 leading column 이 task_id 라 그대로 활용. + UNIQUE KEY `uk_task_hashtag` (`video_analysis_task_id`, `hashtag_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/video_timeline.sql b/sql/ddl/video_timeline.sql new file mode 100644 index 0000000..f504157 --- /dev/null +++ b/sql/ddl/video_timeline.sql @@ -0,0 +1,20 @@ +-- ============================================================ +-- video_timeline +-- 영상 분석 결과의 timestamp 별 설명 (목차). +-- 한 task 안에서 timestamp_seconds 오름차순으로 노출. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `video_timeline` ( + `id` VARCHAR(36) NOT NULL, + `video_analysis_task_id` VARCHAR(36) NOT NULL, + `timestamp_seconds` INT NOT NULL, + `description` VARCHAR(255) NOT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- 한 task 의 미삭제 timeline 을 시간 순으로 조회. + -- VideoTimelineQuerydslRepository.findByVideoAnalysisTaskId 가 사용. + KEY `idx_video_timeline_task_deleted_timestamp` (`video_analysis_task_id`, `deleted`, `timestamp_seconds`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/youtube_channel.sql b/sql/ddl/youtube_channel.sql new file mode 100644 index 0000000..7596a70 --- /dev/null +++ b/sql/ddl/youtube_channel.sql @@ -0,0 +1,24 @@ +-- ============================================================ +-- youtube_channel +-- YouTube 채널 메타데이터. 채널 발견 / 구독자수 기준 추천에 사용. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `youtube_channel` ( + `id` VARCHAR(36) NOT NULL, + `channel_id` VARCHAR(64) NOT NULL, + `title` VARCHAR(500) NOT NULL, + `description` TEXT NOT NULL, + `thumbnail_url` VARCHAR(1000) NOT NULL, + `subscriber_count` BIGINT NOT NULL, + `video_count` BIGINT NOT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- YouTube 의 외부 채널 ID 중복 저장 방지 + upsert 시 기존 row 조회 키. + UNIQUE KEY `uk_youtube_channel_channel_id` (`channel_id`), + + -- 구독자수 기준 채널 정렬/조회 (인기 채널 추천 등). + KEY `idx_youtube_channel_subscriber_count` (`subscriber_count`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/youtube_recent_video.sql b/sql/ddl/youtube_recent_video.sql new file mode 100644 index 0000000..99cd641 --- /dev/null +++ b/sql/ddl/youtube_recent_video.sql @@ -0,0 +1,19 @@ +-- ============================================================ +-- youtube_recent_video +-- 채널별 최근 업로드 영상 캐시 (BaseTimeEntity 미상속 — deleted/created_at/updated_at 없음). +-- 채널 상세 조회 시 최근 영상 N 개를 즉시 반환하는 용도. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `youtube_recent_video` ( + `id` VARCHAR(36) NOT NULL, + `channel_id` VARCHAR(64) NOT NULL, + `video_id` VARCHAR(64) NOT NULL, + `title` VARCHAR(500) NOT NULL, + `thumbnail_url` VARCHAR(1000) NOT NULL, + `published_at` VARCHAR(64) NOT NULL, + + PRIMARY KEY (`id`), + + -- 채널별 최근 영상 조회 시 leading column. + -- YouTubeRecentVideoQuerydslRepository 의 채널 별 영상 일괄 조회가 사용. + KEY `idx_youtube_recent_video_channel_id` (`channel_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/sql/ddl/youtube_video.sql b/sql/ddl/youtube_video.sql new file mode 100644 index 0000000..465d5b4 --- /dev/null +++ b/sql/ddl/youtube_video.sql @@ -0,0 +1,38 @@ +-- ============================================================ +-- youtube_video +-- YouTube 영상 메타데이터. 발견 / 추천 / 분석 후보 풀에 사용. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `youtube_video` ( + `id` VARCHAR(36) NOT NULL, + `video_id` VARCHAR(32) NOT NULL, + `title` VARCHAR(500) NOT NULL, + `description` TEXT NOT NULL, + `thumbnail_url` VARCHAR(1000) NOT NULL, + `channel_id` VARCHAR(64) NOT NULL, + `channel_title` VARCHAR(500) NOT NULL, + `view_count` BIGINT NOT NULL, + `like_count` BIGINT NOT NULL, + `duration` VARCHAR(32) NOT NULL, + `published_at` VARCHAR(64) NOT NULL, + `region` VARCHAR(32) NOT NULL, + `country` VARCHAR(64) NOT NULL, + `city` VARCHAR(64) DEFAULT NULL, + `theme` VARCHAR(32) DEFAULT NULL, + `deleted` BIT(1) NOT NULL, + `created_at` DATETIME(6) DEFAULT NULL, + `updated_at` DATETIME(6) DEFAULT NULL, + + PRIMARY KEY (`id`), + + -- YouTube 의 외부 영상 ID 중복 저장 방지 + upsert 시 기존 row 조회 키. + UNIQUE KEY `uk_youtube_video_video_id` (`video_id`), + + -- 국가별 인기 영상 조회 (조회수 정렬). 추천/발견 화면이 사용. + KEY `idx_youtube_video_country_view` (`country`, `view_count`), + + -- 지역별 인기 영상 조회 (조회수 정렬). + KEY `idx_youtube_video_region_view` (`region`, `view_count`), + + -- 테마별 최신 영상 조회. YouTubeVideoQuerydslRepository.findAllByTheme / cursor 페이지네이션이 사용. + KEY `idx_youtube_video_theme_created` (`theme`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; From 523a4e82abac082fa1ab1fabc8c7d7d576a50483 Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 15:05:48 +0900 Subject: [PATCH 17/29] =?UTF-8?q?refactor:=20ApiDailyCostAlertPersistenceA?= =?UTF-8?q?dapter=20=EB=A5=BC=20select-then-update=20+=20dirty=20checking?= =?UTF-8?q?=20=EC=9C=BC=EB=A1=9C=20=EB=8B=A8=EC=88=9C=ED=99=94=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mysql/adapter/ApiDailyCostAlertPersistenceAdapter.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiDailyCostAlertPersistenceAdapter.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiDailyCostAlertPersistenceAdapter.kt index 2d3af6f..f40e073 100644 --- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiDailyCostAlertPersistenceAdapter.kt +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiDailyCostAlertPersistenceAdapter.kt @@ -20,7 +20,8 @@ class ApiDailyCostAlertPersistenceAdapter( private val jpaRepository: ApiDailyCostAlertJpaRepository, ) : ApiDailyCostAlertPersistencePort { @Transactional(readOnly = true) - override fun findLastSentThresholdKrw(date: LocalDate): Long? = jpaRepository.findByAlertDate(date)?.lastSentThresholdKrw + override fun findLastSentThresholdKrw(date: LocalDate): Long? = + jpaRepository.findByAlertDate(date)?.lastSentThresholdKrw @Transactional override fun upsert(alert: ApiDailyCostAlert) { From c981dd1d502a771ca723e5bf839f055c93b2d7db Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 15:05:52 +0900 Subject: [PATCH 18/29] =?UTF-8?q?fix:=20=EA=B0=80=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EB=88=84=EB=9D=BD=20=EC=8B=9C=20LinktripException(?= =?UTF-8?q?INTERNAL=5FQUOTA=5FPOLICY=5FNOT=5FCONFIGURED)=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20fail-closed=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/domain/quota/ApiQuotaGuardService.kt | 6 +++++- .../kotlin/com/linktrip/common/exception/ExceptionCode.kt | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardService.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardService.kt index ff4d55a..0b58691 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardService.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardService.kt @@ -2,6 +2,8 @@ package com.linktrip.application.domain.quota import com.linktrip.application.port.output.quota.ApiCallCountPersistencePort import com.linktrip.application.port.output.quota.ApiQuotaPolicyPort +import com.linktrip.common.exception.ExceptionCode +import com.linktrip.common.exception.LinktripException import mu.KotlinLogging import org.springframework.stereotype.Service import java.time.LocalDate @@ -21,7 +23,9 @@ class ApiQuotaGuardService( fun isAnyApiExceeded(): Boolean = ApiType.entries.any { isExceeded(it) } fun isExceeded(apiType: ApiType): Boolean { - val limit = policyPort.dailyLimit(apiType) ?: return false + val limit = + policyPort.dailyLimit(apiType) + ?: throw LinktripException(ExceptionCode.INTERNAL_QUOTA_POLICY_NOT_CONFIGURED) val current = countPort.findByApiTypeAndDate(apiType, LocalDate.now())?.callCount ?: 0L val exceeded = current >= limit if (exceeded) { diff --git a/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt b/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt index 03823aa..2fde842 100644 --- a/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt +++ b/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt @@ -34,6 +34,7 @@ enum class ExceptionCode( INTERNAL_SERVER_ERROR(500, "서버 내부 오류가 발생했습니다."), INTERNAL_IMMUTABLE_DATA_DELETE(500, "불변 데이터는 삭제할 수 없습니다."), INTERNAL_IMMUTABLE_DATA_UPDATE(500, "불변 데이터는 수정할 수 없습니다."), + INTERNAL_QUOTA_POLICY_NOT_CONFIGURED(500, "외부 API 일일 한도 정책이 설정되지 않았습니다."), INTERNAL_ERROR_TEST(500, "에러 테스트용 예외입니다."), // 502 From 70824e19ad5a50ce3549428874d21e48dc0953a0 Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 15:06:19 +0900 Subject: [PATCH 19/29] =?UTF-8?q?docs:=20ApiCostPolicyPort=20/=20applicati?= =?UTF-8?q?on.yml=20=EC=A3=BC=EC=84=9D=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/port/output/quota/ApiCostPolicyPort.kt | 2 +- linktrip-bootstrap/src/main/resources/application.yml | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCostPolicyPort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCostPolicyPort.kt index 95fd70e..1fde49c 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCostPolicyPort.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCostPolicyPort.kt @@ -4,7 +4,7 @@ import com.linktrip.application.domain.quota.ApiType /** * API 별 호출 1회당 추정 비용 (KRW). yml 등 외부 소스에서 제공. - * 0 또는 null = 비용 추적 비활성. + * 0 = 비용 추적 비활성 (구현체는 항상 non-null Long 을 반환). */ interface ApiCostPolicyPort { fun perCallKrw(apiType: ApiType): Long diff --git a/linktrip-bootstrap/src/main/resources/application.yml b/linktrip-bootstrap/src/main/resources/application.yml index ee7f33e..ff8fd5b 100644 --- a/linktrip-bootstrap/src/main/resources/application.yml +++ b/linktrip-bootstrap/src/main/resources/application.yml @@ -49,7 +49,9 @@ youtube: api: # 외부 API 일일 호출 한도. 초과 시 컨슈머가 dequeue 자체를 멈춰 비용 발생 호출을 사전 차단. - # null/미설정 = 무제한 (가드 비활성). + # 환경변수 미설정 시 아래 default 값이 적용된다. 키 자체가 누락되면 + # ApiQuotaGuardService 가 fail-closed 로 throw → 가드가 조용히 비활성화되는 사고를 막는다. + # 무제한 운영이 필요하면 환경변수에 매우 큰 값을 명시할 것. daily-quota: gemini: ${API_DAILY_QUOTA_GEMINI:500} youtube-data: ${API_DAILY_QUOTA_YOUTUBE_DATA:9000} From dd454a1c1b48a207dddefbf5ec13144fa5646307 Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 15:06:30 +0900 Subject: [PATCH 20/29] =?UTF-8?q?docs:=20=EC=9E=90=EB=A7=89=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20=EC=84=B9=EC=85=98=20=EC=8B=A0=EC=84=A4=20(?= =?UTF-8?q?=ED=94=84=EB=A1=9D=EC=8B=9C=20=EB=9D=BC=EC=9A=B4=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=EB=B9=88=20+=20sentinel=20=EB=B6=84=EB=A5=98)=20+=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=20=EC=9A=94=EC=B2=AD=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=ED=99=95=EC=9E=A5=20+?= =?UTF-8?q?=20=EB=AF=B8=EC=A0=81=EC=9A=A9=20Spring=20Security=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=A0=9C=EA=B1=B0=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 213 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 187 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 1078162..10ba487 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ | 분류 | 기술 | |------|------| | Language | Kotlin 1.9.25, JDK 21 | -| Framework | Spring Boot 3.4, Spring Batch, Spring Security | +| Framework | Spring Boot 3.4, Spring Batch | | ORM / Query | JPA, QueryDSL | | Database | MySQL | | Cache | Caffeine Cache | @@ -230,45 +230,127 @@ VideoAnalyzeService VideoAnalyzeEventListener VideoAnaly | `INVALID` | 200 | id / url / status 만 | 자막 없음 등 영구 종료 | | `PENDING` / `PROCESSING` | 202 | id / url / status 만 | 클라이언트 폴링 | -### 자막 실패 분류 (sentinel ping) +### 동시 요청 처리 -라이브러리(`youtube-transcript-api`) 가 단일 예외 타입만 던져 원인 모호 → sentinel 영상 ping 으로 분류: +같은 영상을 여러 사용자가 동시에 / 시차로 요청해도 **분석은 정확히 1회만 수행**하고, 각 요청자에게 **개별 TripPlan 을 생성**합니다. +영상의 현재 상태에 따라 처리 분기가 달라지며, 분석 완료 시점에 race condition 이 발생해도 Lazy 보완으로 자가 복구합니다. -``` -자막 추출 시도 - ├─ HTTP 429/403 → ProxyYoutubeClient 가 즉시 다음 프록시 시도 - ├─ 라이브러리 TRE (모호) → sentinel ping - │ ├─ sentinel 정상 → 영상 고유 문제 (자막 없음 등) → INVALID - │ └─ sentinel 실패 → 전 프록시 차단 확정 → PENDING - └─ 자막 추출 성공 -``` +#### 시나리오 1 — 신규 영상 (User A 가 처음 요청) -### 동시 요청 처리 +`video_analysis_task` 도 없고 분석도 진행 중이 아닌 영상. **Task 생성 + 대기열 등록 + 이벤트 발행** 모두 수행. +```mermaid +sequenceDiagram + participant UA as User A + participant Svc as VideoAnalyzeService + participant DB as DB + participant Q as 분석 큐 + + UA->>Svc: POST /analyze (영상 X) + activate Svc + Note over Svc: @Transactional + Svc->>DB: SELECT task by url + DB-->>Svc: not found + Svc->>DB: INSERT task (PENDING) + Svc->>DB: INSERT trip_plan_request (User A) + Svc->>Q: VideoAnalyzeEvent 발행
(AFTER_COMMIT 에 enqueue) + Svc-->>UA: 202 Accepted + deactivate Svc ``` -같은 영상을 여러 사용자가 요청하는 경우: -분석은 1회만 수행하고, 각 요청자의 여행 계획은 개별 생성 -User A: POST /analyze (영상 X) → 새 분석 시작, 대기열 등록, Event 발행 -User B: POST /analyze (영상 X) → PENDING 확인 → 대기열 등록 (Event 발행 안 함) -...분석 완료... -EventListener → 대기열 조회 → [User A, User B] → TripPlan 각각 생성 -``` +#### 시나리오 2 — 분석 진행 중 영상 (User B 가 뒤따라 요청) -``` -분석 완료 후 요청하는 경우: +`task` 가 이미 `PENDING` / `PROCESSING`. **대기열만 등록**하고 **이벤트는 발행 안 함** → 중복 분석 차단. -User C: POST /analyze (영상 X) → COMPLETED 확인 → TripPlan 즉시 생성 + 응답에 결과 인라인 +```mermaid +sequenceDiagram + participant UB as User B + participant Svc as VideoAnalyzeService + participant DB as DB + participant Q as 분석 큐 + + UB->>Svc: POST /analyze (영상 X) + activate Svc + Svc->>DB: SELECT task by url + DB-->>Svc: PENDING (이미 큐에 있음) + Svc->>DB: INSERT trip_plan_request (User B) + Note over Svc: 이벤트 발행 ❌
큐에 중복 enqueue 안 함 + Svc-->>UB: 202 Accepted + deactivate Svc + + Note over Q,DB: ... 분석 완료 후 ... + Q->>DB: 대기열 조회 → [User A, User B] + Q->>DB: 각자에게 TripPlan 생성 ``` +#### 시나리오 3 — 이미 완료된 영상 (User C 가 나중에 요청) + +`task.status = COMPLETED` 이고 분석 결과가 DB 에 있는 상태. **분석 큐 안 거치고 즉시 TripPlan 생성 + 결과 인라인 200**. + +```mermaid +sequenceDiagram + participant UC as User C + participant Svc as VideoAnalyzeService + participant DB as DB + + UC->>Svc: POST /analyze (영상 X) + activate Svc + Svc->>DB: SELECT task by url + DB-->>Svc: COMPLETED + Svc->>DB: INSERT TripPlan (User C) + Svc->>DB: SELECT 분석 결과 (요약 / 일정 / 타임라인) + Svc-->>UC: 200 OK + 결과 인라인 + deactivate Svc ``` -경쟁 조건 보완: -분석 완료 커밋과 대기열 조회 사이에 등록된 요청이 누락될 수 있음 -→ GET /schedule 조회 시 TripPlan이 없으면 즉시 생성 (Lazy 보완) -→ EXISTS 쿼리는 (member_id, video_analysis_task_id) 커버링 인덱스로 처리 +#### Race condition + Lazy 보완 + +**문제**: 분석 완료 커밋과 Consumer 의 대기열 조회 사이의 좁은 창에서 들어온 요청은 `trip_plan_request` 에 들어가긴 하지만 Consumer 가 이미 대기열을 조회한 뒤라 **누락**될 수 있음. + +```mermaid +sequenceDiagram + participant UD as User D + participant Svc as VideoAnalyzeService + participant DB as DB + participant Q as Consumer + + Note over Q: 분석 진행 중... + Q->>DB: UPDATE task SET status = COMPLETED + Q->>DB: COMMIT + + rect rgb(255, 240, 240) + Note over UD,DB: race window — Consumer 가 대기열 조회하기 직전 + UD->>Svc: POST /analyze (영상 X) + Svc->>DB: SELECT task → COMPLETED 보임 + Note over Svc: 시나리오 3 으로 분기되어
TripPlan 즉시 생성됨 ✓ + Svc-->>UD: 200 OK + 결과 + end + + Q->>DB: 대기열 조회 → User D 빠질 수 있음 + Note over Q: 다른 분기에서 누락 시?
아래 Lazy 보완으로 복구 +``` + +**보완 — GET `/schedule` Lazy 생성**: 어떤 경로로 누락되더라도, 사용자가 일정표를 조회하는 시점에 TripPlan 이 없으면 즉시 생성합니다. 두 번째 요청부터는 정상. + +```mermaid +sequenceDiagram + participant U as User + participant Ctl as ScheduleController + participant DB as DB + + U->>Ctl: GET /video/{id}/schedule + activate Ctl + Ctl->>DB: SELECT TripPlan WHERE member_id = ? + DB-->>Ctl: not found + Note over Ctl: Lazy 보완 발동 + Ctl->>DB: INSERT TripPlan (User) + Ctl->>DB: SELECT 분석 결과 + 좌표 + Ctl-->>U: 일정표 + 장소 좌표 + deactivate Ctl ``` +**왜 EXISTS 쿼리가 빠른가**: `trip_plan` 의 `(member_id, video_analysis_task_id)` 가 **유니크 인덱스 + 커버링 인덱스** 라, "이 멤버의 plan 이 있는지" 체크가 인덱스 만으로 끝나 테이블 접근 0. + ### 상태 전이 ``` @@ -280,6 +362,85 @@ PENDING ──→ COMPLETED (분석 성공) --- +## 자막 추출 — 프록시 라운드로빈 + 실패 분류 + +YouTube 자막 API 는 IP 차단 / 지역 차단 / 영상 자체의 자막 부재 등 실패 원인이 다양한데, 라이브러리 (`youtube-transcript-api`) 가 모두 단일 예외 타입으로 던져 원인 구분이 안 됩니다. +이 시스템은 **(1) 프록시 라운드로빈으로 IP 차단을 자동 우회**하고, **(2) sentinel ping 으로 모호한 실패 원인을 분류**해 재시도 가치가 있는 실패 (`PENDING`) 와 영구 종료 (`INVALID`) 를 구별합니다. + +### 프록시 라운드로빈 (webshare) + +```mermaid +sequenceDiagram + participant Caller as 요청 + participant Proxy as ProxyYoutubeClient + participant YT as YouTube + + Note over Proxy: current = KR (sticky) + Caller->>Proxy: videoId + Proxy->>YT: GET (한국 IP) + YT-->>Proxy: HTTP 200 (자막 ok) + Proxy-->>Caller: 자막 + Note over Proxy: 다음 요청도 같은 IP 유지
(sticky session) + Caller->>Proxy: videoId + Proxy->>YT: GET (한국 IP) + YT-->>Proxy: HTTP 429 / 403 (차단) + Note over Proxy: in-flight swap
→ JP 로 즉시 교체 + Proxy->>YT: GET (일본 IP) + YT-->>Proxy: HTTP 200 + Proxy-->>Caller: 자막 +``` + +**프록시 풀 우선순위** (한국 영상이 주력 콘텐츠라 한국 IP 우선): + +``` +KR (2개) → JP (1개) → TW (2개) → MY (1개) → 나머지 (총 10개 sticky) +``` + +- **Sticky session**: 같은 IP 를 계속 사용. 매번 새 IP 로 교체하면 라운드로빈 효율보다 초기화 비용이 큼 +- **429 / 403 시 즉시 swap**: 차단 응답을 받자마자 in-flight 요청을 다음 IP 로 교체. 한 번 차단된 IP 는 같은 요청 처리에서 다시 안 씀 +- **`IOException` catch + 60초 request timeout**: 프록시 측 hang 시 무한 대기 방지 + +### 모호 실패 분류 (sentinel ping) + +라이브러리가 던지는 단일 예외 (`TranscriptRetrievalException`) 만으로는 다음을 구별할 수 없습니다: + +| 실패 원인 | 재시도 가치 | 적합한 status | +|---|---|---| +| 영상 자체에 자막 없음 / 영상 비공개 | ❌ 재시도 무의미 | `INVALID` (영구 종료) | +| 일시적 IP 차단 / 전 프록시 일시 차단 | ✅ 시간 지나면 회복 | `PENDING` (5분 후 재시도) | + +→ **자막이 항상 존재하는 sentinel 영상** (`TVM6Nswlfbg`) 으로 ping 을 날려 분류: + +```mermaid +flowchart TD + A[자막 추출 시도] --> B{HTTP 응답?} + B -->|200| C[자막 추출 성공 ✓] + B -->|429 / 403| D[다음 프록시 swap
위 라운드로빈으로 회귀] + B -->|TranscriptRetrievalException
원인 모호| E[sentinel 영상 ping] + E --> F{sentinel 응답?} + F -->|HTTP 200 정상
우리 프록시는 살아있음| G["영상 고유 문제
(자막 없음 / 비공개)
status = INVALID
재시도 안 함"] + F -->|sentinel 도 실패| H["전 프록시 IP 동시 차단 확정
status = PENDING
5분 후 VideoAnalysisRetryJob 이 재 enqueue"] + + style C fill:#d4edda,stroke:#155724 + style G fill:#f8d7da,stroke:#721c24 + style H fill:#fff3cd,stroke:#856404 +``` + +### 이중 안전망 효과 + +``` +1차 — 프록시 라운드로빈 + 단일 IP 차단을 자동 우회. 사용자에게 실패가 안 보임. + +2차 — sentinel ping + 라운드로빈으로도 회복 안 되는 상황에서 + "영상 문제 vs 전 프록시 차단" 구분 → 재시도 정책 결정. +``` + +→ IP 차단으로 인한 재시도 가능 실패와 자막 부재로 인한 영구 실패가 섞이지 않게 됨. 결과: `PENDING` row 가 정확히 "회복 가능" 한 건만 남음. + +--- + ## 큐 우선순위 큐 한 개 안에서 사용자 요청과 시스템 배치를 분리해 사용자 응답을 보장합니다. From f3b3a4a329de669307f51ec1b7983114f9020885 Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 15:13:26 +0900 Subject: [PATCH 21/29] =?UTF-8?q?test:=20VideoAnalysisQueueConsumerTest=20?= =?UTF-8?q?=EC=97=90=20apiQuotaGuardService=20mock=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/video/VideoAnalysisQueueConsumerTest.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumerTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumerTest.kt index 8bfc04c..7cd0163 100644 --- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumerTest.kt +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumerTest.kt @@ -1,5 +1,6 @@ package com.linktrip.application.domain.video +import com.linktrip.application.domain.quota.ApiQuotaGuardService import com.linktrip.application.domain.trip.TripPlanRequest import com.linktrip.application.domain.trip.TripPlanService import com.linktrip.application.port.output.external.VideoAnalysisNotificationPort @@ -50,6 +51,9 @@ class VideoAnalysisQueueConsumerTest { @Mock lateinit var rateLimitBucketStore: RateLimitBucketStore + @Mock + lateinit var apiQuotaGuardService: ApiQuotaGuardService + private fun createConsumer() = VideoAnalysisQueueConsumer( videoAnalysisQueuePort = videoAnalysisQueuePort, @@ -61,6 +65,7 @@ class VideoAnalysisQueueConsumerTest { tripPlanRequestPort = tripPlanRequestPort, tripPlanService = tripPlanService, rateLimitBucketStore = rateLimitBucketStore, + apiQuotaGuardService = apiQuotaGuardService, ) @Test From 6f21bca8e2742426cb09b670e7ea0631b8ecc47c Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 17:44:19 +0900 Subject: [PATCH 22/29] =?UTF-8?q?feat:=20TripPlanRequest=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=EB=B3=84=20=EC=9D=BC=EC=9E=90=20=EC=B9=B4=EC=9A=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TripPlanRequestPersistencePort.kt | 6 ++++++ .../TripPlanRequestPersistenceAdapter.kt | 6 ++++++ .../TripPlanRequestQuerydslRepository.kt | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/TripPlanRequestPersistencePort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/TripPlanRequestPersistencePort.kt index 78b255f..d66fc61 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/TripPlanRequestPersistencePort.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/TripPlanRequestPersistencePort.kt @@ -1,6 +1,7 @@ package com.linktrip.application.port.output.persistence import com.linktrip.application.domain.trip.TripPlanRequest +import java.time.LocalDate interface TripPlanRequestPersistencePort { fun save(request: TripPlanRequest): TripPlanRequest @@ -15,4 +16,9 @@ interface TripPlanRequestPersistencePort { fun findMemberIdsByVideoAnalysisTaskId(videoAnalysisTaskId: String): List fun saveAll(requests: List) + + fun countByMemberIdAndDate( + memberId: String, + date: LocalDate, + ): Long } diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/TripPlanRequestPersistenceAdapter.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/TripPlanRequestPersistenceAdapter.kt index d10d9cc..0b2b827 100644 --- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/TripPlanRequestPersistenceAdapter.kt +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/TripPlanRequestPersistenceAdapter.kt @@ -6,6 +6,7 @@ import com.linktrip.output.persistence.mysql.entity.TripPlanRequestEntity import com.linktrip.output.persistence.mysql.repository.TripPlanRequestJpaRepository import com.linktrip.output.persistence.mysql.repository.TripPlanRequestQuerydslRepository import org.springframework.stereotype.Component +import java.time.LocalDate @Component class TripPlanRequestPersistenceAdapter( @@ -29,4 +30,9 @@ class TripPlanRequestPersistenceAdapter( override fun saveAll(requests: List) { jpaRepository.saveAll(requests.map { TripPlanRequestEntity.from(it) }) } + + override fun countByMemberIdAndDate( + memberId: String, + date: LocalDate, + ): Long = querydslRepository.countByMemberIdAndDate(memberId, date) } diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/TripPlanRequestQuerydslRepository.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/TripPlanRequestQuerydslRepository.kt index 8a32332..599db25 100644 --- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/TripPlanRequestQuerydslRepository.kt +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/TripPlanRequestQuerydslRepository.kt @@ -4,6 +4,7 @@ import com.linktrip.output.persistence.mysql.entity.QTripPlanRequestEntity import com.linktrip.output.persistence.mysql.entity.TripPlanRequestEntity import com.querydsl.jpa.impl.JPAQueryFactory import org.springframework.stereotype.Repository +import java.time.LocalDate @Repository class TripPlanRequestQuerydslRepository( @@ -30,4 +31,22 @@ class TripPlanRequestQuerydslRepository( request.deleted.isFalse, ) .fetch() + + fun countByMemberIdAndDate( + memberId: String, + date: LocalDate, + ): Long { + val startOfDay = date.atStartOfDay() + val startOfNextDay = date.plusDays(1).atStartOfDay() + return queryFactory + .select(request.count()) + .from(request) + .where( + request.memberId.eq(memberId), + request.createdAt.goe(startOfDay), + request.createdAt.lt(startOfNextDay), + request.deleted.isFalse, + ) + .fetchOne() ?: 0L + } } From a9ab8bd83efeed410aa4b0ad8880fdf5c6dd5a3f Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 17:44:24 +0900 Subject: [PATCH 23/29] =?UTF-8?q?feat:=20trip=5Fplan=5Frequest=20(member?= =?UTF-8?q?=5Fid,=20created=5Fat)=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/mysql/entity/TripPlanRequestEntity.kt | 4 ++++ sql/ddl/trip_plan_request.sql | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/TripPlanRequestEntity.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/TripPlanRequestEntity.kt index b2e7555..cfa1aa3 100644 --- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/TripPlanRequestEntity.kt +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/TripPlanRequestEntity.kt @@ -22,6 +22,10 @@ import jakarta.persistence.UniqueConstraint name = "idx_trip_plan_request_task_processed", columnList = "video_analysis_task_id, processed", ), + Index( + name = "idx_trip_plan_request_member_created", + columnList = "member_id, created_at", + ), ], ) class TripPlanRequestEntity( diff --git a/sql/ddl/trip_plan_request.sql b/sql/ddl/trip_plan_request.sql index e10e143..90f0139 100644 --- a/sql/ddl/trip_plan_request.sql +++ b/sql/ddl/trip_plan_request.sql @@ -19,5 +19,9 @@ CREATE TABLE IF NOT EXISTS `trip_plan_request` ( -- 분석 완료된 task 에 대한 미처리 요청 batch 조회. -- TripPlanRequestQuerydslRepository 의 후속 처리 로직이 사용. - KEY `idx_trip_plan_request_task_processed` (`video_analysis_task_id`, `processed`) + KEY `idx_trip_plan_request_task_processed` (`video_analysis_task_id`, `processed`), + + -- 멤버별 일일 요청 카운트 (어뷰징 방지) 시 created_at 기준 range scan. + -- TripPlanRequestQuerydslRepository.countByMemberIdAndDate 가 사용. + KEY `idx_trip_plan_request_member_created` (`member_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; From fcccf015d7cc4d63caa160e3a0bc0b83a0d116e0 Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 17:44:39 +0900 Subject: [PATCH 24/29] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EC=9D=BC?= =?UTF-8?q?=EC=9D=BC=20=EC=98=81=EC=83=81=20=EB=B6=84=EC=84=9D=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=ED=95=9C=EB=8F=84=20=EA=B0=80=EB=93=9C=20(?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=2010=EA=B1=B4/=EC=9D=BC,=20registerRequest?= =?UTF-8?q?=20=EC=A7=84=EC=9E=85=EC=A0=90)=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/domain/trip/TripPlanService.kt | 16 ++++++++++++++++ .../src/main/resources/application.yml | 6 ++++++ .../linktrip/common/exception/ExceptionCode.kt | 1 + 3 files changed, 23 insertions(+) diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/trip/TripPlanService.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/trip/TripPlanService.kt index dd015af..c0cf69d 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/trip/TripPlanService.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/trip/TripPlanService.kt @@ -13,8 +13,10 @@ import com.linktrip.application.port.output.persistence.TripPlanPersistencePort import com.linktrip.application.port.output.persistence.TripPlanRequestPersistencePort import com.linktrip.common.exception.ExceptionCode import com.linktrip.common.exception.LinktripException +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate import java.time.LocalDateTime @Service @@ -24,6 +26,8 @@ class TripPlanService( private val itineraryItemPort: TravelItineraryItemPersistencePort, private val requestPort: TripPlanRequestPersistencePort, private val hashtagPort: HashtagPersistencePort, + @Value("\${member.daily-video-analyze-limit:10}") + private val dailyVideoAnalyzeLimit: Long, ) : TripPlanUseCase { @Transactional override fun registerRequest( @@ -31,9 +35,21 @@ class TripPlanService( videoAnalysisTaskId: String, ) { if (requestPort.existsByMemberIdAndVideoAnalysisTaskId(memberId, videoAnalysisTaskId)) return + ensureDailyVideoAnalyzeLimitNotExceeded(memberId) requestPort.save(TripPlanRequest.create(memberId, videoAnalysisTaskId)) } + private fun ensureDailyVideoAnalyzeLimitNotExceeded(memberId: String) { + val today = LocalDate.now() + val count = requestPort.countByMemberIdAndDate(memberId, today) + if (count >= dailyVideoAnalyzeLimit) { + throw LinktripException( + ExceptionCode.TOO_MANY_REQUESTS_VIDEO_ANALYZE_DAILY, + "오늘 영상 분석 요청 한도($count/$dailyVideoAnalyzeLimit) 초과", + ) + } + } + @Transactional override fun createFromAnalysisIfAbsent( memberId: String, diff --git a/linktrip-bootstrap/src/main/resources/application.yml b/linktrip-bootstrap/src/main/resources/application.yml index ff8fd5b..cf96f9f 100644 --- a/linktrip-bootstrap/src/main/resources/application.yml +++ b/linktrip-bootstrap/src/main/resources/application.yml @@ -62,3 +62,9 @@ api: gemini: ${API_COST_GEMINI_KRW:50} youtube-data: ${API_COST_YOUTUBE_DATA_KRW:0} google-places: ${API_COST_GOOGLE_PLACES_KRW:7} + +member: + # 멤버 1명이 하루에 요청할 수 있는 서로 다른 영상 분석 수. + # trip_plan_request 의 (member_id, task_id) unique 덕에 같은 영상 중복 요청은 카운트 X. + # 초과 시 TOO_MANY_REQUESTS_VIDEO_ANALYZE_DAILY 로 응답하며 어뷰징 방지에 사용. + daily-video-analyze-limit: ${MEMBER_DAILY_VIDEO_ANALYZE_LIMIT:10} diff --git a/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt b/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt index 2fde842..5c38c1b 100644 --- a/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt +++ b/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt @@ -29,6 +29,7 @@ enum class ExceptionCode( // 429 TOO_MANY_REQUESTS(429, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), + TOO_MANY_REQUESTS_VIDEO_ANALYZE_DAILY(429, "오늘 요청 가능한 영상 분석 한도를 초과했습니다. 내일 다시 시도해주세요."), // 500 INTERNAL_SERVER_ERROR(500, "서버 내부 오류가 발생했습니다."), From 6854d022212c64aa8458c8d88ee078b47e6569ee Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 17:47:25 +0900 Subject: [PATCH 25/29] =?UTF-8?q?test:=20TripPlanServiceTest=20=EC=9D=98?= =?UTF-8?q?=20@InjectMocks=20=EB=A5=BC=20=EB=AA=85=EC=8B=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=20(dailyVideoA?= =?UTF-8?q?nalyzeLimit=20=EC=A3=BC=EC=9E=85)=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/trip/TripPlanServiceTest.kt | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt index 81edfc8..7a93fa0 100644 --- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt @@ -17,11 +17,11 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.InjectMocks import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any @@ -47,8 +47,21 @@ class TripPlanServiceTest { @Mock lateinit var hashtagPort: HashtagPersistencePort - @InjectMocks - lateinit var service: TripPlanService + private lateinit var service: TripPlanService + + @BeforeEach + fun setUp() { + // @InjectMocks 는 Long 같은 단순 타입을 주입 못 하므로 명시 생성. + service = + TripPlanService( + planPort = planPort, + planItemPort = planItemPort, + itineraryItemPort = itineraryItemPort, + requestPort = requestPort, + hashtagPort = hashtagPort, + dailyVideoAnalyzeLimit = 10L, + ) + } @Nested inner class RegisterRequest { From e56d0c6a0e05f7872183ae873ed59beea2dcef28 Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 18:00:46 +0900 Subject: [PATCH 26/29] =?UTF-8?q?test:=20ApiQuotaGuardService=20/=20ApiCal?= =?UTF-8?q?lCounterService=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/quota/ApiCallCounterServiceTest.kt | 166 ++++++++++++++++++ .../domain/quota/ApiQuotaGuardServiceTest.kt | 114 ++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiCallCounterServiceTest.kt create mode 100644 linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardServiceTest.kt diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiCallCounterServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiCallCounterServiceTest.kt new file mode 100644 index 0000000..9942ae4 --- /dev/null +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiCallCounterServiceTest.kt @@ -0,0 +1,166 @@ +package com.linktrip.application.domain.quota + +import com.linktrip.application.domain.notification.CostAlertEvent +import com.linktrip.application.port.output.notification.NotificationPort +import com.linktrip.application.port.output.quota.ApiCallCountPersistencePort +import com.linktrip.application.port.output.quota.ApiCostPolicyPort +import com.linktrip.application.port.output.quota.ApiDailyCostAlertPersistencePort +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.time.LocalDate + +@ExtendWith(MockitoExtension::class) +class ApiCallCounterServiceTest { + @Mock + lateinit var countPort: ApiCallCountPersistencePort + + @Mock + lateinit var costPolicyPort: ApiCostPolicyPort + + @Mock + lateinit var alertPort: ApiDailyCostAlertPersistencePort + + @Mock + lateinit var notificationPort: NotificationPort + + @InjectMocks + lateinit var service: ApiCallCounterService + + @Test + fun `recordSuccess 는 ApiType 과 오늘 날짜로 카운트를 increment 한다`() { + stubBreakdown(geminiCount = 0L) + + service.recordSuccess(ApiType.GEMINI) + + val captor = argumentCaptor() + verify(countPort).increment(captor.capture()) + assertEquals(ApiType.GEMINI, captor.firstValue.apiType) + assertEquals(LocalDate.now(), captor.firstValue.callDate) + assertEquals(1L, captor.firstValue.callCount) + } + + @Test + fun `누적 비용이 임계값 1000원 미달이면_Discord 알림과 임계값 갱신을 모두 수행하지 않는다`() { + // 30 * 50 = 1500 → 1500 < (lastSent=0) + 1000 = false → 알림 발송 케이스 + // 일부러 미달 케이스: 10 * 50 = 500 < 1000 → 발송 X + stubBreakdown(geminiCount = 10L) + whenever(alertPort.findLastSentThresholdKrw(LocalDate.now())).thenReturn(null) + + service.recordSuccess(ApiType.GEMINI) + + verify(notificationPort, never()).sendCostAlert(any()) + verify(alertPort, never()).upsert(any()) + } + + @Test + fun `누적 비용이 첫 1000원 임계값을 넘으면_thresholdKrw 1000 으로 알림을 발송하고 lastSent 를 갱신한다`() { + // 30 * 50 = 1500 → newThreshold = (1500 / 1000) * 1000 = 1000 + stubBreakdown(geminiCount = 30L) + whenever(alertPort.findLastSentThresholdKrw(LocalDate.now())).thenReturn(null) + + service.recordSuccess(ApiType.GEMINI) + + val eventCaptor = argumentCaptor() + verify(notificationPort).sendCostAlert(eventCaptor.capture()) + assertEquals(1000L, eventCaptor.firstValue.thresholdKrw) + assertEquals(1500L, eventCaptor.firstValue.breakdown.totalKrw) + + val alertCaptor = argumentCaptor() + verify(alertPort).upsert(alertCaptor.capture()) + assertEquals(1000L, alertCaptor.firstValue.lastSentThresholdKrw) + } + + @Test + fun `누적 비용이 0원에서 5500원으로 한 번에 점프하면_5000원 임계값으로 단 1회만 발송한다 (중간 임계값 압축)`() { + // 110 * 50 = 5500 → newThreshold = 5000. 1000/2000/3000/4000 알림은 의도적으로 압축됨. + stubBreakdown(geminiCount = 110L) + whenever(alertPort.findLastSentThresholdKrw(LocalDate.now())).thenReturn(null) + + service.recordSuccess(ApiType.GEMINI) + + val captor = argumentCaptor() + verify(notificationPort).sendCostAlert(captor.capture()) + assertEquals(5000L, captor.firstValue.thresholdKrw) + // 알림은 단 1회 + verify(notificationPort, org.mockito.Mockito.times(1)).sendCostAlert(any()) + } + + @Test + fun `같은 임계값 구간 내 재호출은_중복 알림을 발송하지 않는다 (lastSent=1000 + 누적=1200 → return)`() { + // 1200 < (lastSent=1000) + 1000 = 2000 → 발송 X + stubBreakdown(geminiCount = 24L) // 24 * 50 = 1200 + whenever(alertPort.findLastSentThresholdKrw(LocalDate.now())).thenReturn(1000L) + + service.recordSuccess(ApiType.GEMINI) + + verify(notificationPort, never()).sendCostAlert(any()) + } + + @Test + fun `Discord 알림 발송이 실패해도_recordSuccess 는 외부로 예외를 전파하지 않는다 (알림은 best-effort)`() { + // increment 는 이미 성공한 상태인데, 알림 실패로 호출자가 깨지면 외부 API 결과까지 손실되어 손해가 큼. + // 메모상 알림은 log only 정책. + stubBreakdown(geminiCount = 30L) + whenever(alertPort.findLastSentThresholdKrw(LocalDate.now())).thenReturn(null) + whenever(notificationPort.sendCostAlert(any())).thenThrow(RuntimeException("Discord 다운")) + + // throw 하지 않고 정상 종료 + service.recordSuccess(ApiType.GEMINI) + + // increment 는 정상 호출되어야 함 (카운트 자체는 적재됨) + verify(countPort).increment(any()) + } + + @Test + fun `카운트 increment 자체가 실패하면_예외를 그대로 전파한다 (fail-loud - 가드 신뢰성)`() { + // counter 의 drift 는 가드 신뢰성을 깨뜨려 비용 폭주로 이어지므로 fail-loud. + whenever(countPort.increment(any())).thenThrow(RuntimeException("DB 다운")) + + assertThrows { + service.recordSuccess(ApiType.GEMINI) + } + } + + @Test + fun `비용 breakdown 은 findAllByDate 로 1회만 조회되어야 한다 (N+1 회피)`() { + // ApiType 별로 findByApiTypeAndDate 를 N 번 부르지 않고, 일자 기준 1쿼리로 모두 가져온다. + stubBreakdown(geminiCount = 30L) + whenever(alertPort.findLastSentThresholdKrw(LocalDate.now())).thenReturn(null) + + service.recordSuccess(ApiType.GEMINI) + + verify(countPort).findAllByDate(LocalDate.now()) + verify(countPort, never()).findByApiTypeAndDate(any(), any()) + } + + /** + * 단가는 GEMINI=50, YOUTUBE_DATA=0, GOOGLE_PLACES=7 (yml 기본값과 동일) 로 고정. + * 각 카운트는 인자로 받아 totalKrw 를 의도적으로 조절. + */ + private fun stubBreakdown( + geminiCount: Long = 0L, + youtubeDataCount: Long = 0L, + googlePlacesCount: Long = 0L, + ) { + whenever(countPort.findAllByDate(LocalDate.now())).thenReturn( + listOf( + ApiCallCount(id = "g", apiType = ApiType.GEMINI, callDate = LocalDate.now(), callCount = geminiCount), + ApiCallCount(id = "y", apiType = ApiType.YOUTUBE_DATA, callDate = LocalDate.now(), callCount = youtubeDataCount), + ApiCallCount(id = "p", apiType = ApiType.GOOGLE_PLACES, callDate = LocalDate.now(), callCount = googlePlacesCount), + ), + ) + whenever(costPolicyPort.perCallKrw(ApiType.GEMINI)).thenReturn(50L) + whenever(costPolicyPort.perCallKrw(ApiType.YOUTUBE_DATA)).thenReturn(0L) + whenever(costPolicyPort.perCallKrw(ApiType.GOOGLE_PLACES)).thenReturn(7L) + } +} diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardServiceTest.kt new file mode 100644 index 0000000..3738f35 --- /dev/null +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardServiceTest.kt @@ -0,0 +1,114 @@ +package com.linktrip.application.domain.quota + +import com.linktrip.application.port.output.quota.ApiCallCountPersistencePort +import com.linktrip.application.port.output.quota.ApiQuotaPolicyPort +import com.linktrip.common.exception.ExceptionCode +import com.linktrip.common.exception.LinktripException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.whenever +import java.time.LocalDate + +@ExtendWith(MockitoExtension::class) +class ApiQuotaGuardServiceTest { + @Mock + lateinit var countPort: ApiCallCountPersistencePort + + @Mock + lateinit var policyPort: ApiQuotaPolicyPort + + @InjectMocks + lateinit var service: ApiQuotaGuardService + + @Test + fun `정책 한도가 미설정인 ApiType 으로 isExceeded 호출 시_INTERNAL_QUOTA_POLICY_NOT_CONFIGURED 가 발생한다 (fail-closed)`() { + // 운영 yml 누락이나 환경변수 오타 등으로 한도가 비면, 가드가 조용히 무력화되어 비용 폭주 사고로 이어진다. + // counter 의 fail-loud 정책과 일관되게 본 가드도 throw 로 멈춰야 한다. + whenever(policyPort.dailyLimit(ApiType.GEMINI)).thenReturn(null) + + val exception = + assertThrows { + service.isExceeded(ApiType.GEMINI) + } + assertEquals(ExceptionCode.INTERNAL_QUOTA_POLICY_NOT_CONFIGURED, exception.exceptionCode) + } + + @Test + fun `현재 카운트가 한도 미만이면_isExceeded 가 false 를 반환한다`() { + whenever(policyPort.dailyLimit(ApiType.GEMINI)).thenReturn(500L) + whenever(countPort.findByApiTypeAndDate(ApiType.GEMINI, LocalDate.now())) + .thenReturn(apiCallCount(ApiType.GEMINI, callCount = 100L)) + + assertFalse(service.isExceeded(ApiType.GEMINI)) + } + + @Test + fun `현재 카운트가 한도와 정확히 같으면_isExceeded 가 true 를 반환한다 (경계값 - inclusive 차단)`() { + // 한도 도달 = 더 이상 호출 금지. 한도 정확히 도달한 시점부터 차단되어야 함. + whenever(policyPort.dailyLimit(ApiType.GEMINI)).thenReturn(500L) + whenever(countPort.findByApiTypeAndDate(ApiType.GEMINI, LocalDate.now())) + .thenReturn(apiCallCount(ApiType.GEMINI, callCount = 500L)) + + assertTrue(service.isExceeded(ApiType.GEMINI)) + } + + @Test + fun `현재 카운트가 한도를 초과하면_isExceeded 가 true 를 반환한다`() { + whenever(policyPort.dailyLimit(ApiType.GEMINI)).thenReturn(500L) + whenever(countPort.findByApiTypeAndDate(ApiType.GEMINI, LocalDate.now())) + .thenReturn(apiCallCount(ApiType.GEMINI, callCount = 999L)) + + assertTrue(service.isExceeded(ApiType.GEMINI)) + } + + @Test + fun `해당 일자의 카운트 row 가 아직 없으면_0 으로 간주하여 isExceeded 가 false 를 반환한다`() { + // 자정 직후 그날 첫 호출 시점 — row 가 없어도 정상 통과되어야 (가드가 잘못 막으면 안 됨). + whenever(policyPort.dailyLimit(ApiType.GEMINI)).thenReturn(500L) + whenever(countPort.findByApiTypeAndDate(ApiType.GEMINI, LocalDate.now())).thenReturn(null) + + assertFalse(service.isExceeded(ApiType.GEMINI)) + } + + @Test + fun `모든 ApiType 의 카운트가 한도 미만이면_isAnyApiExceeded 가 false 를 반환한다`() { + ApiType.entries.forEach { apiType -> + whenever(policyPort.dailyLimit(apiType)).thenReturn(1000L) + whenever(countPort.findByApiTypeAndDate(apiType, LocalDate.now())) + .thenReturn(apiCallCount(apiType, callCount = 100L)) + } + + assertFalse(service.isAnyApiExceeded()) + } + + @Test + fun `하나의 ApiType 만 한도를 초과해도_isAnyApiExceeded 가 true 를 반환한다 (큐 컨슈머는 어느 하나라도 막히면 dequeue 멈춤)`() { + // GEMINI / YOUTUBE_DATA 정상, GOOGLE_PLACES 만 초과. 마지막 ApiType 까지 순회되어야 검출되는 것을 보장. + ApiType.entries.forEach { apiType -> + whenever(policyPort.dailyLimit(apiType)).thenReturn(500L) + val count = if (apiType == ApiType.GOOGLE_PLACES) 1000L else 100L + whenever(countPort.findByApiTypeAndDate(apiType, LocalDate.now())) + .thenReturn(apiCallCount(apiType, callCount = count)) + } + + assertTrue(service.isAnyApiExceeded()) + } + + private fun apiCallCount( + apiType: ApiType, + callCount: Long, + ): ApiCallCount = + ApiCallCount( + id = "id-${apiType.name}", + apiType = apiType, + callDate = LocalDate.now(), + callCount = callCount, + ) +} From 7cb4e28032f184da9373d540f4514d148ea56a1a Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 18:01:04 +0900 Subject: [PATCH 27/29] =?UTF-8?q?test:=20=EB=A9=A4=EB=B2=84=20=EC=9D=BC?= =?UTF-8?q?=EC=9D=BC=20=EC=98=81=EC=83=81=20=EB=B6=84=EC=84=9D=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=ED=95=9C=EB=8F=84=20=EA=B0=80=EB=93=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/trip/TripPlanServiceTest.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt index 7a93fa0..2b11f51 100644 --- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt @@ -26,6 +26,7 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -85,6 +86,32 @@ class TripPlanServiceTest { assertEquals("m1", captor.firstValue.memberId) assertEquals("t1", captor.firstValue.videoAnalysisTaskId) } + + @Test + fun `오늘 분석 요청이 한도 10건에 도달한 멤버가 새 영상을 요청하면_TOO_MANY_REQUESTS_VIDEO_ANALYZE_DAILY 예외가 발생하고 저장하지 않는다`() { + // 어뷰징 방지 가드: 한도 도달 시 외부 API 호출도, DB 저장도 일어나지 않아야 한다. + whenever(requestPort.existsByMemberIdAndVideoAnalysisTaskId("m1", "t1")).thenReturn(false) + whenever(requestPort.countByMemberIdAndDate(eq("m1"), any())).thenReturn(10L) + + val exception = + assertThrows { + service.registerRequest("m1", "t1") + } + assertEquals(ExceptionCode.TOO_MANY_REQUESTS_VIDEO_ANALYZE_DAILY, exception.exceptionCode) + verify(requestPort, never()).save(any()) + } + + @Test + fun `이미 등록된 요청은 한도 카운트 조회 자체를 거치지 않는다 (dedup 우선 - 같은 영상 재요청은 카운트 영향 X)`() { + // 같은 영상에 대해 같은 멤버가 두 번 요청해도 trip_plan_request 의 unique 로 인해 1 row. + // 한도 체크는 새 row 가 생기는 경로에서만 의미가 있으므로, 이미 등록된 요청은 한도 조회를 스킵해야 한다. + whenever(requestPort.existsByMemberIdAndVideoAnalysisTaskId("m1", "t1")).thenReturn(true) + + service.registerRequest("m1", "t1") + + verify(requestPort, never()).countByMemberIdAndDate(any(), any()) + verify(requestPort, never()).save(any()) + } } @Nested From 8e877e48c2ac2231d102650469013df78092a22e Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 18:12:20 +0900 Subject: [PATCH 28/29] =?UTF-8?q?test:=20KeywordAnalyzeService=20=EC=9D=98?= =?UTF-8?q?=20region/country=20=ED=95=84=ED=84=B0=20+=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C/=EC=98=81=EC=83=81=20=EB=8B=A8=EC=9C=84=20best-effort?= =?UTF-8?q?=20=EA=B2=A9=EB=A6=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/video/KeywordAnalyzeServiceTest.kt | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/KeywordAnalyzeServiceTest.kt diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/KeywordAnalyzeServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/KeywordAnalyzeServiceTest.kt new file mode 100644 index 0000000..e57b228 --- /dev/null +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/KeywordAnalyzeServiceTest.kt @@ -0,0 +1,120 @@ +package com.linktrip.application.domain.video + +import com.linktrip.application.domain.youtube.SearchKeywordLoader +import com.linktrip.application.domain.youtube.YouTubeSearchResult +import com.linktrip.application.port.input.VideoAnalyzeUseCase +import com.linktrip.application.port.output.external.YouTubePort +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@ExtendWith(MockitoExtension::class) +class KeywordAnalyzeServiceTest { + @Mock + lateinit var youTubePort: YouTubePort + + @Mock + lateinit var videoAnalyzeUseCase: VideoAnalyzeUseCase + + @InjectMocks + lateinit var service: KeywordAnalyzeService + + @Test + fun `존재하지 않는 region 으로 호출하면_외부 API 호출 없이 빈 결과를 반환한다 (불필요한 비용 회피)`() { + // 가드 의도: 필터 결과 0 이면 외부 API 가 한 번도 호출되지 않아야 함. + val result = service.analyzeByKeywords(region = "존재하지-않는-region-xyz", country = null, maxResults = 5) + + assertEquals(0, result.keywordCount) + assertEquals(0, result.tasks.size) + verify(youTubePort, never()).searchVideos(any(), any()) + verify(videoAnalyzeUseCase, never()).analyzeVideo(any(), any()) + } + + @Test + fun `region 과 country 모두 null 이면_전체 키워드 풀에 대해 검색을 시도한다`() { + val totalKeywords = SearchKeywordLoader.getAll().size + whenever(youTubePort.searchVideos(any(), any())).thenReturn(emptyList()) + + val result = service.analyzeByKeywords(region = null, country = null, maxResults = 5) + + assertEquals(totalKeywords, result.keywordCount) + verify(youTubePort, times(totalKeywords)).searchVideos(any(), any()) + } + + @Test + fun `YouTube 검색이 일부 키워드에서 예외를 던져도_나머지 키워드는 계속 처리된다 (best-effort)`() { + // 키워드 단위 실패 시 전체 잡이 멈추면 발견된 신규 영상이 모두 누락. 키워드 단위로 격리해야 함. + var callIndex = 0 + whenever(youTubePort.searchVideos(any(), any())).thenAnswer { + callIndex++ + if (callIndex == 1) { + throw RuntimeException("YouTube API 일시 오류") + } + emptyList() + } + val totalKeywords = SearchKeywordLoader.getAll().size + + // 외부로 예외가 전파되지 않고 정상 종료 + val result = service.analyzeByKeywords(region = null, country = null, maxResults = 5) + + // 모든 키워드가 시도되어야 함 (한 키워드 실패가 다른 키워드 처리를 막아선 안 됨) + verify(youTubePort, times(totalKeywords)).searchVideos(any(), any()) + // keywordCount 는 필터 통과한 키워드 수 — 검색 실패 여부와 무관하게 동일 + assertEquals(totalKeywords, result.keywordCount) + } + + @Test + fun `개별 영상 분석이 실패해도_같은 키워드의 다른 영상과 task 결과는 정상 보존된다 (영상 단위 격리)`() { + // 영상 단위 실패가 키워드/잡 전체를 무너뜨리지 않게. + whenever(youTubePort.searchVideos(any(), any())).thenReturn( + listOf( + searchResult("video-1"), + searchResult("video-2-fail"), + searchResult("video-3"), + ), + ) + whenever(videoAnalyzeUseCase.analyzeVideo(any(), eq(Source.BATCH))).thenAnswer { invocation -> + val url = invocation.arguments[0] as String + if (url.endsWith("video-2-fail")) { + throw RuntimeException("분석 일시 오류") + } + videoAnalysisTask(url) + } + val totalKeywords = SearchKeywordLoader.getAll().size + + val result = service.analyzeByKeywords(region = null, country = null, maxResults = 5) + + // 키워드당 3 영상 시도, 그 중 1개만 실패 → 키워드당 2 task 성공 + assertEquals(totalKeywords * 2, result.tasks.size) + verify(videoAnalyzeUseCase, times(totalKeywords * 3)).analyzeVideo(any(), eq(Source.BATCH)) + } + + private fun searchResult(videoId: String): YouTubeSearchResult = + YouTubeSearchResult( + videoId = videoId, + title = "title-$videoId", + description = "", + thumbnailUrl = "", + channelId = "", + channelTitle = "", + publishedAt = "", + ) + + private fun videoAnalysisTask(youtubeUrl: String): VideoAnalysisTask = + VideoAnalysisTask( + id = "task-$youtubeUrl", + youtubeUrl = youtubeUrl, + valid = false, + status = VideoAnalysisTaskStatus.PENDING, + source = Source.BATCH, + ) +} From 07bc8c41e7fa61c1637f22980feaf75c2648ea50 Mon Sep 17 00:00:00 2001 From: toychip Date: Thu, 30 Apr 2026 18:17:04 +0900 Subject: [PATCH 29/29] refactor: ktlintformat (#68) --- .../domain/quota/ApiCallCounterServiceTest.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiCallCounterServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiCallCounterServiceTest.kt index 9942ae4..f113a5f 100644 --- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiCallCounterServiceTest.kt +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiCallCounterServiceTest.kt @@ -155,8 +155,18 @@ class ApiCallCounterServiceTest { whenever(countPort.findAllByDate(LocalDate.now())).thenReturn( listOf( ApiCallCount(id = "g", apiType = ApiType.GEMINI, callDate = LocalDate.now(), callCount = geminiCount), - ApiCallCount(id = "y", apiType = ApiType.YOUTUBE_DATA, callDate = LocalDate.now(), callCount = youtubeDataCount), - ApiCallCount(id = "p", apiType = ApiType.GOOGLE_PLACES, callDate = LocalDate.now(), callCount = googlePlacesCount), + ApiCallCount( + id = "y", + apiType = ApiType.YOUTUBE_DATA, + callDate = LocalDate.now(), + callCount = youtubeDataCount, + ), + ApiCallCount( + id = "p", + apiType = ApiType.GOOGLE_PLACES, + callDate = LocalDate.now(), + callCount = googlePlacesCount, + ), ), ) whenever(costPolicyPort.perCallKrw(ApiType.GEMINI)).thenReturn(50L)