200만 유저에게 동시에 푸시를 보내야 한다면 어떻게 해야 할까?
FCM + Kafka 기반 대규모 푸시 시스템을 구축한 경험을 정리한다.
1. FCM 기본 개념
Firebase Cloud Messaging은 Android/iOS에 푸시를 보내는 크로스 플랫폼 솔루션이다.
메시지 구조
{
"message": {
"notification": {
"title": "제목",
"body": "메시지 내용"
},
"token": "user-specific-device-token"
}
}두 가지 전송 방식
| 방식 | 장점 | 단점 |
|---|---|---|
| 토큰 기반 | 특정 유저에게 개별 전송 가능 | 대량 전송 시 딜레이 |
| 토픽 기반 | 등록된 모든 유저에게 효율적 전송 | 개별 전송 번거로움 |
2. 아키텍처 설계
전체 흐름
왜 Kafka를 선택했나?
처음 구조는 API 서버가 FCM을 직접 호출하는 방식이었다.
운영툴에서 발송 요청이 들어오면 API 서버가 FCM 응답을 기다려야 해서 부하가 한 곳에 집중됐고, 전송에 실패하면 재시도 로직을 API 레이어에 직접 구현해야 했다.
푸시 이력 저장과 FCM 호출이 한 흐름에 묶여 있어 트랜잭션 보장도 어려웠다.
Kafka로 FCM 호출을 API 요청에서 분리할 수 있다.
다만 푸시 이력 저장과 메시지 발행을 하나의 트랜잭션으로 보장하는 문제는 별도로 다뤄야 한다.
API는 Kafka에 메시지를 발행하고 바로 응답을 반환하며, 실제 전송은 Consumer가 맡는다.
전송이 실패해도 Consumer 레벨에서 재시도하면 되고, 발송량이 늘면 Consumer를 수평 확장하는 식으로 대응할 수 있다.
3. 토픽 기반 전송 시스템
토픽 네이밍 규칙
{Environment}-{ProjectCode}-{RegionCode}-{LanguageType}
예시:
- product: mqz-kr-kor
- dev: dev-mqz-us-eng리전과 언어별로 토픽을 분리해 다국어 푸시를 지원한다.
Kafka 메시지 구조
data class SendKotlinFcmTopicMessage(
val projectCode: String,
val regionCode: String,
val pushHistoryId: Long
)Consumer 구현
@Component
class SendFcmTopicConsumer(
private val fcmPort: FcmPort,
private val fcmService: FcmUseCase,
private val localizationProvider: IPushLocalizationProvider,
private val pushHistoryPort: PushHistoryPort,
) {
@KafkaListener(topics = [KafkaTopic.SendKotlinFcmTopic], concurrency = "1")
@Transactional
fun listen(message: String) {
val receive = objectMapper.readValue<SendKotlinFcmTopicMessage>(message)
val pushHistory = pushHistoryPort.findPushHistoryById(receive.pushHistoryId)
.orElseThrow { CustomException(ResponseCode.ERROR) }
// 언어별로 각 토픽에 전송
localizationProvider.findLangs().forEach { lang ->
val notification = fcmService.createNotification(
receive.projectCode,
pushHistory.title,
pushHistory.body,
pushHistory.getExtraData()?.titleTransferStrings,
pushHistory.getExtraData()?.bodyTransferStrings,
lang
)
fcmPort.sendFcmMessageToTopic(
receive.projectCode,
TopicHelper.createTopic(receive.projectCode, receive.regionCode, lang),
notification
)
}
}
}Consumer는 pushHistoryId로 DB에서 푸시 내용을 조회한 뒤, 지원하는 언어를 순회하며 로컬라이징된 메시지를 만들어 각 토픽으로 전송한다.
4. 토픽 구독 관리
유저가 앱을 설치하거나 설정을 변경할 때 토픽 구독을 업데이트해야 한다.
토큰 업데이트 Kafka 메시지
data class UpdateFcmTopicTokenMessage(
@JsonProperty("ProjectCode") val projectCode: String,
@JsonProperty("RegionCode") val regionCode: String,
@JsonProperty("LanguageType") val languageType: String,
@JsonProperty("OldFcmToken") val oldFcmToken: String?,
@JsonProperty("NewFcmToken") val newFcmToken: String?,
@JsonProperty("Uid") val uid: String
)Consumer 구현
@KafkaListener(topics = [KafkaTopic.UpdateFcmTopicTokenTopic], concurrency = "1")
@Transactional
fun listen(message: String) {
val receive = objectMapper.readValue<UpdateFcmTopicTokenMessage>(message)
val userConnection = userConnectionPort.findUser(receive.uid, project.id!!)
userConnection.fcmToken = receive.newFcmToken
when {
// 기존 토큰 제거
receive.newFcmToken.isNullOrBlank() && !receive.oldFcmToken.isNullOrBlank() -> {
fcmPort.removeTopic(
receive.projectCode,
receive.oldFcmToken,
TopicHelper.createTopic(receive.projectCode, receive.regionCode, receive.languageType)
)
}
// 새 토큰 추가
!receive.newFcmToken.isNullOrBlank() && receive.oldFcmToken.isNullOrBlank() -> {
fcmPort.registryTopic(
receive.projectCode,
receive.newFcmToken,
TopicHelper.createTopic(receive.projectCode, receive.regionCode, receive.languageType)
)
}
// 토큰 교체 (기존 삭제 + 새로 추가)
!receive.newFcmToken.isNullOrBlank() && receive.newFcmToken != receive.oldFcmToken -> {
receive.oldFcmToken?.let {
fcmPort.removeTopic(receive.projectCode, it, topic)
}
fcmPort.registryTopic(receive.projectCode, receive.newFcmToken, topic)
}
}
}when 분기가 다루는 케이스는 세 가지다.
로그아웃이나 앱 삭제로 토큰이 사라지면 구독을 해제하고, 신규 설치면 새 토큰을 구독시키고, 앱 재설치나 토큰 갱신으로 토큰이 바뀌면 기존 구독을 제거한 뒤 새로 등록한다.
5. 언어별 로컬라이징
다국어 푸시를 위한 로컬라이징 처리:
override fun createNotification(
projectCode: String,
title: String,
body: String,
titleStrings: List<String>?,
bodyStrings: List<String>?,
language: String
): Notification {
val localizedTitle = MessageFormat.format(
pushLocalizationProvider.find(projectCode, title, language) ?: "",
*(titleStrings?.toTypedArray() ?: arrayOf())
)
val localizedBody = MessageFormat.format(
pushLocalizationProvider.find(projectCode, body, language) ?: "",
*(bodyStrings?.toTypedArray() ?: arrayOf())
)
return Notification(localizedTitle, localizedBody, "")
}인자로 받는 title과 body는 메시지 원문이 아니라 로컬라이징 키다.
language로 해당 언어의 문자열을 조회한 뒤, MessageFormat으로 동적 값을 치환해 최종 메시지를 만든다.
6. FcmPort 인터페이스
FCM 관련 기능을 추상화한 포트:
interface FcmPort {
// 개별 토큰 다수에게 전송
fun sendAllSpecificTokens(projectCode: String, tokens: Set<String>, notification: Notification): List<BatchResponse>
// 개별 토큰에 전송
fun sendSpecificToken(projectCode: String, token: String?, notification: Notification)
// 토픽 구독
fun registryTopic(projectCode: String, token: String, topic: String)
// 토픽 구독 해제
fun removeTopic(projectCode: String, token: String, topic: String)
// 토픽으로 전송
fun sendFcmMessageToTopic(projectCode: String, topic: String, notification: Notification)
}7. 결과
응답 시간이나 처리량을 수치로 계측해 두진 않았다.
확인한 것은 두 가지다.
운영툴에서 발송을 걸었을 때 API가 FCM 응답을 기다리지 않고 Kafka 발행 직후 응답을 돌려준다는 것, 그리고 전송 실패가 API 에러로 이어지지 않고 Consumer의 재시도로 흡수된다는 것이다.
구조가 바뀌면서 확장 방식도 달라졌다.
직접 호출 구조에서는 발송량이 늘면 API 서버도 함께 증설해야 했지만, 이제는 Consumer만 수평 확장하면 된다.
마무리
돌아보면 이 시스템에서 공이 들어간 부분은 전송 코드 자체보다 토픽 설계와 토큰 라이프사이클 관리였다.
리전과 언어로 토픽을 나눠 두면 대규모 발송이 토픽 하나에 메시지를 넣는 문제로 단순해지고, 등록·삭제·교체 케이스를 빠짐없이 처리해야 구독 상태가 실제 기기와 어긋나지 않는다.
Kafka Consumer의 Static Membership 설정 관련 이슈는 별도 글에서 다룬다.