본문 바로가기

개발/React

PWA환경에서 유저 친화적으로 앱 다운로드 안내하기

반응형

출처 : https://suncommander.medium.com/how-to-show-pwa-install-banner-add-to-homescreen-for-your-website-b1fbe6ebfdb5

 

서론

 

현업에서 파일럿프로젝트로 진행하는 프로젝트가 PWA로 되어있었는데, 이를 넘겨받아 디벨롭하는 과정에서 사용하는 유저들에게 더 좋은 경험을 제공하고자 [홈 화면에 추가] 를 통한 바로가기 앱 다운로드를 안내하는 과정을 정리하려고 한다.

 

 

 

 

 

본문

 

먼저 PWA의 앱 다운로드 방법을 설명하기에 앞서 PWA가 무엇인지 간단하게 알아보고 가자.

 

PWA(Progressive Web Apps)는 웹 애플리케이션과 네이티브 애플리케이션의 장점을 결합한 기술입니다. PWA는 웹 기술(HTML, CSS, JavaScript)을 사용하여 개발되며, 네이티브 애플리케이션과 같은 사용자 경험을 제공하는 것이 목표입니다. 

 

 

한마디로 정리하자면 PWA는 웹으로 이루어졌지만 앱과 유사한 환경을 제공해주는 방식이다.

 

그렇다면 왜 이러한 PWA를 두고도 앱을 활용하는 것일까 ?

 

현재 PWA는 웹을 앱과 유사한 환경처럼 보여주는데, 이는 단지 브라우저에서의 주소표시창과 같은 고유 인터페이스를 제거해준다는 점에서 앱과 유사한 형태를 보이는 것이고, 실제로 AppLike한 UI 애니메이션과 같은 부분은 웹 환경이며 아직은 지원해주는 범위가 너무 좁다.

 

따라서 웹 환경으로 만든 프로젝트를 앱으로 감쌀 리소스는 없으나, 이를 유저가 항상 모바일 디바이스에서도 브라우저를 통해 찾아 들어와야하는 불편함을 덜어 앱과 같이 아이콘을 통해 들어올 수 있도록 하는 점에서 유효하다고 볼 수 있다.

 

그렇다면 바로 어떤 방식을 통해 PWA환경일 경우 웹사이트를 앱 형태로 다운로드 받을 수 있도록 할 수 있는지 알아보자

 

이에 대한 핸들러는 기본적으로 웹에서 제공하고있는데, 이는 이벤트리스너로부터 받아올 수 있다.

 

해당 이벤트는 BeforeInstalPrompt Event 이다.

 

하지만 PWA는 위에서 언급한 바와 같이 아직 브라우저에서도 잘 지원하지 않는 기능이다. 실제로도 앱을 다운로드 하는 명령어는 iOS에서만 제공하고, AOS환경에서는 해당 메서드를 제공하지 않는다.

 

여기서 앱 다운로드라고 표현하는 것은 [홈 화면에 추가] 를 통해 해당 웹 페이지에 대한 바로가기를 한번의 동작으로 생성할 수 있도록 하는 방식이다. AOS에서는 이러한 명령어를 제공하지 않을 뿐 수동으로 브라우저 메뉴에서 [홈 화면에 추가] 를 누르면 동일한 동작이 가능하다.

 

공식 문서에서도 실험적 기능이기에 브라우저 호환성을 잘 확인하라고 안내해준다.

 

 

 

 

이러한 이벤트를 통해 핸들러를 받아올 수 있다는 점을 알았기 때문에 이에 대한 코드를 간단히 살펴보자.

 

 

 

https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent

 

 

 

공식문서에서 제공해주는 코드를 간단히 살펴보면 beforeinstallprompt 이벤트를 listen하고, 이때 받아온 이벤트를 특정 변수에 담아주는 형태로 이에 대한 핸들러를 활용하는 것을 알 수 있다.

 

로직에 대해 본격적으로 알아보기 전 한가지만 더 알아보자면, 결국 iOS에서는 해당 명령어를 사용할 수 있도록 하고 AOS에서는 해당 명령어를 사용할 수 없다는 것은 곧 이러한 이벤트로부터 받은 인스턴스를 사용할 수 있는지 없는지에 대한 여부이며, 이는 결국 해당 이벤트 리스닝으로부터 iOS는 이벤트 객체를 받지만, AOS는 이벤트 객체를 받지 못한다는 것이다.

 

 

