PWA와 Next.js를 사용해서 모바일 앱 만들기

Youngeui Hong·2024년 3월 13일
4

Wish Funding

목록 보기
1/1
post-thumbnail

👋🏻 들어가며

최근 사이드 프로젝트를 하나 하고 있는데, 이 프로젝트의 경우 모바일에서 접속할 가능성이 높기 때문에 PC보다는 모바일에 초점을 맞춰 개발하기로 했다.

PC와 모바일에서 모두 접속 가능하게 하면서도, 모바일에 최적화된 사이트를 어떻게 만들 수 있는 방법을 찾다가 프로그레시브 웹 앱(Progressive Web Apps, PWA)에 대해 알게 되었다.

PWA는 모바일 앱과 웹 사이트의 중간 형태로, 웹 개발을 통해 모바일 앱과 유사한 경험을 제공할 수 있다는 점이 매력적이었다. PWA를 사용하면 모바일 앱처럼 홈 화면에 아이콘을 추가하고, 푸시 알림을 받고, 오프라인 상태에서 작동하게 하는 것이 가능했다.

사이드 프로젝트에 적용하기에 앞서, 우선 간단한 TODO 앱을 만들면서 PWA 기능을 익혀보았다. 자세한 코드는 GitHub에서 확인할 수 있다.

📱 manifest: 앱 이름과 아이콘 설정하기

우선 web application manifest를 통해 앱 이름과 아이콘 등을 설정해줘야 PWA로 사용할 수 있다. web app manifest는 PWA가 기기에서 어떻게 동작해야 하는지 알려주는 JSON 파일이다.

manifest를 작성하지 않은 상태에서 Lighthouse 분석을 돌려보면 아래와 같이 PWA 설치가 불가능한 상태라고 뜬다.

manifest.ts

import { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "PWA TODO",
    short_name: "PWA TODO",
    theme_color: "#f5f5f5",
    background_color: "#f5f5f5",
    icons: [
      {
        src: "app-icon/ios/192.png",
        sizes: "192x192",
        type: "image/png",
      },
      {
        src: "app-icon/ios/512.png",
        sizes: "512x512",
        type: "image/png",
      },
      {
        src: "app-icon/ios/192.png",
        sizes: "192x192",
        purpose: "maskable",
        type: "image/png",
      },
      {
        src: "app-icon/ios/512.png",
        sizes: "512x512",
        purpose: "maskable",
        type: "image/png",
      },
    ],
    orientation: "any",
    display: "standalone",
    dir: "auto",
    lang: "ko-KR",
    start_url: "/pwa-todo",
  };
}

manifest 파일은 HTML의 <head> 안의 <link>에 파일의 경로를 작성해서 포함시킬 수 있는데, Next.js의 경우 app 디렉토리 안에 manifest.ts 파일을 두면 별도로 json 파일을 생성하고 <link>를 작성할 필요가 없었다.

위와 같이 manifest.ts를 작성했는데, PWA를 설정하기 위해 반드시 들어가야 하는 속성은 앱의 이름과 관련된 name과 아이콘과 관련된 icons이다.

🔻 icons

앱 아이콘 이미지는 PWA Builder의 Image Genarator를 사용하면 기기에 맞는 여러 사이즈로 만들 수 있어서 좋았다. 그리고 기기에서 아이콘이 어떻게 보일지 미리 보고 싶을 때는 maskable 사이트를 사용하니 편했다.

purpose 필드에 아이콘이 maskable한지를 여부를 넣어주는데, 아이콘이 maskable하다는 것은 여러 모양에 적용 가능함을 의미한다. 예를 들어 안드로이드 기기에서 앱 아이콘이 원형인 경우 maskable하지 않은 경우 왼쪽과 같이 표시될 것이고, maskable한 경우 오른쪽과 같이 뜰 것이다.

🔻 display

다른 속성들도 살펴보면 display 의 경우 앱이 어떻게 표시될지를 나타내는 값인데, PWA의 경우 standalone으로 하는 것이 일반적이다. fullscreen으로 설정하면 아래 그림의 오른쪽과 같이 표시되는데, 안드로이드 기기에서는 적용 불가능하고, iOS 기기에서만 적용 가능하다.

