Skip to content

Commit e68cc20

Browse files
authored
Merge pull request #312 from DevKor-github/develop
#311 반영해 배포
2 parents cbf7d8c + 563c589 commit e68cc20

8 files changed

Lines changed: 208 additions & 23 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package org.devkor.apu.saerok_server.domain.announcement.application;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.Announcement;
6+
import org.devkor.apu.saerok_server.domain.admin.announcement.core.repository.AnnouncementRepository;
7+
import org.devkor.apu.saerok_server.domain.notification.application.assembly.render.NotificationRenderer;
8+
import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand;
9+
import org.devkor.apu.saerok_server.domain.notification.application.model.payload.SystemNotificationPayload;
10+
import org.devkor.apu.saerok_server.domain.notification.core.entity.Notification;
11+
import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType;
12+
import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationRepository;
13+
import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationSettingRepository;
14+
import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository;
15+
import org.devkor.apu.saerok_server.domain.notification.infra.fcm.FcmMessageClient;
16+
import org.devkor.apu.saerok_server.domain.user.core.entity.User;
17+
import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository;
18+
import org.springframework.stereotype.Service;
19+
import org.springframework.transaction.annotation.Transactional;
20+
21+
import java.util.HashMap;
22+
import java.util.List;
23+
import java.util.Map;
24+
25+
@Slf4j
26+
@Service
27+
@RequiredArgsConstructor
28+
public class AnnouncementBulkNotificationService {
29+
30+
private static final int CHUNK_SIZE = 100;
31+
private static final int BATCH_INSERT_SIZE = 100;
32+
private static final int FCM_MAX_TOKENS = 500;
33+
34+
private final AnnouncementRepository announcementRepository;
35+
private final UserRepository userRepository;
36+
private final NotificationRepository notificationRepository;
37+
private final UserDeviceRepository userDeviceRepository;
38+
private final NotificationSettingRepository settingRepository;
39+
private final NotificationRenderer renderer;
40+
private final FcmMessageClient fcmMessageClient;
41+
42+
@Transactional
43+
public void sendBulkNotification(Long announcementId) {
44+
Announcement ann = announcementRepository.findById(announcementId).orElse(null);
45+
if (ann == null || !ann.isPublished() || !Boolean.TRUE.equals(ann.getSendNotification())) {
46+
return;
47+
}
48+
49+
Long annId = ann.getId();
50+
NotificationType type = NotificationType.SYSTEM_PUBLISHED_ANNOUNCEMENT;
51+
Map<String, Object> extras = Map.of(
52+
"announcementId", annId,
53+
"title", ann.getPushTitle(),
54+
"body", ann.getPushBody(),
55+
"inAppBody", ann.getInAppBody()
56+
);
57+
58+
SystemNotificationPayload templatePayload = new SystemNotificationPayload(0L, type, annId, extras);
59+
NotificationRenderer.RenderedMessage rendered = renderer.render(templatePayload);
60+
61+
int offset = 0;
62+
while (true) {
63+
List<Long> userIds = userRepository.findActiveUserIds(offset, CHUNK_SIZE);
64+
if (userIds.isEmpty()) break;
65+
66+
processChunk(userIds, type, extras, rendered, annId);
67+
offset += userIds.size();
68+
69+
if (userIds.size() < CHUNK_SIZE) break;
70+
}
71+
72+
log.info("Bulk announcement notification completed: announcementId={}, totalUsers={}",
73+
annId, offset);
74+
}
75+
76+
private void processChunk(List<Long> userIds, NotificationType type,
77+
Map<String, Object> extras,
78+
NotificationRenderer.RenderedMessage rendered,
79+
Long announcementId) {
80+
// (a) 유저 배치 조회
81+
List<User> users = userRepository.findByIds(userIds);
82+
83+
// (b) 인앱 알림 batch insert
84+
List<Notification> notifications = users.stream()
85+
.map(user -> Notification.builder()
86+
.user(user)
87+
.type(type)
88+
.isRead(false)
89+
.payload(new HashMap<>(extras))
90+
.build())
91+
.toList();
92+
notificationRepository.batchInsert(notifications, BATCH_INSERT_SIZE);
93+
94+
// (c) 활성 디바이스 조회 (1 쿼리)
95+
List<Long> enabledDeviceIds = settingRepository
96+
.findEnabledDeviceIdsByUserIdsAndType(userIds, type);
97+
if (enabledDeviceIds.isEmpty()) return;
98+
99+
// (d) 토큰 조회 (1 쿼리)
100+
List<String> tokens = userDeviceRepository.findTokensByUserDeviceIds(enabledDeviceIds);
101+
if (tokens.isEmpty()) return;
102+
103+
// (e) FCM 전송
104+
PushMessageCommand cmd = PushMessageCommand.createPushMessageCommand(
105+
rendered.pushTitle(), rendered.pushBody(),
106+
type.name(), announcementId, 1, null);
107+
108+
for (int i = 0; i < tokens.size(); i += FCM_MAX_TOKENS) {
109+
List<String> batch = tokens.subList(i, Math.min(i + FCM_MAX_TOKENS, tokens.size()));
110+
fcmMessageClient.sendToDevices(batch, cmd);
111+
}
112+
}
113+
}

