Google Firebase가 오랜 기간 사용되어 온 FCM Legacy HTTP API 지원을 중단하면서 기존 서버 키(Server Key) 기반의 푸시 알림 발송 방식이 작동하지 않는 문제를 겪어봤을 것이다. 웹 문서나 기존 블로그 글을 찾아보아도 예전 서버 키 방식을 안내하는 경우가 많아 대체 어떻게 전환해야 할지 막막할 수 있다.
이번 글에서는 FCM HTTP v1 API가 기존 방식과 어떻게 다른지, 서비스 계정(Service Account) 비대칭키 기반의 OAuth2 인증 토큰을 PHP에서 발급받아 푸시 알림을 발송하는 전 과정을 완벽하게 정리해서 소개하겠다.
기존 FCM Legacy 방식은 Firebase 콘솔에서 발급받은 서버 키 하나를 HTTP 헤더의 Authorization에 포함해서 요청을 보냈다. 구현은 매우 간편했지만 서버 키가 유출될 경우 프로젝트 전체 푸시 권한이 탈취되는 치명적인 보안 허점이 있었다.
반면 FCM HTTP v1 API는 Google Cloud OAuth2 2.0 표준 인증 방식을 따른다. GCP에서 발급받은 비공개 키(JSON)를 이용해 서비스 계정 자격을 증명하고, 수명이 1시간인 Access Token을 발급받아 요청 헤더에 실어 보낸다. 보안성이 대폭 강화되었으며 플랫폼별(Android, iOS, Web) 세부 메시지 설정 기능도 훨씬 정교해졌다.
| 구분 | FCM Legacy HTTP API | FCM HTTP v1 API |
|---|---|---|
| 인증 방식 | 단일 고정 서버 키 (Server Key) | Google OAuth2 2.0 (Service Account JSON) |
| 요청 엔드포인트 | https://fcm.googleapis.com/fcm/send | https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send |
| 보안 수준 | 낮음 (키 유출 시 전체 권한 노출) | 높음 (1시간 제한 Access Token 발급 및 사용) |
| 메시지 payload | 단일 공통 메시지 구조 | message 객체 및 플랫폼별 타겟팅 지원 |
FCM HTTP v1 API를 연동하려면 가장 먼저 서비스 계정 키(JSON)를 발급받아야 한다. 구글 파이어베이스 콘솔에 접속한 뒤 아래 순서로 진행하자.
1) Firebase 콘솔 진입 후 프로젝트 설정으로 이동한다.
2) '서비스 계정' 탭을 선택하고 '새 비공개 키 생성' 버튼을 클릭한다.
3) 다운로드되는 JSON 파일(예: service-account-file.json)을 서버의 안전한 경로에 저장한다.
4) JSON 파일 내부에는 project_id, private_key, client_email 등의 필드가 포함되어 있으므로 절대로 외부 및 Git 저장소에 노출해서는 안 된다.
외부 라이브러리(Google API Client)를 설치하기 어려운 환경에서도 PHP 기본 함수와 cURL, OpenSSL만으로 JWT(JSON Web Token)를 생성하고 OAuth2 액세스 토큰을 발급받을 수 있다.
아래는 구 버전 엔드포인트를 사용하거나, 토큰 발급 절차 없이 요청을 보내 실패하는 전형적인 코드다.
<?php
// ✗ 잘못된 코드: 이미 지원 중단되었거나 중단 예정인 Legacy 인증 방식
$serverKey = "AAAA..."; // 구버전 서버 키 사용
$url = "https://fcm.googleapis.com/fcm/send";
$fields = [
'to' => $deviceToken,
'notification' => [
'title' => '알림 제목',
'body' => '알림 내용'
]
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: key=' . $serverKey,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($fields));
$result = curl_exec($ch);
curl_close($ch);
// 결과: 401 Unauthorized 또는 HTTP Legacy API 지원 중단 에러 발생
?>
서비스 계정 키 파일에서 정보를 읽어와 RS256으로 서명된 JWT를 생성한 후, Google OAuth2 토큰 엔드포인트에서 access_token을 가져와 v1 API를 호출하는 연동 코드다.
<?php
function getGoogleAccessToken($keyFilePath) {
if (!file_exists($keyFilePath)) {
throw new Exception("서비스 계정 키 파일을 찾을 수 없습니다.");
}
$keyData = json_decode(file_get_contents($keyFilePath), true);
$clientEmail = $keyData['client_email'];
$privateKey = $keyData['private_key'];
$header = json_encode(['alg' => 'RS256', 'typ' => 'JWT']);
$now = time();
$payload = json_encode([
'iss' => $clientEmail,
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now
]);
$base64UrlHeader = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($header));
$base64UrlPayload = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($payload));
$signatureInput = $base64UrlHeader . "." . $base64UrlPayload;
openssl_sign($signatureInput, $signature, $privateKey, OPENSSL_ALGO_SHA256);
$base64UrlSignature = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($signature));
$jwt = $signatureInput . "." . $base64UrlSignature;
$ch = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt
]));
$response = curl_exec($ch);
curl_close($ch);
$tokenData = json_decode($response, true);
return $tokenData['access_token'] ?? null;
}
function sendFcmV1Notification($keyFilePath, $deviceToken, $title, $body) {
$keyData = json_decode(file_get_contents($keyFilePath), true);
$projectId = $keyData['project_id'];
$accessToken = getGoogleAccessToken($keyFilePath);
if (!$accessToken) {
return false;
}
$url = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send";
// FCM HTTP v1 API payload 규격 (message 객체 필수)
$payload = [
'message' => [
'token' => $deviceToken,
'notification' => [
'title' => $title,
'body' => $body
],
'data' => [
'click_action' => 'FLUTTER_NOTIFICATION_CLICK'
]
]
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken,
'Content-Type: application/json; UTF-8'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['code' => $httpCode, 'response' => json_decode($result, true)];
}
// 실전 실행 예시
$keyPath = __DIR__ . '/secret/service-account.json';
$targetToken = '사용자_디바이스_토큰_값';
$res = sendFcmV1Notification($keyPath, $targetToken, '결제 완료 알림', '주문하신 상품의 결제가 완료되었습니다.');
print_r($res);
?>
{
"code": 200,
"response": {
"name": "projects/my-fcm-project/messages/0:1718901234567890%a1b2c3d4"
}
}
FCM v1 API로 전환하면서 많은 백엔드 개발자들이 놓치는 핵심 포인트는 두 가지다.
✗ **잘못된 것**: 알림을 한 건 전송할 때마다 Google OAuth2 API를 호출해서 access_token을 새로 생성함.
✓ **올바른 것**: OAuth2 토큰은 발급 후 1시간(3600초) 동안 유효하다. 토큰 발급 응답값을 Redis, Memcached, 혹은 파일에 저장하고 만료 5분 전까지 재사용해야 Google API 접근 제한(Rate Limit)을 피하고 전송 속도를 극대화할 수 있다.
✗ **잘못된 것**: 요청 JSON 데이터 작성 시 최상위에 `to`, `notification` 키를 두고 이전 Legacy 방식 구조 그대로 전달함.
✓ **올바른 것**: v1 API는 반드시 최상위에 `message` 객체를 두고, 그 내부에서 `token`(또는 `topic`), `notification`, `data` 필드를 선언해야 400 Bad Request 에러가 발생하지 않는다.
FCM HTTP v1 API 연동은 구글의 강화된 보안 표준에 맞추기 위한 필수 작업이다. 서비스 계정 기반의 OAuth2 인증 체계 구축이라는 작은 최적화/습관이 모여서 서비스의 안정적인 알림 전달과 시스템 보안 향상이라는 큰 효과를 만든다는 점을 잊지 말자. 이 글의 인증 구현 샘플 코드를 참고해 기존 푸시 발송 모듈을 v1 규격으로 전환하면, 서비스 중단 없이 안정적인 푸시 알림 인프라를 얻을 수 있을 것이다.