오늘 배운 것

어제 찾은 라이브러리를 참고하여 다시 알림 기능을 개발해 보겠다. 

 

어제 언급했던 대로 RN 역시 마찬가지로 안드로이드 API 33버전 이후부터는 사용자에게 명시적으로 알림 기능 허용을 받아내야 앱에서 알림을 보이게 할 수 있었다. 그 방법 중 하나는 RN에서 기본적으로 제공하는 PermissionsAndroid API를 사용하는 것이었고, 다른 방법은 react-native-permissions라는 모듈을 설치하는 것이었다. 사실 모듈을 설치해서 어떻게 구체적으로 작업이 되는 것인지는 잘 모르겠지만, 우선 설치해 주었다. 


그리고 디바이스는 사용자가 어떻게 두느냐에 따라서 세 가지의 상태 중 하나에 속한다고 했다.

1. Foreground: 앱이 실행되고 있고, 사용자가 지금 앱 화면을 보고 있을 때

2. Background: 앱이 실행되고 있지만 사용자가 앱 화면을 보고 있지는 않을 때. 흔히 말하는 백그라운드에서 실행될 때. 

3. Quit: 앱이 백그라운드에서도 실행되고 있지 않을 때

 

나는 1번과 2번 상태일 때만 앱이 동작하고 3번 상태일 때는 아예 어떠한 알림이나 로직이 동작하지 않는 줄 알았는데, 뒤의 내용을 읽어보니 그렇지는 않은 모양이다. 

 

그리고 디바이스의 상태와 알림 메시지의 형태에 따라서 어떤 메시지 핸들러가 호출될지도 결정된다고 한다. 

메시지의 형태란 노티 메시지가 있는지, 아니면 데이터를 포함하고 있는지에 따라서 달라진다. 기본적으로 노티 메시지가 있을 경우 알림의 우선순위가 높아진다고 한다. 

 

앱이 Foreground에 있을 때는 onMessage라는 핸들러를 이용해서 메시지를 처리하고, Background나 Quit 상태일 때는 setBackgroundMessageHandler 핸들러를 이용해서 처리한다. 

 

또한 앱이 Foreground에 있거나 메시지 내용에 노티 메시지가 없고 단순 데이터만 포함될 때는 화면에 알림을 표시하지 않는다고 한다. 

 

앞서 앱이 Foreground 상태일 때는 onMessage 핸들러를 사용하는데, 해당 메시지 핸들러는 React의 Context(정확히 어떤 Context를 의미하는지는 100% 이해하진 못했다. 그냥 React 내부의 정보나 상태라고만 이해했다)에 접근할 수 있기 때문에 UI나 컴포넌트의 상태를 업데이트 시킬 수 있다고 한다. 

 

반면 디바이스가 Background나 Quit 상태일 때는 onMessage 핸들러 대신 setBackgroundMessageHandler를 사용해야 하며, 코드의 시작점인 index.js(ts) 파일에 해당 핸들러 설정을 해 줘야 하는 것으로 보였다. 

 

그리고 메시지에 notification 설정값이 없이 data 설정값만 포함되는 경우, 안드로이드 및 iOS에서는 해당 메시지의 우선순위를 낮게 간주해서 별도로 앱을 깨워서 핸들러를 실행시키지는 않는다고 한다. 그래서 만약 data 설정값만 메시지에 포함되는데 별도의 핸들러를 통해 로직을 실행시키고 싶다면, 서버에서 알림을 보낼 때 해당 메시지의 priority 값을 high로 설정해서 보내야 하겠다. 

 

일단은 알림 로직을 작성하는 데 필요한 정보는 대강 알았으니 이제부턴 코드를 작성하면 되겠다. 그런데 막히는 부분이 하나 있다. 나는 앱이 Foreground에 있을 때에도, Background에 있을 때에도 모두 notification을 받으면 투두 API를 쏴서 새 데이터를 받아와서 다시 투두 데이터를 받아왔으면 좋겠는데, 그러려면 Background에 있을 때에도 컴포넌트 내부에서 데이터 상태를 업데이트 해 줘야 하겠다. 

 

