200만 유저에게 푸시 보내기 - Kafka + FCM 대규모 푸시 시스템 구축기
200만 유저에게 동시에 푸시를 보내야 한다면 어떻게 해야 할까? FCM + Kafka 기반 대규모 푸시 시스템을 구축한 경험을 정리한다.
1. FCM 기본 개념
Firebase Cloud Messaging은 Android/iOS에 푸시를 보내는 크로스 플랫폼 솔루션이다.
메시지 구조
{
"message": {
"notification": {
"title": "제목",
"body": "메시지 내용"
},
"token": "user-specific-device-token"
}
}두 가지 전송 방식
| 방식 | 장점 | 단점 |
|---|---|---|
| 토큰 기반 | 특정 유저에게 개별 전송 가능 | 대량 전송 시 딜레이 |
| 토픽 기반 | 등록된 모든 유저에게 효율적 전송 | 개별 전송 번거로움 |
2. 아키텍처 설계
전체 흐름
flowchart LR
Admin["운영툴"] --> API["Integration API"]
API --> Kafka["Kafka"]
Kafka --> Consumer["Kotlin Consumer"]
Consumer --> FCM["Firebase"]
FCM --> Device["200만 기기"]
style Kafka fill:#ef4444,color:#fff
style FCM fill:#fbbf24,color:#000왜 Kafka를 선택했나?
직접 FCM 호출의 문제:
- API 서버에 부하 집중
- 실패 시 재시도 로직 복잡
- 트랜잭션 보장 어려움
Kafka 도입 효과:
- 비동기 처리로 API 응답 빠름
- 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
)
}
}
}핵심:
pushHistoryId로 DB에서 푸시 내용 조회- 언어별로 로컬라이징된 메시지 생성
- 각 토픽으로 FCM 전송
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)
}
}
}처리 케이스:
- 토큰 제거 (로그아웃, 앱 삭제)
- 새 토큰 추가 (신규 설치)
- 토큰 교체 (앱 재설치, 토큰 갱신)
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. 결과
Before/After
| 항목 | Before (직접 호출) | After (Kafka + FCM) |
|---|---|---|
| API 응답 시간 | 느림 (FCM 응답 대기) | 빠름 (Kafka 전송 후 즉시 응답) |
| 처리 안정성 | 실패 시 유실 위험 | Consumer에서 재시도 |
| 확장성 | 제한적 | Consumer 수평 확장 |
얻은 것
-
토픽 기반 전송 활용
- 대규모 전송에 효율적
- 리전/언어별 세분화로 타겟팅
-
Kafka로 비동기 처리
- API 부하 분산
- 안정적인 메시지 처리
-
토큰 라이프사이클 관리
- 등록/삭제/교체 케이스 모두 처리
- 유효하지 않은 토큰 정리
-
로컬라이징 분리
- 푸시 내용과 번역 분리
- 언어별 토픽으로 효율적 전송
마무리
Kafka + FCM으로 대규모 푸시 시스템을 구축했다. 핵심은 토픽 기반 전송, Kafka를 통한 비동기 처리, 그리고 토큰 라이프사이클 관리다.
Kafka Consumer의 Static Membership 설정 관련 이슈는 별도 글에서 다룬다.
참고 자료
Loading comments...