GAS에서 FCM 푸시 보내기: JWT 인코딩과 수신 토큰 관리
TECH INSIGHT / EXPLAINED
푸시 전송에는 서로 다른 두 종류의 토큰이 등장한다. OAuth 액세스 토큰은 서버가 보낼 권한을 나타내고, FCM 등록 토큰은 메시지를 받을 앱 인스턴스를 가리킨다. 이 둘을 구분하면 인증 오류와 수신처 오류를 서로 다른 경로로 처리할 수 있다.
실제 프로젝트의 로컬 실행 화면
기존 프로젝트의 화면 또는 함수를 로컬에서 실행해 촬영했습니다. 개인정보 없는 샘플 입력을 사용했으며, 실행한 범위는 각 이미지 아래에 설명했습니다.
GAS에서 HTTP v1을 호출하는 흐름
Apps Script에서는 UrlFetchApp으로 FCM HTTP v1 API를 호출할 수 있다. 서비스 계정 JWT를 직접 서명하는 방법은 한 가지 구현 방식이다. 운영 환경에서 검증된 인증 라이브러리나 별도 전송 서비스를 사용할 수 있다면 수동 암호 처리의 유지보수 부담도 비교한다.
서비스 계정 방식은 RS256 헤더와 클레임을 만들고, 서명한 JWT를 https://oauth2.googleapis.com/token에 교환한 뒤 짧게 유효한 액세스 토큰을 얻는다. scope는 https://www.googleapis.com/auth/firebase.messaging, aud는 토큰 교환 주소다. iat·exp는 초 단위이며 exp는 iat로부터 최대 1시간 이내로 둔다. 클레임의 scope와 대상 프로젝트 IAM 권한은 모두 필요하다.
Base64url에는 패딩도 제거한다
JWT 각 부분은 UTF-8 바이트를 URL 안전 Base64로 인코딩하고 끝의 = 패딩을 제거한다. 아래는 claims 객체가 앞서 준비되었다고 가정한 서명 입력 조각이다. 개인키를 사용하거나 네트워크로 보내는 코드는 아니다.
function base64urlUtf8(text) {
return Utilities.base64EncodeWebSafe(
text, Utilities.Charset.UTF_8
).replace(/=+$/, '');
}
// JWT signing input only; no key or network request here.
const headerPart = base64urlUtf8(JSON.stringify({alg: 'RS256', typ: 'JWT'}));
const claimPart = base64urlUtf8(JSON.stringify(claims));
const signingInput = headerPart + '.' + claimPart;
서명은 이 문자열의 정확한 바이트에 적용하고, 서명 바이트도 패딩 없는 Base64url로 붙인다. 토큰 교환의 HTTP 상태, JSON 오류, access_token과 expires_in을 확인한다. 만료 시각에 여유를 둬 캐시하고 동시에 갱신하는 실행은 잠금으로 조정한다. JWT나 토큰 전체를 디버그 로그에 출력하지 않는다.
개인키 보관은 인코딩과 다른 문제다
서비스 계정 JSON을 올바르게 파싱하면 private_key의 이스케이프된 개행이 복원된다. 저장 경로 때문에 Base64로 감싸는 방법도 있지만 암호화나 훼손 방지 보장은 아니다. Script Properties를 쓰는 경우 프로젝트 편집자는 키에 접근할 수 있다고 가정한다. 권한을 좁히고 회전·폐기 절차를 마련하며, 운영 규모에 맞는 비밀 관리 방식을 선택한다.
수신 토큰은 인증된 등록 요청으로 받는다
클라이언트 SDK에서 받은 등록 토큰을 로그인한 사용자의 인증된 POST 요청으로 전달한다. URL 쿼리에 넣으면 기록·로그 등에 남을 수 있다. 서버에는 사용자, 앱 인스턴스, 토큰, 갱신 시각을 함께 저장하고 로그아웃이나 연결 해제 때 관계를 정리한다. 여러 기기를 쓰는 사용자를 토큰 하나로 덮어쓰지 않는다.
User Properties의 범위만 믿지 말고 실제 실행 주체와 서비스 사용자를 확인한다. 서버의 OAuth 토큰을 클라이언트 등록 토큰과 혼동해 저장하거나 브라우저로 반환해서는 안 된다.
응답 성공과 기기 표시를 구분한다
FCM 전송 주소는 https://fcm.googleapis.com/v1/projects/PROJECT_ID/messages:send다. muteHttpExceptions는 오류 HTTP 응답을 읽을 수 있게 할 뿐 성공으로 바꾸지 않는다. UNREGISTERED는 해당 등록 토큰을 제거하고, 인증·권한 오류는 설정을 확인하며, 일시 오류와 할당량 오류는 공식 지침에 맞춰 지연 재시도한다.
메시지 ID를 받았다는 것은 기기에서 표시됐다는 확인이 아니다. 중요 이벤트는 앱 내부 기록과 event_id를 남겨 재조회할 수 있게 한다. 높은 우선순위도 즉시 도착을 보장하지 않는다. 잠금 화면에는 민감한 계좌 정보를 최소화하고 Telegram과 함께 쓰더라도 공통 백엔드 장애는 두 경로를 동시에 멈출 수 있음을 운영 설계에 반영한다.
참고 자료
내용·예제 검토: 2026-09-29
댓글
댓글 쓰기