그런데 문제는 Foreground에서는 React Context에 접근해서 앱이나 컴포넌트의 상태를 업데이트할 수 있는데, Background에서는 React Context에 접근할 수 없어서 해당 작업이 불가능하다는 점이었다. 

 

그런데 생각해보니 프론트의 역할은 notification을 받으면 그냥 react query를 통해 API를 호출해주면 되는 것이었다. 그러면 그냥 Background든 Foreground든 react query로 해당 API를 호출해주면 되겠다. 그런데 또 생각해보니 Background에서 굳이 notification을 받아서 최신의 데이터를 계속 유지할 필요가 있을까? 라는 의문도 들었다. 

 

결국 우선은 Foreground에서만 알림을 처리하고, 후에 AsyncStorage, sqlite에서 데이터를 저장해 놓고 쓸 때 그때 Background에서도 알림을 처리하기로 하였다. 

 

그러려면 기존에 사용하던 react query 관련 훅을 재사용하는게 좋아 보여서, 'useTodosQuery'라는 투두 관련 데이터를 불러오는 훅 코드를 보았다. 그런데 해당 코드에는 'refetchInterval' 값이 설정되어 있어서 주기적으로 투두 값을 업데이트 하고 있었다. 우리는 알람을 한번 보내면 그때 한 번 API를 보내서 투두 데이터를 반영하는 작업만 하고 싶었기에 이 값이 필요없었다. 결론은 useTodosQuery와 똑같은 작업을 하되 refetchInterval이 없는 커스텀 훅을 새로 만들어야 한다. 

 

이때 staleTime의 경우 useQuery로 데이터를 불러오고 일정 시간 내에 useQuery를 통해 또 다른 fetch가 일어난다면 cache 데이터를 사용할지를 지정한다. 해당 fetch 작업은 메인 컴포넌트에서 지속적으로 투두를 fetching하는 것과는 무관하므로, 이 작업에 영향을 주어서는 안 되었다. 따라서 staleTime을 0으로 지정했다. 이는 기존에 사용하고 있던 useTodosQuery도 마찬가지였다. 따라서 두 커스텀 훅 모두 staleTime을 0으로 지정해 주었다. 

 

또한 enabled 값을 해당 useTodosQueryByNotification만 false로 지정해 주었는데(기본값은 true다), 그 이유는 enabled 값을 true로 하면 해당 훅이 처음 선언될 때 무조건 한 번 fetch를 하기 때문이었다. 해당 커스텀 훅은 실제 messaging의 onMessage 이벤트 핸들러에 메시지가 들어왔을 때만 실행되어야 하므로, 이 값을 false로 적용했다. 

export const useTodosQueryByNotification = (accessToken, userId, onSuccess) => {
  return useQuery({
    queryKey: [TODO_QUERY_KEY],
    queryFn: () => fetcher(accessToken, userId),
    onSuccess: onSuccess,
    keepPreviousData: true,
    staleTime: 0,
    enabled: false,
  });
};

 

다음과 같이 해당 커스텀 훅을 컴포넌트 안에 선언하고, 필요한 정보들을 파라미터로 리턴하도록 설정해 주었다. data의 경우는 fetch를 했을 때 가져오는 데이터, isSuccess는 fetch가 성공적으로 진행되었는지의 여부, 그리고 refetch의 경우 수동으로 데이터를 fetch하고 싶을 때 실행시켜주는 함수였다. 

 

현재 해당 훅은 enabled 값이 false이기 때문에 자동으로 데이터를 fetch하지 않는다. 이 훅을 통해 데이터를 가져오려면 refetch 인자로 받은 notificationRefetch() 함수를 사용해야 한다. 

const {
    data: notificationData,
    isSuccess: isNotificationSuccess,
    refetch: notificationRefetch,
  } = useTodosQueryByNotification(accessToken, userId);

 

