-
Notifications
You must be signed in to change notification settings - Fork 1
예산 임계치 기반 판단 로직 및 알림 중복 방지 기능 추가 #18
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
Changes from all commits
9916295
7994758
5b52320
b7bca50
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package io.springaileger.notification; | ||
|
|
||
| import io.springaileger.budget.BudgetDecision; | ||
|
|
||
| /** | ||
| * 예산 임계치 도달 시 사용자에게 알림을 전달하는 역할을 정의합니다. | ||
| * | ||
| * 실제 전송 방식(이메일, Slack, Webhook 등)은 | ||
| * 구현체에서 결정되며, budget/notification 로직과 분리됩니다. | ||
| */ | ||
| public interface BudgetEmailSender { | ||
|
|
||
| /** | ||
| * 예산 사용률이 50%에 도달했을 때 전송되는 알림입니다. | ||
| */ | ||
| void sendHalfUsageWarning(String email, BudgetDecision decision); | ||
|
|
||
| /** | ||
| * 예산 사용률이 80%에 도달했을 때 전송되는 경고 알림입니다. | ||
| */ | ||
| void sendEightyPercentWarning(String email, BudgetDecision decision); | ||
|
|
||
| /** | ||
| * 예산이 초과되어 호출이 차단되었을 때 전송되는 알림입니다. | ||
| */ | ||
| void sendExceededNotification(String email, BudgetDecision decision); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,65 @@ | ||||||||||||||
| package io.springaileger.notification; | ||||||||||||||
|
|
||||||||||||||
| import io.springaileger.budget.BudgetDecision; | ||||||||||||||
| import io.springaileger.budget.BudgetThreshold; | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * 예산 평가 결과를 기반으로 | ||||||||||||||
| * 사용자 알림을 발송할지 여부를 판단하는 서비스입니다. | ||||||||||||||
| * | ||||||||||||||
| * - 임계치(50%, 80%, 100%) 도달 시 알림 전송 | ||||||||||||||
| * - 동일 임계치에 대해서는 1회만 알림 발송 | ||||||||||||||
| * | ||||||||||||||
| * 예산 판단 로직(budget)과 알림 실행 로직을 분리하기 위한 계층입니다. | ||||||||||||||
| */ | ||||||||||||||
| public class BudgetNotificationService { | ||||||||||||||
|
|
||||||||||||||
| private final BudgetEmailSender emailSender; | ||||||||||||||
| private final NotificationStateStore stateStore; | ||||||||||||||
|
|
||||||||||||||
| public BudgetNotificationService( | ||||||||||||||
| BudgetEmailSender emailSender, | ||||||||||||||
| NotificationStateStore stateStore | ||||||||||||||
| ) { | ||||||||||||||
| this.emailSender = emailSender; | ||||||||||||||
| this.stateStore = stateStore; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * 예산 판단 결과를 바탕으로 필요 시 알림을 전송합니다. | ||||||||||||||
| * | ||||||||||||||
| * 이미 알림을 보낸 임계치 이하의 상태인 경우 | ||||||||||||||
| * 중복 알림을 방지하기 위해 아무 작업도 하지 않습니다. | ||||||||||||||
| */ | ||||||||||||||
| public void notifyIfNeeded( | ||||||||||||||
| String targetId, | ||||||||||||||
| BudgetDecision decision | ||||||||||||||
| ) { | ||||||||||||||
| BudgetThreshold current = decision.threshold(); | ||||||||||||||
| BudgetThreshold lastNotified = | ||||||||||||||
| stateStore.getLastNotifiedThreshold(targetId); | ||||||||||||||
|
|
||||||||||||||
| // 이미 알림을 보낸 임계치라면 종료 | ||||||||||||||
| if (current.ordinal() <= lastNotified.ordinal()) { | ||||||||||||||
| return; | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+43
to
+45
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.
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| switch (current) { | ||||||||||||||
| case HALF -> | ||||||||||||||
| emailSender.sendHalfUsageWarning(targetId, decision); | ||||||||||||||
|
|
||||||||||||||
| case WARNING -> | ||||||||||||||
| emailSender.sendEightyPercentWarning(targetId, decision); | ||||||||||||||
|
|
||||||||||||||
| case EXCEEDED -> | ||||||||||||||
| emailSender.sendExceededNotification(targetId, decision); | ||||||||||||||
|
|
||||||||||||||
| default -> { | ||||||||||||||
| return; | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // 알림 발송 후 상태 갱신 | ||||||||||||||
| stateStore.updateLastNotifiedThreshold(targetId, current); | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package io.springaileger.notification; | ||
|
|
||
| import io.springaileger.budget.BudgetThreshold; | ||
|
|
||
| import java.util.concurrent.ConcurrentHashMap; | ||
|
|
||
| /** | ||
| * NotificationStateStore의 인메모리 구현체입니다. | ||
| * | ||
| * 로컬 실행 및 테스트 환경에서 사용하기 위한 기본 구현이며, | ||
| * 프로덕션에서는 DB 또는 외부 저장소 구현체로 교체 가능합니다. | ||
| */ | ||
| public class InMemoryNotificationStateStore | ||
| implements NotificationStateStore { | ||
|
|
||
| private final ConcurrentHashMap<String, BudgetThreshold> store = | ||
| new ConcurrentHashMap<>(); | ||
|
|
||
| @Override | ||
| public BudgetThreshold getLastNotifiedThreshold(String targetId) { | ||
| return store.getOrDefault(targetId, BudgetThreshold.NONE); | ||
| } | ||
|
|
||
| @Override | ||
| public void updateLastNotifiedThreshold( | ||
| String targetId, | ||
| BudgetThreshold threshold | ||
| ) { | ||
| store.put(targetId, threshold); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package io.springaileger.notification; | ||
|
|
||
| import io.springaileger.budget.BudgetThreshold; | ||
|
|
||
| /** | ||
| * 예산 알림의 중복 발송을 방지하기 위해 | ||
| * 마지막으로 알림을 보낸 임계치를 저장하는 저장소 인터페이스입니다. | ||
| * | ||
| * 사용자/테넌트 식별자 기준으로 알림 상태를 관리합니다. | ||
| */ | ||
| public interface NotificationStateStore { | ||
|
|
||
| /** | ||
| * 지정된 대상에 대해 마지막으로 알림을 보낸 임계치를 반환합니다. | ||
| */ | ||
| BudgetThreshold getLastNotifiedThreshold(String targetId); | ||
|
|
||
| /** | ||
| * 지정된 대상의 마지막 알림 임계치를 갱신합니다. | ||
| */ | ||
| void updateLastNotifiedThreshold(String targetId, BudgetThreshold threshold); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| package io.springaileger.notification; | ||
|
|
||
| import io.springaileger.budget.*; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import java.math.BigDecimal; | ||
|
|
||
| import static org.mockito.Mockito.*; | ||
|
|
||
| /** | ||
| * BudgetNotificationService가 | ||
| * 예산 임계치에 따라 알림을 한 번만 보내는지 검증하는 테스트 | ||
| */ | ||
| class BudgetNotificationServiceTest { | ||
|
|
||
| @Test | ||
| void should_send_notification_only_once_per_threshold() { | ||
| // given | ||
| BudgetEmailSender emailSender = mock(BudgetEmailSender.class); | ||
| NotificationStateStore stateStore = new InMemoryNotificationStateStore(); | ||
|
|
||
| BudgetNotificationService service = | ||
| new BudgetNotificationService(emailSender, stateStore); | ||
|
|
||
| BudgetDecision decision = new BudgetDecision( | ||
| BudgetState.WARN, | ||
| BudgetThreshold.WARNING, | ||
| "80% 도달", | ||
| BigDecimal.valueOf(80), | ||
| BigDecimal.valueOf(100) | ||
| ); | ||
|
|
||
| // when: 같은 decision을 두 번 전달 | ||
| service.notifyIfNeeded("user@test.com", decision); | ||
| service.notifyIfNeeded("user@test.com", decision); | ||
|
|
||
| // then: 메일은 한 번만 전송 | ||
| verify(emailSender, times(1)) | ||
| .sendEightyPercentWarning("user@test.com", decision); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,20 @@ | ||
| package io.tokenledger.budget; | ||
| package io.springaileger.budget; | ||
|
|
||
| import java.math.BigDecimal; | ||
|
|
||
|
|
||
| /** | ||
| * 예산 평가 결과를 나타내는 값 객체입니다. | ||
| * <p> | ||
| * | ||
| * 호출 가능 여부와 | ||
| * 판단에 필요한 최소한의 정보를 담습니다. | ||
| */ | ||
|
|
||
| // 우리가 "의도한" 형태 | ||
| public record BudgetDecision( | ||
|
Comment on lines
+13
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. |
||
| BudgetState state, | ||
| BudgetThreshold threshold, | ||
| String reason, | ||
| BigDecimal currentUsage, | ||
| BigDecimal limit | ||
| ) {} | ||
| ) {} | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,12 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| package io.springaileger.budget; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||
| * 예산 사용률에 따른 임계치 단계입니다. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| public enum BudgetThreshold { | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| NONE, // 임계치 미도달 | ||||||||||||||||||||||||||||||||||||||||||||||||||
| HALF, // 50% | ||||||||||||||||||||||||||||||||||||||||||||||||||
| WARNING, // 80% | ||||||||||||||||||||||||||||||||||||||||||||||||||
| EXCEEDED // 100% | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+6
to
+12
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.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,6 @@ | ||
| package io.tokenledger.budget.internal; | ||
| package io.springaileger.budget.internal; | ||
|
|
||
| import io.tokenledger.budget.*; | ||
| import io.tokenledger.budget.exception.BudgetExceededException; | ||
| import io.springaileger.budget.*; | ||
|
|
||
| import java.math.BigDecimal; | ||
| import java.util.Map; | ||
|
|
@@ -10,12 +9,9 @@ | |
| * BudgetEvaluator의 기본 구현체입니다. | ||
| * | ||
| * 판단 기준: | ||
| * - 80% 미만 → ALLOW | ||
| * - 80% 이상 → WARN | ||
| * - 100% 이상 → BLOCK (예외 발생) | ||
| * | ||
| * 이 클래스의 evaluate 메서드는 부수 효과가 없는 순수 함수로 동작합니다. | ||
| * 실제 비용 누적은 BudgetStateStore.addCost를 통해 별도로 수행해야 합니다. | ||
| * - 50% 이상 → ALLOW (HALF) | ||
| * - 80% 이상 → WARN (WARNING) | ||
| * - 100% 이상 → BLOCK (EXCEEDED, 예외 발생) | ||
| */ | ||
| public class DefaultBudgetEvaluator implements BudgetEvaluator { | ||
|
|
||
|
|
@@ -30,34 +26,30 @@ public DefaultBudgetEvaluator( | |
| this.monthlyLimit = monthlyLimit; | ||
| } | ||
|
|
||
| @Override | ||
| public BudgetDecision evaluate(Map<String, String> tags) { | ||
| return evaluate(tags, BigDecimal.ZERO); | ||
| } | ||
|
|
||
| @Override | ||
| public BudgetDecision evaluate( | ||
| Map<String, String> tags, | ||
| BigDecimal costAmount | ||
| ) { | ||
|
|
||
| // ✅ 현재까지 누적 비용 | ||
| // 현재까지 누적 비용 | ||
| BigDecimal accumulated = store.getAccumulatedCost(tags); | ||
|
|
||
| // ✅ 이번 호출까지 포함한 비용 (비교용) | ||
| // 이번 호출까지 포함한 비용 | ||
| BigDecimal nextUsage = accumulated.add(costAmount); | ||
|
|
||
| // ✅ 경고 기준 (80%) | ||
| BigDecimal warnThreshold = | ||
| monthlyLimit.multiply(new BigDecimal("0.8")); | ||
| // 임계치 계산 | ||
| BigDecimal halfThreshold = monthlyLimit.multiply(BigDecimal.valueOf(0.5)); | ||
| BigDecimal warnThreshold = monthlyLimit.multiply(BigDecimal.valueOf(0.8)); | ||
|
Comment on lines
+42
to
+43
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. |
||
|
|
||
| /* ===================== | ||
| 1️⃣ 차단 (BLOCK) | ||
| ===================== */ | ||
| /* ===================== | ||
| 1️⃣ 차단 (100%) | ||
| ===================== */ | ||
| if (nextUsage.compareTo(monthlyLimit) >= 0) { | ||
|
|
||
| BudgetDecision decision = new BudgetDecision( | ||
| BudgetState.BLOCK, | ||
| BudgetThreshold.EXCEEDED, | ||
| "월 예산 초과로 AI 호출이 차단되었습니다", | ||
| nextUsage, | ||
| monthlyLimit | ||
|
|
@@ -66,23 +58,41 @@ public BudgetDecision evaluate( | |
| throw new BudgetExceededException(decision); | ||
| } | ||
|
|
||
| /* ===================== | ||
| 2️⃣ 경고 (WARN) | ||
| ===================== */ | ||
| // 비용 누적 (차단이 아닌 경우) | ||
| store.addCost(tags, costAmount); | ||
|
|
||
| /* ===================== | ||
| 2️⃣ 경고 (80%) | ||
| ===================== */ | ||
| if (nextUsage.compareTo(warnThreshold) >= 0) { | ||
| return new BudgetDecision( | ||
| BudgetState.WARN, | ||
| BudgetThreshold.WARNING, | ||
| "월 예산의 80%에 도달했습니다", | ||
| nextUsage, | ||
| monthlyLimit | ||
| ); | ||
| } | ||
|
|
||
| /* ===================== | ||
| 3️⃣ 허용 (ALLOW) | ||
| ===================== */ | ||
| /* ===================== | ||
| 3️⃣ 절반 경고 (50%) | ||
| ===================== */ | ||
| if (nextUsage.compareTo(halfThreshold) >= 0) { | ||
| return new BudgetDecision( | ||
| BudgetState.ALLOW, | ||
| BudgetThreshold.HALF, | ||
| "월 예산의 50%에 도달했습니다", | ||
| nextUsage, | ||
| monthlyLimit | ||
| ); | ||
| } | ||
|
|
||
| /* ===================== | ||
| 4️⃣ 정상 | ||
| ===================== */ | ||
| return new BudgetDecision( | ||
| BudgetState.ALLOW, | ||
| BudgetThreshold.NONE, | ||
| "예산 범위 내입니다", | ||
| nextUsage, | ||
| monthlyLimit | ||
|
|
||
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.
현재 알림 상태 저장소(
NotificationStateStore)가targetId만을 키로 사용하고 있어, 예산 주기가 바뀌어도(예: 다음 달) 이전 상태가 유지되는 문제가 있습니다. 주기가 바뀔 때 알림이 다시 발송될 수 있도록 키에 주기 정보(예: 연월)를 포함하거나 상태 초기화 메커니즘이 필요합니다.