[이타인클럽앱] Cloud Functions을 이용한 Push Notification 구현 2/3

in #busy5 years ago (edited)

몇 시간에 걸쳐 코딩하고 쓴 글이 그냥 삭제됐네요 ㅜ.ㅜ. busy의 자동 저장 기능도 왜 이 글만 안되는지...

너무 믿고서 그냥 Submit 버튼을 눌렀나 봅니다.
화 내지 않을께요. 그냥 더욱 정갈한 글로 써보겠습니다!


한 사용자가 다수의 사용자에게 Push notification을 보내는 방법입니다. 이전에는 Firebase의 Cloud Messaging 콘솔에서 메시지 보내고 사용자 기기에서 수신하는 방법을 알아봤었습니다. 이번에는 콘솔을 이용하지 않고, 사용자 앱에서 보내는 것을 구현합니다.

총 3편으로 구성될 예정입니다.

1편: 개발환경 구축
2편: Push 토큰 저장 및 메시지 송신 코딩
3편: 메시지 수신 코딩 및 테스트


참고 사이트

react-native-firebase 사이트에서 다음 내용을 참고하라고 하지만 내용이 오래되어 제대로 동작하지 않습니다. 전체적 흐름을 파악하는데 참고합니다.
https://medium.com/yale-sandbox/react-native-push-notifications-with-firebase-cloud-functions-74b832d45386

다음 내용도 참고합니다.
React Native Firebase 푸시 알림(push notification), background listener - 2.firebase 리스너 구현

사용자 Sign up과 Push 토큰 저장

push 메시지를 송수신 하기 위해서는 사용자 기기의 push 토큰이 필요합니다. 편의상 사용자가 신규로 가입할 때, 사용자의 push 토큰을 DB에 저장하는 방식으로 구현하겠습니다.

하지만, push 토큰은 다음과 같은 경우 변경될 수 있으므로, 더욱 세밀하게 고려하여 코딩해야 합니다.

  • Firebase의 앱 인스턴스 ID가 삭제될 때
  • 앱이 새로운 기기에 설치될 때
  • 사용자가 앱을 지우고 다시 설치할 때
  • 사용자가 앱 데이터를 지울 때

위와 같은 경우에는 DB를 업데이트 해 줘야 합니다. 여기서는 생략합니다.

사용자가 새로 가입할 때, 토큰 정보를 얻어와서 firestore에 저장하는 코드를 아래와 같이 구현합니다.

// execute authentication using firebase and update the state
export const loginUser = ({ email, password }) => {
  return (dispatch) => {
    // dispatch action
    dispatch({ type: LOGIN_USER });

    firebase.auth().signInWithEmailAndPassword(email, password)
    .then(user => loginUserSuccess(dispatch, user))
    .catch(error => {
      console.log(error);
      console.log('creating a new user account...');
      firebase.auth().createUserWithEmailAndPassword(email, password)
      .then(user => {
        // login
        loginUserSuccess(dispatch, user);
        //// reigster the user in the database
        // request permission from the user
        firebase.messaging().requestPermission();
        console.log('got the permission');
        // get the device's push token
        firebase.messaging().getToken()
          .then(token => {
            const { currentUser } = firebase.auth();
            console.log('currentUserId', currentUser.uid);
            // store token in the user's document
            firebase.firestore().collection('users').doc(currentUser.uid)
              .set({ pushToken: token, numHelps: 0, numAsks: 0, region: 'suwon' });
        });
      })
      .catch(() => {
        console.log(error);
        loginUserFail(dispatch);
      });
    });
  };
};

위 코드에서 중요한 흐름은 다음과 같습니다.

  1. 사용자가 새로 등록되면, 메시징관련 요청
  2. 요청이 허가되면, 해당 기기의 push 토큰을 획득
  3. 토큰 정보를 기타 부가 정보와 함께 firestore에 저장 (/users/userid)

제대로 저장되면 아래와 같이 Firebase 콘솔의 Database에 DB가 만들어 집니다.
image.png

앱에서 메시지 보내기

이어서 아래와 같이 앱 화면에서 메시지를 보내는 부분을 구현합니다.


image.png

프로젝트 전반적으로 컴포넌트의 state는 redux를 이용하고 있습니다. 이 부분은 별도로 설명하지 않겠습니다. 위 화면에서 "도움 요청하기" 버튼을 누르면 처리되는 부분입니다.

