Konum Tabanlı Push Notification: Sıfırdan Kendi Bildirim Altyapınızı Kurmak

Merhabalar, bu yazımda mobil uygulamalar için APNs ve FCM API'lerine doğrudan bağlanan, konum tabanlı ve ölçeklenebilir bir bildirim sistemini nasıl kurduğumu adım adım anlatacağım.
Push notification denildiğinde akla ilk gelen araçlar genellikle OneSignal, Firebase Cloud Messaging, Braze veya Dengage gibi third-party servisler oluyor. Bu araçlar hızlı başlangıç için son derece cazip; ancak iş "Kullanıcının anlık konumuna göre dinamik bildirim gönder" noktasına geldiğinde, mevcut hazır çözümlerle istediğiniz esnekliği, hassasiyeti ve performansı yakalamak oldukça zorlaşıyor.
Kariyer, iş ilanları, sipariş takibi, saha operasyonları ve teslimat gibi Precise Location (Tam Konum) verisiyle çalışan uygulamalarda bu ihtiyaç kaçınılmaz hale geliyor: Kullanıcı belirli bir bölgedeyken o bölgedeki yeni bir iş ilanını, yakınındaki bir kampanyayı ya da anlık teslimat durumunu sunucu tarafında eşleştirip anında push atabiliyor olmanız gerekiyor.
Birlikte, APNs ve FCM API'lerine doğrudan bağlanan, cihazdan güvenli şekilde anlık konum toplayan ve arka planda konuma göre bildirim gönderebilen bir altyapıyı sıfırdan nasıl kurduğumu ele alacağız. Hem React Native (mobile) hem de NestJS (backend) tarafındaki mimariyi, gerçek bir projede kullanılabilecek kod örnekleriyle birlikte inceleyeceğiz.
Hangi Durumlarda Kendi Notification Sistemimizi Kurmalıyız?
Hazır servisler yerine kendi bildirim altyapınızı kurmanızı gerektiren ana nedenler şunlardır:
- İçerik Bazlı Konum Hedefleme: OneSignal veya Braze gibi servisler geofence tabanlı çalışır: Önceden coğrafi bir alan çizersiniz ve kullanıcı o alana girdiğinde cihaz tetiklenir. Ancak amacınız yeni bir içerik publish edildiğinde (yeni bir iş ilanı, dinamik kampanya veya teslimat noktası) sunucu tarafında "Bu içeriğe X km yakınlıktaki aktif kullanıcılar kimler?" sorgusu yapıp otomatik push göndermekse, bu content-triggered geo-matching akışını hazır sunan bir servis yok.
- Maliyet Kontrolü: Third-party servisler genellikle aktif cihaz (MAU) veya gönderilen bildirim sayısına göre ölçeklenen yüksek pricing modellerine sahiptir. APNs ve FCM'e doğrudan bağlanarak kendi pipeline'ınızı çalıştırdığınızda ise notification gönderim altyapısı tamamen ücretsizdir.
- Veri Sahipliği ve Privacy: Kullanıcı cihaz bilgileri, anlık konum geçmişi ve bildirim tercihleri third-party şirketlerin server'larına gitmez; tamamen kendi database'inizde kalır.
- Esneklik ve Tam Kontrol: Silent push'lar, multi-app yönetimi, özel retry mekanizmaları gibi kritik ihtiyaçları üçüncü taraf panellere bağımlı olmadan yönetebilirsiniz.
- Vendor Lock-in Bağımsızlığı: Altyapıyı istediğiniz zaman farklı bir cloud provider'a taşıyabilir veya kendi business logic'inize göre kolayca extend edebilirsiniz.
Mimari Genel Bakış
Sistem üç ana katmandan oluşuyor: Mobile Client (React Native), API Gateway (GraphQL) ve Bağımsız Bildirim Mikroservisi (NestJS). Servisler arası iletişim RabbitMQ üzerinden async messaging / event-driven mimari ile gerçekleşiyor.
Uber, Netflix, LinkedIn ve Slack gibi teknoloji şirketleri de aynı yaklaşımı benimsiyor. APNs ve FCM'e doğrudan bağlanan kendi kontrol ettikleri notification pipeline'larını kuruyorlar. Uber'in RAMEN platformu ve Netflix'in RENO sistemi bu mimarinin en bilinen örneklerindendir.

