[Python] AIOAPNS를 사용한 APNs 푸시 메시지 전송

2025. 2. 24. 13:35Programming/Server

반응형

왜 FCM을 사용하지 않고 APNs를 사용하게 됐는가?

  AIOAPNS 패키지를 사용해서 APNs로 푸시 메시지를 발송 기능을 구현해야 할 일이 생겼다. 일반적인 경우 푸시 메시지를 발송할 일이 있다면 FCM을 사용해서 iOS/안드로이드 양쪽으로 푸시 메시지를 발송하게 되는데, 이번에는 FCM 토큰 유효기간으로 인한 의구심이 들었다. 파이어베이스 문서에 명시되어있는 비활성 토큰은 다음과 같다. 270일간 활동이 없으면 만료된 토큰으로 간주한다는 얘기인데, 이 '활동'이 푸시 메시지를 수신한 것을 의미하는지, 아니면 푸시 메시지를 수신한 이후 사용자가 아무런 반응을 보이지 않은 것인지 분명하지 않다. 

비활성 등록 토큰은 FCM에 1개월 넘게 연결되지 않은 비활성 기기와 연결된 토큰입니다. 시간이 지날수록 기기가 FCM에 다시 연결될 가능성은 점점 줄어듭니다. 이러한 비활성 토큰의 메시지 전송 및 주제 팬아웃은 전송되지 않을 가능성이 높습니다.
토큰이 비활성화되는 이유는 여러 가지가 있습니다. 예를 들어 토큰이 손실되거나, 파손되거나, 스토리지로 넘어가거나, 잊혀진 경우입니다.
비활성 토큰이 270일 동안 활동이 없으면 FCM에서 만료된 토큰으로 간주합니다. 토큰이 만료되면 FCM는 토큰을 유효하지 않은 것으로 표시하고 토큰으로의 전송을 거부합니다. 하지만 FCM은 기기가 다시 연결되고 앱이 열리는 흔치 않은 경우에 앱 인스턴스의 새 토큰을 발급합니다.

* 출처: https://firebase.google.com/docs/cloud-messaging/manage-tokens?hl=ko

 

  아마도 비활성 기기는 전자인 '푸시 메시지를 수신하지 못하는 기기'를 의미하겠지만, 이렇게 해석이 불분명한 상황에서 270일이 경과됐을 시 푸시 메시지가 정상적으로 수신되지 않는다면, 화재 감지나 도난 감지를 사용자가에 알려야하는 앱에서는 문제가 될 수 있으므로 대응책이 필요했다. 안드로이드 앱은 푸시 메시지를 수신했을 시 백그라운드에서 코드를 실행하는게 가능하지만, 아이폰 앱은 시스템에서 푸시 메시지를 수신하기 때문에 그러한 일련의 동작이 불가능하다. 대신, APNs 문서 중 다음의 내용을 통해 아이폰에서 발행되는 디바이스 토큰은 별도의 만료기간이 없고, 사용자가 몇몇 동작을 할 경우에만 새로운 토큰이 발급된다는 것을 알 수 있다.

Never cache device tokens in local storage. APNs issues a new token when the user restores a device from a backup, when the user installs your app on a new device, and when the user reinstalls the operating system. You get an up-to-date token each time you ask the system to provide the token.
* 로컬 스토리지에 디바이스 토큰을 절대 캐시하지 마세요. APNs는 사용자가 백업에서 디바이스를 복원하거나, 새로운 디바이스에 앱을 설치하거나, 운영 체제를 재설치할 때마다 새로운 토큰을 발급합니다. 시스템에 토큰을 요청할 때마다 최신 토큰을 받을 수 있습니다.

* 출처: https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns

 

  즉, 아이폰을 사용하는 경우 APNs으로 푸시 메시지를 발송하도록 구현한다면, 토큰 만료에 대해 우려할 필요가 없다는 얘기가 된다. 뿐만 아니라 Firebase 서버가 다운됐더라도 아이폰을 사용하는 사용자는 여전히 푸시 메시지를 전송받을 수 있다는 장점도 있다. 단점이라면 인증서를 매년 수동으로 갱신해줘야되는데 까먹는 순간 푸시 메시지가 전송되지 않는다는 점 정도. 자동화할 수 있는 방법이 있을 것 같지만, 일단은 그런 내용은 차치하고, 클라이언트 환경에 맞춰서 FCM/APNs를 구분해서 푸시 메시지를 전송하기로 했다.

 

