[React] 공유하기 기능 완벽하게 구현하기 (Web Share API)

21

Web

목록 보기
1/5

얼마 전, 동아리에서 사이드 프로젝트를 진행하는데 공유하기 기능을 구현해야 했었다.
Web Share API도 이미 있고, 매우 간단할거라 생각했으나 그것은 나의 오산이었다....
본 포스팅에서는 공유하기 기능의 기본 개념내게 있었던 이슈, 그리고 그 해결 방안을 풀어보고자 한다.

💡 이제 막 첫 구현을 하려는 분보다는, 구현을 하면서 생각지 못한 예외 케이스에 직면하신 분이나 다른 사람은 코드를 어떻게 짰는지 궁금하신 분들이 읽으면 훨씬 더 도움이 될 것 같다.

[기본 개념]

1. 개요

공유하기 기능이란, 특정 페이지 또는 data를 누군가에게 전달할 수 있게 만들어주는 기능이다.
보통은 모바일 기기에서 공유하기를 실행시키면 다음과 같은 기본 팝업 화면이 뜬다.

웹 페이지에서는 보통 클립보드에 복사하게끔 만든다.
이런 기능을 사용하기 위해 web share API를 사용한다.

2. Web Share API

그렇다면 web share API란 뭘까?
Web Share Api는 MDN에서 만든 공유 기능을 제공하는 API이다.
이를 통해 각각의 네이티브한 공유 기능, 즉 안드로이드 기기/iOS 기기/데스크탑에 따른 기본 공유하기 팝업창을 띄울 수 있다. Web Share API에서 제공하는 메소드는 총 2가지로, 다음과 같다.

1) navigator.canShare()

지정된 데이터가 공유 가능한지 여부를 나타내는 boolean 값을 반환한다.

2) navigator.share()

Promise전달된 데이터가 공유 대상으로 성공적으로 전송되었는지 확인하는 결과를 반환한다.
navigator.share() 메소드는 각 디바이스의 네이티브한 공유하기 기능을 작동시킨다.

더 자세한 내용이 궁금하다면 MDN 공식문서에서 확인할 수 있다.

3. 코드 구현

위 내용을 참고해 내가 처음으로 작성한 기본 코드는 다음과 같다.

share.ts

const isShareSupported = () => navigator.share ?? false;

type Data = {
  url: string;
  text: string;
  title: string;
  files?: File[];
};

/**
 * 인자로 받은 data를 OS 기본옵션으로 공유합니다.
 * 기본 공유옵션이 지원되지 않을 경우, url만을 클립보드에 링크를 복사하는 기능으로 대체됩니다.
 *
 * @param data 공유할 data 객체
 * @param data.url 공유될 또는 클립복드에 복사될 url
 * @param data.text 공유시 해당 메신저에 추가적인 텍스트로 전달되는 문구
 * @param data.title 공유시 썸네일에 제공되는 타이틀 문구
 * @param data.files 공유할 file 리스트
 *
 * @example
 * ```ts
 * const result = await share('data');
 * if (result) {
 *   console.log('공유 성공');
 * } else {
 *   console.log('공유 실패');
 * }
 * ```
 */

export const share = (data: Data) => {
  return new Promise<boolean>(
    async (resolve) => {
      if (isShareSupported()) {
        await navigator.share(data);
        resolve(true);
        return;
      }
      resolve(false);
    }
  );
};

export default share;

js Doc으로 이미 작성했으니, 사용 코드는 굳이 기재하지 않겠다.
navigator.canShare() API가 있을 시, 공유하기 기능을 실행하고 아니라면 실행을 못한다는 문구만 띄워주면 되지 않을까 싶었다. _안되는 환경에서는 알아서 클립보드 복사 되는줄... 안되는 환경이면 false값이 제대로 올줄... 착각했더랍죠^^.

[겪은 이슈]

먼저, 위와 같이 코드를 작성했을때 내가 겪었던 이슈들은 크게 4가지로, 다음과 같다.

1. 디버깅(로컬) 환경에서 아예 동작하지 않는다. (= 웹에서 동작하지 않는다.)

dev모드로 로컬을 돌려 웹에서 테스트를 하는데, 아무런 반응이 없었다. 성공 또는 실패도 없고 그냥 아무런 동작이 되지 않았다.

2. 공유하기 실패 시,alert 처리를 해주었음에도 불구하고 아무런 반응이 없다.

특정 상황에서 공유하기 기능이 성공하는 것을 확인했는데, 공유가 불가능한 상황에서는 아무런 동작을 하지 않았다. 내가 위에 작성한 코드를 보면, share 함수가 false를 반환하면 "공유 실패"라는 alert 창이 떠야하는데, 아무런 반응이 없었다. 에러가 난 것도 아니고, 그냥 아무런 반응이 없다.

3. 안드로이드 기기에서 navigator.share()가 동작하지 않는다.

안드로이드 기기에서 크롬으로 사이트 내 공유하기 버튼을 눌러도 역시 아무런 반응이 없었다.

4. 안드로이드 인앱 브라우저에서 copyToClipBoard()가 동작하지 않는다.

