SW 개발/Spring

Spring Boot, APNs, 푸시 서버 제작하기

지단로보트 2019. 9. 10. 11:04

개요

  • 애플이 제공하는 APNs를 이용하면, 내가 제작한 앱이 설치된 애플의 제품군에 해당하는 모든 기기에 메시지를 전송할 수 있다. 이번 글에서는 Kotlin, Spring Boot 기반 프로젝트에서 APNs를 이용한 메시지 전송 방법을 설명하고자 한다.

관련 용어

  • 메시지를 전송하려면, 대상이 되는 기기 각각을 식별할 수 있는 고유의 식별 문자열이 요구된다. 이를 Device Token이라고 부른다. (FCM에서는 FCM Token이라고 부르는 것과 같은 개념이다.) 일반적으로 앱은 자신이 실행 중인 기기의 Device Token을 앱 서버로 전송하고, 이를 수집한 앱 서버가 알람 등의 메시지를 각 기기로 전송하는 방식으로 운영된다. 이 때 앱 서버는 애플이 제공하는 APNs 서버에 메시지를 전송하여 메시지 전송을 위임한다.
  • 대상 기기를 식별하더라도, 어떤 앱으로 전송된 메지시를 전송할 것인지에 대한 고유의 식별 문자열이 요구된다. 이를 Topic이라고 부른다. 일반적으로 앱의 Bundle ID에 해당한다.

build.gradle

  • 프로젝트 내 /build.gradle에 아래 내용을 추가한다.
dependencies {
    compile group: 'com.turo', name: 'pushy', version: '0.13.9'
}
  • PushyJava 진영에서 가장 오래되고 안정적인 APNs 메시징 라이브러리이다. Netty 기반으로 작동하여, 복수개의 메시지를 비동기로 동시에 전송할 수 있어, 소요 시간을 대폭 절약할 수 있는 것이 특징이다.

APNs 초기화 코드 작성

  • APNs 초기화 코드를 작성할 차례이다. 아래는 TLS 기반 인증의 예로, 메시지를 전송 받을 자신의 앱에 대한 PKCS#12 인증키 파일과, 인증키를 해독할 비밀번호가 필요하다.
package com.jsonobject.example

import com.turo.pushy.apns.ApnsClient
import com.turo.pushy.apns.ApnsClientBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.io.ClassPathResource

@Configuration
class ApnsConfig {

    @Bean
    fun someAppDevApnsClient(): ApnsClient {

        return ApnsClientBuilder()
                .setApnsServer(ApnsClientBuilder.DEVELOPMENT_APNS_HOST)
                .setClientCredentials(ClassPathResource("some_app_dev_pkcs_12_file.p12").inputStream, "some_app_dev_password")
                .build()
    }

    @Bean
    fun someAppProdApnsClient(): ApnsClient {

        return ApnsClientBuilder()
                .setApnsServer(ApnsClientBuilder.PRODUCTION_APNS_HOST)
                .setClientCredentials(ClassPathResource("some_app_prod_pkcs_12_file.p12").inputStream, "some_app_prod_password")
                .build()
    }
}
  • ApnsClient는 한 번 생성하면 계속 재사용할 수 있는 오브젝트이다. 따라서, 앱과 환경 단위로 싱글턴 빈을 생성해두면 시스템 자원을 효율적으로 사용할 수 있다.
  • 인증서 로드시 ClassPathResource().inputStream으로 로드해야 운영 환경에서 정상적으로 작동한다. ClassPathResource().file로 로드시 개발 환경에서는 문제가 없지만, 패키징되어 실행되는 운영 환경에서는 java.io.FileNotFoundException 예외가 발생한다.
  • TLS 기반의 인증 방식은 P12 인증서 파일을 요구하는데 유효기간이 존재하여 매년 해당 인증서 파일을 갱신해야 하는 수고로움이 있다. 반면에 Token 기반의 인증 방식은 P8 인증서 파일을 요구하며 유효기간이 없어 영구적으로 사용이 가능하다. [관련 링크]

메시지 전송 테스트

  • 이제 APNs 서버로 메시지를 전송할 차례이다.
val payloadBuilder = ApnsPayloadBuilder()
payloadBuilder.setAlertTitle("test_title")
payloadBuilder.setAlertBody("test_body")
payloadBuilder.addCustomProperty("test_data_1", "abc")
payloadBuilder.addCustomProperty("test_data_2", "def")
val payload = payloadBuilder.buildWithDefaultMaximumLength()

val token = TokenUtil.sanitizeTokenString("destination_device_token")
val pushNotification = SimpleApnsPushNotification(token, "my_apps_bundle_id", payload)
val sendNotificationFuture: PushNotificationFuture<SimpleApnsPushNotification, PushNotificationResponse<SimpleApnsPushNotification>> = apnsClient.sendNotification(pushNotification)

// 메시지를 동기로 전송
val response = sendNotificationFuture.get()
println(response)

// 메시지를 비동기로 전송
sendNotificationFuture.addListener { future ->
    val response = future.now
    println(response)
}

// 성공 응답시 null 반환
// 오류 응답시 BadDeviceToken 등의 오류 코드 문자열 반환
println(response.rejectionReason)

// 응답에서 원본 메시지 획득 가능
// {"aps":{"alert":{"body":"test_body","title":"test_title"}},"test_data_3":"def","test_data_1":"abc"}
println(response.pushNotification.payload)
  • alertBody의 값이 없으면 디바이스가 백그라운드 상태일 경우 알림이 노출되지 않는다. 즉, 무시된다.
  • customProperty의 값은 JSON으로 변환 가능한 모든 오브젝트가 허용된다. 최종적으로 JSON으로 변환되어 전송된다.
  • 코드 상에서 빌드된 payloadAPNs 서버에 JSON 문자열로 최종 가공되어 전송되는데 이 크기가 4,096 bytes를 초과하면, 라이브러리가 임의로 alertBody 필드의 크기를 줄여서 전송한다. 이 시도로도 크기가 초과되면, IllegalArgumentException 예외를 발생시킨다.
  • APNs 서버는 약 1,500개의 메시지를 동시에 수신할 수 있다. 즉, 비동기로 1,500개 정도는 문제 없이 전송 가능하다. 만약 비동기로 전송하는 메시지의 개수가 이를 초과할 경우 Pushy가 자동으로 버퍼에 담아 순차적으로 문제 없이 전송하게 된다.
  • 복수개의 메시지를 효과적으로 보내려면 비동기 전송은 필수이다. 비동기 전송의 응답 안에 전송 메시지에 대한 JSON 문자열을 담고 있기 때문에 전송 전에 미리 해당 메시지를 식별할 수 있는 ID를 커스텀 프라퍼티로 담아두면 결과에 대한 처리를 명확히 할 수 있다. (response.pushNotification.payload)

예외 케이스 정리

  • 실패 케이스는 response.rejectionReason 응답 문자열로 확인할 수 있다.
  • APNs는 동시적으로 발생하는 발송 요청에 대해 뚜렷하게 제한을 두고 있지 않다. 따라서 동시 발송으로 인한 오류가 발생할 확률은 낮다. 다만, 동일한 토큰으로 동시에 연속적으로 많은 요청을 하면 TooManyRequests 오류를 응답한다. [관련 링크]

참고 글