| name | notification |
| description | Use when sending Firebase Cloud Messaging (FCM) push notifications. Covers `FcmClient` in ssolv-infrastructure, device token storage/rotation (`DeviceTokenEntity`), notification event payloads, and opt-out policy. Trigger on any FCM / push / device token / notification-related code. |
Notification Patterns (FCM)
๊ฐ์
FCM ํธ์ ์๋ฆผ์ ssolv-infrastructure: FcmClient๋ฅผ ํตํด ๋ฐ์กํ๋ค.
์๋ฆผ ๋ฐ์ก์ ํญ์ ํธ๋์ญ์
์ปค๋ฐ ์ดํ์ ์ํํ๋ค โ ํธ๋์ญ์
๋กค๋ฐฑ์ด ๋ฐ์ํด๋ ์๋ฆผ์ด ๋ฐ์ก๋๋ ๊ฒ์ ๋ฐฉ์ง.
FcmClient ์ฌ์ฉ ํจํด
fcmClient.send(
token = deviceToken,
title = "์๋ฆผ ์ ๋ชฉ",
body = "์๋ฆผ ๋ด์ฉ",
data = mapOf("type" to "MEETING_RESULT_READY", "meetingToken" to token)
)
fcmClient.sendMulticast(
tokens = validTokens,
title = "์๋ฆผ ์ ๋ชฉ",
body = "์๋ฆผ ๋ด์ฉ",
data = mapOf("path" to "/meetings/$meetingToken/result/overview")
)
ํธ๋์ญ์
-ํ ๋ฐ์ก ํจํด
@Service
class SomeNotificationService(
private val someRepository: SomeRepository,
private val deviceTokenQueryRepository: DeviceTokenQueryRepository,
private val fcmClient: FcmClient,
private val transactionTemplate: TransactionTemplate,
private val stringRedisTemplate: StringRedisTemplate,
) {
suspend fun send(targetId: Long) = withTracingContext() {
val idempotencyKey = "sent:notification:SOME_EVENT:$targetId"
val isNew = stringRedisTemplate.opsForValue()
.setIfAbsent(idempotencyKey, "true", Duration.ofHours(1)) ?: false
if (!isNew) return@withTracingContext
val data = transactionTemplate.execute {
runBlocking { someRepository.findById(targetId) }
} ?: return@withTracingContext
val tokens = transactionTemplate.execute {
runBlocking {
deviceTokenQueryRepository.findValidTokensByUserIds(
userIds = listOf(data.userId),
isNotificationEnabled = true
)
}
} ?: emptyList()
if (tokens.isEmpty()) return@withTracingContext
fcmClient.sendMulticast(tokens = tokens, title = "...", body = "...", data = mapOf(...))
}
}
ํต์ฌ: DB ์กฐํ๋ transactionTemplate.execute { runBlocking { ... } } ์์์,
FCM ๋ฐ์ก์ ๊ทธ ๋ฐ๊นฅ์์ ์ํํ๋ค.
๋๋ฐ์ด์ค ํ ํฐ ๊ด๋ฆฌ
POST /api/v1/notifications/device-token
DELETE /api/v1/notifications/device-token
DeviceTokenQueryRepository๋ isNotificationEnabled = true์ธ ํ ํฐ๋ง ์กฐํํ๋ค.
์ ์ ๊ฐ ์๋ฆผ์ ๋ ๊ฒฝ์ฐ ์๋์ผ๋ก ์ ์ธ๋จ.
Redis Stream ๊ธฐ๋ฐ ๋น๋๊ธฐ ์๋ฆผ
์๋ฆผ ๋ฐ์ก์ด ๋ฉ์ธ ํ๋ก์ฐ์ ๋ถ๋ฆฌ๋์ด์ผ ํ ๋ Redis Stream์ ์ฌ์ฉํ๋ค.
(์: ๋ฏธํ
๊ฒฐ๊ณผ ๊ณ์ฐ ์๋ฃ ํ ์ ์ฒด ์ฐธ์์์๊ฒ ์๋ฆผ)
์ค๋ฌธ ์๋ฃ โ meeting_calculation_stream ์ด๋ฒคํธ ๋ฐํ
โ PlaceSearchConsumer โ ์ฅ์ ์ถ์ฒ ์๋ฃ
โ meeting_notification_stream ์ด๋ฒคํธ ๋ฐํ
โ MeetingNotificationConsumer โ SendMeetingResultNotificationService.send()
Stream ์์๋ ssolv-domain: RedisStreamConstants ์ฐธ๊ณ .
Consumer ๊ตฌํ ํจํด์ /async-processing ์คํฌ ์ฐธ๊ณ .
๋ฉฑ๋ฑ์ฑ (์ค๋ณต ๋ฐ์ก ๋ฐฉ์ง)
๊ฐ์ ์ด๋ฒคํธ๋ก ์๋ฆผ์ด ๋ ๋ฒ ๋ฐ์ก๋์ง ์๋๋ก Redis setIfAbsent๋ก ์ ๊ธํ๋ค.
val key = "sent:notification:{EVENT_TYPE}:{entityId}:{userId}"
val acquired = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "true", Duration.ofHours(1)) ?: false
if (!acquired) return
TTL์ ์ด๋ฒคํธ ์ฑ๊ฒฉ์ ๋ฐ๋ผ ์กฐ์ . ๋ฏธํ
๊ฒฐ๊ณผ ์๋ฆผ์ 1์๊ฐ์ผ๋ก ์ถฉ๋ถ.
data ํ์ด๋ก๋ ๊ท์น
์ฑ์์ ๋ฅ๋งํฌ ์ฒ๋ฆฌ๋ฅผ ์ํด data ๋งต์ ์๋ ํค๋ฅผ ํฌํจํ๋ค:
mapOf(
"type" to "MEETING_RESULT_READY",
"meetingToken" to meetingToken,
"path" to "/meetings/$meetingToken/result/overview"
)
ํ
์คํธ ์ฒ๋ฆฌ
ํตํฉ ํ
์คํธ์์ FCM ํด๋ผ์ด์ธํธ๋ ๋ฐ๋์ Mock์ผ๋ก ๋์ฒดํ๋ค (์ค์ FCM ํธ์ถ ๊ธ์ง).
@IntegrationTest
class NotificationIntegrationTest {
@MockitoBean private lateinit var fcmClient: FcmClient
@MockkBean private lateinit var fcmClient: FcmClient
}
๋จ์ ํ
์คํธ์์:
@UnitTest
class SendMeetingResultNotificationServiceTest {
@Mock private lateinit var fcmClient: FcmClient
@Mock private lateinit var deviceTokenQueryRepository: DeviceTokenQueryRepository
@Test
fun `์๋ฆผ ์์ ๋น๋์ ์ฌ์ฉ์๋ FCM ํธ์ถ ์ ํจ`() = runTest {
whenever(deviceTokenQueryRepository.findValidTokensByUserIds(any(), any()))
.thenReturn(emptyList())
service.send(meetingId = 1L, userId = 1L)
verifyNoInteractions(fcmClient)
}
}