From 0041e6276714d0d66ed3a8281266014593c45eef Mon Sep 17 00:00:00 2001 From: k0081915 Date: Sun, 15 Mar 2026 05:00:16 +0900 Subject: [PATCH 1/7] =?UTF-8?q?DABOM-459=20feat:=20family=20=EC=9B=94?= =?UTF-8?q?=EB=B3=84=20=EC=A7=91=EA=B3=84=EB=A5=BC=20family=5Fquota?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/domain/family/entity/Family.java | 29 +--- .../domain/family/entity/FamilyQuota.java | 56 ++++++++ .../repository/FamilyQuotaRepository.java | 54 ++++++++ .../family/repository/FamilyRepository.java | 25 +--- .../service/UsagePersistServiceImpl.java | 6 +- .../service/helper/FamilyQuotaWriter.java | 127 ++++++++++++++++++ 6 files changed, 242 insertions(+), 55 deletions(-) create mode 100644 src/main/java/com/project/domain/family/entity/FamilyQuota.java create mode 100644 src/main/java/com/project/domain/family/repository/FamilyQuotaRepository.java create mode 100644 src/main/java/com/project/domain/usage/service/helper/FamilyQuotaWriter.java diff --git a/src/main/java/com/project/domain/family/entity/Family.java b/src/main/java/com/project/domain/family/entity/Family.java index 8a6d87a..c28febd 100644 --- a/src/main/java/com/project/domain/family/entity/Family.java +++ b/src/main/java/com/project/domain/family/entity/Family.java @@ -1,7 +1,5 @@ package com.project.domain.family.entity; -import java.time.LocalDate; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -32,36 +30,11 @@ public class Family extends BaseEntity { @Column(name = "created_by_id", nullable = false) private Long createdById; - @Column(name = "total_quota_bytes", nullable = false) - private Long totalQuotaBytes; - - @Column(name = "used_bytes", nullable = false) - private Long usedBytes; - - @Column(name = "current_month", nullable = false) - private LocalDate currentMonth; - @Builder - public Family( - Long id, - String name, - Long createdById, - Long totalQuotaBytes, - Long usedBytes, - LocalDate currentMonth) { + public Family(Long id, String name, Long createdById) { this.id = id; this.name = name; this.createdById = createdById; - this.totalQuotaBytes = totalQuotaBytes; - this.usedBytes = usedBytes; - this.currentMonth = currentMonth; - } - - public double calculateUsedPercent() { - if (totalQuotaBytes == null || totalQuotaBytes == 0) { - return 0.0; - } - return (double) usedBytes / totalQuotaBytes * 100.0; } public void changeName(String name) { diff --git a/src/main/java/com/project/domain/family/entity/FamilyQuota.java b/src/main/java/com/project/domain/family/entity/FamilyQuota.java new file mode 100644 index 0000000..f9ab290 --- /dev/null +++ b/src/main/java/com/project/domain/family/entity/FamilyQuota.java @@ -0,0 +1,56 @@ +package com.project.domain.family.entity; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import com.project.global.util.BaseEntity; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "family_quota", + uniqueConstraints = + @UniqueConstraint( + name = "uk_family_quota_family_month", + columnNames = {"family_id", "current_month", "deleted_at"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FamilyQuota extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "family_id", nullable = false) + private Long familyId; + + @Column(name = "current_month", nullable = false) + private LocalDate currentMonth; + + @Column(name = "total_quota_bytes", nullable = false) + private Long totalQuotaBytes; + + @Column(name = "used_bytes", nullable = false) + private Long usedBytes; + + @Builder + public FamilyQuota( + Long id, Long familyId, LocalDate currentMonth, Long totalQuotaBytes, Long usedBytes) { + this.id = id; + this.familyId = familyId; + this.currentMonth = currentMonth; + this.totalQuotaBytes = totalQuotaBytes; + this.usedBytes = usedBytes; + } +} diff --git a/src/main/java/com/project/domain/family/repository/FamilyQuotaRepository.java b/src/main/java/com/project/domain/family/repository/FamilyQuotaRepository.java new file mode 100644 index 0000000..bde13fc --- /dev/null +++ b/src/main/java/com/project/domain/family/repository/FamilyQuotaRepository.java @@ -0,0 +1,54 @@ +package com.project.domain.family.repository; + +import java.time.LocalDate; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.project.domain.family.entity.FamilyQuota; + +public interface FamilyQuotaRepository extends JpaRepository { + + @Query( + """ + SELECT fq + FROM FamilyQuota fq + WHERE fq.familyId = :familyId + AND fq.currentMonth = :currentMonth + AND fq.deletedAt IS NULL + """) + Optional findActiveByFamilyIdAndCurrentMonth( + @Param("familyId") Long familyId, @Param("currentMonth") LocalDate currentMonth); + + Optional findTopByFamilyIdAndDeletedAtIsNullOrderByCurrentMonthDesc(Long familyId); + + @Query( + value = + """ + SELECT fq.* + FROM family_quota fq + WHERE fq.family_id = :familyId + AND fq.deleted_at IS NULL + ORDER BY fq.current_month DESC, fq.id DESC + LIMIT 1 + FOR UPDATE + """, + nativeQuery = true) + Optional findLatestByFamilyIdForUpdate(@Param("familyId") Long familyId); + + @Modifying + @Query( + "update FamilyQuota fq " + + "set fq.usedBytes = fq.usedBytes + :bytesUsed, " + + "fq.updatedAt = CURRENT_TIMESTAMP " + + "where fq.familyId = :familyId " + + "and fq.currentMonth = :currentMonth " + + "and fq.deletedAt is null") + int incrementUsedBytes( + @Param("familyId") Long familyId, + @Param("currentMonth") LocalDate currentMonth, + @Param("bytesUsed") Long bytesUsed); +} diff --git a/src/main/java/com/project/domain/family/repository/FamilyRepository.java b/src/main/java/com/project/domain/family/repository/FamilyRepository.java index ff9241c..cc55446 100644 --- a/src/main/java/com/project/domain/family/repository/FamilyRepository.java +++ b/src/main/java/com/project/domain/family/repository/FamilyRepository.java @@ -1,30 +1,7 @@ package com.project.domain.family.repository; -import java.time.LocalDate; - import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import com.project.domain.family.entity.Family; -public interface FamilyRepository extends JpaRepository { - - @Modifying - @Query( - "update Family f " - + "set f.currentMonth = case when f.currentMonth < :eventMonth then" - + " :eventMonth else f.currentMonth end, " - + "f.usedBytes = case " - + "when f.currentMonth < :eventMonth then :bytesUsed " - + "when f.currentMonth = :eventMonth then f.usedBytes + :bytesUsed " - + "else f.usedBytes end, " - + "f.updatedAt = CURRENT_TIMESTAMP " - + "where f.id = :familyId " - + "and f.deletedAt is null") - int updateUsedBytesByEventMonth( - @Param("familyId") Long familyId, - @Param("eventMonth") LocalDate eventMonth, - @Param("bytesUsed") Long bytesUsed); -} +public interface FamilyRepository extends JpaRepository {} diff --git a/src/main/java/com/project/domain/usage/service/UsagePersistServiceImpl.java b/src/main/java/com/project/domain/usage/service/UsagePersistServiceImpl.java index 1629047..cfb3751 100644 --- a/src/main/java/com/project/domain/usage/service/UsagePersistServiceImpl.java +++ b/src/main/java/com/project/domain/usage/service/UsagePersistServiceImpl.java @@ -12,7 +12,7 @@ import com.project.domain.family.repository.FamilyMemberRepository; import com.project.domain.usage.enums.UsagePersistProcessResult; import com.project.domain.usage.service.helper.CustomerQuotaWriter; -import com.project.domain.usage.service.helper.FamilyUsageWriter; +import com.project.domain.usage.service.helper.FamilyQuotaWriter; import com.project.domain.usage.service.helper.UsagePersistEventValidator; import com.project.domain.usage.service.helper.UsageRecordWriter; import com.project.global.common.TimeConstants; @@ -32,7 +32,7 @@ public class UsagePersistServiceImpl implements UsagePersistService { private final UsagePersistEventValidator usagePersistEventValidator; private final UsageRecordWriter usageRecordWriter; private final CustomerQuotaWriter customerQuotaWriter; - private final FamilyUsageWriter familyUsageWriter; + private final FamilyQuotaWriter familyQuotaWriter; private final LogSanitizer logSanitizer; // usage-persist 처리의 전체 흐름을 조율 @@ -81,7 +81,7 @@ public void persist(EventEnvelope envelope, String recordKe // 4) 허용 이벤트의 월 누적 반영 customerQuotaWriter.persistAllowedQuota(payload, currentMonth, eventId, originEventId); - familyUsageWriter.updateFamilyUsedBytes( + familyQuotaWriter.persistAllowedQuota( payload.familyId(), currentMonth, payload.bytesUsed(), eventId, originEventId); } diff --git a/src/main/java/com/project/domain/usage/service/helper/FamilyQuotaWriter.java b/src/main/java/com/project/domain/usage/service/helper/FamilyQuotaWriter.java new file mode 100644 index 0000000..82e9633 --- /dev/null +++ b/src/main/java/com/project/domain/usage/service/helper/FamilyQuotaWriter.java @@ -0,0 +1,127 @@ +package com.project.domain.usage.service.helper; + +import java.time.LocalDate; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import com.project.domain.family.entity.FamilyQuota; +import com.project.domain.family.repository.FamilyQuotaRepository; +import com.project.global.util.LogSanitizer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FamilyQuotaWriter { + private static final String FAMILY_QUOTA_LOG_SUFFIX = + " familyId={}, bytesUsed={}, currentMonth={}"; + + private final FamilyQuotaRepository familyQuotaRepository; + private final LogSanitizer logSanitizer; + + public void persistAllowedQuota( + Long familyId, + LocalDate currentMonth, + Long bytesUsed, + String eventId, + String originEventId) { + // 현재 월 row가 이미 있으면 update로 끝냄 + if (tryUpdateExistingQuota(familyId, currentMonth, bytesUsed, eventId, originEventId)) { + return; + } + // 현재 월 row가 없을 때만 최신 스냅샷을 이어받아 생성함 + createQuotaRow(familyId, currentMonth, bytesUsed, eventId, originEventId); + } + + private boolean tryUpdateExistingQuota( + Long familyId, + LocalDate currentMonth, + Long bytesUsed, + String eventId, + String originEventId) { + int updatedRows = + familyQuotaRepository.incrementUsedBytes(familyId, currentMonth, bytesUsed); + if (updatedRows <= 0) { + return false; + } + + log.info( + "Persisted usage to existing family_quota row. eventId={}, originEventId={}," + + FAMILY_QUOTA_LOG_SUFFIX, + logSanitizer.sanitize(eventId), + logSanitizer.sanitize(originEventId), + familyId, + bytesUsed, + currentMonth); + return true; + } + + private void createQuotaRow( + Long familyId, + LocalDate currentMonth, + Long bytesUsed, + String eventId, + String originEventId) { + FamilyQuota latestSnapshot = + familyQuotaRepository.findLatestByFamilyIdForUpdate(familyId).orElse(null); + if (latestSnapshot == null) { + log.warn( + "Missing latest family_quota snapshot. eventId={}, originEventId={}," + + FAMILY_QUOTA_LOG_SUFFIX, + logSanitizer.sanitize(eventId), + logSanitizer.sanitize(originEventId), + familyId, + bytesUsed, + currentMonth); + throw new IllegalStateException("Latest family_quota snapshot not found"); + } + + // 다른 트랜잭션이 현재 월 row를 먼저 만들었으면 update 재시도로 수렴함 + if (currentMonth.equals(latestSnapshot.getCurrentMonth())) { + if (tryUpdateExistingQuota(familyId, currentMonth, bytesUsed, eventId, originEventId)) { + return; + } + throw new IllegalStateException("Failed to update current family_quota row"); + } + + FamilyQuota familyQuota = + FamilyQuota.builder() + .familyId(familyId) + .currentMonth(currentMonth) + .totalQuotaBytes(latestSnapshot.getTotalQuotaBytes()) + .usedBytes(bytesUsed) + .build(); + + try { + // 최신 totalQuotaBytes를 복사해서 새 월 row를 엶 + familyQuotaRepository.saveAndFlush(familyQuota); + } catch (DataIntegrityViolationException e) { + // insert 경합이면 update 재시도로 복구함 + if (tryUpdateExistingQuota(familyId, currentMonth, bytesUsed, eventId, originEventId)) { + log.info( + "Recovered from concurrent family_quota insert race. eventId={}," + + " originEventId={}," + + FAMILY_QUOTA_LOG_SUFFIX, + logSanitizer.sanitize(eventId), + logSanitizer.sanitize(originEventId), + familyId, + bytesUsed, + currentMonth); + return; + } + throw e; + } + + log.info( + "Persisted usage by creating family_quota row. eventId={}, originEventId={}," + + FAMILY_QUOTA_LOG_SUFFIX, + logSanitizer.sanitize(eventId), + logSanitizer.sanitize(originEventId), + familyId, + bytesUsed, + currentMonth); + } +} From f12a6e456acf7074d91a6209dcfe2f4d13f6d976 Mon Sep 17 00:00:00 2001 From: k0081915 Date: Sun, 15 Mar 2026 05:01:40 +0900 Subject: [PATCH 2/7] =?UTF-8?q?DABOM-459=20feat:=20family=20redis=20?= =?UTF-8?q?=ED=82=A4=EB=A5=BC=20=EC=9B=94=20suffix=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/cache/FamilyCacheRepository.java | 71 ----------------- .../infra/cache/dto/FamilyCacheDto.java | 35 --------- .../service/helper/FamilyUsageWriter.java | 44 ----------- .../service/helper/UsageLuaExecutor.java | 9 ++- .../helper/UsageRedisWarmupHelper.java | 77 ++++++++++++++----- .../project/global/config/RedisConfig.java | 30 -------- .../global/util/RedisKeyGenerator.java | 34 ++++++-- src/main/resources/lua/usage_update.lua | 28 +++---- .../service/helper/FamilyUsageWriterTest.java | 65 ---------------- 9 files changed, 107 insertions(+), 286 deletions(-) delete mode 100644 src/main/java/com/project/domain/family/infra/cache/FamilyCacheRepository.java delete mode 100644 src/main/java/com/project/domain/family/infra/cache/dto/FamilyCacheDto.java delete mode 100644 src/main/java/com/project/domain/usage/service/helper/FamilyUsageWriter.java delete mode 100644 src/test/java/com/project/domain/usage/service/helper/FamilyUsageWriterTest.java diff --git a/src/main/java/com/project/domain/family/infra/cache/FamilyCacheRepository.java b/src/main/java/com/project/domain/family/infra/cache/FamilyCacheRepository.java deleted file mode 100644 index 5d7cf0b..0000000 --- a/src/main/java/com/project/domain/family/infra/cache/FamilyCacheRepository.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.project.domain.family.infra.cache; - -import java.time.LocalDate; -import java.util.Optional; - -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Repository; - -import com.project.domain.family.entity.Family; -import com.project.domain.family.infra.cache.dto.FamilyCacheDto; -import com.project.global.common.TimeConstants; -import com.project.global.util.RedisKeyGenerator; - -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class FamilyCacheRepository { - - private final RedisTemplate familyStringRedisTemplate; - private final RedisTemplate familyCacheRedisTemplate; - private final RedisKeyGenerator redisKeyGenerator; - - public void save(Family family) { - String key = redisKeyGenerator.generateFamilyInfoKey(family.getId()); - familyCacheRedisTemplate.opsForValue().set(key, FamilyCacheDto.from(family)); - } - - public Optional findById(Long familyId) { - String key = redisKeyGenerator.generateFamilyInfoKey(familyId); - FamilyCacheDto dto = familyCacheRedisTemplate.opsForValue().get(key); - - if (dto == null) { - return Optional.empty(); - } - - return Optional.of(dto.toEntity()); - } - - public void evict(Long familyId) { - String key = redisKeyGenerator.generateFamilyInfoKey(familyId); - familyCacheRedisTemplate.delete(key); - } - - public Optional findFamilyRemainingBytes(Long familyId) { - String key = redisKeyGenerator.generateFamilyRemainingKey(familyId); - return findLongValue(key); - } - - public Optional findCustomerMonthlyUsageBytes(Long familyId, Long customerId) { - LocalDate currentMonth = LocalDate.now(TimeConstants.ASIA_SEOUL).withDayOfMonth(1); - String key = - redisKeyGenerator.generateFamilyCustomerMonthlyUsageKey( - familyId, customerId, currentMonth); - return findLongValue(key); - } - - private Optional findLongValue(String key) { - String raw = familyStringRedisTemplate.opsForValue().get(key); - - if (raw == null || raw.isBlank()) { - return Optional.empty(); - } - - try { - return Optional.of(Long.parseLong(raw)); - } catch (NumberFormatException e) { - return Optional.empty(); - } - } -} diff --git a/src/main/java/com/project/domain/family/infra/cache/dto/FamilyCacheDto.java b/src/main/java/com/project/domain/family/infra/cache/dto/FamilyCacheDto.java deleted file mode 100644 index 735e2dc..0000000 --- a/src/main/java/com/project/domain/family/infra/cache/dto/FamilyCacheDto.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.project.domain.family.infra.cache.dto; - -import java.time.LocalDate; - -import com.project.domain.family.entity.Family; - -public record FamilyCacheDto( - Long id, - String name, - Long createdById, - Long totalQuotaBytes, - Long usedBytes, - LocalDate currentMonth) { - - public static FamilyCacheDto from(Family family) { - return new FamilyCacheDto( - family.getId(), - family.getName(), - family.getCreatedById(), - family.getTotalQuotaBytes(), - family.getUsedBytes(), - family.getCurrentMonth()); - } - - public Family toEntity() { - return Family.builder() - .id(id) - .name(name) - .createdById(createdById) - .totalQuotaBytes(totalQuotaBytes) - .usedBytes(usedBytes) - .currentMonth(currentMonth) - .build(); - } -} diff --git a/src/main/java/com/project/domain/usage/service/helper/FamilyUsageWriter.java b/src/main/java/com/project/domain/usage/service/helper/FamilyUsageWriter.java deleted file mode 100644 index 2ac4fd8..0000000 --- a/src/main/java/com/project/domain/usage/service/helper/FamilyUsageWriter.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.project.domain.usage.service.helper; - -import java.time.LocalDate; - -import org.springframework.stereotype.Service; - -import com.project.domain.family.repository.FamilyRepository; -import com.project.global.util.LogSanitizer; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Service -@RequiredArgsConstructor -public class FamilyUsageWriter { - - private final FamilyRepository familyRepository; - private final LogSanitizer logSanitizer; - - // 허용 이벤트의 bytesUsed를 family.used_bytes에 누적 반영한다. - public void updateFamilyUsedBytes( - Long familyId, - LocalDate eventMonth, - Long bytesUsed, - String eventId, - String originEventId) { - int updatedRows = - familyRepository.updateUsedBytesByEventMonth(familyId, eventMonth, bytesUsed); - if (updatedRows > 0) { - return; - } - - log.warn( - "Failed to update family.used_bytes. eventId={}, originEventId={}, familyId={}," - + " eventMonth={}, bytesUsed={}", - logSanitizer.sanitize(eventId), - logSanitizer.sanitize(originEventId), - familyId, - eventMonth, - bytesUsed); - throw new IllegalStateException("Failed to update family used bytes"); - } -} diff --git a/src/main/java/com/project/domain/usage/service/helper/UsageLuaExecutor.java b/src/main/java/com/project/domain/usage/service/helper/UsageLuaExecutor.java index e89529d..5e35b9d 100644 --- a/src/main/java/com/project/domain/usage/service/helper/UsageLuaExecutor.java +++ b/src/main/java/com/project/domain/usage/service/helper/UsageLuaExecutor.java @@ -31,7 +31,9 @@ public UsageUpdateResult execute(UsageLuaCommand command, String eventId) { command.remainingKey(), command.monthlyKey(), command.constraintsKey(), - command.alertsKey(), + command.alert50Key(), + command.alert30Key(), + command.alert10Key(), command.dedupKey()), String.valueOf(command.usageBytes()), command.currentHhmm(), @@ -81,8 +83,9 @@ public record UsageLuaCommand( String remainingKey, String monthlyKey, String constraintsKey, - String alertsKey, - // usage-event 중복 검사 키 + String alert50Key, + String alert30Key, + String alert10Key, String dedupKey, long usageBytes, String currentHhmm, diff --git a/src/main/java/com/project/domain/usage/service/helper/UsageRedisWarmupHelper.java b/src/main/java/com/project/domain/usage/service/helper/UsageRedisWarmupHelper.java index 5548c3e..ba81943 100644 --- a/src/main/java/com/project/domain/usage/service/helper/UsageRedisWarmupHelper.java +++ b/src/main/java/com/project/domain/usage/service/helper/UsageRedisWarmupHelper.java @@ -15,6 +15,8 @@ import com.project.domain.customer.entity.CustomerQuota; import com.project.domain.customer.repository.CustomerQuotaRepository; import com.project.domain.family.entity.Family; +import com.project.domain.family.entity.FamilyQuota; +import com.project.domain.family.repository.FamilyQuotaRepository; import com.project.domain.family.repository.FamilyRepository; import com.project.domain.usage.infra.cache.dto.FamilyInfoRedisHash; import com.project.domain.usage.service.dto.FamilyInfo; @@ -30,9 +32,10 @@ public class UsageRedisWarmupHelper { private final StringRedisTemplate stringRedisTemplate; private final FamilyRepository familyRepository; + private final FamilyQuotaRepository familyQuotaRepository; private final CustomerQuotaRepository customerQuotaRepository; - public boolean ensureFamilyInfoCached(long familyId, String key) { + public boolean ensureFamilyInfoCached(long familyId, LocalDate eventMonth, String key) { try { // Redis 조회 Map hash = stringRedisTemplate.opsForHash().entries(key); @@ -40,25 +43,33 @@ public boolean ensureFamilyInfoCached(long familyId, String key) { return true; // 이미 캐시 존재 } - // DB 조회 - Family entity = familyRepository.findById(familyId).orElse(null); - if (entity == null) { + // family와 family_quota 스냅샷을 조합해서 월별 info hash를 채움 + Family family = familyRepository.findById(familyId).orElse(null); + if (family == null) { log.warn("Family not found in DB during Redis fallback. familyId={}", familyId); return false; } + FamilyQuota familyQuota = resolveFamilyQuotaForInfo(familyId, eventMonth); + if (familyQuota == null) { + log.warn( + "Family quota snapshot not found in DB during info warmup. familyId={}," + + " eventMonth={}", + familyId, + eventMonth); + return false; + } + FamilyInfo info = new FamilyInfo( familyId, - entity.getName(), - entity.getTotalQuotaBytes(), - entity.getCreatedAt()); + family.getName(), + familyQuota.getTotalQuotaBytes(), + family.getCreatedAt()); // Redis 저장 stringRedisTemplate.opsForHash().putAll(key, FamilyInfoRedisHash.toHash(info)); - return true; - } catch (DataAccessException e) { log.error("Data access error during ensureFamilyInfoCached. familyId={}", familyId, e); return false; @@ -68,7 +79,7 @@ public boolean ensureFamilyInfoCached(long familyId, String key) { } } - public boolean ensureRemainingBytesCached(long familyId, String key) { + public boolean ensureRemainingBytesCached(long familyId, LocalDate eventMonth, String key) { try { // Redis에 값이 있으면 성공 String cached = stringRedisTemplate.opsForValue().get(key); @@ -76,14 +87,34 @@ public boolean ensureRemainingBytesCached(long familyId, String key) { return true; } - // Redis에 없으면 DB 조회 - Family family = familyRepository.findById(familyId).orElse(null); - if (family == null) { - log.warn("Family not found in DB during Redis fallback. familyId={}", familyId); + // 현재 월 row가 있으면 실제 잔여량을 쓰고 없으면 최신 총량으로 월초 상태를 시드함 + FamilyQuota currentMonthQuota = + familyQuotaRepository + .findActiveByFamilyIdAndCurrentMonth(familyId, eventMonth) + .orElse(null); + FamilyQuota latestSnapshot = + currentMonthQuota != null + ? currentMonthQuota + : familyQuotaRepository + .findTopByFamilyIdAndDeletedAtIsNullOrderByCurrentMonthDesc( + familyId) + .orElse(null); + if (latestSnapshot == null) { + log.warn( + "Family quota snapshot not found in DB during remaining warmup." + + " familyId={}, eventMonth={}", + familyId, + eventMonth); return false; } - long remaining = Math.max(0L, family.getTotalQuotaBytes() - family.getUsedBytes()); + long remaining = + currentMonthQuota != null + ? Math.max( + 0L, + currentMonthQuota.getTotalQuotaBytes() + - currentMonthQuota.getUsedBytes()) + : latestSnapshot.getTotalQuotaBytes(); // Redis에 쓰기 Boolean written = @@ -95,7 +126,6 @@ public boolean ensureRemainingBytesCached(long familyId, String key) { // setIfAbsent가 false면 다시 GET해서 존재 확인 // 다른 스레드/인스턴스가 먼저 세팅했어도 그건 성공으로 판단 return stringRedisTemplate.hasKey(key); - } catch (DataAccessException e) { // Redis/DB 접근 계층 예외 (스프링 데이터 공통) log.error( @@ -120,7 +150,7 @@ public boolean ensureCustomerUsageCached( return true; } - // CustomerQuota 조회 + // 월 row가 없어도 첫 이벤트 처리를 위해 0으로 시드함 CustomerQuota quota = customerQuotaRepository .findActiveByFamilyIdAndCustomerIdAndCurrentMonth( @@ -154,7 +184,6 @@ public boolean ensureCustomerUsageCached( // setIfAbsent가 false면 다시 GET해서 존재 확인 // 다른 스레드/인스턴스가 먼저 세팅했어도 그건 성공으로 판단 return stringRedisTemplate.hasKey(key); - } catch (DataAccessException e) { log.error( "Data access error during usage cache warm-up. familyId={}, customerId={}," @@ -175,4 +204,16 @@ public boolean ensureCustomerUsageCached( return false; } } + + private FamilyQuota resolveFamilyQuotaForInfo(long familyId, LocalDate eventMonth) { + // info hash는 현재 월 스냅샷을 우선하고 없으면 최신 스냅샷으로 대체함 + return familyQuotaRepository + .findActiveByFamilyIdAndCurrentMonth(familyId, eventMonth) + .or( + () -> + familyQuotaRepository + .findTopByFamilyIdAndDeletedAtIsNullOrderByCurrentMonthDesc( + familyId)) + .orElse(null); + } } diff --git a/src/main/java/com/project/global/config/RedisConfig.java b/src/main/java/com/project/global/config/RedisConfig.java index 77ab225..8f96bfb 100644 --- a/src/main/java/com/project/global/config/RedisConfig.java +++ b/src/main/java/com/project/global/config/RedisConfig.java @@ -9,41 +9,11 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.RedisScript; -import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.project.domain.family.infra.cache.dto.FamilyCacheDto; - @Configuration public class RedisConfig { - @Bean - public RedisTemplate familyCacheRedisTemplate( - RedisConnectionFactory connectionFactory) { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(connectionFactory); - - StringRedisSerializer keySerializer = new StringRedisSerializer(); - - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - - Jackson2JsonRedisSerializer valueSerializer = - new Jackson2JsonRedisSerializer(objectMapper, FamilyCacheDto.class); - - template.setKeySerializer(keySerializer); - template.setHashKeySerializer(keySerializer); - template.setValueSerializer(valueSerializer); - template.setHashValueSerializer(valueSerializer); - - template.afterPropertiesSet(); - return template; - } - @Bean public RedisTemplate familyStringRedisTemplate( RedisConnectionFactory connectionFactory) { diff --git a/src/main/java/com/project/global/util/RedisKeyGenerator.java b/src/main/java/com/project/global/util/RedisKeyGenerator.java index ba914c1..5fa7651 100644 --- a/src/main/java/com/project/global/util/RedisKeyGenerator.java +++ b/src/main/java/com/project/global/util/RedisKeyGenerator.java @@ -16,24 +16,40 @@ public class RedisKeyGenerator { DateTimeFormatter.ofPattern("yyyyMM"); // 가족 알림 상태 키 - public String generateFamilyAlertsKey(Long familyId) { + public String generateFamilyAlertKey(Long familyId, int threshold, LocalDate eventMonth) { return FAMILY_KEY_PREFIX + KEY_SEPARATOR + familyId + KEY_SEPARATOR + "alert" + KEY_SEPARATOR - + "THRESHOLD"; + + "THRESHOLD" + + KEY_SEPARATOR + + threshold + + KEY_SEPARATOR + + formatMonth(eventMonth); } // 가족 quota 정보 키 - public String generateFamilyInfoKey(Long familyId) { - return FAMILY_KEY_PREFIX + KEY_SEPARATOR + familyId + KEY_SEPARATOR + "info"; + public String generateFamilyInfoKey(Long familyId, LocalDate eventMonth) { + return FAMILY_KEY_PREFIX + + KEY_SEPARATOR + + familyId + + KEY_SEPARATOR + + "info" + + KEY_SEPARATOR + + formatMonth(eventMonth); } // 가족 잔여 데이터 키 - public String generateFamilyRemainingKey(Long familyId) { - return FAMILY_KEY_PREFIX + KEY_SEPARATOR + familyId + KEY_SEPARATOR + "remaining"; + public String generateFamilyRemainingKey(Long familyId, LocalDate eventMonth) { + return FAMILY_KEY_PREFIX + + KEY_SEPARATOR + + familyId + + KEY_SEPARATOR + + "remaining" + + KEY_SEPARATOR + + formatMonth(eventMonth); } // 고객 월별 사용량 키 @@ -51,7 +67,7 @@ public String generateFamilyCustomerMonthlyUsageKey( + KEY_SEPARATOR + "monthly" + KEY_SEPARATOR - + eventMonth.format(MONTH_SUFFIX_FORMATTER); + + formatMonth(eventMonth); } // 고객 정책 제약 키 @@ -76,4 +92,8 @@ public String generatePolicyEventDedupKey(String eventId, Long customerId) { public String generateUsageEventDedupKey(String eventId) { return USAGE_EVENT_DEDUP_KEY_PREFIX + KEY_SEPARATOR + eventId; } + + private String formatMonth(LocalDate eventMonth) { + return eventMonth.format(MONTH_SUFFIX_FORMATTER); + } } diff --git a/src/main/resources/lua/usage_update.lua b/src/main/resources/lua/usage_update.lua index 3082138..f7d7364 100644 --- a/src/main/resources/lua/usage_update.lua +++ b/src/main/resources/lua/usage_update.lua @@ -1,9 +1,11 @@ --- KEYS[1]: family:{fid}:info --- KEYS[2]: family:{fid}:remaining +-- KEYS[1]: family:{fid}:info:{yyyyMM} +-- KEYS[2]: family:{fid}:remaining:{yyyyMM} -- KEYS[3]: family:{fid}:customer:{uid}:usage:monthly:{yyyyMM} -- KEYS[4]: family:{fid}:customer:{uid}:constraints --- KEYS[5]: family:{fid}:alert:THRESHOLD (prefix) --- KEYS[6]: event:dedup:usage:{eventId} +-- KEYS[5]: family:{fid}:alert:THRESHOLD:50:{yyyyMM} +-- KEYS[6]: family:{fid}:alert:THRESHOLD:30:{yyyyMM} +-- KEYS[7]: family:{fid}:alert:THRESHOLD:10:{yyyyMM} +-- KEYS[8]: event:dedup:usage:{eventId} -- ARGV[1]: usageBytes -- ARGV[2]: currentHHmm (e.g. 2230) -- ARGV[3]: normalizedAppId @@ -38,7 +40,8 @@ end -- 1) 동일 eventId 재처리 방지 if dedupTtlSeconds > 0 then - local firstSeen = redis.call('SET', KEYS[6], '1', 'NX', 'EX', dedupTtlSeconds) + -- 같은 eventId는 월별 상태 반영 전에 바로 차단함 + local firstSeen = redis.call('SET', KEYS[8], '1', 'NX', 'EX', dedupTtlSeconds) if not firstSeen then return {0, 0, 'DUPLICATE', 0, 0, -1, 1} end @@ -103,6 +106,7 @@ end local currentRemaining = tonumber(redis.call('GET', KEYS[2])) if currentRemaining == nil then + -- 월초 첫 이벤트면 remaining이 아직 없을 수 있어서 totalQuota로 시작함 local totalLimit = tonumber(redis.call('HGET', KEYS[1], 'totalQuota') or '0') currentRemaining = totalLimit end @@ -125,23 +129,21 @@ if totalLimit > 0 then ratio = newRemaining / totalLimit end -local alertLevel = nil +local alertKey = nil if ratio < 0.1 then - alertLevel = '10' + alertKey = KEYS[7] status = 'WARNING_10' elseif ratio < 0.3 then - alertLevel = '30' + alertKey = KEYS[6] status = 'WARNING_30' elseif ratio < 0.5 then - alertLevel = '50' + alertKey = KEYS[5] status = 'WARNING_50' end --- 같은 임계치 알림은 한 번만 발행 -if alertLevel then - local alertKey = KEYS[5] .. ':' .. alertLevel +if alertKey then + -- 같은 월 같은 임계치는 suffix key 존재 여부로 한 번만 발행함 local isSent = redis.call('EXISTS', alertKey) - if isSent == 1 then status = 'NORMAL' else diff --git a/src/test/java/com/project/domain/usage/service/helper/FamilyUsageWriterTest.java b/src/test/java/com/project/domain/usage/service/helper/FamilyUsageWriterTest.java deleted file mode 100644 index 8347822..0000000 --- a/src/test/java/com/project/domain/usage/service/helper/FamilyUsageWriterTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.project.domain.usage.service.helper; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.nullable; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import java.time.LocalDate; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.project.domain.family.repository.FamilyRepository; -import com.project.global.util.LogSanitizer; - -@ExtendWith(MockitoExtension.class) -class FamilyUsageWriterTest { - - @InjectMocks private FamilyUsageWriter familyUsageWriter; - - @Mock private FamilyRepository familyRepository; - @Mock private LogSanitizer logSanitizer; - - @BeforeEach - void setUp() { - lenient() - .when(logSanitizer.sanitize(nullable(String.class))) - .thenAnswer( - invocation -> { - String raw = invocation.getArgument(0); - return raw == null ? "null" : raw; - }); - } - - @Test - @DisplayName("family 월경계 업데이트가 성공하면 예외 없이 종료한다") - void updateFamilyUsedBytes_Success() { - LocalDate eventMonth = LocalDate.of(2026, 3, 1); - given(familyRepository.updateUsedBytesByEventMonth(100L, eventMonth, 1024L)).willReturn(1); - - familyUsageWriter.updateFamilyUsedBytes(100L, eventMonth, 1024L, "evt_1", "origin_1"); - - verify(familyRepository, times(1)).updateUsedBytesByEventMonth(100L, eventMonth, 1024L); - } - - @Test - @DisplayName("family row 업데이트에 실패하면 예외를 던진다") - void updateFamilyUsedBytes_Fail_ThrowsException() { - LocalDate eventMonth = LocalDate.of(2026, 3, 1); - given(familyRepository.updateUsedBytesByEventMonth(100L, eventMonth, 1024L)).willReturn(0); - - assertThrows( - IllegalStateException.class, - () -> - familyUsageWriter.updateFamilyUsedBytes( - 100L, eventMonth, 1024L, "evt_1", "origin_1")); - } -} From 2572d6532a9bc9ded6034505013fe7f2895b4849 Mon Sep 17 00:00:00 2001 From: k0081915 Date: Sun, 15 Mar 2026 05:02:00 +0900 Subject: [PATCH 3/7] =?UTF-8?q?DABOM-459=20test:=20family=5Fquota=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=EA=B3=BC=20=EC=9B=94=20suffix=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usage/service/UsageSyncServiceImpl.java | 18 +- .../service/UsagePersistServiceImplTest.java | 14 +- .../service/UsageSyncServiceImplTest.java | 49 ++++-- .../service/helper/FamilyQuotaWriterTest.java | 128 ++++++++++++++ .../service/helper/UsageLuaExecutorTest.java | 29 +++- .../helper/UsageRedisWarmupHelperTest.java | 158 ++++++++++++++++++ 6 files changed, 359 insertions(+), 37 deletions(-) create mode 100644 src/test/java/com/project/domain/usage/service/helper/FamilyQuotaWriterTest.java create mode 100644 src/test/java/com/project/domain/usage/service/helper/UsageRedisWarmupHelperTest.java diff --git a/src/main/java/com/project/domain/usage/service/UsageSyncServiceImpl.java b/src/main/java/com/project/domain/usage/service/UsageSyncServiceImpl.java index 8391ea9..eab3385 100644 --- a/src/main/java/com/project/domain/usage/service/UsageSyncServiceImpl.java +++ b/src/main/java/com/project/domain/usage/service/UsageSyncServiceImpl.java @@ -42,7 +42,6 @@ public class UsageSyncServiceImpl implements UsageSyncService { private final LogSanitizer logSanitizer; private final KafkaMetrics kafkaMetrics; - // usage-event dedup TTL @Value("${app.kafka.dedup.usage-ttl-seconds}") private long dedupTtlSeconds; @@ -58,21 +57,24 @@ public void syncUsage(String eventId, String eventTime, UsagePayload payload) { LocalDate eventMonth = resolvedEventDateTime.toLocalDate().withDayOfMonth(1); // 2) Lua 실행에 필요한 Redis 키 생성 - String infoKey = redisKeyGenerator.generateFamilyInfoKey(familyId); - String remainingKey = redisKeyGenerator.generateFamilyRemainingKey(familyId); + String infoKey = redisKeyGenerator.generateFamilyInfoKey(familyId, eventMonth); + String remainingKey = redisKeyGenerator.generateFamilyRemainingKey(familyId, eventMonth); String monthlyKey = redisKeyGenerator.generateFamilyCustomerMonthlyUsageKey( familyId, customerId, eventMonth); String constraintsKey = redisKeyGenerator.generateFamilyCustomerConstraintsKey(familyId, customerId); - String alertsKey = redisKeyGenerator.generateFamilyAlertsKey(familyId); + String alert50Key = redisKeyGenerator.generateFamilyAlertKey(familyId, 50, eventMonth); + String alert30Key = redisKeyGenerator.generateFamilyAlertKey(familyId, 30, eventMonth); + String alert10Key = redisKeyGenerator.generateFamilyAlertKey(familyId, 10, eventMonth); String dedupKey = redisKeyGenerator.generateUsageEventDedupKey(eventId); // 3) Redis warmup 보장 boolean familyInfoRedisWarmup = - usageRedisWarmupHelper.ensureFamilyInfoCached(familyId, infoKey); + usageRedisWarmupHelper.ensureFamilyInfoCached(familyId, eventMonth, infoKey); boolean familyRemainingRedisWarmup = - usageRedisWarmupHelper.ensureRemainingBytesCached(familyId, remainingKey); + usageRedisWarmupHelper.ensureRemainingBytesCached( + familyId, eventMonth, remainingKey); boolean customerMonthlyUsageRedisWarmup = usageRedisWarmupHelper.ensureCustomerUsageCached( familyId, customerId, monthlyKey, eventMonth); @@ -96,7 +98,9 @@ public void syncUsage(String eventId, String eventTime, UsagePayload payload) { remainingKey, monthlyKey, constraintsKey, - alertsKey, + alert50Key, + alert30Key, + alert10Key, dedupKey, usageBytes, currentHhmm, diff --git a/src/test/java/com/project/domain/usage/service/UsagePersistServiceImplTest.java b/src/test/java/com/project/domain/usage/service/UsagePersistServiceImplTest.java index b072b1c..4f54000 100644 --- a/src/test/java/com/project/domain/usage/service/UsagePersistServiceImplTest.java +++ b/src/test/java/com/project/domain/usage/service/UsagePersistServiceImplTest.java @@ -24,7 +24,7 @@ import com.dabom.messaging.kafka.event.dto.usage.UsagePersistPayload; import com.project.domain.family.repository.FamilyMemberRepository; import com.project.domain.usage.service.helper.CustomerQuotaWriter; -import com.project.domain.usage.service.helper.FamilyUsageWriter; +import com.project.domain.usage.service.helper.FamilyQuotaWriter; import com.project.domain.usage.service.helper.UsagePersistEventValidator; import com.project.domain.usage.service.helper.UsageRecordWriter; import com.project.global.util.LogSanitizer; @@ -38,7 +38,7 @@ class UsagePersistServiceImplTest { @Mock private UsagePersistEventValidator usagePersistEventValidator; @Mock private UsageRecordWriter usageRecordWriter; @Mock private CustomerQuotaWriter customerQuotaWriter; - @Mock private FamilyUsageWriter familyUsageWriter; + @Mock private FamilyQuotaWriter familyQuotaWriter; @Mock private LogSanitizer logSanitizer; @BeforeEach @@ -53,7 +53,7 @@ void setUp() { } @Test - @DisplayName("허용 이벤트면 eventMonth 기준으로 quota와 family를 함께 갱신한다") + @DisplayName("허용 이벤트면 eventMonth 기준으로 quota와 family_quota를 함께 갱신한다") void persist_AllowedEvent_UpdatesQuotaAndFamilyByEventMonth() { UsagePersistPayload payload = new UsagePersistPayload( @@ -73,13 +73,13 @@ void persist_AllowedEvent_UpdatesQuotaAndFamilyByEventMonth() { verify(customerQuotaWriter, times(1)) .persistAllowedQuota(payload, eventMonth, "evt_1", "origin_1"); - verify(familyUsageWriter, times(1)) - .updateFamilyUsedBytes(100L, eventMonth, 2048L, "evt_1", "origin_1"); + verify(familyQuotaWriter, times(1)) + .persistAllowedQuota(100L, eventMonth, 2048L, "evt_1", "origin_1"); verify(customerQuotaWriter, never()).persistBlockedQuota(any(), any(), any(), any(), any()); } @Test - @DisplayName("차단 이벤트면 usage_record와 family 누적 없이 차단 상태만 반영한다") + @DisplayName("차단 이벤트면 usage_record와 family_quota 누적 없이 차단 상태만 반영한다") void persist_BlockedEvent_OnlyPersistsBlockState() { UsagePersistPayload payload = new UsagePersistPayload( @@ -100,6 +100,6 @@ void persist_BlockedEvent_OnlyPersistsBlockState() { .persistBlockedQuota(payload, eventMonth, "evt_2", "origin_2", "TIME_BLOCK"); verify(usageRecordWriter, never()).persistUsageRecord(any(), any(), any()); verify(customerQuotaWriter, never()).persistAllowedQuota(any(), any(), any(), any()); - verify(familyUsageWriter, never()).updateFamilyUsedBytes(any(), any(), any(), any(), any()); + verify(familyQuotaWriter, never()).persistAllowedQuota(any(), any(), any(), any(), any()); } } diff --git a/src/test/java/com/project/domain/usage/service/UsageSyncServiceImplTest.java b/src/test/java/com/project/domain/usage/service/UsageSyncServiceImplTest.java index a16c6e3..22d14d3 100644 --- a/src/test/java/com/project/domain/usage/service/UsageSyncServiceImplTest.java +++ b/src/test/java/com/project/domain/usage/service/UsageSyncServiceImplTest.java @@ -83,11 +83,13 @@ void syncUsage_SuccessFlow() { verify(usageLuaExecutor, times(1)).execute(commandCaptor.capture(), eq(eventId)); UsageLuaExecutor.UsageLuaCommand command = commandCaptor.getValue(); - assertEquals("family:100:info", command.infoKey()); - assertEquals("family:100:remaining", command.remainingKey()); + assertEquals("family:100:info:202603", command.infoKey()); + assertEquals("family:100:remaining:202603", command.remainingKey()); assertEquals("monthlyKey", command.monthlyKey()); assertEquals("constraintsKey", command.constraintsKey()); - assertEquals("alertsKey", command.alertsKey()); + assertEquals("family:100:alert:THRESHOLD:50:202603", command.alert50Key()); + assertEquals("family:100:alert:THRESHOLD:30:202603", command.alert30Key()); + assertEquals("family:100:alert:THRESHOLD:10:202603", command.alert10Key()); assertEquals("event:dedup:usage:evt_1", command.dedupKey()); assertEquals(1024L, command.usageBytes()); assertEquals("1234", command.currentHhmm()); @@ -130,20 +132,30 @@ void syncUsage_WarmupFailed() { LocalDate eventMonth = LocalDate.of(2026, 3, 1); UsagePayload payload = new UsagePayload(100L, 1L, "appId", 1024L, Map.of()); - given(redisKeyGenerator.generateFamilyInfoKey(100L)).willReturn("family:100:info"); - given(redisKeyGenerator.generateFamilyRemainingKey(100L)) - .willReturn("family:100:remaining"); + given(redisKeyGenerator.generateFamilyInfoKey(100L, eventMonth)) + .willReturn("family:100:info:202603"); + given(redisKeyGenerator.generateFamilyRemainingKey(100L, eventMonth)) + .willReturn("family:100:remaining:202603"); given(redisKeyGenerator.generateFamilyCustomerMonthlyUsageKey(100L, 1L, eventMonth)) .willReturn("monthlyKey"); given(redisKeyGenerator.generateFamilyCustomerConstraintsKey(100L, 1L)) .willReturn("constraintsKey"); - given(redisKeyGenerator.generateFamilyAlertsKey(100L)).willReturn("alertsKey"); + given(redisKeyGenerator.generateFamilyAlertKey(100L, 50, eventMonth)) + .willReturn("family:100:alert:THRESHOLD:50:202603"); + given(redisKeyGenerator.generateFamilyAlertKey(100L, 30, eventMonth)) + .willReturn("family:100:alert:THRESHOLD:30:202603"); + given(redisKeyGenerator.generateFamilyAlertKey(100L, 10, eventMonth)) + .willReturn("family:100:alert:THRESHOLD:10:202603"); given(redisKeyGenerator.generateUsageEventDedupKey(eventId)) .willReturn("event:dedup:usage:" + eventId); - given(usageRedisWarmupHelper.ensureFamilyInfoCached(100L, "family:100:info")) + given( + usageRedisWarmupHelper.ensureFamilyInfoCached( + 100L, eventMonth, "family:100:info:202603")) .willReturn(false); - given(usageRedisWarmupHelper.ensureRemainingBytesCached(100L, "family:100:remaining")) + given( + usageRedisWarmupHelper.ensureRemainingBytesCached( + 100L, eventMonth, "family:100:remaining:202603")) .willReturn(true); given(usageRedisWarmupHelper.ensureCustomerUsageCached(100L, 1L, "monthlyKey", eventMonth)) .willReturn(true); @@ -178,26 +190,31 @@ void syncUsage_DuplicateSkipsPublish() { } private void stubCommon(long familyId, long customerId, LocalDate eventMonth, String eventId) { - given(redisKeyGenerator.generateFamilyInfoKey(familyId)) - .willReturn("family:" + familyId + ":info"); - given(redisKeyGenerator.generateFamilyRemainingKey(familyId)) - .willReturn("family:" + familyId + ":remaining"); + given(redisKeyGenerator.generateFamilyInfoKey(familyId, eventMonth)) + .willReturn("family:" + familyId + ":info:202603"); + given(redisKeyGenerator.generateFamilyRemainingKey(familyId, eventMonth)) + .willReturn("family:" + familyId + ":remaining:202603"); given( redisKeyGenerator.generateFamilyCustomerMonthlyUsageKey( familyId, customerId, eventMonth)) .willReturn("monthlyKey"); given(redisKeyGenerator.generateFamilyCustomerConstraintsKey(familyId, customerId)) .willReturn("constraintsKey"); - given(redisKeyGenerator.generateFamilyAlertsKey(familyId)).willReturn("alertsKey"); + given(redisKeyGenerator.generateFamilyAlertKey(familyId, 50, eventMonth)) + .willReturn("family:" + familyId + ":alert:THRESHOLD:50:202603"); + given(redisKeyGenerator.generateFamilyAlertKey(familyId, 30, eventMonth)) + .willReturn("family:" + familyId + ":alert:THRESHOLD:30:202603"); + given(redisKeyGenerator.generateFamilyAlertKey(familyId, 10, eventMonth)) + .willReturn("family:" + familyId + ":alert:THRESHOLD:10:202603"); given(redisKeyGenerator.generateUsageEventDedupKey(eventId)) .willReturn("event:dedup:usage:" + eventId); given( usageRedisWarmupHelper.ensureFamilyInfoCached( - familyId, "family:" + familyId + ":info")) + familyId, eventMonth, "family:" + familyId + ":info:202603")) .willReturn(true); given( usageRedisWarmupHelper.ensureRemainingBytesCached( - familyId, "family:" + familyId + ":remaining")) + familyId, eventMonth, "family:" + familyId + ":remaining:202603")) .willReturn(true); given( usageRedisWarmupHelper.ensureCustomerUsageCached( diff --git a/src/test/java/com/project/domain/usage/service/helper/FamilyQuotaWriterTest.java b/src/test/java/com/project/domain/usage/service/helper/FamilyQuotaWriterTest.java new file mode 100644 index 0000000..9602e11 --- /dev/null +++ b/src/test/java/com/project/domain/usage/service/helper/FamilyQuotaWriterTest.java @@ -0,0 +1,128 @@ +package com.project.domain.usage.service.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.LocalDate; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import com.project.domain.family.entity.FamilyQuota; +import com.project.domain.family.repository.FamilyQuotaRepository; +import com.project.global.util.LogSanitizer; + +@ExtendWith(MockitoExtension.class) +class FamilyQuotaWriterTest { + + @InjectMocks private FamilyQuotaWriter familyQuotaWriter; + + @Mock private FamilyQuotaRepository familyQuotaRepository; + @Mock private LogSanitizer logSanitizer; + + @BeforeEach + void setUp() { + lenient() + .when(logSanitizer.sanitize(nullable(String.class))) + .thenAnswer( + invocation -> { + String raw = invocation.getArgument(0); + return raw == null ? "null" : raw; + }); + } + + @Test + @DisplayName("현재 월 row가 있으면 usedBytes를 누적 갱신한다") + void persistAllowedQuota_UpdateExistingRow() { + LocalDate eventMonth = LocalDate.of(2026, 3, 1); + given(familyQuotaRepository.incrementUsedBytes(100L, eventMonth, 1024L)).willReturn(1); + + familyQuotaWriter.persistAllowedQuota(100L, eventMonth, 1024L, "evt_1", "origin_1"); + + verify(familyQuotaRepository, times(1)).incrementUsedBytes(100L, eventMonth, 1024L); + verify(familyQuotaRepository, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("현재 월 row가 없으면 최신 스냅샷 totalQuota를 이어받아 새 row를 만든다") + void persistAllowedQuota_CreateCurrentMonthRow() { + LocalDate eventMonth = LocalDate.of(2026, 3, 1); + FamilyQuota latest = + FamilyQuota.builder() + .id(1L) + .familyId(100L) + .currentMonth(LocalDate.of(2026, 2, 1)) + .totalQuotaBytes(10000L) + .usedBytes(7000L) + .build(); + + given(familyQuotaRepository.incrementUsedBytes(100L, eventMonth, 1024L)).willReturn(0); + given(familyQuotaRepository.findLatestByFamilyIdForUpdate(100L)) + .willReturn(Optional.of(latest)); + + familyQuotaWriter.persistAllowedQuota(100L, eventMonth, 1024L, "evt_1", "origin_1"); + + ArgumentCaptor quotaCaptor = ArgumentCaptor.forClass(FamilyQuota.class); + verify(familyQuotaRepository).saveAndFlush(quotaCaptor.capture()); + assertEquals(100L, quotaCaptor.getValue().getFamilyId()); + assertEquals(eventMonth, quotaCaptor.getValue().getCurrentMonth()); + assertEquals(10000L, quotaCaptor.getValue().getTotalQuotaBytes()); + assertEquals(1024L, quotaCaptor.getValue().getUsedBytes()); + } + + @Test + @DisplayName("insert race가 나면 update 재시도로 복구한다") + void persistAllowedQuota_RecoverFromInsertRace() { + LocalDate eventMonth = LocalDate.of(2026, 3, 1); + FamilyQuota latest = + FamilyQuota.builder() + .id(1L) + .familyId(100L) + .currentMonth(LocalDate.of(2026, 2, 1)) + .totalQuotaBytes(10000L) + .usedBytes(7000L) + .build(); + + given(familyQuotaRepository.incrementUsedBytes(100L, eventMonth, 1024L)) + .willReturn(0) + .willReturn(1); + given(familyQuotaRepository.findLatestByFamilyIdForUpdate(100L)) + .willReturn(Optional.of(latest)); + given(familyQuotaRepository.saveAndFlush(any(FamilyQuota.class))) + .willThrow(new DataIntegrityViolationException("race")); + + familyQuotaWriter.persistAllowedQuota(100L, eventMonth, 1024L, "evt_1", "origin_1"); + + verify(familyQuotaRepository, times(2)).incrementUsedBytes(100L, eventMonth, 1024L); + } + + @Test + @DisplayName("최신 스냅샷이 없으면 예외를 던진다") + void persistAllowedQuota_ThrowsWhenLatestSnapshotMissing() { + LocalDate eventMonth = LocalDate.of(2026, 3, 1); + given(familyQuotaRepository.incrementUsedBytes(100L, eventMonth, 1024L)).willReturn(0); + given(familyQuotaRepository.findLatestByFamilyIdForUpdate(100L)) + .willReturn(Optional.empty()); + + assertThrows( + IllegalStateException.class, + () -> + familyQuotaWriter.persistAllowedQuota( + 100L, eventMonth, 1024L, "evt_1", "origin_1")); + } +} diff --git a/src/test/java/com/project/domain/usage/service/helper/UsageLuaExecutorTest.java b/src/test/java/com/project/domain/usage/service/helper/UsageLuaExecutorTest.java index 772de21..cb2d346 100644 --- a/src/test/java/com/project/domain/usage/service/helper/UsageLuaExecutorTest.java +++ b/src/test/java/com/project/domain/usage/service/helper/UsageLuaExecutorTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -50,11 +51,13 @@ void setUp() { void execute_ParseSuccess() { UsageLuaExecutor.UsageLuaCommand command = new UsageLuaExecutor.UsageLuaCommand( - "family:100:info", - "family:100:remaining", + "family:100:info:202603", + "family:100:remaining:202603", "monthlyKey", "constraintsKey", - "alertsKey", + "family:100:alert:THRESHOLD:50:202603", + "family:100:alert:THRESHOLD:30:202603", + "family:100:alert:THRESHOLD:10:202603", "event:dedup:usage:evt_1", 1024L, "2230", @@ -73,15 +76,27 @@ void execute_ParseSuccess() { UsageUpdateResult result = usageLuaExecutor.execute(command, "evt_1"); + ArgumentCaptor keysCaptor = ArgumentCaptor.forClass(List.class); verify(redisTemplate) .execute( eq(usageUpdateScript), - anyList(), + keysCaptor.capture(), eq("1024"), eq("2230"), eq("com.youtube.app"), eq("60")); + assertEquals( + List.of( + "family:100:info:202603", + "family:100:remaining:202603", + "monthlyKey", + "constraintsKey", + "family:100:alert:THRESHOLD:50:202603", + "family:100:alert:THRESHOLD:30:202603", + "family:100:alert:THRESHOLD:10:202603", + "event:dedup:usage:evt_1"), + keysCaptor.getValue()); assertEquals(5000L, result.totalUsed()); assertEquals(5000L, result.remaining()); assertEquals("NORMAL", result.status()); @@ -96,7 +111,7 @@ void execute_ParseSuccess() { void execute_ParseDuplicateFlag() { UsageLuaExecutor.UsageLuaCommand command = new UsageLuaExecutor.UsageLuaCommand( - "a", "b", "c", "d", "e", "dup", 1L, "0000", "", 60L); + "a", "b", "c", "d", "e", "f", "g", "dup", 1L, "0000", "", 60L); given( redisTemplate.execute( eq(usageUpdateScript), @@ -118,7 +133,7 @@ void execute_ParseDuplicateFlag() { void execute_NullResult() { UsageLuaExecutor.UsageLuaCommand command = new UsageLuaExecutor.UsageLuaCommand( - "a", "b", "c", "d", "e", "dup", 1L, "0000", "", 60L); + "a", "b", "c", "d", "e", "f", "g", "dup", 1L, "0000", "", 60L); given( redisTemplate.execute( eq(usageUpdateScript), @@ -137,7 +152,7 @@ void execute_NullResult() { void execute_InvalidResultSize() { UsageLuaExecutor.UsageLuaCommand command = new UsageLuaExecutor.UsageLuaCommand( - "a", "b", "c", "d", "e", "dup", 1L, "0000", "", 60L); + "a", "b", "c", "d", "e", "f", "g", "dup", 1L, "0000", "", 60L); given( redisTemplate.execute( eq(usageUpdateScript), diff --git a/src/test/java/com/project/domain/usage/service/helper/UsageRedisWarmupHelperTest.java b/src/test/java/com/project/domain/usage/service/helper/UsageRedisWarmupHelperTest.java new file mode 100644 index 0000000..e932ebb --- /dev/null +++ b/src/test/java/com/project/domain/usage/service/helper/UsageRedisWarmupHelperTest.java @@ -0,0 +1,158 @@ +package com.project.domain.usage.service.helper; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import com.project.domain.customer.repository.CustomerQuotaRepository; +import com.project.domain.family.entity.Family; +import com.project.domain.family.entity.FamilyQuota; +import com.project.domain.family.repository.FamilyQuotaRepository; +import com.project.domain.family.repository.FamilyRepository; + +@ExtendWith(MockitoExtension.class) +class UsageRedisWarmupHelperTest { + + @InjectMocks private UsageRedisWarmupHelper usageRedisWarmupHelper; + + @Mock private StringRedisTemplate stringRedisTemplate; + @Mock private FamilyRepository familyRepository; + @Mock private FamilyQuotaRepository familyQuotaRepository; + @Mock private CustomerQuotaRepository customerQuotaRepository; + @Mock private HashOperations hashOperations; + @Mock private ValueOperations valueOperations; + + @BeforeEach + void setUp() { + lenient().when(stringRedisTemplate.opsForHash()).thenReturn(hashOperations); + lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + } + + @Test + @DisplayName("현재 월 family_quota가 있으면 해당 totalQuota로 info hash를 채운다") + void ensureFamilyInfoCached_UsesCurrentMonthQuota() { + long familyId = 100L; + LocalDate eventMonth = LocalDate.of(2026, 3, 1); + String key = "family:100:info:202603"; + Family family = Family.builder().id(familyId).name("우리집").createdById(1L).build(); + ReflectionTestUtils.setField( + family, "createdAt", LocalDateTime.parse("2026-01-01T10:00:00")); + FamilyQuota familyQuota = + FamilyQuota.builder() + .familyId(familyId) + .currentMonth(eventMonth) + .totalQuotaBytes(10000L) + .usedBytes(3000L) + .build(); + + given(hashOperations.entries(key)).willReturn(Map.of()); + given(familyRepository.findById(familyId)).willReturn(Optional.of(family)); + given(familyQuotaRepository.findActiveByFamilyIdAndCurrentMonth(familyId, eventMonth)) + .willReturn(Optional.of(familyQuota)); + + boolean result = usageRedisWarmupHelper.ensureFamilyInfoCached(familyId, eventMonth, key); + + assertTrue(result); + verify(hashOperations).putAll(eq(key), anyMap()); + } + + @Test + @DisplayName("현재 월 family_quota가 있으면 remaining은 total-used로 시드한다") + void ensureRemainingBytesCached_UsesCurrentMonthQuota() { + long familyId = 100L; + LocalDate eventMonth = LocalDate.of(2026, 3, 1); + String key = "family:100:remaining:202603"; + FamilyQuota familyQuota = + FamilyQuota.builder() + .familyId(familyId) + .currentMonth(eventMonth) + .totalQuotaBytes(10000L) + .usedBytes(3000L) + .build(); + + given(valueOperations.get(key)).willReturn(null); + given(familyQuotaRepository.findActiveByFamilyIdAndCurrentMonth(familyId, eventMonth)) + .willReturn(Optional.of(familyQuota)); + given(valueOperations.setIfAbsent(key, "7000")).willReturn(true); + + boolean result = + usageRedisWarmupHelper.ensureRemainingBytesCached(familyId, eventMonth, key); + + assertTrue(result); + verify(valueOperations).setIfAbsent(key, "7000"); + } + + @Test + @DisplayName("현재 월 row가 없고 최신 스냅샷만 있으면 remaining은 totalQuota 전체로 시드한다") + void ensureRemainingBytesCached_SeedsFullQuotaWhenCurrentMonthMissing() { + long familyId = 100L; + LocalDate eventMonth = LocalDate.of(2026, 3, 1); + String key = "family:100:remaining:202603"; + FamilyQuota latestSnapshot = + FamilyQuota.builder() + .familyId(familyId) + .currentMonth(LocalDate.of(2026, 2, 1)) + .totalQuotaBytes(9000L) + .usedBytes(8500L) + .build(); + + given(valueOperations.get(key)).willReturn(null); + given(familyQuotaRepository.findActiveByFamilyIdAndCurrentMonth(familyId, eventMonth)) + .willReturn(Optional.empty()); + given( + familyQuotaRepository + .findTopByFamilyIdAndDeletedAtIsNullOrderByCurrentMonthDesc( + familyId)) + .willReturn(Optional.of(latestSnapshot)); + given(valueOperations.setIfAbsent(key, "9000")).willReturn(true); + + boolean result = + usageRedisWarmupHelper.ensureRemainingBytesCached(familyId, eventMonth, key); + + assertTrue(result); + verify(valueOperations).setIfAbsent(key, "9000"); + } + + @Test + @DisplayName("최신 스냅샷도 없으면 remaining warmup은 실패한다") + void ensureRemainingBytesCached_FailsWhenNoSnapshot() { + long familyId = 100L; + LocalDate eventMonth = LocalDate.of(2026, 3, 1); + String key = "family:100:remaining:202603"; + + given(valueOperations.get(key)).willReturn(null); + given(familyQuotaRepository.findActiveByFamilyIdAndCurrentMonth(familyId, eventMonth)) + .willReturn(Optional.empty()); + given( + familyQuotaRepository + .findTopByFamilyIdAndDeletedAtIsNullOrderByCurrentMonthDesc( + familyId)) + .willReturn(Optional.empty()); + + boolean result = + usageRedisWarmupHelper.ensureRemainingBytesCached(familyId, eventMonth, key); + + assertFalse(result); + } +} From 6abf3d5bea44b06f1906bda5746b49c7e7d4001f Mon Sep 17 00:00:00 2001 From: k0081915 Date: Sun, 15 Mar 2026 05:29:01 +0900 Subject: [PATCH 4/7] =?UTF-8?q?DABOM-459=20refactor:=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/helper/FamilyQuotaWriter.java | 6 ++- .../exception/code/FamilyErrorCode.java | 6 ++- .../service/helper/FamilyQuotaWriterTest.java | 46 ++++++++++++++++--- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/project/domain/usage/service/helper/FamilyQuotaWriter.java b/src/main/java/com/project/domain/usage/service/helper/FamilyQuotaWriter.java index 82e9633..0e07e8d 100644 --- a/src/main/java/com/project/domain/usage/service/helper/FamilyQuotaWriter.java +++ b/src/main/java/com/project/domain/usage/service/helper/FamilyQuotaWriter.java @@ -7,6 +7,8 @@ import com.project.domain.family.entity.FamilyQuota; import com.project.domain.family.repository.FamilyQuotaRepository; +import com.project.global.exception.ApplicationException; +import com.project.global.exception.code.FamilyErrorCode; import com.project.global.util.LogSanitizer; import lombok.RequiredArgsConstructor; @@ -76,7 +78,7 @@ private void createQuotaRow( familyId, bytesUsed, currentMonth); - throw new IllegalStateException("Latest family_quota snapshot not found"); + throw new ApplicationException(FamilyErrorCode.LATEST_QUOTA_SNAPSHOT_NOT_FOUND); } // 다른 트랜잭션이 현재 월 row를 먼저 만들었으면 update 재시도로 수렴함 @@ -84,7 +86,7 @@ private void createQuotaRow( if (tryUpdateExistingQuota(familyId, currentMonth, bytesUsed, eventId, originEventId)) { return; } - throw new IllegalStateException("Failed to update current family_quota row"); + throw new ApplicationException(FamilyErrorCode.FAMILY_QUOTA_UPDATE_FAILED); } FamilyQuota familyQuota = diff --git a/src/main/java/com/project/global/exception/code/FamilyErrorCode.java b/src/main/java/com/project/global/exception/code/FamilyErrorCode.java index ae92f56..41369b4 100644 --- a/src/main/java/com/project/global/exception/code/FamilyErrorCode.java +++ b/src/main/java/com/project/global/exception/code/FamilyErrorCode.java @@ -9,7 +9,11 @@ @RequiredArgsConstructor public enum FamilyErrorCode implements BaseErrorCode { FAMILY_NOT_FOUND(HttpStatus.NOT_FOUND, "FAMILY_001", "가족 정보를 찾을 수 없습니다."), - FAMILY_INVALID_SEARCH_CONDITION(HttpStatus.BAD_REQUEST, "FAMILY_002", "가족 검색 조건이 올바르지 않습니다."); + FAMILY_INVALID_SEARCH_CONDITION(HttpStatus.BAD_REQUEST, "FAMILY_002", "가족 검색 조건이 올바르지 않습니다."), + LATEST_QUOTA_SNAPSHOT_NOT_FOUND( + HttpStatus.NOT_FOUND, "FAMILY_003", "가족의 최신 quota 스냅샷을 찾을 수 없습니다."), + FAMILY_QUOTA_UPDATE_FAILED( + HttpStatus.CONFLICT, "FAMILY_004", "가족 quota를 현재 월 기준으로 갱신하지 못했습니다."); private final HttpStatus httpStatus; private final String customCode; diff --git a/src/test/java/com/project/domain/usage/service/helper/FamilyQuotaWriterTest.java b/src/test/java/com/project/domain/usage/service/helper/FamilyQuotaWriterTest.java index 9602e11..393939c 100644 --- a/src/test/java/com/project/domain/usage/service/helper/FamilyQuotaWriterTest.java +++ b/src/test/java/com/project/domain/usage/service/helper/FamilyQuotaWriterTest.java @@ -25,6 +25,8 @@ import com.project.domain.family.entity.FamilyQuota; import com.project.domain.family.repository.FamilyQuotaRepository; +import com.project.global.exception.ApplicationException; +import com.project.global.exception.code.FamilyErrorCode; import com.project.global.util.LogSanitizer; @ExtendWith(MockitoExtension.class) @@ -112,17 +114,47 @@ void persistAllowedQuota_RecoverFromInsertRace() { } @Test - @DisplayName("최신 스냅샷이 없으면 예외를 던진다") - void persistAllowedQuota_ThrowsWhenLatestSnapshotMissing() { + @DisplayName("최신 스냅샷이 없으면 family error code로 예외를 던진다") + void persistAllowedQuota_ThrowsApplicationExceptionWhenLatestSnapshotMissing() { LocalDate eventMonth = LocalDate.of(2026, 3, 1); given(familyQuotaRepository.incrementUsedBytes(100L, eventMonth, 1024L)).willReturn(0); given(familyQuotaRepository.findLatestByFamilyIdForUpdate(100L)) .willReturn(Optional.empty()); - assertThrows( - IllegalStateException.class, - () -> - familyQuotaWriter.persistAllowedQuota( - 100L, eventMonth, 1024L, "evt_1", "origin_1")); + ApplicationException exception = + assertThrows( + ApplicationException.class, + () -> + familyQuotaWriter.persistAllowedQuota( + 100L, eventMonth, 1024L, "evt_1", "origin_1")); + + assertEquals(FamilyErrorCode.LATEST_QUOTA_SNAPSHOT_NOT_FOUND, exception.getCode()); + } + + @Test + @DisplayName("현재 월 row update 재시도도 실패하면 family error code로 예외를 던진다") + void persistAllowedQuota_ThrowsApplicationExceptionWhenCurrentMonthUpdateFails() { + LocalDate eventMonth = LocalDate.of(2026, 3, 1); + FamilyQuota latest = + FamilyQuota.builder() + .id(1L) + .familyId(100L) + .currentMonth(eventMonth) + .totalQuotaBytes(10000L) + .usedBytes(7000L) + .build(); + + given(familyQuotaRepository.incrementUsedBytes(100L, eventMonth, 1024L)).willReturn(0); + given(familyQuotaRepository.findLatestByFamilyIdForUpdate(100L)) + .willReturn(Optional.of(latest)); + + ApplicationException exception = + assertThrows( + ApplicationException.class, + () -> + familyQuotaWriter.persistAllowedQuota( + 100L, eventMonth, 1024L, "evt_1", "origin_1")); + + assertEquals(FamilyErrorCode.FAMILY_QUOTA_UPDATE_FAILED, exception.getCode()); } } From 25bf07241c8536e6591fc98614f19e33a39be3d1 Mon Sep 17 00:00:00 2001 From: k0081915 Date: Sun, 15 Mar 2026 05:29:51 +0900 Subject: [PATCH 5/7] =?UTF-8?q?DABOM-459=20refactor:=20=EC=88=98=EB=8F=99?= =?UTF-8?q?=20updatedAt=20=EA=B0=B1=EC=8B=A0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/family/repository/FamilyQuotaRepository.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/project/domain/family/repository/FamilyQuotaRepository.java b/src/main/java/com/project/domain/family/repository/FamilyQuotaRepository.java index bde13fc..35d9a3e 100644 --- a/src/main/java/com/project/domain/family/repository/FamilyQuotaRepository.java +++ b/src/main/java/com/project/domain/family/repository/FamilyQuotaRepository.java @@ -42,8 +42,7 @@ Optional findActiveByFamilyIdAndCurrentMonth( @Modifying @Query( "update FamilyQuota fq " - + "set fq.usedBytes = fq.usedBytes + :bytesUsed, " - + "fq.updatedAt = CURRENT_TIMESTAMP " + + "set fq.usedBytes = fq.usedBytes + :bytesUsed " + "where fq.familyId = :familyId " + "and fq.currentMonth = :currentMonth " + "and fq.deletedAt is null") From 9fabd269e7c58d9b1b984df9084fc7d125432a63 Mon Sep 17 00:00:00 2001 From: k0081915 Date: Sun, 15 Mar 2026 05:32:41 +0900 Subject: [PATCH 6/7] =?UTF-8?q?DABOM-459=20refactor:=20if-else=20=EA=B5=AC?= =?UTF-8?q?=EB=AC=B8=EC=9C=BC=EB=A1=9C=20=EA=B0=80=EB=8F=85=EC=84=B1=20?= =?UTF-8?q?=EB=86=92=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../helper/UsageRedisWarmupHelper.java | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/project/domain/usage/service/helper/UsageRedisWarmupHelper.java b/src/main/java/com/project/domain/usage/service/helper/UsageRedisWarmupHelper.java index ba81943..f61a568 100644 --- a/src/main/java/com/project/domain/usage/service/helper/UsageRedisWarmupHelper.java +++ b/src/main/java/com/project/domain/usage/service/helper/UsageRedisWarmupHelper.java @@ -88,34 +88,34 @@ public boolean ensureRemainingBytesCached(long familyId, LocalDate eventMonth, S } // 현재 월 row가 있으면 실제 잔여량을 쓰고 없으면 최신 총량으로 월초 상태를 시드함 + long remaining; FamilyQuota currentMonthQuota = familyQuotaRepository .findActiveByFamilyIdAndCurrentMonth(familyId, eventMonth) .orElse(null); - FamilyQuota latestSnapshot = - currentMonthQuota != null - ? currentMonthQuota - : familyQuotaRepository - .findTopByFamilyIdAndDeletedAtIsNullOrderByCurrentMonthDesc( - familyId) - .orElse(null); - if (latestSnapshot == null) { - log.warn( - "Family quota snapshot not found in DB during remaining warmup." - + " familyId={}, eventMonth={}", - familyId, - eventMonth); - return false; + if (currentMonthQuota != null) { + remaining = + Math.max( + 0L, + currentMonthQuota.getTotalQuotaBytes() + - currentMonthQuota.getUsedBytes()); + } else { + FamilyQuota latestSnapshot = + familyQuotaRepository + .findTopByFamilyIdAndDeletedAtIsNullOrderByCurrentMonthDesc( + familyId) + .orElse(null); + if (latestSnapshot == null) { + log.warn( + "Family quota snapshot not found in DB during remaining warmup." + + " familyId={}, eventMonth={}", + familyId, + eventMonth); + return false; + } + remaining = latestSnapshot.getTotalQuotaBytes(); } - long remaining = - currentMonthQuota != null - ? Math.max( - 0L, - currentMonthQuota.getTotalQuotaBytes() - - currentMonthQuota.getUsedBytes()) - : latestSnapshot.getTotalQuotaBytes(); - // Redis에 쓰기 Boolean written = stringRedisTemplate.opsForValue().setIfAbsent(key, String.valueOf(remaining)); From 8fef8554704a5b04334700d180eabda6e655371f Mon Sep 17 00:00:00 2001 From: k0081915 Date: Sun, 15 Mar 2026 05:42:10 +0900 Subject: [PATCH 7/7] =?UTF-8?q?DABOM-459=20fix:=20MySQL=20=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a8fea80..cfb639f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,7 +10,7 @@ spring: name: backend datasource: - url: ${DATABASE_URL:jdbc:mysql://localhost:3310/app_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8} + url: ${DATABASE_URL:jdbc:mysql://localhost:13306/app_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8} username: ${DATABASE_USER:app_user} password: ${DATABASE_PASSWORD:app_password} driver-class-name: com.mysql.cj.jdbc.Driver