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
33 changes: 33 additions & 0 deletions k6/notification-dedup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import http from 'k6/http';
import { check } from 'k6';

export const options = {
vus: 30,
duration: '30s',
};

const BASE_URL = 'http://localhost:8080';

export default function () {

// 모든 요청이 동일한 eventId
const payload = JSON.stringify({
eventId: 'dedup-performance-test',
userId: 1,
channels: ['SMS'],
});

const res = http.post(
`${BASE_URL}/api/v1/notifications`,
payload,
{
headers: {
'Content-Type': 'application/json',
},
}
);

check(res, {
'status is 202': (r) => r.status === 202,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.backendsystemdesignlab.notification.dedup;

import com.backendsystemdesignlab.notification.notification.dto.SendNotificationResponse;
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;

@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationDedupCache {

private static final String KEY_PREFIX = "notification:dedup:";
private static final Duration TTL = Duration.ofHours(24);

private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;

public Optional<SendNotificationResponse> find(String eventId) {

try {
String value = redisTemplate.opsForValue().get(KEY_PREFIX + eventId);

if (value == null) {
log.debug("[Dedup MISS] eventId={}", eventId);
return Optional.empty();
}

log.debug("[Dedup HIT] eventId={}", eventId);

return Optional.of(
objectMapper.readValue(
value,
SendNotificationResponse.class
)
);
} catch (DataAccessException e) {
log.warn("[Dedup] Redis 조회 실패. DB로 fallback. eventId={}", eventId);
return Optional.empty();
} catch (JacksonException e) {
log.warn("[Dedup] Redis 데이터 역직렬화 실패. eventId={}", eventId);
return Optional.empty();
}
}

public void save(String eventId, SendNotificationResponse response) {

try {
String value = objectMapper.writeValueAsString(response);
log.debug("[Dedup SAVE] eventId={}", eventId);
redisTemplate.opsForValue().set(KEY_PREFIX + eventId, value, TTL);
} catch (DataAccessException e) {
log.warn("[Dedup] Redis 저장 실패. 캐시 없이 계속 진행. eventId={}", eventId);
} catch (JacksonException e) {
log.warn("[Dedup] 응답 직렬화 실패. eventId={}", eventId);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,40 +1,57 @@
package com.backendsystemdesignlab.notification.notification.service;

import com.backendsystemdesignlab.notification.messaging.DeliveryPublisher;
import com.backendsystemdesignlab.notification.dedup.NotificationDedupCache;
import com.backendsystemdesignlab.notification.notification.dto.*;

import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class NotificationService {

private final NotificationTransactionService transactionService;
private final DeliveryPublisher deliveryPublisher;
private final NotificationDedupCache dedupCache;

public SendNotificationResponse send(SendNotificationRequest request) {

// DB 작업
PreparedNotification prepared = transactionService.prepare(request);
// 1. Redis Fast Path
var cached = dedupCache.find(request.eventId());

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

// 2. 기존 DB 처리
PreparedNotification prepared;

try {
prepared = transactionService.prepare(request);
} catch (DataIntegrityViolationException e) {
return transactionService.findExisingResponse(request.eventId())
.orElseThrow(() -> e);
}
SendNotificationResponse response;

// 이미 처리했던 eventId
if (prepared.alreadyProcessed()) {
long deliveryCount = transactionService.countDeliveries(prepared.notificationId());
return new SendNotificationResponse(
response = new SendNotificationResponse(
prepared.notificationId(),
prepared.status(),
deliveryCount
);
} else {
response = new SendNotificationResponse(
prepared.notificationId(),
prepared.status(),
prepared.deliveryCount()
);
}

// deliveryPublisher.publishAll(prepared.notificationId(), prepared.deliveries());

return new SendNotificationResponse(
prepared.notificationId(),
prepared.status(), // PROCESSING 비동기 이기 때문에 아직 Provider 전송이 안끝남
prepared.deliveryCount()
);
dedupCache.save(request.eventId(), response);

return response;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.backendsystemdesignlab.notification.notification.dto.DeliveryCommand;
import com.backendsystemdesignlab.notification.notification.dto.PreparedNotification;
import com.backendsystemdesignlab.notification.notification.dto.SendNotificationRequest;
import com.backendsystemdesignlab.notification.notification.dto.SendNotificationResponse;
import com.backendsystemdesignlab.notification.notification.repository.NotificationDeliveryRepository;
import com.backendsystemdesignlab.notification.notification.repository.NotificationRepository;
import com.backendsystemdesignlab.notification.outbox.OutboxEvent;
Expand All @@ -21,10 +22,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.*;
import java.util.stream.Collectors;

@Service
Expand Down Expand Up @@ -258,4 +256,17 @@ public void recordPublishFinalFailure(Long notificationId, Long deliveryId) {

updateNotificationStatus(notificationId, notification);
}

@Transactional(readOnly = true)
public Optional<SendNotificationResponse> findExisingResponse(String eventId) {
return notificationRepository.findByEventId(eventId)
.map(notification -> {
long deliveryCount = deliveryRepository.countByNotificationId(notification.getId());
return new SendNotificationResponse(
notification.getId(),
notification.getStatus(),
deliveryCount
);
});
}
}
2 changes: 2 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ spring:
redis:
host: ${SPRING_DATA_REDIS_HOST:localhost}
port: ${SPRING_DATA_REDIS_PORT:6379}
connect-timeout: 300ms
timeout: 300ms

management:
endpoints:
Expand Down