메시지를 수신했는지는 계속 확인되어야 하므로 useEffect 훅 안에 해당 코드를 선언하고, 메시지를 받았을 경우, 즉 onMessage 이벤트 핸들러가 트리거되었을 경우에 해당 notificationRefetch() 함수를 실행시켰다. 그리고 이를 통해 데이터가 성공적으로 fetch되면 isNotificationSuccess의 값이 true로 바뀔 것이기 때문에 이 조건에 해당되는 경우 받아온 notificationData의 값을 zustand를 통해 만든 TodoStore를 통해 todos의 값으로 바꿔주었다. 

useEffect(() => {
    const unsubscribe = messaging().onMessage(async remoteMessage => {
      notificationRefetch();
      if (isNotificationSuccess) {
        useTodoStore.setState({ todos: notificationData });
        let filteredTodos = notificationData.filter(
          todo =>
            todo.categoryId === selectedCategory &&
            isTodoIncludedInTodayView(
              todo.startDate,
              todo.endDate,
              selectedDate.format('YYYY-MM-DD'),
            ),
        );
        useTodoStore.setState({ currentTodos: filteredTodos });
      }
    });
    return unsubscribe;
  }, [
    notificationRefetch,
    notificationData,
    isNotificationSuccess,
    selectedDate,
    selectedCategory,
  ]);

 

이렇게 Inbox, Category 데이터에 대해서도 같은 작업을 반복해 주었다. 


이제는 서버 쪽을 개발할 차례이다. 우선 django에서 firebase를 사용해야 하기에 관련 라이브러리를 설치해 주었다. 

$ pip install firebase_admin

 

Firebase 문서에 해당 라이브러리 관련 내용이 나와있어서 참고하면서 작업했다. 

 

그런데 생각해 보면, 클라이언트에서는 서버에서 알림이 오면 그냥 받으면 된다지만 서버는 어떤 클라이언트에게 알림을 보낼지 명시해줘야 했다. 이때 사용하는 것이 FCM(firebase cloud messaging) 토큰이다. 이는 디바이스 토큰이랑은 다른 개념이라고 한다. 

 

디바이스 토큰과 FCM 토큰 모두 각각의 디바이스를 식별할 수 있다는 공통점이 있다. 하지만 디바이스 토큰은 한번 기기가 바뀌지 않는 한 고유한 반면, FCM 토큰은 상황에 따라(주기적으로 firebase 서버에서 갱신할 수 있다고 한다) 바뀔 수도 있다. 그런데 나는 디바이스 토큰의 값을 다음과 같이 구하고 있었어서, 그러면 내가 디바이스 토큰이라 생각했던 값은 사실 디바이스 토큰이 아니라 FCM 토큰이었던 건가? 라는 의문이 들었다. 

import messaging from '@react-native-firebase/messaging';

const token = await messaging().getToken();

 

알고보니 안드로이드에서는 FCM 토큰과 디바이스 토큰을 같은 것으로 취급한다고 한다. 그래서 내가 얻은 FCM 토큰은 디바이스 토큰으로 동작할 수 있겠다. 

 

이제 코드를 작성해보자. 그런데 또 다른 의문이 들었다. 

 

아래와 같이 코드를 작성하려는데, 해당 Certificate를 작성하려면 firebase 관련 파일이 프로젝트 디렉토리에 있어야 했다. 하지만 해당 파일은 firebase 계정 및 관리 정보가 담긴 파일로, 이를 레포지토리에 노출시킬 수는 없었다. 그렇다고 로컬에서 혼자 관리하면 다른 팀원과 같이 개발하는 데 문제가 생긴다. 

from firebase_admin import credentials, messaging

# Firebase Admin SDK 초기화
cred = credentials.Certificate('path/to/your/firebase-adminsdk.json')

 