🔻 theme_color

standalone으로 하면 위 그림의 왼쪽처럼 status bar 뒤가 비게 되는데 이 영역의 색상은 theme_color 속성을 통해 설정할 수 있다.

이렇게 manifest 설정을 마치고 Lighthouse 분석을 다시 돌려보면 아래와 같이 PWA 설치가 가능함을 확인할 수 있다.

🔔 푸시 알림 기능 구현하기

푸시 알림 개요

푸시 알림 기능 구현에 필요한 작업은 크게 아래와 같이 나누어볼 수 있다.

  1. 클라이언트로부터 푸시 알림 동의 받기
  2. 서버에 클라이언트의 구독 정보를 저장하기
  3. 서버로부터 푸시 알림이 오면 Notification을 띄우기
    3-1. Service Worker 등록하기
    3-2. push 이벤트 핸들러 등록하기
    3-3. 서버에서 클라이언트로 푸시 알림 보내기

푸시 알림 동의 받기

푸시 알림을 보내려면 먼저 클라이언트로부터 Notification에 대한 동의를 받아야 한다.

Service Worker, Notification, Push 기능을 지원하지 않는 브라우저도 있으므로 이를 확인한 후 동의를 받도록 한다.

🔻 Notification.requestPermission()

// Notification 허용 버튼 클릭 시
async function onClickAlert() {
  if (
    "serviceWorker" in navigator &&
    "Notification" in window &&
    "PushManager" in window
  ) {
    Notification.requestPermission().then(async (result) => {
      if (result === "granted") {
        const subscription = await getPushSubscription();
        await savePushSubscription(subscription);
        setAlertGranted(true);
      } else if (result === "denied") {
        setAlertGranted(false);
      }
    });
  }
}

알림 구독 정보 저장하기

Notification.requestPermission()까지만 해도 앱이 켜져 있는 상태에서 클라이언트는 알림을 받을 수 있다. 하지만 여기까지만 하면 앱이 꺼져 있는 상태에서는 푸시 알림을 받을 수 없다. 앱이 꺼져 있는 상태에서도 알림을 받으려면 Push API 관련 작업이 필요하다.

웹 푸시는 아래와 같은 흐름으로 이루어지는데, 푸시 메세지가 유출되거나 변조되는 것을 막으려면 암호화 및 서버 인증 과정을 거쳐야 한다. 이 작업을 쉽게 할 수 있도록 web-push 라이브러리를 사용했다.

먼저 인증에 사용할 VAPID (Voluntary Application Server Identification) key를 발급 받아야 한다. web-push 라이브러리를 설치한 후 터미널창에 아래 명령어를 입력하면 VAPID key를 발급 받을 수 있는데, 나는 발급된 키를 환경변수에 담아두고 사용했다.

🔻 VAPID key 발급

web-push generate-vapid-keys

다음으로는 PushSubscription 정보를 받아야 한다. PushSubscription은 아래와 같은 형식으로 구성되는데, 여기에는 알림을 받는 클라이언트의 endpoint 정보와 인증에 필요한 key 정보가 담겨있다.

🔻 PushSubscription

{
  "endpoint": "https://random-push-service.com/some-kind-of-unique-id-1234/v2/",
  "keys": {
    "p256dh": "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=",
    "auth": "tBHItJI5svbpez7KI4CCXg=="
  }
}

PushSubscription 을 받으려면 registration.pushManager.subscribe()를 호출하면 된다.

이 때 두 가지 옵션값을 설정할 수 있는데, 먼저 userVisibleOnly는 push 이벤트가 발생했을 때 사용자에게 알림을 보낼지 여부이다. 간혹 사용자에게 알리지 않고 백그라운드 작업이 필요한 경우 이 값을 false로 하면 된다.

다음으로 applicationServerKey는 푸시 알림을 보내는 애플리케이션을 식별하는 데에 사용되는 키다. 여기에는 앞서 발급받은 VAPID key의 public key를 담아주면 된다.

🔻 registration.pushManager.subscribe()

