2025. 5. 5. 16:11ㆍProject Log/학부 졸업프로젝트
노인 헬스케어 앱에서 알림이 왜 중요한가?
알림은 노인과 앱의 '커넥션(Connection)'이라고 할 수 있다. 단순히 구색을 맞추는 형식의 알림이 되어서는 안 된다.
건강한 생활 습관 형성을 돕고, 평소 노인의 건강 상태에 대한 지속적 관심을 보여줄 수 있는 부분이다.
습관 형성과 관련된 다양한 심리학 이론이 존재한다. 그중에서 습관 루프 이론(Habit Loop)을 떠올려본다. 특정 행동을 유발하는 자극인 '신호(Cue)', 반복 행동을 하는 '루틴(Routine)', 강화 효과를 주는 '보상(Reward)'으로 구성된다. 나는 지금 구현하려는 알림이 Cue의 역할을 할 수 있다고 생각한다.
이번 포스팅에서는 '아침 안부 인사 알림'의 '자동화'를 구현할 것이고, 이후 다양한 종류의 알림을 커스텀 해나갈 것이다.
푸시알림 자동화 전략?
지난 포스팅에서 Flutter의 알림 수신 코드를 작성하고, FCM Messaging 서비스의 '테스트 메시지 전송'에 성공했다.
https://yr-dev.tistory.com/entry/푸시알림-구현-Flutter-FirebaseCloudMessage-연동
[푸시알림 구현] Flutter 앱 - Firebase Cloud Message 연동
개요Flutter 앱에 푸시 알림 기능을 추가하고자 한다. 이번에는 Firebase에서 제공하는 Cloud Messaging 서비스 (FCM)을 이용했다. 알림의 내용이 서비스 관점에서 훨씬 중요하지만, 우선 이 포스팅은 기술
yr-dev.tistory.com
기쁨도 잠시, FCM Messaging 콘솔에서 Cron job으로 알림을 자동화하는 방법이 없고 알림 내용 커스텀도 한계가 있음을 깨달았다. 잠과 맞바꾼 성과를 버리고 처음부터 다시 하나? 당황했다. Flutter-FCM 알람 자동화 사례와 공식 문서, GPT와의 질의응답 내용을 휘젓고 다녔다.
다행히 Flutter의 알림 수신 코드는 그대로 사용할 수 있는 것이었다. 테스트 메시지 전송 대신 아래 흐름을 구현하면 되었다.
1. Device FCM Token 전송을 자동화한다.
2. Firebase HTTP V1 API를 통해 알림을 전송하는 Lambda 함수 코드를 작성한다.
3. AWS EventBridge를 통해 Lambda 함수를 Invoke 하는 Cron job을 설정한다.
4. 플러터에서 알림을 수신한다. (이전 포스팅에서 이미 구현한 부분)
Device FCM Token 자동화
플러터 앱에서 Device Token을 람다 함수로 보내주면, 람다 함수가 device_tokens 테이블에 저장하는 흐름이다.
먼저 device_tokens 테이블을 생성한다.
CREATE TABLE device_tokens (
id INT AUTO_INCREMENT PRIMARY KEY, -- 고유 ID
user_id INT NOT NULL, -- 사용자 ID (외래 키)
fcm_token VARCHAR(255) NOT NULL, -- FCM 토큰
platform ENUM('ios', 'android', 'web') NOT NULL, -- 디바이스 플랫폼 (iOS, Android, Web)
is_active BOOLEAN DEFAULT TRUE, -- 토큰 활성화 여부 (기본값: 활성)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 생성 일시
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -- 업데이트 일시
UNIQUE (user_id, fcm_token) -- 동일한 사용자와 토큰 조합은 유일
);
FCM Device Token을 MySQL에 저장하는 람다 함수(save-fcm-device-token)를 작성한다. 환경 변수에 DB 관련 정보를 등록했고, mysql2 라이브러리는 기존에 사용하던 layer를 추가했다.
import mysql from 'mysql2/promise';
export const handler = async (event) => {
let connection;
try {
const { fcmToken, userId, platform } = JSON.parse(event.body);
connection = await mysql.createConnection({
host: process.env.DB_HOST, // RDS 엔드포인트
user: process.env.DB_USER, // MySQL 사용자명
password: process.env.DB_PASSWORD, // MySQL 비밀번호
database: process.env.DB_NAME, // MySQL 데이터베이스 이름
});
const [rows] = await connection.execute(
'SELECT * FROM device_tokens WHERE user_id = ?',
[userId]
);
let query, values;
if (rows.length > 0) {
query = `
UPDATE device_tokens
SET fcm_token = ?, platform = ?, is_active = TRUE
WHERE user_id = ?;
`;
values = [fcmToken, platform, userId];
} else {
query = `
INSERT INTO device_tokens (user_id, fcm_token, platform, is_active)
VALUES (?, ?, ?, TRUE);
`;
values = [userId, fcmToken, platform];
}
const [results] = await connection.execute(query, values);
console.log('Token saved or updated successfully:', results);
return {
statusCode: 200,
body: JSON.stringify({ message: 'Token saved or updated successfully' }),
};
} catch (error) {
console.error('Error saving or updating token:', error);
return {
statusCode: 500,
body: JSON.stringify({ message: 'Failed to save or update token' }),
};
} finally {
if (connection) {
await connection.end();
}
}
};
어제 작성한 Flutter 코드의 main 함수에 Device FCM Token을 Lambda 함수로 보내는 코드를 추가한다.
final fcmToken = await messaging.getToken();
if (settings.authorizationStatus == AuthorizationStatus.authorized && fcmToken != null) {
print('FCM Token: $fcmToken');
await sendTokenToLambda(fcmToken);
} else {
print('FCM Token: null');
}
API Gateway URL을 통해 람다 함수를 호출한다.
(이전에는 API Gateway 탭을 따로 열어서, API를 만들고 람다 함수를 연결하고, POST 메서드를 추가했었다. 이번에는 람다 함수 탭에서 바로 트리거 추가를 눌러 API를 생성하고 배포 단계를 설정하는 방식이 훨씬 빠르고 간편함을 알게 되었다.)
Future<void> sendTokenToLambda(String fcmToken) async {
final url = Uri.parse('API_GATEWAY_URL'); // 실제 API GATEWAY URL로 수정
final Map<String, dynamic> data = {
'fcmToken': fcmToken,
'userId': 1,
'platform': 'android',
};
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
},
body: json.encode(data),
);
if (response.statusCode == 200) {
print('Token successfully sent to Lambda');
} else {
print('Failed to send token to Lambda: ${response.statusCode}');
}
}
RDS MySQL에 user_id와 함께 FCM Device Token 정보가 저장되었다.
Firebase HTTP V1 API: 알림 전송 Lambda 함수
Firebase HTTP V1 API는 알람 기능 구현을 위해 제공되는 API이다. 해당 API를 사용하려면 Firebase Access Token이 필요하다. [서비스 계정] - [새 비공개 키 생성]을 통해 .json 파일을 다운로드하고, 로컬의 코드와 같은 디렉토리로 복사한다. json 파일명이 복잡해서, admin-sdk-key.json으로 바꿔주었다.
아래 코드를 통해 Json 파일로부터 Firebase Access Token을 추출한다.
const { JWT } = require('google-auth-library');
const serviceAccount = require('./admin-sdk-key.json');
async function getAccessToken() {
const jwtClient = new JWT({
email: serviceAccount.client_email,
key: serviceAccount.private_key,
scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
});
const accessToken = await jwtClient.authorize();
console.log('Access Token:', accessToken.access_token);
return accessToken.access_token;
}
getAccessToken();
이제 알림을 전송하는 코드를 작성한다.
먼저 1) 로컬 node.js 코드에서 Flutter 앱과 테스팅을 하고, 2) Lambda 함수로 수정 및 배포한 후에 다시 테스팅을 했다.
로컬에서는 .env 파일에 Firebase Access Token 정보를 저장하여 사용했고, 일단 Device FCM Token은 DB에 저장된 것을 직접 넣어서 테스트했다.
require('dotenv').config();
const fetch = require('node-fetch');
const DEVICE_FCM_TOKEN = '{실제 DEVICE_FCM_TOKEN 입력}';
async function sendNotification(token, title, body) {
const accessToken = process.env.FIREBASE_ACCESS_TOKEN;
const message = {
message: {
token: token,
notification: {
title: title,
body: body,
},
android: {
priority: "high",
},
},
};
const response = await fetch('https://fcm.googleapis.com/v1/projects/dayinbloom-4dde1/messages:send', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
});
const data = await response.json();
console.log('Status:', response.status);
console.log('Response:', data);
}
sendNotification(
DEVICE_FCM_TOKEN,
'꽃이 되는 하루',
'좋은 아침이에요! 잠은 푹 주무셨나요?'
);
람다 함수(auto-morning-alarm)로 수정하면서 DB 관련 환경 변수들은 .env 파일 말고, 람다 함수 설정에 추가했다. FIREBASE_ACCESS_TOKEN은 일정 시간이 지나면 만료되어 사용할 수 없다. 따라서 람다 함수가 호출될 때마다 firebase-sdk-key.json 파일을 이용해 토큰을 새로 발급받도록 했다. (위쪽의 Firebase Access Token 추출 코드를 활용했다.)
특정 userId와 함께 저장된 FCM Device Token 정보를 DB에서 추출하여 사용한다. fetch 함수로 API 요청을 보내며, 알림 제목과 내용도 지정하였다.
import { JWT } from 'google-auth-library';
import mysql from 'mysql2/promise';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const serviceAccount = require('./firebase-sdk-key.json');
async function getAccessToken() {
const jwtClient = new JWT({
email: serviceAccount.client_email,
key: serviceAccount.private_key,
scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
});
const accessToken = await jwtClient.authorize();
return accessToken.access_token;
}
async function sendNotification(token, title, body) {
const accessToken = await getAccessToken();
const message = {
message: {
token,
notification: { title, body },
android: { priority: 'high' },
},
};
const response = await fetch(
'https://fcm.googleapis.com/v1/projects/dayinbloom-4dde1/messages:send',
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
}
);
const data = await response.json();
console.log('Status:', response.status);
console.log('Response:', data);
}
export const handler = async (event) => {
let connection;
try {
connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
const userId = event.userId || 1;
const [rows] = await connection.execute(
'SELECT fcm_token FROM device_tokens WHERE user_id = ? AND is_active = TRUE',
[userId]
);
if (rows.length === 0) {
console.log('No active token found for user:', userId);
return {
statusCode: 404,
body: JSON.stringify({ message: 'No active token found' }),
};
}
const fcmToken = rows[0].fcm_token;
const title = '꽃이 되는 하루';
const body = '좋은 아침이에요! 푹 주무셨나요?';
await sendNotification(fcmToken, title, body);
return {
statusCode: 200,
body: JSON.stringify({ message: 'Notification sent successfully' }),
};
} catch (error) {
console.error('Error sending notification:', error);
return {
statusCode: 500,
body: JSON.stringify({ message: 'Failed to send notification' }),
};
} finally {
if (connection) {
await connection.end();
}
}
};
AWS EventBridge: Lambda 함수에 대한 Cron job 설정
위에서 생성한 'auto-morning-alarm' 람다 함수를 일정 시간마다 호출하는 Cron job을 생성한다. EventBridge Scheduler 설정은 매우 간단한 편이다. 일정을 생성하고 Cron 기반 패턴을 지정과 람다 함수 연결을 한다. 아침 안부 인사 알림이므로 오전 7시쯤마다 실행되겠지만, 우선 테스트를 위해 1분마다 실행되도록 설정했다.
실행 결과
1분 주기로 Cron 설정을 했더니, 자동으로 'auto-morning-alarm' 람다 함수를 호출하여 알림을 보내주는 것을 볼 수 있었다. EventBridge가 1분마다 계속 돌면 람다 사용량이 무섭게 늘어날 테니, 반드시 쓰지 않을 때는 비활성화를 눌러야 한다.
To do
- 이번에는 아침 안부 알람만 테스트 했는데, 앞으로 여러 종류의 알림 내용과 Cron 주기를 결정해야 한다.
- 복잡한 권한 설정들로 인해 일단 Android에서만 테스트를 진행했는데, iOS로 구현을 확장해야 한다.
- 앱 내부 지난 알림 리스트 부분과 연결을 해야 한다.