모바일 푸쉬 메시지(FCM)를 위한 PHP 서버 구현 2부 share

today 2016-12-24 face Posted by appkr turned_in Work & Play forum 0

이 포스트는 2016년 12월 24일 첫 포스트 이후, 2017년 1월 24일에 코드 리팩토링 내용을 반영하여 변경되었습니다.

FCM(Firebase Cloud Message)은 Android, iOS, Web 등의 클라이언트에 푸쉬 메시지를 보내기 위한 서비스다. 과거 GCM(Google Cloud Message)이 진화한 것이다.

Firebase Logo

지난 1부에서는 푸쉬 메시지를 받을 모바일 단말 애플리케이션이 구글 FCM 서버로 부터 받은 고유 식별 토큰을 애플리케이션 서버로 전달해 등록하는 과정을 구현했다. 이번 포스트에서는 등록한 토큰으로 푸쉬 메시지를 쏘는 기능을 구현한다.

• • •

1. FCM 라이브러리 선택 및 적용

이 글을 쓰는 시점에 PHP 버전의 공식 FCM 라이브러리는 없다. Packagist를 검색해보면 다음과 같은 결과를 얻을 수 있다.

Packagist Search

다운로드 9,070회와 좋아요 59개를 받은 brozot/laravel-fcm, 8,456회와 50개를 받은 paragraph1/php-fcm 라이브러리가 눈에 들어온다. 스페이스 대신 탭을 쓰고, PHP에는 없는 ENUM을 만들어 쓰는 등 Java 스러워서 한참을 고민하다, 버전이 1.2.x이고 코드 퀄리티가 더 훌륭해서 brozot/laravel-fcm로 선택했다.

1.1. 라이브러리 설치

컴포저로 라이브러리를 가져온다.

~/fcm-scratchpad $ composer require brozot/laravel-fcm

설치한 라이브러리를 라라벨에서 사용하기 위해서는 라이브러리에서 제공하는 서비스 프로바이더를 등록해야 한다. 서비스 프로바이더가 라라벨 부팅 시점에 필요한 인스턴스를 생성해서 서비스 컨테이너에 미리 등록하면, 애플리케이션 실행 중에 언제든 꺼내 쓸 수 있다.

<?php // config/app.php

return [
    // ...
    'providers' => [
        // ...
        LaravelFCM\FCMServiceProvider::class,
    ]  
];

이전 포스트에서 설명했듯이 Firebase 콘솔로 부터 받은 서버 키와 발신자 ID를 방금 설치한 라이브러리에 알려주어야 FCM 서버와 정상적으로 HTTP 통신을 할 수 있다. 라이브러리의 설정 파일을 복제하고, .env 파일에 값을 기록하자.

~/fcm-scratchpad $ php artisan vendor:publish --provider="LaravelFCM\FCMServiceProvider"

위 명령은 config/fcm.php 파일을 생성한다.

<?php // config/fcm.php

return [
    // ...
    'http' => [
        'server_key' => env('FCM_SERVER_KEY', 'Your FCM server key'),
        'sender_id' => env('FCM_SENDER_ID', 'Your sender id'),
        // ...
    ],
];

아하! FCM_SERVER_KEY, FCM_SENDER_ID 두 개의 환경 변수를 참조하는 것을 알았으므로, 1부에서 FCM 콘솔에서 프로젝트를 등록하고 얻은 값들을 .env 파일에 써준다.

# .env

FCM_SERVER_KEY=AAAAMqBU...OJy1Dw
FCM_SENDER_ID=217...581

1.2. 설치한 라이브러리를 사용하여 FCM 보내기

설치한 라이브러리의 기능을 가장 쉽게 확인할 수 있는 방법은 라우트를 하나 만들고 브라우저에서 해당 라우트로 접속해서 작동을 확인하는 방법이다. 아래 코드는 라이브러리의 README에 소개된 내용 그대로다.

<?php // routes/api.php

use Illuminate\Http\Request;
use LaravelFCM\Message\OptionsBuilder;
use LaravelFCM\Message\PayloadDataBuilder;
use LaravelFCM\Message\PayloadNotificationBuilder;

Route::get('fcm', function () {
    $optionBuilder = new OptionsBuilder();
    $optionBuilder->setTimeToLive(60*20);

    $notificationBuilder = new PayloadNotificationBuilder('알림 제목');
    $notificationBuilder->setBody('알림 본문')->setSound('default');

    $dataBuilder = new PayloadDataBuilder();
    $dataBuilder->addData(['foo' => 'bar']);

    $option = $optionBuilder->build();
    $notification = $notificationBuilder->build();
    $data = $dataBuilder->build();

    $token = 'eI..b0:APA...FJx';

    $downstreamResponse = app('fcm.sender')->sendTo($token, $option, $notification, $data);

    var_dump('numberSuccess', $downstreamResponse->numberSuccess());
    var_dump('numberFailure', $downstreamResponse->numberFailure());
    var_dump('numberModification', $downstreamResponse->numberModification());
    var_dump('tokensToDelete', $downstreamResponse->tokensToDelete());
    var_dump('tokensToModify', $downstreamResponse->tokensToModify());
    var_dump('tokensToRetry', $downstreamResponse->tokensToRetry());
    var_dump('tokensWithError', $downstreamResponse->tokensWithError());
});

로컬 서버를 구동하고 GET /api/fcm을 방문하면 다음 결과를 얻을 수 있다.

~/fcm-scratchpad/routes/api.php:46:string 'numberSuccess' (length=13)
~/fcm-scratchpad/routes/api.php:46:int 0

~/fcm-scratchpad/routes/api.php:47:string 'numberFailure' (length=13)
~/fcm-scratchpad/routes/api.php:47:int 1

~/fcm-scratchpad/routes/api.php:48:string 'numberModification' (length=18)
~/fcm-scratchpad/routes/api.php:48:int 0

~/fcm-scratchpad/routes/api.php:49:string 'tokensToDelete' (length=14)
~/fcm-scratchpad/routes/api.php:49:
array (size=1)
  0 => string 'eI..b0:APA...FJx' (length=152)
  
~/fcm-scratchpad/routes/api.php:50:string 'tokensToModify' (length=14)
~/fcm-scratchpad/routes/api.php:50:
array (size=0)
  empty
  
~/fcm-scratchpad/routes/api.php:51:string 'tokensToRetry' (length=13)
~/fcm-scratchpad/routes/api.php:51:
array (size=0)
  empty
  
~/fcm-scratchpad/routes/api.php:52:string 'tokensWithError' (length=15)
~/fcm-scratchpad/routes/api.php:52:
array (size=0)
  empty

유효하지 않은 단말기 토큰이므로 실패가 떨어지는 것은 당연하다. 우리의 PHP 애플리케이션 서버에서 생성한 메시지가 FCM 서버를 거쳐 단말기에게 전달되는 전체 과정을 확인하지는 못했지만, 적어도 FCM 서버와 통신이 된다는 것은 확인했다.

2. 푸쉬 메시지 요청에 대한 FCM 서버 응답의 이해

아무리 라이브러리를 가져다 쓴다지만 FCM 문서를 이해하지 않고는, FCM 서버의 응답 메시지에 따라 어떤 후속 처리를 해야 할지 알 수 없다. 다음은 총 6개의 단말기에 푸쉬 메시지를 보냈을 때 FCM 서버의 응답 예제이다.

{ 
  "multicast_id": 216,
  "success": 3,
  "failure": 3,
  "canonical_ids": 1,
  "results": [
    { "message_id": "1:0408" },
    { "error": "Unavailable" },
    { "error": "InvalidRegistration" },
    { "message_id": "1:1516" },
    { "message_id": "1:2342", "registration_id": "32" },
    { "error": "NotRegistered"}
  ]
}

