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..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 @@ -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) { @@ -64,6 +66,8 @@ public Notification create(CreateNotificationCommand command) { command.getEventId(), command.getUserId()); + unreadCountCache.invalidateAfterCommit(command.getUserId()); + return savedNotification; } @@ -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.invalidateAfterCommit(userId); } public int markAllAsRead(Long userId) { - return notificationRepository.markAllAsRead(userId); + int count = notificationRepository.markAllAsRead(userId); + unreadCountCache.invalidateAfterCommit(userId); + return count; } public void softDelete(Long userId, UUID notificationId) { Notification notification = findNotificationForUser(userId, notificationId); notification.softDelete(); + 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 new file mode 100644 index 00000000..16108cc3 --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/infrastructure/redis/NotificationUnreadCountCache.java @@ -0,0 +1,95 @@ +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 org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +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); + } + } + + /** + * 트랜잭션 커밋 완료 후 캐시를 무효화합니다. + *

커밋 이전 무효화 시 다른 스레드가 아직 커밋되지 않은 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); + } + } +}