// callback for ask button press
  onButtonPress = () => {
    const { message, msgCount } = this.props;
    console.log('message', message);
    console.log('msgCount', msgCount);
    this.props.sendMessage({ message, msgCount });
  }

단순히 sendMessage라는 함수에 사용자가 입력한 message와 사용자가 요청한 message count msgCount를 전달합니다.

다음으로 sendMessage action creator를 구현합니다.

// action creator to send message to all users
export const sendMessage = ({ message, msgCount }) => {
  return (dispatch) => {
    console.log('message', message);
    console.log('msgCount', msgCount);
    // get the current user
    const { currentUser } = firebase.auth();

    // get message count of the current user from db
    let count = 0;
    const messages = firebase.firestore().collection('asks')
      .doc(currentUser.uid).collection('messages');
    messages.get()
      .then(snapshot => {
        console.log('snapsot', snapshot);
        snapshot.forEach((doc) => {
          count++;
          console.log(doc.id, count);
        });
        console.log('number of messages', count);
        // dispatch action
        dispatch({ type: SEND_MESSAGE, payload: { message, count } });
        // store the message in the firestore
        firebase.firestore()
          .collection('asks')
            .doc(currentUser.uid)
              .collection('messages')
                .doc(`${count}`)
                  .set({ message });
      })
      .catch(err => {
        console.lor('Error getting the messages', err);
      });
  };
};

위 코드를 보면, message DB를 다음과 같은 구조로 만듭니다.

asks -> user id -> messages -> 0 -> "메시지1"
                               1 -> "메시지2"

사용자 별로 어떤 메시지를 몇 개 보내는지 관리합니다. 이 정보를 이용하면 나중에 사용자 프로필 페이지를 업데이트할 수 있습니다.

추가적으로 사용자가 몇 개의 메시지를 보냈는지 DB에서 가져옵니다. 사용자가 앱을 재시작하면 msgCount는 초기화 되므로, DB에서 값을 읽어와서 업데이트 합니다. 참고로 collection의 documents개수를 얻는 별도의 기능는 없는거 같습니다. 그래서 좀 구리지만, 모든 documents를 읽어서 개수를 셉니다. 더 좋은 방법이 있을 겁니다. document id가 메시지 개수를 의미하므로 분명 더 간단한 방법이 있을 것입니다.

주의: redux action creator로 구현되어 있습니다. 우리는 firebase의 결과 이후로 순차적으로 일처리를 하고 싶은데, action creator는 firebase가 일처리를 끝내는 것을 기다리지 않고, 그냥 다음을 실행합니다. 따라서 위 코드처럼, firebase의 함수 처리가 완료되면, then 구문 밑에 이후 실행되어야 하는 코드를 넣었습니다. 그렇지 않으면, 메시지 개수 얻는 것등이 제대로 동작하지 않습니다.

그럼 이제 앱에서 메시지를 보내고 Firebase콘솔의 Firestore에 접속하면 아래와 같이 메시지가 기록되어 있는 것을 확인합니다.
image.png

Cloud Functions

Firestore에 사용자가 보낸 메시지가 잘 기록되는 것이 확인되었습니다. 이제, 이것을 트리거 신호로 앱 사용자 전체에게 메시지를 전송하는 부분을 구현합니다. 고맙게도 Firebase의 Cloud Functions을 이용하면 매우 편합니다.

프로젝트 폴더에 functions으로 이동하여, index.js에 아래와 같이 입력합니다.

const functions = require('firebase-functions');
const admin = require('firebase-admin');

// initialize app
admin.initializeApp();

// send push notification
exports.sendMessage = functions.firestore.document('/asks/{userId}/messages/{msgId}').onWrite(async (change, context) => {
  // change includes changed data in firestore
  console.log('change: ', change );
  // context includes params
  console.log('context: ', context );
  // get user id from the context params
  const sender = context.params.userId;
  // get the changed data
  const data = change.after.data();
  console.log('message', data);
  // get users collection
  const users = admin.firestore().collection('users');
  // build push notification
  const payload = {
    notification: {
    title: '이타인클럽 도움요청',
    body: data.message
    }
  };

  await users.get()
  .then(snapshot => {
    snapshot.forEach(doc => {
      console.log('doc.id', doc.id);
      console.log('sender', sender);
      console.log('doc', doc);
      // do not send notification to the sender
      if (doc.id !== sender) {
        // get the push token of a user
        pushToken = doc.data().pushToken;
        console.log('token, sending message', pushToken, payload);
        // send notification trhough firebase cloud messaging (fcm)
        admin.messaging().sendToDevice(pushToken, payload);
      } else {
        console.log( 'the sender is the same', doc.id, sender);
      }
    });
    return 'sent message to all users';
  })
  .catch(err => {
    console.log('Error getting documents', err);
  });
});

