diff --git a/k6/notification-dedup.js b/k6/notification-dedup.js new file mode 100644 index 0000000..9994606 --- /dev/null +++ b/k6/notification-dedup.js @@ -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, + }); +} \ No newline at end of file diff --git a/src/main/java/com/backendsystemdesignlab/notification/dedup/NotificationDedupCache.java b/src/main/java/com/backendsystemdesignlab/notification/dedup/NotificationDedupCache.java new file mode 100644 index 0000000..819a342 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/notification/dedup/NotificationDedupCache.java @@ -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 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); + } + } +} diff --git a/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationService.java b/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationService.java index 00e0607..959639c 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationService.java +++ b/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationService.java @@ -1,9 +1,10 @@ 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 @@ -11,30 +12,46 @@ 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; } } 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 9921b65..d55161c 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationTransactionService.java +++ b/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationTransactionService.java @@ -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; @@ -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 @@ -258,4 +256,17 @@ public void recordPublishFinalFailure(Long notificationId, Long deliveryId) { updateNotificationStatus(notificationId, notification); } + + @Transactional(readOnly = true) + public Optional findExisingResponse(String eventId) { + return notificationRepository.findByEventId(eventId) + .map(notification -> { + long deliveryCount = deliveryRepository.countByNotificationId(notification.getId()); + return new SendNotificationResponse( + notification.getId(), + notification.getStatus(), + deliveryCount + ); + }); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 84766c8..98ac30b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: