From 73842aedf26b67ff8e9ff15e1f6053e418c3e6cc Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 20 Dec 2025 18:34:31 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat(infra):=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/config/notification-messages.yml | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 3adfb92207d96af39b2d1f7e0f2b4f428819c6ae Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 21 Dec 2025 23:01:19 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat(infra):=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/NotificationBatchConfig.java | 44 +++++++++++++++++++ .../feature/NotificationMessagesConfig.java | 4 ++ src/main/resources/application.yml | 11 ++++- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/NotificationBatchConfig.java 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..f1323cb7 --- /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 = 61; + + @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..a5964434 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -38,4 +38,13 @@ 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 + # Redis TTL (최대 대기 시간 + 여유) + ttl-seconds: 61 \ No newline at end of file From d92f971b6da2ad90873e9f2e583b23a03eb1fdcc Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sun, 21 Dec 2025 23:06:47 +0900 Subject: [PATCH 03/13] =?UTF-8?q?refactor(infra):=20redis=EC=9D=98=20ttl?= =?UTF-8?q?=EC=9D=84=2090=EC=9C=BC=EB=A1=9C=20=EB=8A=98=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조금 더 안전하게 하기 위해 --- .../global/core/config/feature/NotificationBatchConfig.java | 2 +- src/main/resources/application.yml | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) 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 index f1323cb7..ea27a96a 100644 --- 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 @@ -18,7 +18,7 @@ public class NotificationBatchConfig { private boolean enabled = true; // 배치 처리 활성화 여부. private int initialWindowSeconds = 30; private int maxWindowSeconds = 60; - private int ttlSeconds = 61; + private int ttlSeconds = 90; @PostConstruct void validateConfig() { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a5964434..278d68f6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -42,9 +42,6 @@ app: notification-batch: enabled: true - # 첫 알림 발생 시 대기 시간 (초) initial-window-seconds: 30 - # 추가 알림 발생 시 최대 대기 시간 (초) max-window-seconds: 60 - # Redis TTL (최대 대기 시간 + 여유) - ttl-seconds: 61 \ No newline at end of file + ttl-seconds: 90 \ No newline at end of file From 9419de8926684bd497bda98d3b8e7069cdea61ee Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Mon, 22 Dec 2025 17:21:13 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20batch=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 클래스, BatchKey, 그리고 actor 리스트에 쓰일 BatchActor를 생성함 --- .../application/model/batch/BatchActor.java | 13 +++ .../application/model/batch/BatchKey.java | 28 +++++ .../model/batch/NotificationBatch.java | 108 ++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchActor.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchKey.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/NotificationBatch.java 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/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();} +} From 21bc0c4e14fe8e3f2e2ea537f35df93a0157588c Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Mon, 22 Dec 2025 17:23:45 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=EC=9D=98=20'?= =?UTF-8?q?=EC=83=81=ED=83=9C'=EA=B0=80=20=EC=95=84=EB=8B=8C,=20'=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC'=EB=A5=BC=20=EB=8B=B4=EB=8A=94=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이를 통해 배치가 불필요한 작업은 배치를 만들지 않고도 작업이 가능한 관심사의 분리 및 배치의 처리 결과를 확인 가능 --- .../application/model/batch/BatchResult.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/batch/BatchResult.java 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 // 즉시 전송 (배치 처리 안 함) + } +} From 18ccc31342aacc5d280bd600514e1ad6a821f768 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Tue, 23 Dec 2025 17:45:57 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=B2=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store/NotificationBatchStore.java | 25 ++++ .../infra/redis/NotificationBatchDto.java | 112 +++++++++++++++++ .../redis/RedisNotificationBatchStore.java | 114 ++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/store/NotificationBatchStore.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/NotificationBatchDto.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/RedisNotificationBatchStore.java 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..5a25a218 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/redis/NotificationBatchDto.java @@ -0,0 +1,112 @@ +package org.devkor.apu.saerok_server.domain.notification.infra.redis; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +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. + */ +@Getter +public class NotificationBatchDto { + + // Getters for Jackson + private final Long recipientId; + private final String subject; + private final String action; + private final Long relatedId; + private final List actors; + private final LocalDateTime createdAt; + private final LocalDateTime expiresAt; + private final Map extras; + + @JsonCreator + public 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 + ) { + this.recipientId = recipientId; + this.subject = subject; + this.action = action; + this.relatedId = relatedId; + this.actors = actors; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.extras = 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 static class ActorDto { + private final Long id; + private final String name; + + @JsonCreator + public ActorDto( + @JsonProperty("id") Long id, + @JsonProperty("name") String name + ) { + this.id = id; + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return 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; + } +} From eb6a01125a88ba0a0808583eed149cd1c255303a Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Tue, 23 Dec 2025 17:51:09 +0900 Subject: [PATCH 07/13] =?UTF-8?q?refactor:=20=EB=B0=B0=EC=B9=98=20dto?= =?UTF-8?q?=EB=A5=BC=20=EB=A0=88=EC=BD=94=EB=93=9C=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/redis/NotificationBatchDto.java | 72 ++++--------------- 1 file changed, 15 insertions(+), 57 deletions(-) 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 index 5a25a218..9cd824e0 100644 --- 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 @@ -1,8 +1,6 @@ package org.devkor.apu.saerok_server.domain.notification.infra.redis; -import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Getter; 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; @@ -16,39 +14,16 @@ /** * Redis 직렬화를 위한 NotificationBatch DTO. */ -@Getter -public class NotificationBatchDto { - - // Getters for Jackson - private final Long recipientId; - private final String subject; - private final String action; - private final Long relatedId; - private final List actors; - private final LocalDateTime createdAt; - private final LocalDateTime expiresAt; - private final Map extras; - - @JsonCreator - public 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 - ) { - this.recipientId = recipientId; - this.subject = subject; - this.action = action; - this.relatedId = relatedId; - this.actors = actors; - this.createdAt = createdAt; - this.expiresAt = expiresAt; - this.extras = extras; - } +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() @@ -76,7 +51,7 @@ public NotificationBatch toBatch() { ); List batchActors = actors.stream() - .map(dto -> BatchActor.of(dto.id, dto.name)) + .map(dto -> BatchActor.of(dto.id(), dto.name())) .toList(); return new NotificationBatch( @@ -88,25 +63,8 @@ public NotificationBatch toBatch() { ); } - public static class ActorDto { - private final Long id; - private final String name; - - @JsonCreator - public ActorDto( - @JsonProperty("id") Long id, - @JsonProperty("name") String name - ) { - this.id = id; - this.name = name; - } - - public Long getId() { - return id; - } - - public String getName() { - return name; - } - } + public record ActorDto( + @JsonProperty("id") Long id, + @JsonProperty("name") String name + ) {} } From 5b1e1fe53bc5dff9cf56aae02bf419ed9d185e62 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Tue, 23 Dec 2025 17:52:22 +0900 Subject: [PATCH 08/13] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payload/BatchedNotificationPayload.java | 45 +++++++++++++++++++ .../model/payload/NotificationPayload.java | 3 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java 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..cd932898 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java @@ -0,0 +1,45 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.payload; + +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); + } + + @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 { /** * 클라이언트에서 식별 가능한 최종 알림 타입. From 373d3771317f094561e299844acf567e7a803dfa Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Tue, 23 Dec 2025 18:59:37 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/NotificationBatchService.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchService.java 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(); + } +} From be9cad2da0b391a6be8b2e45021f6c3a00b9179f Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Tue, 23 Dec 2025 19:29:43 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=A0=8C=EB=8D=94=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../render/BatchedNotificationRenderer.java | 53 +++++++++++++++++++ .../DelegatingNotificationRenderer.java | 5 ++ .../render/NotificationTemplateRenderer.java | 6 +++ 3 files changed, 64 insertions(+) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/assembly/render/BatchedNotificationRenderer.java 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))); From 3be81f3bbca40a8404a402e566af9230021afe0b Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Tue, 23 Dec 2025 20:46:25 +0900 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20=EC=9D=B8=EC=95=B1=20writer?= =?UTF-8?q?=EC=97=90=20=EB=B0=B0=EC=B9=98=20=EC=95=8C=EB=A6=BC=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EB=A1=9C=EB=93=9C=20=EB=B6=84=EA=B8=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store/InAppNotificationWriter.java | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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) From e6a930b6448c04bcfe77c46236e810d324a82b67 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Tue, 23 Dec 2025 22:10:20 +0900 Subject: [PATCH 12/13] =?UTF-8?q?feat:=20=ED=8D=BC=EB=B8=94=EB=A6=AC?= =?UTF-8?q?=EC=85=94=EC=97=90=20=EB=B0=B0=EC=B9=98=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배치화된 알림 전송을 위한 분기를 만들었고, 스케줄러에서 배치화된 알림 최종 push를 담당하도록 했습니다. 중복되는 로직은 sendNotification으로 묶었습니다. --- .../NotificationBatchScheduler.java | 54 +++++++++++++++++++ .../facade/NotificationPublisher.java | 42 +++++++++++++++ .../payload/BatchedNotificationPayload.java | 13 +++++ 3 files changed, 109 insertions(+) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/NotificationBatchScheduler.java 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/facade/NotificationPublisher.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotificationPublisher.java index 10207dbe..a95a131f 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,17 +1,25 @@ 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.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 { @@ -21,6 +29,7 @@ public class NotificationPublisher { private final NotificationRepository notificationRepository; private final PushGateway pushGateway; private final UserRepository userRepository; + private final NotificationBatchService batchService; /** *

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

