이번 글에서는 최근 학습 겸 재미 삼아 만들어본 날아다니는 밈의 제작 과정을 살펴보면서 크롬 확장 프로그램 제작 및 등록 방법을 알아보겠습니다. 본인의 프로그램을 제작하실 때 가이드라인 정도로 봐주시기 바랍니다. (※ 주의 : 저의 앱은 쓸모 있는 용도가 아니라 학습을 목적으로 만든 것입니다!)

 

날아다니는 밈

날아다니는 밈 재생기입니다.

chrome.google.com

시작에 앞서 : 꼭 해봐야만 할까?

크롬 확장 프로그램을 만들어보는 것은 선택 사항입니다. 본인이 판단해서 필요하다고 생각하거나, 그냥 재미삼아 해보고싶으면 따라서 해보면 됩니다. 다만, 다음과 같은 사람에게는 도움이 될 거라고 생각합니다.

  • 웹 개발 언어(HTML, CSS, Javascript)를 공부하고싶은 사람
  • 자기만의 프로덕트를 간단하게 만들고 배포해보고싶은 사람

프로그램을 만드는데 필요한 지식은 많지 않습니다. 본 포스트의 내용을 참고하여 본인만의 크롬 확장 프로그램을 개발해보고, 이를 마켓플레이스에 등록까지 해보시길 바라겠습니다!

프로그램 기획

당연한 이야기지만 프로그램을 만들기 전에 기획을 먼저 해야 합니다. 본인이 만들고자 하는 프로그램의 구상이 있다면, 이를 충분히 구체화시켜 오류가 없도록 미리 준비하시기 바라겠습니다.

출처: https://giphy.com/

저는 일명 댄싱 도지라고 하는 해외 밈에 착안했습니다. 재밌는 GIF를 Giphy로부터 가져와서 백그라운드에 저장하고, 브라우저 내에서 발생하는 키보드 및 클릭 이벤트를 감지해서 현재 보이는 영역의 랜덤한 위치에 짧게 띄워주고 사라지는 것을 생각했습니다. (네, 정말 쓸데없는 기능입니다.)

주요 기능 및 설명은 아래와 같습니다.

  • 밈 팝업 : 타이핑 or 클릭 이벤트에 맞춰서 GIF를 브라우저 영역에 표시
  • 밈 변경 : 버튼을 누르면 Giphy API를 활용해 GIF 주소 정보를 백그라운드에 저장
  • on/off 스위치 : GIF를 보여줄지 안 보여줄지 결정

크롬 확장 프로그램 구조 이해하기

이제 구현을 하기 위해 크롬 확장 프로그램의 구조를 알아봐야 합니다. 이미 공식 문서에 내용은 잘 나와 있지만, 전부 영어이므로 한글로 간략하게 정리해보겠습니다. 프로젝트 내부의 주요 구성 요소는 아래와 같습니다.

  • manifest.json
    • 프로그램의 메타 정보를 저장하는 필수 파일입니다.
  • popup.html
    • 우상단 툴바 아이콘을 클릭했을 때 나타나는 팝업입니다.
    • 최소한의 필요한 기능만을 포함한 간결한 UI를 만드는 것을 권장합니다.
  • popup.js
    • popup.html 뷰에서 발생하는 이벤트를 제어하기위한 자바스크립트입니다.
    • background.js, contentscript.js와 메세지를 주고받을 수 있습니다.
  • background.js
    • 프로그램의 전체적인 이벤트 핸들러입니다. 이벤트가 발생하면 특정 코드를 수행합니다.
    • 이벤트가 발생하는 시점에만 수행되고 나머지 시간에는 유휴상태로 있도록 만드는 것이 좋습니다.
    • 탭 갯수와 상관없이 백그라운드는 한 개만 존재합니다.
    • popup.js, contentscript.js와 메세지를 주고받을 수 있습니다.
  • contentscript.js
    • 브라우저에 로드된 웹페이지 영역에서 동작하는 자바스크립트입니다.
    • DOM을 활용해서 페이지 내의 요소를 추가, 변경, 삭제할 수 있습니다.
    • 각 탭 마다 1회 로드됩니다.
    • background.js, popup.js와 메세지를 주고받을 수 있습니다.

