Next.js

PWA 푸시알림 구현하기(Next.js + PWA + Push Notification + Supabase) - 1

mnmhbbb 2024. 8. 13. 00:32

Next.js + PWA + Push Notification + Supabase 조합을 처음 사용하면서 남기는 기록

사용할 Web API:

  • Push API: 푸시 서비스 구독/해지, 메시지 수신
  • Notification API: 사용자에게 알림 노출

 

1. Next.js 기본 세팅

https://mnmhbbb.tistory.com/560

 

Next.js 기본 프로젝트 셋업(Pnpm)

Next.js + TypeScript + Tailwind CSS pnpm create next-app전부 Yes tailwind css 셋업: (https://nextjs.org/docs/app/building-your-application/styling/tailwind-css)pnpm install -D tailwindcss postcss autoprefixernpx tailwindcss init -p에서 필요한

mnmhbbb.tistory.com

 

2. PWA 세팅(서비스워커 설정, 매니페스트 생성)

pnpm install next-pwa

공식 문서 참고하여 next.config.mjs를 다음과 같이 수정하고,
pnpm run build하면 public에 workbox-*.js과 sw.js 파일이 자동으로 생성됨.
이로써 서비스워커 설정은 끝.

import nextPwa from "next-pwa";

/** @type {import('next').NextConfig} */
const nextConfig = {};

const withPWA = nextPwa({
  dest: "public",
  register: true,
  skipWaiting: true,
  // disable: process.env.NODE_ENV === 'development'
  // customWorkerDir: "worker",
  // runtimeCaching,
});

const config = withPWA({
  ...nextConfig,
});

export default config;

pwa 추가 설정은 공식문서 참조(https://www.npmjs.com/package/next-pwa#available-options)

-> 그러나 이후 next-pwa 제거함(사용할 pwa 기능이 많아서 커스텀 설정을 해야 하기 때문에 큰 의미가 없는 듯 하여 제거함)

 

layout.tsx의 meta에 필요한 Meta 태그 적용

다음 공식 문서 참조: https://nextjs.org/docs/app/api-reference/functions/generate-metadata

 

Functions: generateMetadata | Next.js

Learn how to add Metadata to your Next.js application for improved search engine optimization (SEO) and web shareability.

nextjs.org

 

빌드할 때마다 생성되는 Pwa 정적 파일을 .gitignore에 추가

# pwa
public/sw.js*
public/workbox-*.js*

 

(만약 이미 커밋하고 오리진에 push까지 했다면,)

git rm --cached public/sw.js
git rm --cached public/workbox-*.js

 

그 다음,

pnpm run build && pnpm run start

하여 로컬에서 pwa 가 정상적으로 동작하는지 확인

개발자도구 - Application - Manifest, Service workers
개발자도구 - Linehouse 

 

 

3. 구독, 알림

next-pwa 패키지 덕분에 간단하게 서비스워커 설정을 마쳤지만,
푸시 알림 기능을 넣기 위해선 커스텀 서비스워커를 만들어서 설정을 직접 추가해야 한다.
push나 notification 관련 설정을 직접 제공하지 않기 때문.

next-pwa의 설명을 참조하여 /worker/index.js를 생성하여, 원하는 서비스워커 설정을 추가하면,
기존 next-pwa 설정에 원하는 설정을 추가할 수 있다.

서비스워커가 푸시 이벤트와 알림 클릭 이벤트를 감지하도록 함.

self.addEventListener("push", (event) => {
  console.log("[Service Worker] Push Received.", event.data.text());
  const { title, body, icon, badge } = event.data.json();
  const options = {
    body,
    icon,
    badge,
  };
  event.waitUntil(self.registration.showNotification(title, options));
});

self.addEventListener("notificationclick", (event) => {
  console.log("[Service Worker] notificationclick");
  event.notification.close();

  event.waitUntil(
    clients
      .matchAll({
        type: "window",
      })
      .then((clientList) => {
        for (const client of clientList) {
          if (client.url === "/" && "focus" in client) return client.focus();
        }
        if (clients.openWindow) return clients.openWindow("/");
      }),
  );
});

서비스워커는 push 이벤트가 발생했을 때, 위와 같은 형태로 알림을 띄워준다. 더 많은 옵션은 공식 문서 참조
또한, 알림을 클릭했을 때 기존 동일 출처 탭이 있는 경우 해당 탭에 포커스를 맞추고, 그렇지 않으면 새 탭을 열어 브라우저를 사이트 출처의 루트로 열게 됨.

 

(이 상태에서 다시 로컬 서버를 켰을 때,

Module not found: Error: Can't resolve 'babel-loader' in '/Users/

이러한 에러가 발생하여 babel-loader 추가 설치함)

pnpm install babel-loader -D

 

-> 그랬으나, next-pwa 삭제하고 직접 서비스워커 설정 파일을 만들어서 적용함.
next.config.mjs도 초기세팅으로 복원.

/** @type {import('next').NextConfig} */
const nextConfig = {};

export default nextConfig;


/public/sw.js:

self.addEventListener("install", (event) => {
  console.log("[Service Worker] Installing Service Worker...");
  self.skipWaiting(); // waiting 상태의 서비스 워커를 active 상태로 변경하도록 강제함
});

self.addEventListener("activate", (event) => {
  console.log("[Service Worker] Activating Service Worker...");
  event.waitUntil(self.clients.claim()); // 활성화 즉시 클라이언트를 제어함(새로고침 불필요)
});

self.addEventListener("push", (event) => {
  console.log("[Service Worker] Push Received.", event.data.text());
  const { title, body, icon, badge } = event.data.json();
  const options = {
    body,
    icon,
    badge,
  };
  event.waitUntil(self.registration.showNotification(title, options));
});

self.addEventListener("notificationclick", (event) => {
  console.log("[Service Worker] notificationclick");
  event.notification.close();

  event.waitUntil(
    clients
      .matchAll({
        type: "window",
      })
      .then((clientList) => {
        for (const client of clientList) {
          if (client.url === "/" && "focus" in client) return client.focus();
        }
        if (clients.openWindow) return clients.openWindow("/");
      }),
  );
});

 

 

web push 프로토콜에서 필요한 VAPID를 생성하기 위해 web-push 패키지 설치

다음 명령어 입력:

npx web-push generate-vapid-keys

하면 나오는 public, private key를 .env에 추가

 

브라우저에서 VAPID 공개키를 푸시 서비스로 전달할 때
Uint8Array 형식으로 변환하여 전달해야 하므로, 다음 유틸 함수를 만들어서 사용함.

export function urlB64ToUint8Array(base64String: string): Uint8Array {
	const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
	const base64 = (base64String + padding)
		.replace(/\-/g, "+")
		.replace(/_/g, "/");
	const rawData = window.atob(base64);
	const outputArray = new Uint8Array(rawData.length);
	for (let i = 0; i < rawData.length; ++i) {
		outputArray[i] = rawData.charCodeAt(i);
	}
	return outputArray;
}

 

드디어 구독 기능을 구현할 차례:

이제 위에서 등록한 서비스워커를 통해 푸시매니저에 접근할 수 있는데, 푸시매니저에서 subscribe 메서드를 제공한다. 
이 메서드가 옵션 객체를 전달할 건데, 각각 다음을 의미한다. 

  • applicationServerKey: 아까 web-push로 생성한 공개키를 위 유틸리티 함수를 사용하여 변환한 값
  • userVisibleOnly: true(=푸시 알림을 유저에게 보이게 한다는 의미)

가 들어있는 옵션 객체에 담아 전달하면, 구독정보(pushSubscription)객체를 받을 수 있다.

  const generatePushSubscription = async (registration: ServiceWorkerRegistration) => {
    const applicationServerKey = urlB64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!);
    const options = {
      applicationServerKey,
      userVisibleOnly: true,
    };
    const pushSubscription = await registration.pushManager.subscribe(options);
    try {
      const res = await fetch("/api/subscribe", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(pushSubscription),
      });

 

푸시 서비스로부터 받은 구독 정보 객체(pushSubscription):

이제 이 데이터를 백엔드 db에 저장하자.
(각 유저의 구독 상태를 저장하고, 알림을 보내기 전에 확인하여 보내기 위함)

 

이를 위해 간단하게 supabase를 만들어서 진행하려고 한다.
새 프로젝트를 생성하고, 공식 문서의 설명에 따라

새 테이블을 만드는데, 샘플 SQL이 다음과 같이 나와있다.
좌측 SQL Editor을 사용해서 샘플데이터가 포함된 notes 테이블을 생성할 수 있다.

-- Create the table
create table notes (
  id bigint primary key generated always as identity,
  title text not null
);

-- Insert some sample data into the table
insert into notes (title)
values
  ('Today I created a Supabase project.'),
  ('I added some data and queried it from Next.js.'),
  ('It was awesome!');

alter table notes enable row level security;
create policy "public can read notes"
on public.notes
for select to anon
using (true);

그런 다음 .env에 다음 환경변수를 추가한다.

NEXT_PUBLIC_SUPABASE_URL=<SUBSTITUTE_SUPABASE_URL>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<SUBSTITUTE_SUPABASE_ANON_KEY>

이제 supabase 패키지를 설치한다.

pnpm install @supabase/supabase-js @supabase/ssr

app/notes/page.tsx, utils/supabase/server.ts에서 위 샘플 데이터를 불러오는지 확인하면 연결이 잘 된 것.

// app/notes/page.tsx

import { createClient } from "@/utils/supabase/server";

export default async function Notes() {
  const supabase = createClient();
  const { data: notes } = await supabase.from("notes").select();

  return <pre>{JSON.stringify(notes, null, 2)}</pre>;
}
// utils/supabase/server.ts

import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";

export function createClient() {
  const cookieStore = cookies();

  return createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, {
    cookies: {
      getAll() {
        return cookieStore.getAll();
      },
      setAll(cookiesToSet) {
        try {
          cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
        } catch {
          // The `setAll` method was called from a Server Component.
          // This can be ignored if you have middleware refreshing
          // user sessions.
        }
      },
    },
  });
}

/notes에서 정상 동작 확인. 연결이 잘 됐다.

 

이제 user 테이블을 임시로 만들어서, 구독정보를 저장시킨다.
id: uuid, created_at: timestamp, pushSubscription: varchar
(푸시 알림 여부만 테스트하고 삭제할 거라서 컬럼명도 타입도 중구난방이다..)
2번째 포스팅에서 제대로 설정한다.

여전히 이 문서를 참조하여 utils/client.ts, server.ts를 만든다. 
supabase 연결을 모듈화하여 다른 파일에서 편하게 사용하기 위함이다.
각각 클라이언트 컴포넌트와 서버 컴포넌트에서 supabase에 접근할 때 사용된다.

import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  // Create a supabase client on the browser with project's credentials
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'

export function createClient() {
  const cookieStore = cookies()

  // Create a server's supabase client with newly configured cookie,
  // which could be used to maintain user's session
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // The `setAll` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
      },
    }
  )
}

 