소스 코드를 보니 Certificate에 주어지는 값은 파일 경로나 딕셔너리 값만 가능했다. 그렇다면 로컬에 파일을 둘 수 없다면 파일 경로값은 줄 수 없으니, 딕셔너리 값을 넣어줘야겠다. Certificate는 firebase에서 json 파일을 통해 확인된 증명 정보를 넣어주는 객체이다. 

 

아마도 AWS의 Secrets Manager에 해당 값을 딕셔너리로 저장하고, 이를 불러오는 방법을 써야할 것 같다. 

 

다음과 같이 'FIREBASE'라는 변수값에 다운로드 받은 json 파일 값을 넣어주고 이를 파이썬 로컬 서버에서 불러왔다. 

import firebase_admin
from firebase_admin import credentials, messaging
from django.conf import settings

firebase_info = eval(settings.SECRETS.get("FIREBASE"))
cred = credentials.Certificate(firebase_info)
firebase_admin.initialize_app(cred)

 

그리고 다음과 같이 클라이언트의 FCM 토큰, 제목, 본문을 인자로 받아 알림을 보내는 함수도 작성해 주자. 여기서 Message는 FCM 서버를 통해 전송될 수 있는 타입의 객체이고, Notification은 Message 안에 notification 내용(notification 아니면 data 아니면 둘 다가 Message에 포함될 수 있다)을 적을 때 사용하는 객체이다. 여기서는 별도로 데이터는 보내지 않고 notification만 보낼 것이기 때문에 다음과 같이 작성했다. 

def send_push_notification(token, title, body):
    message = messaging.Message(
        notification=messaging.Notification(
            title=title,
            body=body,
        ),
        token=token,
    )

    try:
        messaging.send(message)
    except Exception as e:
        # sentry capture exception
        return {"status": "error"}
    return {"status": "success"}

 

이제 대략적인 코드를 짰으니 코드가 동작하는지를 확인해 보려고 한다. 그런데 테스트를 하면 좋을 것 같았다. 문제는 서버에서 보낸 알림을 클라이언트에서 제대로 받는지를 확인해야 하는데, 이런 양방향 통신이 일어나는 테스트는 해 본 적이 없어서 방법이 생각나질 않았다. 

 

결론은 서버와 클라이언트에서 각각 테스트를 작성하고 확인하면 되었다. 먼저 django에서는 FCM 알람을 보내는 테스트 코드를 작성한다. 

from unittest.mock import patch
from django.test import TestCase
from todos.firebase_messaging import send_push_notification

class FCMNotificationTest(TestCase):

    @patch("todos.firebase_messaging.send_push_notification")
    def test_send_push_notification(self, mock_send_fcm):
        mock_send_fcm.return_value = {"status": "success"}
        response = send_push_notification("device_token", "title", "message")
        mock_send_fcm.assert_called_once()
        self.assertEqual(response["status"], "success")

 

여기서 뭔가 raw json이나 딕셔너리 객체 값을 그냥 사용하는 것 같아서 이를 개선하고 싶었다... 이전에 회사에서 다른 분들의 코드에서 본 @dataclass decorator가 생각나서 적용해 보았다. 해당 코드를 추가하고 위의 코드를 아래에 정의된 변수값으로 바꿔주었다. 

from dataclasses import dataclass

@dataclass
class PushNotificationStatus:
    status: str

PUSH_NOTIFICATION_SUCCESS = PushNotificationStatus("success")
PUSH_NOTIFICATION_ERROR = PushNotificationStatus("error")

 

그런데 문득 내가 dataclass가 뭔지 잘은 모르고 쓰고 있었구나 싶었다. 공식문서를 솔직히 다 읽지는 못했고... 필요한 부분만 읽은 다음에 GPT에게도 요약을 부탁했다. 

 

