[Spring/JAVA] Spring에서 APNs를 통한 푸시 전송 기능 개선

[Spring/JAVA] Spring에서 APNs를 통한 푸시 전송 기능 개선

Java에서 APNs 푸시를 전송하는 방법에 대해서 소개한다

1. 문제

두 달 전 쯤에 시연 할 때까지만 해도 잘 동작하던 VoIP 기능이 갑자기 동작하지 않는 문제가 발생했다.

처음에는 앱 문제인 줄 알았다. 왜냐하면, 앱 리뉴얼 1차 출시 때 일시적으로 제거한 VoIP 기능을 다시 복구하는 과정에서 잘못된 줄 알았기 때문이다.

게다가 서버에서도 VoIP 푸시 전송 과정에 별다른 로그를 출력하지 않았기 때문에 iOS 프로젝트 설정이 잘못되어 푸시를 못받는 줄 알았다.

FCM(Firebase Cloud Message)이 아니라 APNs(Apple Push Notification Service)를 사용하는 이유는 FCM에서는 VoIP Push를 지원하지 않기 때문이다.

2. 원인 파악 과정

기존에 APNs에 VoIP 푸시를 요청 할 때, Pushy라이브러리를 사용했다.

VoIP 테스트를 좀 더 쉽게 하기 위해 이 라이브러리를 최신 버전으로 업데이트하고 테스트 코드를 작성했는데, 아래와 같이 성공을 응답하고 일정 시간이 지나자 채널 인증 토큰이 expired 되었다는 로그가 출력 되었다.

Sent push notification: SimplePushNotificationResponse{pushNotification=SimpleApnsPushNotification{token='{토큰}', payload='{"aps":{}, "FROM":"보내는사람"}', invalidationTime=2024-05-26T06:05:50.149986Z, priority=IMMEDIATE, pushType=VOIP, topic='{BUNDLE_ID}.voip', collapseId='null', apnsId=null}, success=true, apnsId=, apnsUniqueId= rejectionReason='null', tokenExpirationTimestamp=null}

15:55:50.824 [nioEventLoopGroup-2-1] DEBUG com.eatthepath.pushy.apns.TokenAuthenticationApnsClientHandler - Authentication token for channel [id: 0xc4072949, L:/192.168.0.33:47364 - R:api.sandbox.push.apple.com/17.188.143.98:443] has expired

로그 상으로는 일단 성공을 응답했기 때문에, 앱에서 푸시를 “수신 못해서 토큰이 만료되는 것이라 생각”해 며칠 동안 끙끙 앓았다. ㅠㅠ

혹시나 “라이브러리가 문제인가?”싶어서 node.js의 apn라이브러리로 테스트했더니 앱에서 VoIP 푸시가 수신되었다.

java용 APNs 라이브러리는 pushy 외에는 너무 오랫동안 업데이트되지 않았기 때문에 어쩔 수 없이 node.js로 테스트 하였다.

3. 해결 과정

앞서 라이브러리 문제임을 파악했으므로, 직접 APNs 라이브러리를 작성하기로 하였다.

APNs 알림 전송 문서APNs 인증 토큰 생성 문서의 내용을 정리하면 요청을 아래와 같이 보낼 수 있다.

# Development
@hostname = api.sandbox.push.apple.com
# Production
@hostname = api.push.apple.com
# 기기의 VoIP토큰
@voipToken = ...
@token = ...
@bundleId = ...

POST https://{hostname}/3/device/{voipToken}
Authorization: bearer {token}
Content-Type: application/json; charset=utf-8
apns-topic: {bundleId}.voip
apns-push-type: voip

{
  ...Payload
}

그리고 token은 아래와 같은 방법으로 만든다.

// 애플 개발자 센터에서 다운받은 p8 Push Key
@p8KeyContents = ...
// Push Key의 Id
@keyId = ...
// 개발자 계정 Team ID
@teamId = ...

@header = encodeBase64({
   "alg" : "ES256",
   "kid" : "{keyId}"
})

@payload = encodeBase64({
   "iss": "{teamId}",
   "iat": {epochSeconds}
})

@verifySignature = ecdsaSha256(
  {header}.{payload},
  {p8keyContents}
)

@token = {header}.{payload}.{verifySignature}

위 문서대로 크게 APNs Client, APNs 설정, Push 전송 데이터로 나누어 코드를 작성했다.

우선 APNs 설정을 담당하는 SimpleApnsConfiguration 코드는 아래와 같다.

// file: 'SimpleApnsConfiguration.kt'
/**
 * Configuration class for APNs.
 *
 * @param keyPath Path to the p8 key file.
 * @param teamId Team ID.
 * @param keyId Key ID.
 * @param isProduction Whether to use the production server. if install app is app store or test flight, set true.
 * @author SKAIBlue
 */