나중에 로그인 기능을 붙이면 middleware도 추가할 것.

 

이제 /api/subscribe/route.ts 에 POST api를 만든다.
푸시서비스로부터 받은 구독정보(pushSubscription)를 DB에 저장하는 기능을 한다.

import { createClient } from "@/utils/supabase/server";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  try {
    const pushSubscription = await req.json();
    const supabase = createClient(true);

    const { data, error } = await supabase
      .from("user")
      .insert([
        {
          id: crypto.randomUUID(),
          created_at: new Date().toISOString(),
          pushSubscription,
        },
      ])
      .select();

    if (error) {
      return NextResponse.json({ message: error.message }, { status: 400 });
    } else {
      return NextResponse.json({ message: "success", userId: data[0].id }, { status: 200 });
    }
  } catch (error) {
    return NextResponse.json({ message: "An unexpected error occurred" }, { status: 500 });
  }
}

supabase에 데이터가 잘 들어가는지 확인

 

구독 해제하기

api/unsubscribe/route.ts에 POST API 라우트를 만듦
구독과 마찬가지로, 푸시매니저를 통해 구현할 수 있다.
https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/unsubscribe

푸시매니저에서 제공하는 getSubscription을 통해 pushSubscription를 받고, 이곳에 unscribe() 메서드를 호출한다.

  const handleUnSubscription = async () => {
    try {
      const registration = await navigator.serviceWorker.ready;
      const subscription = await registration.pushManager.getSubscription();

      if (subscription) {
        const successful = await subscription.unsubscribe();
        if (successful) {
          setStatus(NotificationPermission.default);

          const res = await fetch("/api/unsubscribe", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify({ id: userId }), // TEMP
          });

          if (!res.ok) {
            throw new Error("Failed to update server");
          }
          console.log("Server updated successfully");
        }
      } else {
        console.log("No subscription to unsubscribe");
        setStatus(NotificationPermission.default);
      }
    } catch (error) {
      console.error(`Error during unsubscription: ${error}`);
      alert(`Unsubscription failed: ${error}`);
    }
  };

 

