Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

현재 알림 상태 저장소(NotificationStateStore)가 targetId만을 키로 사용하고 있어, 예산 주기가 바뀌어도(예: 다음 달) 이전 상태가 유지되는 문제가 있습니다. 주기가 바뀔 때 알림이 다시 발송될 수 있도록 키에 주기 정보(예: 연월)를 포함하거나 상태 초기화 메커니즘이 필요합니다.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

ordinal()을 직접 비교하는 대신 BudgetThreshold에 비교 로직을 캡슐화하여 사용하는 것이 좋습니다(제공된 BudgetThreshold 관련 제안 참고). 또한, 현재 로직은 임계치 확인과 상태 갱신 사이에 원자성이 보장되지 않아 동시성 환경에서 중복 알림이 발생할 수 있는 구조입니다.

Suggested change
if (current.ordinal() <= lastNotified.ordinal()) {
return;
}
if (!current.isHigherThan(lastNotified)) {
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);
}
}
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

이 주석(// 우리가 "의도한" 형태)은 개발 과정의 메모로 보입니다. 코드의 의도는 클래스 구조로 충분히 전달되므로, 프로덕션 코드의 깔끔함을 위해 삭제하는 것을 권장합니다.

public record BudgetDecision(

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

ordinal()에 의존하는 대신, 각 임계치에 명시적인 순서나 비교 로직을 제공하는 것이 안전합니다. 이를 통해 향후 Enum 상수의 순서가 바뀌더라도 로직이 깨지는 것을 방지할 수 있습니다.

Suggested change
public enum BudgetThreshold {
NONE, // 임계치 미도달
HALF, // 50%
WARNING, // 80%
EXCEEDED // 100%
}
public enum BudgetThreshold {
NONE(0), // 임계치 미도달
HALF(1), // 50%
WARNING(2), // 80%
EXCEEDED(3); // 100%
private final int level;
BudgetThreshold(int level) {
this.level = level;
}
public boolean isHigherThan(BudgetThreshold other) {
return this.level > other.level;
}
}

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;
Expand All @@ -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 {

Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

halfThresholdwarnThresholdmonthlyLimit이 결정되면 변하지 않는 값입니다. evaluate 메서드 호출 시마다 매번 계산하기보다 생성자에서 미리 계산하여 필드로 관리하는 것이 효율적입니다.


/* =====================
1️⃣ 차단 (BLOCK)
===================== */
/* =====================
1️⃣ 차단 (100%)
===================== */
if (nextUsage.compareTo(monthlyLimit) >= 0) {

BudgetDecision decision = new BudgetDecision(
BudgetState.BLOCK,
BudgetThreshold.EXCEEDED,
"월 예산 초과로 AI 호출이 차단되었습니다",
nextUsage,
monthlyLimit
Expand All @@ -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
Expand Down
Loading