dataclass는 일종의 decorator로, 클래스를 선언할 때 같이 정의해야 하는 여러 메소드들(__init__, __repr__, __eq__)을 해당 데코레이터를 붙이면 암묵적으로 정의해 주는 기능을 가졌다고 이해했다. 또한 필드를 'fieldname:type'의 형태로 정의하면 이를 통해서 메소드를 생성해 주는 기능도 있다. 또한 상속도 되기 때문에 상속받은 부모 클래스의 필드를 가져와서 메소드를 생성할 수도 있다. 그리고 이를 사용하면 반복적으로 작성해야 하는 메소드가 줄기 때문에 유지보수랑 효율성 면에서 좋다고 한다.

 

아무튼, 이렇게 작성하고 위의 테스트 코드를 살펴보자. @patch라는 데코레이터를 이용해서 'todos.firebase_messaging.send_push_notification'이라는 실제 객체를 모방하는 mock 객체인 'mock_send_fcm'을 생성한 다음, 실제 로직을 수행하여 둘의 결과값을 비교하는 코드이다. 해당 명령어로 테스트를 실행시켜 보았다. 

python -m unittest todos.tests.test_firebase_alarm

 

의도와 달리 'send_push_notification'이 한 번도 호출되지 않아서 오류가 났다. 알고보니 코드에 mock 객체를 호출하는 코드가 없었다. 호출 로직('mock_send_fcm()')을 추가해주고 다시 진행하였다. 

 

이번에는 테스트 로직을 호출한 값이 성공에 해당하는 PUSH_NOTIFICATION_SUCCESS 값과 달라서 오류가 났다. 에러 로그를 찍어보니 실험 용으로 'device_token'이라는 문자열을 FCM 토큰 대용으로 보내고 있었는데 이게 유효하지 않은 FCM 토큰이라서 에러가 난 거였다. 

 

그런데 내가 valid한 FCM 토큰을 어떻게 구할까? 라는 의문이 들었다. 방법은 두 가지가 있었다. 

1. 내 안드로이드 에뮬레이터의 FCM 토큰으로 테스트하기

2. 클라이언트와 서버를 잇는 엔드 투 엔드 E2E 테스트 하기

 

사실 장기적으로 볼 때 확장 가능성 있는 것은 2번이다. 그래서 우선은 1번으로 임의 테스트를 하고, 2번의 방법도 도입하기로 했다. 찾아보니 React Native의 'Jest'와 'Detox', Django의 'test suite'를 같이 연동하면 엔드투엔드 테스트가 가능하다고 한다. 설정이 까다롭다고는 하는데 편한 개발을 위해서는 정말 해 두면 좋은 작업이라고는 생각한다. 이 부분도 이슈에 넣어놓았다. 


그리고 토스 슬래시를 신청했다! 혹시나 싶어 시간표 공유 이벤트에도 참여해 보았는데 당첨이 꼭 되었으면 좋겠다..! 

시간표도 작성해 보았다.

 

 궁금한 점

1. onMessage 메시지 핸들러가 접근할 수 있는 React의 Context는 어떤 의미일까?

2. FCM 토큰은 앱을 지웠다가 다시 까는 경우 등에 값이 바뀔 수도 있다고 한다. 이러한 경우는 어떻게 대처해야 할까?

3. 'python manage.py shell' 명령어는 어떻게 동작할까? 별도의 shell 환경에서 터미널이 열리는게 신기한데 이것도 생각해보면 장고 명령어다. 그 원리가 궁금하다. 

4. FCM 서버의 동작 원리가 궁금하다. 

5. 사실 decorator에 대해서도 문법만 알지 동작 원리는 잘 모른다. 이것도 알아보자. 

6. React Native의 'Detox'와 Django의 'test suite'를 연결하면 엔드투엔드 테스트가 가능하다고 한다. 솔직히 쫌 많이 탐난다... 그동안 서버에다가 클라이언트에서 직접 요청을 날려가면서 테스트한 적이 참 많았기 때문이다. 이 녀석을 내일 간단히 찍먹해 보고, 할 만하다고 생각되면 조금이라도 도입해 보고 싶다. 

 

+ Recent posts