리텐션 향상을 위한 FCM 푸시 시스템 구축
모바일 서비스의 성패는 유저를 어떻게 다시 불러들이느냐에 달려 있습니다. 프로젝트 Fienmee를 설계하며, 단순히 메시지를 '보내는' 것을 넘어, 유저가 알림을 '관리'하고 서비스가 이를 '추적'할 수 있는 견고한 푸시 시스템을 구축하기 위해 고민했던 과정들을 기록합니다.
1. 플랫폼 통합을 통한 운영 효율화
초기 설계 단계에서 가장 큰 고민은 iOS(APNs)와 Android 플랫폼의 파편화된 푸시 프로토콜을 어떻게 효율적으로 관리하느냐였습니다. 소규모 개발 팀 환경에서 각 플랫폼별 개별 서버를 운영하는 것은 리소스 낭비가 컸고, 전송 로직의 일관성을 유지하기도 어려웠습니다.
이를 해결하기 위해 전송 통로를 FCM(Firebase Cloud Messaging)으로 단일화하는 전략을 채택했습니다. FCM의 추상화 계층을 활용하면 서버 측에서 단 하나의 API 호출만으로 구글이 각 OS에 맞춰 메시지를 전달해주기 때문입니다. 덕분에 까다로운 APNs HTTP/2 프로토콜 구현이나 매년 갱신해야 하는 인증서 관리 부담을 Firebase에 위임하고 비즈니스 로직에만 집중할 수 있었습니다.
결과적으로 유지보수 양을 줄이고, 무료 티어 내에서 안정적인 멀티캐스트 전송 인프라를 구축할 수 있었습니다.
2. 도메인 기반 알림 발생 로직 설계
알림 인프라가 갖춰진 후에는 '언제, 누구에게' 보낼 것인가라는 본질적인 문제에 집중했습니다. 무분별한 알림은 유저 피로도를 높여 앱 삭제를 유발하기 때문에, 도메인 로직 내에 정교한 발송 트리거를 심어야 했습니다.
우선 댓글이나 리뷰 작성 시, 작성자와 글의 소유자가 동일한 경우(Self-interaction) 알림 대상에서 제외하는 필터링 로직을 구현했습니다. 특히 '좋아요' 기능의 경우, 단순한 DB 업데이트가 아닌 이전 상태(prevLiked)를 대조하여 신규 발생 시에만 알림이 가도록 설계했습니다. '해제' 시에는 알림을 생성하지 않음으로써 유저가 체감하는 정보의 가치를 높였습니다.
또한, 알림 수신 시 유저가 해당 콘텐츠로 즉시 진입할 수 있도록 페이로드에 events:detail:${id}와 같은 딥링크 파라미터를 포함하여 재방문 동선을 최적화했습니다.
// 댓글 작성 로직 중 일부 (apps/server/src/routes/events.ts)
router.post('/:id/comments', verifyToken, async (req, res) => {
// ... 댓글 생성 로직
const event = await EventsModel.findByIdAndUpdate(cargo.id, { $push: { comments: comment._id } });
// 본인이 아닌 경우에만 행사 등록자에게 알림 전송
if (event.authorId && !event.authorId.equals(req.user._id)) {
await NotificationModel.createAndSendNotification(
NotificationType.COMMENT,
event.authorId,
'내 행사에 새로운 댓글이 작성됐어요!',
`${event.name} 행사에 새로운 댓글이 작성됐어요!`,
`events:detail:${event._id}`, // 딥링크 경로
);
}
res.sendStatus(204);
});3. Foreground 상태의 수신 제약 극복
개발 과정에서 마주친 기술적 난관 중 하나는 안드로이드 등 일부 OS의 정책이었습니다. 앱이 실행 중인 포그라운드 상태에서는 FCM 메시지를 수신하더라도 시스템이 알림 팝업을 띄우지 않고 조용히 무시하기 때문입니다. 유저가 앱을 사용하는 도중에도 중요한 실시간 인터랙션을 즉시 인지하게 하는 것이 UX 측면에서 매우 중요했습니다.
이 문제를 해결하기 위해 클라이언트 측에 Notifee 라이브러리를 도입했습니다. FCM의 onMessage 리스너를 통해 포그라운드 데이터를 가로챈 뒤, 이를 바탕으로 시스템 수준의 로컬 알림을 강제로 트리거하는 방식을 취했습니다. 특히 안드로이드 8.0 이상에서 필수인 알림 채널(Channel)을 미리 정의하고 중요도를 HIGH로 설정하여, 어떤 상태에서도 유저에게 즉시 알림이 노출되도록 보장했습니다.
// Notifee를 활용한 포그라운드 알림 강제 렌더링 (App.tsx)
PushNotificationService.setMessageHandler(
async background => { /* 백그라운드 메시지 로깅 */ },
async remoteMessage => {
const { title, body } = remoteMessage.notification ?? {};
await notifee.displayNotification({
title,
body,
android: {
channelId: 'default',
smallIcon: 'ic_launcher',
pressAction: { id: 'default', launchActivity: 'default' },
},
});
}
);4. 알림 영속성 및 상태 관리 설계
마지막으로 고민한 지점은 알림 데이터의 휘발성이었습니다. 푸시 알림은 단말기에 도달하면 사라지는 특성이 있어, 유저가 지난 알림을 다시 확인하거나 읽음 상태를 관리할 수 있는 '앱 내 알림함'을 위한 영속성 설계가 필수적이었습니다.
이를 위해 "기록 후 전송(Write-then-Send)" 구조를 설계했습니다. 메시지 발송 직전 MongoDB에 알림 객체를 먼저 생성하여 데이터 유실을 방지하고, 유저가 앱 접속 시 자신의 알림 히스토리를 조회할 수 있게 했습니다.
또한 FCM 발송 결과에서 실패 응답(failureCount)이 돌아오면, 해당 토큰이 유효하지 않거나 앱이 삭제된 것으로 판단하여 DB에서 즉시 제거하는 토큰 정화(Cleanup) 로직을 더했습니다. 이 과정을 통해 서버는 항상 유효한 토큰만 유지하며 전송 성공률을 극대화할 수 있었습니다.
// 전송 결과에 따른 자동 토큰 정화 로직
const result = await messaging().sendEachForMulticast(message);
if (result.failureCount > 0) {
result.responses.forEach((res, idx) => {
if (!res.success) {
// 유효하지 않은 토큰(앱 삭제 등) 정보를 DB에서 즉시 제거
NotificationTokenModel.deleteOne({ _id: tokens[idx]._id });
}
});
}5. 마치며
이번 설계를 통해 깨달은 것은 푸시 알림이 단순히 메시지를 던지는 기능을 넘어, 인프라 통합 - 도메인 필터링 - 수신 보장 - 데이터 영속성이 유기적으로 맞물려야 하는 하나의 완성된 시스템이라는 점이었습니다.
기술적 제약을 라이브러리와 아키텍처로 해결하며 서비스의 신뢰도를 높일 수 있었고, 무엇보다 유저가 서비스와 대화하는 '가장 직접적인 통로'를 견고하게 다졌다는 점에서 큰 의미가 있었습니다.
Comments (0)
댓글을 불러오는 중...