diff --git a/src/main/kotlin/com/moa/service/FcmService.kt b/src/main/kotlin/com/moa/service/FcmService.kt index 1378139..94f5ea9 100644 --- a/src/main/kotlin/com/moa/service/FcmService.kt +++ b/src/main/kotlin/com/moa/service/FcmService.kt @@ -9,33 +9,58 @@ import org.springframework.stereotype.Service class FcmService( private val fcmTokenRepository: FcmTokenRepository, ) { - private val log = LoggerFactory.getLogger(javaClass) - fun send(token: String, data: Map): Boolean { - val message = Message.builder() + fun sendEach(requests: List>>): List { + if (requests.isEmpty()) return emptyList() + val results = ArrayList(requests.size) + requests.chunked(MAX_BATCH_SIZE).forEach { batch -> + try { + val messages = batch.map { (token, data) -> buildMessage(token, data) } + val response = FirebaseMessaging.getInstance().sendEach(messages) + response.responses.forEachIndexed { i, sendResponse -> + if (!sendResponse.isSuccessful) { + handleFcmException(sendResponse.exception, batch[i].first) + } + results.add(sendResponse.isSuccessful) + } + } catch (ex: Exception) { + log.error("FCM batch send failed for {} messages", batch.size, ex) + repeat(batch.size) { results.add(false) } + } + } + return results + } + + private fun buildMessage(token: String, data: Map): Message = + Message.builder() .setToken(token) + .setNotification( + Notification.builder() + .setTitle(data["title"]) + .setBody(data["body"]) + .build() + ) .putAllData(data) .build() - return try { - FirebaseMessaging.getInstance().send(message) - true - } catch (ex: FirebaseMessagingException) { - handleFcmException(ex, token) - false + private fun handleFcmException(ex: Exception?, token: String) { + val fcmEx = ex as? FirebaseMessagingException ?: run { + log.error("FCM 전송 중 예상치 못한 예외: {}", token, ex) + return } - } - - private fun handleFcmException(ex: FirebaseMessagingException, token: String) { - when (ex.messagingErrorCode) { + when (fcmEx.messagingErrorCode) { MessagingErrorCode.UNREGISTERED, MessagingErrorCode.INVALID_ARGUMENT -> { log.warn("유효하지 않은 FCM 토큰 삭제: {}", token) fcmTokenRepository.deleteByToken(token) } - else -> log.error("FCM 전송 실패", ex) + else -> log.error("FCM 전송 실패: {}", token, fcmEx) } } + + companion object { + private const val MAX_BATCH_SIZE = 500 + } } diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index a5dcfed..e1323c8 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -88,7 +88,10 @@ class WorkdayService( } else { val policy = workPolicyVersionRepository .findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc(memberId, date) - ?: throw NotFoundException() + ?: workPolicyVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + memberId, + LocalDate.now() + ) ?: throw NotFoundException() // 요청값이 하나라도 비어있으면 정책의 기본 시간을 할당 policy.clockInTime to policy.clockOutTime diff --git a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt index cbf7b7d..e863b23 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt @@ -1,26 +1,24 @@ package com.moa.service.notification +import com.moa.entity.NotificationLog import com.moa.entity.NotificationStatus +import com.moa.repository.FcmTokenRepository import com.moa.repository.NotificationLogRepository import com.moa.service.FcmService -import com.moa.service.FcmTokenService import org.slf4j.LoggerFactory import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional import java.time.LocalDate import java.time.LocalTime @Service class NotificationDispatchService( private val notificationLogRepository: NotificationLogRepository, - private val fcmTokenService: FcmTokenService, + private val fcmTokenRepository: FcmTokenRepository, private val fcmService: FcmService, private val notificationMessageBuilder: NotificationMessageBuilder, ) { - private val log = LoggerFactory.getLogger(javaClass) - @Transactional fun processNotifications(date: LocalDate, currentTime: LocalTime) { val pendingLogs = notificationLogRepository .findAllByScheduledDateAndScheduledTimeLessThanEqualAndStatus( @@ -28,35 +26,41 @@ class NotificationDispatchService( scheduledTime = currentTime, status = NotificationStatus.PENDING, ) - if (pendingLogs.isEmpty()) return log.info("Dispatching {} pending notifications", pendingLogs.size) - for (notification in pendingLogs) { - val tokens = fcmTokenService.getTokensByMemberId(notification.memberId) + val memberIds = pendingLogs.map { it.memberId }.distinct() + val tokensByMemberId = fcmTokenRepository.findAllByMemberIdIn(memberIds) + .groupBy { it.memberId } + data class DispatchItem( + val notification: NotificationLog, + val token: String, + val data: Map, + ) + + val dispatchItems = mutableListOf() + for (notification in pendingLogs) { + val tokens = tokensByMemberId[notification.memberId].orEmpty() if (tokens.isEmpty()) { notification.status = NotificationStatus.FAILED log.warn("No FCM tokens for member {}, marking as FAILED", notification.memberId) continue } + val data = notificationMessageBuilder.buildMessage(notification).toData() + tokens.forEach { dispatchItems.add(DispatchItem(notification, it.token, data)) } + } - val message = notificationMessageBuilder.buildMessage(notification) - val data = mapOf( - "title" to message.title, - "body" to message.body, - "type" to message.type.name, - ) - - var anySuccess = false - for (token in tokens) { - if (fcmService.send(token.token, data)) { - anySuccess = true - } + if (dispatchItems.isNotEmpty()) { + val results = fcmService.sendEach(dispatchItems.map { it.token to it.data }) + results.forEachIndexed { i, success -> + if (success) dispatchItems[i].notification.status = NotificationStatus.SENT } - - notification.status = if (anySuccess) NotificationStatus.SENT else NotificationStatus.FAILED + pendingLogs.filter { it.status == NotificationStatus.PENDING } + .forEach { it.status = NotificationStatus.FAILED } } + + notificationLogRepository.saveAll(pendingLogs) } } diff --git a/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt index b2c9e3b..b03261f 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt @@ -4,10 +4,8 @@ import com.moa.entity.DailyWorkScheduleType import com.moa.entity.NotificationLog import com.moa.entity.NotificationType import com.moa.repository.DailyWorkScheduleRepository -import com.moa.repository.ProfileRepository import com.moa.repository.WorkPolicyVersionRepository import com.moa.service.EarningsCalculator -import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.math.BigDecimal import java.text.NumberFormat @@ -17,12 +15,10 @@ import java.util.* @Service class NotificationMessageBuilder( - private val profileRepository: ProfileRepository, private val workPolicyVersionRepository: WorkPolicyVersionRepository, private val dailyWorkScheduleRepository: DailyWorkScheduleRepository, private val earningsCalculator: EarningsCalculator, ) { - private val log = LoggerFactory.getLogger(javaClass) fun buildMessage(notification: NotificationLog): NotificationMessage { val title = notification.notificationType.title @@ -66,4 +62,10 @@ class NotificationMessageBuilder( } } -data class NotificationMessage(val title: String, val body: String, val type: NotificationType) +data class NotificationMessage(val title: String, val body: String, val type: NotificationType) { + fun toData(): Map = mapOf( + "title" to title, + "body" to body, + "type" to type.name, + ) +}