코딩하기

이제 코드를 살펴보겠습니다. 원본 소스코드는 Github 레포지토리를 참고하시기 바랍니다.

1. manifest.json 작성하기

가장 먼저 해야할 것은 manifest.json 파일을 작성하는 일입니다. 파일에는 제목, 제작자 이름, 설명, 권한 등의 정보가 포함됩니다.

여기서 프로그램에 불필요한 권한이 포함되지 않도록 주의해야 합니다. 사용하지 않는 권한을 요청했을 경우 마켓 등록 단계에서 거절될 확률이 있으며 거절되지 않더라도 요청에 대한 사용 이유를 전부 적어줘야 합니다. 저는 GIF 정보를 백그라운드에 저장하기 위해 스토리지 API를 사용했으며, 이에 대한 권한만 지정했습니다.

그리고 content_scripts 부분의 matches는 contentscript.js가 발동되기 위한 도메인 주소를 지정하는 것인데 저는 모든 페이지에서 해당 기능이 발동되기를 원하기 때문에 이와 같이 지정해주었습니다.

더보기
{
  "author": "Youngkyun Kim",
  "name": "__MSG_extName__",
  "description": "__MSG_extDescription__",
  "version": "1.0.0",
  "manifest_version": 2,
  "default_locale": "en",
  "browser_action": {
    "default_icon": "images/doge-head-128.png",
    "icons": {
       "16": "images/doge-head-16.png",
       "48": "images/doge-head-48.png",
      "128": "images/doge-head-128.png"
    },
    "default_popup": "popup.html"
  },
  "permissions": [
    "storage"
  ],
  "background": {
    "scripts":  ["background.js"],
    "persistent": false
  },
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": ["contentscript.js"]
    }
  ],
  "web_accessible_resources": [
    "images/*.gif"
  ]
}

2. background.js 작성하기

다음으로 background.js에서 이벤트 핸들링 코드들을 작성해보겠습니다. 필요한 이벤트 처리는 크게 설치 이벤트, 메세지 이벤트, 탭 활성화 이벤트 세 가지입니다.

  1. chrome.runtime.onInstalled.addListener : 프로그램의 설치 시점에 설정값들을 세팅합니다.
  2. chrome.runtime.onMessage.addListener : 메세지를 받았을 때 수행됩니다. request.action 값을 통해 메세지의 의도를 전달하는 방식으로 구현할 수 있습니다.
  3. chrome.tabs.onActivated.addListener : 현재 활성화된 탭이 변경되는 시점에 수행됩니다.

상단에는 Giphy에서 랜덤하게 GIF를 가져오기 위한 코드가 있습니다. API 연동에 필요한 Key 값은 스크립트 내의 변수로 포함시켰습니다.

더보기
const API_KEY = 'API_KEY';

async function giphyRandom() {
  const res = await fetch(`https://api.giphy.com/v1/gifs/random?api_key=${API_KEY}`)
  const json = await res.json()
  const data = json.data;
  return data;
}

chrome.runtime.onInstalled.addListener(async function (details) {
  if (details.reason === "install") {
    const initStatus = {
      enabled: true,
      object: null,
      url: chrome.runtime.getURL('images/dancing-doge.gif'),
    };

    console.log(initStatus);

    // Initialize data
    chrome.storage.sync.set(initStatus);
  } else if (details.reason === "update") {}
});

chrome.runtime.onMessage.addListener(async function(request, sender, sendResponse) {
  // REFRESH: save new git object to storage API
  if (request.action === "REFRESH") {
    const GIFObject = await giphyRandom();
    chrome.storage.sync.set({
      object: GIFObject.images.fixed_width,
      url: GIFObject.images.fixed_width.url,
    })
    sendResponse({ message: "OK" });
  }
});

chrome.tabs.onActivated.addListener(function(activeInfo) {
  // UPDATE: re-rendering view
  chrome.tabs.sendMessage(activeInfo.tabId, { action: "UPDATE" });
});

3. popup 작성하기

이제 popup 영역을 작성해보겠습니다. 뷰에 해당하는 popup.html에는 특별한 내용이 없고 구동부에 해당하는 popup.js가 중요합니다. 스토리지 API의 연동 부분, 값을 읽어와서 Giphy의 뷰를 만들어주는 부분, 백그라운드에서 수신할 REFRESH 메세지를 전송하는 부분이 주요 코드입니다.

