Skip to content

Commit a9260f5

Browse files
[BOOK-433] refactor: 멀티디바이스에서 유효하지 않은 FID/FCM 토큰 정리 로직 추가 (#129) (#130)
* [BOOK-433] refactor: 멀티디바이스에서 유효하지 않은 FID/FCM 토큰 정리 로직 추가 (#129) * [BOOK-433] refactor: dto 정적팩토리 메서드 패턴 사용 및 매직넘버 상수처리 * [BOOK-433] refactor: batch - INVALID_ARGUMENT 처리 개선 --------- Co-authored-by: DongHoon Lee <[email protected]>
1 parent b55b7fc commit a9260f5

File tree

7 files changed

+133
-36
lines changed

7 files changed

+133
-36
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package org.yapp.batch.dto
2+
3+
data class FcmSendResult private constructor(
4+
val successCount: Int,
5+
val failureCount: Int,
6+
val invalidTokens: List<String>
7+
) {
8+
companion object {
9+
private const val ZERO_COUNT = 0
10+
11+
fun of(
12+
successCount: Int,
13+
failureCount: Int,
14+
invalidTokens: List<String>
15+
): FcmSendResult {
16+
return FcmSendResult(successCount, failureCount, invalidTokens)
17+
}
18+
19+
fun empty(): FcmSendResult {
20+
return FcmSendResult(
21+
successCount = ZERO_COUNT,
22+
failureCount = ZERO_COUNT,
23+
invalidTokens = emptyList()
24+
)
25+
}
26+
27+
fun allFailed(failureCount: Int): FcmSendResult {
28+
return FcmSendResult(
29+
successCount = ZERO_COUNT,
30+
failureCount = failureCount,
31+
invalidTokens = emptyList()
32+
)
33+
}
34+
}
35+
}
Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,87 @@
11
package org.yapp.batch.service
22

3-
import com.google.firebase.messaging.FirebaseMessaging
4-
import com.google.firebase.messaging.Message
5-
import com.google.firebase.messaging.Notification
3+
import com.google.firebase.messaging.*
64
import org.slf4j.LoggerFactory
75
import org.springframework.stereotype.Service
6+
import org.yapp.batch.dto.FcmSendResult
87

98
@Service
109
class FcmService {
1110
private val logger = LoggerFactory.getLogger(FcmService::class.java)
1211

13-
fun sendNotification(token: String, title: String, body: String): String? {
14-
try {
15-
val notification = Notification.builder()
16-
.setTitle(title)
17-
.setBody(body)
18-
.build()
12+
fun sendMulticastNotification(tokens: List<String>, title: String, body: String): FcmSendResult {
13+
if (tokens.isEmpty()) {
14+
logger.warn("FCM token list is empty. Skipping notification.")
15+
return FcmSendResult.empty()
16+
}
1917

20-
val message = Message.builder()
21-
.setToken(token)
18+
val notification = buildNotification(title, body)
19+
val messages = tokens.map { token ->
20+
Message.builder()
2221
.setNotification(notification)
22+
.setToken(token)
2323
.build()
24+
}
2425

25-
val response = FirebaseMessaging.getInstance().send(message)
26-
logger.info("Successfully sent message: {}", response)
27-
return response
28-
} catch (e: Exception) {
29-
logger.error("Failed to send FCM notification", e)
30-
return null
26+
try {
27+
val response = FirebaseMessaging.getInstance().sendEach(messages)
28+
return processFcmResponse(response, tokens)
29+
} catch (e: FirebaseMessagingException) {
30+
logger.error("Failed to send FCM notification to ${tokens.size} tokens", e)
31+
return FcmSendResult.allFailed(tokens.size)
3132
}
3233
}
3334

34-
fun sendMulticastNotification(tokens: List<String>, title: String, body: String): List<String> {
35-
val successfulSends = mutableListOf<String>()
35+
private fun buildNotification(title: String, body: String): Notification {
36+
return Notification.builder()
37+
.setTitle(title)
38+
.setBody(body)
39+
.build()
40+
}
41+
42+
private fun processFcmResponse(response: BatchResponse, tokens: List<String>): FcmSendResult {
43+
val invalidTokens = mutableListOf<String>()
44+
val noFailures = 0
45+
46+
if (response.failureCount > noFailures) {
47+
response.responses.forEachIndexed { index, sendResponse ->
48+
if (sendResponse.isSuccessful) {
49+
return@forEachIndexed
50+
}
51+
52+
val failedToken = tokens[index]
53+
val errorCode = sendResponse.exception?.messagingErrorCode
3654

37-
tokens.forEach { token ->
38-
val messageId = sendNotification(token, title, body)
39-
if (messageId != null) {
40-
successfulSends.add(messageId)
55+
if (errorCode == MessagingErrorCode.UNREGISTERED) {
56+
invalidTokens.add(failedToken)
57+
logger.warn("Unregistered FCM token: {}. Error: {}", failedToken, errorCode)
58+
return@forEachIndexed
59+
}
60+
61+
if (errorCode == MessagingErrorCode.INVALID_ARGUMENT) {
62+
val errorMessage = sendResponse.exception?.message ?: ""
63+
if (errorMessage.contains("invalid registration token", ignoreCase = true)) {
64+
invalidTokens.add(failedToken)
65+
logger.warn("Invalid FCM token format: {}. Error: {}", failedToken, errorMessage)
66+
return@forEachIndexed
67+
}
68+
}
69+
70+
logger.error("Failed to send to token: {}. Error: {}", failedToken, errorCode, sendResponse.exception)
4171
}
4272
}
4373

44-
return successfulSends
74+
logger.info(
75+
"FCM multicast message sent. Success: {}, Failure: {}, Invalid Tokens: {}",
76+
response.successCount,
77+
response.failureCount,
78+
invalidTokens.size
79+
)
80+
81+
return FcmSendResult.of(
82+
successCount = response.successCount,
83+
failureCount = response.failureCount,
84+
invalidTokens = invalidTokens
85+
)
4586
}
4687
}

batch/src/main/kotlin/org/yapp/batch/service/NotificationService.kt

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ class NotificationService(
2525
private const val UNRECORDED_NOTIFICATION_MESSAGE = "이번주에 읽은 책, 잊기 전에 기록해 보세요!"
2626
private const val DORMANT_NOTIFICATION_TITLE = "📚 Reed와 함께 독서 기록 시작"
2727
private const val DORMANT_NOTIFICATION_MESSAGE = "그동안 읽은 책을 모아 기록해 보세요!"
28+
private const val NO_SUCCESSFUL_DEVICES = 0
29+
private const val NO_DEVICES_SENT = 0
2830
}
2931

3032
@Transactional
@@ -91,17 +93,17 @@ class NotificationService(
9193
val userId = User.Id.newInstance(user.id)
9294
if (notificationDomainService.hasActiveNotification(userId, notificationType)) {
9395
logger.info("User ${user.id} already has active $notificationType notification, skipping")
94-
return Pair(false, 0)
96+
return Pair(false, NO_DEVICES_SENT)
9597
}
9698

9799
val devices = deviceDomainService.findDevicesByUserId(user.id)
98100
if (devices.isEmpty()) {
99101
logger.info("No devices found for user ${user.id}")
100-
return Pair(false, 0)
102+
return Pair(false, NO_DEVICES_SENT)
101103
}
102104

103105
val successDeviceCount = sendToDevices(devices, title, message)
104-
if (successDeviceCount > 0) {
106+
if (successDeviceCount > NO_SUCCESSFUL_DEVICES) {
105107
notificationDomainService.createAndSaveNotification(
106108
userId = userId,
107109
title = title,
@@ -112,7 +114,7 @@ class NotificationService(
112114
}
113115

114116
logger.info("Failed to send notification to any device for user ${user.id}")
115-
return Pair(false, 0)
117+
return Pair(false, NO_DEVICES_SENT)
116118
}
117119

118120
private fun sendToDevices(
@@ -121,20 +123,22 @@ class NotificationService(
121123
message: String
122124
): Int {
123125
val validTokens = devices
124-
.filter { it.fcmToken.isNotBlank() }
125126
.map { it.fcmToken }
127+
.filter { it.isNotBlank() }
126128

127129
if (validTokens.isEmpty()) {
128-
return 0
130+
logger.warn("No valid FCM tokens found for devices: {}", devices.map { it.id })
131+
return NO_DEVICES_SENT
129132
}
130133

131-
return try {
132-
val results = fcmService.sendMulticastNotification(validTokens, title, message)
133-
results.size
134-
} catch (e: Exception) {
135-
logger.error("Error sending notifications to devices", e)
136-
0
134+
val result = fcmService.sendMulticastNotification(validTokens, title, message)
135+
136+
if (result.invalidTokens.isNotEmpty()) {
137+
logger.info("Found ${result.invalidTokens.size} invalid tokens to remove.")
138+
deviceDomainService.removeDevicesByTokens(result.invalidTokens)
137139
}
140+
141+
return result.successCount
138142
}
139143

140144
@Transactional

domain/src/main/kotlin/org/yapp/domain/device/DeviceDomainService.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,10 @@ class DeviceDomainService(
2020
return deviceRepository.findByUserId(userId)
2121
.map { DeviceVO.from(it) }
2222
}
23+
24+
fun removeDevicesByTokens(tokens: List<String>) {
25+
if (tokens.isNotEmpty()) {
26+
deviceRepository.deleteByTokens(tokens)
27+
}
28+
}
2329
}

domain/src/main/kotlin/org/yapp/domain/device/DeviceRepository.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ interface DeviceRepository {
66
fun findByDeviceId(deviceId: String): Device?
77
fun save(device: Device): Device
88
fun findByUserId(userId: UUID): List<Device>
9+
fun deleteByTokens(tokens: List<String>)
910
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
package org.yapp.infra.device
22

33
import org.springframework.data.jpa.repository.JpaRepository
4+
import org.springframework.data.jpa.repository.Modifying
5+
import org.springframework.data.jpa.repository.Query
46
import org.yapp.infra.device.entity.DeviceEntity
57
import java.util.UUID
68

79
interface DeviceJpaRepository : JpaRepository<DeviceEntity, UUID> {
810
fun findByDeviceId(deviceId: String): DeviceEntity?
911
fun findByUserId(userId: UUID): List<DeviceEntity>
12+
13+
@Modifying(clearAutomatically = true)
14+
@Query("DELETE FROM DeviceEntity d WHERE d.fcmToken IN :tokens")
15+
fun deleteByFcmTokenIn(tokens: List<String>)
1016
}

infra/src/main/kotlin/org/yapp/infra/device/DeviceRepositoryImpl.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,8 @@ class DeviceRepositoryImpl(
2121
override fun findByUserId(userId: UUID): List<Device> {
2222
return deviceJpaRepository.findByUserId(userId).map { it.toDomain() }
2323
}
24+
25+
override fun deleteByTokens(tokens: List<String>) {
26+
deviceJpaRepository.deleteByFcmTokenIn(tokens)
27+
}
2428
}

0 commit comments

Comments
 (0)