우선 results.message_id가 있는 메시지들은 성공한 것이다. 다행히 우리가 설치한 라이브러리는 FCM 서버의 응답을 미리 파싱해서 이해하기 쉬운 메서드 이름으로 제공하고 있는데, 다음 표로 정리했다.

응답 필드 라이브러리의 메서드(DownstreamResponse) 설명 예제 응답 번호(zero-index)
success numberSuccess() 푸쉬 메시지 전달에 성공한 건수 0,3,4
failure numberFailure() 푸쉬 메시지 전달에 실패한 건수 1,2,5
canonical_ids numberModification() 단말기의 고유 식별 토큰이 변경된 건수 4
  tokensToDelete() 애플리케이션 서버에서 삭제할 토큰의 목록 5
  tokensToModify() 애플리케이션 서버에서 업데이트할 토큰의 목록 4
  tokensToRetry() 애플리케이션 서버에서 재 전송해야 하는 토큰의 목록 1
  tokensWithError() 에러가 난 이유 목록  

3. FCM 전송 및 응답 처리 클래스 구현

라이브러리가 제공하는 사용법 예제만으로는 뭔가 부족하다. 우리 서비스만을 위한 클래스를 만들텐데, 이름을 FCMHandler라고 하자. 이 클래스는 라이브러리의 API를 이용하여 FCM 메시지를 보내고 응답 결과에 따라 전송을 재시도하거나 서버에 등록된 단말기 등록 정보를 조작하는 일을 할 것이다. 아래 코드 블록의 인라인 설명 및 주석을 참고한다.

<?php // app/Services/FCMHandler.php

namespace App\Services;

use App\Device;
use LaravelFCM\Message\OptionsBuilder;
use LaravelFCM\Message\PayloadDataBuilder;
use LaravelFCM\Message\PayloadNotificationBuilder;
use LaravelFCM\Response\DownstreamResponse;
use Log;

/**
 * Class FCMHandler
 * @package App\Services
 */
class FCMHandler
{
    /** @var array FCM을 수신한 registration_id 목록 */
    private $to = [];

    /** @var array FCM 데이터 메시지 본문 */
    private $data = [];

    /** @var string FCM 알림 제목 */
    private $title;

    /** @var string FCM 알림 본문 */
    private $body;

    /** @var array 전송 실패시 재시도 간격 */
    private $retryIntervals = [1, 2, 4];

    /** @var int 전송 실패시 재시도 카운트 */
    private $retryIndex = 0;

    /** @var \LaravelFCM\Sender\FCMSender */
    protected $fcm;

    /**
     * 전송이 실패해서 여러 번 재전송할 때를 대비해 한 번 만든 메시지 인스턴스를 캐시하는 저장소.
     *
     * @var array
     *  [
     *      'optionBuilder' => \LaravelFCM\Message\Options,
     *      'notificationBuilder' => \LaravelFCM\Message\PayloadNotification,
     *      'dataBuilder' => \LaravelFCM\Message\PayloadData
     *  ]
     */
    private $cache = [];

    /**
     * 푸쉬 메시지를 보낼 단말기의 registration_id 목록을 설정한다.
     *
     * @param array $to
     * @return $this
     */
    public function to(array $to)
    {
        $this->to = $to;

        return $this;
    }

    /**
     * 푸쉬 메시지로 보낼 데이터 본문을 설정한다.
     *
     * @param array $data
     * @return $this
     */
    public function data(array $data)
    {
        $this->data = $data;

        return $this;
    }

    /**
     * 푸쉬 메시지로 보낼 알림 제목과 본문을 설정한다.
     *
     * @param string $title
     * @param string $body
     * @return $this
     */
    public function notification(string $title = null, string $body = null)
    {
        $this->title = $title;
        $this->body = $body;

        return $this;
    }