aioapns 라이브러리를 사용한 파이썬 환경에서의 푸시 메시지 전송 

 

GitHub - Fatal1ty/aioapns: An efficient APNs Client Library for Python/asyncio

An efficient APNs Client Library for Python/asyncio - Fatal1ty/aioapns

github.com

 

  이번에는 파이썬으로 작성된 서버에서 APNs로 푸시 메시지를 발송하기 위해 aioapns 라이브러리를 사용한다. 애플에서 지원하는 Sign in with apple 기능을 구현할 때와 마찬가지로, 애플 생태계 외부에서 애플이 제공하는 기능을 사용하기 위해서는 HTTP를 사용해서 통신하게되는데, 대부분의 라이브러리는 이러한 HTTP 통신을 간단하게 사용할 수 있도록 래핑한 것들이다. aioapns 역시 마찬가지인데, 굳이 aioapns를 고른 이유는 PyAPNs2같은 라이브러리의 마지막 수정 이력이 3년 전이기 때문.

  물론 애플에서 공개한 APNs API가 변경되거나 특별한 버그가 없다면 3년 전에 마지막으로 업데이트된 라이브러리여도 크게 상관은 없겠지만, 상대적으로 8개월 전에 마지막 업데이트된 aioapns를 사용하는 편이 더 나을 것이라고 판단했다.

 

  먼저 라이브러리에서 제공하는 샘플 코드를 살펴보면 다음과 같다.

import asyncio
from uuid import uuid4
from aioapns import APNs, NotificationRequest, PushType


async def run():
    apns_cert_client = APNs(
        client_cert='/path/to/apns-cert.pem',
        use_sandbox=False,
    )
    apns_key_client = APNs(
        key='/path/to/apns-key.p8',
        key_id='<KEY_ID>',
        team_id='<TEAM_ID>',
        topic='<APNS_TOPIC>',  # Bundle ID
        use_sandbox=False,
    )
    request = NotificationRequest(
        device_token='<DEVICE_TOKEN>',
        message = {
            "aps": {
                "alert": "Hello from APNs",
                "badge": "1",
            }
        },
        notification_id=str(uuid4()),  # optional
        time_to_live=3,                # optional
        push_type=PushType.ALERT,      # optional
    )
    await apns_cert_client.send_notification(request)
    await apns_key_client.send_notification(request)

loop = asyncio.get_event_loop()
loop.run_until_complete(run())

  전송할 방식에 맞춰 APNs 객체와 NotificationRequest 객체를 작성한 뒤, send_notification() 함수를 호출해주는 것이 전부다. 예제 코드가 실행되면 asyncio.get_event_loop()를 통해 이벤트 루프를 얻어온 뒤, run() 함수를 비동기로 실행하게 된다. pem 인증서를 사용할 경우 비밀번호가 필요한 것으로 알고있는데 예제코드에서는 사용하지 않고 있어서 의문이지만, 대부분은 비밀번호를 사용하지 않는 p8 인증서를 사용할 것으로 생각되므로(...) 생략한다.

 

  별도로 예외 처리에 대한 내용이 기재되어있지 않아서 소스 코드를 확인해보니, connection.py에 작성된 APNsBaseClientProtocol 클래스의 on_response_received() 함수에서 다음과 같은 내용을 확인할 수 있었다. 별도로 예외를 발생시키는 코드는 없고, 응답 헤더에 포함된 상태 코드를 UTF8 타입으로 디코딩하여, 반환하는 것으로 보인다.

def on_response_received(self, headers: Dict[bytes, bytes]) -> None:
    notification_id = headers.get(b"apns-id", b"").decode("utf8")
    status = headers.get(b":status", b"").decode("utf8")
    if status == APNS_RESPONSE_CODE.SUCCESS:
        request = self.requests.pop(notification_id, None)
        if request:
            result = NotificationResult(notification_id, status)
            request.set_result(result)
        else:
            logger.warning(
                "Got response for unknown notification request %s",
                notification_id,
            )
    else:
        self.request_statuses[notification_id] = status
        