class SimpleApnsConfiguration (
    private val keyPath: String,
    private val teamId: String,
    private val keyId: String,
    private val isProduction: Boolean
) {
    private var p8der: String? = null
    private var key: PrivateKey? = null
    val jwtToken: String
        get() = generateJwtToken()

    init {
        try {
            this.p8der = Files.readAllLines(Path.of(this.keyPath)).stream()
                .filter { s: String -> !s.contains("----") }
                .collect(Collectors.joining())
            val priPKCS8 = PKCS8EncodedKeySpec(Base64.getDecoder().decode(p8der))
            key = KeyFactory.getInstance("EC").generatePrivate(priPKCS8)
        } catch (e: IOException) {
            // Key 파일을 읽을 수 없음
            throw SimpleApnsKeyException("Cannot read p8 key file")
        } catch (e: InvalidKeySpecException) {
            // Key 파일이 유효하지 않음
            throw SimpleApnsKeyException("Key file is Invalid")
        } catch (e: NoSuchAlgorithmException) {
            // 알고리즘이 지원되지 않음
            throw SimpleApnsKeyException("Algorithm is not supported")
        }
    }

    /**
     * Make a URL for the APNs server.
     *
     * @param deviceToken Device token.
     */
    fun makeUrl(deviceToken: String): String {
        if (isProduction) return "https://api.push.apple.com/3/device/$deviceToken"
        return "https://api.sandbox.push.apple.com/3/device/$deviceToken"
    }

    /**
     * Generate a JWT token.
     */
    private fun generateJwtToken(): String {
        return Jwts.builder()
            .header()
            .keyId(keyId)
            .and()
            .issuer(teamId)
            .issuedAt(Date())
            .signWith(key, Jwts.SIG.ES256)
            .compact()
    }
}

그리고 푸시 전송 데이터를 표현하는 코드 SimpleApnsPushDetails는 interface로 작성하였으며, 이를 구현하여 다양한 타입의 payload를 지원하도록 하였다. Map형태의 payload를 담을 수 있는 DefaultSimpleApnsPushDetails를 작성해두었다.

// file: 'SimpleApnsPushDetails.kt'
/**
 * Interface for APNs push details.
 *
 * @author SKAIBlue
 */
interface SimpleApnsPushDetails {
    val token: String
    val topic: String
    val payload: Any
    val type: ApnsPushType
}
// file: 'DefaultSimpleApnsPushDetails.kt'
/**
 * Default implementation of SimpleApnsPushDetails.
 *
 * @author SKAIBlue
 */
class DefaultSimpleApnsPushDetails(
    override val token: String,
    override val topic: String,
    override val payload: Map<String, String>,
    override val type: ApnsPushType = ApnsPushType.ALERT,
): SimpleApnsPushDetails

마지막으로 APNs 푸시 전송을 담당하는 클라이언트이다.

// file: 'SimpleApnsClient.kt'
/**
 * APNs client that sends push notifications to the APNs server.
 *
 * Before using this class, you need to create a `SimpleApnsConfiguration` object.
 *
 * @author SKAIBlue
 */
class SimpleApnsClient(private val configuration: SimpleApnsConfiguration) {
    private val client = HttpClient.newHttpClient()
    private val mapper = ObjectMapper()

    /**
     * Send a push notification.
     *
     * @param push The push notification to send.
     */
    fun send(push: SimpleApnsPushDetails): CompletableFuture<Unit> {
        val request = buildRequest(push)

        return client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply {
            if (it.statusCode() == 200) return@thenApply
            val res = mapper.readValue(it.body(), ApnsPushSendResponse::class.java)
            throw SimpleApnsSendException("Fail to send push message status code = ${it.statusCode()}], reason = ${res.reason}")
        }
    }

    /**
     * Build a request to send a push notification.
     */
    private fun buildRequest(push: SimpleApnsPushDetails) = push.let {
        val body = mapper.writeValueAsString(push.payload)

        HttpRequest.newBuilder()
            .uri(URI.create(configuration.makeUrl(push.token)))
            .header("content-type", "application/json; charset=utf-8")
            .header("apns-topic", push.topic)
            .header("apns-push-type", push.type.value)
            .header("authorization", "bearer " + configuration.jwtToken)
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build()
    }
}

전체 코드는 Github에 공유하였으며, 사용법은 아래와 같다.

// file: `ApnsClientTest.kt`
val token  = "{TOKEN}"
val topic  = "{TOPIC}"

val push = DefaultSimpleApnsPushDetails(
    token = token,
    topic = topic,
    payload = mapOf(
        "from" to "SKAIBlue"
    ),
    // Default ALERT
    // If you want to send VoIP Push, set to ApnsPushType.VOIP
    type = ApnsPushType.VOIP
)

client.send(push).thenAccept {
    // Success
}.exceptionally {
    // Failed
    println(it.message)
}.join()

출처

타이틀 이미지: UnsplashJamie Street