    /**
     * 메시지 전송 실패시 재시도 간격과 회수를 설정한다.
     *
     * @param array[int] $intervals
     * @return $this
     */
    public function retryIntervals(array $intervals = [])
    {
        if (! empty($intervals)) {
            $this->retryIntervals = $intervals;
        }

        return $this;
    }

    /**
     * 푸쉬 메시지 전송을 라이브러리에 위임하고, 전송 결과를 처리한다.
     *
     * @param int $sleep
     * @return DownstreamResponse
     * @throws Exception
     */
    public function send($sleep = 0)
    {
        sleep($sleep);

        $response = $this->fire();
        $this->log($response);

        if ($response->numberModification() > 0) {
            // 메시지는 성공적으로 전달됐다.
            // 단말기 공장 초기화 등의 이유로 구글 FCM Server에 등록된 registration_id가 바꼈다.
            $tokens = $response->tokensModify();
            $this->updateDevices($tokens);
        }

        if ($response->numberFailure() > 0) {
            if ($tokens = $response->tokensToDelete()) {
                // 해당 registration_id를 가진 단말기가 구글 FCM 서비스에 등록되어 있지 않다.
                $this->deleteDevices($tokens);
            }

            if ($tokens = $response->tokensToRetry()) {
                // 구글 FCM Server가 5xx 응답을 반환했다.
                $this->to($tokens);

                if (isset($this->retryIntervals[$this->retryIndex])) {
                    // 메시지 전송에 실패했다.
                    // static::$retryIntervals에 설정된 간격으로 재시도한다.
                    $this->send(
                        $this->retryIntervals[$this->retryIndex++]
                    );
                }
            }
        }

        return $response;
    }

    /**
     * 푸쉬 메시지를 전송합니다.
     *
     * @return DownstreamResponse
     */
    protected function fire()
    {
        // 라이브러리가 제공한 LaravelFCM\FCMServiceProvider를 열어 보면
        // 라라벨의 서비스 컨테이너에 인스턴스를 등록할 때의 키를 알 수 있다..
        // 'fcm.sender'라는 키를 사용하고 있어서, app() 헬퍼를 이용해서 등록된 인스턴스를 가져왔다.
        // 마치 $container = ['key' => new stdClass]에서 $container['key']를
        // 사용해서 할당된 stdClass 인스턴스를 얻어 오는 것과 같은 개념이다.
        /** @var FCMSender $fcmSender */
        $fcmSender = app('fcm.sender');
        $notification = ($this->title && $this->body)
            ? $this->buildNotification() : null;

        return $fcmSender->sendTo(
            $this->getTo(),
            $this->buildOption(),
            $notification,
            $this->buildPayload()
        );
    }

    /**
     * 중복 수신자를 제거한 수신자 목록을 반환한다.
     *
     * @return array
     */
    protected function getTo()
    {
        return array_unique($this->to);
    }

    /**
     * 푸쉬 메시지 전송 옵션을 설정한다.
     *
     * @return \LaravelFCM\Message\Options
     */
    protected function buildOption()
    {
        if (array_key_exists('optionBuilder', $this->cache)) {
            // 캐시 되어 있으면 캐시를 사용한다.
            return $this->cache['optionBuilder'];
        }

        $optionBuilder = new OptionsBuilder();
        
        // 필요한 옵션을 더 줄 수 있다.
        // $optionBuilder->setCollapseKey('collapse_key');
        // $optionBuilder->setDelayWhileIdle(true);
        // $optionBuilder->setTimeToLive(60*2);
        // $optionBuilder->setDryRun(false);

        return $this->cache['optionBuilder'] = $optionBuilder->build();
    }

    /**
     * (단말기의 Notification Center에 표시될) 알림 제목과 본문을 설정한다..
     *
     * @return \LaravelFCM\Message\PayloadNotification
     */
    protected function buildNotification()
    {
        if (array_key_exists('notificationBuilder', $this->cache)) {
            return $this->cache['notificationBuilder'];
        }

        $notificationBuilder = new PayloadNotificationBuilder();
        $notificationBuilder->setTitle()->setBody()->setSound('default');

        return $this->cache['notificationBuilder'] = $notificationBuilder->build();
    }

