-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] Api Limit — 외부 API 일일 호출 한도 가드 + 누적 비용 알림 #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
toychip
wants to merge
29
commits into
main
Choose a base branch
from
feat/#68-api-limit-guard
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 76b0b48
feat: API quota/cost output port 정의 (#68)
toychip 38543c7
feat: 비용 알림 이벤트 추가 및 NotificationPort.sendCostAlert 시그니처 정의 (#68)
toychip 0f3f9ac
feat: API 일일 한도 가드 / 호출 카운터 도메인 서비스 추가 (#68)
toychip 3ea5dd2
feat: API 호출 카운트 / 비용 알림 추적 JPA 엔티티 추가 (#68)
toychip 4053ff5
feat: API 호출 카운트 atomic +1 Querydsl + 조회 JpaRepository 추가 (#68)
toychip 989f856
feat: API quota/cost 영속화 어댑터 구현 (update-first 패턴으로 lost update 차단) (#68)
toychip 36b30e0
feat: 외부 설정 기반 API 한도 / 비용 정책 어댑터 구현 (#68)
toychip 0968cbd
feat: Discord 비용 알림 메시지 포매팅 추가 (#68)
toychip 5c2e7ce
feat: Gemini 호출 직전 한도 가드 + 직후 카운터 와이어업 (#68)
toychip b1eddc8
feat: YouTube Data API 6개 호출처 한도 가드 + 카운터 와이어업 (#68)
toychip 435f4f4
feat: Google Places 호출 직전 한도 가드 + 직후 카운터 와이어업 (#68)
toychip 6c250e2
feat: 영상 분석 큐 컨슈머 dequeue 단계 한도 가드 + 60초 백오프 (#68)
toychip c715e77
docs: README 에 quota 모듈 / webshare 외부 API / 인메모리 큐 설명 반영 (#68)
toychip 7f4720d
feat: API 일일 한도 / 호출당 비용 yml 기본값 추가 (#68)
toychip 78a34f0
feat: 전체 테이블 DDL 추가 (#68)
toychip 523a4e8
refactor: ApiDailyCostAlertPersistenceAdapter 를 select-then-update + …
toychip c981dd1
fix: 가드 정책 누락 시 LinktripException(INTERNAL_QUOTA_POLICY_NOT_CONFIGURE…
toychip 70824e1
docs: ApiCostPolicyPort / application.yml 주석을 실제 동작에 맞게 정정 (#68)
toychip dd454a1
docs: 자막 추출 섹션 신설 (프록시 라운드로빈 + sentinel 분류) + 동시 요청 처리 시나리오 확장 + 미적용 …
toychip f3b3a4a
test: VideoAnalysisQueueConsumerTest 에 apiQuotaGuardService mock 추가 (…
toychip 6f21bca
feat: TripPlanRequest 멤버별 일자 카운트 쿼리 추가 (#68)
toychip a9ab8bd
feat: trip_plan_request (member_id, created_at) 인덱스 추가 (#68)
toychip fcccf01
feat: 멤버 일일 영상 분석 요청 한도 가드 (기본 10건/일, registerRequest 진입점) (#68)
toychip 6854d02
test: TripPlanServiceTest 의 @InjectMocks 를 명시 생성으로 전환 (dailyVideoAnal…
toychip e56d0c6
test: ApiQuotaGuardService / ApiCallCounterService 비즈니스 시나리오 테스트 추가 (…
toychip 7cb4e28
test: 멤버 일일 영상 분석 요청 한도 가드 테스트 추가 (#68)
toychip 8e877e4
test: KeywordAnalyzeService 의 region/country 필터 + 키워드/영상 단위 best-effo…
toychip 07bc8c4
refactor: ktlintformat (#68)
toychip File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
13 changes: 13 additions & 0 deletions
13
...pplication/src/main/kotlin/com/linktrip/application/domain/notification/CostAlertEvent.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) |
31 changes: 31 additions & 0 deletions
31
linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCount.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) | ||
| } | ||
| } |
78 changes: 78 additions & 0 deletions
78
...pplication/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCounterService.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | ||
| 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 | ||
| } | ||
| } | ||
19 changes: 19 additions & 0 deletions
19
...rip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCostBreakdown.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) |
29 changes: 29 additions & 0 deletions
29
...ip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiDailyCostAlert.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) | ||
| } | ||
| } |
36 changes: 36 additions & 0 deletions
36
...application/src/main/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardService.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
16 changes: 16 additions & 0 deletions
16
linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiType.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
20 changes: 20 additions & 0 deletions
20
...src/main/kotlin/com/linktrip/application/port/output/quota/ApiCallCountPersistencePort.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| } |
11 changes: 11 additions & 0 deletions
11
...plication/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCostPolicyPort.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
14 changes: 14 additions & 0 deletions
14
...ain/kotlin/com/linktrip/application/port/output/quota/ApiDailyCostAlertPersistencePort.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 포트 계약은 임계값 알림 중복 발송 경쟁 조건을 막기 어렵습니다.
계약 변경 예시 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 |
||
11 changes: 11 additions & 0 deletions
11
...lication/src/main/kotlin/com/linktrip/application/port/output/quota/ApiQuotaPolicyPort.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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? | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
임계값 알림 중복 발송 경쟁 조건이 있습니다.
현재는
lastSent 조회 → sendCostAlert → upsert순서라 동시 호출에서 같은 임계값을 여러 스레드가 동시에 통과해 중복 알림이 나갈 수 있습니다.중복 방지를 보장하려면 DB 레벨 원자 연산(예:
tryAdvanceThreshold(date, newThreshold)CAS/upsert-if-greater)으로 “발송 권한”을 선점한 요청만 알림을 보내야 합니다.🤖 Prompt for AI Agents