✅ 들어가며
안녕하세요. 저는 지금 29th SOPT 의 장기해커톤인 앱잼에 참여중입니다.
현재 참여하는 프로젝트에서 FCM (Firebase Cloud Messaging) 을 사용하는데 이 어플의 핵심 기능 중 하나가 사용자가 지정한 시간에 알림을 보내는 것입니다.
사실 그냥 FCM 을 어떤 이벤트의 응답으로 보내는 것, 또는 홍보용으로 보내는 것 이라면 크게 어렵지 않습니다.
하지만 여러 사용자가 지정한 시간에 각각 맞춰서 알람을 전송해야하므로 스케쥴러를 사용해야합니다.
먼저 생각했던 방법은 서버 내에서 node-scheduler 를 사용해서 스케쥴링 하려 했지만, 이 경우 특정 사용자의 알림을 수정하고, 삭제하는데 어려움이 있다는 문제점이 있었고, db와 연결된 스케쥴러가 아니라 만약 서버가 꺼진다면 지난 알림이 다 사라진다는 큰 문제점이 있었습니다.
이에 대안으로 MongoDB와 직접 연동되는 스케쥴러인 agenda.js 를 사용하는 것으로 결정했습니다.
하지만 이미 앱 서버는 postgreSQL을 사용하고 있었고, Push-Server를 따로 빼야하는 상황에서 agenda 까지 적용하기에는 데모데이까지 개발 시간이 부족했습니다.
효율적인 방식을 생각해보다 MongoDB 에서 DB 내 변경을 실시간으로 전달해주는 Change Streams 를 사용해보기로 했습니다.
따라서 릴리즈 때 agenda를 적용하기로 결정했고, 앱잼 내에서는 MongoDB Change Streams 를 사용해 구현했습니다.
서버 팀원 중 MongoDB 를 사용할 수 있는 사람이 저였고, 원래 noSQL 을 주로 사용하기도 했어서 제가 푸시 알림을 맡아서 구현했습니다. 여기서는 제가 개발한 방식인 FCM 과 Change Streams 를 정리해보려고 합니다.
✅ Github 링크
https://github.com/TeamHavit/Havit-Push-Server
✅ Architecture
Push-Server 는 Node.js, Express, MongoDB, TypeScript 로 구성하였고, 배포 환경은 AWS EC2 (Ubuntu 18.04 LTS), PM2를 사용했습니다.
✅ Dependencies
"devDependencies": {
"@types/express": "^4.17.11",
"@types/mongoose": "^5.10.5",
"@types/node": "^15.6.0",
"nodemon": "^2.0.7",
"ts-node": "^9.1.1",
"typescript": "^4.2.4"
},
"dependencies": {
"dotenv": "^9.0.2",
"express": "^4.17.1",
"firebase-admin": "^10.0.1",
"lodash": "^4.17.21",
"mongoose": "^5.12.10"
}
✅ FCM
먼저 FCM 코드를 짜야하니 FCM Flow를 이해해야 합니다.
FCM 은 Firebase에서 제공하는 Cloud Messaging 서비스로 손쉽게 서버 - 클라이언트 앱 간 푸시 알림 전송을 가능하게 합니다.
제가 개발한 FCM Flow 는 다음과 같습니다.
1. 클라이언트 앱에서 고유한 디바이스 fcm token 발급
2. 클라이언트 앱에서 서버로 fcm token 전송 하여 유저 DB에 등록
3. 클라이언트 앱에서 알림 생성 시 필요한 정보를 서버에 전송 - Reminder DB 저장
4. Reminder DB 저장 시 Schedule DB 내에 sendAt (전송시간)을 기준으로 index 생성
5. Schedule DB 에서 delete 가 발생할 때 마다 변경을 감지하여 FCM 전송
FCM 을 서버에서 전송하는 방식은 간단합니다.
android, iOS 각각의 앱이 FCM 에 앱 등록을 하고, 서버는 service account 를 발급 받아 가지고 있으면 됩니다.
서버에서 FCM 을 send 하기 위해서는 전송 유형이 2가지 입니다. 아래에 정리하겠습니다.
📌 FCM Data vs Notification
Notification
- 백그라운드 상태에서 FCM이 클라이언트 대신 사용자에게 바로 알림 전송
- 포그라운드 상태에서 플랫폼 별 콜백 처리 필요
- 항상 축소형 알림으로 전송
- 구현 간단하여 보통 일반적인 공지에 사용
- 클라이언트에서 따로 처리할 수 없어 조건적인 처리가 불가능
Data
- 클라이언트 앱이 메시지 처리 담당
- data payload를 통해 커스터마이징
- 축소형, 비축소형 모두 가능
- payload 확인 후 클라이언트에서 조건적인 처리 가능
- 백그라운드, 포그라운드 상태 모두 클라이언트 측에서 처리 필요
저희 앱에서는 커스텀 알림이 필요해서 Data 형식으로 메시지를 전송했습니다.
이 경우, 클라이언트 단에서 메시지를 전송 받고 커스텀하는 과정이 필요합니다.
✅ MongoDB Change Streams
MongoDB Change Streams에 대해 간단히 정리해보자면, DB의 변경을 실시간으로 감지해 애플리케이션으로 전달해줍니다.
변경된 데이터를 실시간으로 접근할 수 있어 변경에 따른 다양한 이벤트를 구현할 수 있습니다.
또한 Change Streams 는 내부적으로 OP Log를 기반으로 작동하여 서버가 꺼지고 나서 다시 켜졌을 때 특정 부분부터 Change Streams 를 받아 볼 수도 있습니다. (저는 구현하지는 않았습니다)
✅ DB
User.ts
import mongoose from "mongoose";
import { IUser } from "../interfaces/IUser";
const UserSchema = new mongoose.Schema({
fcmToken: {
type: String,
required: true,
unique: true,
}
});
export default mongoose.model<IUser & mongoose.Document>("User", UserSchema);
Reminder.ts
import mongoose from "mongoose";
import { IReminder } from "../interfaces/IReminder";
const ReminderSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
contentId: {
type: Number,
required: true
},
ogTitle: {
type: String,
required: true
},
ogImage: {
type: String,
required: true
},
url: {
type: String,
required: true
},
time: {
type: Date,
required: true
},
});
export default mongoose.model<IReminder & mongoose.Document>("Reminder", ReminderSchema);
Schedule.ts
import mongoose from "mongoose";
import { ISchedule } from "../interfaces/ISchedule";
const ScheduleSchema = new mongoose.Schema({
sendAt: {
type: Date,
required: true
},
isDeleted: {
type: Boolean,
required: true
}
});
ScheduleSchema.index({ sendAt: 1 }, { expireAfterSeconds: 0 });
export default mongoose.model<ISchedule & mongoose.Document>("Schedule", ScheduleSchema);
schedule schema 에서 index에 TTL 을 적용해 sendAt 기준 시간에 데이터를 삭제시키게끔 만들어줍니다.
삭제시키는 이유는 schedule db에서 삭제됨을 감지하고 reminder db 내용을 전송할것이기 때문입니다.
db.ts
본격적으로 mongoose를 사용하여 연결하고, change streams를 받아보는 코드입니다.
import mongoose from "mongoose";
import config from "../config";
import Schedule from "../models/Schedule";
import Reminder from "../models/Reminder";
import * as admin from "firebase-admin";
import { ChangeStream } from "mongodb";
import { ISchedule } from "../interfaces/ISchedule";
const _ = require('lodash');
const serviceAccount = require('../config/fcm-admin-credentials.json');
const titles = require('../modules/titleArray');
const connectDB = async () => {
let firebase;
try {
if(admin.apps.length === 0) {
firebase = admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
} else {
firebase = admin.app();
}
await mongoose.connect(config.mongoURI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
console.log("Mongoose Connected ...");
// 삭제 매칭 코드 뺌
const changeStream = Schedule.watch([{ $match: { operationType: 'delete' } }]);
changeStream.on('change', async (data) => {
const id = data['documentKey']._id;
const reminder = await Reminder.findOne({ _id: id }).populate({ path: 'userId', select: { fcmToken: 1, isDeleted: 1 } });
if (reminder.userId['isDeleted']) return; // 삭제된 스케줄
const randomTitle = _.shuffle(titles)[0];
let message = {
data: {
title: randomTitle as string,
body: reminder.ogTitle as string,
image: reminder.ogImage as string,
url: reminder.url as string
},
token: reminder.userId['fcmToken'],
};
admin
.messaging()
.send(message)
.then(function (response) {
console.log('Successfully sent message: : ', response)
})
.catch(function (err) {
console.log('Error Sending message!!! : ', err)
})
});
} catch (err) {
console.error(err.message);
process.exit(1);
}
};
export default connectDB;
const changeStream = Schedule.watch([{ $match: { operationType: 'delete' } }]);
Schedule DB 에서 delete operation이 일어난 데이터들을 감지하는 change stream을 만들어줍니다.
changeStream.on('change', async (data) => {
const id = data['documentKey']._id;
const reminder = await Reminder.findOne({ _id: id }).populate({ path: 'userId', select: { fcmToken: 1, isDeleted: 1 } });
changeStream에서 변경(스케줄 삭제)이 일어날 때마다 해당 data (삭제된 스케쥴)의 id(reminder._id)를 가지고 Reminder DB에서 전송할 내용을 꺼내옵니다.
또한 이미 Reminder 내부에 userId는 User DB와 ref 관계이기 때문에 해당 user의 fcm token을 select 해오면 됩니다.
✅ Firebase 연동 및 FCM 전송 코드
firebase-admin을 사용하기 위해서는 FCM 서비스 계정을 연동시키는 코드가 필요합니다.
mongoDB를 연동하는 부분에서 같이 firebase 도 연동해주었습니다.
const serviceAccount = require('../config/fcm-admin-credentials.json');
if(admin.apps.length === 0) {
firebase = admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
} else {
firebase = admin.app();
}
FCM 전송 코드는 굉장히 간단합니다.
data 객체 내부는 커스텀 key-value를 가질 수 있기 때문에 클라이언트에 필요한 정보들은 묶어서 전송하면 됩니다.
firebase-admin 에 messaging().send() 를 통해 firebase 서버로 메시지를 전송할 수 있습니다.
이렇게 전송한 메시지는 클라이언트가 미리 설정해둔 callback 함수로 이동하여 처리할 수 있게됩니다.
let message = {
data: {
title: randomTitle as string,
body: reminder.ogTitle as string,
image: reminder.ogImage as string,
url: reminder.url as string
},
token: reminder.userId['fcmToken'],
};
admin
.messaging()
.send(message)
.then(function (response) {
console.log('Successfully sent message: : ', response)
})
.catch(function (err) {
console.log('Error Sending message!!! : ', err)
})
✅ reminder controller
/**
* @route POST /reminder
* @desc add reminder
* @access Public
*/
const createReminder = async (req: Request, res: Response) => {
const { time, userId, contentId, ogTitle, ogImage, url } = req.body;
try {
const reminder = await reminderService.createReminder({ time, userId, contentId, ogTitle, ogImage, url });
await scheduleService.createSchedule({ _id: reminder._id, sendAt: reminder.time });
res.status(sc.CREATED).send(util.success(sc.CREATED, responseMessage.CREATED_REMINDER));
} catch (error) {
console.log(error);
res.status(sc.INTERNAL_SERVER_ERROR).send(util.fail(sc.INTERNAL_SERVER_ERROR, responseMessage.INTERNAL_SERVER_ERROR));
}
};
알림 생성 시 우리는 받아온 데이터를 Reminder로 저장하게 되고, 마찬가지로 Change Streams를 사용하기 위해 Schedule에도 저장해줘야합니다.
await scheduleService.createSchedule({ _id: reminder._id, sendAt: reminder.time });
중요한 점은 Schedule 생성 시 _id를 자체적으로 reminder._id로, sendAt을 reminder.time으로 저장해줘야합니다.
그 이유는 위에 말했다 싶이 sendAt을 기준으로 삭제될 것이고, reminder에서 데이터를 꺼내서 보내야하기 때문에!
✅ 구현 결과
실제 모든 코드를 다 올리진 않았고 (github repo에 가시면 있다) 알림을 생성하는 flow를 정리해보았습니다.
클라이언트에서 알림을 생성한 결과를 보면
이런식으로 시간에 맞춰 알림이 오게 됩니다!
✅ 마무리
먼저 앱 서버 내에서 해결하지 못하고, Push-Server를 따로 빼서 클라이언트 분들에게 고통을 남긴 점 사과드립니다. 😞
알림 기능이 앱잼 내에서 굉장히 걱정되는 부분 중 하나였는데 어려움이 발생해도 다른 방식을 찾아서 만들어낸 점이 뿌듯합니다.
아직 부족한 부분이 많고 릴리즈 때에는 완전히 갈아엎어야 할 수도 있지만 FCM과 mongoDB Change Streams에 대해 더 공부할 수 있어서 좋은 경험이었습니다. 릴리즈도 아좌좌
🔗 참고 자료
https://medium.com/@marchpig/mongodb-change-streams-baa78eaa82ed
'DEV > Node.js' 카테고리의 다른 글
[Nest.js] official document 정리 (설치, controller) - 1 (0) | 2022.08.04 |
---|---|
node.js, express, typescript 로 S3에 image upload 하기 (Feat. multer, aws-sdk) (1) | 2022.06.11 |
[CI/CD] AWS CodeDeploy, CodePipeline 으로 node.js, ec2, git 배포 자동화하기 (4) | 2021.12.16 |
[Node.js] Express, TypeScript, MongoDB 회원가입 (1) (3) | 2021.06.18 |
Node.js , express, mongoDB, typescript 초기 설정 (0) | 2021.05.26 |