또한, DB에 저장됐던 구독정보도 삭제한다.

import { createClient } from "@/utils/supabase/server";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  try {
    const { id } = await req.json();
    const supabase = createClient(true);

    const { error } = await supabase.from("user").delete().eq("id", id);

    if (error) {
      return NextResponse.json({ message: error.message }, { status: 400 });
    } else {
      return NextResponse.json({ message: "success" }, { status: 200 });
    }
  } catch (error) {
    return NextResponse.json({ message: "An unexpected error occurred" }, { status: 500 });
  }
}

 


이제 푸시 알림 전송 버튼을 만들 차례이다.
1) 클라이언트에서 푸시 알림이 요청을 했을 때(/api/send-notification)
2) 백엔드에서 푸시서비스로 이 구독정보(pushSubscription)와 알림 title, body 등의 값을 넘기면,
3) 푸시서비스에서 브라우저로 푸시 알림 함.
(클라이언트 -> 백엔드 -> 푸시서비스 -> 클라이언트)
이는 서비스워커 설정에 등록한 push 이벤트 감지를 통해 이루어진다.

  const handleUnSubscription = async () => {
    try {
      const registration = await navigator.serviceWorker.ready; // 서비스 워커가 준비될 때까지 기다림
      const subscription = await registration.pushManager.getSubscription();

      if (subscription) {
        const successful = await subscription.unsubscribe();
        if (successful) {
          setStatus(NotificationPermission.default);
          const res = await fetch("/api/unsubscribe", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify({ id: userId }), // TEMP
          });

          if (!res.ok) {
            throw new Error("Failed to update server");
          }
          console.log("Server updated successfully");
        }
      } else {
        console.log("No subscription to unsubscribe");
        setStatus(NotificationPermission.default);
      }
    } catch (error) {
      console.error(`Error during unsubscription: ${error}`);
      alert(`Unsubscription failed: ${error}`);
    }
  };

 