    /**
     * 메시지 본문을 설정한다.
     *
     * @return \LaravelFCM\Message\PayloadData
     */
    protected function buildPayload()
    {
        if (array_key_exists('dataBuilder', $this->cache)) {
            return $this->cache['dataBuilder'];
        }

        $dataBuilder = new PayloadDataBuilder();
        $dataBuilder->addData($this->data);

        return $this->cache['dataBuilder'] = $dataBuilder->build();
    }

    /**
     * 변경된 단말기의 토큰을 DB에 기록한다.
     *
     * @param array[$oldKey => $newKey] $tokens
     * @return bool
     */
    protected function updateDevices(array $tokens)
    {
        foreach ($tokens as $old => $new) {
            $device = Device::wherePushServiceId($old)->firstOrFail();
            $device->push_service_id = $new;
            $device->save();
        }

        return true;
    }

    /**
     * 유효하지 않은 단말기 토큰을 DB에서 삭제한다.
     *
     * @param array[$token] $tokens
     * @return bool
     */
    protected function deleteDevices(array $tokens) {
        foreach ($tokens as $token) {
            $device = Device::wherePushServiceId($token)->firstOrFail();
            $device->delete();
        }

        return true;
    }

    /**
     * 로그를 남긴.
     *
     * @param DownstreamResponse $response
     */
    protected function log(DownstreamResponse $response)
    {
        $logMessage = sprintf(
            "FCM broadcast (%dth try) send to %d devices success %d, fail %d, number of modified token %d.",
            $this->retryIndex,
            count($this->getTo()),
            $response->numberSuccess(),
            $response->numberFailure(),
            $response->numberModification()
        );

        $rawRequest = json_encode([
            'to' => $this->getTo(),
            'notification' => [
                'title' => $this->title,
                'body' => $this->body,
            ],
            'data' => $this->data,
        ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);

        $rawResponse = var_export($response, true);

        Log::info($logMessage . PHP_EOL . $rawRequest . PHP_EOL . $rawResponse);
    }
}

우리의 FCMHandler 클래스가 노출하는 API는 딱 네 개다.