안드로이드 기기의 웹 브라우저에서 정상 동작하는 것까지 확인을 했는데, 카카오톡이나 페이스북 메신저 등의 인앱 브라우저에서는 또 먹통이었다.

이 외에도 클립보드에 복사한 url 전송 시 Meta tag가 적용되지 않는다거나 하는 자잘한 이슈들이 몇 개 더 있었다.

나와 같은 방법으로 Web Share API만 사용했다면, 위와 같은 버그는 필연적으로 존재한다.

지금부터는 이에 대한 해결 과정의 서사를 풀어보겠다.

[해결 방안]

1. https 환경인지 확인한다.

이는 공공연하게 알려진 문제라 쉽게 해결할 수 있었다.
공유하기 기능(Web Share API)은 https:// 환경에서만 지원 된다. 즉, http:// 인 환경에서는, 공유하기 기능이 실행 되지 않는다. 그래서 난 배포를 해가며 테스팅을 했다...(^^..ㅜㅜ)
+) 대신, 이후에 나올 copyToClipBoard 기능은 로컬이나 웹에서도 테스트가 가능하다.

2. share API를 지원하지 않을 시, Clipboard API를 사용하자.

공유하기 기능을 지원하지 않는 경우, 클립보드에 복사하는 기능을 추가해준다. 안드로이드 기기는 share를 지원하지 않는다.. 그럼 반만 되는거 아니냐고요^^ 암튼..

옛날에는 document.execCommand()를 사용했다는데, 이것이 deprecated 되었고 이를 대체하기 위해서 MDN에서 Clipboard API를 만들었다고 한다. 그래서 난 당연히 이 API를 사용했다.
자세한 내용이 궁금하다면 공식문서를 참고하자.


호환도 잘 되는 것 같길래 실행부에 다음과 같이 추가를 해주었다.

else if (navigator.clipboard) {
    navigator.clipboard
    .writeText(`${BASE_URL}${router.pathname}`)
    .then(() => alert("링크가 클립보드에 복사되었습니다."));
  } else {
    alert("공유하기가 지원되지 않는 환경 입니다.");
}

당연히 잘 되는 줄 알았다 ^^ 하지만 안드로이드에서 여전히 동작하지 않았다!!!!!!!!!!!!!

3. Clipboard API 지원하지 않을 시, document.execCommand를 사용하자.

2번을 했음에도 불구하고 여전히 먹통인 상황은 나를 화나게 했다. 다음과 같은 에러도 방방 떴다.

navigator.clipboard.writetext notallowederror
navigator.clipboard is undefined
navigator.clipboard.writetext document is not focused

구글링해보니 엄청나게 많은 사람들이 나와 비슷한 고통을 겪고 있었고.. 여러 방법을 써봤지만 원론적인 해결방안은 없었다. 아~ 원래 있었는데? 아니 없어요 그냥.

결국 내가 찾은 해답은 document.execCommand()로 예외처리를 해주어야한다 였다... 난 deprecated된 기능은 사용하면 안된다고 생각했는데, 예외 상황을 대응하기 위해서는 모두 사용한다고 하더랍죠.. 전 몰랐죠.. 네.. 이제라도 알았으니 다행이다 ^^

완성된 코드는 다음과 같다^^

index.ts

  const handleShare = async () => {
    const result = await share(dataToShare);
    if (result === "copiedToClipboard") {
      alert("링크를 클립보드에 복사했습니다.");
    } else if (result === "failed") {
      alert("공유하기가 지원되지 않는 환경입니다.");
    }
  };

share가 되는 환경이면 팝업창이 뜨기 때문에 따로 alert 메세지가 필요 없고, 나머지의 경우 결과를 알려주기 위한 alert 창을 띄워주었다.

copyToClipBoard.ts

const getDummyTextarea = () => {
  const textarea = document.createElement("textarea") as HTMLTextAreaElement;
  textarea.style.top = "0";
  textarea.style.left = "0";
  textarea.style.display = "fixed";

  return textarea;
};

export const isClipboardSupported = () => navigator?.clipboard != null;
export const isClipboardCommandSupported = () =>
  document.queryCommandSupported?.("copy") ?? false;

/**
 * 인자로 받은 텍스트를 클립보드에 복사합니다.
 * @param text 복사할 텍스트
 *
 * @example
 * ```ts
 * const result = await copyToClipboard('하이');
 * if (result) {
 *   console.log('클립보드에 복사 성공');
 * } else {
 *   console.log('클립보드에 복사 실패');
 * }
 * ```
 */
export const copyToClipboard = (text: string) => {
  return new Promise<boolean>(async (resolve) => {
    const rootElement = document.body;

    // if (isClipboardSupported()) {
    //   await navigator.clipboard.writeText(text);
    //   resolve(true);
    //   return;
    // }

    if (isClipboardCommandSupported()) {
      const textarea = getDummyTextarea();
      textarea.value = text;

      rootElement.appendChild(textarea);

      textarea.focus();
      textarea.select();

      document.execCommand("copy");
      rootElement.removeChild(textarea);
      resolve(true);
      return;
    }

    resolve(false);
    return;
  });
};