저는 tailwind.css를 사용해봤는데 이걸 굳이 사용 안하셔도 됩니다. html 코드 안에 CSS 코드를 직접 작성하셔도 되고, 별도 css 파일로 만들어서 작성해도 충분합니다. tailwind의 자세한 코드는 레포지토리의 README를 참고하시기 바랍니다.

더보기

popup.html

<!DOCTYPE html>
<html>
  <head>
    <title>Flying Meme Animator</title>
    <link rel="stylesheet" href="css/tailwind.css">
  </head>
  <body id="root-wrapper">
    <!-- header section -->
    <header id="header-wrapper">
      <label for="enabled" class="switch">
        <div>
          <input id="enabled" type="checkbox" class="sr-only" />
          <div class="bar"></div>
          <div class="dot"></div>
        </div>
      </label>
      <button id="close" class="text-gray-600">
        <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
        </svg>
      </button>
    </header>

    <!-- main section -->
    <main id="main-wrapper"></main>

    <!-- footer section -->
    <footer id="footer-wrapper">
      Powered By&nbsp;<a href="https://giphy.com/" target="blank" class="text-blue-500 hover:text-blue-700 hover:underline">Giphy</a>
    </footer>

    <script src="popup.js"></script>
  </body>
</html>

popup.js

(async function () {

  /** Utility functions */
  function removeAllChildNodes(parent) {
    while (parent.firstChild) {
      parent.removeChild(parent.firstChild);
    }
  }

  /**
   * Make HTMLDivElement from GIFObject
   * @param {Images.fixed_width} object https://developers.giphy.com/docs/api/schema/
   * @returns {HTMLDivElement}
   */
  function makeGiphyElement(object) {
    const container = document.createElement('div');
    const overlay = document.createElement('div');
    const refreshButton = document.createElement('button');

    container.classList.add('giphy-item');
    overlay.classList.add('overlay', 'hidden');

    if (object != null) {
      container.style.backgroundImage = `url('${object.url}')`;
      container.style.width = `${object.width}px`;
      container.style.height = `${object.height}px`;
    } else {
      container.style.backgroundImage = `url('${chrome.runtime.getURL(`images/dancing-doge.gif`)}')`;
      container.style.width = `200px`;
      container.style.height = `202px`;
    }

    container.addEventListener('mouseover', () => {
      overlay.classList.remove('hidden');
      overlay.classList.add('flex');
    }, false)
    container.addEventListener('mouseout', () => {
      overlay.classList.add('hidden');
      overlay.classList.remove('flex');
    }, false)

    refreshButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
    </svg>`;

    refreshButton.addEventListener('click', () => chrome.runtime.sendMessage({ action: "REFRESH" }));

    overlay.append(refreshButton);
    container.append(overlay);

    return container;
  }

  /** Chrome extension API functions */
  async function setStorageData(data) {
    return new Promise(function (resolve, reject) {
      chrome.storage.sync.set(data, function () { resolve(); })
    })
  }

  async function findAll(itemList = [
    'object',
    'enabled',
  ]) {
    return new Promise(function (resolve, reject) {
      chrome.storage.sync.get(itemList, function (data) { resolve(data); })
    })
  }

  window.addEventListener('load', async function () {
    // find elements
    const closeButton = document.getElementById('close');
    const mainWrapper = document.getElementById('main-wrapper');
    const enabled = document.getElementById('enabled');

    /** DOM handler functions */
    async function bindStorageDataToElement() {
      const data = await findAll();

      removeAllChildNodes(mainWrapper);
      mainWrapper.append(makeGiphyElement(data.object));
      enabled.checked = data.enabled;
    }

    /** Header Event handlers */
    closeButton.addEventListener('click', () => window.close(), false);

    /** Settings form event handlers */
    enabled.addEventListener('change', async function () {
      await setStorageData({ enabled: this.checked });
    }, false)

    // Add event handlers for chrome API
    chrome.storage.onChanged.addListener(bindStorageDataToElement);

    // Initialize
    await bindStorageDataToElement();

  }, false);
})()

4. contentscript.js 작성하기

마지막으로 contentscript.js를 작성해보겠습니다. 해당 코드는 웹페이지 영역에서 실행되므로 주의 깊게 작성해야 합니다.

여기서 중요한 부분은 z-index와 스크롤 위치 계산입니다. GIF가 항상 보이기 위해선 z-index에 높은 값을 지정해주는 것이 좋습니다. 물론 충분히 높은 값을 주더라도 모든 페이지에서 항상 보이도록 만들 수 없지만, 그래도 어느 정도 보장을 할 수 있는 값을 넣어주어야 합니다. 그리고 GIF가 표시될 위치는 스크롤 위치에 상대적이기 때문에 이를 포지션에 반영해야 합니다.

더보기
(function () {
  const image = document.createElement('img');

  let object = null;
  let popupTimer = null;
  let popupDuration = 500;

  /** Initialize styles */
  image.style.position = 'absolute';
  image.style.zIndex = 9999;
  image.style.display = 'none';
  image.style.top = '0px';
  image.style.left = '0px';

  /** Utility functions */
  function getRandomArbitrary(min, max) {
    return Math.random() * (max - min) + min;
  }

  /** Visibility functions */
  function popup() {
    const x = getRandomArbitrary(0, window.innerWidth - (object ? object.width : image.style.width));
    const y = getRandomArbitrary(0, window.innerHeight - (object ? object.height : image.style.height));
    const offsetX = window.pageXOffset || document.documentElement.scrollLeft;
    const offsetY = window.pageYOffset || document.documentElement.scrollTop;
    image.style.top  = `${offsetY + y}px`;
    image.style.left = `${offsetX + x}px`;
    image.style.display = 'inline';

    clearTimeout(popupTimer);
    popupTimer = setTimeout(function () {
      image.style.display = 'none';
    }, popupDuration);
  }

  /** DOM handler functions */
  function bindStorageDataToElement() {
    chrome.storage.sync.get([
      'url',
      'object',
      'enabled',
    ], function(items) {
      object = items.object;

      if (items.url) {
        image.src = items.url;
      }

      if (!items.enabled && document.body.contains(image)) {
        document.body.removeChild(image);
      } else if (items.enabled && !document.body.contains(image)) {
        document.body.appendChild(image);
      } else {
        // Nothing to do
      }
    });
  }

  // Add event handlers
  window.addEventListener('keydown', popup, false);
  window.addEventListener('click', popup, false);

  // Add event handlers for chrome API
  chrome.storage.onChanged.addListener(bindStorageDataToElement);

  chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
    // Re render on update message received
    if (request.action === "UPDATE") {
      bindStorageDataToElement();
    }
  });

  // Initialize
  bindStorageDataToElement();
})();

업로드 및 테스트하기

작성이 완료되었다면 브라우저에 업로드해서 테스트를 해볼 수 있습니다. 업로드 절차는 아래와 같습니다. (영문 기준)

1. Manage Extensions 클릭

2. 우상단 Developer mode 체크

3. 좌상단 Load unpacked 클릭해서 업로드

마켓 플레이스 배포하기

배포는 크롬 개발자 대시보드에서 진행할 수 있습니다. 처음에 5$의 수수료를 먼저 결제해야 하고, 결제가 완료되면 아래와 같은 대시보드를 확인할 수 있습니다.

새 항목을 눌러 개발한 소스코드를 zip 파일로 압축해서 업로드하면 초안 상태로 항목이 추가되는 것을 볼 수 있습니다. 이제 항목에 들어가서 필요한 내용들을 채워서 제출하면 됩니다.

특히 개인정보 보호관행 탭의 내용을 작성할 때 권한 요청에 대한 이유를 상세하게 적어야 통과가 되는 듯합니다. 저는 1차로 제출한 것이 거절되었습니다.

거절 사유

이유는 사용하지 않는 tab 권한을 요청했다는 것인데, 제가 background.js 코드 내에서 사용한 chrome.tabs.* 때문에 tab 권한이 필요하다고 생각해서 넣은 것이 거절 이유였습니다. 이를 제거하고 다시 시도했더니 정상적으로 등록이 되었습니다.

레퍼런스