@@ -34,7 +43,40 @@ public class NotificationPublisher { */ @Transactional public void push(NotificationPayload payload) { + // 시스템 알림은 즉시 전송 + if (payload instanceof SystemNotificationPayload) { + sendNotification(payload); + return; + } + + if (payload instanceof ActionNotificationPayload actionPayload) { + BatchResult result = batchService.addToBatch(actionPayload); + + if (result.shouldSendImmediately()) { + sendNotification(payload); + } + // 배치에 추가되었으면 스케줄러가 나중에 전송 + } + } + + /** + * 배치 알림 전송 (스케줄러에서 호출). + */ + @Transactional + public void pushBatch(NotificationBatch batch) { + try { + BatchedNotificationPayload payload = BatchedNotificationPayload.fromBatch(batch); + sendNotification(payload); + + } catch (Exception e) { + log.error("Failed to send batch notification: key={}", batch.getKey(), e); + } finally { + // 성공/실패 여부와 관계없이 배치 삭제 (재시도 방지) + batchService.deleteBatch(batch.getKey()); + } + } + private void sendNotification(NotificationPayload payload) { // recipient가 삭제/미존재면 조용히 무시 if (userRepository.findById(payload.recipientId()).isEmpty()) { return; 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 index cd932898..592c6528 100644 --- 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 @@ -1,5 +1,6 @@ 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; @@ -27,6 +28,18 @@ public record 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); From 6eaf4461dc8e47f51676caa145f712cf7ac6400f Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Wed, 24 Dec 2025 02:50:59 +0900 Subject: [PATCH 13/13] =?UTF-8?q?refactor:=20=ED=8D=BC=EB=B8=94=EB=A6=AC?= =?UTF-8?q?=EC=85=94=EC=97=90=EC=84=9C=20=EC=BD=94=EB=93=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EB=AC=B6=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../facade/NotificationPublisher.java | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) 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 751acd1f..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 @@ -46,7 +46,9 @@ public class NotificationPublisher { public void push(NotificationPayload payload) { // 시스템 알림은 즉시 전송 if (payload instanceof SystemNotificationPayload) { - sendNotification(payload); + prepareNotificationTarget(payload).ifPresent(target -> + pushGateway.sendToUser(target.userId(), target.type(), target.command()) + ); return; } @@ -54,7 +56,9 @@ public void push(NotificationPayload payload) { BatchResult result = batchService.addToBatch(actionPayload); if (result.shouldSendImmediately()) { - sendNotification(payload); + prepareNotificationTarget(payload).ifPresent(target -> + pushGateway.sendToUser(target.userId(), target.type(), target.command()) + ); } // 배치에 추가되었으면 스케줄러가 나중에 전송 } @@ -67,7 +71,9 @@ public void push(NotificationPayload payload) { public void pushBatch(NotificationBatch batch) { try { BatchedNotificationPayload payload = BatchedNotificationPayload.fromBatch(batch); - sendNotification(payload); + 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); @@ -77,15 +83,14 @@ public void pushBatch(NotificationBatch batch) { } } - private void sendNotification(NotificationPayload payload) { + 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( @@ -97,7 +102,7 @@ private void sendNotification(NotificationPayload payload) { notificationId ); - pushGateway.sendToUser(payload.recipientId(), payload.type(), cmd); + return java.util.Optional.of(new PushTarget(payload.recipientId(), payload.type(), cmd)); } /** @@ -116,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()) {