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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<NotificationBatch> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<NotificationBatch> 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<NotificationBatch> findExpiredBatches() {
return batchStore.findExpiredBatches();
}

public void deleteBatch(BatchKey key) {
batchStore.deleteBatch(key);
}

/**
* 동시성 제어를 위한 락 키 생성.
* 같은 배치 키에 대한 동시 접근을 막기 위해 String 인터닝 활용.
*/
private String getLockKey(BatchKey key) {
return key.toRedisKey().intern();
}
}
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ static RenderedMessage render(NotificationMessagesConfig.Template template, Map<
return new RenderedMessage(title, body);
}

static RenderedMessage renderBatch(NotificationMessagesConfig.Template template, Map<String, String> vars) {
String title = renderTemplate(template.getBatchPushTitle(), vars);
String body = renderTemplate(template.getBatchPushBody(), vars);
return new RenderedMessage(title, body);
}

static Map<String, String> toVars(Map<String, Object> extras) {
Map<String, String> vars = new HashMap<>();
extras.forEach((k, v) -> vars.put(k, v == null ? "" : String.valueOf(v)));
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -27,13 +29,29 @@ public Long save(NotificationPayload payload) {
NotificationType type = payload.type();

User actor = null;
Map<String, Object> 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<String, Object> 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)
Expand Down
Loading