공유 호스팅의 PHP 크론으로 FCM 예약 푸시 보내기
Flutter 앱 8개에 당첨 결과 알림을 5분 주기 PHP 크론과 FCM HTTP v1로 보낸다. 데이터가 나왔을 때만, 하루 한 번, 그리고 200을 믿지 않는다.
저희 Flutter 앱 중 8개는 복권과 경마 결과를 보여 줍니다. 이 앱들이 보낼 수 있는 가장 쓸모 있는 알림은 "결과가 나왔습니다"입니다. 이 메시지에는 까다로운 조건이 두 가지 있습니다. 결과가 나오기 전에 보내면 안 되고, 두 번 보내도 안 됩니다.
Firebase 콘솔로도 정해진 시각에 보내는 예약은 할 수 있지만, 추첨 결과가 실제로 공개됐는지는 확인할 수 없습니다. 그래서 예약은 자체 서버가 맡게 했습니다. 공유 호스팅의 PHP 스크립트를 크론으로 5분마다 돌리고, FCM HTTP v1 API를 직접 부릅니다. 아래는 그 구조와, 효과가 있었던 세부 사항입니다.
SDK 없이 보내기: 순수 PHP로 FCM HTTP v1
고정 서버 키를 쓰던 옛 FCM API는 이제 쓸 수 없습니다. v1은 서비스 계정에서 얻은 OAuth2 액세스 토큰이 필요합니다. SDK 없이 하면 세 단계입니다.
// 1. 서비스 계정 개인 키로 서명한 JWT (RS256)
$head = b64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$claim = b64url(json_encode([
'iss' => $sa['client_email'],
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => 'https://oauth2.googleapis.com/token',
'iat' => $now, 'exp' => $now + 3600,
]));
openssl_sign("$head.$claim", $sig, $sa['private_key'], 'sha256WithRSAEncryption');
// 2. 액세스 토큰으로 교환하고, 만료 5분 전까지 캐시
$exp = $now + (int)($j['expires_in'] ?? 3600) - 300;
file_put_contents(FCM_TOKEN_CACHE, json_encode(['token' => $tok, 'exp' => $exp]));
@chmod(FCM_TOKEN_CACHE, 0600);
// 3. projects/<id>/messages:send 에 POST. 401이면 새 토큰을 받아 한 번만 재시도
운영하면서 얻은 세부 사항:
- 토큰은 파일에 캐시한다. 크론 실행은 매번 새 PHP 프로세스라서 실행 사이에 남는 메모리가 없습니다. 토큰은 소유자만 읽을 수 있는 파일(0600)에 두고, 만료 5분 전에 새로 받습니다.
- 401일 때 재시도는 한 번만. 만료되거나 폐기된 토큰은 흔한 경우입니다. 그 밖의 오류는 진짜 오류이니 반복하지 말고 기록합니다.
- 짧은 제한 시간. 연결 8초, 전체 15초. 멈춘 실행은 다음 실행을 막습니다.
- dry-run에는
validate_only. FCM은 보내지 않고 검증만 할 수 있어서, 관리 화면 미리보기와cron.php --dry가 전체 경로를 안전하게 시험합니다. - 키는 서버에 둔다. 서비스 계정의 역할은 FCM 발송 하나뿐이고, 키는 웹 서버가 내보내기를 거부하는 디렉토리에 있습니다.
토픽, 그리고 "200 OK"가 아무 뜻도 없는 이유
FCM v1에는 "이 앱의 모든 설치에 보내기"가 없습니다. 대상은 토큰, 토픽, 조건 중 하나입니다. 각 앱은 시작할 때 <앱ID>_all 토픽을 구독하고, 서버는 그 토픽으로 보냅니다.
함정은 구독자가 0명인 토픽에 보내도 200이 돌아온다는 점입니다. 처음에 이것에 속았기 때문에 발송 함수 위에 주석으로 적어 뒀습니다. 처음 일정을 켰을 때 아직 토픽을 구독하는 빌드를 내지 않은 앱이 있었습니다. 로그로는 모두 성공처럼 보였지만, 그 메시지는 아무 데도 가지 않았습니다.
도달은 기기에서 재야 합니다. 메시지마다 데이터에 무작위 12자 push_id를 넣습니다. 사용자가 알림을 눌러 앱을 열면 앱이 이를 보고합니다.
FirebaseMessaging.onMessageOpenedApp.listen((m) {
final id = m.data['push_id'];
if (id is String && id.isNotEmpty) AdConfig.reportPushOpen(id); // config.php?...&ping=1&push_id=
});
앱이 화면에 떠 있을 때 받은 알림은 열람으로 세지 않습니다. 보고에는 앱이 원래 쓰던 설정 엔드포인트를 ping=1과 함께 쓰므로, 실행 횟수 통계가 부풀지 않습니다.
iOS에서는 APNs 토큰이 생기기 전에 토픽을 구독하면 실패할 수 있습니다. 앱은 첫 onTokenRefresh 때 한 번 더 구독을 시도합니다.
크론: 5분마다 돌고, 보내는 건 하루 최대 한 번
스케줄러는 호스팅 관리 패널에 */5 * * * *로 등록하고, 명령줄에서 php cron.php를 실행합니다. 매번의 흐름은 이렇습니다.
- 막지 않는
flock을 잡는다. 이전 실행이 아직 돌고 있으면skipped: already_running을 찍고 끝낸다 - 켜진 일정을 읽고, 일정마다 "지금 보내야 하나"를 판단한다
- 보내고, 시도를 기록하고, 성공을 표시한다
"지금 보내야 하나"는 보내지 않는 이유를 반드시 돌려줍니다.
| 이유 | 뜻 |
|---|---|
| 요일이 다름 | 오늘은 추첨이 없다 |
already_sent_today |
일정의 시간대 기준 오늘이 last_sent_date와 같다 |
| 시각 전 | 아직 이르다 |
window_closed |
시각+허용 시간을 넘겼다. 오늘은 포기 |
trigger_unavailable |
결과 데이터 출처를 읽을 수 없다. 확실하지 않으면 보내지 않는다 |
쓸모를 만드는 건 트리거입니다. 일정은 시계만이 아니라 데이터를 기다릴 수 있습니다. "복권 API가 오늘 추첨일을 돌려주면 보낸다", "최신 회차 번호가 저장된 값과 달라지면 보낸다". 여러 출처는 &&로 묶을 수 있습니다.
가장 중요한 건 발송 완료를 표시하는 줄입니다.
if ($res['ok']) {
// 성공했을 때만 오늘 보낸 것으로 표시한다. 실패하면 허용 시간이 끝날 때까지 다음 회차에서 재시도된다
$today = $nowUtc->setTimezone(new DateTimeZone($row['tz'] ?: 'Asia/Tokyo'))->format('Y-m-d');
$db->prepare('UPDATE tlabo_ad_push_schedule SET last_sent_date = ?, last_sent_at = NOW() WHERE id = ?')
->execute([$today, (int)$row['id']]);
}
실패한 발송은 5분 뒤 다시 시도되고, 허용 시간이 끝날 때까지 계속됩니다. "오늘"은 일정 자신의 시간대로 계산합니다. 일본 추첨과 미국 추첨은 같은 날짜를 공유하지 않기 때문입니다.
운영상의 세부 사항이 하나 더 있습니다. 아무 일 없는 회차에는 크론이 아무것도 출력하지 않습니다. 공유 호스팅은 크론 출력을 계정 주인에게 메일로 보내기 때문에, 5분마다 뭔가 찍는 작업은 하루 288통의 메일이 됩니다. 출력은 발송이나 실패가 있을 때만 합니다. 매 실행마다 하트비트 파일도 써서, 메일을 읽지 않아도 "크론이 살아 있는지" 확인할 수 있습니다.
부딪힌 문제
- 같은 푸시가 두 번. 한 앱의 푸시가 데이터 출처마다 일정 행 여러 개로 나뉘어 있었습니다. 행마다 따로 "결과가 나왔다"고 판단한 것입니다. 출처를 모두
&&로 묶은 한 행으로 합쳐서, 판단도 발송도 한 번이 되게 했습니다. - PHP 구문 해석의 함정. 메시지 템플릿에서 변수 바로 뒤에 멀티바이트 문자가 있었습니다(
"$v≠"). PHP는 그 문자를 변수 이름의 일부로 읽었습니다.{$v}로 써서 해결했습니다. - 시간대는 독자마다 다르지만 토픽은 그렇지 않다. 토픽은 모든 구독자에게 동시에 갑니다. 미국 사용자에게 동부 시간 9시에 보내는 아침 푸시는 태평양 시간으로는 6시에 도착합니다. 지역별 토픽으로 나누면 해결되지만, 지금은 알려진 한계입니다. 언어 그룹별 아침 푸시는 30분씩 간격을 뒀습니다.
지금까지의 숫자
토픽을 구독하는 앱들이 2026년 11월 5일에야 스토어 승인을 받아서, 서버 쪽 열람률은 이제 막 쌓이기 시작했습니다. 비교 기준이 될 숫자는 이 시스템 전에 콘솔로 보내던 푸시의 기록뿐입니다. Google Analytics에 push_opened가 4,688번 기록됐고, 가장 큰 앱에서는 세션의 약 11%였습니다. 예약 푸시는 이 기준과 비교하게 됩니다.
점검표
- [ ] 서비스 계정은 FCM 역할만, 키는 공개 영역 밖에
- [ ] 액세스 토큰은 0600 파일에 캐시하고 만료 전에 갱신, 401이면 한 번만 재시도
- [ ] 막지 않는 잠금, 아무 일 없을 땐 출력하지 않기, 하트비트 파일
- [ ] FCM이 메시지를 받은 뒤에만 발송 완료로 표시하고, "오늘"은 일정의 시간대로 계산하기
- [ ] 시간만이 아니라 데이터로 보내고, 출처를 읽을 수 없으면 보내지 않기
- [ ] 열람은 기기에서 측정하고, 200을 도달로 보지 않기
자주 묻는 질문
Firebase SDK 없이 순수 PHP로 FCM v1을 보낼 수 있나요?
보낼 수 있습니다. 서비스 계정 키로 JWT에 서명하고(openssl_sign, RS256), Google OAuth2 토큰 엔드포인트에서 firebase.messaging 범위의 액세스 토큰으로 바꾼 뒤, 만료 직전까지 캐시해 두고 curl로 messages:send에 POST합니다.
FCM이 200을 돌려주면 알림이 도착한 건가요?
아닙니다. 토픽 발송은 구독자가 0명이어도 200을 돌려줍니다. FCM이 메시지를 받았다는 뜻일 뿐입니다. 도달은 기기에서 측정해야 합니다. 예를 들어 열람을 서버로 보고하게 합니다.
크론이 5분마다 도는데 같은 푸시를 두 번 보내지 않으려면요?
일정마다 마지막으로 보낸 날짜를 그 일정의 시간대 기준으로 저장하고, 발송이 성공했을 때만 갱신합니다. 그리고 막지 않는(non-blocking) 파일 잠금으로 겹친 실행은 바로 끝내게 합니다.