def on_data_received(self, raw_data: bytes, stream_id: int) -> None:
    data = json.loads(raw_data.decode())
    reason = data.get("reason", "")
    timestamp = data.get("timestamp")

    if not reason:
        return

    notification_id = self.request_streams.pop(stream_id, None)
    if notification_id:
        request = self.requests.pop(notification_id, None)
        if request:
            # TODO: Теоретически здесь может быть ошибка, если нет ключа
            status = self.request_statuses.pop(notification_id)
            result = NotificationResult(
                notification_id,
                status,
                description=reason,
                timestamp=timestamp,
            )
            request.set_result(result)
        else:
            logger.warning("Could not find request %s", notification_id)
    else:
        logger.warning(
            "Could not find notification by stream %s", stream_id
        )

  이후 요청에 대한 응답 본문은 on_data_received에서 처리되는 것으로 보이는데, NotificationResult 객체를 생성해서 request.set_result(result)를 호출함으로써 푸시 메시지 발송 결과를 반환하는 것으로 보인다. 응답 본문에 포함된 reason값은 응답 본문을 파싱한 값이므로, 애플에서 제공하는 문서 중 Handling notification responses from APNs - Understand Error codes를 통해서 그 내용에 대한 파악이 가능하다. 에러코드 중 토큰이 만료됐는지 확인하기 위한 코드는 400 - BadDeviceToken410 - Unregistered이다.

  문서에 기재되어있듯 400 - BadDeviceToken은 토큰이 발급된 환경과 일치하지 않아 유효성 검사에 실패한 경우를 말하며, 410 - Unregistered는 디바이스 토큰이 비활성화됐음을 의미한다. 따라서 응답이 400 - BadDeviceToken으로 온 경우에는 Production/development 서버를 전환해서 다시 한 번 발송해보고, 410 - Unregistered인 경우에는 디바이스 토큰을 삭제하면 되겠다.

  다음은 위의 내용에 기반하여 aioapns를 사용해 APNs로 푸시 메시지를 발송하는 예제 코드다.

async def _send_push_notification_to_apns(apns_token, payload, use_sandbox=False):
    apns_key_client = APNs(
        key=#p8 인증서 경로,
        key_id=#KEY ID,
        team_id=#TEAM ID,
        topic=#TOPIC(BUNDLE ID),
        use_sandbox=use_sandbox,
    )
    
    response = await apns_key_client.send_notification(
        NotificationRequest(
            device_token=apns_token,
            message=payload,
            notification_id=str(uuid4()),
        )
    )
    
    if response.status == '200':
        #전송 성공
    elif response.status == '410' and response.description == 'Unregistered':
        #비활성화된 토큰이므로 삭제 처리
    elif response.status == '400' and response.description == 'BadDeviceToken':
        #토큰 발급 환경과 전송 환경이 불일치하여 유효성 체크 실패
        if not use_sandbox:
            #Production 환경으로 전송을 시도한 경우, Development 환경으로 재시도
            await _send_push_notification_to_apns(token, payload, True)
        else:
            #Production/Development 환경 모두 전송 실패
    else:
        # 그 외의 사유로 인한 전송 실패
        
loop = asyncio.get_event_loop()
loop.run_until_complete(
    _send_push_notification_to_apns(
        apns_token, 
        {
            'aps': {
                'alert': {
                    'title': message_title,
                    'body': message_body
                },
                'sound': 'default',
                'content-available': 1,
            },
            'data': message_data
        },
    )
)

 

정리

1. FCM Token은 비활성 기기와 연결된 FCM 토큰이 270일간 활동이 없으면 만료된 토큰으로 간주한다.

2. 안드로이드 환경에서는 FCM 토큰을 통해 푸시 메시지를 수신한 경우, 백그라운드 코드로 갱신된 토큰을 서버로 전송하는 작업이 가능하다. 반면 iOS 환경에서는 그러한 작업이 불가능하다.

3. APNs에서 사용되는 디바이스 토큰은 만료기간이 별도로 정해져있지 않으며, 사용자가 데이터를 백업하거나 다른 기기로 데이터를 이전하는 등의 행위를 할 때 재생성된다.

4. 파이썬 환경에서 aioapns 라이브러리를 사용해, 간단하게 APNs로 푸시메시지를 전송할 수 있다.

반응형