Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
aa9fca5
feat: API quota 도메인 모델 추가 (ApiType / ApiCallCount / ApiDailyCostAlert…
toychip Apr 29, 2026
76b0b48
feat: API quota/cost output port 정의 (#68)
toychip Apr 29, 2026
38543c7
feat: 비용 알림 이벤트 추가 및 NotificationPort.sendCostAlert 시그니처 정의 (#68)
toychip Apr 29, 2026
0f3f9ac
feat: API 일일 한도 가드 / 호출 카운터 도메인 서비스 추가 (#68)
toychip Apr 29, 2026
3ea5dd2
feat: API 호출 카운트 / 비용 알림 추적 JPA 엔티티 추가 (#68)
toychip Apr 29, 2026
4053ff5
feat: API 호출 카운트 atomic +1 Querydsl + 조회 JpaRepository 추가 (#68)
toychip Apr 29, 2026
989f856
feat: API quota/cost 영속화 어댑터 구현 (update-first 패턴으로 lost update 차단) (#68)
toychip Apr 29, 2026
36b30e0
feat: 외부 설정 기반 API 한도 / 비용 정책 어댑터 구현 (#68)
toychip Apr 29, 2026
0968cbd
feat: Discord 비용 알림 메시지 포매팅 추가 (#68)
toychip Apr 29, 2026
5c2e7ce
feat: Gemini 호출 직전 한도 가드 + 직후 카운터 와이어업 (#68)
toychip Apr 29, 2026
b1eddc8
feat: YouTube Data API 6개 호출처 한도 가드 + 카운터 와이어업 (#68)
toychip Apr 29, 2026
435f4f4
feat: Google Places 호출 직전 한도 가드 + 직후 카운터 와이어업 (#68)
toychip Apr 29, 2026
6c250e2
feat: 영상 분석 큐 컨슈머 dequeue 단계 한도 가드 + 60초 백오프 (#68)
toychip Apr 29, 2026
c715e77
docs: README 에 quota 모듈 / webshare 외부 API / 인메모리 큐 설명 반영 (#68)
toychip Apr 29, 2026
7f4720d
feat: API 일일 한도 / 호출당 비용 yml 기본값 추가 (#68)
toychip Apr 29, 2026
78a34f0
feat: 전체 테이블 DDL 추가 (#68)
toychip Apr 30, 2026
523a4e8
refactor: ApiDailyCostAlertPersistenceAdapter 를 select-then-update + …
toychip Apr 30, 2026
c981dd1
fix: 가드 정책 누락 시 LinktripException(INTERNAL_QUOTA_POLICY_NOT_CONFIGURE…
toychip Apr 30, 2026
70824e1
docs: ApiCostPolicyPort / application.yml 주석을 실제 동작에 맞게 정정 (#68)
toychip Apr 30, 2026
dd454a1
docs: 자막 추출 섹션 신설 (프록시 라운드로빈 + sentinel 분류) + 동시 요청 처리 시나리오 확장 + 미적용 …
toychip Apr 30, 2026
f3b3a4a
test: VideoAnalysisQueueConsumerTest 에 apiQuotaGuardService mock 추가 (…
toychip Apr 30, 2026
6f21bca
feat: TripPlanRequest 멤버별 일자 카운트 쿼리 추가 (#68)
toychip Apr 30, 2026
a9ab8bd
feat: trip_plan_request (member_id, created_at) 인덱스 추가 (#68)
toychip Apr 30, 2026
fcccf01
feat: 멤버 일일 영상 분석 요청 한도 가드 (기본 10건/일, registerRequest 진입점) (#68)
toychip Apr 30, 2026
6854d02
test: TripPlanServiceTest 의 @InjectMocks 를 명시 생성으로 전환 (dailyVideoAnal…
toychip Apr 30, 2026
e56d0c6
test: ApiQuotaGuardService / ApiCallCounterService 비즈니스 시나리오 테스트 추가 (…
toychip Apr 30, 2026
7cb4e28
test: 멤버 일일 영상 분석 요청 한도 가드 테스트 추가 (#68)
toychip Apr 30, 2026
8e877e4
test: KeywordAnalyzeService 의 region/country 필터 + 키워드/영상 단위 best-effo…
toychip Apr 30, 2026
07bc8c4
refactor: ktlintformat (#68)
toychip Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
489 changes: 432 additions & 57 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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))
Comment on lines +48 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

임계값 알림 중복 발송 경쟁 조건이 있습니다.

현재는 lastSent 조회 → sendCostAlert → upsert 순서라 동시 호출에서 같은 임계값을 여러 스레드가 동시에 통과해 중복 알림이 나갈 수 있습니다.
중복 방지를 보장하려면 DB 레벨 원자 연산(예: tryAdvanceThreshold(date, newThreshold) CAS/upsert-if-greater)으로 “발송 권한”을 선점한 요청만 알림을 보내야 합니다.

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

In
`@linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCounterService.kt`
around lines 48 - 60, There is a race where multiple threads can read lastSent
via alertPort.findLastSentThresholdKrw and all proceed to send the same alert
before alertPort.upsert runs; change the flow so only the caller that atomically
claims the threshold at DB level sends the alert: implement and call a new
DB-backed method (e.g., alertPort.tryAdvanceThreshold(date, newThreshold) or
upsert-if-greater/CAS) that returns true only if it stored/advanced the saved
threshold, and only when it returns true do you call
notificationPort.sendCostAlert(CostAlertEvent(...)) and avoid calling
sendCostAlert when tryAdvanceThreshold returns false; keep existing
ApiDailyCostAlert.create logic but move persistence into the atomic claim
operation in the alertPort implementation.

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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.linktrip.application.domain.quota

/**
* 특정 일자의 API 별 누적 비용 (KRW) 분해.
* 합계 / 0원 제외 필터 등 표현/판정 로직을 도메인에 포함.
*/
data class ApiCostBreakdown(
val items: List<ApiCostItem>,
) {
val totalKrw: Long = items.sumOf { it.costKrw }

/** 비용 0원인 API 는 제외 (Discord 메시지에 노출할 항목만). */
fun nonZero(): List<ApiCostItem> = items.filter { it.costKrw > 0L }
}

data class ApiCostItem(
val apiType: ApiType,
val costKrw: Long,
)
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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

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)
?: throw LinktripException(ExceptionCode.INTERNAL_QUOTA_POLICY_NOT_CONFIGURED)
val current = countPort.findByApiTypeAndDate(apiType, LocalDate.now())?.callCount ?: 0L
val exceeded = current >= limit
if (exceeded) {
logger.warn { "$apiType 일일 한도 초과: 현재=$current / 한도=$limit" }
}
return exceeded
}
}
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,16 +26,30 @@ 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(
memberId: String,
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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

/**
Expand All @@ -8,4 +9,6 @@ import com.linktrip.application.domain.notification.ExceptionAlertEvent
*/
interface NotificationPort {
fun sendExceptionAlert(event: ExceptionAlertEvent)

fun sendCostAlert(event: CostAlertEvent)
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,4 +16,9 @@ interface TripPlanRequestPersistencePort {
fun findMemberIdsByVideoAnalysisTaskId(videoAnalysisTaskId: String): List<String>

fun saveAll(requests: List<TripPlanRequest>)

fun countByMemberIdAndDate(
memberId: String,
date: LocalDate,
): Long
}
Original file line number Diff line number Diff line change
@@ -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<ApiCallCount>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.linktrip.application.port.output.quota

import com.linktrip.application.domain.quota.ApiType

/**
* API 별 호출 1회당 추정 비용 (KRW). yml 등 외부 소스에서 제공.
* 0 = 비용 추적 비활성 (구현체는 항상 non-null Long 을 반환).
*/
interface ApiCostPolicyPort {
fun perCallKrw(apiType: ApiType): Long
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +10 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

현재 포트 계약은 임계값 알림 중복 발송 경쟁 조건을 막기 어렵습니다.

find → 판단 → send → upsert 흐름은 동시 요청에서 같은 임계값을 중복 발송할 수 있습니다. 포트 레벨에서 “임계값 전진”을 원자적으로 처리하는 메서드(성공/실패 반환)를 제공하는 쪽이 안전합니다.

계약 변경 예시
 interface ApiDailyCostAlertPersistencePort {
-    fun findLastSentThresholdKrw(date: LocalDate): Long?
-
-    fun upsert(alert: ApiDailyCostAlert)
+    fun tryAdvanceThreshold(date: LocalDate, newThresholdKrw: Long): Boolean
 }
// 호출부 개념 예시
if (alertPort.tryAdvanceThreshold(today, newThreshold)) {
    notificationPort.sendCostAlert(event)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiDailyCostAlertPersistencePort.kt`
around lines 10 - 14, The current ApiDailyCostAlertPersistencePort (methods
findLastSentThresholdKrw and upsert) allows a race in the
find→decide→send→upsert flow; add an atomic operation to the port such as
tryAdvanceThreshold(date: LocalDate, candidateThreshold: Long): Boolean that
will check the stored last threshold and, only if the candidate is strictly
greater, persist the new threshold and return true (false otherwise). Update
callers to call ApiDailyCostAlertPersistencePort.tryAdvanceThreshold(...) and
only send notifications when it returns true; implementers of the port (DB/repo
classes backing ApiDailyCostAlertPersistencePort) must perform the
check-and-update atomically (transactional update, SQL compare-and-set, or
optimistic locking) instead of separate findLastSentThresholdKrw and upsert
calls to avoid concurrent duplicate sends.

Original file line number Diff line number Diff line change
@@ -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?
}
Loading
Loading