ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot / React Native] FCM을 활용한 알람 서비스 구축
    프로젝트/당일 2024. 6. 5. 14:38

    FCM을 활용한 알람 서비스 구축 왜 궁금했을까❓

    당일 서비스는 그림일기를 생성하는데 약 2~3분의 시간이 소요된다. 사용자의 요청이 많아지면 응답 시간이 더 늘어날 것이라 판단되어 그림일기 생성이 완료되면 알람을 전송하기로 했다. React Native Expo를 사용하고 있기에 FCM을 용하여 알람 기능을 구현하기로 했다.

     

    1. FCM Push Alarm 통신 과정

    1. 사용자가 당일 서비스를 가입하게 되면 알람을 위해 Expo Backend에 Expo Token 발급을 요청한다.
    2. Expo Backend는 디바이스에 고유한 Expo Token을 발급하여 사용자에게 전송한다.
    3. 사용자는 이를 받아 API Server에 자신의 Expo Token을 보낸다.
    4. API Server는 사용자 DB에 Expo Token을 저장한다.
    5. 사용자가 오늘의 일기를 작성하여 API Server에 전송한다.
    6. API Server는 일기 데이터를 저장하고 GPU Server에 그림일기 생성 요청을 보낸다.
    7. GPU Server는 그림일기 4개가 완성되면 API Server에게 완료됐다는 응답를 보낸다.
    8. API Server는 GPU Server로부터 받은 데이터와 4번에서 저장했던 Expo Token을 이용하여 FCM에게 알람 요청을 보낸다.
    9. FCM은 Expo Token을 이용하여 사용자에게 알람을 전송한다.
    위 과정은 사용자의 그림일기 생성과 알람 기능의 흐름을 도식화한 것이다.

     

    2. FCM 설정

     

    Add Google Service Account Keys using FCM V1

    Learn how to create or use a Google Service Account Key for sending Android Notifications using FCM V1.

    docs.expo.dev

     

    해당 링크를 통해 Firebase Console 페이지에 접속하고 프로젝트 추가를 누른다.

    • 프로젝트 이름을 입력하고 계속을 누른다.

    • 구글 애널리틱스를 통해 통계나 분석 데이터를 확인할 수 있는데 필요하다면 체크하고 계속을 누른다.

    • Google 애널리틱스를 사용할 구글 계정을 선택하고 프로젝트 만들기를 누른다.

    • 콘솔에서 왼쪽 탭을 눌러 프로젝트 설정을 클릭하고 서비스 계정 탭을 누른다.

    • 기본 값이 Node.js로 되어 있을텐데 그대로 두고 새 비공개 키 생성을 누른다.
    • 그리고 키 생성을 눌러 credentials.json으로 이름으로 프로젝트 파일 위치에 저장한다.

    • EXPO dev 페이지에 들어가서 create a project 버튼을 클릭한다.
    • Display Name(APP 이름)과 Slug를 app.json에 있는 그대로 입력하고 create를 눌러준다.
    • 그러면 프로젝트가 하나 생성될텐데 ID 값을 복사한다.
    {
    	"expo": {
        	...
        	"extra": {
                "eas": {
                    "projectId": "프로젝트 ID값 입력"
                }
            },
            ...
        }
    }
    • app.json에 위와 같이 설정을 추가해준다.
    npm i -g eas-cli
    • 터미널을 켜서 프로젝트 파일 위치로 이동하고 eas-cli를 설치해준다.

    • eas credentils를 입력하면 위와 같은 화면이 나타나게 된다.
    • Android -> production -> Google Service Account -> Upload a Google Service Account Key를 선택한다.
    • credentials.json이 있을텐데 해당 파일을 클릭하여 서비스 키를 등록한다.
    • 다시, Manage your Google Service Account Key for Push Notifications (FCM V1)를 선택한다.
    • Select an existing Google Service Account Key for Push Notifications (FCM V1)를 선택하고 서비스 키를 선택하면 된다.

    • 구글 클라우드 플랫폼에 접속해서 Cloud Messaging을 검색한다.
    • 그리고 사용 버튼을 눌러 해당 서비스를 활성화시킨다.

    • 다시 FCM 콘솔로 돌아와 안드로이드 아이콘을 클릭한다.
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#ffffff"
      },
      "package": "com.dangil.today",
    },

    • app.json에 보고 android 패키지 이름을 확인하고 위와 같이 입력하고 앱 등록을 누른다.

    • google-service.json 파일 다운하고 expo 프로젝트 폴더의 app.json과 package.json 파일과 같은 위치에 저장해준다.
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#ffffff"
      },
      "package": "com.dangil.today",
      "googleServicesFile": "./google-services.json",
    },
    • app.json을 위와 같이 수정해주면 FCM 설정을 마치게 된다.

     

    3. React Native

     

    Notifications

    A library that provides an API to fetch push notification tokens and to present, schedule, receive and respond to notifications.

    docs.expo.dev

    3.1. 권한 및 채널 개설

    // App.jsx
    export default function App() {
        useEffect(() => {
            // 푸시 알림 등록 설정
            registerForPushNotificationsAsync();
        }, [])
    
        return (
            ...
        )
    }
    // notification.jsx
    
    // 알림 등록
    export async function registerForPushNotificationsAsync() {
        // 안드로이드 채널 설정
        if (Platform.OS === 'android') {
            await Notifications.setNotificationChannelAsync('default', {
                name: 'Dangil',
                importance: Notifications.AndroidImportance.MAX,
                vibrationPattern: [0, 250, 250, 250],
                lightColor: '#FF231F7C',
            });
        }
    
        // 디바이스인지 확인 => 에뮬레이터 X
        if (Device.isDevice) {
            // 알림 권한 확인
            const { status: existingStatus } = await Notifications.getPermissionsAsync();
            let finalStatus = existingStatus;
    
            // 권한 허용하지 않았다면 권한 허용 요청 보냄
            if (existingStatus !== 'granted') {
                const { status } = await Notifications.requestPermissionsAsync();
                finalStatus = status;
            }
    
            // 권한 거부
            if (finalStatus !== 'granted') {
                alert('알림 권한이 거부되었습니다.');
                return;
            }
        } else {
            Alert.alert('알림', '모바일 기기 접속 권장드립니다.');
        }
    }
    • 우선 알람을 위해서는 하나의 채널이 개설되어야 해서 APP을 실행하면 registerForPushNotificationsAsync()가 실행되도록 했다.
    • setNotificationChannelAsync()를 통해 알람 채널을 하나 개설한다.
    • getPermissionsAsync()를 통해 알람 권한을 체크하며 권한이 없다면 요청을 하게 된다.
    • 이때, 사용자가 거절하면 알림창을 띄워 거절을 알려주게 된다.

    3.2. Device Token 등록 및 저장

    export async function getDeviceToken() {
        let deviceToken = '';
        // projectId 자동 설정
        try {
            const projectId = Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
            if (!projectId) {
                throw new Error('Project ID를 찾지 못했습니다.');
            }
    
            // 토큰 발급 및
            const deviceToken = (
                await Notifications.getExpoPushTokenAsync({
                    projectId: projectId,
                })
            ).data;
    
            // 토큰 서버로 전송
            Notices.postToken({ deviceToken: JSON.stringify(deviceToken) })
                .then((response) => console.log('토큰 전송 완료', deviceToken))
                .catch((error) => console.log(error));
        } catch (error) {
            deviceToken = `${error}`;
        }
    
        return deviceToken;
    }
    • 회원가입이 이뤄지고 사용자가 로그인을 하게 되면 getDeviceToken() 함수가 실행된다.
    • getExpoPushTokenAsync() 함수를 이용하여 고유한 Device Token을 획득하고 postToken API를 이용하여 API 서버로 Device Token을 전송한다.

    3.3. Push 알람 처리

    Foreground

    // 3. 알림 오면 받아서 context에 저장
    Notifications.addNotificationReceivedListener(notification => {
      const response = notification.request.content.data;
      const convertedResponse: NoticeData = {
        noticeId: response.noticeId,
        diaryId: response.diaryId,
        kind: response.NoticeKind,
        content: response.content,
        confirm: response.confirm,
        createdAt: response.createdAt,
        updatedAt: response.updatedAt,
      };
    
      dispatch({
        type: 'CREATE',
        noticeId: convertedResponse.noticeId,
        diaryId: convertedResponse.diaryId,
        kind: convertedResponse.kind,
        content: convertedResponse.content,
        confirm: convertedResponse.confirm,
        createdAt: convertedResponse.createdAt,
        updatedAt: convertedResponse.updatedAt,
      });
    });
    • addNotificationReceivedListener()는 앱이 foreground에 실행될 때 알람이 수신되면 실행되는 리스너이다.

    Background

     

    Deep linking | React Navigation

    This guide will describe how to configure your app to handle deep links on various platforms. To handle incoming links, you need to handle 2 scenarios:

    reactnavigation.org

    {
        "expo": {
            "scheme": "today",
        }
    }

     

    • 백그라운드 상황에서 알람을 수신하고 클릭하게 되면 특정한 화면으로 이동시켜야 하는데 이때 사용되는 것이 Deep Linking 기술이다.
    • 공식 문서에서 나온 것처럼 app.json에 "scheme" 옵션을 추가해준다.
    npx expo install expo-linking
    • 또한, deep linking에 사용될 라이브러리를 설치해준다.
    export default function App() {
      ...
      return (
        <NativeBaseProvider>
          <ThemeProvider theme={theme}>
            <IsLoginProvider>
              <NoticeProvider>
                <NavigationContainer linking={linking}>
                  <RootStack />
                </NavigationContainer>
              </NoticeProvider>
            </IsLoginProvider>
          </ThemeProvider>
        </NativeBaseProvider>
      );
    }
    • NavigationContainer에 linking을 넘겨주고 있는데 이 linking을 한 번 자세히 살펴보자
    // deep link url 생성
    export const prefix = Linking.createURL('/');
    
    export const linking = {
      prefixes: [prefix],
      // 원하는 화면을 매핑하기 위해 설정
      config: {
        initialRouteName: 'MainTab',
        screens: {
          MainTab: {
            initialRouteName: 'DiaryNav',
            screens: {
              DiaryNav: {
                initialRouteName: 'DiaryList',
                screens: {
                  SelectImage: 'SelectImage/:diaryId',
                  DiaryList: 'DiaryList',
                  SelectEmotion: 'SelectEmotion',
                },
              },
            },
          },
          NotificationScreen: 'NotificationScreen',
        },
      },
    
      // 딥링크 url 받는 부분
      async getInitialURL() {
        // 딥링크를 이용해 앱이 오픈되었을 때
        const url = await Linking.getInitialURL();
    
        if (url != null) {
          return url;
        }
        
        return null;
      },
    
      subscribe(listener: any) {
        const onReceiveURL = ({ url }: { url: string }) => listener(url);
    
        const eventListenerSubscription = Linking.addEventListener('url', onReceiveURL);
    
        const subscription = Notifications.addNotificationResponseReceivedListener(async response => {
          const isLogin = await checkLogin();
          const responses = response.notification.request.content.data;
    
          // content.data의 kind가 OFFER이면 => 일기 작성 페이지
          // content.data의 kind가 COMPLETE => 이미지 선택 페이지
          const selectImageURL = `${prefix}SelectImage/${responses.diaryId}`;
          const writeDiaryURL = `${prefix}SelectEmotion`;
    
          if (!isLogin) {
            if (responses.kind === 'COMPLETE') {
              await AsyncStorage.setItem('selectImageURL', selectImageURL);
            } else {
              await AsyncStorage.setItem('writeDiaryURL', writeDiaryURL);
            }
          } else {
            if (responses.kind === 'COMPLETE') {
              listener(selectImageURL);
            } else {
              listener(writeDiaryURL);
            }
          }
        });
    
        return () => {
          // 리스너 삭제
          eventListenerSubscription.remove();
          subscription.remove();
        };
      },
    };
    • Linking의 prefix에는 createURL을 통해 만든 앱의 최상위 주소를 넣는다.
    • 이후 config에는 화면 전환이 필요한 화면 경로를 넣어준다.
    • getInitialURL()은 앱 링크에 의해 실행된 경우 링크 url을 제공하고 그렇지 않으면 null을 반환한다.
    • subscribe()은 deep link에 의해 열린 경우 실행되며 리스너에 핸들러를 지정하여 원하는 로직을 실행한다.
    • addNotificationResponseReceivedListener()를 통해 백그라운드에서 알람을 수신하고 알람의 Response를 통해 Deep Link를 제작하여 화면 전환을 이뤄낸다.

    4. Spring Boot

    4.1. Device Token 저장

    // 일기 안씀 로드 처리
    public void saveToken(SaveTokenRequest saveTokenRequest) {
      Member member = memberRepository.getReferenceById(saveTokenRequest.getMemberId());
      member.updateToken(saveTokenRequest.getDeviceToken());
    }
    • MySQL 회원 테이블에 Device Token을 Update

    4.2. Push 알람 요청

    // 파이썬 서버 이미지 로드 완료 알림 and 알림 DB 저장
    public void completeNotice(Long diaryId, Long memberId, Integer sequence) {
      ...  
      // 알림
      pushMessageService.sendPushMessage(
              PushMessageRequest.builder()
                      .token(member.getDeviceToken())
                      .title("그림 생성 완료")
                      .body(sequence.toString() + "번째 일기에 대한 그림이 생성되었습니다.")
                      .diaryId(diaryId)
                      .build(),
              noticeCompleteResponse
      );
    }
    • FastAPI가 이미지 생성을 완료하면 Service 코드인 completeNotice()를 실행하게 된다.
    public void sendPushMessage(PushMessageRequest pushMessageRequest, NoticeCompleteResponse noticeCompleteResponse) {
      sendMessage(makeJson(pushMessageRequest, noticeCompleteResponse));
    }
    public String makeJson(PushMessageRequest pushMessageRequest, NoticeCompleteResponse noticeCompleteResponse) {
      return "{" +
                  "\"to\": \"" + pushMessageRequest.getToken() + "\"," +
                  "\"title\": \"" + pushMessageRequest.getTitle() + "\"," +
                  "\"body\": \"" + pushMessageRequest.getBody() + "\"," +
                  "\"data\": {" +
                       "\"noticeId\":" + noticeCompleteResponse.getNoticeId() + "," +
                       "\"diaryId\":" + noticeCompleteResponse.getDiaryId() + "," +
                       "\"kind\": \"" + noticeCompleteResponse.getKind() + "\"," +
                       "\"content\": \"" + noticeCompleteResponse.getContent() + "\"," +
                       "\"confirm\": " + noticeCompleteResponse.getConfirm() + "," +
                       "\"createdAt\": \"" + noticeCompleteResponse.getCreatedAt() + "\"," +
                       "\"updatedAt\": \"" + noticeCompleteResponse.getUpdatedAt() + "\"" +
                   "}" +
              "}";
    }

    • FCM을 API로 호출할 경우에는 위와 같이 메시지 형식을 갖춰야 한다. 그래서 makeJson() 함수를 통해 알람 데이터를 가공했다.
    public void sendMessage(String s) {
        RestTemplate restTemplate = new RestTemplate();
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
    
        HttpEntity<String> requestEntity = new HttpEntity<>(s, headers);
    
        ResponseEntity<String> responseEntity = restTemplate.exchange(
                "https://exp.host/--/api/v2/push/send",
                HttpMethod.POST,
                requestEntity,
                String.class);
    
    }
    • RestTemplate를 이용하여 FCM Push 알람 API를 호출하여 사용자 디바이스에 알람을 전송할 수 있다.

     

Designed by Tistory.