diff --git a/docs/04-experiment.md b/docs/04-experiment.md
index f088062..7993b9f 100644
--- a/docs/04-experiment.md
+++ b/docs/04-experiment.md
@@ -264,6 +264,76 @@ Queue → Consumer 10개
Concurrency 실험에서는 Prefetch를 250으로 고정하고 30 VU로 측정했으며,
Prefetch 실험에서는 Concurrency를 5로 고정하고 50 VU로 측정했다.
+### Experiment 4. Redis Preference Cache
+
+Notification 생성 시 사용자의 채널별 알림 허용 여부를 확인하기 위해
+매 요청마다 MySQL의 `notification_preferences` 테이블을 조회하고 있었다.
+
+동일 사용자에게 반복적으로 알림이 발생하는 경우에도 Preference는 자주 변경되지 않기 때문에
+동일한 DB 조회가 반복되는 문제가 있었다.
+
+이에 사용자별 활성화된 알림 채널을 Redis에 저장하는 Cache-Aside 구조를 적용했다.
+
+```text
+기존
+
+Notification 요청
+ ↓
+MySQL Preference 조회
+ ↓
+Delivery 생성
+```
+
+```text
+Redis Cache 적용
+
+Notification 요청
+ ↓
+Redis Preference 조회
+ ├─ HIT → 활성 Channel 사용
+ │
+ └─ MISS
+ ↓
+ MySQL 조회
+ ↓
+ Redis 저장
+```
+Redis Key는 사용자 단위로 구성했다.
+```text
+notification:preference:{userId}
+```
+Value에는 사용자가 활성화한 전체 Channel을 저장한다.
+```json
+{
+ "enabledChannels": ["PUSH", "EMAIL"]
+}
+```
+TTL은 30분으로 설정했다.
+Preference 변경 시에는 DB를 먼저 갱신한 뒤 Redis Cache를 삭제한다.
+```text
+Preference 변경
+ ↓
+DB UPDATE + COMMIT
+ ↓
+Redis Cache Evict
+ ↓
+다음 Notification 요청
+ ↓
+Cache MISS
+ ↓
+DB 최신 Preference 조회
+ ↓
+Redis Cache 재구성
+```
+Redis 장애 시에는 Cache 조회와 저장 실패가 Notification 요청 실패로 전파되지 않도록
+MySQL을 기준 데이터로 사용하는 DB Fallback 구조로 적용했다.
+
+성능 실험에서는 동일한 `userId`를 사용하면서
+각 요청마다 새로운 `eventID`를 생성했다.
+
+이는 동일 `eventId` 사용 시 앞서 구현한 Dedup Cache가 요청을 먼저 처리하여
+Preference 조회 로직 자체가 실행되지 않는 것을 방지하기 위함이다.
+
## 11. 개선 후 결과
### Experiment 1. Transaction Boundary 분리 결과
@@ -413,6 +483,26 @@ Worker 간 메시지 분배의 유연성도 낮아질 수 있다.
Prefetch 250
+### Experiment 4. Redis Preference Cache 결과
+
+30 VU, 30초 동안 동일 사용자에게 새로운 `eventId`로 알림을 반복 생성했다.
+
+| 지표 | DB Preference 조회 | Redis Preference Cache | 변화 |
+|---|---:|---:|---:|
+| RPS | 180.51 | 513.69 | 2.85배 |
+| Avg | 164.96ms | 58.02ms | 64.8% 감소 |
+| Median | 104.91ms | 47.95ms | 54.3% 감소 |
+| p90 | 358.47ms | 99.91ms | 72.1% 감소 |
+| p95 | 455.88ms | 129.45ms | 71.6% 감소 |
+| Error Rate | 0% | 0% | 동일 |
+
+Redis Cache 적용 후 RPS는 180.51에서 513.69로 약 2.85배 증가했다.
+
+평균 응답 시간은 164.96ms에서 58.02ms로 약 64.8% 감소했으며,
+p95는 455.88ms에서 129.45ms로 약 71.6% 감소했다.
+
+두 실험 모두 HTTP 요청 실패는 발생하지 않았다.
+
## 12. 결과 분석
### 12.1 DB Connection Pool 병목 확인
@@ -560,6 +650,38 @@ Consumer 처리량 향상으로 해석하지 않았으며,
동일 프로세스의 CPU 및 DB Connection Pool 자원 경합과
로컬 테스트 환경의 변동이 영향을 주었을 가능성이 있다.
+### 12.8 Redis Preference Cache 적용 효과
+
+Cache 미적용 구조에서는 새로운 Notification 요청마다
+사용자의 활성화된 알림 Channel을 MySQL에서 조회했다.
+
+Preference는 알림 요청 빈도에 비해 변경 빈도가 낮은 데이터이므로,
+동일 사용자에게 알림 요청이 반복될수록 동일한 SELECT가 계속 발생했다.
+
+Redis Cache-Aside 적용 후에는 최초 Cache MISS 요청에서만 DB를 조회하고,
+이후 요청에서는 Redis에 저장된 활성 Channel을 사용했다.
+
+30 VU 테스트에서 RPS는 180.51에서 513.69로 약 2.85배 증가했고,
+p95는 455.88ms에서 129.45ms로 약 71.6% 감소했다.
+
+단, Redis Cache가 Notification 생성 자체를 제거하느 ㄴ것은 아니다.
+
+각 요청마다 여전히 다음 작업은 수행된다.
+
+- Notification INSERT
+- Delivery INSERT
+- Outbox INSERT
+
+따라서 이번 개선은 알림 생성 자체를 캐싱한 것이 아니라,
+반복적으로 발생하던 Preference 조회 경로를 Redis로 전환하여
+MySQL 조회 부하와 Connection 점유를 줄인 결과로 해석했다.
+
+또한 Preference 변경 시에는 DB 갱신 이후 Cache를 삭제하여
+다음 요청에서 최신 DB 값을 다시 Cache에 저장하도록 했다.
+
+Redis 장애 시에는 DB Fallback을 수행하므로
+Cache 장애가 Notification API 실패로 직접 전파되지 않도록 구성했다.
+
## 13. Platform Thread와 Virtual Thread 비교
실제 블로킹 I/O가 존재하는 경우 반드시 수행한다.
diff --git a/k6/notification-preference-cache.js b/k6/notification-preference-cache.js
new file mode 100644
index 0000000..9f874db
--- /dev/null
+++ b/k6/notification-preference-cache.js
@@ -0,0 +1,36 @@
+import http from 'k6/http';
+import { check } from 'k6';
+
+export const options = {
+ vus: 30,
+ duration: '30s',
+};
+
+export default function () {
+
+ // 모든 요청마다 새로운 eventId
+ const eventId =
+ `preference-perf-${__VU}-${__ITER}-${Date.now()}`;
+
+ const payload = JSON.stringify({
+ eventId: eventId,
+ userId: 1,
+ channels: ['EMAIL'],
+ });
+
+ const params = {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ };
+
+ const response = http.post(
+ 'http://localhost:8080/api/v1/notifications',
+ payload,
+ params
+ );
+
+ check(response, {
+ 'status is 202': (r) => r.status === 202,
+ });
+}
\ No newline at end of file
diff --git a/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationTransactionService.java b/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationTransactionService.java
index d55161c..c4fa4c3 100644
--- a/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationTransactionService.java
+++ b/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationTransactionService.java
@@ -11,11 +11,11 @@
import com.backendsystemdesignlab.notification.notification.repository.NotificationRepository;
import com.backendsystemdesignlab.notification.outbox.OutboxEvent;
import com.backendsystemdesignlab.notification.outbox.OutboxEventRepository;
+import com.backendsystemdesignlab.notification.preference.NotificationPreferenceService;
import com.backendsystemdesignlab.notification.user.domain.NotificationChannel;
import com.backendsystemdesignlab.notification.user.domain.NotificationPreference;
import com.backendsystemdesignlab.notification.user.domain.User;
import com.backendsystemdesignlab.notification.user.domain.UserDevice;
-import com.backendsystemdesignlab.notification.user.repository.NotificationPreferenceRepository;
import com.backendsystemdesignlab.notification.user.repository.UserDeviceRepository;
import com.backendsystemdesignlab.notification.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
@@ -31,7 +31,7 @@ public class NotificationTransactionService {
private final UserRepository userRepository;
private final UserDeviceRepository userDeviceRepository;
- private final NotificationPreferenceRepository preferenceRepository;
+ private final NotificationPreferenceService preferenceService;
private final NotificationRepository notificationRepository;
private final NotificationDeliveryRepository deliveryRepository;
private final OutboxEventRepository outboxEventRepository;
@@ -55,10 +55,7 @@ public PreparedNotification prepare(SendNotificationRequest request) {
User user = userRepository.findById(request.userId())
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
- Set enabledChannels = preferenceRepository.findAllByUserIdAndEnabledTrue(user.getId())
- .stream()
- .map(NotificationPreference::getChannel)
- .collect(Collectors.toSet());
+ Set enabledChannels = preferenceService.getEnabledChannels(user.getId());
Notification notification = notificationRepository.save(new Notification(request.eventId(), user));
diff --git a/src/main/java/com/backendsystemdesignlab/notification/preference/NotificationPreferenceCache.java b/src/main/java/com/backendsystemdesignlab/notification/preference/NotificationPreferenceCache.java
new file mode 100644
index 0000000..2c29af1
--- /dev/null
+++ b/src/main/java/com/backendsystemdesignlab/notification/preference/NotificationPreferenceCache.java
@@ -0,0 +1,69 @@
+package com.backendsystemdesignlab.notification.preference;
+
+import com.backendsystemdesignlab.notification.user.domain.NotificationChannel;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+import tools.jackson.core.JacksonException;
+import tools.jackson.databind.ObjectMapper;
+
+import java.time.Duration;
+import java.util.Optional;
+import java.util.Set;
+
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class NotificationPreferenceCache {
+
+ private static final String KEY_PREFIX = "notification:preference:";
+ private static final Duration TTL = Duration.ofMinutes(30);
+ private final StringRedisTemplate redisTemplate;
+ private final ObjectMapper objectMapper;
+
+ public Optional> find(Long userId) {
+
+ try {
+ String value = redisTemplate.opsForValue().get(KEY_PREFIX + userId);
+
+ if (value == null) {
+ log.debug("[Preference Cache MISS] userId={}", userId);
+ return Optional.empty();
+ }
+
+ PreferenceCacheValue cached = objectMapper.readValue(value, PreferenceCacheValue.class);
+ log.debug("[Preference Cache HIT] userId={}", userId);
+ return Optional.of(cached.enabledChannels());
+ } catch (DataAccessException e) {
+ log.warn("[Preference Cache ERROR] Redis 조회 실패. DB Fallback. userId={}", userId);
+ return Optional.empty();
+ } catch (JacksonException e) {
+ log.warn("[Preference Cache ERROR] 역직렬화 실패. DB Fallback. userId={}", userId);
+ return Optional.empty();
+ }
+ }
+
+ public void save(Long userId, Set enabledChannels) {
+
+ try {
+ PreferenceCacheValue value = new PreferenceCacheValue(enabledChannels);
+ String json = objectMapper.writeValueAsString(value);
+ redisTemplate.opsForValue().set(KEY_PREFIX + userId, json, TTL);
+ } catch (DataAccessException e) {
+ log.warn("[Preference Cache ERROR] Redis 저장 실패. userId={}", userId);
+ } catch (JacksonException e) {
+ log.warn("[Preference Cache ERROR] 직렬화 실패. userId={}", userId);
+ }
+ }
+
+ public void evict(Long userId) {
+
+ try {
+ redisTemplate.delete(KEY_PREFIX + userId);
+ } catch (DataAccessException e) {
+ log.warn("[Preference Cache] Redis 삭제 실패. userId={}", userId);
+ }
+ }
+}
diff --git a/src/main/java/com/backendsystemdesignlab/notification/preference/NotificationPreferenceController.java b/src/main/java/com/backendsystemdesignlab/notification/preference/NotificationPreferenceController.java
new file mode 100644
index 0000000..ede1343
--- /dev/null
+++ b/src/main/java/com/backendsystemdesignlab/notification/preference/NotificationPreferenceController.java
@@ -0,0 +1,20 @@
+package com.backendsystemdesignlab.notification.preference;
+
+import com.backendsystemdesignlab.notification.user.domain.NotificationChannel;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/users")
+public class NotificationPreferenceController {
+
+ private final NotificationPreferenceService preferenceService;
+
+ @PatchMapping("/{userId}/notification-preferences/{channel}")
+ public ResponseEntity update(@PathVariable Long userId, @PathVariable NotificationChannel channel, @RequestBody UpdatePreferenceRequest request) {
+ preferenceService.updatePreference(userId, channel, request.enabled());
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/src/main/java/com/backendsystemdesignlab/notification/preference/NotificationPreferenceService.java b/src/main/java/com/backendsystemdesignlab/notification/preference/NotificationPreferenceService.java
new file mode 100644
index 0000000..8756350
--- /dev/null
+++ b/src/main/java/com/backendsystemdesignlab/notification/preference/NotificationPreferenceService.java
@@ -0,0 +1,41 @@
+package com.backendsystemdesignlab.notification.preference;
+
+import com.backendsystemdesignlab.notification.user.domain.NotificationChannel;
+import com.backendsystemdesignlab.notification.user.domain.NotificationPreference;
+import com.backendsystemdesignlab.notification.user.repository.NotificationPreferenceRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class NotificationPreferenceService {
+
+ private final NotificationPreferenceRepository preferenceRepository;
+ private final NotificationPreferenceCache preferenceCache;
+ private final NotificationPreferenceTransactionService transactionService;
+
+ public Set getEnabledChannels(Long userId) {
+ var cached = preferenceCache.find(userId);
+
+ if (cached.isPresent()) {
+ return cached.get();
+ }
+
+ // DB Fallback
+ Set enabledChannels = preferenceRepository.findAllByUserIdAndEnabledTrue(userId)
+ .stream()
+ .map(NotificationPreference::getChannel)
+ .collect(Collectors.toSet());
+
+ preferenceCache.save(userId, enabledChannels);
+ return enabledChannels;
+ }
+
+ public void updatePreference(Long userId, NotificationChannel channel, boolean enabled) {
+ transactionService.update(userId, channel, enabled);
+ preferenceCache.evict(userId);
+ }
+}
diff --git a/src/main/java/com/backendsystemdesignlab/notification/preference/NotificationPreferenceTransactionService.java b/src/main/java/com/backendsystemdesignlab/notification/preference/NotificationPreferenceTransactionService.java
new file mode 100644
index 0000000..a1e9ce3
--- /dev/null
+++ b/src/main/java/com/backendsystemdesignlab/notification/preference/NotificationPreferenceTransactionService.java
@@ -0,0 +1,22 @@
+package com.backendsystemdesignlab.notification.preference;
+
+import com.backendsystemdesignlab.notification.user.domain.NotificationChannel;
+import com.backendsystemdesignlab.notification.user.domain.NotificationPreference;
+import com.backendsystemdesignlab.notification.user.repository.NotificationPreferenceRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class NotificationPreferenceTransactionService {
+
+ private final NotificationPreferenceRepository preferenceRepository;
+
+ @Transactional
+ public void update(Long userId, NotificationChannel channel, boolean enabled) {
+ NotificationPreference preference = preferenceRepository.findByUserIdAndChannel(userId, channel)
+ .orElseThrow(() -> new IllegalArgumentException("알림 설정을 찾을 수 없습니다."));
+ preference.changeEnabled(enabled);
+ }
+}
diff --git a/src/main/java/com/backendsystemdesignlab/notification/preference/PreferenceCacheValue.java b/src/main/java/com/backendsystemdesignlab/notification/preference/PreferenceCacheValue.java
new file mode 100644
index 0000000..4d366c4
--- /dev/null
+++ b/src/main/java/com/backendsystemdesignlab/notification/preference/PreferenceCacheValue.java
@@ -0,0 +1,10 @@
+package com.backendsystemdesignlab.notification.preference;
+
+import com.backendsystemdesignlab.notification.user.domain.NotificationChannel;
+
+import java.util.Set;
+
+public record PreferenceCacheValue(
+ Set enabledChannels
+) {
+}
diff --git a/src/main/java/com/backendsystemdesignlab/notification/preference/UpdatePreferenceRequest.java b/src/main/java/com/backendsystemdesignlab/notification/preference/UpdatePreferenceRequest.java
new file mode 100644
index 0000000..cd00d43
--- /dev/null
+++ b/src/main/java/com/backendsystemdesignlab/notification/preference/UpdatePreferenceRequest.java
@@ -0,0 +1,6 @@
+package com.backendsystemdesignlab.notification.preference;
+
+public record UpdatePreferenceRequest(
+ boolean enabled
+) {
+}
diff --git a/src/main/java/com/backendsystemdesignlab/notification/user/domain/NotificationPreference.java b/src/main/java/com/backendsystemdesignlab/notification/user/domain/NotificationPreference.java
index 04b6d12..df5e9ab 100644
--- a/src/main/java/com/backendsystemdesignlab/notification/user/domain/NotificationPreference.java
+++ b/src/main/java/com/backendsystemdesignlab/notification/user/domain/NotificationPreference.java
@@ -52,4 +52,8 @@ public NotificationChannel getChannel() {
public boolean isEnabled() {
return enabled;
}
+
+ public void changeEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
}
diff --git a/src/main/java/com/backendsystemdesignlab/notification/user/repository/NotificationPreferenceRepository.java b/src/main/java/com/backendsystemdesignlab/notification/user/repository/NotificationPreferenceRepository.java
index 98f93ac..d6a1451 100644
--- a/src/main/java/com/backendsystemdesignlab/notification/user/repository/NotificationPreferenceRepository.java
+++ b/src/main/java/com/backendsystemdesignlab/notification/user/repository/NotificationPreferenceRepository.java
@@ -1,10 +1,13 @@
package com.backendsystemdesignlab.notification.user.repository;
+import com.backendsystemdesignlab.notification.user.domain.NotificationChannel;
import com.backendsystemdesignlab.notification.user.domain.NotificationPreference;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
+import java.util.Optional;
public interface NotificationPreferenceRepository extends JpaRepository {
List findAllByUserIdAndEnabledTrue(Long userId);
+ Optional findByUserIdAndChannel(Long userId, NotificationChannel channel);
}