Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions docs/04-experiment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 분리 결과
Expand Down Expand Up @@ -413,6 +483,26 @@ Worker 간 메시지 분배의 유연성도 낮아질 수 있다.
<p>Prefetch 250</p>
<img src="./images/prefetch-250.png">

### 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 병목 확인
Expand Down Expand Up @@ -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가 존재하는 경우 반드시 수행한다.
Expand Down
36 changes: 36 additions & 0 deletions k6/notification-preference-cache.js
Original file line number Diff line number Diff line change
@@ -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,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -55,10 +55,7 @@ public PreparedNotification prepare(SendNotificationRequest request) {
User user = userRepository.findById(request.userId())
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

Set<NotificationChannel> enabledChannels = preferenceRepository.findAllByUserIdAndEnabledTrue(user.getId())
.stream()
.map(NotificationPreference::getChannel)
.collect(Collectors.toSet());
Set<NotificationChannel> enabledChannels = preferenceService.getEnabledChannels(user.getId());

Notification notification = notificationRepository.save(new Notification(request.eventId(), user));

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Set<NotificationChannel>> 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<NotificationChannel> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Void> update(@PathVariable Long userId, @PathVariable NotificationChannel channel, @RequestBody UpdatePreferenceRequest request) {
preferenceService.updatePreference(userId, channel, request.enabled());
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -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<NotificationChannel> getEnabledChannels(Long userId) {
var cached = preferenceCache.find(userId);

if (cached.isPresent()) {
return cached.get();
}

// DB Fallback
Set<NotificationChannel> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.backendsystemdesignlab.notification.preference;

import com.backendsystemdesignlab.notification.user.domain.NotificationChannel;

import java.util.Set;

public record PreferenceCacheValue(
Set<NotificationChannel> enabledChannels
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.backendsystemdesignlab.notification.preference;

public record UpdatePreferenceRequest(
boolean enabled
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,8 @@ public NotificationChannel getChannel() {
public boolean isEnabled() {
return enabled;
}

public void changeEnabled(boolean enabled) {
this.enabled = enabled;
}
}
Loading