Skip to content

Commit 2f550ea

Browse files
authored
[REFACTOR] 알림 조회 redis 캐싱 추가 (#404)
<!-- ## PR 제목 컨벤션 [TYPE] 설명 (#이슈번호) 예시: - [FEAT] 회원가입 API 구현 (#14) - [FIX] 이미지 업로드 시 NPE 수정 (#23) - [REFACTOR] 토큰 로직 분리 (#8) - [DOCS] ERD 스키마 업데이트 (#6) - [CHORE] CI/CD 파이프라인 추가 (#3) - [RELEASE] v1.0.0 배포 (#30) TYPE: FEAT, FIX, DOCS, REFACTOR, TEST, CHORE, RENAME, REMOVE, RELEASE --> ## Summary <!-- 변경 사항을 간단히 설명해주세요 --> 미읽음 알림 개수 조회 API(GET /api/v1/notifications/unread-count) 성능 개선을 위해 Redis 캐시를 적용했습니다. ## Changes <!-- 변경된 내용을 목록으로 작성해주세요 --> - NotificationUnreadCountCache 컴포넌트 추가 (캐시 키: notification:unread:{userId}, TTL: 10분) - NotificationQueryService.getUnreadCount(): 캐시 조회 → 미스 시 DB 조회 후 캐시 저장 - NotificationQueryService.getNotifications(): unread count를 getUnreadCount()로 조회해 캐시 재사용 - NotificationService: 알림 생성, 읽음 처리, 전체 읽기, 소프트 삭제 시 캐시 무효화 - Redis 장애 시 DB 조회로 폴백 (예외 처리 추가) ## Type of Change <!-- 해당하는 항목에 x 표시해주세요 --> - [ ] Bug fix (기존 기능에 영향을 주지 않는 버그 수정) - [ ] New feature (기존 기능에 영향을 주지 않는 새로운 기능 추가) - [ ] Breaking change (기존 기능에 영향을 주는 수정) - [x] Refactoring (기능 변경 없는 코드 개선) - [ ] Documentation (문서 수정) - [ ] Chore (빌드, 설정 등 기타 변경) - [ ] Release (develop → main 배포) ## Related Issues <!-- 관련 이슈 번호를 작성해주세요 (예: Closes #123, Fixes #456) --> Closes #403 ## 참고 사항 <!-- 리뷰어가 알아야 할 추가 정보가 있다면 작성해주세요 -->
1 parent eae2bec commit 2f550ea

3 files changed

Lines changed: 113 additions & 3 deletions

File tree

src/main/java/com/example/RealMatch/notification/application/service/NotificationQueryService.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import com.example.RealMatch.notification.domain.entity.enums.NotificationKind;
1919
import com.example.RealMatch.notification.domain.repository.NotificationRepository;
2020
import com.example.RealMatch.notification.exception.NotificationErrorCode;
21+
import com.example.RealMatch.notification.infrastructure.redis.NotificationUnreadCountCache;
2122
import com.example.RealMatch.notification.presentation.dto.response.NotificationDateGroup;
2223
import com.example.RealMatch.notification.presentation.dto.response.NotificationListResponse;
2324
import com.example.RealMatch.notification.presentation.dto.response.NotificationResponse;
@@ -30,6 +31,7 @@
3031
public class NotificationQueryService {
3132

3233
private final NotificationRepository notificationRepository;
34+
private final NotificationUnreadCountCache unreadCountCache;
3335

3436
private static final DateTimeFormatter DATE_LABEL_FORMATTER =
3537
DateTimeFormatter.ofPattern("yy.MM.dd (E)", Locale.KOREAN);
@@ -52,7 +54,7 @@ public NotificationListResponse getNotifications(Long userId, String filter, int
5254

5355
List<NotificationDateGroup> groups = buildDateGroups(notificationPage.getContent());
5456

55-
long unreadCount = notificationRepository.countUnreadByUserId(userId);
57+
long unreadCount = getUnreadCount(userId);
5658

5759
return new NotificationListResponse(
5860
items,
@@ -66,7 +68,12 @@ public NotificationListResponse getNotifications(Long userId, String filter, int
6668
}
6769

6870
public long getUnreadCount(Long userId) {
69-
return notificationRepository.countUnreadByUserId(userId);
71+
return unreadCountCache.get(userId)
72+
.orElseGet(() -> {
73+
long count = notificationRepository.countUnreadByUserId(userId);
74+
unreadCountCache.set(userId, count);
75+
return count;
76+
});
7077
}
7178

7279
private List<NotificationKind> resolveKinds(String filter) {

src/main/java/com/example/RealMatch/notification/application/service/NotificationService.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import com.example.RealMatch.notification.domain.repository.NotificationOutboxRepository;
2323
import com.example.RealMatch.notification.domain.repository.NotificationRepository;
2424
import com.example.RealMatch.notification.exception.NotificationErrorCode;
25+
import com.example.RealMatch.notification.infrastructure.redis.NotificationUnreadCountCache;
2526
import com.example.RealMatch.user.domain.entity.enums.NotificationChannel;
2627

2728
import lombok.RequiredArgsConstructor;
@@ -42,6 +43,7 @@ public class NotificationService {
4243
private final NotificationDeliveryRepository notificationDeliveryRepository;
4344
private final NotificationOutboxRepository notificationOutboxRepository;
4445
private final NotificationChannelResolver channelResolver;
46+
private final NotificationUnreadCountCache unreadCountCache;
4547

4648
@Transactional(propagation = Propagation.REQUIRES_NEW)
4749
public Notification create(CreateNotificationCommand command) {
@@ -64,6 +66,8 @@ public Notification create(CreateNotificationCommand command) {
6466
command.getEventId(),
6567
command.getUserId());
6668

69+
unreadCountCache.invalidateAfterCommit(command.getUserId());
70+
6771
return savedNotification;
6872
}
6973

@@ -112,15 +116,19 @@ private String generateIdempotencyKey(String eventId, NotificationKind kind,
112116
public void markAsRead(Long userId, UUID notificationId) {
113117
Notification notification = findNotificationForUser(userId, notificationId);
114118
notification.markAsRead();
119+
unreadCountCache.invalidateAfterCommit(userId);
115120
}
116121

117122
public int markAllAsRead(Long userId) {
118-
return notificationRepository.markAllAsRead(userId);
123+
int count = notificationRepository.markAllAsRead(userId);
124+
unreadCountCache.invalidateAfterCommit(userId);
125+
return count;
119126
}
120127

121128
public void softDelete(Long userId, UUID notificationId) {
122129
Notification notification = findNotificationForUser(userId, notificationId);
123130
notification.softDelete();
131+
unreadCountCache.invalidateAfterCommit(userId);
124132
}
125133

126134
private Notification findNotificationForUser(Long userId, UUID notificationId) {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.example.RealMatch.notification.infrastructure.redis;
2+
3+
import java.time.Duration;
4+
import java.util.OptionalLong;
5+
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
import org.springframework.data.redis.core.StringRedisTemplate;
9+
import org.springframework.stereotype.Component;
10+
import org.springframework.transaction.support.TransactionSynchronization;
11+
import org.springframework.transaction.support.TransactionSynchronizationManager;
12+
13+
import lombok.RequiredArgsConstructor;
14+
15+
/**
16+
* 미읽음 알림 개수 조회 성능 최적화를 위한 Redis 캐시.
17+
* <p>캐시 키: notification:unread:{userId}
18+
* <p>캐시 무효화: 알림 생성, 읽음 처리, 전체 읽기, 소프트 삭제 시 호출
19+
* <p>Redis 장애 시 DB 조회로 폴백
20+
*/
21+
@Component
22+
@RequiredArgsConstructor
23+
public class NotificationUnreadCountCache {
24+
25+
private static final Logger LOG = LoggerFactory.getLogger(NotificationUnreadCountCache.class);
26+
private static final String KEY_PREFIX = "notification:unread:";
27+
private static final Duration TTL = Duration.ofMinutes(10);
28+
29+
private final StringRedisTemplate redisTemplate;
30+
31+
public OptionalLong get(Long userId) {
32+
if (userId == null) {
33+
return OptionalLong.empty();
34+
}
35+
try {
36+
String key = KEY_PREFIX + userId;
37+
String value = redisTemplate.opsForValue().get(key);
38+
if (value == null) {
39+
return OptionalLong.empty();
40+
}
41+
return OptionalLong.of(Long.parseLong(value));
42+
} catch (NumberFormatException e) {
43+
invalidate(userId);
44+
return OptionalLong.empty();
45+
} catch (Exception e) {
46+
LOG.warn("[UnreadCountCache] Redis get failed, fallback to DB. userId={}", userId, e);
47+
return OptionalLong.empty();
48+
}
49+
}
50+
51+
public void set(Long userId, long count) {
52+
if (userId == null) {
53+
return;
54+
}
55+
try {
56+
redisTemplate.opsForValue().set(KEY_PREFIX + userId, String.valueOf(count), TTL);
57+
} catch (Exception e) {
58+
LOG.warn("[UnreadCountCache] Redis set failed. userId={}", userId, e);
59+
}
60+
}
61+
62+
public void invalidate(Long userId) {
63+
if (userId == null) {
64+
return;
65+
}
66+
try {
67+
redisTemplate.delete(KEY_PREFIX + userId);
68+
} catch (Exception e) {
69+
LOG.warn("[UnreadCountCache] Redis invalidate failed. userId={}", userId, e);
70+
}
71+
}
72+
73+
/**
74+
* 트랜잭션 커밋 완료 후 캐시를 무효화합니다.
75+
* <p>커밋 이전 무효화 시 다른 스레드가 아직 커밋되지 않은 DB 값을 읽어
76+
* 캐시에 저장하는 레이스 컨디션을 방지합니다.
77+
*/
78+
public void invalidateAfterCommit(Long userId) {
79+
if (userId == null) {
80+
return;
81+
}
82+
if (TransactionSynchronizationManager.isSynchronizationActive()) {
83+
TransactionSynchronizationManager.registerSynchronization(
84+
new TransactionSynchronization() {
85+
@Override
86+
public void afterCommit() {
87+
invalidate(userId);
88+
}
89+
}
90+
);
91+
} else {
92+
invalidate(userId);
93+
}
94+
}
95+
}

0 commit comments

Comments
 (0)