src/main/java/org/devkor/apu/saerok_server/domain/announcement/application/AnnouncementPublicationService.java

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,15 @@
33
import lombok.RequiredArgsConstructor;
44
import org.devkor.apu.saerok_server.domain.admin.announcement.core.entity.Announcement;
55
import org.devkor.apu.saerok_server.domain.admin.announcement.core.repository.AnnouncementRepository;
6-
import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifySystemService;
7-
import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType;
8-
import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository;
6+
import org.devkor.apu.saerok_server.domain.announcement.application.event.AnnouncementPublishedEvent;
7+
import org.springframework.context.ApplicationEventPublisher;
98
import org.springframework.stereotype.Service;
109
import org.springframework.transaction.annotation.Transactional;
1110

1211
import java.time.LocalDateTime;
1312
import java.time.OffsetDateTime;
1413
import java.time.ZoneId;
1514
import java.util.List;
16-
import java.util.Map;
1715

1816
@Service
1917
@Transactional
@@ -23,8 +21,7 @@ public class AnnouncementPublicationService {
2321
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
2422

2523
private final AnnouncementRepository announcementRepository;
26-
private final UserRepository userRepository;
27-
private final NotifySystemService notifySystemService;
24+
private final ApplicationEventPublisher eventPublisher;
2825

2926
public OffsetDateTime toKstOffset(LocalDateTime localDateTime) {
3027
if (localDateTime == null) return null;
@@ -59,22 +56,6 @@ public void notifyPublishedAnnouncement(Announcement announcement) {
5956
if (!announcement.isPublished() || !Boolean.TRUE.equals(announcement.getSendNotification())) {
6057
return;
6158
}
62-
List<Long> userIds = userRepository.findActiveUserIds();
63-
if (userIds.isEmpty()) {
64-
return;
65-
}
66-
Map<String, Object> extras = Map.of(
67-
"announcementId", announcement.getId(),
68-
"title", announcement.getPushTitle(),
69-
"body", announcement.getPushBody(),
70-
"inAppBody", announcement.getInAppBody()
71-
);
72-
73-
notifySystemService.notifyUsersDeduplicatedPush(
74-
userIds,
75-
NotificationType.SYSTEM_PUBLISHED_ANNOUNCEMENT,
76-
announcement.getId(),
77-
extras
78-
);
59+
eventPublisher.publishEvent(new AnnouncementPublishedEvent(announcement.getId()));
7960
}
8061
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package org.devkor.apu.saerok_server.domain.announcement.application.event;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.devkor.apu.saerok_server.domain.announcement.application.AnnouncementBulkNotificationService;
6+
import org.springframework.scheduling.annotation.Async;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.transaction.event.TransactionPhase;
9+
import org.springframework.transaction.event.TransactionalEventListener;
10+
11+
@Slf4j
12+
@Component
13+
@RequiredArgsConstructor
14+
public class AnnouncementNotificationWorker {
15+
16+
private final AnnouncementBulkNotificationService bulkNotificationService;
17+
18+
@Async("announcementNotificationExecutor")
19+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
20+
public void handle(AnnouncementPublishedEvent event) {
21+
try {
22+
bulkNotificationService.sendBulkNotification(event.announcementId());
23+
} catch (Exception e) {
24+
log.error("Failed to send bulk announcement notification: announcementId={}",
25+
event.announcementId(), e);
26+
}
27+
}
28+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package org.devkor.apu.saerok_server.domain.announcement.application.event;
2+
3+
public record AnnouncementPublishedEvent(Long announcementId) {}

src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationRepository.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,18 @@ public void markAllAsReadByUserId(Long userId) {
5656
.setParameter("userId", userId)
5757
.executeUpdate();
5858
}
59+
60+
public void batchInsert(List<Notification> notifications, int batchSize) {
61+
for (int i = 0; i < notifications.size(); i++) {
62+
em.persist(notifications.get(i));
63+
if ((i + 1) % batchSize == 0) {
64+
em.flush();
65+
em.clear();
66+
}
67+
}
68+
if (!notifications.isEmpty() && notifications.size() % batchSize != 0) {
69+
em.flush();
70+
em.clear();
71+
}
72+
}
5973
}

src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,20 @@ public List<Long> findEnabledDeviceIdsByUserAndType(Long userId, NotificationTyp
5252
.getResultList();
5353
}
5454

55+
public List<Long> findEnabledDeviceIdsByUserIdsAndType(List<Long> userIds, NotificationType type) {
56+
if (userIds == null || userIds.isEmpty()) return List.of();
57+
return em.createQuery("""
58+
select ns.userDevice.id
59+
from NotificationSetting ns
60+
where ns.userDevice.user.id in :userIds
61+
and ns.type = :type
62+
and ns.enabled = true
63+
""", Long.class)
64+
.setParameter("userIds", userIds)
65+
.setParameter("type", type)
66+
.getResultList();
67+
}
68+
5569
public void save(NotificationSetting setting) {
5670
em.persist(setting);
5771
}

src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,25 @@ public List<Long> findActiveUserIds() {
4949
.setParameter("withdrawn", SignupStatusType.WITHDRAWN)
5050
.getResultList();
5151
}
52+
53+
public List<Long> findActiveUserIds(int offset, int limit) {
54+
return em.createQuery(
55+
"SELECT u.id FROM User u " +
56+
"WHERE u.deletedAt IS NULL AND u.signupStatus <> :withdrawn " +
57+
"ORDER BY u.id",
58+
Long.class)
59+
.setParameter("withdrawn", SignupStatusType.WITHDRAWN)
60+
.setFirstResult(offset)
61+
.setMaxResults(limit)
62+
.getResultList();
63+
}
64+
65+
public List<User> findByIds(List<Long> ids) {
66+
if (ids == null || ids.isEmpty()) return List.of();
67+
return em.createQuery(
68+
"SELECT u FROM User u WHERE u.id IN :ids",
69+
User.class)
70+
.setParameter("ids", ids)
71+
.getResultList();
72+
}
5273
}

src/main/java/org/devkor/apu/saerok_server/global/core/config/infra/AsyncConfig.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,15 @@ public Executor pushNotificationExecutor() {
2020
executor.initialize();
2121
return executor;
2222
}
23+
24+
@Bean(name = "announcementNotificationExecutor")
25+
public Executor announcementNotificationExecutor() {
26+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
27+
executor.setCorePoolSize(1);
28+
executor.setMaxPoolSize(2);
29+
executor.setQueueCapacity(10);
30+
executor.setThreadNamePrefix("ann-notify-");
31+
executor.initialize();
32+
return executor;
33+
}
2334
}

0 commit comments

Comments
 (0)