diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java index ccee55c2..85679e97 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java @@ -11,6 +11,7 @@ @Component public class AtomicExamQuotaDecrementOperator implements VoidCacheAtomicOperator { + private final RedisTemplate redisTemplate; private final DefaultRedisScript decrementScript; @@ -31,9 +32,9 @@ public void execute(String key) { if (result == null) { throw new RuntimeException("Failed to execute decrement Lua script"); } - }catch (Exception e){ + } catch (Exception e) { String msg = e.getMessage(); - if(msg != null && msg.contains("Current value is already zero or negative")){ + if (msg != null && msg.contains("Current value is already zero or negative")) { throw new CustomRuntimeException(ErrorCode.EXAM_QUOTA_ZERO_OR_NEGATIVE); } throw new CustomRuntimeException(ErrorCode.LUA_SCRIPT_ERROR); @@ -47,6 +48,6 @@ public String getName() { @Override public String getActionName() { - return "decrement"; + return OperationType.DECREMENT.key(); } } diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java index d2e55bb5..ef7657b2 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java @@ -11,6 +11,7 @@ @Component public class AtomicExamQuotaIncrementOperator implements VoidCacheAtomicOperator { + private final RedisTemplate redisTemplate; private final DefaultRedisScript decrementScript; @@ -21,6 +22,7 @@ public AtomicExamQuotaIncrementOperator( this.redisTemplate = redisTemplate; this.decrementScript = decrementScript; } + @Override public String getName() { return "examQuota"; @@ -28,7 +30,7 @@ public String getName() { @Override public String getActionName() { - return "increment"; + return OperationType.INCREMENT.key(); } @Override @@ -41,12 +43,12 @@ public void execute(String key) { if (result == null) { throw new RuntimeException("Failed to execute increment Lua script"); } - }catch (Exception e){ + } catch (Exception e) { String msg = e.getMessage(); - if(msg != null && msg.contains("Current or Max Capacity is nil")){ + if (msg != null && msg.contains("Current or Max Capacity is nil")) { throw new CustomRuntimeException(ErrorCode.EXAM_QUOTA_NOT_FOUND); } - if(msg != null && msg.contains("Current value has reached the maximum capacity")){ + if (msg != null && msg.contains("Current value has reached the maximum capacity")) { throw new CustomRuntimeException(ErrorCode.EXAM_QUOTA_EXCEEDED); } throw new CustomRuntimeException(ErrorCode.LUA_SCRIPT_ERROR); diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaCacheManager.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaCacheManager.java index ad64c5c8..a0056cc3 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaCacheManager.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaCacheManager.java @@ -14,15 +14,13 @@ import life.mosu.mosuserver.infra.persistence.redis.operator.VoidCacheAtomicOperator; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @Component @Transactional @Slf4j -public class ExamQuotaCacheManager extends KeyValueCacheManager implements - CommandLineRunner { +public class ExamQuotaCacheManager extends KeyValueCacheManager { private final ExamJpaRepository examJpaRepository; @@ -41,12 +39,24 @@ public ExamQuotaCacheManager( public Optional getMaxCapacity(Long examId) { String key = ExamQuotaPrefix.MAX_CAPACITY.with(examId); - return cacheReader.read(key); + return cacheReader.read(key) + .or(() -> examJpaRepository.findByIdAndExamDateAfter(examId, LocalDate.now()) + .map(exam -> { + Long maxCapacity = exam.getCapacity().longValue(); + cacheWriter.writeOrUpdate(key, maxCapacity); + return maxCapacity; + })); } public Optional getCurrentApplications(Long examId) { String key = ExamQuotaPrefix.CURRENT_APPLICATIONS.with(examId); - return cacheReader.read(key); + return cacheReader.read(key) + .or(() -> examJpaRepository.countApplicationsByExamIdGroupedByExamId(examId) + .map(exam -> { + Long currentApplications = exam.applicationCount(); + cacheWriter.writeOrUpdate(key, currentApplications); + return currentApplications; + })); } public void setMaxCapacity(Long examId, Long maxCapacity) { @@ -70,44 +80,64 @@ public void deleteCurrentApplications(Long examId) { } public void increaseCurrentApplications(Long examId) { - VoidCacheAtomicOperator increment = (VoidCacheAtomicOperator) cacheAtomicOperatorMap.get( - "increment"); - try { - increment.execute(examId.toString()); - } catch (Exception e) { - log.error("Cache increment failed", e); - throw new CustomRuntimeException(ErrorCode.CACHE_UPDATE_FAIL); - } + executeWithFallback(examId, OperationType.INCREMENT); } public void decreaseCurrentApplications(Long examId) { - VoidCacheAtomicOperator decrement = (VoidCacheAtomicOperator) cacheAtomicOperatorMap.get( - "decrement"); + executeWithFallback(examId, OperationType.DECREMENT); + } + + public void loadMaxCapacities(Map maxCapacities) { + maxCapacities.forEach(this::setMaxCapacity); + } + + public void loadCurrentApplications(Map currentApplications) { + currentApplications.forEach(this::setCurrentApplications); + } + + private void executeWithFallback(Long examId, OperationType operationType) { + + VoidCacheAtomicOperator operator = + (VoidCacheAtomicOperator) cacheAtomicOperatorMap.get( + operationType.key()); + if (operator == null) { + log.error("Unsupported cache operation key: {}", operationType.key()); + throw new CustomRuntimeException(ErrorCode.CACHE_OPERATION_NOT_SUPPORTED); + } try { - decrement.execute(examId.toString()); + operator.execute(examId.toString()); } catch (Exception e) { - log.error("Cache decrease failed", e); - throw new CustomRuntimeException(ErrorCode.CACHE_UPDATE_FAIL); - } - } + log.warn("Cache {} failed for examId={}, fallback to DB", operationType.key(), examId, + e); - @Override - public void run(String... args) throws Exception { - initCache(); + if (!recoverAndRetry(examId, operator, operationType.key())) { + throw new CustomRuntimeException(ErrorCode.CACHE_UPDATE_FAIL); + } + } } - @Override - protected void initCache() { - - examJpaRepository.findByExamDateAfter(LocalDate.now()) - .forEach(exam -> setMaxCapacity( - exam.getId(), - exam.getCapacity().longValue() - )); - examJpaRepository.countApplicationsGroupedBySchoolName() - .forEach(projection -> setCurrentApplications( - projection.examId(), - projection.applicationCount() - )); + private boolean recoverAndRetry( + Long examId, + VoidCacheAtomicOperator operator, + String operationKey + ) { + try { + Long currentCount = examJpaRepository.countApplicationsByExamIdGroupedByExamId(examId) + .orElseThrow(() -> new CustomRuntimeException(ErrorCode.EXAM_NOT_FOUND)) + .applicationCount(); + Long maxCount = examJpaRepository.findByIdAndExamDateAfter(examId, LocalDate.now()) + .orElseThrow(() -> new CustomRuntimeException(ErrorCode.EXAM_NOT_FOUND)) + .getCapacity() + .longValue(); + + setCurrentApplications(examId, currentCount); + setMaxCapacity(examId, maxCount); + operator.execute(examId.toString()); + + return true; + } catch (Exception ex) { + log.error("Fallback {} failed for examId={}", operationKey, examId, ex); + return false; + } } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaLoadService.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaLoadService.java new file mode 100644 index 00000000..f1a2637b --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaLoadService.java @@ -0,0 +1,51 @@ +package life.mosu.mosuserver.application.exam.cache; + +import java.time.LocalDate; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import life.mosu.mosuserver.domain.exam.entity.ExamJpaEntity; +import life.mosu.mosuserver.domain.exam.entity.ExamJpaRepository; +import life.mosu.mosuserver.domain.exam.projection.SchoolExamCountProjection; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ExamQuotaLoadService { + + private final ExamJpaRepository examJpaRepository; + private final ExamQuotaCacheManager examQuotaCacheManager; + + @Async + @Transactional(readOnly = true) + public CompletableFuture loadMaxCapacities() { + var maxCapacities = examJpaRepository.findByExamDateAfter(LocalDate.now()) + .stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap( + ExamJpaEntity::getId, + e -> e.getCapacity().longValue() + )); + examQuotaCacheManager.loadMaxCapacities(maxCapacities); + return CompletableFuture.completedFuture(null); + } + + @Async + @Transactional(readOnly = true) + public CompletableFuture loadCurrentApplications() { + var currentApplications = examJpaRepository.countApplicationsGroupedByExamId() + .stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap( + SchoolExamCountProjection::examId, + SchoolExamCountProjection::applicationCount + )); + examQuotaCacheManager.loadCurrentApplications(currentApplications); + return CompletableFuture.completedFuture(null); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/OperationType.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/OperationType.java new file mode 100644 index 00000000..3c69276f --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/OperationType.java @@ -0,0 +1,9 @@ +package life.mosu.mosuserver.application.exam.cache; + +public enum OperationType { + INCREMENT, DECREMENT; + + public String key() { + return this.name().toLowerCase(); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/exam/initializer/ExamQuotaCacheInitializer.java b/src/main/java/life/mosu/mosuserver/application/exam/initializer/ExamQuotaCacheInitializer.java new file mode 100644 index 00000000..0f94cdc3 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/exam/initializer/ExamQuotaCacheInitializer.java @@ -0,0 +1,26 @@ +package life.mosu.mosuserver.application.exam.initializer; + +import java.util.concurrent.CompletableFuture; +import life.mosu.mosuserver.application.exam.cache.ExamQuotaLoadService; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ExamQuotaCacheInitializer implements ApplicationRunner { + + private final ExamQuotaLoadService loadService; + + /** + * 병렬 실행으로 초기 Cache 로드 속도 최적화 + */ + @Override + public void run(ApplicationArguments args) { + CompletableFuture maxCapFuture = loadService.loadMaxCapacities(); + CompletableFuture currAppFuture = loadService.loadCurrentApplications(); + + CompletableFuture.allOf(maxCapFuture, currAppFuture).join(); + } +} diff --git a/src/main/java/life/mosu/mosuserver/domain/exam/entity/ExamJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/exam/entity/ExamJpaRepository.java index 7b47acaa..b5510f9a 100644 --- a/src/main/java/life/mosu/mosuserver/domain/exam/entity/ExamJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/exam/entity/ExamJpaRepository.java @@ -32,16 +32,32 @@ public interface ExamJpaRepository extends JpaRepository { List findByExamDateAfter(LocalDate today); + Optional findByIdAndExamDateAfter(Long examId, LocalDate today); + @Query(""" SELECT new life.mosu.mosuserver.domain.exam.projection.SchoolExamCountProjection( e.id, COUNT(ea.id) ) - FROM ExamApplicationJpaEntity ea - JOIN ExamJpaEntity e ON ea.examId = e.id + FROM ExamJpaEntity e + LEFT JOIN ExamApplicationJpaEntity ea ON ea.examId = e.id + GROUP BY e.id + """) + List countApplicationsGroupedByExamId(); + + + @Query(""" + SELECT new life.mosu.mosuserver.domain.exam.projection.SchoolExamCountProjection( + e.id, + COUNT(ea.id) + ) + FROM ExamJpaEntity e + LEFT JOIN ExamApplicationJpaEntity ea ON ea.examId = e.id + where e.id = :examId GROUP BY e.id """) - List countApplicationsGroupedBySchoolName(); + Optional countApplicationsByExamIdGroupedByExamId( + @Param("examId") Long examId); List findByIdIn(List examIds); diff --git a/src/main/java/life/mosu/mosuserver/global/config/ExamQuotaAtomicOperationConfig.java b/src/main/java/life/mosu/mosuserver/global/config/ExamQuotaAtomicOperationConfig.java index a69fc540..e7da0024 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/ExamQuotaAtomicOperationConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/ExamQuotaAtomicOperationConfig.java @@ -15,6 +15,7 @@ @Configuration public class ExamQuotaAtomicOperationConfig { + @Value("classpath:scripts/decrement_exam_quota.lua") private Resource decrementScript; @@ -27,7 +28,8 @@ public DefaultRedisScript decrementExamQuotaScript() { DefaultRedisScript script = new DefaultRedisScript<>(); script.setResultType(Long.class); try { - String lua = new String(decrementScript.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String lua = new String(decrementScript.getInputStream().readAllBytes(), + StandardCharsets.UTF_8); script.setScriptText(lua); } catch (IOException e) { throw new RuntimeException("Failed to load decrement_exam_quota.lua", e); @@ -41,7 +43,8 @@ public DefaultRedisScript incrementExamQuotaScript() { DefaultRedisScript script = new DefaultRedisScript<>(); script.setResultType(Long.class); try { - String lua = new String(incrementScript.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String lua = new String(incrementScript.getInputStream().readAllBytes(), + StandardCharsets.UTF_8); script.setScriptText(lua); } catch (IOException e) { throw new RuntimeException("Failed to load increment_exam_quota.lua", e); @@ -54,10 +57,10 @@ public DefaultRedisScript incrementExamQuotaScript() { public Map> examCacheAtomicOperatorMap( AtomicExamQuotaIncrementOperator incrementOp, AtomicExamQuotaDecrementOperator decrementOp - ){ + ) { return Map.of( - "increment", incrementOp, - "decrement", decrementOp + incrementOp.getActionName(), incrementOp, + decrementOp.getActionName(), decrementOp ); } } diff --git a/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java b/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java index 56f81849..8a6e3e88 100644 --- a/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java +++ b/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java @@ -152,9 +152,10 @@ public enum ErrorCode { NOT_FOUND_FORM(HttpStatus.NOT_FOUND, "신청서를 찾을 수 없습니다."), //LUA 관련 - LUA_SCRIPT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "LUA 스크립트 실행 중 오류가 발생했습니다."); - + LUA_SCRIPT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "LUA 스크립트 실행 중 오류가 발생했습니다."), + //캐시 관련 + CACHE_OPERATION_NOT_SUPPORTED(HttpStatus.NOT_IMPLEMENTED, "캐시 작업이 지원되지 않습니다."); private final HttpStatus status; private final String message; } diff --git a/src/main/java/life/mosu/mosuserver/infra/persistence/redis/KeyValueCacheManager.java b/src/main/java/life/mosu/mosuserver/infra/persistence/redis/KeyValueCacheManager.java index a0a330df..68c03fb4 100644 --- a/src/main/java/life/mosu/mosuserver/infra/persistence/redis/KeyValueCacheManager.java +++ b/src/main/java/life/mosu/mosuserver/infra/persistence/redis/KeyValueCacheManager.java @@ -1,7 +1,5 @@ package life.mosu.mosuserver.infra.persistence.redis; -import jakarta.annotation.PostConstruct; -import java.util.List; import java.util.Map; import life.mosu.mosuserver.infra.persistence.redis.operator.CacheAtomicOperator; import life.mosu.mosuserver.infra.persistence.redis.operator.CacheLoader; @@ -11,11 +9,9 @@ @RequiredArgsConstructor public abstract class KeyValueCacheManager { + protected final CacheLoader cacheLoader; protected final CacheWriter cacheWriter; protected final CacheReader cacheReader; - protected final Map> cacheAtomicOperatorMap; - - protected abstract void initCache(); - + protected final Map> cacheAtomicOperatorMap; }