From 99162959920c40b23149a45df035e3b4c187cece Mon Sep 17 00:00:00 2001 From: Kyungjin Date: Sun, 3 May 2026 13:03:59 +0900 Subject: [PATCH 1/3] test(budget): add notification service unit tests --- .../main/java/io/springaileger/budget/BudgetThreshold.java | 4 ++++ .../java/io/springaileger/notification/BudgetEmailSender.java | 4 ++++ .../springaileger/notification/BudgetNotificationService.java | 4 ++++ .../notification/BudgetNotificationServiceTest.java | 4 ++++ 4 files changed, 16 insertions(+) create mode 100644 spring-ai-ledger-budget/src/main/java/io/springaileger/budget/BudgetThreshold.java create mode 100644 spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetEmailSender.java create mode 100644 spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetNotificationService.java create mode 100644 spring-ai-ledger-budget/src/test/java/io/springaileger/notification/BudgetNotificationServiceTest.java diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/BudgetThreshold.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/BudgetThreshold.java new file mode 100644 index 0000000..12c3f40 --- /dev/null +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/BudgetThreshold.java @@ -0,0 +1,4 @@ +package io.springaileger.budget; + +public class BudgetThreshold { +} diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetEmailSender.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetEmailSender.java new file mode 100644 index 0000000..8b39fec --- /dev/null +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetEmailSender.java @@ -0,0 +1,4 @@ +package io.springaileger.notification; + +public class BudgetEmailSender { +} diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetNotificationService.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetNotificationService.java new file mode 100644 index 0000000..1426376 --- /dev/null +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetNotificationService.java @@ -0,0 +1,4 @@ +package io.springaileger.notification; + +public class BudgetNotificationService { +} diff --git a/spring-ai-ledger-budget/src/test/java/io/springaileger/notification/BudgetNotificationServiceTest.java b/spring-ai-ledger-budget/src/test/java/io/springaileger/notification/BudgetNotificationServiceTest.java new file mode 100644 index 0000000..22921cc --- /dev/null +++ b/spring-ai-ledger-budget/src/test/java/io/springaileger/notification/BudgetNotificationServiceTest.java @@ -0,0 +1,4 @@ +package io.springaileger.notification; + +public class BudgetNotificationServiceTest { +} From 799475887c7ec41b7d85c6eec89ed61c8f9cb4d6 Mon Sep 17 00:00:00 2001 From: Kyungjin Date: Sun, 3 May 2026 14:55:53 +0900 Subject: [PATCH 2/3] feat(notification): prevent duplicate threshold notifications --- .../notification/InMemoryNotificationStateStore.java | 4 ++++ .../io/springaileger/notification/NotificationStateStore.java | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 spring-ai-ledger-budget/src/main/java/io/springaileger/notification/InMemoryNotificationStateStore.java create mode 100644 spring-ai-ledger-budget/src/main/java/io/springaileger/notification/NotificationStateStore.java diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/InMemoryNotificationStateStore.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/InMemoryNotificationStateStore.java new file mode 100644 index 0000000..8aae03d --- /dev/null +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/InMemoryNotificationStateStore.java @@ -0,0 +1,4 @@ +package io.springaileger.notification; + +public class InMemoryNotificationStateStore { +} diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/NotificationStateStore.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/NotificationStateStore.java new file mode 100644 index 0000000..b749ff6 --- /dev/null +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/NotificationStateStore.java @@ -0,0 +1,4 @@ +package io.springaileger.notification; + +public class NotificationStateStore { +} From 5b523207a38d98c658d656848ecaaa1a306dd041 Mon Sep 17 00:00:00 2001 From: Kyungjin Date: Mon, 11 May 2026 20:59:20 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=EC=98=88=EC=82=B0=20=EC=9E=84=EA=B3=84?= =?UTF-8?q?=EC=B9=98(50/80/100)=20=ED=8C=90=EB=8B=A8=20=EB=B0=8F=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../springaileger/budget/BudgetDecision.java | 8 +-- .../springaileger/budget/BudgetThreshold.java | 12 +++- .../internal/DefaultBudgetEvaluator.java | 59 ++++++++++------- .../notification/BudgetEmailSender.java | 27 +++++++- .../BudgetNotificationService.java | 63 ++++++++++++++++++- .../InMemoryNotificationStateStore.java | 31 ++++++++- .../notification/NotificationStateStore.java | 22 ++++++- .../BudgetNotificationServiceTest.java | 41 +++++++++++- 8 files changed, 224 insertions(+), 39 deletions(-) diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/BudgetDecision.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/BudgetDecision.java index a503c4c..37a393a 100644 --- a/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/BudgetDecision.java +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/BudgetDecision.java @@ -10,13 +10,11 @@ * 판단에 필요한 최소한의 정보를 담습니다. */ +// 우리가 "의도한" 형태 public record BudgetDecision( - BudgetState state, - + BudgetThreshold threshold, String reason, - BigDecimal currentUsage, - BigDecimal limit -) {} +) {} \ No newline at end of file diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/BudgetThreshold.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/BudgetThreshold.java index 12c3f40..625ab7d 100644 --- a/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/BudgetThreshold.java +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/BudgetThreshold.java @@ -1,4 +1,12 @@ package io.springaileger.budget; -public class BudgetThreshold { -} +/** + * 예산 사용률에 따른 임계치 단계입니다. + */ +public enum BudgetThreshold { + + NONE, // 임계치 미도달 + HALF, // 50% + WARNING, // 80% + EXCEEDED // 100% +} \ No newline at end of file diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/internal/DefaultBudgetEvaluator.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/internal/DefaultBudgetEvaluator.java index 8146448..63f9295 100644 --- a/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/internal/DefaultBudgetEvaluator.java +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/budget/internal/DefaultBudgetEvaluator.java @@ -9,11 +9,10 @@ * BudgetEvaluator의 기본 구현체입니다. * * 판단 기준: - * - 80% 미만 → ALLOW - * - 80% 이상 → WARN - * - 100% 이상 → BLOCK (예외 발생) + * - 50% 이상 → ALLOW (HALF) + * - 80% 이상 → WARN (WARNING) + * - 100% 이상 → BLOCK (EXCEEDED, 예외 발생) */ - public class DefaultBudgetEvaluator implements BudgetEvaluator { private final BudgetStateStore store; @@ -33,23 +32,24 @@ public BudgetDecision evaluate( 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)); - /* ===================== - 1️⃣ 차단 (BLOCK) - ===================== */ + /* ===================== + 1️⃣ 차단 (100%) + ===================== */ if (nextUsage.compareTo(monthlyLimit) >= 0) { BudgetDecision decision = new BudgetDecision( BudgetState.BLOCK, + BudgetThreshold.EXCEEDED, "월 예산 초과로 AI 호출이 차단되었습니다", nextUsage, monthlyLimit @@ -58,31 +58,44 @@ public BudgetDecision evaluate( throw new BudgetExceededException(decision); } - /* ===================== - 2️⃣ 경고 (WARN) - ===================== */ - if (nextUsage.compareTo(warnThreshold) >= 0) { - - store.addCost(tags, costAmount); + // 비용 누적 (차단이 아닌 경우) + store.addCost(tags, costAmount); + /* ===================== + 2️⃣ 경고 (80%) + ===================== */ + if (nextUsage.compareTo(warnThreshold) >= 0) { return new BudgetDecision( BudgetState.WARN, + BudgetThreshold.WARNING, "월 예산의 80%에 도달했습니다", nextUsage, monthlyLimit ); } - /* ===================== - 3️⃣ 허용 (ALLOW) - ===================== */ - store.addCost(tags, costAmount); + /* ===================== + 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 ); } -} \ No newline at end of file +} diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetEmailSender.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetEmailSender.java index 8b39fec..dcc8c9f 100644 --- a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetEmailSender.java +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetEmailSender.java @@ -1,4 +1,27 @@ package io.springaileger.notification; -public class BudgetEmailSender { -} +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); +} \ No newline at end of file diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetNotificationService.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetNotificationService.java index 1426376..cd0c625 100644 --- a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetNotificationService.java +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/BudgetNotificationService.java @@ -1,4 +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; + } + + 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); + } +} \ No newline at end of file diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/InMemoryNotificationStateStore.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/InMemoryNotificationStateStore.java index 8aae03d..6367663 100644 --- a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/InMemoryNotificationStateStore.java +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/InMemoryNotificationStateStore.java @@ -1,4 +1,31 @@ package io.springaileger.notification; -public class InMemoryNotificationStateStore { -} +import io.springaileger.budget.BudgetThreshold; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * NotificationStateStore의 인메모리 구현체입니다. + * + * 로컬 실행 및 테스트 환경에서 사용하기 위한 기본 구현이며, + * 프로덕션에서는 DB 또는 외부 저장소 구현체로 교체 가능합니다. + */ +public class InMemoryNotificationStateStore + implements NotificationStateStore { + + private final ConcurrentHashMap 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); + } +} \ No newline at end of file diff --git a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/NotificationStateStore.java b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/NotificationStateStore.java index b749ff6..2704f4a 100644 --- a/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/NotificationStateStore.java +++ b/spring-ai-ledger-budget/src/main/java/io/springaileger/notification/NotificationStateStore.java @@ -1,4 +1,22 @@ package io.springaileger.notification; -public class NotificationStateStore { -} +import io.springaileger.budget.BudgetThreshold; + +/** + * 예산 알림의 중복 발송을 방지하기 위해 + * 마지막으로 알림을 보낸 임계치를 저장하는 저장소 인터페이스입니다. + * + * 사용자/테넌트 식별자 기준으로 알림 상태를 관리합니다. + */ +public interface NotificationStateStore { + + /** + * 지정된 대상에 대해 마지막으로 알림을 보낸 임계치를 반환합니다. + */ + BudgetThreshold getLastNotifiedThreshold(String targetId); + + /** + * 지정된 대상의 마지막 알림 임계치를 갱신합니다. + */ + void updateLastNotifiedThreshold(String targetId, BudgetThreshold threshold); +} \ No newline at end of file diff --git a/spring-ai-ledger-budget/src/test/java/io/springaileger/notification/BudgetNotificationServiceTest.java b/spring-ai-ledger-budget/src/test/java/io/springaileger/notification/BudgetNotificationServiceTest.java index 22921cc..45bc296 100644 --- a/spring-ai-ledger-budget/src/test/java/io/springaileger/notification/BudgetNotificationServiceTest.java +++ b/spring-ai-ledger-budget/src/test/java/io/springaileger/notification/BudgetNotificationServiceTest.java @@ -1,4 +1,41 @@ package io.springaileger.notification; -public class BudgetNotificationServiceTest { -} +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); + } +} \ No newline at end of file