// PushSubscription을 가져오는 함수
async function getPushSubscription(): Promise<PushSubscription | null> {
  try {
    const registration = await navigator.serviceWorker.getRegistration();

    if (!registration) {
      console.error("ServiceWorkerRegistration을 찾을 수 없습니다.");
      return null;
    }

    if (!process.env.VAPID_PUBLIC_KEY) {
      console.error("VAPID Puplic key가 존재하지 않습니다.");
      return null;
    }

    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: process.env.VAPID_PUBLIC_KEY,
    });

    return subscription;
  } catch (e) {
    console.error(
      "PushSubscription을 가져오는 동안 오류가 발생했습니다: ",
      e,
    );
    return null;
  }
}

// 서버에 PushSubscription을 저장하는 함수
async function savePushSubscription(subscription: PushSubscription | null) {
  if (!subscription) {
    console.error("PushSubscription이 존재하지 않습니다.");
    return;
  }

  axios
    .post("/api/subscribe", {
    subscription,
  })
    .catch((e) => console.error(e));
}

🔻 서버에 Subscription 정보 저장하기 (/app/api/subscribe/route.ts)

PushSubscription을 받으면 추후에 알림을 보낼 때 사용할 수 있도록 서버에 저장을 해야 한다. 나는 Vercel의 Postgres를 사용해서 구축한 DB에 이 정보를 저장하도록 했다.

import { sql } from "@vercel/postgres";
import { SubscriptionInfo } from "@/app/pwa-todo/types";

export async function POST(req: Request) {
  const { subscription } = await req.json();
  const data = await sql`
        INSERT INTO pwa_subscription (subscription)
        VALUES (${subscription})
    `;
  return Response.json({ success: data.rowCount === 1 });
}

ServiceWorker 등록하기

푸시 알림 기능을 구현하려면 앱이 켜져있지 않을 때에도 백그라운드에서 알림이 필요한 상황을 파악할 수 있어야 한다. 이러한 백그라운드 작업을 수행하려면 Service Worker를 등록해야 한다.

Service Worker는 자신이 커버하는 범위 내에 있는 요청이 들어오면 일종의 네트워크 프록시처럼 그 요청을 가로채서 Service Worker에 등록된 작업들을 수행한다.

Service Worker가 커버하는 범위는 javascript 파일의 위치에 따라 결정된다. 파일이 루트에 위치하면 / 범위로 등록돼서 모든 url을 커버하게 되고, /sub-directory 디렉토리 안에 위치하면 /sub-directory와 맵핑되는 url을 커버하게 된다. 공식문서에는 Service Worker를 가능한 한 루트에 가깝게 등록하는 것이 권장된다고 적혀있다. 실제로 다른 디렉토리에 넣어서 작업하다가 undefined 에러를 몇 번 마주했다. 그래서 /public 디렉토리에 sw.js 파일을 둬서 모든 범위를 커버할 수 있도록 했다.

useEffect(() => {
  if ("serviceWorker" in navigator) {
    navigator.serviceWorker
      .register("/sw.js")
      .then((registration) => {
        console.log(
          "Service Worker registration successful with scope: ",
          registration.scope,
        );
      })
      .catch((err) =>
         console.error("Service Worker registration failed: ", err),
      );
  }
}, []);

push 이벤트 핸들러 등록하기

이제 Service Worker 등록을 마쳤으니, 서버로부터 푸시가 왔을 때 어떤 작업을 하면 될지 Service Worker에게 알려주면 된다.

웹 푸시가 이루어지는 과정은 아래 그림과 같은데, 서버로부터 온 메세지를 기기가 받으면 브라우저는 Service Worker를 깨우고, Push Event가 발생된다. 우리가 해야 할 일은 push 이벤트가 발생했을 때 알림을 띄울 수 있도록 하는 것이다.

/sw.js 파일에 아래와 같이 push 이벤트 핸들러를 작성해주었다. 여기에서 self는 Service Worker를 의미한다. push 이벤트 핸들러는 ServiceWorkerRegistrationshowNotification()을 호출해서 알림을 띄울 수 있도록 한다.