위 코드는 index.js 파일 전문입니다. 주의깊게 볼 부분을 몇 개로 나눠서 살펴봅니다.

exports.sendMessage = functions.firestore.document('/asks/{userId}/messages/{msgId}').onWrite(async (change, context)

다음으로 메시지 관련 데이터를 얻는 코드를 살펴보겠습니다.

  // get user id from the context params
  const sender = context.params.userId;
  // get the changed data
  const data = change.after.data();
  // build push notification
  const payload = {
    notification: {
    title: '이타인클럽 도움요청',
    body: data.message
    }
  };

위 함수의 인자로, changecontext가 전달됩니다.
change인자에는 메시지 내용이 들어 있고, 보다 자세히는 데이터가 변화된 내용이 포함되어 있습니다. context에는 와일드 카드와 관련된 내용이 들어 있습니다. 따라서 어떤 사용자가 메시지를 보냈는지 알 수 있습니다.

마지막으로 사용자 DB를 읽어서 push 토큰을 얻어오는 코드입니다.

  // get users collection
  const users = admin.firestore().collection('users');
await users.get()
  .then(snapshot => {
    snapshot.forEach(doc => {
      // do not send notification to the sender
      if (doc.id !== sender) {
        // get the push token of a user
        pushToken = doc.data().pushToken;
        // send notification trhough firebase cloud messaging (fcm)
        admin.messaging().sendToDevice(pushToken, payload);
(생략)

위 코드를 보면, 메시지 형태를 만들고, 사용자 기기의 push 토큰과 함께 Firebase Cloud Messaging (FCM)에 전달하기만 됩니다.

코드를 작성한 후에 프로젝트 폴더에서 터미널에서 아래와 같이 입력합니다.

$ firebase deploy

Cloud Functions은 클라우드에서 동작하는 것입니다. 즉 클라우드로 올리는 배포 작업이 필요합니다. 그리고 클라우드에서 백엔드로 동작하기 때문에 브라우저에서 console로 로그를 확인할 수 없습니다 그래도, 골치아픈 백엔드 서버 설정을 안해도 되니 얼마나 좋은지 모르겠습니다.

보다 자세한 설명은 공식문서를 참고하세요.
https://firebase.google.com/docs/functions/get-started

deploy할 때 한가지 에러 상황을 보여드리겠습니다.
index.js파일에서 아래와 같이 firestore 경로를 collection까지만 입력하면 deploy할 때 아래와 같은 에러가 발생합니다. 아래 코드의 의도는 /ask 콜렉션 전체에 뭔가 쓰여지면 트리거가 발생하도록 하고 싶은 것이죠.

exports.sendMessage = functions.firestore.document('/asks').onWrite(async (change, context) => {
(생략)

image.png

에러 메시지로는 전혀 무슨 문제 인지 알 수가 없습니다!!! 그러니까 firestore 경로에 주의하세요. 경로를 '/asks/{userId}/messages'이렇게 해도 동일하게 에러가 발생합니다. 반드시 document까지의 경로를 설정해야 합니다.

firebase 콘솔의 Functions에 접속해보면 추가적인 에러 메시지가 나타나는 것 같습니다.
image.png

사용자 앱에서 메시지를 전송하고, Firebase콘솔의 Functions에 접속해서 로그를 확인해 봅니다.
image.png
문제가 없다면 메시지 전송이 잘 되었다는 로그를 확인할 수 있습니다.


그러면 3편에서 전송한 메시지를 앱에서 다음과 같은 각 경우에 수신할 수 있도록 하는 부분을 구현해보겠습니다.

  • 앱이 Foreground 상태일 때
  • 앱이 Background 상태일 때
  • 앱이 종료된 상태일 때

Coin Marketplace

STEEM 0.30
TRX 0.12
JST 0.033
BTC 63955.40
ETH 3139.68
USDT 1.00
SBD 3.87