또한 이러한 beforeinstallprompt 이벤트 리스닝은 페이지가 load될때 실행된다고 한다.

 

재미있는 점은 이를 [이 이벤트가 발생하는 시기는 보장되어 있지 않지만 일반적으로 페이지 로드 시 발생합니다.] 라고 적어둔 것이다. 반드시 실행되는 것은 아닐 수 있으니 주의하라는 의미로 보인다.

 

https://developer.mozilla.org/en-US/d ocs/Web/API/Window/beforeinstallprompt_event

 

 

 

이제 배경설명은 여기까지하고 우리는 대부분 React 환경에서 이를 사용하는 일이 더 많을 것이기에 이를 React에서 사용하는 코드로 바꿔보겠다.

 

 

import { useState } from 'react'

type PromptResponse = Promise<{
  outcome: 'accepted' | 'dismissed';
  platform: string;
}>;

interface BeforeInstallPromptEvent extends Event {
  readonly platforms: string[];
  readonly userChoice: PromptResponse;
  prompt(): PromptResponse;
}

const isBeforeInstallPromptEvent = (e: Event): e is BeforeInstallPromptEvent => {
  return 'platforms' in e && 'userChoice' in e && 'prompt' in e;
};

const useCheckAddToHomeAvailable = () => {
  const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);

  useEffect(() => {
    const handler = (e: Event) => {
      if (isBeforeInstallPromptEvent(e)) {
        e.preventDefault();
        setDeferredPrompt(e);
      }
    };

    window.addEventListener('beforeinstallprompt', handler);

    return () => {
      window.removeEventListener('beforeinstallprompt', handler);
    };
  }, []);
};

export default useCheckAddToHomeAvailable;

 

 

위의 코드를 타입을 제외하고 하나씩 살펴보자.

 

const isBeforeInstallPromptEvent = (e: Event): e is BeforeInstallPromptEvent => {
  return 'platforms' in e && 'userChoice' in e && 'prompt' in e;
};

 

 

이는 인자로 Event 객체가 들어오는 상황에서 BeforeInstallPrompt에 대한 이벤트임을 체크하는 타입가드함수이다.

 

해당 함수를 활용하면 beforeinstallprompt 이벤트로부터 이벤트 객체가 넘어올 때, 해당 조건에 맞는다면 이를 BeforeInstallPrompt Instance 타입으로 인지시킬 수 있다.

 

 

const useCheckAddToHomeAvailable = () => {
  const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);

  useEffect(() => {
    const handler = (e: Event) => {
      if (isBeforeInstallPromptEvent(e)) {
        e.preventDefault();
        setDeferredPrompt(e);
      }
    };

    window.addEventListener('beforeinstallprompt', handler);

    return () => {
      window.removeEventListener('beforeinstallprompt', handler);
    };
  }, []);
};

export default useCheckAddToHomeAvailable;

 

 

이 코드는 위의 공식문서에 첨부된 코드를 React화 한 부분이라고 볼 수 있다.

 

eventListener를 활용하여 해당 이벤트를 listen하고 해당 이벤트로부터 넘어온 이벤트 객체를 직전에 설명한 타입가드를 통해 검증하여 검증된 beforeinstallprompt instance를 deferredPrompt에 set해주는 것이다.

 

 

이제 위에서 지나쳤던 BeforeInstallPrompt Instance의 인터페이스를 살펴보자.

 

// https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent#instance_properties

type PromptResponse = Promise<{
  outcome: 'accepted' | 'dismissed';
  platform: string;
}>;

interface BeforeInstallPromptEvent extends Event {
  readonly platforms: string[];
  readonly userChoice: PromptResponse;
  prompt(): PromptResponse;
}

 

 

각 속성과 메서드를 하나씩 살펴보자

 

  • platforms : 이벤트가 발송된 플랫폼이 포함된 문자열 항목의 배열을 반환한다. 예를 들어 사용자가 웹 버전이나 안드로이드 버전 중에서 선택할 수 있는 "web" 또는 "play"와 같은 버전을 사용자에게 제공하려는 사용자 에이전트를 위해 제공된다.
  • userChoice : 사용자가 앱을 설치하라는 메시지를 받았을 때 사용자의 선택을 설명하는 객체로 resolve되는 Promise를 반환한다.
  • prompt : 사용자에게 앱 설치 여부를 묻는 메시지를 표시한다. 이 메서드는 앱을 설치하라는 메시지를 받았을 때 사용자의 선택 사항을 설명하는 객체로 resolve되는 Promise를 반환한다.

 

