Shared Worker와 Service Worker는 이전 글에서 언급하였듯이 데이터 공유와 비동기 작업을 효율적으로 처리하기 위해 제공되는 Web Worker의 확장된 인터페이스입니다. 두 Worker모두 메인 쓰레드와 분리되어 독립된 스레드에서 실행되며, 성능 최적화를 통한 사용자의 경험 향상을 위해 사용할 수 있습니다.
Shared Worker
Shared Worker는 동일한 출처(origin)에서 실행되는 여러 브라우저 탭, iframe, 또는 웹 애플리케이션 간에 공유될 수 있는 Worker입니다. 만약 브라우저의 서로 다른 탭(tab)이더라도 같은 출처를 가진다면 모두가 동일한 Worker에 접근 가능합니다. 그래서 만약 여러 개의 탭이나 윈도우 창이 켜져있더라도 모두 같은 데이터를 공유받거나, 같은 로직을 처리하여야 한다면 단 한 번의 작업으로 모든 브라우저 탭 등이 필요한 연산을 수행하고 결과를 보내줄 수 있습니다. 예를 들어 하나의 탭에서 작업을 수행하면 수행한 작업을 열려있는 모든 다른 탭에도 반영할 필요가 있을 때 Shared Worker를 사용합니다.
Shared Worker 생성
new SharedWorker(scriptURL[, options])
scriptURL: SharedWorker가 실행할 스크립트 파일의 경로입니다.
options: 이름을 지정하는 name 속성 등 추가 설정을 위한 옵션 객체입니다.
여러 개의 탭에서 같은 이미지 공유하기
이제 이 shared worker를 이용하여 강아지와 고양이 사진을 불러오며 여러 개의 탭이 있을 경우 같은 사진을 보도록 만들어 보겠습니다. 먼저 HTMl입니다. 고양이와 강아지를 소환할 각 버튼과 소환된 동물을 둘 ul태그가 있습니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<style>
img {
height: 300px;
width: auto;
}
li {
list-style: none;
}
</style>
<body>
<button id="cat-button">고양이 소환</button>
<button id="dog-button">강아지 소환</button>
<ul id="photo-list"></ul>
<script src="./index.js"></script>
</body>
</html>
그리고 메인 쓰레드의 코드입니다. HTML로부터 버튼을 가져와 누르면 동물을 소환할 sharedWorker에 메세지를 보내어 소환된 동물을 받을 수 있습니다.
const catButton = document.getElementById("cat-button");
const dogButton = document.getElementById("dog-button");
const ul = document.getElementById("photo-list");
const sharedWorker = new SharedWorker("./sharedWorker.js");
sharedWorker.port.start();
catButton.addEventListener("click", async () => {
sharedWorker.port.postMessage("cat");
});
dogButton.addEventListener("click", () => {
sharedWorker.port.postMessage("dog");
});
sharedWorker.port.onmessage = (e) => {
console.log(e.data);
addPhotoToList(e.data);
};
function addPhotoToList(photoUrl) {
const li = document.createElement("li");
const image = document.createElement("img");
image.src = photoUrl;
li.appendChild(image);
ul.appendChild(li);
}
마지막으로 오늘의 주인공 sharedWorker입니다. 먼저 코드를 보시죠!
let ports = [];
onconnect = (e) => {
const port = e.ports[0];
ports.push(port);
port.onmessage = async (e) => {
console.log(ports);
const requestUrl =
e.data === "dog" ? "https://dog.ceo/api/breeds/image/random" : "https://cataas.com/cat?json=true";
try {
const response = await fetch(requestUrl);
let imageUrl;
const data = await response.json();
if (e.data === "dog") {
imageUrl = data.message;
} else {
imageUrl = `https://cataas.com/cat/${data._id}`;
}
ports.forEach((port) => {
port.postMessage(imageUrl);
});
} catch (error) {
console.log("error", error);
}
};
port.start();
};
ports
배열은 새로운 탭 또는 브라우저 창이 Worker와 연결될 경우 연결된 port를 담는 배열입니다. 이 배열이 없다면 아무리 sharedWokrer라고 해도 데이터를 "공유"할 수는 없습니다. 이유는 잠시 뒤 알아보기로 하고 코드 설명을 계속하겠습니다. onconnect
는 메인 쓰레드에서 워커에 연결될 경우 실행되는 함수입니다. onconnect
의 인자로 들어오는 것은 Message 이벤트인데요, 대부분의 경우 신경쓸 필요가 없이 ports
속성만 확인하면 됩니다. ports
속성에는 메인 쓰레드와 Shared Worker 간의 연결을 책임 지는 MessagePort
객체가 들어있는데, 배열이긴 하지만 항상 이번에 연결된 메인 쓰레드(즉, 브라우저 창, 탭 등의 클라이언트) 하나만 들어 있습니다.
그래서 연결된 모든 클라이언트에 메세지를 전송하기 위해서는 ports
라는 배열을 두어 직접 관리해 주어야 합니다. 특히, 메세지를 보낼 때도 모든 port
에 대해 각각의 메인 쓰레드로 전송할 필요가 있습니다. 원한다면 특정 포트에만 메세지를 보내 원하는 메인 쓰레드(브라우저 창이나 탭)만 데이터를 받게 할 수도 있습니다. 이제 하나의 탭 혹은 브라우저에만 동물을 소환해도 모든 탭에서 소환된 동물을 볼 수 있게 되었습니다.
개선하기: 연결 시 그동안 소환한 동물 받아오기
하지만 아직 모자란 부분이 있습니다. 만약 두 개의 탭이 열렸고, 동물을 3마리 소환한 상태라고 가정해 봅시다. 이때, 세 번째 탭을 연다면 세 번째 탭은 0 마리의 동물부터 시작해 4번 째 소환된 동물부터 공유받을 수 있습니다. 항상 3 마리 적은 동물을 가지게 되는 것입니다. 세 번째 탭이 열릴 때 그동안 소환되어 있던 동물이 모두 공유된다면 더 좋을 것 같습니다.
먼저 sharedWorker를 수정하겠습니다. 먼저 sharedWorker가 ports
배열 외에도 imageArray
를 저장하도록 합니다. 그리고 최초 연결되었을 때, postMessage
를 통해 imgaeArray
를 전달합니다. 그러면 이때까지 소환된 동물을 연결된 시점에 메인 쓰레드로 전달할 수 있습니다. 그리고 새로운 동물이 소환될 때마다, imageArray
에 추가합니다.
let ports = [];
let imageArray = [];
// ...
onconnect = (e) => {
ports.push(port);
port.postMessage(imageArray);
// ... imageUrl가 정해진 이후, postMessage 이전
imageArray.push(imageUrl);
먼저 메인 쓰레드를 수정해보겠습니다. sharedWorker로부터 메세지를 받았을 때 기존의 소환된 동물도 모두 HTML에 추가하여야 하므로 event
의 data
가 배열이면 순회하면서 추가하도록 합니다.
// ...
sharedWorker.port.onmessage = (event) => {
const data = event.data;
if (Array.isArray(data)) {
event.data.forEach((e) => {
addPhotoToList(e);
});
} else {
addPhotoToList(event.data);
}
};
자, 이제 사용성은 개선된 것 같습니다. 하지만 여기서 끝이 아닙니다. 만약 사용자가 여러 탭이나 창 중 하나를 종료한다고 해도 sharedWorker
의 ports
배열의 개수를 줄이는 코드가 존재하지 않습니다. 쓸모없이 메모리를 잡고 있는 셈입니다. 이는 작은 요소로 무시할 수 있겠으나 생각보다는 간단한 방법으로 해결이 가능합니다.
개선하기: 종료된 탭이나 창의 port 제거하기
만약 SharedWorker의 모든 창이나 탭이 종료된다면 가비지 컬렉터가 관련된 SharedWorker를 메모리에서 제거하기에 큰 신경을 쓸 필요는 없지만 여러 탭 중 일부만 종료한다면 직접 그 탭 또는 창과 연결되어 있던 port
를 제거해 주어야 합니다. 우리가 직접 porst
배열을 관리하고 있기 때문이죠. 아쉽게도 현재의 SharedWorker는 우리가 직접 관리해야만 여러 탭에 메세지를 보낼 수 있지만 MessageEvent
의 ports
가 배열로 관리되고 있는 것을 보면 언젠가는 직접 관리할 필요가 없게 될지도 모릅니다. 하지만 아직은 아니므로 일부 탭이 종료되었을시 관리하는 port
도 삭제하는 코드를 추가해 보도록 하겠습니다.
수정 전에 먼저 두 가지 사실을 알려드리겠습니다. 첫 번째는 프로젝트가 예시 어플리케이션의 목적에 맞는 JS 파일과 SharedWorker에서 자동 port 관리를 추상화한 TS 파일로 나누었다는 것입니다. 두 번째로는 토스의 N개의 탭, 단 하나의 웹소켓: SharedWorker을 참조하였지만, 이 방식으로는 port
가 정리되지 않았다는 것입니다.(크롬의 경우 chrome://inspect/#workers에서 sharedWorker의 로그를 확인할 수 있습니다.) 그래서 주기적으로 ping을 보내어 너무 오래 응답이 오지 않을 경우 port를 삭제하는 방식으로 구현하였습니다.
단순히 SharedWorker
에서 port
들을 WeakRef
로 감싸서 약한 참조를 유지하고 브라우저 탭이 사라지면 가비지 컬렉터가 회수할 수 있게 만드는 방법은 왜 안될까요?
직접 실험해 본 결과 WeakRef
로 SharedWorker
의 port
를 감싼다고 하더라도 각 port
와 연결된 탭이 닫혔을 때, 해당 port
에 대한 모든 참조가 사라지지는 않는 것 같습니다.해당 기능에 대한 구체적인 구현 스펙을 찾지 못해 단순한 추측에 불과하지만 SharedWorker
는 여러 탭과 창 사이의 통신을 보장하는데, 해당 worker를 사용하는 탭에서 port
(정확하게는 MessagePort
)에 대한 참조를 잃더라도 onMessage
혹은 postMessage
가 동작하여야 하기 때문에 브라우저 내부적으로 각 MessagePort
에 대한 강한 참조를 유지하고 있는 것 같습니다. 즉, MassgePort
를 해제하는 유일한 방법은 port.colse()
의 호출이 필요하다는 것입니다. 그래서 이벤트만 남아있는 상황을 막고 posrt
가 명시적으로 닫혀 더이상 통신할 필요가 없다는 것이 활실한 때에만 port
에 대한 참조를 끊을 수 있게 되는 겁니다.
메인 쓰레드에서 관리하고 있는 SharedWorker
에 연결 완료 후 ping을 계속해서 보내는 기능과 SharedWorker
에 연결 해제 요청을 보내는 기능, 현재 연결이 시작 되었는지 확인하는 기능을 가진 클래스를 만들었습니다. 그리고 SharedWorker
가 될 클래스는 연결 시 ping을 받아 연결이 지속되고 있는 지 확인하며 연결된 탭이 종료되지 않았는 지 확인하고 종료된 탭을 port
목록에서 제외시키는 기능과 전체 탭에 공통된 메세지를 보내는 기능을 추가하였습니다.
Git hub에서 코드를 clone 받아 사용하실 수 있습니다.
// index.js
import SharedWorkerConnection from "./SharedWorkerConnection.js";
const catButton = document.getElementById("cat-button");
const dogButton = document.getElementById("dog-button");
const ul = document.getElementById("photo-list");
const sharedWorker = new SharedWorkerConnection("./sharedWorker.js");
sharedWorker.start();
catButton.addEventListener("click", () => {
if (sharedWorker.isConnected) {
sharedWorker.port.postMessage({ type: "request", animal: "cat" });
}
});
dogButton.addEventListener("click", () => {
if (sharedWorker.isConnected) {
sharedWorker.port.postMessage({ type: "request", animal: "dog" });
}
});
sharedWorker.setMessageHandler((event) => {
const { type, data } = event.data;
switch (type) {
case "connected":
if (Array.isArray(data)) {
data.forEach(addPhotoToList);
}
break;
case "newImage":
addPhotoToList(data);
break;
}
});
function addPhotoToList(photoUrl) {
const li = document.createElement("li");
const image = document.createElement("img");
image.src = photoUrl;
li.appendChild(image);
ul.appendChild(li);
}
// SharedWorkerConnection.ts
export default class SharedWorkerConnection {
sharedWorker: SharedWorker;
private interval: number | undefined = undefined;
isConnected: boolean = false;
port: MessagePort;
constructor(workerPath: string) {
this.sharedWorker = new SharedWorker(workerPath, { type: "module" });
this.port = this.sharedWorker.port;
window.addEventListener("unload", () => this.handleUnload());
}
start() {
this.sharedWorker.port.start();
this.startPing();
console.log("SharedWorker connection started");
}
// disconnect 처리
private handleUnload() {
if (this.isConnected) {
this.sharedWorker.port.postMessage({ type: "$disconnect" });
}
}
// ping 처리
private startPing() {
this.interval = setInterval(() => {
if (this.isConnected) {
this.sharedWorker.port.postMessage({ type: "$ping" });
} else {
this.interval && clearInterval(this.interval);
}
}, 5000);
}
setMessageHandler(handler: (event: MessageEvent<any>) => any) {
this.sharedWorker.port.onmessage = (event) => {
handler(event);
switch (event.type) {
case "$connected":
this.isConnected = true;
break;
}
};
}
}
// sharedWorker.js
import CleanableSharedWorkerBase from "./CleanableSharedWorkerBase.js";
const imageArray = [];
const workerBase = new CleanableSharedWorkerBase();
self.onconnect = function (event) {
const [port, id] = workerBase.onConnect(event);
port.postMessage({ type: "connected", data: imageArray });
port.onmessage = async (e) => {
const { animal } = e.data;
workerBase.onMessage(port, id, e);
handleRequest(animal);
};
};
async function handleRequest(animal) {
const requestUrl = animal === "dog" ? "https://dog.ceo/api/breeds/image/random" : "https://cataas.com/cat?json=true";
try {
const response = await fetch(requestUrl);
const data = await response.json();
const imageUrl = animal === "dog" ? data.message : `https://cataas.com/cat/${data._id}`;
imageArray.push(imageUrl);
workerBase.broadcastData({ type: "newImage", data: imageUrl });
} catch (error) {
console.error("Error fetching image:", error);
}
}
// CleanableSharedWorkerBase.ts
export default class CleanableSharedWorkerBase {
private readonly ports = new Map<number, PortConnection>();
portCounter = 0;
constructor() {
setInterval(() => this.cleanupPorts(), 10000);
}
onConnect(e: MessageEvent) {
const port = e.ports[0];
const id = this.portCounter++;
const connection = new PortConnection(port, id);
this.ports.set(id, connection);
port.postMessage({ type: "$connected" });
console.log("New port connected:", id);
console.log("Total ports:", this.ports.size);
return [port, id];
}
onMessage(port: MessagePort, id: number, e: MessageEvent) {
const { type } = e.data;
switch (type) {
case "$ping":
this.handlePing(id);
break;
case "$disconnect":
this.handleDisconnect(port, id);
break;
}
}
broadcastData(data: any) {
for (const [portId, connection] of this.ports.entries()) {
try {
connection.port.postMessage(data);
} catch (error) {
console.log("Error broadcasting to port:", portId);
connection.isActive = false;
}
}
}
private cleanupPorts() {
console.log("Starting port cleanup...");
for (const [id, connection] of this.ports.entries()) {
if (!connection.isActive || connection.isStale()) {
try {
connection.port.close();
} catch (error) {
console.log("port 닫는 중 에러", error);
}
this.ports.delete(id);
console.log("port 제거:", id);
}
}
console.log("port cleanup end - Active ports:", this.ports.size);
}
private handlePing(id: number) {
const conn = this.ports.get(id);
if (conn) {
conn.updatePing();
}
}
private handleDisconnect(port: MessagePort, id: number) {
if (this.ports.has(id)) {
try {
port.close();
} catch (error) {
console.log("Error closing port:", error);
}
this.ports.delete(id);
console.log("Port disconnected:", id);
console.log("Remaining ports:", this.ports.size);
}
}
}
class PortConnection {
lastPing: number;
isActive: boolean = true;
constructor(public port: MessagePort, public id: number) {
this.lastPing = Date.now();
}
updatePing() {
this.lastPing = Date.now();
}
isStale() {
// 15초 이상 핑이 없으면 stale로 간주
return Date.now() - this.lastPing > 15000;
}
}
참고 자료
MDN, SharedWorker, MessagePort
toss, slash24, N개의 탭, 단 하나의 웹소켓: SharedWorker
HTML Standard, Workers - Shared workers
수정 내역
2025-02-08: SharedWorker
가 WeakRef
에 의해 사라지지 않는 이유(추정) 추가
'개발 > JavaScript' 카테고리의 다른 글
자바스크립트와 멀티 쓰레드 3 - 다른 형태의 워커(사실 의미가 적은 글) (0) | 2025.02.23 |
---|---|
자바스크립트와 멀티 쓰레드 3 - Service Worker로 백그라운드에서 네트워크다루기 (0) | 2025.02.08 |
자바스크립트와 멀티 쓰레드 1 - 자바스크립트가 싱글 쓰레드라고 불리는 이유와 Worker로 여러 개의 쓰레드 사용하기 (0) | 2025.01.11 |
자바스크립트 await는 이벤트 루프 내에서 어떻게 동작할까 (0) | 2024.10.23 |
동적 배열을 사용하는 자바스크립트에서 일어나는 일 (0) | 2024.10.15 |