EP25. Supabase Push Notification
EP24 ๊ฐ์์์๋ Flutter์ฑ์์ FCM Token์ ์ป๊ณ Firebase ์ฝ์์์ ํธ์์๋ฆผ์ ๋ฐ์กํ๋ ํ
์คํธ๋ฅผ ์งํํ์ต๋๋ค.
์๋น์ค ์ด์์์๋ ๋ฐฑ์ค๋ ๋ก์ง์ ์ํด ํธ์ ์๋ฆผ์ ๋ฐ์กํ๊ฒ ๋ฉ๋๋ค.
์์)
โข
๋งค์ผ ์์นจ 9์ ๋ง์ผํ
๊ด๋ จ ๋ฉ์์ง
โข
์ํ ๋ฐฐ์ก ์๋ฃ ์๋ฆผ
โข
์ฑํ
๋ฉ์์ง ์์ ์๋ฆผ
1. ์์คํ ๊ตฌ์กฐ๋
Supabase Edge Functions ๋ฐฑ์ค๋ ๋ก์ง์์ ์ง์ ์ ์ผ๋ก Push๋ฉ์์ง๋ฅผ ๋ฐ์กํ์ง ์์ต๋๋ค. (๋ฌผ๋ก ๊ธฐ์ ์ ์ผ๋ก ๊ฐ๋ฅํฉ๋๋ค๋ง ๋ ๋์ ๋ฐฉ๋ฒ์ ์ ์ํ๊ฒ ์ต๋๋ค.)
Supabase Edge Functions์ API์ธ ํธ์ ๋ฐ์ก์ ์ํ Push ํจ์๋ฅผ ์ถ๊ฐํฉ๋๋ค.
Supabase Database์ notification ํ
์ด๋ธ์ ์์ฑํ๊ณ row๊ฐ ์ถ๊ฐ๋๋ ๊ฒฝ์ฐ ํธ์ ์๋ฆผ์ด ๋ฐ์ก๋๋๋ก trigger๋ฅผ ์ค์ ํฉ๋๋ค.
2. Firebase Admin ๊ถํ ํ๋
Push ๋ฐ์ก์ Firebase Admin์ ์ด์ฉํด ์งํํฉ๋๋ค. Supabase์์ Firebase Admin๊ธฐ๋ฅ์ ์ฌ์ฉํ๊ธฐ ์ํด์๋ Firebase Admin Key๋ฅผ ์ด์ฉํด์ผํฉ๋๋ค.
โข
Firebase > ํ๋ก์ ํธ ์ค์ > ์๋น์ค ๊ณ์ > Firebase Admin SDK
`์ ๋น๊ณต๊ฐ ํค ์์ฑ`๋ฒํผ์ผ๋ก ๋น๊ณต๊ฐ key ํ์ผ์ ๋ค์ด๋ฐ์ต๋๋ค. Admin SDK๊ตฌ์ฑ ์ค๋ํซ์ Node.js๋ฅผ ์ ํํ๋ฉด ๋ฉ๋๋ค.
โข
๋์ผํ Key๋ ๋จ 1๋ฒ๋ง ๋ค์ด๋ฐ์ ์ ์์ผ๋ฏ๋ก ์์ ํ ๊ณณ์ ์ ์ฅํด๋์๊ธธ ๋ฐ๋๋๋ค.
โข
์ด Key๋ Admin Key๋ก ๋งค์ฐ ๊ฐ๋ ฅํ ๊ถํ์ ํ์ฌํ ์ ์์ผ๋ฏ๋ก Public Github ๋ฑ ๊ณต๊ฐ๋ ์ฅ์์ ๋ฐฉ์น๋์ง ์๋๋ก ์ฃผ์ํด์ผ ํฉ๋๋ค.
{firebase ํ๋ก์ ํธ ๊ด๋ จ๋ ์ด๋ฆ}.json ํ์ผ์ด ๋ค์ด๋ก๋ ๋๋ฉฐ ํ์ผ์ ์ด์ด๋ณด๋ฉด json ํํ๋ก ์ฌ๋ฌ key๋ค์ด ์ ์ฅ๋์ด ์์์ ์ ์ ์์ต๋๋ค.
3. Firebase Admin Key ๋ก์ปฌ ํ๊ฒฝ๋ณ์ ์ค์
2๋ฒ ๊ณผ์ ์์ ์ป์ Firebase Admin Key๋ฅผ ์ฌ์ฉํด์ผ ํ์ง๋ง ์์ ํ๊ฒ ๊ด๋ฆฌํ๊ธฐ ์ํด ํ๊ฒฝ๋ณ์๋ก ๊ด๋ฆฌํฉ๋๋ค.
์ ๊ฐ์์์ supabase๋๋ ํ ๋ฆฌ ํ์์ .env.production ์ด๋ผ๋ ํ๊ฒฝ๋ณ์ ํ์ผ์ ์์ฑํ์ต๋๋ค.
์ด ํ๊ฒฝ๋ณ์ ํ์ผ์ Firebase Admin Key์ ๋ณด๋ฅผ ์ถ๊ฐํฉ๋๋ค.
โข
FB_CLIENT_EMAIL
โฆ
client_email ๊ฐ์ ์
๋ ฅํฉ๋๋ค
โข
FB_PRIVATE_KEY
โฆ
private_key ๊ฐ์ ์
๋ ฅํฉ๋๋ค.
โฆ
๊ฐํ๋ฌธ์, โโโ-BEGIN PRIVATE KEYโโ-โ prefix, โโโ-END PRIVATE KEYโโ-\nโ suffix๋ฅผ ๋ชจ๋ ํฌํจํ๋๋ก. ์๋ณธ ๊ทธ๋๋ก ์
๋ ฅํฉ๋๋ค.
โข
FB_PROJECT_ID
โฆ
project_id ๊ฐ์ ์
๋ ฅํฉ๋๋ค.
์ฐธ๊ณ ) ํ๊ฒฝ๋ณ์ ๋ณ๊ฒฝ ์ supabase start ๋ช
๋ น์ด๋ก supabase๋ฅผ ์ฌ์์ ํด์ผํฉ๋๋ค.
4. ๋ก์ปฌ ํ๊ฒฝ๋ณ์๋ฅผ ๋ฐฑ์ค๋ ๋ก์ง์์ ๋ถ๋ฌ์ค๋ ๋ก์ง ๊ตฌํ
supabase/functions/environments.ts ํ์ผ์ ์ถ๊ฐํ ํ๊ฒฝ๋ณ์๋ฅผ ์ถ๊ฐํฉ๋๋ค.
โข
supabase/functions/environments.ts
export const supabaseProjectURL = Deno.env.get(
"SB_PROJECT_URL",
)!;
export const supabaseServiceRoleKey = Deno.env.get(
"SB_SERVICE_ROLE_KEY",
)!;
export const firebaseClientEmail = Deno.env.get(
"FB_CLIENT_EMAIL",
)!;
export const firebasePrivateKey = Deno.env.get(
"FB_PRIVATE_KEY",
)!.replace(/\\n/g, "\n"); // ๐ replace ๋ก์ง ์ถ๊ฐ ์ฃผ์
export const firebaseProjectID = Deno.env.get(
"FB_PROJECT_ID",
)!;
TypeScript
๋ณต์ฌ
์ถ๊ฐํ Firebase ๊ด๋ จ ํ๊ฒฝ๋ณ์ 3๊ฐ์ง๋ฅผ Deno Framework์์ ์ฌ์ฉํ ์ ์๋๋ก ๊ฐ์ ธ์ต๋๋ค.
Private Key์ ๊ฒฝ์ฐ ๊ฐํ๋ฌธ์ ์ฒ๋ฆฌ๋ฅผ ์ํด replace ๋ก์ง์ด ํ์ํจ์ ์ฃผ์ํฉ๋๋ค.
5. Firebase Admin Key Public ํ๊ฒฝ๋ณ์ ์ค์
Public Supabase์์๋ Firebase Admin Key๋ฅผ ์ฌ์ฉํ ์ ์๋๋ก ํ๊ฒฝ๋ณ์๋ฅผ ์ค์ ํฉ๋๋ค.
โข
Supabase > Settings > Edge Functions
6. Notification ํ ์ด๋ธ ์์ฑ
Notification Trigger๊ฐ ๋ Database Table์ ์์ฑํฉ๋๋ค.
ํ
์ด๋ธ์ ์ด๋ฆ์ fcm_notifications๋ก ์ค์ ํฉ๋๋ค. (๋ค๋ฅธ์ด๋ฆ๋ ๊ฐ๋ฅํฉ๋๋ค.)
Column ์ค๋ช
โข
id
โฆ
primary ๊ฐ์ผ๋ก auto increment๋ก ์ฌ์ฉํฉ๋๋ค.
โข
created_at
โฆ
row๊ฐ ์ถ๊ฐ๋ ์์ ์ผ๋ก. ๋ฐฑ์ค๋ ๋ก์ง์ผ๋ก๋ถํฐ Push ๋ฐ์ก ์๋ฆผ์ ์์ฒญ๋ฐ์ ์๊ฐ์ด ๋ฉ๋๋ค.
โข
completed_at
โฆ
FCM์ Push ๋ฐ์ก ์์ฒญ์ ์๋ฃํ ์์ ์ด ๋ฉ๋๋ค.
โข
user_id
โฆ
push๋ฅผ ์์ ํ user์ ID ๊ฐ์
๋๋ค.
โข
title
โฆ
Push Notification์ ์ ๋ชฉ์ผ๋ก ๋ณด์ฌ์ง ํ
์คํธ์
๋๋ค
โข
body
โฆ
Push Notification์ ๋ณธ๋ฌธ์ผ๋ก ๋ณด์ฌ์ง ํ
์คํธ์
๋๋ค
โข
result
โฆ
FCM์ Push ๋ฐ์ก ์์ฒญ์ ๋ํ ๊ฒฐ๊ณผ๋ฅผ ์ ์ฅํฉ๋๋ค.
7. Push ํจ์ ์์ฑ
supabase functions new push
Bash
๋ณต์ฌ
์ ๋ช
๋ น์ด๋ก push๋ผ๋ ์ด๋ฆ์ ํจ์๋ฅผ ์ถ๊ฐํฉ๋๋ค.
supabase/functions ํ์์ push ๋๋ ํ ๋ฆฌ์ index.ts ํ์ผ์ด ์์ฑ๋ฉ๋๋ค.
โข
supabase/functions/push/index.ts
import {
firebaseClientEmail,
firebasePrivateKey,
firebaseProjectID,
} from "../environments.ts";
import { JWT } from "npm:google-auth-library@9";
import { supabase } from "../api/lib/supabase.ts";
// fcm_notifications ํ
์ด๋ธ์ ๋ ์ฝ๋๋ฅผ ๋ํ๋ด๋ ์ธํฐํ์ด์ค
interface UserNotificationRecord {
id: string;
user_id: string;
title: string | null;
body: string | null;
}
// Webhook ํ์ด๋ก๋๋ฅผ ๋ํ๋ด๋ ์ธํฐํ์ด์ค
interface UserNotificationWebhookPayload {
type: "INSERT";
table: string;
record: UserNotificationRecord;
schema: "public";
}
// ์บ์๋ ์ก์ธ์ค ํ ํฐ ๋ฐ ๋ง๋ฃ ์๊ฐ
let cachedAccessToken: string | null = null;
let tokenExpirationTime: number | null = null;
// ์นํ
์๋ํฌ์ธํธ
Deno.serve(async (req) => {
const payload: UserNotificationWebhookPayload = await req.json();
// FCM ํ ํฐ ์กฐํ
const { data, error } = await supabase
.from("fcm_tokens")
.select("fcm_token")
.eq("user_id", payload.record.user_id);
const completedAt = new Date().toISOString();
// FCM ํ ํฐ์ด ์๊ฑฐ๋ ์ค๋ฅ๊ฐ ๋ฐ์ํ ๊ฒฝ์ฐ ์ฒ๋ฆฌ
if (error || !data || data.length === 0) {
console.error("Error fetching FCM tokens:", error || "No FCM tokens found");
await updateNotificationResult(
payload.record.id,
completedAt,
{ "NOT_EXIST_USER": [] },
).catch((err) =>
console.error("Error updating result for non-existent user:", err)
);
return new Response(
JSON.stringify({ error: "No FCM tokens found for the user" }),
{
headers: { "Content-Type": "application/json" },
status: 404,
},
);
}
const fcmTokens = data.map((row: { fcm_token: string }) => row.fcm_token);
try {
// FCM ์๋ฆผ์ ๋ณด๋ด๊ธฐ ์ํ ์ก์ธ์ค ํ ํฐ ๊ฐ์ ธ์ค๊ธฐ
const accessToken = await getAccessToken({
clientEmail: firebaseClientEmail,
privateKey: firebasePrivateKey,
});
// ๊ฐ FCM ํ ํฐ์ ๋ํด ์๋ฆผ ์ ์ก
const notificationPromises = fcmTokens.map((token) =>
sendNotification(token, payload.record, accessToken)
);
// ๋ชจ๋ ์๋ฆผ ์ ์ก ์์
์๋ฃ ๋๊ธฐ
const results = await Promise.all(notificationPromises);
// ๊ฒฐ๊ณผ ์์ฝ
const resultSummary: { [key: string]: string[] } = { "SUCCESS": [] };
results.forEach(({ fcmToken, status }) => {
if (status === "SUCCESS") {
resultSummary["SUCCESS"].push(fcmToken);
} else {
if (!resultSummary[status]) {
resultSummary[status] = [];
}
resultSummary[status].push(fcmToken);
}
});
// ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๊ฒฐ๊ณผ ์
๋ฐ์ดํธ
await updateNotificationResult(
payload.record.id,
completedAt,
resultSummary,
).catch((err) => console.error("Error updating notification result:", err));
return new Response(JSON.stringify({ results }), {
headers: { "Content-Type": "application/json" },
});
} catch (err) {
console.error("Error sending FCM messages:", err);
await updateNotificationResult(
payload.record.id,
completedAt,
{ "SEND_ERROR": [] },
).catch((err) =>
console.error("Error updating result for send error:", err)
);
return new Response(
JSON.stringify({ error: "Failed to send FCM messages" }),
{
headers: { "Content-Type": "application/json" },
status: 500,
},
);
}
});
// FCM ์๋ฆผ์ ๋ณด๋ด๊ธฐ ์ํ ์ก์ธ์ค ํ ํฐ ๊ฐ์ ธ์ค๊ธฐ
const getAccessToken = async ({
clientEmail,
privateKey,
}: {
clientEmail: string;
privateKey: string;
}): Promise<string> => {
const now = Date.now();
if (cachedAccessToken && tokenExpirationTime && now < tokenExpirationTime) {
return cachedAccessToken;
}
const jwtClient = new JWT({
email: clientEmail,
key: privateKey,
scopes: ["https://www.googleapis.com/auth/firebase.messaging"],
});
const tokens = await jwtClient.authorize();
cachedAccessToken = tokens.access_token!;
// ํ ํฐ ๋ง๋ฃ ์๊ฐ ์ค์
tokenExpirationTime = tokens.expiry_date!;
return cachedAccessToken;
};
// ๋จ์ผ FCM ํ ํฐ์ ์๋ฆผ ์ ์ก
const sendNotification = async (
fcmToken: string,
record: UserNotificationRecord,
accessToken: string,
): Promise<{ fcmToken: string; status: string }> => {
try {
const res = await fetch(
`https://fcm.googleapis.com/v1/projects/${firebaseProjectID}/messages:send`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
message: {
token: fcmToken,
notification: {
title: record.title,
body: record.body,
},
},
}),
},
);
const resData = await res.json();
if (res.status < 200 || 299 < res.status) {
const errorCode = resData.error?.details?.[0]?.errorCode ||
resData.error?.status || "UNKNOWN_ERROR";
console.error("Error sending FCM message:", errorCode);
return { fcmToken, status: errorCode };
} else {
return { fcmToken, status: "SUCCESS" };
}
} catch (err) {
console.error("Error sending FCM message:", err);
return { fcmToken, status: "SEND_ERROR" };
}
};
// ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๊ฒฐ๊ณผ ์
๋ฐ์ดํธ
const updateNotificationResult = async (
id: string,
completedAt: string,
result: { [key: string]: string[] },
) => {
try {
const { error } = await supabase
.from("fcm_notifications")
.update({ completed_at: completedAt, result: JSON.stringify(result) })
.eq("id", id);
if (error) {
console.error("Error updating notification result:", error);
throw error;
}
} catch (err) {
console.error("Exception updating notification result:", err);
throw err;
}
};
TypeScript
๋ณต์ฌ
8. Push ํจ์ ๋ฐฐํฌ
bash deploy.sh
TypeScript
๋ณต์ฌ
deploy ์คํฌ๋ฆฝํธ๋ฅผ ์คํํ์ฌ push ํจ์๋ฅผ public์ผ๋ก ๋ฐฐํฌํฉ๋๋ค.
โข
Supabase > Edge Functions
Edge Functions ๋์๋ณด๋์์ push ํจ์๊ฐ ์ถ๊ฐ๋ ๊ฒ์ ํ์ธํ ์ ์์ต๋๋ค.
9. Supabase Database Webhook ์ค์
fcm_notifications ํ
์ด๋ธ์ ๋ฐ์ดํฐ๊ฐ ์์ฑ๋๋ฉด Push ํจ์๋ฅผ ํธ์ถํ๋๋ก webhook์ ์ค์ ํฉ๋๋ค.
โข
Supabase > Database > Webhooks
Enable Webhooks ๋ฒํผ์ ๋๋ฌ Webhook์ ์ถ๊ฐํฉ๋๋ค.
โข
fcm_notifications ํ
์ด๋ธ์ ํธ๋ฆฌ๊ฑฐ๋ก ์ฌ์ฉํ๊ฒ ๋ค๋ ์ ์ธ์
๋๋ค.
โข
fcm_notifications ํ
์ด๋ธ์ ์๋ก์ด row๊ฐ ์ถ๊ฐ๋๋ ๊ฒ์ ํธ๋ฆฌ๊ฑฐ๋ก ์ฌ์ฉํ๊ฒ ๋ค๋ ์ ์ธ์
๋๋ค.
โข
ํธ๋ฆฌ๊ฑฐ๊ฐ ๋๋ฉด Supabase Edge Function์ ํธ์ถํ๊ฒ ๋ค๋ ์ ์ธ์
๋๋ค.
โข
Edge Functions ์ค push ํจ์๋ฅผ ํธ์ถํ๊ฒ ๋ค๋ ์ ์ธ์
๋๋ค.
10. Supabase DB pull
Supabase์ฝ์์์ fcm_notifications ํ
์ด๋ธ์ ์์ฑํ๊ณ webhook์ ์์ฑํ์ต๋๋ค.
์์
๋ด์ฉ์ ๋ก์ปฌ์๋ ๋ฐ์ํ ์ ์๋๋ก ์๋ ๋ช
๋ น์ด๋ก DB ๋ณ๊ฒฝ์ฌํญ์ ๋ก์ปฌ๋ก ๊ฐ์ ธ์ต๋๋ค.
supabase db pull
Bash
๋ณต์ฌ
supabase/migrations ๋๋ ํ ๋ฆฌ ํ์์ DB ๋ณ๊ฒฝ์ฌํญ์ด ํ์ผ๋ก ์์ฑ๋ฉ๋๋ค. (์ํฉ์ ๋ฐ๋ผ sqlํ์ผ ๊ฐฏ์๋ ๋ฌ๋ผ์ง ์ ์์ต๋๋ค.)
11. Database๋ฅผ ํ์ฉํ Push ๋ฐ์ก ํ ์คํธ
โข
supabase > database > fcm_notifications table
insert row ๋ฉ๋ด๋ฅผ ํตํด ๋ฐ์ดํฐ๋ฒ ์ด์ค์ Row๋ฅผ ์ถ๊ฐํฉ๋๋ค.
fcm_tokens ํ
์ด๋ธ์ ๋ฑ๋ก๋, fcm_token์ ๋ณด์ ํ user_id์ ํธ์ ์๋ฆผ title, body์ ๋ณด๋ฅผ ์
๋ ฅํ๊ณ Save๋ฒํผ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ์ ์ฅํฉ๋๋ค.
ํธ๋ฆฌ๊ฑฐ์ ์ํด Push ๋ฉ์์ง๊ฐ ๋ฐ์ก๋์ด ๋๋ฐ์ด์ค์์ Push ์๋ฆผ ์์ ์ ํ์ธํ ์ ์์ต๋๋ค.
fcm_notifications ํ
์ด๋ธ์์ ํธ์ ๋ฐ์ก ์๋ฃ ์๊ฐ๊ณผ FCM token๋ณ ์ฑ๊ณต ์ฌ๋ถ ๊ฒฐ๊ณผ๋ฅผ ํ์ธํ ์ ์์ต๋๋ค.
12. ๋ฐฑ์ค๋ ๋ก์ง์์ ํธ์ถํ ์ ์๋ ํจ์๋ก ๊ตฌํ
fcm_notification ํ
์ด๋ธ์ user_id, title, body๋ฅผ insertํ๋ ํจ์๋ฅผ ๋ง๋ค์ด ๋ฐฑ์ค๋ ๋ก์ง์์ ํ์ํ ๋ ํธ์ถํ ์ ์๋๋ก ํ๊ฒ ์ต๋๋ค.
supabase/functions/push ํ์์ enqueuePushNotifications.ts ํ์ผ์ ์ถ๊ฐํฉ๋๋ค.
import { supabase } from "../api/lib/supabase.ts";
interface PushNotificationParams {
userId: string;
body?: string;
title?: string;
}
// fcm_notifications ํ
์ด๋ธ์ ๋ฐ์ดํฐ๋ฅผ ์ถ๊ฐํ์ฌ ํน์ ์ ์ ์๊ฒ ํธ์ ์๋ฆผ์ ๋ฐ์กํ๋๋ก ์ ์ฌ
export const enqueuePushNotification = async (
{ userId, body, title }: PushNotificationParams,
) => {
const { data, error } = await supabase
.from("fcm_notifications")
.insert([
{ user_id: userId, body: body || null, title: title || null },
]);
if (error) {
console.error("Error enqueuing push notification:", error);
throw error;
}
return data;
};
TypeScript
๋ณต์ฌ
์ด์ ๋ฐฑ์ค๋ ๋ก์ง์์ ์๋ ์ฝ๋์ฒ๋ผ ํธ์๋ฉ์์ง ๋ฐ์ก์ ์์ฒญํ ์ ์์ต๋๋ค.
await enqueuePushNotification({ userId: userID, body: "ํธ์ ๋ฉ์์ง๋ฅผ ๋ฐ์กํฉ๋๋ค.", })
TypeScript
๋ณต์ฌ