From b3cef82f10048378f7344ec7cc338b8fdb64fb07 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Fri, 20 Feb 2026 02:03:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(#403):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20redis=20=EC=BA=90=EC=8B=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationQueryService.java | 11 ++- .../service/NotificationService.java | 10 ++- .../redis/NotificationUnreadCountCache.java | 70 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/example/RealMatch/notification/infrastructure/redis/NotificationUnreadCountCache.java diff --git a/src/main/java/com/example/RealMatch/notification/application/service/NotificationQueryService.java b/src/main/java/com/example/RealMatch/notification/application/service/NotificationQueryService.java index cce9087d..7356591f 100644 --- a/src/main/java/com/example/RealMatch/notification/application/service/NotificationQueryService.java +++ b/src/main/java/com/example/RealMatch/notification/application/service/NotificationQueryService.java @@ -18,6 +18,7 @@ import com.example.RealMatch.notification.domain.entity.enums.NotificationKind; import com.example.RealMatch.notification.domain.repository.NotificationRepository; import com.example.RealMatch.notification.exception.NotificationErrorCode; +import com.example.RealMatch.notification.infrastructure.redis.NotificationUnreadCountCache; import com.example.RealMatch.notification.presentation.dto.response.NotificationDateGroup; import com.example.RealMatch.notification.presentation.dto.response.NotificationListResponse; import com.example.RealMatch.notification.presentation.dto.response.NotificationResponse; @@ -30,6 +31,7 @@ public class NotificationQueryService { private final NotificationRepository notificationRepository; + private final NotificationUnreadCountCache unreadCountCache; private static final DateTimeFormatter DATE_LABEL_FORMATTER = DateTimeFormatter.ofPattern("yy.MM.dd (E)", Locale.KOREAN); @@ -52,7 +54,7 @@ public NotificationListResponse getNotifications(Long userId, String filter, int List groups = buildDateGroups(notificationPage.getContent()); - long unreadCount = notificationRepository.countUnreadByUserId(userId); + long unreadCount = getUnreadCount(userId); return new NotificationListResponse( items, @@ -66,7 +68,12 @@ public NotificationListResponse getNotifications(Long userId, String filter, int } public long getUnreadCount(Long userId) { - return notificationRepository.countUnreadByUserId(userId); + return unreadCountCache.get(userId) + .orElseGet(() -> { + long count = notificationRepository.countUnreadByUserId(userId); + unreadCountCache.set(userId, count); + return count; + }); } private List resolveKinds(String filter) { diff --git a/src/main/java/com/example/RealMatch/notification/application/service/NotificationService.java b/src/main/java/com/example/RealMatch/notification/application/service/NotificationService.java index 417c29e7..47f6186b 100644 --- a/src/main/java/com/example/RealMatch/notification/application/service/NotificationService.java +++ b/src/main/java/com/example/RealMatch/notification/application/service/NotificationService.java @@ -22,6 +22,7 @@ import com.example.RealMatch.notification.domain.repository.NotificationOutboxRepository; import com.example.RealMatch.notification.domain.repository.NotificationRepository; import com.example.RealMatch.notification.exception.NotificationErrorCode; +import com.example.RealMatch.notification.infrastructure.redis.NotificationUnreadCountCache; import com.example.RealMatch.user.domain.entity.enums.NotificationChannel; import lombok.RequiredArgsConstructor; @@ -42,6 +43,7 @@ public class NotificationService { private final NotificationDeliveryRepository notificationDeliveryRepository; private final NotificationOutboxRepository notificationOutboxRepository; private final NotificationChannelResolver channelResolver; + private final NotificationUnreadCountCache unreadCountCache; @Transactional(propagation = Propagation.REQUIRES_NEW) public Notification create(CreateNotificationCommand command) { @@ -58,6 +60,8 @@ public Notification create(CreateNotificationCommand command) { Notification savedNotification = notificationRepository.save(notification); + unreadCountCache.invalidate(command.getUserId()); + createPendingDeliveriesWithOutbox( savedNotification.getId(), command.getKind(), @@ -112,15 +116,19 @@ private String generateIdempotencyKey(String eventId, NotificationKind kind, public void markAsRead(Long userId, UUID notificationId) { Notification notification = findNotificationForUser(userId, notificationId); notification.markAsRead(); + unreadCountCache.invalidate(userId); } public int markAllAsRead(Long userId) { - return notificationRepository.markAllAsRead(userId); + int count = notificationRepository.markAllAsRead(userId); + unreadCountCache.invalidate(userId); + return count; } public void softDelete(Long userId, UUID notificationId) { Notification notification = findNotificationForUser(userId, notificationId); notification.softDelete(); + unreadCountCache.invalidate(userId); } private Notification findNotificationForUser(Long userId, UUID notificationId) { diff --git a/src/main/java/com/example/RealMatch/notification/infrastructure/redis/NotificationUnreadCountCache.java b/src/main/java/com/example/RealMatch/notification/infrastructure/redis/NotificationUnreadCountCache.java new file mode 100644 index 00000000..e9a0351e --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/infrastructure/redis/NotificationUnreadCountCache.java @@ -0,0 +1,70 @@ +package com.example.RealMatch.notification.infrastructure.redis; + +import java.time.Duration; +import java.util.OptionalLong; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +/** + * 미읽음 알림 개수 조회 성능 최적화를 위한 Redis 캐시. + *

캐시 키: notification:unread:{userId} + *

캐시 무효화: 알림 생성, 읽음 처리, 전체 읽기, 소프트 삭제 시 호출 + *

Redis 장애 시 DB 조회로 폴백 + */ +@Component +@RequiredArgsConstructor +public class NotificationUnreadCountCache { + + private static final Logger LOG = LoggerFactory.getLogger(NotificationUnreadCountCache.class); + private static final String KEY_PREFIX = "notification:unread:"; + private static final Duration TTL = Duration.ofMinutes(10); + + private final StringRedisTemplate redisTemplate; + + public OptionalLong get(Long userId) { + if (userId == null) { + return OptionalLong.empty(); + } + try { + String key = KEY_PREFIX + userId; + String value = redisTemplate.opsForValue().get(key); + if (value == null) { + return OptionalLong.empty(); + } + return OptionalLong.of(Long.parseLong(value)); + } catch (NumberFormatException e) { + invalidate(userId); + return OptionalLong.empty(); + } catch (Exception e) { + LOG.warn("[UnreadCountCache] Redis get failed, fallback to DB. userId={}", userId, e); + return OptionalLong.empty(); + } + } + + public void set(Long userId, long count) { + if (userId == null) { + return; + } + try { + redisTemplate.opsForValue().set(KEY_PREFIX + userId, String.valueOf(count), TTL); + } catch (Exception e) { + LOG.warn("[UnreadCountCache] Redis set failed. userId={}", userId, e); + } + } + + public void invalidate(Long userId) { + if (userId == null) { + return; + } + try { + redisTemplate.delete(KEY_PREFIX + userId); + } catch (Exception e) { + LOG.warn("[UnreadCountCache] Redis invalidate failed. userId={}", userId, e); + } + } +} From 1aaa02ce03995498cbefdbccd36d137896ae3434 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Fri, 20 Feb 2026 02:11:25 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(#403):=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationService.java | 10 ++++---- .../redis/NotificationUnreadCountCache.java | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/example/RealMatch/notification/application/service/NotificationService.java b/src/main/java/com/example/RealMatch/notification/application/service/NotificationService.java index 47f6186b..b4750a25 100644 --- a/src/main/java/com/example/RealMatch/notification/application/service/NotificationService.java +++ b/src/main/java/com/example/RealMatch/notification/application/service/NotificationService.java @@ -60,14 +60,14 @@ public Notification create(CreateNotificationCommand command) { Notification savedNotification = notificationRepository.save(notification); - unreadCountCache.invalidate(command.getUserId()); - createPendingDeliveriesWithOutbox( savedNotification.getId(), command.getKind(), command.getEventId(), command.getUserId()); + unreadCountCache.invalidateAfterCommit(command.getUserId()); + return savedNotification; } @@ -116,19 +116,19 @@ private String generateIdempotencyKey(String eventId, NotificationKind kind, public void markAsRead(Long userId, UUID notificationId) { Notification notification = findNotificationForUser(userId, notificationId); notification.markAsRead(); - unreadCountCache.invalidate(userId); + unreadCountCache.invalidateAfterCommit(userId); } public int markAllAsRead(Long userId) { int count = notificationRepository.markAllAsRead(userId); - unreadCountCache.invalidate(userId); + unreadCountCache.invalidateAfterCommit(userId); return count; } public void softDelete(Long userId, UUID notificationId) { Notification notification = findNotificationForUser(userId, notificationId); notification.softDelete(); - unreadCountCache.invalidate(userId); + unreadCountCache.invalidateAfterCommit(userId); } private Notification findNotificationForUser(Long userId, UUID notificationId) { diff --git a/src/main/java/com/example/RealMatch/notification/infrastructure/redis/NotificationUnreadCountCache.java b/src/main/java/com/example/RealMatch/notification/infrastructure/redis/NotificationUnreadCountCache.java index e9a0351e..16108cc3 100644 --- a/src/main/java/com/example/RealMatch/notification/infrastructure/redis/NotificationUnreadCountCache.java +++ b/src/main/java/com/example/RealMatch/notification/infrastructure/redis/NotificationUnreadCountCache.java @@ -7,6 +7,8 @@ import org.slf4j.LoggerFactory; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import lombok.RequiredArgsConstructor; @@ -67,4 +69,27 @@ public void invalidate(Long userId) { LOG.warn("[UnreadCountCache] Redis invalidate failed. userId={}", userId, e); } } + + /** + * 트랜잭션 커밋 완료 후 캐시를 무효화합니다. + *

커밋 이전 무효화 시 다른 스레드가 아직 커밋되지 않은 DB 값을 읽어 + * 캐시에 저장하는 레이스 컨디션을 방지합니다. + */ + public void invalidateAfterCommit(Long userId) { + if (userId == null) { + return; + } + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + invalidate(userId); + } + } + ); + } else { + invalidate(userId); + } + } }