내용을 잘 살펴보면 중요한 부분은 userChoiceprompt이다.

 

prompt는 네이티브 모달을 통해 해당 웹 페이지의 [홈 화면에 추가] 를 바로 수행할지에 대한 실행, 취소를 묻는 프롬프트를 실행하는 메서드이다. 이는 Promise를 반환하는데, 이때 해당 Promise로부터 유저가 실행을 눌렀는지 취소를 눌렀는지는 outcome으로부터 알 수 있다.

 

userChoice는 위에서 prompt로부터 반환받은 Promise와 같은 역할을 하는데 내부적으로 prompt에 대한 실행 결과가 자동으로 담기는 것으로 보인다. 이 역시도 Promise를 반환하며 이로부터 유저가 실행을 눌렀는지 취소를 눌렀는지는 outcome으로부터 알 수 있다.

 

 

 

그렇다면 이제 이를 활용하여 install 할 수 있도록 하는 로직을 만들어보자.

 

 

const installApp = async () => {
  if (!deferredPrompt) return;

  const { outcome } = await deferredPrompt.prompt();

  if (outcome === 'accepted') {
    clearPrompt();
  }
});

const clearPrompt = usePreservedCallback(() => {
  setDeferredPrompt(null);
}

 

 

일단 deferredPrompt에 해당 이벤트객체가 담기지 않았다면 실행할 수 없기때문에 이에 대한 Validation을 간단하게 수행한다.

 

이후 위에서 언급한 바와 같이 BeforeInstallPrompt Instance에 존재하는 prompt 메서드를 실행하여 설치에 대한 모달을 띄우고, 이에 대한 유저의 응답을 기다린다.

 

그렇게 받아온 Promise의 반환값 중 outcome이 accpted라면 바로 앱 다운로드가 실행되어 우리는 사용한 deferredPrompt를 초기화해주면 된다.

 

실제로 공식문서에서도 BeforeInstallPrompt Instance는 한번 사용 후 초기화를 해주어야한다며 페이지 리로드없이는 일회성 Instance로 사용해야한다고 되어있다.

 

또한 BeforeInstallPrompt 이벤트로부터 값을 받아오기 위해서는 HTTPS 환경이어야한다는 점도 테스트를 어렵게 하기때문에 유의해야한다.

 

 

이렇게 만든 로직은 정상적으로 동작하지만 아직 한가지 더 확인해야할 것이 있다.

 

prompt로부터 실행을 누르게 되면 내부적으로 앱을 설치해주는데, 이때 어떤 앱으로 만들지에 대한 기본 정보들이 있어야 앱을 만들 수 있다. 이를 제공하기 위해 manifest.json을 제대로 작성해주어야한다.

 

이에 대한 상세한 설명은 길어질 수 있기때문에 아래의 링크로 대체한다.

https://web.dev/articles/add-manifest?hl=ko#create

 

 


이제 이를 바탕으로 실제로 유저에게 어떤 방식으로 이런 플로우를 수행하도록 할지 생각해보자

 

적용

 

먼저 확인해야할 부분은 iOS환경인지 AOS환경인지에 대한 부분이나, 이는 결국 BeforeInstallPrompt 이벤트로부터 반환받는 Event 객체가 존재하는지에 대한 여부로 두 환경을 체크할 수 있기 때문에 이 부분은 해결되었다.

 

그 다음은 두가지가 있는데,

 

첫번째는 앱 내 웹 브라우저를 사용하다보니 다운로드 후 웹 브라우저는 백그라운드에 두고 앱으로 이동한 뒤 다시 웹으로 돌아오는 경우도 있을 것이다. 이러한 상황에서는 페이지 리로드를 수행하지 않은 상태에서 앱을 설치하고 다시 웹으로 돌아오기때문에 앱이 설치되어있는지에 대한 여부를 정확히 파악하고, 이에 맞는 UI를 제공해야한다.

 

예를 들면 [앱 다운로드] 버튼이 있었다고 한다면, 앱 다운로드 여부에 따라서 해당 버튼은 노출되지 않아야하기 때문이다.

 

두번째설치된 앱으로 진입하는 경우이다. 설치된 앱으로 진입한 경우에는 이미 앱으로 진입했기떄문에 위와 동일하게 [앱 다운로드] 와 같은 버튼은 제공하지 않아야한다.

 

 

 

그렇다면 위의 두가지 케이스를 체크하는 로직을 만들어보자.

 

첫번째 케이스부터 구현해보면 확인해야하는 포인트는 두가지이다. 

 

  1. 앱이 설치되어있는지 확인하는 로직
  2. 이를 백그라운드 이동 및 재진입했을때에도 갱신할 수 있는 로직

 

이는 이렇게 정리할 수 있는데 하나씩 확인해보자.

 

먼저 확인해야하는 로직은 앱이 설치되어있는지 확인하는 로직이다. 다행히 이에 대해서는 판별할 수 있도록 웹에서 메서드를 제공해주고 있다.

 

https://web.dev/articles/get-installed-related-apps?hl=ko

 

 

 

앱이 설치되었나요? getInstalledRelatedApps()로 알 수 있습니다.  |  Articles  |  web.dev

getInstalledRelatedApps() API는 iOS/Android/데스크톱 앱 또는 PWA가 사용자 기기에 설치되어 있는지 확인할 수있는 웹 플랫폼 API입니다.

web.dev

 

 

위의 글에서 필요한 부분만 정리해보자면 manifest.json 에 앱에 대한 related_application 값을 정리해서 작성해주어야하고, 이렇게 작성된 상태에서 앱이 설치되어있다면 navigator객체의 getInstalledRelatedApps 메서드를 통해 설치된 앱에 대한 Array를 받아올 수 있다고 한다.

 

코드로 정리해보면 아래와 같다.

 

// manifest.json

"related_applications": [{
  "platform": "webapp",
  "url": "https://globalbunjang.com/manifest.json"
}]

 

 

getInstalledRelatedApps 메서드를 사용할때에는 Typescript환경에서는 타입선언이 안되어있기 때문에 별도의 Global 타입선언도 필요하다.

 

interface RelatedApplication {
  platform: string;
  url: string;
}

declare global {
  interface Navigator {
    getInstalledRelatedApps: () => Promise<RelatedApplication[]>;
  }
}

const [appInstalled, setAppInstalled] = useState(false);

const checkAppInstalled = async () => {
  try {
    const relatedApps = await navigator.getInstalledRelatedApps();
    const isInstalled = relatedApps.length > 0;

    setAppInstalled(isInstalled);
  } catch (error) {
    setAppInstalled(false);
  }
};

 

 

 

위와 같이 navigator의 getInstalledRelatedApp를 활용해서 manifest.json에 작성해준 정보를 가진 앱이 다운로드되어있는지를 확인해줄 수 있다.

 

이제는 두번째 로직이 필요하다. 웹 사이트내에서 앱을 다운로드 했을 때 이를 백그라운드 이동 및 재진입했을때에도 다운로드 여부를 갱신할 수 있는 로직이다. 위에서 정리한 로직은 당연하게도 필요한 시점에서만 호출할 수 있도록 만들어져있다. 그렇다면 이에 대해서는 어떻게 백그라운드 이동 및 재진입을 감지할 수 있을까?

 

이는 브라우저의 visibilityChange 이벤트를 활용하면 가능하다.

 

https://developer.mozilla.org/ko/docs/Web/API/Document/visibilitychange_event

 

 

우리는 React 환경에서 이를 활용하고자하기때문에 간단히 재사용이 가능한 훅으로 만들 수 있다.

 

import { useEffect } from 'react';

import { noop } from 'lodash-es';
import { usePreservedCallback } from './usePreservedCallback';

interface UseVisibilityChangeProps {
  onShow?: (event: Event) => void;
  onHide?: (event: Event) => void;
}

const useVisibilityChange = ({ onShow = noop, onHide = noop }: UseVisibilityChangeProps) => {
  const memoizedCallback = usePreservedCallback((event: Event) => {
    const isVisible = document.visibilityState === 'visible';
    const callbackAction = isVisible ? onShow : onHide;

    callbackAction(event);
  });

  useEffect(() => {
    document.addEventListener('visibilitychange', memoizedCallback);

    return () => {
      document.removeEventListener('visibilitychange', memoizedCallback);
    };
  }, [memoizedCallback]);
};

export default useVisibilityChange;

 

 

여기서 usePreservedCallback은 함수가 인자로 들어오는 경우 useCallback의 의존성 배열로 넣게된다면 함수는 객체로 판별이 되어 매번 다른 값으로 인지되어 함수를 새로 생성할 수 있기때문에 이를 Freeze해주는 트릭이기에 해당 글의 주제와 맞지 않아 이렇게 간단한 설명만 하고 상세한 코드는 첨부하지 않겠다.

 

위와 같이 visibilityChange에 대한 이벤트를 감지할 수 있는 훅을 만들었다면 이를 직전에 만든 앱 설치여부 판별 로직과 같이 사용하면 된다. 간단하게 훅으로 만들어 사용할 수 있겠다.

 

import { useState } from 'react';
import useVisibilityChange from './useVisibilityChange';

const useCheckAppInstalled = () => {
  const [appInstalled, setAppInstalled] = useState(false);

  const checkAppInstalled = async () => {
    try {
      const relatedApps = await navigator.getInstalledRelatedApps();
      const isInstalled = relatedApps.length > 0;

      setAppInstalled(isInstalled);
    } catch (error) {
      setAppInstalled(false);
    }
  };

  useVisibilityChange({
    onShow: checkAppInstalled,
  });

  return appInstalled;
};

export default useCheckAppInstalled;

 

 

 

이야기가 길어졌지만 빠르게 두번째 체크조건이었던 설치된 앱으로 진입하는 경우도 확인해보자.

 

이 역시도 manifest.json에 PWA를 만들기 위한 플래그 중 하나인 display 값을 확인하면 체크할 수 있다.

 

먼저 manifest.json에 해당 프로덕트를 PWA로 만들기 위해 display값을 standalone으로 주고, 이렇게 설정된 프로덕트를 다운받았다면 해당 앱으로 진입 시에는 media query를 통해 해당 값을 확인할 수 있다. 이 역시도 활용하기 용이하도록 훅으로 만들어보자.

 

import { useEffect, useState } from 'react';

const useCheckUsingApp = () => {
  const [isUsingApp, setIsUsingApp] = useState(false);

  useEffect(() => {
    if (window.matchMedia('(display-mode: standalone)').matches) {
      setIsUsingApp(true);
    }
  }, []);

  return { isUsingApp };
};

export default useCheckUsingApp;

 

 

위와 같이 display-mode 값이 standalone인 경우를 확인하면 설치된 앱으로 진입하는 경우까지도 체크할 수 있다.

 

 

이제 모든 준비가 완료되었다. 실제 적용하는 코드를 확인해보자

 

const AppDownloadSection = () => {
  const deferredPrompt = useAtomValue(deferredPromptAtom);

  const { install } = useInstallApp();
  const { isUsingApp } = useCheckUsingApp();
  const appInstalled = useCheckAppInstalled();

  const handleDownloadClick = () => {
    if (deferredPrompt) {
      install();
    } else {
      setIsShowBottomSheet(true); // AOS에서는 별도의 앱 다운로드 안내가 필요
    }
  };

  if (isUsingApp || appInstalled) return null;

  return (
    <div>
      <Button onClick={handleDownloadClick}>Download Now</Button>
    </div>
  );
};

 

 

전반적으로는 위에서 살펴본 훅과 상태를 기반으로 구성되어있기 때문에 간단히만 살펴보자.

 

먼저 해당 다운로드 섹션 자체는 설치된 앱으로 진입한 상태거나 앱이 다운로드되어있다면 보여주지 않도록 한다.

 

위의 조건에서 벗어나 다운로드 버튼이 보여지는 상태라면, iOS에서는 deferredPrompt값이 존재하기때문에 그대로 install을 수행, 해당 값이 없는 AOS환경이라면 유저에게 직접 해당 웹 사이트에 대한 바로가기를 [홈 화면에 추가] 할 수 있도록 브라우저 상에서의 플로우를 직접 안내하는 방법이 최선이다.

 

 

 

 

정리

 

기존에는 PWA에 대해서 막연하게만 알고있었는데 이번에 현업에서 PWA로 구성된 프로젝트를 넘겨받아 이를 디벨롭하는 과정에서 누락되어있던 [앱 다운로드] 기능을 구현하게 되었는데, 유저 경험을 관점으로 생각해보면 고려할 부분이 많은데 이에 대해 제대로 정리된 글이 없어 한번 정리하게 되었다. 하지만 작업하면서 느낀점은 역시 할수만 있다면 PWA보다는 앱으로 래핑하여 사용하는 것이 다방면에서 더 좋은 방법으로 보인다.

반응형