[Python] PyFCM을 사용한 Push Notification 전송시 예외 처리를 사용한 FCM 토큰 관리
https://github.com/olucurious/PyFCM
GitHub - olucurious/PyFCM: Python client for FCM - Firebase Cloud Messaging (Android, iOS and Web)
Python client for FCM - Firebase Cloud Messaging (Android, iOS and Web) - olucurious/PyFCM
github.com
PyFCM을 사용해서 Push Notification을 전송하도록 했는데, PyFCM은 문서화가 잘 되어있지 않은건지, 아니면 언어 특성상 알아서 잘 쓰면 되는건지 에러 관련해서 처리하는 방법을 찾아볼 수가 없었다. Python을 사용한 Firebase Cloud Message를 찾아보면 가장 많이 나왔던게 PyFCM이었던 것에 비해서, 조금은 당황스러운 결과이긴 하다. 아무튼 이번에는 PyFCM을 사용할 때 예외 처리를 통해 더 이상 사용할 수 없는 FCM 토큰을 관리하는 방법에 대해서 정리해보려고 한다.
https://firebase.google.com/docs/cloud-messaging/manage-tokens
FCM 등록 토큰 관리를 위한 권장사항 | Firebase Cloud Messaging
4월 9~11일, Cloud Next에서 Firebase가 돌아옵니다. 지금 등록하기 의견 보내기 FCM 등록 토큰 관리를 위한 권장사항 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.
firebase.google.com
Firebase 문서를 살펴보면 다음의 경우에 FCM 토큰이 등록된다는 것을 알 수 있다.
- 새 기기에서 앱 복원
- 사용자가 앱 제거 또는 재설치
- 사용자가 앱 데이터 소거
- FCM에서 기존 토큰이 만료된 후 앱이 다시 활성화
또한 FCM은 앱 인스턴스가 한 달 동안 연결이 되지 않은 경우 토큰을 비활성 상태로 간주한다. 1개월이 지난 토큰은 비활성 기기일 가능성이 높으며, 비활성 기기가 아닌 경우에는 토큰을 갱신했을 것이기 때문이다. 또한 사용사례에 따라서 1개월이 너무 길거나 짧을수도 있으므로, 사용사례에 따라 적합한 주기보다 오랫동안 사용하지 않은 FCM 토큰은 삭제하는 것이 좋다.
여기에 더해서 Push Notification을 전송했을 시, UNREGISTERED(404)나 INVALID_ARGUMENT(400) 오류 메시지를 통해 토큰이 유효하지 않거나 만료된 것을 감지할 수 있다. INVALID_ARGUMENT는 페이로드에 문제가 있을때도 발생할 수 있으니 유의하도록 하자. 자, 그럼 다음은 PyFCM이 어떻게 푸시 메시지를 전송하는지 살펴보도록 하자. 다음은 PyFCM/fcm.py에 작성된 FCMNotification 클래스의 notify 함수다. 잘 보면 주석으로 작성된 문서 내용 중 Raise 항목에 에러에 대한 내용이 기재되어있어서, Cursor IDE가 함수 설명을 제대로 안띄워줬구나싶은 생각이 들지만, 그래도 꿋꿋하게 작성해나가도록 하자.
def notify(
self,
fcm_token=None,
notification_title=None,
notification_body=None,
notification_image=None,
data_payload=None,
topic_name=None,
topic_condition=None,
android_config=None,
webpush_config=None,
apns_config=None,
fcm_options=None,
dry_run=False,
timeout=120,
):
"""
Send push notification to a single device
Args:
fcm_token (str, optional): FCM device registration ID
notification_title (str, optional): Message title to display in the notification tray
notification_body (str, optional): Message string to display in the notification tray
notification_image (str, optional): Icon that appears next to the notification
data_payload (dict, optional): Arbitrary key/value payload, which must be UTF-8 encoded
topic_name (str, optional): Name of the topic to deliver messages to e.g. "weather".
topic_condition (str, optional): Condition to broadcast a message to, e.g. "'foo' in topics && 'bar' in topics".
android_config (dict, optional): Android specific options for messages - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidconfig
apns_config (dict, optional): Apple Push Notification Service specific options - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig
webpush_config (dict, optional): Webpush protocol options - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#webpushconfig
fcm_options (dict, optional): Platform independent options for features provided by the FCM SDKs - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#fcmoptions
timeout (int, optional): Set time limit for the request
Returns:
dict: name (str) - The identifier of the message sent, in the format of projects/*/messages/{message_id}
Raises:
FCMServerError: FCM is temporary not available
AuthenticationError: error authenticating the sender account
InvalidDataError: data passed to FCM was incorrecly structured
FCMSenderIdMismatchError: the authenticated sender is different from the sender registered to the token
FCMNotRegisteredError: device token is missing, not registered, or invalid
"""
payload = self.parse_payload(
fcm_token=fcm_token,
notification_title=notification_title,
notification_body=notification_body,
notification_image=notification_image,
data_payload=data_payload,
topic_name=topic_name,
topic_condition=topic_condition,
android_config=android_config,
apns_config=apns_config,
webpush_config=webpush_config,
fcm_options=fcm_options,
dry_run=dry_run,
)
response = self.send_request(payload, timeout)
return self.parse_response(response)
notify 함수는 Push Notification으로 전송할 payload를 작성한 뒤 send_request() 함수를 호출하고, 응답 결과를 parse_response() 함수로 파싱하여 반환한다. FCMNotification 클래스는 BaseAPI를 상속받으므로 send_request() 함수 및 parse_response() 함수의 구현부는 BaseAPI 클래스가 작성되어있는 baseapi.py에서 찾아볼 수 있다.
def send_request(self, payload=None, timeout=None):
response = self.requests_session.post(
self.FCM_END_POINT, data=payload, timeout=timeout
)
if (
"Retry-After" in response.headers
and int(response.headers["Retry-After"]) > 0
):
sleep_time = int(response.headers["Retry-After"])
time.sleep(sleep_time)
return self.send_request(payload, timeout)
return response
먼저 send_request()를 살펴보면 requests_session.post()를 사용해서 FCM_END_POINT로 payload를 전송하고, 응답 헤더에 Retry-After가 포함되어있다면 해당 시간만큼 대기한 뒤 다시 한 번 요청을 전송한다. 전송이 완료되면 응답을 반환하고 있는데, 여기까지만 보더라도 HTTPv1 통신을 하고 있다는 것을 짐작할 수 있다.
그렇다면 이번에는 응답을 파싱하던 parse_response() 함수의 내용을 살펴보자. FCM 문서에서 살펴본 바와 같이 응답에 대한 예외에는 HTTP 상태코드가 포함되어있으므로, parse_response()에서 문제 상황을 어떻게 핸들링하는지 알 수 있으리라 추측해볼 수 있다.
def parse_response(self, response):
"""
Parses the json response sent back by the server and tries to get out the important return variables
Returns:
dict: name (str) - The identifier of the message sent, in the format of projects/*/messages/{message_id}
Raises:
FCMServerError: FCM is temporary not available
AuthenticationError: error authenticating the sender account
InvalidDataError: data passed to FCM was incorrecly structured
FCMSenderIdMismatchError: the authenticated sender is different from the sender registered to the token
FCMNotRegisteredError: device token is missing, not registered, or invalid
"""
if response.status_code == 200:
if (
"content-length" in response.headers
and int(response.headers["content-length"]) <= 0
):
raise FCMServerError(
"FCM server connection error, the response is empty"
)
else:
return response.json()
elif response.status_code == 401:
raise AuthenticationError(
"There was an error authenticating the sender account"
)
elif response.status_code == 400:
raise InvalidDataError(response.text)
elif response.status_code == 403:
raise FCMSenderIdMismatchError(
"The authenticated sender ID is different from the sender ID for the registration token."
)
elif response.status_code == 404:
raise FCMNotRegisteredError("Token not registered")
else:
raise FCMServerError(
f"FCM server error: Unexpected status code {response.status_code}. The server might be temporarily unavailable."
)
기대했던대로 parse_response() 함수 내부에서는 HTTP 상태값을 기준으로 응답 본문을 JSON 객체로 파싱해서 반환하거나, 예외를 발생시키는 것을 볼 수 있다. FCM 문서에서 살펴본 바와 같이 등록되지 않은 토큰, 즉 UNREGISTERED인 경우에는 404이므로 FCMNotRegisteredError가 raise되는 걸 알 수 있다. 또한, INVALID_ARGUMENT인 경우에는 400으로 InvalidDataError가 raise되는 걸 알 수 있다.
재밌는 점이라면 INVALID_ARGUMENT인 경우에는 에러 객체에 메시지 값으로, 응답 본문을 문자열로 변환하여 전달한다는 점이다. 전송한 페이로드에 문제가 있는지, 아니면 FCM 토큰에 문제가 있는지는 예외가 발생했을 시 인자로 전달된 응답 본문을 파싱하여 핸들링할 수 있겠다. 주석을 통해 IDE가 멀쩡했다면 함수에 마우스 커서를 갖다댔을 때 예외 처리와 관련된 내용이 주르륵 나왔을 것 같지만, 아무튼 꿋꿋하게 모른척하고 예외 처리한 코드를 정리해보면 다음과 같다.
try:
fcm_notification.notify(
fcm_token = fcm_token,
data_payload = data_payload,
apns_config = apns_config,
)
except FCMNotRegisteredError as e:
# FCM 토큰이 등록되지 않은 경우이므로 삭제한다.
except InvalidDataError as e:
error_response = str(e);
error_data = json.loads(str(e))
error_status = error_data.get("error", {}).get("status", "")
error_message = error_data.get("error", {}).get("message", "")
# 에러 메시지를 보고 FCM 토큰을 삭제할지 결정한다.
FCMNotification으로 notify를 사용해 Push Notification을 전송할 시, 다음과 같이 FCMNotRgisteredError와 InvalidDataError에 대한 try-except 구문을 적용해서 적절하게 FCM 토큰을 관리할 수 있다. 코드를 쭉 훑어보기 전에는 PyFCM을 갖다쓰는게 편하겠거니 했었다. 정작 PyFCM 코드와 FCM 문서를 훑어보고난 뒤 잠시 생각해보니, 어차피 FCM 문서에도 파이썬을 사용해서 푸시 메시지를 전송하는 코드가 다 나와있어서, PyFCM을 갖다쓰나 FCM 문서에 기재되어있는 코드를 가지고 모듈을 새로 만드나 큰 차이는 없을 것 같다는 생각이 얼핏 든다.
아무튼 이것으로 시작은 IDE 문제였던 것 같지만, 아무튼 PyFCM을 사용해서 예외처리를 통해 FCM 토큰을 관리하는 방법에 대해 살펴봤다.