프로젝트/당일
[Spring Boot / React Native] FCM을 활용한 알람 서비스 구축
cks._.hong
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 설정
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를 호출하여 사용자 디바이스에 알람을 전송할 수 있다.