export default copyToClipboard;

위와 같이 더미 textarea를 만들어 복사하게 만들어주었다. 주석친 부분은, 동작을 하는 상황도 있고 안하는 상황도 있어서(예외 케이스 매우 킹받음) 막아두었다. isClipboardSupported 자체가 지원이 안되면 중간에 막히는 것 같다. 사용했을때 될 때도 있고 안될때도 있어서 불안정한 것 같아 제외했다! 이것도 매우 긴 과정이 있었으나.. 포스팅이 쓸데없이 길어지는 것 같아 생략했다.

share.ts

import copyToClipboard from "./copyToClipboard";

export const isShareSupported = () => navigator.share ?? false;

/**
 * 인자로 받은 data를 OS 기본옵션으로 공유합니다.
 * 기본 공유옵션이 지원되지 않을 경우, url만을 클립보드에 링크를 복사하는 기능으로 대체됩니다.
 *
 * @param data 공유할 data 객체
 * @param data.url 공유될 또는 클립복드에 복사될 url
 * @param data.text 공유시 해당 메신저에 추가적인 텍스트로 전달되는 문구
 * @param data.title 공유시 썸네일에 제공되는 타이틀 문구
 * @param data.files 공유할 file 리스트
 *
 * @example
 * ```ts
 * const result = await share('data');
 * if (result === 'share') {
 *   console.log('공유 성공');
 * } else if (result === 'clipboard') {
 *   console.log('클립보드 복사 성공');
 * } else {
 *   console.log('공유 실패');
 * }
 * ```
 */

export const share = (data: ShareData) => {
  return new Promise<"shared" | "copiedToClipboard" | "failed">(
    async (resolve) => {
      if (isShareSupported()) {
        await navigator.share(data);
        resolve("shared");
        return "shared";
      }

      if (data.url) {
        const result = await copyToClipboard(data.url);

        if (result) {
          resolve("copiedToClipboard");
          return "copiedToClipboard";
        }
      }
      resolve("failed");
      return "failed";
    }
  );
};

export default share;

💡 Tips

1. 안드로이드 웹 뷰 안에 적용하기

안드로이드 웹 뷰일때는 안드로이드에서 메소드를 전달받아 실행시켜주자.
userAgent로 안드로이드 기기인지 확인해, 안드로이드 개발자님들께서 보내주신 메소드를 받아 다음과 같이 적용하면 된다.

 const handleShare = async () => {
    if (androidWebView) {
      nativeShare(
        { url: `${BASE_URL}/result/shared/${drinkId}` },
        function (result_cd: any, result_msg: any, extra: any) {
          console.log(result_cd + result_msg + JSON.stringify(extra));
        }
      );
    } else {
      const result = await share(dataToShare);
      if (result === "copiedToClipboard") {
        alert("링크를 클립보드에 복사했습니다.");
      } else if (result === "failed") {
        alert("공유하기가 지원되지 않는 환경입니다.");
      }
    }
  };

2. typescript를 사용할 때, data Type을 직접 만들기 보다는 ShareData를 활용하자.

일부러 내가 초기 코드에는 내가 직접 만든 data Interface를 사용하는 코드를 올렸는데, 이럴 필요 없이 기본적으로 제공되는 shareData로 통일할 수 있다. 훨씬 안전하고 명시적임!

3. meata data가 안나온다면 해당 페이지에 따로 적용해주자.

공유하고자 하는 index의 scipt에 meta data를 따로 넣어주면 사용자가 링크를 공유했을 시 원하는 썸네일과 문구를 보여줄 수 있다!

<Head>
    <meta
      property="og:title"
      content="매력적인 술꾼! 당신의 술 취향을 증명할 수 있도록 초대장이 도착했어요."
      property="og:image"
      content="https://zuzu-resource.s3.ap-northeast-2.amazonaws.com/proof_logo.png" />
</Head>

(추후에 내용 추가 예정)

자세한 코드가 궁금하시다면...

해당 사이드 프로젝트 깃헙 PR에 들어가보시면 확인하실 수 있다요^^

글을 마치며,,,

굉장히 길고 복잡한 과정과 상황이 있었는데 시간이 지나고 나서 정리하려니, 빼먹은 부분도 많고 과정이 세세하게 정리되지 않은 것 같아서 너무 아쉽다 ㅠㅠ 하지만 지금이라도 정리하는 것이,, 안하는 것 보다 나을 것이고,, 암튼 읽는 분들에게 조금이라도 도움이 되었으면 좋겠다! ^^

profile
𝙸 𝚊𝚖 𝚊 𝗙𝗘 𝚍𝚎𝚟𝚎𝚕𝚘𝚙𝚎𝚛 𝚠𝚑𝚘 𝚕𝚘𝚟𝚎𝚜 𝗼𝘁𝘁𝗲𝗿. 🦦💛

2개의 댓글

comment-user-thumbnail
2023년 10월 11일

적용하고 있는 프로젝트에 정말 잘 활용했습니다. 덕분에 이틀 정도의 삽집을 안할 수 있게 되었네요. 정말 감사드립니다. :)

1개의 답글