diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchScheduler.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchScheduler.java new file mode 100644 index 00000000..d0a9a6f7 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchScheduler.java @@ -0,0 +1,54 @@ +package org.devkor.apu.saerok_server.domain.notification.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotificationPublisher; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; +import org.devkor.apu.saerok_server.global.core.config.feature.NotificationBatchConfig; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 알림 배치 스케줄러. + * 주기적으로 만료된 배치를 조회하여 전송한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationBatchScheduler { + + private final NotificationBatchService batchService; + private final NotificationPublisher publisher; + private final NotificationBatchConfig batchConfig; + + /** + * 10초마다 만료된 배치를 조회하여 전송. + */ + @Scheduled(fixedDelay = 10000, initialDelay = 10000) + public void processExpiredBatches() { + if (!batchConfig.isEnabled()) { + return; + } + + try { + List expiredBatches = batchService.findExpiredBatches(); + + if (expiredBatches.isEmpty()) { + return; + } + + for (NotificationBatch batch : expiredBatches) { + try { + publisher.pushBatch(batch); + } catch (Exception e) { + log.error("만료된 배치 처리에 실패했습니다: key={}", batch.getKey(), e); + } + } + + } catch (Exception e) { + log.error("Error in batch scheduler", e); + } + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java new file mode 100644 index 00000000..355eda93 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java @@ -0,0 +1,86 @@ +package org.devkor.apu.saerok_server.domain.notification.application; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.*; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.store.NotificationBatchStore; +import org.devkor.apu.saerok_server.global.core.config.feature.NotificationBatchConfig; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +/** + * 알림 배치 관리 서비스. + * 배치 생성, 추가, 조회 등의 작업을 담당한다. + */ +@Service +@RequiredArgsConstructor +public class NotificationBatchService { + + private final NotificationBatchStore batchStore; + private final NotificationBatchConfig batchConfig; + + /** + * 배치에 알림 추가. + * 기존 배치가 있으면 추가하고 최대 시간까지 연장하며, 없으면 새로 생성한다. + */ + public BatchResult addToBatch(ActionNotificationPayload payload) { + // 배치 처리가 비활성화되어 있으면 즉시 전송 + if (!batchConfig.isEnabled()) { + return BatchResult.sendImmediately(); + } + + BatchKey key = new BatchKey( + payload.recipientId(), + payload.subject(), + payload.action(), + payload.relatedId() + ); + + BatchActor actor = BatchActor.of(payload.actorId(), payload.actorName()); + + synchronized (this.getLockKey(key)) { + Optional existingBatch = batchStore.findBatch(key); + + if (existingBatch.isPresent()) { + // 기존 배치에 추가하고 최대 시간까지 연장 + NotificationBatch updatedBatch = existingBatch.get() + .addActor(actor, payload.extras(), batchConfig.getMaxWindowSeconds()); + + batchStore.saveBatch(updatedBatch); + + return BatchResult.added(updatedBatch); + + } else { + // 새 배치 생성 + NotificationBatch newBatch = NotificationBatch.create( + key, + actor, + batchConfig.getInitialWindowSeconds(), + payload.extras() + ); + + batchStore.saveBatch(newBatch); + + return BatchResult.created(newBatch); + } + } + } + + public List findExpiredBatches() { + return batchStore.findExpiredBatches(); + } + + public void deleteBatch(BatchKey key) { + batchStore.deleteBatch(key); + } + + /** + * 동시성 제어를 위한 락 키 생성. + * 같은 배치 키에 대한 동시 접근을 막기 위해 String 인터닝 활용. + */ + private String getLockKey(BatchKey key) { + return key.toRedisKey().intern(); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/BatchedNotificationRenderer.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/BatchedNotificationRenderer.java new file mode 100644 index 00000000..94a0886f --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/BatchedNotificationRenderer.java @@ -0,0 +1,53 @@ +package org.devkor.apu.saerok_server.domain.notification.application.assembly.render; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.BatchedNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; +import org.devkor.apu.saerok_server.global.core.config.feature.NotificationMessagesConfig; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 배치 처리된 알림 렌더러. + * 액터 수에 따라 단일/집계 메시지를 렌더링한다. + */ +@Component +@RequiredArgsConstructor +public class BatchedNotificationRenderer implements NotificationRenderer { + + private final NotificationMessagesConfig messages; + + @Override + public RenderedMessage render(NotificationPayload p) { + if (!(p instanceof BatchedNotificationPayload b)) { + throw new IllegalArgumentException("Unsupported payload: " + p.getClass()); + } + + NotificationMessagesConfig.Template t = messages.forType(b.type()); + + // 단일 액터인 경우 기존 메시지 형식 사용 + if (b.isSingleActor()) { + return renderSingle(t, b); + } + + return renderBatched(t, b); + } + + private RenderedMessage renderSingle(NotificationMessagesConfig.Template t, BatchedNotificationPayload b) { + var vars = NotificationTemplateRenderer.toVars(b.extras()); + vars.put("actorName", NotificationTemplateRenderer.nullToEmpty(b.getFirstActor().name())); + + return NotificationTemplateRenderer.render(t, vars); + } + + private RenderedMessage renderBatched(NotificationMessagesConfig.Template t, BatchedNotificationPayload b) { + var vars = NotificationTemplateRenderer.toVars(b.extras()); + vars.put("actorName", NotificationTemplateRenderer.nullToEmpty(b.getFirstActor().name())); + vars.put("count", String.valueOf(b.actorCount())); + vars.put("othersCount", String.valueOf(b.actorCount() - 1)); + + return NotificationTemplateRenderer.renderBatch(t, vars); + } + +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/DelegatingNotificationRenderer.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/DelegatingNotificationRenderer.java index bcedfa6f..fd3e38d9 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/DelegatingNotificationRenderer.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/DelegatingNotificationRenderer.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.BatchedNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.SystemNotificationPayload; import org.springframework.context.annotation.Primary; @@ -14,6 +15,7 @@ public class DelegatingNotificationRenderer implements NotificationRenderer { private final ActionNotificationRenderer actionRenderer; private final SystemNotificationRenderer systemRenderer; + private final BatchedNotificationRenderer batchedRenderer; @Override public RenderedMessage render(NotificationPayload payload) { @@ -23,6 +25,9 @@ public RenderedMessage render(NotificationPayload payload) { if (payload instanceof SystemNotificationPayload) { return systemRenderer.render(payload); } + if (payload instanceof BatchedNotificationPayload) { + return batchedRenderer.render(payload); + } throw new IllegalArgumentException("Unsupported payload: " + payload.getClass()); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/NotificationTemplateRenderer.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/NotificationTemplateRenderer.java index 1b629d76..9fe25924 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/NotificationTemplateRenderer.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/NotificationTemplateRenderer.java @@ -17,6 +17,12 @@ static RenderedMessage render(NotificationMessagesConfig.Template template, Map< return new RenderedMessage(title, body); } + static RenderedMessage renderBatch(NotificationMessagesConfig.Template template, Map vars) { + String title = renderTemplate(template.getBatchPushTitle(), vars); + String body = renderTemplate(template.getBatchPushBody(), vars); + return new RenderedMessage(title, body); + } + static Map toVars(Map extras) { Map vars = new HashMap<>(); extras.forEach((k, v) -> vars.put(k, v == null ? "" : String.valueOf(v))); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/store/InAppNotificationWriter.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/store/InAppNotificationWriter.java index bc09ac7e..44167794 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/store/InAppNotificationWriter.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/store/InAppNotificationWriter.java @@ -1,7 +1,9 @@ package org.devkor.apu.saerok_server.domain.notification.application.assembly.store; import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchActor; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.BatchedNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; import org.devkor.apu.saerok_server.domain.notification.core.entity.Notification; import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; @@ -27,13 +29,29 @@ public Long save(NotificationPayload payload) { NotificationType type = payload.type(); User actor = null; + Map payloadMap = new HashMap<>(); + + // 공통 extras 복사 + if (payload.extras() != null) { + payloadMap.putAll(payload.extras()); + } + if (payload instanceof ActionNotificationPayload a) { actor = userRepository.findById(a.actorId()) .orElseThrow(() -> new IllegalArgumentException("Actor not found: " + a.actorId())); } - Map payloadMap = new HashMap<>(); - if (payload.extras() != null) payloadMap.putAll(payload.extras()); + if (payload instanceof BatchedNotificationPayload b) { + // 첫 번째 액터를 대표 액터로 저장 + BatchActor firstActor = b.getFirstActor(); + actor = userRepository.findById(firstActor.id()) + .orElse(null); // 배치 알림의 경우 액터가 없을 수도 있음 (삭제된 사용자) + + payloadMap.put("actorCount", b.actorCount()); + payloadMap.put("actors", b.actors().stream() + .map(a -> Map.of("id", a.id(), "name", a.name())) + .toList()); + } Notification entity = Notification.builder() .user(recipient) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java index 27ca17b0..25679f0b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java @@ -1,18 +1,26 @@ package org.devkor.apu.saerok_server.domain.notification.application.facade; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.NotificationBatchService; import org.devkor.apu.saerok_server.domain.notification.application.assembly.render.NotificationRenderer; import org.devkor.apu.saerok_server.domain.notification.application.assembly.render.NotificationRenderer.RenderedMessage; import org.devkor.apu.saerok_server.domain.notification.application.assembly.store.InAppNotificationWriter; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushTarget; import org.devkor.apu.saerok_server.domain.notification.application.gateway.PushGateway; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchResult; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.BatchedNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.SystemNotificationPayload; import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationRepository; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class NotificationPublisher { @@ -22,6 +30,7 @@ public class NotificationPublisher { private final NotificationRepository notificationRepository; private final PushGateway pushGateway; private final UserRepository userRepository; + private final NotificationBatchService batchService; /** *

모든 알림의 공통 파이프라인

@@ -35,15 +44,53 @@ public class NotificationPublisher { */ @Transactional public void push(NotificationPayload payload) { + // 시스템 알림은 즉시 전송 + if (payload instanceof SystemNotificationPayload) { + prepareNotificationTarget(payload).ifPresent(target -> + pushGateway.sendToUser(target.userId(), target.type(), target.command()) + ); + return; + } + + if (payload instanceof ActionNotificationPayload actionPayload) { + BatchResult result = batchService.addToBatch(actionPayload); + + if (result.shouldSendImmediately()) { + prepareNotificationTarget(payload).ifPresent(target -> + pushGateway.sendToUser(target.userId(), target.type(), target.command()) + ); + } + // 배치에 추가되었으면 스케줄러가 나중에 전송 + } + } + /** + * 배치 알림 전송 (스케줄러에서 호출). + */ + @Transactional + public void pushBatch(NotificationBatch batch) { + try { + BatchedNotificationPayload payload = BatchedNotificationPayload.fromBatch(batch); + prepareNotificationTarget(payload).ifPresent(target -> + pushGateway.sendToUser(target.userId(), target.type(), target.command()) + ); + + } catch (Exception e) { + log.error("Failed to send batch notification: key={}", batch.getKey(), e); + } finally { + // 성공/실패 여부와 관계없이 배치 삭제 (재시도 방지) + batchService.deleteBatch(batch.getKey()); + } + } + + private java.util.Optional prepareNotificationTarget(NotificationPayload payload) { // recipient가 삭제/미존재면 조용히 무시 if (userRepository.findById(payload.recipientId()).isEmpty()) { - return; + return java.util.Optional.empty(); } RenderedMessage renderedMessage = renderer.render(payload); Long notificationId = inAppWriter.save(payload); - int unread = notificationRepository.countUnreadByUserId(payload.recipientId()).intValue(); PushMessageCommand cmd = PushMessageCommand.createPushMessageCommand( @@ -55,7 +102,7 @@ public void push(NotificationPayload payload) { notificationId ); - pushGateway.sendToUser(payload.recipientId(), payload.type(), cmd); + return java.util.Optional.of(new PushTarget(payload.recipientId(), payload.type(), cmd)); } /** @@ -74,24 +121,7 @@ public void pushDeduplicatedByDevice(Iterable pay continue; } - if (userRepository.findById(payload.recipientId()).isEmpty()) { - continue; - } - - RenderedMessage renderedMessage = renderer.render(payload); - Long notificationId = inAppWriter.save(payload); - int unread = notificationRepository.countUnreadByUserId(payload.recipientId()).intValue(); - - PushMessageCommand cmd = PushMessageCommand.createPushMessageCommand( - renderedMessage.pushTitle(), - renderedMessage.pushBody(), - payload.type().name(), - payload.relatedId(), - unread, - notificationId - ); - - targets.add(new PushTarget(payload.recipientId(), payload.type(), cmd)); + prepareNotificationTarget(payload).ifPresent(targets::add); } if (targets.isEmpty()) { diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchActor.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchActor.java new file mode 100644 index 00000000..d9bc429a --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchActor.java @@ -0,0 +1,13 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.batch; + +/** + * 배치 내 행동 주체(actor) 정보. + */ +public record BatchActor( + Long id, + String name +) { + public static BatchActor of(Long id, String name) { + return new BatchActor(id, name); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchKey.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchKey.java new file mode 100644 index 00000000..31e4d616 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchKey.java @@ -0,0 +1,28 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.batch; + +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; + +/** + * 알림 배치를 식별하기 위한 키. + */ +public record BatchKey( + Long recipientId, + NotificationSubject subject, + NotificationAction action, + Long relatedId +) { + /** + * Redis 키 형식으로 변환. + * 형식: notification:batch:{recipientId}:{subject}:{action}:{relatedId} + */ + public String toRedisKey() { + return String.format( + "notification:batch:%d:%s:%s:%d", + recipientId, + subject.name(), + action.name(), + relatedId + ); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchResult.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchResult.java new file mode 100644 index 00000000..b1957513 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchResult.java @@ -0,0 +1,30 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.batch; + +/** + * 배치 추가 작업의 결과. + */ +public record BatchResult( + BatchAction action, + NotificationBatch batch +) { + + public static BatchResult created(NotificationBatch batch) { return new BatchResult(BatchAction.CREATED, batch); } + + public static BatchResult added(NotificationBatch batch) { + return new BatchResult(BatchAction.ADDED, batch); + } + + public static BatchResult sendImmediately() { + return new BatchResult(BatchAction.SEND_IMMEDIATELY, null); + } + + public boolean shouldSendImmediately() { + return action == BatchAction.SEND_IMMEDIATELY; + } + + public enum BatchAction { + CREATED, // 새 배치 생성됨 + ADDED, // 기존 배치에 추가됨 + SEND_IMMEDIATELY // 즉시 전송 (배치 처리 안 함) + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/NotificationBatch.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/NotificationBatch.java new file mode 100644 index 00000000..a110de46 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/NotificationBatch.java @@ -0,0 +1,108 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.batch; + +import lombok.Getter; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +/** + * 알림 배치 도메인 모델. + * 일정 시간 동안 같은 대상(수신자, 주제, 행동, 관련 ID)에 대한 알림을 모아서 처리한다. + */ +@Getter +public class NotificationBatch { + private final BatchKey key; + private final List actors; + private final LocalDateTime createdAt; + private final LocalDateTime expiresAt; + private final Map extras; + + public NotificationBatch( + BatchKey key, + List actors, + LocalDateTime createdAt, + LocalDateTime expiresAt, + Map extras + ) { + this.key = key; + this.actors = new ArrayList<>(actors == null ? List.of() : actors); + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.extras = new HashMap<>(extras == null ? Map.of() : extras); + } + + /** + * 새 배치 생성. + */ + public static NotificationBatch create( + BatchKey key, + BatchActor initialActor, + int initialWindowSeconds, + Map extras + ) { + LocalDateTime now = LocalDateTime.now(); + return new NotificationBatch( + key, + List.of(initialActor), + now, + now.plusSeconds(initialWindowSeconds), + extras != null ? extras : Map.of() + ); + } + + /** + * 배치에 새 액터 추가하고 만료 시간 연장 (최대 시간까지만). + * 중복된 액터는 추가하지 않는다. + * + * @param maxWindowSeconds 배치 생성 시점부터의 최대 대기 시간 + */ + public NotificationBatch addActor(BatchActor actor, Map newExtras, int maxWindowSeconds) { + List updatedActors = new ArrayList<>(this.actors); + + // 중복 체크 + boolean exists = updatedActors.stream() + .anyMatch(a -> a.id().equals(actor.id())); + + if (!exists) { + updatedActors.add(actor); + } + + // extras 병합 (새로운 extras로 기존 것을 덮어씀 - 최신 정보 유지) + Map mergedExtras = new HashMap<>(this.extras); + if (newExtras != null) { + mergedExtras.putAll(newExtras); + } + + // 만료 시간 연장 + LocalDateTime maxExpiresAt = this.createdAt.plusSeconds(maxWindowSeconds); + + // 기존 만료 시간보다 더 늦은 경우에만 연장 + LocalDateTime finalExpiresAt = maxExpiresAt.isAfter(this.expiresAt) ? maxExpiresAt : this.expiresAt; + + return new NotificationBatch( + this.key, + updatedActors, + this.createdAt, + finalExpiresAt, + mergedExtras + ); + } + + public boolean isExpired() {return LocalDateTime.now().isAfter(expiresAt);} + + public int getActorCount() {return actors.size();} + + // BatchKey 위임 편의 메서드 + public Long getRecipientId() {return key.recipientId();} + + public NotificationSubject getSubject() {return key.subject();} + + public NotificationAction getAction() {return key.action();} + + public Long getRelatedId() {return key.relatedId();} +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java new file mode 100644 index 00000000..592c6528 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java @@ -0,0 +1,58 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.payload; + +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchActor; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationTypeResolver; + +import java.util.List; +import java.util.Map; + +/** + * 배치 처리된 알림 payload. + * 여러 액터의 행동을 하나로 묶어서 전달한다. + */ +public record BatchedNotificationPayload( + Long recipientId, + NotificationSubject subject, + NotificationAction action, + Long relatedId, + List actors, + int actorCount, + Map extras +) implements NotificationPayload { + + public BatchedNotificationPayload { + extras = (extras == null) ? Map.of() : Map.copyOf(extras); + } + + public static BatchedNotificationPayload fromBatch(NotificationBatch batch) { + return new BatchedNotificationPayload( + batch.getRecipientId(), + batch.getSubject(), + batch.getAction(), + batch.getRelatedId(), + batch.getActors(), + batch.getActorCount(), + batch.getExtras() + ); + } + + @Override + public NotificationType type() { + return NotificationTypeResolver.from(subject, action); + } + + public BatchActor getFirstActor() { + if (actors.isEmpty()) { + throw new IllegalStateException("Batch has no actors"); + } + return actors.getFirst(); + } + + public boolean isSingleActor() { + return actorCount == 1; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayload.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayload.java index 723d7a8b..6012bd83 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayload.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayload.java @@ -10,6 +10,7 @@ *
    *
  • ActionNotificationPayload: 다른 사용자(actor)의 행동에 의해 발생하는 알림
  • *
  • SystemNotificationPayload: 공지/점검 등 시스템 차원에서 발생하는 알림
  • + *
  • BatchedNotificationPayload: 여러 사용자의 행동을 모은 배치 알림
  • *
* *

@@ -18,7 +19,7 @@ *

*/ public sealed interface NotificationPayload - permits ActionNotificationPayload, SystemNotificationPayload { + permits ActionNotificationPayload, SystemNotificationPayload, BatchedNotificationPayload { /** * 클라이언트에서 식별 가능한 최종 알림 타입. diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/store/NotificationBatchStore.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/store/NotificationBatchStore.java new file mode 100644 index 00000000..2fefd9dd --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/store/NotificationBatchStore.java @@ -0,0 +1,25 @@ +package org.devkor.apu.saerok_server.domain.notification.application.store; + +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchKey; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; + +import java.util.List; +import java.util.Optional; + +/** + * 알림 배치 저장소 인터페이스. + */ +public interface NotificationBatchStore { + + Optional findBatch(BatchKey key); + + void saveBatch(NotificationBatch batch); + + void deleteBatch(BatchKey key); + + /** + * 만료된 배치 목록 조회. + * redis의 key 만료를 감안한 메서드 + */ + List findExpiredBatches(); +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/NotificationBatchDto.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/NotificationBatchDto.java new file mode 100644 index 00000000..9cd824e0 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/NotificationBatchDto.java @@ -0,0 +1,70 @@ +package org.devkor.apu.saerok_server.domain.notification.infra.redis; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchActor; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchKey; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * Redis 직렬화를 위한 NotificationBatch DTO. + */ +public record NotificationBatchDto( + @JsonProperty("recipientId") Long recipientId, + @JsonProperty("subject") String subject, + @JsonProperty("action") String action, + @JsonProperty("relatedId") Long relatedId, + @JsonProperty("actors") List actors, + @JsonProperty("createdAt") LocalDateTime createdAt, + @JsonProperty("expiresAt") LocalDateTime expiresAt, + @JsonProperty("extras") Map extras +) { + + public static NotificationBatchDto fromBatch(NotificationBatch batch) { + List actorDtos = batch.getActors().stream() + .map(actor -> new ActorDto(actor.id(), actor.name())) + .toList(); + + return new NotificationBatchDto( + batch.getRecipientId(), + batch.getSubject().name(), + batch.getAction().name(), + batch.getRelatedId(), + actorDtos, + batch.getCreatedAt(), + batch.getExpiresAt(), + batch.getExtras() + ); + } + + public NotificationBatch toBatch() { + BatchKey key = new BatchKey( + recipientId, + NotificationSubject.valueOf(subject), + NotificationAction.valueOf(action), + relatedId + ); + + List batchActors = actors.stream() + .map(dto -> BatchActor.of(dto.id(), dto.name())) + .toList(); + + return new NotificationBatch( + key, + batchActors, + createdAt, + expiresAt, + extras == null ? Map.of() : extras + ); + } + + public record ActorDto( + @JsonProperty("id") Long id, + @JsonProperty("name") String name + ) {} +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java new file mode 100644 index 00000000..70fec9bb --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java @@ -0,0 +1,114 @@ +package org.devkor.apu.saerok_server.domain.notification.infra.redis; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.BatchKey; +import org.devkor.apu.saerok_server.domain.notification.application.model.batch.NotificationBatch; +import org.devkor.apu.saerok_server.domain.notification.application.store.NotificationBatchStore; +import org.devkor.apu.saerok_server.global.core.config.feature.NotificationBatchConfig; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Redis 기반 알림 배치 저장소 구현체. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisNotificationBatchStore implements NotificationBatchStore { + + private static final String KEY_PREFIX = "notification:batch:"; + private static final String KEY_PATTERN = KEY_PREFIX + "*"; + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final NotificationBatchConfig batchConfig; + + @Override + public Optional findBatch(BatchKey key) { + try { + String redisKey = key.toRedisKey(); + String json = redisTemplate.opsForValue().get(redisKey); + + if (json == null) { + return Optional.empty(); + } + + NotificationBatchDto dto = objectMapper.readValue(json, NotificationBatchDto.class); + NotificationBatch batch = dto.toBatch(); + return Optional.of(batch); + + } catch (JsonProcessingException e) { + log.error("Redis에서 배치 데이터 역직렬화에 실패했습니다: {}", key, e); + return Optional.empty(); + } + } + + @Override + public void saveBatch(NotificationBatch batch) { + try { + String redisKey = batch.getKey().toRedisKey(); + NotificationBatchDto dto = NotificationBatchDto.fromBatch(batch); + String json = objectMapper.writeValueAsString(dto); + + redisTemplate.opsForValue().set(redisKey, json, Duration.ofSeconds(batchConfig.getTtlSeconds())); + + } catch (JsonProcessingException e) { + log.error("Redis에서 배치 데이터 직렬화에 실패했습니다: {}", batch.getKey(), e); + throw new IllegalStateException("Redis에 배치 저장하는 것에 실패했습니다", e); + } + } + + @Override + public void deleteBatch(BatchKey key) { + String redisKey = key.toRedisKey(); + redisTemplate.delete(redisKey); + } + + @Override + public List findExpiredBatches() { + List expiredBatches = new ArrayList<>(); + + ScanOptions scanOptions = ScanOptions.scanOptions() + .match(KEY_PATTERN) + .count(100) + .build(); + + try (Cursor cursor = redisTemplate.scan(scanOptions)) { + while (cursor.hasNext()) { + String redisKey = cursor.next(); + String json = redisTemplate.opsForValue().get(redisKey); + + if (json == null) { + // 키가 스캔 후 만료되었을 수 있음 + continue; + } + + try { + NotificationBatchDto dto = objectMapper.readValue(json, NotificationBatchDto.class); + NotificationBatch batch = dto.toBatch(); + + if (batch.isExpired()) { + expiredBatches.add(batch); + } + + } catch (JsonProcessingException e) { + log.error("Redis 키 역직렬화에 실패했습니다: {}", redisKey, e); + } + } + } catch (Exception e) { + log.error("만료된 배치 스캔에 실패했습니다.", e); + } + + return expiredBatches; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java new file mode 100644 index 00000000..ea27a96a --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java @@ -0,0 +1,44 @@ +package org.devkor.apu.saerok_server.global.core.config.feature; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 알림 배치 처리 설정. + */ +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "notification-batch") +public class NotificationBatchConfig { + + private boolean enabled = true; // 배치 처리 활성화 여부. + private int initialWindowSeconds = 30; + private int maxWindowSeconds = 60; + private int ttlSeconds = 90; + + @PostConstruct + void validateConfig() { + if (initialWindowSeconds <= 0) { + throw new IllegalStateException("notification-batch.initial-window-seconds는 양수여야합니다"); + } + if (maxWindowSeconds <= 0) { + throw new IllegalStateException("notification-batch.max-window-seconds는 양수여야합니다"); + } + if (maxWindowSeconds < initialWindowSeconds) { + throw new IllegalStateException( + String.format("notification-batch.max-window-seconds (%d) >= initial-window-seconds (%d) 이어야 합니다", + maxWindowSeconds, initialWindowSeconds) + ); + } + if (ttlSeconds <= maxWindowSeconds) { + throw new IllegalStateException( + String.format("notification-batch.ttl-seconds (%d) > max-window-seconds (%d) 이어야 합니다", + ttlSeconds, maxWindowSeconds) + ); + } + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationMessagesConfig.java b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationMessagesConfig.java index aa930ea9..3d5dd1bd 100644 --- a/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationMessagesConfig.java +++ b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationMessagesConfig.java @@ -43,5 +43,9 @@ public static class Template { private String pushTitle; private String pushBody; private String inAppBody; + + // 배치 알림용 템플릿 + private String batchPushTitle; + private String batchPushBody; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 20c7ca18..278d68f6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -38,4 +38,10 @@ firebase: app: cookie: - secure: false \ No newline at end of file + secure: false + +notification-batch: + enabled: true + initial-window-seconds: 30 + max-window-seconds: 60 + ttl-seconds: 90 \ No newline at end of file diff --git a/src/main/resources/config/notification-messages.yml b/src/main/resources/config/notification-messages.yml index 4b51e952..67f871c1 100644 --- a/src/main/resources/config/notification-messages.yml +++ b/src/main/resources/config/notification-messages.yml @@ -3,12 +3,18 @@ notification-messages: LIKED_ON_COLLECTION: push-title: "{actorName}" push-body: "나의 새록을 좋아해요." + batch-push-title: "새록 좋아요" + batch-push-body: "{count}개의 좋아요가 달렸어요." COMMENTED_ON_COLLECTION: push-title: "{actorName}" push-body: "나의 새록에 댓글을 남겼어요. \"{comment}\"" + batch-push-title: "{actorName} 외 {othersCount}명" + batch-push-body: "나의 새록에 댓글을 남겼어요. \"{comment}\"" SUGGESTED_BIRD_ID_ON_COLLECTION: push-title: "동정 의견 공유" push-body: "두근두근! 새로운 의견이 공유되었어요. 확인해볼까요?" + batch-push-title: "동정 의견 공유" + batch-push-body: "{count}개의 새로운 의견이 공유되었어요. 확인해볼까요?" SYSTEM_PUBLISHED_ANNOUNCEMENT: push-title: "{title}" push-body: "{body}" \ No newline at end of file