From 1e0b8eb396d8ede850b55e80f781b3575ce47d34 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 16 Jul 2026 19:13:13 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20AI=20=EC=86=8C=EC=9A=94=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AiHistoryAsyncQueryService.java | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/service/AiHistoryAsyncQueryService.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiHistoryAsyncQueryService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiHistoryAsyncQueryService.java new file mode 100644 index 00000000..f038260f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiHistoryAsyncQueryService.java @@ -0,0 +1,62 @@ +package com.Timo.Timo.domain.ai.service; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; +import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AiHistoryAsyncQueryService { + + private final AiTodoQueryRepository aiTodoQueryRepository; + + @Async("aiHistoryExecutor") + @Transactional(readOnly = true) + public CompletableFuture> findSimilarTitleHistories( + Long userId, + String title, + LocalDateTime toExclusive, + ZoneId userZoneId, + int limit + ) { + return CompletableFuture.completedFuture( + aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitle( + userId, + title, + toExclusive, + userZoneId, + limit + ) + ); + } + + @Async("aiHistoryExecutor") + @Transactional(readOnly = true) + public CompletableFuture> findRecentTagHistories( + Long userId, + Long tagId, + LocalDateTime toExclusive, + ZoneId userZoneId, + int limit + ) { + return CompletableFuture.completedFuture( + aiTodoQueryRepository.findActualDurationHistoriesByTagId( + userId, + tagId, + toExclusive, + userZoneId, + limit + ) + ); + } +} \ No newline at end of file From d516b27b3a56641c0a5eaa7cc873f2efc21d05e1 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 16 Jul 2026 19:13:30 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat(ai):=20AI=20=ED=9E=88=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20Redis=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/service/AiHistoryCacheService.java | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/service/AiHistoryCacheService.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiHistoryCacheService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiHistoryCacheService.java new file mode 100644 index 00000000..01d0dbc5 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiHistoryCacheService.java @@ -0,0 +1,157 @@ +package com.Timo.Timo.domain.ai.service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AiHistoryCacheService { + + private static final String HISTORY_VERSION_KEY = "ai:history:version:"; + private static final String SIMILAR_KEY_PREFIX = "ai:history:similar:"; + private static final String TAG_KEY_PREFIX = "ai:history:tag:"; + private static final Duration HISTORY_CACHE_TTL = Duration.ofMinutes(5); + private static final TypeReference> HISTORY_LIST_TYPE = new TypeReference<>() { + }; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public CacheLookupResult getSimilarTitleHistories( + Long userId, + String title, + LocalDateTime toExclusive, + ZoneId userZoneId, + int limit + ) { + return getHistories(buildSimilarKey(userId, title, toExclusive, userZoneId, limit)); + } + + public void cacheSimilarTitleHistories( + Long userId, + String title, + LocalDateTime toExclusive, + ZoneId userZoneId, + int limit, + List histories + ) { + cacheHistories(buildSimilarKey(userId, title, toExclusive, userZoneId, limit), histories); + } + + public CacheLookupResult getRecentTagHistories( + Long userId, + Long tagId, + LocalDateTime toExclusive, + ZoneId userZoneId, + int limit + ) { + return getHistories(buildTagKey(userId, tagId, toExclusive, userZoneId, limit)); + } + + public void cacheRecentTagHistories( + Long userId, + Long tagId, + LocalDateTime toExclusive, + ZoneId userZoneId, + int limit, + List histories + ) { + cacheHistories(buildTagKey(userId, tagId, toExclusive, userZoneId, limit), histories); + } + + public void bumpUserHistoryVersion(Long userId) { + redisTemplate.opsForValue().increment(HISTORY_VERSION_KEY + userId); + } + + private CacheLookupResult getHistories(String key) { + String value = redisTemplate.opsForValue().get(key); + if (value == null || value.isBlank()) { + return CacheLookupResult.miss(); + } + + try { + return CacheLookupResult.hit(objectMapper.readValue(value, HISTORY_LIST_TYPE)); + } catch (Exception exception) { + log.warn("Failed to deserialize AI history cache. key={}", key, exception); + redisTemplate.delete(key); + return CacheLookupResult.miss(); + } + } + + private void cacheHistories(String key, List histories) { + try { + redisTemplate.opsForValue().set( + key, + objectMapper.writeValueAsString(histories), + HISTORY_CACHE_TTL + ); + } catch (Exception exception) { + log.warn("Failed to serialize AI history cache. key={}", key, exception); + } + } + + private String buildSimilarKey( + Long userId, + String title, + LocalDateTime toExclusive, + ZoneId userZoneId, + int limit + ) { + String normalizedTitle = title == null ? "" : title.trim().toLowerCase(); + return SIMILAR_KEY_PREFIX + + userId + + ":v" + getUserHistoryVersion(userId) + + ":" + normalizedTitle.hashCode() + + ":" + toExclusive + + ":" + userZoneId.getId() + + ":" + limit; + } + + private String buildTagKey( + Long userId, + Long tagId, + LocalDateTime toExclusive, + ZoneId userZoneId, + int limit + ) { + return TAG_KEY_PREFIX + + userId + + ":v" + getUserHistoryVersion(userId) + + ":" + tagId + + ":" + toExclusive + + ":" + userZoneId.getId() + + ":" + limit; + } + + private long getUserHistoryVersion(Long userId) { + String value = redisTemplate.opsForValue().get(HISTORY_VERSION_KEY + userId); + if (value == null || value.isBlank()) { + return 0L; + } + return Long.parseLong(value); + } + + public record CacheLookupResult(boolean hit, List histories) { + + private static CacheLookupResult hit(List histories) { + return new CacheLookupResult(true, histories); + } + + private static CacheLookupResult miss() { + return new CacheLookupResult(false, List.of()); + } + } +} \ No newline at end of file From 7ddfa982e81e49988c3e919c2ceefc58398dea3d Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 16 Jul 2026 19:13:43 +0900 Subject: [PATCH 03/15] =?UTF-8?q?refactor(ai):=20AI=20=ED=9E=88=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EB=B3=91?= =?UTF-8?q?=EB=A0=AC=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/service/AiTodoHistoryService.java | 70 ++++++++++++++++--- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java index ffdd3627..516db14e 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java @@ -3,12 +3,12 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.List; +import java.util.concurrent.CompletableFuture; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; -import com.Timo.Timo.domain.ai.repository.AiTodoQueryRepository; import lombok.RequiredArgsConstructor; @@ -16,7 +16,8 @@ @RequiredArgsConstructor public class AiTodoHistoryService { - private final AiTodoQueryRepository aiTodoQueryRepository; + private final AiHistoryAsyncQueryService aiHistoryAsyncQueryService; + private final AiHistoryCacheService aiHistoryCacheService; @Transactional(readOnly = true) public AiTodoHistories findHistories( @@ -27,24 +28,73 @@ public AiTodoHistories findHistories( ZoneId userZoneId, int limit ) { - List similarTitleHistories = - aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitle( + AiHistoryCacheService.CacheLookupResult similarCacheResult = + aiHistoryCacheService.getSimilarTitleHistories( userId, title, toExclusive, userZoneId, limit ); - List recentTagHistories = tagId == null - ? List.of() - : aiTodoQueryRepository.findActualDurationHistoriesByTagId( + CompletableFuture> similarTitleFuture = similarCacheResult.hit() + ? CompletableFuture.completedFuture(similarCacheResult.histories()) + : aiHistoryAsyncQueryService.findSimilarTitleHistories( userId, - tagId, + title, toExclusive, userZoneId, limit - ); + ).thenApply(histories -> { + aiHistoryCacheService.cacheSimilarTitleHistories( + userId, + title, + toExclusive, + userZoneId, + limit, + histories + ); + return histories; + }); + + CompletableFuture> recentTagFuture; + if (tagId == null) { + recentTagFuture = CompletableFuture.completedFuture(List.of()); + } else { + AiHistoryCacheService.CacheLookupResult tagCacheResult = + aiHistoryCacheService.getRecentTagHistories( + userId, + tagId, + toExclusive, + userZoneId, + limit + ); + recentTagFuture = tagCacheResult.hit() + ? CompletableFuture.completedFuture(tagCacheResult.histories()) + : aiHistoryAsyncQueryService.findRecentTagHistories( + userId, + tagId, + toExclusive, + userZoneId, + limit + ).thenApply(histories -> { + aiHistoryCacheService.cacheRecentTagHistories( + userId, + tagId, + toExclusive, + userZoneId, + limit, + histories + ); + return histories; + }); + } + + List similarTitleHistories = similarTitleFuture.join(); + List recentTagHistories = recentTagFuture.join(); - return new AiTodoHistories(similarTitleHistories, recentTagHistories); + return new AiTodoHistories( + similarTitleHistories, + recentTagHistories + ); } } From 91253f73fd782955eb590f2160cbf810abfe793a Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 16 Jul 2026 19:14:29 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat(ai):=20AI=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AiFeedbackPersistenceService.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/domain/ai/service/AiFeedbackPersistenceService.java diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiFeedbackPersistenceService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiFeedbackPersistenceService.java new file mode 100644 index 00000000..b4e5b2e3 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiFeedbackPersistenceService.java @@ -0,0 +1,23 @@ +package com.Timo.Timo.domain.ai.service; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AiFeedbackPersistenceService { + + private final TimerRecordRepository timerRecordRepository; + + @Async("aiHistoryExecutor") + @Transactional + public void persistFeedback(Long timerId, String feedback) { + timerRecordRepository.findById(timerId) + .ifPresent(timerRecord -> timerRecord.updateAiFeedback(feedback)); + } +} \ No newline at end of file From 3774cbb44fdd41418e4be412a30fe952359a7171 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 16 Jul 2026 19:14:47 +0900 Subject: [PATCH 05/15] =?UTF-8?q?refactor(ai):=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8=20=EC=A2=85=EB=A3=8C=20=ED=9B=84=20AI=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EC=A0=80=EC=9E=A5=EA=B3=BC=20=ED=9E=88?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=BA=90=EC=8B=9C=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/timer/service/TimerService.java | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java index 8eea3fb4..a298e92c 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -1,6 +1,8 @@ package com.Timo.Timo.domain.timer.service; import com.Timo.Timo.domain.ai.service.AiTodoService; +import com.Timo.Timo.domain.ai.service.AiHistoryCacheService; +import com.Timo.Timo.domain.ai.service.AiFeedbackPersistenceService; import com.Timo.Timo.domain.timer.dto.response.TimerActiveResponse; import com.Timo.Timo.domain.timer.dto.response.TimerFinishResponse; import com.Timo.Timo.domain.timer.dto.response.TimerExtendResponse; @@ -49,6 +51,8 @@ public class TimerService { private final UserRepository userRepository; private final TodoInstanceReorderer todoInstanceReorderer; private final AiTodoService aiTodoService; + private final AiHistoryCacheService aiHistoryCacheService; + private final AiFeedbackPersistenceService aiFeedbackPersistenceService; private final PlatformTransactionManager transactionManager; @Transactional @@ -205,12 +209,11 @@ private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus t FinishedTimer finishedTimer = transactionTemplate.execute(status -> finishTimerInTransaction(userId, timerId, targetStatus) ); + aiHistoryCacheService.bumpUserHistoryVersion(userId); String feedback = generateAiFeedback(userId, finishedTimer.todoId()); if (feedback != null) { - transactionTemplate.executeWithoutResult(status -> - updateAiFeedback(timerId, feedback) - ); + aiFeedbackPersistenceService.persistFeedback(timerId, feedback); } return new TimerFinishResponse( @@ -262,12 +265,6 @@ private String generateAiFeedback(Long userId, Long todoId) { } } - private void updateAiFeedback(Long timerId, String feedback) { - TimerRecord timerRecord = timerRecordRepository.findByIdForUpdate(timerId) - .orElseThrow(() -> new CustomException(TimerErrorCode.TIMER_NOT_FOUND)); - timerRecord.updateAiFeedback(feedback); - } - private record FinishedTimer( Long timerId, Long todoId, From 7be55b28ecb932c7ab24ee7dcf8c972f0dc32ff5 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 16 Jul 2026 19:15:03 +0900 Subject: [PATCH 06/15] =?UTF-8?q?chore(ai):=20AI=20=ED=9E=88=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EA=B8=B0=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/global/config/AsyncConfig.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/java/com/Timo/Timo/global/config/AsyncConfig.java diff --git a/src/main/java/com/Timo/Timo/global/config/AsyncConfig.java b/src/main/java/com/Timo/Timo/global/config/AsyncConfig.java new file mode 100644 index 00000000..54fdb99b --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/config/AsyncConfig.java @@ -0,0 +1,24 @@ +package com.Timo.Timo.global.config; + +import java.util.concurrent.Executor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "aiHistoryExecutor") + public Executor aiHistoryExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setThreadNamePrefix("ai-history-"); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(50); + executor.initialize(); + return executor; + } +} \ No newline at end of file From c47a9c6df932854b9964331589a0c04586890ea9 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Thu, 16 Jul 2026 19:15:25 +0900 Subject: [PATCH 07/15] =?UTF-8?q?chore(ai):=20AI=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=A4=91=EB=B3=B5=20=EB=A1=9C=EA=B7=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/ai/service/AiTodoService.java | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java index f3f215bb..eb77f84e 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java @@ -64,12 +64,6 @@ public RecommendDurationResponse recommendDuration(Long userId, RecommendDuratio ); rateLimiter.validate(userId, estimateTokenCost(prompt)); - log.info( - "AI duration recommendation histories loaded. similarTitle={}, recentTag={}", - histories.similarTitleHistories().size(), - histories.recentTagHistories().size() - ); - String geminiJson = geminiService.generateJson(prompt); GeminiDurationRecommendation recommendation = parseRecommendation(geminiJson); return validate(recommendation); @@ -99,12 +93,6 @@ public String createFeedback(Long userId, Long todoId) { ); rateLimiter.validate(userId, estimateTokenCost(prompt)); - log.info( - "AI todo feedback histories loaded. similarTitle={}, recentTag={}", - histories.similarTitleHistories().size(), - histories.recentTagHistories().size() - ); - String geminiJson = geminiService.generateJson(prompt); GeminiTodoFeedback feedback = parseFeedback(geminiJson); return validateFeedback(feedback); From b71de38cdf6450d109c8df309e429d9115cf29fa Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 1 Sep 2026 18:13:31 +0900 Subject: [PATCH 08/15] =?UTF-8?q?refactor(ai):=20AI=20=ED=9E=88=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=A1=B0=ED=9A=8C=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/domain/timer/entity/TimerRecord.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java index d58bedeb..7a39a416 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java +++ b/src/main/java/com/Timo/Timo/domain/timer/entity/TimerRecord.java @@ -14,6 +14,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -31,7 +32,10 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Entity -@Table(name = "timer_records") +@Table( + name = "timer_records", + indexes = @Index(name = "idx_timer_records_user_ended", columnList = "user_id, ended_at") +) @EntityListeners(AuditingEntityListener.class) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -146,4 +150,4 @@ public LocalDate getTimerDate() { ZoneId userZone = ZoneId.of(user.getZoneId()); return startedAt.atZone(ZoneOffset.UTC).withZoneSameInstant(userZone).toLocalDate(); } -} +} \ No newline at end of file From 71dd33be4f74230fbff2ae08d7099e8761bea96e Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 1 Sep 2026 18:14:58 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=20=20refactor(ai):=20=EC=9C=A0=EC=82=AC?= =?UTF-8?q?=20title=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20+=20=EC=95=B1=EB=8B=A8=20?= =?UTF-8?q?=EC=9C=A0=EC=82=AC=EB=8F=84=20=ED=8C=90=EC=A0=95=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/repository/AiTodoQueryRepository.java | 81 ++++++++++++------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java index adf319dc..b7d5a95e 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -4,12 +4,14 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZoneOffset; +import java.util.Comparator; import java.util.List; import org.springframework.stereotype.Repository; import com.Timo.Timo.domain.ai.dto.TodoDurationHistory; import com.Timo.Timo.domain.ai.dto.TodoFeedbackSource; +import com.Timo.Timo.domain.timer.entity.TimerRecord; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; @@ -18,6 +20,9 @@ @RequiredArgsConstructor public class AiTodoQueryRepository { + private static final int CANDIDATE_WINDOW = 200; + private static final int UNMATCHED_PRIORITY = 3; + private final EntityManager entityManager; public TodoFeedbackSource findFeedbackSource(Long userId, Long todoId) { @@ -59,39 +64,53 @@ public List findActualDurationHistoriesBySimilarTitle( ZoneId userZoneId, int limit ) { - List rows = entityManager.createQuery(""" - select new com.Timo.Timo.domain.ai.repository.TodoDurationHistoryRow( - t.title, - tr.actualSeconds, - coalesce(tr.endedAt, tr.startedAt) - ) + List candidates = entityManager.createQuery(""" + select tr from TimerRecord tr - join tr.todo t - where t.user.id = :userId - and tr.user.id = :userId + join fetch tr.todo t + where tr.user.id = :userId and tr.actualSeconds is not null - and coalesce(tr.endedAt, tr.startedAt) < :toExclusive - and ( - lower(t.title) like lower(concat('%', :title, '%')) - or lower(:title) like lower(concat('%', t.title, '%')) - ) - order by - case - when lower(t.title) = lower(:title) then 0 - when lower(t.title) like lower(concat('%', :title, '%')) then 1 - when lower(:title) like lower(concat('%', t.title, '%')) then 2 - else 3 - end, - coalesce(tr.endedAt, tr.startedAt) desc, - tr.id desc - """, TodoDurationHistoryRow.class) + and tr.endedAt < :toExclusive + order by tr.endedAt desc, tr.id desc + """, TimerRecord.class) .setParameter("userId", userId) - .setParameter("title", title) .setParameter("toExclusive", toExclusive) - .setMaxResults(limit) + .setMaxResults(CANDIDATE_WINDOW) .getResultList(); - return toHistories(rows, userZoneId); + String normalizedSearchTitle = normalize(title); + + return candidates.stream() + .map(record -> new ScoredCandidate( + record, + matchPriority(normalize(record.getTodo().getTitle()), normalizedSearchTitle) + )) + .filter(scored -> scored.priority() < UNMATCHED_PRIORITY) + .sorted(Comparator.comparingInt(ScoredCandidate::priority) + .thenComparing(scored -> scored.record().getEndedAt(), Comparator.reverseOrder())) + .limit(limit) + .map(scored -> toHistory(scored.record(), userZoneId)) + .toList(); + } + + private int matchPriority(String candidateTitle, String searchTitle) { + if (candidateTitle.equals(searchTitle)) { + return 0; + } + if (candidateTitle.contains(searchTitle)) { + return 1; + } + if (searchTitle.contains(candidateTitle)) { + return 2; + } + return UNMATCHED_PRIORITY; + } + + private String normalize(String value) { + return value == null ? "" : value.trim().toLowerCase(); + } + + private record ScoredCandidate(TimerRecord record, int priority) { } public List findActualDurationHistoriesByTagId( @@ -152,6 +171,14 @@ private List toHistories(List rows, .toList(); } + private TodoDurationHistory toHistory(TimerRecord record, ZoneId userZoneId) { + return new TodoDurationHistory( + record.getTodo().getTitle(), + record.getActualSeconds(), + toUserLocalDate(record.getEndedAt(), userZoneId) + ); + } + private LocalDate toUserLocalDate(LocalDateTime utcDateTime, ZoneId userZoneId) { return utcDateTime .atZone(ZoneOffset.UTC) From 22661d7aa1cdc3f9e420249582b5036f3c852c6c Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 1 Sep 2026 19:17:59 +0900 Subject: [PATCH 10/15] =?UTF-8?q?=20=20refactor(ai):=20=EC=9C=A0=EC=82=AC?= =?UTF-8?q?=20title=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20+=20=EC=95=B1=EB=8B=A8=20?= =?UTF-8?q?=EC=9C=A0=EC=82=AC=EB=8F=84=20=ED=8C=90=EC=A0=95=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/prompt/TodoDurationPromptBuilder.java | 26 +++++++++++++++++-- .../ai/prompt/TodoFeedbackPromptBuilder.java | 24 +++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java index b1a5fcba..c6a39c7b 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java @@ -25,9 +25,14 @@ public String build( - 비슷한 투두명 기록과 태그 기록이 모두 있으면 둘을 함께 보고, 비슷한 투두명 기록을 조금 더 중요하게 봐. - 기록이 아예 없으면 현재 투두명만 기준으로 일반적인 예상 소요 시간을 판단해. + 기록 신뢰도 판단 기준: + - 각 기록 그룹 앞의 요약(count/avgMinutes/minMinutes/maxMinutes)은 이미 정확히 계산된 값이니 그대로 신뢰하고, 직접 다시 계산하지 마. + - count가 1이면 그 값 하나에 과도하게 의존하지 말고 일반적인 감각과 함께 보수적으로 조정해. + - count가 3 이상이면 avgMinutes를 중심으로 판단하되, minMinutes~maxMinutes 범위를 크게 벗어난 추천은 피해. + 규칙: - 응답은 반드시 JSON 객체 하나만 반환해. - - recommendedMinutes는 분 단위 정수로 반환해. + - recommendedMinutes는 1 이상의 분 단위 정수로 반환해. - 실제 기록에 없는 패턴은 만들지 마. 반환해야 할 응답 JSON 형식: @@ -56,9 +61,26 @@ public String build( private String formatHistories(List histories) { if (histories == null || histories.isEmpty()) { - return "[]"; + return "요약: {\"count\":0}\n기록: []"; } + return "요약: %s\n기록: %s".formatted(summarize(histories), listHistories(histories)); + } + + private String summarize(List histories) { + List minutes = histories.stream() + .map(history -> toMinutes(history.actualSeconds())) + .toList(); + int count = minutes.size(); + int avg = Math.round(minutes.stream().mapToInt(Integer::intValue).sum() / (float) count); + int min = minutes.stream().mapToInt(Integer::intValue).min().orElse(0); + int max = minutes.stream().mapToInt(Integer::intValue).max().orElse(0); + + return """ + {"count":%d,"avgMinutes":%d,"minMinutes":%d,"maxMinutes":%d}""".formatted(count, avg, min, max); + } + + private String listHistories(List histories) { return histories.stream() .map(history -> """ {"title":"%s","date":"%s","actualMinutes":%d} diff --git a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java index 19dcdbdb..578ba755 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java +++ b/src/main/java/com/Timo/Timo/domain/ai/prompt/TodoFeedbackPromptBuilder.java @@ -27,8 +27,11 @@ public String build( - 1순위: 비슷한 투두명 실제 소요시간 기록 - 2순위: 같은 태그의 최근 실제 소요시간 기록 - 3순위: 기록이 없으면 이번 태스크의 연장 또는 조기 종료 여부 + - 각 기록 그룹 앞의 요약(count/avgMinutes/minMinutes/maxMinutes)은 이미 정확히 계산된 값이니 그대로 신뢰하고, 직접 다시 계산하지 마. + - count가 1이면 그 값 하나만으로 단정하지 말고 "아직 데이터가 적다"는 뉘앙스 없이 조심스럽게만 반영해. 3. 다음 행동 추천 - 다음에 예상 시간을 어떻게 잡으면 좋을지 제안해. + - count가 3 이상인 그룹이 있으면 그 avgMinutes를 다음 예상 시간 제안의 기준으로 우선 사용해. 규칙: - 응답은 반드시 JSON 객체 하나만 반환해. @@ -36,7 +39,7 @@ public String build( - feedback은 현재 결과 관찰, 패턴 해석, 다음 행동 추천을 압축해서 포함해. - 실제 기록에 없는 패턴은 만들지 마. - 기록이 부족하면 부족하다고 길게 말하지 말고, 이번 결과 기준으로만 제안해. - - 다음 예상 시간은 분 단위로 제안해. + - 다음 예상 시간은 1 이상의 분 단위 정수로 제안해. 반환해야 할 응답 JSON 형식: { @@ -70,9 +73,26 @@ public String build( private String formatHistories(List histories) { if (histories == null || histories.isEmpty()) { - return "[]"; + return "요약: {\"count\":0}\n기록: []"; } + return "요약: %s\n기록: %s".formatted(summarize(histories), listHistories(histories)); + } + + private String summarize(List histories) { + List minutes = histories.stream() + .map(history -> toMinutes(history.actualSeconds())) + .toList(); + int count = minutes.size(); + int avg = Math.round(minutes.stream().mapToInt(Integer::intValue).sum() / (float) count); + int min = minutes.stream().mapToInt(Integer::intValue).min().orElse(0); + int max = minutes.stream().mapToInt(Integer::intValue).max().orElse(0); + + return """ + {"count":%d,"avgMinutes":%d,"minMinutes":%d,"maxMinutes":%d}""".formatted(count, avg, min, max); + } + + private String listHistories(List histories) { return histories.stream() .map(history -> """ {"title":"%s","date":"%s","actualMinutes":%d} From cd78cb28673cc33cc273261dbeac75366aa03916 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 1 Sep 2026 19:24:29 +0900 Subject: [PATCH 11/15] =?UTF-8?q?refactor(timer):=20timer=5Frecords=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-01__timer_records_add_user_ended_index.sql | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 db/migration/2026-09-01__timer_records_add_user_ended_index.sql diff --git a/db/migration/2026-09-01__timer_records_add_user_ended_index.sql b/db/migration/2026-09-01__timer_records_add_user_ended_index.sql new file mode 100644 index 00000000..90c80d20 --- /dev/null +++ b/db/migration/2026-09-01__timer_records_add_user_ended_index.sql @@ -0,0 +1,8 @@ +-- AI 유사 title 히스토리 조회(AiTodoQueryRepository#findActualDurationHistoriesBySimilarTitle)를 +-- LIKE 전체 스캔 대신 인덱스 range scan + LIMIT으로 후보를 추린 뒤 애플리케이션에서 +-- 부분 문자열 유사도를 판정하도록 리팩토링했다. +-- ddl-auto: update는 이미 존재하는 테이블에 인덱스를 안정적으로 추가하지 않으므로 +-- 기존 DB(특히 prod)에는 아래 SQL을 수동 적용해야 한다. + +ALTER TABLE timer_records + ADD INDEX idx_timer_records_user_ended (user_id, ended_at); From 1cc4edac065127009b806415485c5155c89f3434 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 1 Sep 2026 23:08:13 +0900 Subject: [PATCH 12/15] =?UTF-8?q?refactor(timer):=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-01__timer_records_add_user_ended_index.sql | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 db/migration/2026-09-01__timer_records_add_user_ended_index.sql diff --git a/db/migration/2026-09-01__timer_records_add_user_ended_index.sql b/db/migration/2026-09-01__timer_records_add_user_ended_index.sql deleted file mode 100644 index 90c80d20..00000000 --- a/db/migration/2026-09-01__timer_records_add_user_ended_index.sql +++ /dev/null @@ -1,8 +0,0 @@ --- AI 유사 title 히스토리 조회(AiTodoQueryRepository#findActualDurationHistoriesBySimilarTitle)를 --- LIKE 전체 스캔 대신 인덱스 range scan + LIMIT으로 후보를 추린 뒤 애플리케이션에서 --- 부분 문자열 유사도를 판정하도록 리팩토링했다. --- ddl-auto: update는 이미 존재하는 테이블에 인덱스를 안정적으로 추가하지 않으므로 --- 기존 DB(특히 prod)에는 아래 SQL을 수동 적용해야 한다. - -ALTER TABLE timer_records - ADD INDEX idx_timer_records_user_ended (user_id, ended_at); From 5d6ce654eb07c610e6b86ffbb8ec7dd2b1c78338 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Tue, 1 Sep 2026 23:30:03 +0900 Subject: [PATCH 13/15] =?UTF-8?q?refactor(ai):=20candidate=20window=20size?= =?UTF-8?q?=2030=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java index b7d5a95e..2a19036b 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java +++ b/src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java @@ -20,7 +20,7 @@ @RequiredArgsConstructor public class AiTodoQueryRepository { - private static final int CANDIDATE_WINDOW = 200; + private static final int CANDIDATE_WINDOW = 30; private static final int UNMATCHED_PRIORITY = 3; private final EntityManager entityManager; From 4a1dc82ac973772b02ab93506812066b97d9e5aa Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 2 Sep 2026 22:47:53 +0900 Subject: [PATCH 14/15] =?UTF-8?q?=20=20refactor(ai):=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8=20=EC=99=84=EB=A3=8C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=97=90=20REQUIRES=5FNEW=EB=A5=BC=20=EB=AA=85?= =?UTF-8?q?=EC=8B=9C=ED=95=B4=20=EC=BB=A4=EB=B0=8B=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/timer/service/TimerService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java index a298e92c..939bcb74 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -34,6 +34,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @@ -206,6 +207,7 @@ public TimerFinishResponse stopTimer(Long userId, Long timerId) { private TimerFinishResponse finishTimer(Long userId, Long timerId, TimerStatus targetStatus) { TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); FinishedTimer finishedTimer = transactionTemplate.execute(status -> finishTimerInTransaction(userId, timerId, targetStatus) ); @@ -283,4 +285,4 @@ private static FinishedTimer from(TimerRecord timerRecord) { ); } } -} +} \ No newline at end of file From 2dab214756da1202c15b5145eb437878cde4aff7 Mon Sep 17 00:00:00 2001 From: aneykrap Date: Wed, 2 Sep 2026 22:48:37 +0900 Subject: [PATCH 15/15] =?UTF-8?q?=20=20refactor(ai):=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=EC=A0=90?= =?UTF-8?q?=20=EA=B3=A0=EC=A0=95=20=EB=B0=8F=20Redis=20=EC=9E=A5=EC=95=A0?= =?UTF-8?q?=20=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/service/AiHistoryCacheService.java | 84 +++++++++---------- .../ai/service/AiTodoHistoryService.java | 18 +--- 2 files changed, 41 insertions(+), 61 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiHistoryCacheService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiHistoryCacheService.java index 01d0dbc5..78d57747 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiHistoryCacheService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiHistoryCacheService.java @@ -40,17 +40,6 @@ public CacheLookupResult getSimilarTitleHistories( return getHistories(buildSimilarKey(userId, title, toExclusive, userZoneId, limit)); } - public void cacheSimilarTitleHistories( - Long userId, - String title, - LocalDateTime toExclusive, - ZoneId userZoneId, - int limit, - List histories - ) { - cacheHistories(buildSimilarKey(userId, title, toExclusive, userZoneId, limit), histories); - } - public CacheLookupResult getRecentTagHistories( Long userId, Long tagId, @@ -61,45 +50,45 @@ public CacheLookupResult getRecentTagHistories( return getHistories(buildTagKey(userId, tagId, toExclusive, userZoneId, limit)); } - public void cacheRecentTagHistories( - Long userId, - Long tagId, - LocalDateTime toExclusive, - ZoneId userZoneId, - int limit, - List histories - ) { - cacheHistories(buildTagKey(userId, tagId, toExclusive, userZoneId, limit), histories); + public void cacheHistories(String key, List histories) { + try { + redisTemplate.opsForValue().set( + key, + objectMapper.writeValueAsString(histories), + HISTORY_CACHE_TTL + ); + } catch (Exception exception) { + log.warn("Failed to serialize AI history cache. key={}", key, exception); + } } public void bumpUserHistoryVersion(Long userId) { - redisTemplate.opsForValue().increment(HISTORY_VERSION_KEY + userId); + try { + redisTemplate.opsForValue().increment(HISTORY_VERSION_KEY + userId); + } catch (Exception exception) { + log.warn("Failed to bump AI history cache version. userId={}", userId, exception); + } } private CacheLookupResult getHistories(String key) { - String value = redisTemplate.opsForValue().get(key); + String value; + try { + value = redisTemplate.opsForValue().get(key); + } catch (Exception exception) { + log.warn("Failed to read AI history cache, treating as miss. key={}", key, exception); + return CacheLookupResult.miss(key); + } + if (value == null || value.isBlank()) { - return CacheLookupResult.miss(); + return CacheLookupResult.miss(key); } try { - return CacheLookupResult.hit(objectMapper.readValue(value, HISTORY_LIST_TYPE)); + return CacheLookupResult.hit(objectMapper.readValue(value, HISTORY_LIST_TYPE), key); } catch (Exception exception) { log.warn("Failed to deserialize AI history cache. key={}", key, exception); redisTemplate.delete(key); - return CacheLookupResult.miss(); - } - } - - private void cacheHistories(String key, List histories) { - try { - redisTemplate.opsForValue().set( - key, - objectMapper.writeValueAsString(histories), - HISTORY_CACHE_TTL - ); - } catch (Exception exception) { - log.warn("Failed to serialize AI history cache. key={}", key, exception); + return CacheLookupResult.miss(key); } } @@ -137,21 +126,26 @@ private String buildTagKey( } private long getUserHistoryVersion(Long userId) { - String value = redisTemplate.opsForValue().get(HISTORY_VERSION_KEY + userId); - if (value == null || value.isBlank()) { + try { + String value = redisTemplate.opsForValue().get(HISTORY_VERSION_KEY + userId); + if (value == null || value.isBlank()) { + return 0L; + } + return Long.parseLong(value); + } catch (Exception exception) { + log.warn("Failed to read AI history cache version, defaulting to 0. userId={}", userId, exception); return 0L; } - return Long.parseLong(value); } - public record CacheLookupResult(boolean hit, List histories) { + public record CacheLookupResult(boolean hit, List histories, String key) { - private static CacheLookupResult hit(List histories) { - return new CacheLookupResult(true, histories); + private static CacheLookupResult hit(List histories, String key) { + return new CacheLookupResult(true, histories, key); } - private static CacheLookupResult miss() { - return new CacheLookupResult(false, List.of()); + private static CacheLookupResult miss(String key) { + return new CacheLookupResult(false, List.of(), key); } } } \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java index 516db14e..9f904b54 100644 --- a/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java +++ b/src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java @@ -45,14 +45,7 @@ public AiTodoHistories findHistories( userZoneId, limit ).thenApply(histories -> { - aiHistoryCacheService.cacheSimilarTitleHistories( - userId, - title, - toExclusive, - userZoneId, - limit, - histories - ); + aiHistoryCacheService.cacheHistories(similarCacheResult.key(), histories); return histories; }); @@ -77,14 +70,7 @@ public AiTodoHistories findHistories( userZoneId, limit ).thenApply(histories -> { - aiHistoryCacheService.cacheRecentTagHistories( - userId, - tagId, - toExclusive, - userZoneId, - limit, - histories - ); + aiHistoryCacheService.cacheHistories(tagCacheResult.key(), histories); return histories; }); }