+) 위 푸시 알림 함수에서는 subscribe할 때와 달리, navigator.serviceWorker.ready를 사용하고 있는데,
1번의 경우는 서비스워커 상태에 관계없이 등록여부를 가져온다. 만약 등록 정보가 없을 경우 null을 가져오는데, 이렇게 null일 경우 서비스워커를 등록시키기 위해 이렇게 사용하였다.
2번은 서비스워커가 활성화될 때까지 기다렸다가 활성상태를 반환한다. 따라서, 이와 같이 푸시알림 기능을 사용할 때 적합하다.
푸시 알림은 백그라운드에서 동작해야 하므로, 완전히 활성화된 서비스 워커가 필요한데 ready는 이를 보장하기 때문.

 // 1
const registration = await navigator.serviceWorker.getRegistration();

// 2
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();

 

/api/send-notification 라우트에서 다음과 같이 푸시서비스에 정보를 전달한다.

import { createClient } from "@/utils/supabase/server";
import { NextRequest, NextResponse } from "next/server";

import webPush from "web-push";

const subject = "https://next-pwa-todo.vercel.app";
const publicVapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!;
const privateVapidKey = process.env.VAPID_PRIVATE_KEY!;

webPush.setVapidDetails(subject, publicVapidKey, privateVapidKey);

interface PushSubscriptionType {
  endpoint: string;
  keys: {
    p256dh: string;
    auth: string;
  };
  expirationTime: null;
}