self.addEventListener("push", function (event) {
  const { title, message } = event.data.json();
  const options = {
    body: message,
    icon: "/app-icon/ios/192.png",
  };
  event.waitUntil(self.registration.showNotification(title, options));
});

푸시 알림 보내기

여기까지 오면 push 이벤트가 발생했을 때 알림을 띄울 수 있도록 하는 모든 작업이 마무리되었으니, 마지막으로 푸시 알림을 보내기만 하면 된다.

이를 위해 이전에 DB에 저장해두었던 클라이언트들의 구독 정보(PushSubscription)들을 조회해온다.

🔻 Subscription 정보 조회 API (/app/api/subscribe/route.ts)

import { sql } from "@vercel/postgres";
import { SubscriptionInfo } from "@/app/pwa-todo/types";

export async function GET() {
  const data = await sql<SubscriptionInfo>`
    SELECT *
    FROM pwa_subscription
    ORDER BY id
  `;
  return Response.json(data.rows);
}

다음으로 web-push 라이브러리의 sendNotification()를 호출하여 클라이언트로 푸시 알림을 보낼 수 있는 API를 만든다. sendNotification()을 호출할 때는 알림을 받을 클라이언트의 PushSubscription과, 알림창의 제목과 내용, VAPID keys를 전달해주면 된다.

🔻 푸시 알림 전송 API (/app/api/send-message/route.ts)

import webPush, { PushSubscription } from "web-push";

export async function POST(req: Request) {
  const { pushSubscription, title, message } = await req.json();
  const subscription = JSON.parse(pushSubscription) as PushSubscription;
  const payload = JSON.stringify({ title, message });

  if (
    !process.env.VAPID_SUBJECT ||
    !process.env.VAPID_PUBLIC_KEY ||
    !process.env.VAPID_PRIVATE_KEY
  ) {
    console.error("VAPID key 정보가 없습니다.");
    return Response.error();
  }

  const options = {
    vapidDetails: {
      subject: process.env.VAPID_SUBJECT,
      publicKey: process.env.VAPID_PUBLIC_KEY,
      privateKey: process.env.VAPID_PRIVATE_KEY,
    },
    TTL: 60,
  };

  try {
    const response = await webPush.sendNotification(
      subscription,
      payload,
      options,
    );
    return Response.json(response);
  } catch (error) {
    console.error("notification error", error);
    return Response.error();
  }
}

🔻 클라이언트 푸시 알림 전송 코드

  // 구독하고 있는 클라이언트들에게 Push 알림을 보내는 함수
  async function pushNotification() {
    const subscriptions = await axios
      .get("/api/subscribe")
      .then((response) => response.data);

    let promiseChain = Promise.resolve();

    for (let i = 0; i < subscriptions.length; i++) {
      const subscription = subscriptions[i];
      promiseChain = promiseChain.then(() => {
        return triggerPushMsg(
          subscription,
          "🔔 TODO",
          "오늘의 할 일 잊지 마세요!",
        );
      });
    }

    return promiseChain;
  }

  async function triggerPushMsg(
    pushSubscription: SubscriptionInfo,
    title: string,
    message: string,
  ) {
    await axios
      .post("/api/send-message", {
        pushSubscription: pushSubscription.subscription,
        title,
        message,
      })
      .catch((e) => console.error(e));
  }

👀 결과물

그러면 아래와 같이 홈 화면에 앱을 추가하고 알림을 받는 것이 가능하다.

PWA의 아쉬운 점은 설치 과정이 직관적이지 않은 점인 것 같다. 최소한 설치하기 버튼을 눌렀을 때 설치가 되게 할 수 있으면 좋을 것 같은데, iOS의 경우 반드시 사파리로 들어가서 공유하기 버튼을 누른 뒤, 홈 화면에 추가하기 버튼을 눌러야 설치를 할 수 있다. 아무래도 네이티브 언어로 개발한 앱에 비해서는 제약이 있는 것 같다.

그럼에도 불구하고 앱 스토어에 배포하는 과정 없이 모바일 앱과 유사한 경험을 제공할 수 있고, 데스크톱과 모바일에서 모두 사용 가능한 사이트를 만들 수 있다는 것은 큰 장점인 것 같다.

0개의 댓글