front-end / / 2024. 1. 8. 18:52

[PWA] Background Sync

Send data event when you're offline

Background Sync는 어떻게 동작하는가? Background Sync는 인터넷이 안되는 환경에서 데이터를 전송하는 것이다.

브라우저 웹페이지에서 사용자가 서버로 데이터를 등록한다. 하지만 인터넷 연결이 안되면 등록이 실패할 것이다.
우리의 목표는 서버로 데이터를 전송하는 것이다. 대부분의 경우는 성공하겠지만 인터넷이 안되는 상황에는 그런일이 발생할 가능성이 있다. service worker로 offline 페이지를 표시할 수 있지만, 데이터 전송을 하는 경우는 어떻게 하나? 이런 경우 요청을 일단 저장하고 나중에 가능할 때 전송하면 되지 않을까?

service worker는 전송 데이터를 캐싱을 하지는 못한다. 응답만을 캐싱할 수 있다. 여기서 sync task를 등록하도록 service worker에서 작업할 수 있다.
즉, service worker에게 이렇게 말하는 것이다. "보내려는 데이터가 있는데 sync task로 등록해줘"

task를 등록하는 것만으로 끝나는 것이 아니라 전송할 데이터를 저장해야 한다. 이를 활용하기 위해 indexedDB에 저장하는 것이다.

재전송할 준비가 완료된 다음, 다시 인터넷 연결이 다시 될 때, service worker는 해당 작업을 실행한다.

재연결이 되면 Sync 이벤트가 실행되므로 service worker의 이벤트를 수신하면 된다. 이벤트가 발생할때 전송하면 된다.

Sync를 사용할 경우의 장점으로는 탭을 닫아도 동작한다는 것이다. 이 작업을 위해 브라우저를 열 필요는 없다. 인터넷 연결만 되면 되는 것이다. 사용자는 동작을 하고 브라우저를 닫아도 된다.

아래는 SyncManager API 스펙이니 참고하면 된다.

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

Synchronization Task 등록

이름과 내용을 포함하는 post를 등록하는 예제를 하나 만들어볼 것이다. 인터넷이 안되는 환경에서 작성한 post를 로컬에 저장하는 용도로 indexedDB를 사용할 것이다.

화면에서 등록을 하고 실제 서버는 firebase database를 이용해서 저장할 것이다.

[indexDB] 라이브러리
https://github.com/jakearchibald/idb

[firebase]

firebase data를 저장하는 방법은 아래 링크를 참고하도록 한다.
https://firebase.google.com/docs/database/rest/save-data?hl=ko

단순히 json형식의 데이터를 저장하는 방법이며 fetch로 새 글을 등록하는 HTTP를 호출하면 되므로 크게 어렵지 않게 사용할 수 있다.

시나리오

  • 인터넷이 안되는 환경에서 사용자가 post를 등록하고 indexedDB에 작성한 post를 저장한다.
  • 다시 인터넷이 연결되면 firebase에 이전에 작성한 글을 자동으로 등록한다.

[post.html]

<div style="margin: 100px 50px">
    <form class="ui form" onSubmit="return handleSubmit(event)">
        <div class="field">
            <label>Name</label>
            <input type="text" name="name" id="name" placeholder="이름">
        </div>
        <div class="field">
            <label>Content</label>
            <textarea name="content" id="content"></textarea>
        </div>
        <button class="ui button" type="submit">Submit</button>
    </form>
</div>

<script src="static/idb.js"></script>
<script src="static/idbutil.js"></script>
<script src="static/app.js"></script>
<script src="static/post.js"></script>

[post.js]

form.addEventListener("submit", function (event) {
  event.preventDefault();

  const nameInput = document.querySelector("#name").value;
  const contentInput = document.querySelector("#content").value;
  if (nameInput.trim() === "") {
    alert("이름을 입력해주세요.");
    return;
  }
  if (contentInput.trim() === "") {
    alert("내용을 입력해주세요.");
    return;
  }

  if ("serviceWorker" in navigator && "SyncManager" in window) {
    navigator.serviceWorker.ready.then(function (sw) {
      const post = {
        id: new Date().toISOString(),
        name: nameInput,
        content: contentInput,
      };
      writeData("sync-posts", post)
        .then(function () {
          return sw.sync.register("sync-new-post");
        })
        .then(function () {
          console.log("Your Post was saved for syncing");
          alert('글이 등록되었습니다. 추후에 동기화가 될 예정입니다.');
          location.href = "post.html";
        })
        .catch(function (err) {
          console.log(err);
        });
    });
  } else {
    sendData();
  }
});

[idbutil.js]

const dbPromise = idb.open("posts-store", 1, function (db) {
  if (!db.objectStoreNames.contains("sync-posts")) {
    db.createObjectStore("sync-posts", { keyPath: "id" });
  }
});

function writeData(st, data) {
  return dbPromise.then(function (db) {
    var tx = db.transaction(st, "readwrite");
    var store = tx.objectStore(st);
    store.put(data);
    return tx.complete;
  });
}

function readAllData(st) {
  return dbPromise.then(function (db) {
    var tx = db.transaction(st, "readonly");
    var store = tx.objectStore(st);
    return store.getAll();
  });
}

function clearAllData(st) {
  return dbPromise.then(function (db) {
    var tx = db.transaction(st, "readwrite");
    var store = tx.objectStore(st);
    store.clear();
    return tx.complete;
  });
}

function deleteItemFromData(st, id) {
  dbPromise
    .then(function (db) {
      var tx = db.transaction(st, "readwrite");
      var store = tx.objectStore(st);
      store.delete(id);
      return tx.complete;
    })
    .then(function () {
      console.log("Item deleted!");
    });
}

[sw.js] sync 이벤트 구현

self.addEventListener("sync", function (event) {
  console.log("[Service Worker] Background syncing", event);
  if (event.tag === "sync-new-post") {
    console.log("[Service Worker] Syncing new Posts");
    event.waitUntil(
      readAllData("sync-posts").then(function (items) { // indexedDB에서 조회
        for (let item of items) {
          fetch(
            "https://xxxxxxxxx.firebasedatabase.app/posts.json", // firebase database 주소
            {
              method: "POST",
              header: {
                "Content-Type": "application/json",
                Accept: "application/json",
              },
              body: JSON.stringify({
                id: item.id,
                name: item.name,
                content: item.content,
              }),
            },
          ).then(function (res) {
            console.log("Sent data", res);
            if (res.ok) {
              deleteItemFromData("sync-posts", item.id); //등록 후 indexedDB 삭제
            }
          });
        }
      }),
    );
  }
});

위의 코드를 작성하고 글을 등록해보자. 물론 글 작성화면에서 offline 모드로 변경을 하고 해보자. 그러면 글 등록이 되었지만 "글이 등록되었습니다. 추후에 동기화가 될 예정입니다." 라는 메시자가 표시될 것이다.

글 등록을 완료하고 나서 offline을 다시 해제하면 sync 이벤트가 발행되고 정상적으로 서버로 등록한 글이 저장되는 것을 알 수 있다.

[offline 해제 시 표시되는 로그]

[Service Worker] Background syncing SyncEvent {isTrusted: true, tag: 'sync-new-post', lastChance: false, type: 'sync', target: ServiceWorkerGlobalScope, …}
sw.js:103 [Service Worker] Syncing new Posts
sw.js:122 Sent data Response {type: 'cors', url: 'https://xxxxxxxx.firebasedatabase.app/posts.json', redirected: false, status: 200, ok: true, …}
idbutil.js:42 Item deleted!

참고

https://www.udemy.com/course/progressive-web-app-pwa-the-complete-guide

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유