Mobil Taraf: Cihaz Kaydı ve Token Yönetimi
Push notification altyapısının mobil ayağı üç temel sorumluluğa sahiptir: Permission (izin) alma, platform token'ı edinme ve backend sync (senkronizasyon). Tüm bu akışı global bir React PushProvider component'inde topluyoruz.
1. Persistent Device ID (Stabil Cihaz Kimliği)
Her cihazın database'imizde benzersiz bir kaydı olması gerekir. Ancak standart DeviceInfo.getUniqueId() çağrısı bazı durumlarda (app silinip yüklendiğinde) değişebilir. Bu sorunu aşmak için iki katmanlı bir kalıcılık yaklaşımı izliyoruz:
import Keychain from 'react-native-keychain';
import DeviceInfo from 'react-native-device-info';
const KEYCHAIN_SERVICE = 'com.yourapp.device.id';
const KEYCHAIN_ACCOUNT = 'device_id';
export const getOrCreatePushDeviceId = async (): Promise<string | null> => {
// 1. Keychain / Keystore'dan okumayı dene (uygulama silinse bile kalır)
const stored = await Keychain.getGenericPassword({
service: KEYCHAIN_SERVICE,
});
if (stored && stored.password) {
return stored.password;
}
// 2. Bulunamadıysa işletim sisteminden al ve Keychain'e yaz
const osDeviceId = await DeviceInfo.getUniqueId();
if (osDeviceId) {
await Keychain.setGenericPassword(KEYCHAIN_ACCOUNT, osDeviceId, {
service: KEYCHAIN_SERVICE,
accessible: Keychain.ACCESSIBLE.ALWAYS,
});
return osDeviceId;
}
return null;
};
Not: iOS tarafında Keychain, app silinip aynı provisioning profile ile tekrar yüklendiğinde dahi veriyi korur. Android'de ise Keystore benzer bir kalıcılık sağlar. Bu sayede kullanıcı app'i silip tekrar yüklese bile aynı cihaz ID'si ile match olur.
2. Platform Token'ı Alma: APNs vs FCM
iOS ve Android işletim sistemlerinin push token alma dinamikleri farklıdır. iOS tarafında doğrudan APNs token'ına ihtiyaç duyuyoruz. Ancak iOS cold start anında APNs token'ını hemen üretmeyebilir; bu nedenle bir retry mekanizması ekliyoruz.
import messaging, { AuthorizationStatus } from '@react-native-firebase/messaging';
import { Platform } from 'react-native';
export const getPushToken = async () => {
if (Platform.OS === 'ios') {
const status = await messaging().hasPermission();
if (status === AuthorizationStatus.NOT_DETERMINED) {
await messaging().requestPermission();
}
await messaging().registerDeviceForRemoteMessages();
const apnsToken = await getApnsTokenWithRetry();
return { pushProvider: 'APNS', pushToken: apnsToken };
}
// Android: FCM token'ı doğrudan alınır
const fcmToken = await messaging().getToken();
return { pushProvider: 'FCM', pushToken: fcmToken };
};
3. PushProvider: Orkestrasyon Katmanı
Tüm parçaları birleştiren ana component'imiz PushProvider. App açıldığında, token refresh olduğunda, auth state değiştiğinde veya app foreground'a geldiğinde cihaz kaydını sync eder:
import React, { useEffect } from 'react';
import { AppState } from 'react-native';
import messaging from '@react-native-firebase/messaging';
export const PushProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated } = useAppSelector(state => state.auth);
let lastCheck = 0;
// 1. Uygulama açılışında senkronizasyon
useEffect(() => {
requestNotificationPermission();
syncDevice(undefined, { reason: 'boot' });
}, []);
// 2. Firebase token yenilendiğinde senkronizasyon
useEffect(() => {
return messaging().onTokenRefresh(() => {
syncDevice(undefined, { reason: 'token-refresh' });
});
}, []);
// 3. Çıkış yapıldığında userId ilişkisini temizle
useEffect(() => {
if (!isAuthenticated) {
syncDevice(null, { reason: 'auth-reset' });
}
}, [isAuthenticated]);
// 4. Uygulama ön plana geldiğinde senkronizasyon (30 saniye bekleme süresiyle)
useEffect(() => {
const sub = AppState.addEventListener('change', state => {
if (state !== 'active') return;
if (Date.now() - lastCheck < 30000) return;
lastCheck = Date.now();
syncDevice(undefined, { reason: 'app-active' });
});
return () => sub.remove();
}, []);
return <>{children}</>;
};
4. Deduplication: Gereksiz API Call'larını Engelleme
Mobil tarafta gereksiz network request'lerinin önüne geçmek için cihaz bilgilerinden (model, OS, izinler, konum, token) anlık bir "Payload Signature" üretiyoruz. Üretilen signature local storage'daki (AsyncStorage/MMKV) son signature ile aynıysa API call yapılmaz:
const signature = createPayloadSignature(payload);
const lastSignature = await storage.getItem(STORAGE_KEYS.PUSH_LAST_SYNC_SIGNATURE);
if (lastSignature === signature && registrationId) {
return payload; // Değişiklik yok, API isteği atlanır
}
Signature oluşturulurken
syncedAtgibi dinamik timestamp'ler sıfırlanır. Böylece sadece gerçek veri değişikliklerinde (token yenilenmesi, konum güncellemesi, permission değişimi) backend'e istek atılır.
5. Network Middleware ve Retry Stratejisi
- Apollo Client Middleware: Backend'e giden her GraphQL request'inin header kısmına
cx-device-ideklenir. Registration ID yoksa otomatik device sync tetiklenir. - Exponential Backoff + Jitter: Network hatalarında veya token'ın henüz hazır olmadığı durumlarda retry bekleme süreleri
5s -> 15s -> 45s -> 2dk -> 5dkşeklinde artar. Her süreye%15oranında jitter eklenir. Bu sayede binlerce cihazın aynı anda server'a yüklenmesi (thundering herd problem) engellenir.
6. Native Konfigürasyon Notları
iOS Tarafı:
- Entitlements:
aps-environmentanahtarı ortama göredevelopmentveyaproductionolarak ayarlanmalıdır. - AppDelegate:
FirebaseApp.configure()çağrısı yeterlidir. APNs delegate method'ları@react-native-firebase/messagingkütüphanesinin method swizzling özelliği ile otomatik yönetilir. - GoogleService-Info.plist: Firebase Console'dan indirilip projeye eklenmelidir.
Önemli: iOS bildirim izinleri için ekstra bir kütüphane (
react-native-permissions) kullanmıyoruz. Firebase Messaging iOS tarafında kendi native permission süreçlerini doğrudan yönetir.
Android Tarafı:
google-services.json eklendikten sonra AndroidManifest.xml konfigürasyonunda WAKE_LOCK iznine dikkat edilmelidir:
<uses-permission android:name="android.permission.WAKE_LOCK" tools:remove="android:maxSdkVersion" />
tools:remove="android:maxSdkVersion" tanımı kritik önem taşır: Firebase'in ReactNativeFirebaseMessagingReceiver bileşeninin arka planda bildirimleri kesintisiz işleyebilmesi için API 26+ (Android 8.0 ve üzeri) sürümlerde de bu iznin geçerli kılınması gerekir. Android 13+ (API 33) için ise standart PermissionsAndroid.request(POST_NOTIFICATIONS) çağrısı yeterlidir.
Backend Katmanı: APNs ve FCM'e Doğrudan Bağlantı
Backend tarafı NestJS mimarisi üzerinde mikroservis olarak kurgulanmıştır. Notification servisi tamamen bağımsız çalışır ve ana sistemle RabbitMQ üzerinden haberleşir.
1. APNs Entegrasyonu: Native HTTP/2 ve JWT
Apple APNs server'larına herhangi bir dış wrapper/SDK kullanmadan, Node.js'in native http2 modülü üzerinden doğrudan bağlanıyoruz. Authentication için ES256 algoritması ile imzalanmış JWT kullanıyoruz:
import * as http2 from 'http2';
import * as jwt from 'jsonwebtoken';
import * as fs from 'fs';
export class ApnsProvider {
private cachedToken: string | null = null;
private tokenExpiresAt: number = 0;
private getJWT(creds: ApnsCredentials): string {
const now = Math.floor(Date.now() / 1000);
// Token'ın süresi dolmadıysa önbellekten dön (50 dakika geçerli tutulur)
if (this.cachedToken && now < this.tokenExpiresAt) {
return this.cachedToken;
}
const privateKey = fs.readFileSync(creds.keyPath, 'utf8');
this.cachedToken = jwt.sign({}, privateKey, {
algorithm: 'ES256',
keyid: creds.keyId,
issuer: creds.teamId,
expiresIn: '1h',
});
this.tokenExpiresAt = now + 50 * 60;
return this.cachedToken;
}
async sendNotification(deviceToken: string, payload: any, creds: ApnsCredentials) {
const host = process.env.NODE_ENV === 'production'
? 'api.push.apple.com'
: 'api.sandbox.push.apple.com';
const client = http2.connect(`https://${host}`);
const token = this.getJWT(creds);
const req = client.request({
':method': 'POST',
':path': `/3/device/${deviceToken}`,
'authorization': `bearer ${token}`,
'apns-topic': creds.bundleId,
'apns-push-type': payload.silent ? 'background' : 'alert',
'apns-priority': payload.silent ? '5' : '10',
});
req.write(JSON.stringify(payload));
req.end();
}
}
JWT Caching Stratejisi: APNs JWT token'ları 1 saat geçerlidir. Her notification gönderiminde yeniden imzalama maliyetinden kaçınmak için token'ı 50 dakika boyunca memory'de cache'liyoruz. Cache anahtarı
keyId:teamIdformatındadır; bu da aynı sunucudan birden fazla iOS uygulamasını sorunsuz yönetebilmemizi sağlar.
2. FCM Entegrasyonu: v1 HTTP API
Android tarafında legacy FCM server key'leri yerine FCM v1 HTTP API kullanıyoruz. v1 API, OAuth2 Access Token ile çalışır ve platform bazlı özelleştirilebilir bir mesaj yapısı sunar:
import { GoogleAuth } from 'google-auth-library';
export class FcmProvider {
async sendNotification(deviceToken: string, payload: any, projectId: string, serviceAccountPath: string) {
const auth = new GoogleAuth({
keyFile: serviceAccountPath,
scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
});
const client = await auth.getClient();
const accessToken = await client.getAccessToken();
const response = await fetch(
`https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
token: deviceToken,
notification: {
title: payload.title,
body: payload.body,
},
android: {
priority: 'high',
notification: { sound: 'default' },
},
},
}),
}
);
return response.json();
}
}
3. Payload Format Farklılıkları
APNs ve FCM v1 API'lerinin beklediği JSON şemaları farklıdır. Backend tarafındaki mapper servisimiz aynı business logic'ten iki farklı payload üretir:
iOS (APNs Payload):
{
"aps": {
"alert": {
"title": "Bölgende yeni fırsat!",
"body": "Sana yakın yeni içerikler yayınlandı."
},
"sound": "default"
}
}
Android (FCM v1 Payload):
{
"message": {
"token": "fcm_device_token_xyz",
"notification": {
"title": "Bölgende yeni fırsat!",
"body": "Sana yakın yeni içerikler yayınlandı."
},
"android": {
"priority": "high",
"notification": {
"sound": "default"
}
}
}
}
4. Multi-App Desteği (Credential Resolver)
Altyapımız birden fazla mobil uygulamaya aynı mikroservis üzerinden hizmet verebilir. Bunu PushCredentialResolver servisi ile yönetiyoruz.
Çözümleme öncelik sırası: platform:appId -> appId -> global env. Config bulunamasa dahi sistem güvenli şekilde default env ayarlarına fallback olur.
Konum Tabanlı Bildirimler: Mimari Farkımız
Bu altyapıyı hazır servislerden ayıran en önemli özellik, anlık konum toplama ve server-side geo-matching pipeline'ıdır.
Cihaz Kaydında Toplanan Veriler
Her sync adımında cihazdan toplanan veriler şunlardır:
| Kategori | Toplanan Alanlar |
|---|---|
| Identity | deviceId, platform, pushProvider, pushToken, userId |
| App | appId, appVersion, buildNumber |
| Device | phoneType, deviceModel, manufacturer, osName, osVersion, osApiLevel |
| Network / Locale | timezone, locale |
| Permissions | permissionStatus, appNotificationsEnabled, platform flags |
| Location | latitude, longitude, accuracy, capturedAt |
Konum Verisinin Saklanması ve Sorgulanması
Veritabanı tarafında bu verileri relational veya time-series uygun bir DB (örneğin PostgreSQL) ve tercih ettiğiniz bir ORM / query builder (Prisma, TypeORM, Entity Framework vb.) ile saklayabilirsiniz.
Buradaki kritik yaklaşım, konum verisini tek bir kolon/hücre üzerinde sürekli ezmek yerine time-series mantığıyla (örneğin son 10 snapshot bazında) tutmaktır. Bu sayede sadece "kullanıcı şu an nerede" sorusuna değil, "kullanıcı son 2 saatte nerelerde bulundu" sorusuna da yanıt verebiliriz.
model NotificationDeviceLocationSnapshot {
id String @id @default(dbgenerated("uuidv7()")) @db.Uuid
registrationId String @db.Uuid
latitude Float
longitude Float
accuracy Float
capturedAt DateTime @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
registration NotificationDeviceRegistration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
@@index([registrationId, capturedAt(sort: Desc)])
}
Mesafe hesaplamasında Haversine formülünü pure bir matematik fonksiyonu olarak (örneğin TypeScript veya kullandığınız dilin math kütüphaneleriyle) implemente edebilirsiniz:
const EARTH_RADIUS_KM = 6371;
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
export function haversineDistanceKm(
from: { latitude: number; longitude: number },
to: { latitude: number; longitude: number }
): number {
const deltaLat = toRadians(to.latitude - from.latitude);
const deltaLon = toRadians(to.longitude - from.longitude);
const fromLat = toRadians(from.latitude);
const toLat = toRadians(to.latitude);
const a =
Math.sin(deltaLat / 2) ** 2 +
Math.cos(fromLat) * Math.cos(toLat) * Math.sin(deltaLon / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
}
Bildirim Nasıl Tetikleniyor?
- Konum güncellenir: Uygulama ön plana geldiğinde veya kullanıcının konumu değiştiğinde, güncel konum backend'e gönderilir.
- Yakındaki kullanıcılar bulunur: Sisteme konuma bağlı yeni bir içerik eklendiğinde (örneğin Kadıköy'de yeni bir ilan), arka planda çalışan servis son konumu bu noktaya
X kmmesafede olan aktif cihazları bulur. - Bildirimler gönderilir: Bulunan kullanıcılara RabbitMQ kuyruğu üzerinden paralel olarak bildirim iletilir.
Bu akışı hazır servislerle kurmak mümkün değil, çünkü hiçbiri içerik yayınlandığı anda konuma göre kullanıcı eşleştirmesi yapmıyor.
Gerekli Dosyalar ve Credential Yönetimi
Sistemin sorunsuz çalışması için gerekli dosya ve yetkiler:
| Dosya / Key | Nereden Alınır? | Ne İş Yapar? |
|---|---|---|
| `.p8` (APNs Key) | Apple Developer -> Keys | APNs JWT imzalama işlemi için kullanılır. |
| `GoogleService-Info.plist` | Firebase Console -> iOS App | iOS Firebase bağlantısını sağlar. |
| `google-services.json` | Firebase Console -> Android App | Android Firebase yapılandırmasını sağlar. |
| `fcm.json` (Service Account) | Google Cloud Console -> IAM | FCM v1 API OAuth2 access token üretir. |
.p8 vs .p12 Notu: Apple iki tip sertifika sunar.
.p12sertifikaları eski yöntemdir ve her yıl yenilenmesi gerekir..p8(token-based / JWT) yöntemi ise süresizdir, tek bir key ile tüm iOS app'lerinizi imzalamanıza olanak tanır. Projemizde.p8tercih edilmiştir.
Sonuç
Kendi bildirim altyapınızı kurmak ilk bakışta zor görünebilir, ancak APNs ve FCM'in API'leri oldukça anlaşılır. Asıl önemli olan token yönetimi, hata yönetimi ve konum eşleştirme gibi süreçleri doğru bir mimariyle kurgulamak.
Peki karşılığında ne kazanıyoruz?
- Veri Sahipliği: Kullanıcı verileri ve konum geçmişi tamamen sizin kontrolünüzde kalır.
- Maliyet Kontrolü: Cihaz ve bildirim sayısı arttıkça büyüyen aylık lisans ücretlerinden kurtulursunuz.
- İçerik Bazlı Konum Hedefleme: Hazır servislerin sunmadığı, konuma dayalı dinamik bildirim senaryolarını özgürce kurabilirsiniz.
Siz de uygulamanızda konum ile bildirimleri bir araya getiren bir altyapı kurmayı düşünüyorsanız, umarım bu yazı size iyi bir başlangıç noktası olur. Okuduğunuz için teşekkürler, soru ve önerileriniz için benimle iletişime geçebilirsiniz.