-
[Spring Boot / React Native] FCM을 활용한 알람 서비스 구축프로젝트/당일 2024. 6. 5. 14:38
FCM을 활용한 알람 서비스 구축 왜 궁금했을까❓
당일 서비스는 그림일기를 생성하는데 약 2~3분의 시간이 소요된다. 사용자의 요청이 많아지면 응답 시간이 더 늘어날 것이라 판단되어 그림일기 생성이 완료되면 알람을 전송하기로 했다. React Native Expo를 사용하고 있기에 FCM을 용하여 알람 기능을 구현하기로 했다.
1. FCM Push Alarm 통신 과정
- 사용자가 당일 서비스를 가입하게 되면 알람을 위해 Expo Backend에 Expo Token 발급을 요청한다.
- Expo Backend는 디바이스에 고유한 Expo Token을 발급하여 사용자에게 전송한다.
- 사용자는 이를 받아 API Server에 자신의 Expo Token을 보낸다.
- API Server는 사용자 DB에 Expo Token을 저장한다.
- 사용자가 오늘의 일기를 작성하여 API Server에 전송한다.
- API Server는 일기 데이터를 저장하고 GPU Server에 그림일기 생성 요청을 보낸다.
- GPU Server는 그림일기 4개가 완성되면 API Server에게 완료됐다는 응답를 보낸다.
- API Server는 GPU Server로부터 받은 데이터와 4번에서 저장했던 Expo Token을 이용하여 FCM에게 알람 요청을 보낸다.
- FCM은 Expo Token을 이용하여 사용자에게 알람을 전송한다.
위 과정은 사용자의 그림일기 생성과 알람 기능의 흐름을 도식화한 것이다.
2. FCM 설정
해당 링크를 통해 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
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
{ "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를 호출하여 사용자 디바이스에 알람을 전송할 수 있다.
'프로젝트 > 당일' 카테고리의 다른 글
[Infra] Apache Kafka (0) 2024.06.06 [Network] Socket, WebSocket, SockJS, STOMP (0) 2024.06.05 [Infra] Kafka를 통한 Spring Boot - FastAPI 통신 (0) 2024.06.03 [Infra] STOMP를 통한 Spring Boot - FastAPI 통신 (0) 2024.06.02 [FastAPI] AWS S3를 활용한 이미지 저장 (0) 2024.06.01