export async function POST(req: NextRequest) {
  try {
    const { id } = await req.json();
    const supabase = createClient(true);

    const { data: subscription, error } = await supabase.from("user").select("pushSubscription").eq("id", id);

    if (error) {
      console.error("Supabase error:", error);
      return NextResponse.json({ message: error.message }, { status: 400 });
    } else {
      const notificationPayload = {
        title: "Hello from PWA",
        body: "This is a test push notification",
        icon: "/assets/icons/icon-192x192.png",
        badge: "/assets/icons/icon-192x192.png",
      };

      const rawPushSubscription = JSON.parse(subscription?.[0].pushSubscription);

      const pushSubscription: PushSubscriptionType = {
        endpoint: rawPushSubscription.endpoint,
        keys: {
          p256dh: rawPushSubscription.keys.p256dh,
          auth: rawPushSubscription.keys.auth,
        },
        expirationTime: null,
      };

      await webPush.sendNotification(pushSubscription, JSON.stringify(notificationPayload));
      return NextResponse.json({ message: "success" }, { status: 200 });
    }
  } catch (error) {
    console.error("Unexpected error:", error);
    return NextResponse.json({ message: "An unexpected error occurred" }, { status: 500 });
  }
}

푸시서비스가 이를 받으면, 최종적으로 클라이언트에 푸시 알림이 도착한다.

최종 푸시 알림 테스트:

 

title, body, icon 등 알림 옵션에 대한 설명은 web-push 공식 문서 참조

 

 

구현할 것:

1) 현재 구독 여부에 따라 구독/해제 버튼 노출 -> 버튼 클릭
2) 버튼 클릭하여 구독 / 해제 실행
3) 푸시 알림 버튼 -> 그 즉시 알림 노출
4) 로그인(자체 로그인, 소셜 로그인) 기능 추가
5) 커스텀 푸시 알림 기능(각 유저가 원하는 일시, 원하는 타이틀, 원하는 내용, 원하는 이미지)

node-schedule 사용하여, 유저가 요청한 시간에 push를 보내기

 

 


reference: 

https://developer.chrome.com/blog/push-notifications-on-the-open-web?hl=ko

 

오픈 웹의 푸시 알림  |  Blog  |  Chrome for Developers

Chrome 42에서 푸시 메시지 및 알림이 지원됩니다.

developer.chrome.com

 

https://youtu.be/JVMiTexyp6U?si=D0r5LqT6mussxl3s

프로젝트 전체적인 흐름을 파악할 수 있음

 

https://velog.io/@hyunjoong/Next.js-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-PWA-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0

 

Next.js 프로젝트 PWA 구축하기

next js에 pwa를 구축해보자

velog.io

 

https://velog.io/@rachaen/next-pwa-push%EC%95%8C%EB%9E%8C

 

[next-pwa] push알람

Next.js 프로젝트에서 서비스 워커를 사용하여 push 기능을 구현해보았습니다.프로젝트가 백엔드 프레임워크로 spring을 사용하고 있기도하고 간단하게 push알람이 되는지 여부를 체크하기 위해서

velog.io

-> next.config.js의 next-pwa 각 설정 & 커스텀 서비스워커 세팅법이 잘 설명돼있음
더 자세한 내용은 공식문서 참조(https://www.npmjs.com/package/next-pwa#available-options)

 

https://geundung.dev/114

 

웹 푸시 알림(Web Push Notification)

👉 시작하면서 웹 푸시 알림(Web Push Notification)이란, 말 그대로 브라우저 환경에서 푸시 알림을 받을 수 있는 기술을 의미한다. 푸시 알림이라고 하면 네이티브 앱의 전유물이라고 느낄 수 있지

geundung.dev

-> Web Push Protocol부터 푸시 알림 흐름을 상세히 설명하는 글!

 

https://wonsss.github.io/PWA/web-push-notification/

 

Web Push를 통한 구독 및 백그라운드 알림 발송 기능 구현(Push API, Notifications API, ServiceWorker, FCM)

1. Web Push Notification이란? 웹 푸시 알림은 브라우저를 통해 웹 사이트에서 사용자의 기기로 전송되는 실행 가능한 메시지이다. 알림을 받기 위해 별도의 앱 설치나 이메일 등을 필요로 하지 않고

wonsss.github.io