  • to(array $to) 메서드는 수신자를 지정한다.
  • data(array $data) 메서드는 전송할 데이터를 설정한다.
  • notification(string $title, string $body) 메서드는 알림 데이터를 설정한다.
  • send() 메시지를 전송한다. 이 메서드는 FCM 서버로부터 Unavailable 응답을 받았다면, 1, 2, 4초 간격으로 send() 메서드 자신을 다시 호출하여 메시지 전송을 재시도하는 등의 일을 한다.

4. FCM 보내기 구현

1.2절에서 썼던 코드는 FCMHandler를 이용하면 다음과 같이 바뀐다.

<?php // routes/api.php

// ...

use Illuminate\Http\Request;
use App\Services\FCMHandler;

Route::get('fcm', function (Request $request, FCMHandler $fcm) {
    // 푸쉬 메시지를 수신할 단말기의 토큰 목록을 추출한다.
    $user = $request->user();
    $to = $user->devices()->pluck('push_service_id')->toArray();

    if (! empty($to)) {
        // 보낼 내용이 마땅치 않아 로그인한 사용자 모델을 푸쉬 메시지 본몬으로 ㅡㅡ;. 
        $message = array_merge(
            $user->toArray(),
            ['foo' => 'bar']
        );

        // FCMHandler 덕분에 코드는 이렇게 한 줄로 간결해졌다.
        $fcm->to($to)->data($message)->send();
    }

    return response()->json([
        'success' => 'HTTP 요청 처리 완료'
    ]);
})->middleware('auth.basic.once');

로컬 서버를 구동한다. 1부에서 구현했던 단말기 등록 API를 먼저 호출해서 단말기를 등록한다. 포스트맨에서 GET /api/fcm 요청해서 푸쉬 메시지를 보내본다.

GET http://localhost:8000/api/fcm
Accept: application/json
Content-Type: application/json
Authorization: Basic dXNlckBleGFtcGxlLmNvbTpzZWNyZXQ=

그림과 같은 응답을 받았고, 로그 파일에는 전송 실패 메시지가 기록되었다.

Postman

# storage/logs/laravel.log
[2017-01-24 13:22:24] local.INFO: FCM broadcast (0th try) send to 1 devices success 0, fail 1, number of modified token 0.
{
    "to": [
        "eIrjxWASTb0:APA91bF8mv9AdXMAxQ0ALcvFJ4zvfzLxDs7LmGXrKB4btklQKuhcD94KTJV7tCghnxSQMAsShTjzjWHfWDC1aXe_JAQO0Ao4nuFEfpQI0QaUyX7Mh0aFm1RLVDhcP7nAArzaxF6jBFJx"
    ],
    "notification": {
        "title": null,
        "body": null
    },
    "data": {
        "foo": "bar"
    }
}
LaravelFCM\Response\DownstreamResponse::__set_state(array(
   'numberTokensSuccess' => 0,
   'numberTokensFailure' => 1,
   'numberTokenModify' => 0,
   'messageId' => NULL,
   'tokensToDelete' => 
  array (
    0 => 'eIrjxWASTb0:APA91bF8mv9AdXMAxQ0ALcvFJ4zvfzLxDs7LmGXrKB4btklQKuhcD94KTJV7tCghnxSQMAsShTjzjWHfWDC1aXe_JAQO0Ao4nuFEfpQI0QaUyX7Mh0aFm1RLVDhcP7nAArzaxF6jBFJx',
  ),
   'tokensToModify' => 
  array (
  ),
   'tokensToRetry' => 
  array (
  ),
   'tokensWithError' => 
  array (
  ),
   'hasMissingToken' => false,
   'tokens' => 
  array (
    0 => 'eIrjxWASTb0:APA91bF8mv9AdXMAxQ0ALcvFJ4zvfzLxDs7LmGXrKB4btklQKuhcD94KTJV7tCghnxSQMAsShTjzjWHfWDC1aXe_JAQO0Ao4nuFEfpQI0QaUyX7Mh0aFm1RLVDhcP7nAArzaxF6jBFJx',
  ),
))

5. 결론

2부에 걸친 포스트를 통해 모바일 단말기에 Firebase Cloud Message를 보내기 위한 PHP 애플리케이션 서버를 구현하는 방법을 다루었다. 3부를 쓸 기회가 있다면 빠진 퍼즐 조각인 모바일 앱 부분을 소개할 예정이다.

고정된 위치에서 전원과 인터넷에 연결되어 있는 기기와 달리, 모바일은 계속 변하는 환경에 노출되어 있다. 따라서 모바일 환경에 적합한 기술을 선택하는 것이 중요한데, FCM이 최고의 선택이라고 장담할 수는 없지만, 가장 대중적인 선택임에는 틀림없다.

• • •

이번 포스트의 예제 프로젝트는 https://github.com/appkr/fcm-scratchpad에 공개되어 있다. 이 포스트에서 사용한 포스트맨 콜렉션은 https://raw.githubusercontent.com/appkr/fcm-scratchpad/master/docs/fcm-scratchpad.postman_collection.json에서 받을 수 있다.

덧.

이 예제 프로젝트와 연동해서 작동하는 Android 예제 클라이언트를 brownsoo 님이 제공해 주셨습니다. 상세한 설명은 https://github.com/brownsoo/fcm-scratchpad-android를 참고해 주세요.

Android Client Example

comments powered by Disqus
keyboard_arrow_up