지난 시간(G-2)까지 우리는 브라우저가 기본으로 주는 기능 위에서 작업했어요. HTML로 뼈대를 세우고, CSS로 꾸미고, JavaScript로 움직임을 넣고, 접근성과 검색 최적화까지 챙겼죠. 그런데 G-2 마지막에 이런 약속을 했어요. "다음 시간부터는 브라우저가 한 겹 더 깊이 숨겨 둔 강력한 기능들을 꺼내 쓴다"고요.
마지막엔 이 셋을 합쳐 사진에 흑백 필터를 거는 인스타그램 기능을 만들어요. 필터를 거는 동안에도 화면이 멈추지 않고, 한 번 본 사진은 인터넷이 끊겨도 보이도록요.
텍스트
[ 지금까지 (A~G) ] [ 오늘 (H-1) ]
HTML · CSS · JS Web Worker → 화면 안 멈추는 무거운 일
DOM · fetch · 모듈 Service Worker → 요청 가로채기
성능 · 접근성 · SEO Cache API → 오프라인 저장
PWA → 설치되는 웹앱
🎯 오늘의 목표: 메인 스레드를 막지 않고 무거운 일을 처리하고, 네트워크가 끊겨도 동작하는 "앱 같은 웹"을 만든다.
이번 모듈은 난이도가 꽤 높아요(★★★). 새 개념이 다섯 개나 나오니까, 한 Step에 하나씩 천천히 눈으로 확인하며 갈게요. 어렵게 느껴지는 게 정상이에요. "아직 다 이해 못 해도 괜찮아요" — 일단 화면에서 동작하는 걸 보고, 원리는 그 다음에 잡아요.
Step 1: 왜 백그라운드 일꾼이 필요한가
JavaScript는 일꾼이 한 명이에요
먼저 질문 하나. 브라우저에서 자바스크립트는 동시에 여러 일을 할 수 있을까요?
답은 "기본적으로는 아니다"예요. 자바스크립트는 싱글 스레드(single thread) 언어예요. 스레드(thread)는 "일을 처리하는 일꾼" 한 명이라고 생각하면 돼요. 식당으로 비유하면, 주방에 요리사가 딱 한 명이에요. 이 한 명이 주문도 받고, 요리도 하고, 서빙도 하고, 설거지도 다 해요.
이 한 명의 일꾼(메인 스레드, main thread)이 우리 페이지에서 하는 일은 정말 많아요.
텍스트
[ 메인 스레드 = 일꾼 한 명 ]
┌──────────────────────────────┐
│ · 화면 그리기 (렌더링) │
│ · JavaScript 실행 │
│ · 클릭 · 입력 · 스크롤 처리 │
└──────────────────────────────┘
이 모든 걸 혼자서, 순서대로
평소엔 각 일이 워낙 빨리 끝나서(수 밀리초) 우리 눈엔 "동시에 일어나는 것처럼" 보여요. 버튼을 누르면 바로 반응하고, 스크롤하면 부드럽게 따라오죠.
일꾼이 무거운 일에 붙잡히면 화면이 멈춰요
문제는 한 가지 일이 오래 걸릴 때예요. 요리사가 한 손님의 복잡한 요리에 5분을 매달리면, 그동안 다른 주문은 전혀 못 받아요. 새 손님이 들어와 "여기요!" 해도 못 들어요.
자바스크립트도 똑같아요. 예를 들어 이런 무거운 계산을 메인 스레드에서 돌린다고 해볼게요.
JavaScript
// ⚠ 이렇게 하면 안 돼요 — 메인 스레드가 이 반복에 붙잡혀요
let sum = 0;
for (let i = 0; i < 10_000_000_000; i++) {
sum += i;
}
// 이 반복이 끝날 때까지 (몇 초간) 화면이 통째로 얼어붙어요
이 반복이 도는 몇 초 동안, 일꾼은 오직 이 계산에만 붙잡혀 있어요. 그 사이엔 이런 일이 벌어져요.
텍스트
시간 → 0초 ─────────── 계산 중 (멈춤) ─────────── 3초
│ │
버튼 클릭 ✗ 안 먹힘 스크롤 ✗ 안 됨 하트 애니메이션 ✗ 멈춤
버튼을 눌러도 눌리지 않고, 스크롤도 안 되고, 입력칸에 글자를 쳐도 안 나와요. 사용자 눈엔 "사이트가 죽었나?" 싶은 순간이죠. 이걸 블로킹(blocking), 화면이 얼어붙는 걸 프리징(freezing)이라고 해요.
🙋 학생 질문 — "튜터님, 그럼 무거운 계산은 그냥 하면 안 되는 건가요?"
좋은 질문이에요. 무거운 계산 자체가 나쁜 건 아니에요. 사진 필터, 큰 데이터 정렬, 이미지 압축 같은 건 실제로 무거워요. 문제는 그걸 일꾼 한 명에게(메인 스레드에) 시키는 것이에요.
해법은 "일꾼을 한 명 더 고용하는 것"이에요. 무거운 일 전담 일꾼을 따로 두면, 그 일꾼이 계산하는 동안에도 메인 일꾼은 화면을 계속 그릴 수 있어요. 그 추가 일꾼이 바로 다음 Step에서 만날 Web Worker(웹 워커)예요. 우리말로 "백그라운드 일꾼"이라고 부를게요.
💡 여기까지 정리: 자바스크립트는 일꾼이 한 명(싱글 스레드)이라, 무거운 일에 붙잡히면 화면 전체가 멈춰요. 이 문제를 푸는 게 오늘의 출발점이에요.
Step 2: Web Worker 첫걸음
일꾼을 한 명 더 고용하기
Web Worker(웹 워커)는 메인 스레드와 별개로 도는 두 번째 일꾼이에요. 무거운 일을 이 일꾼에게 넘기면, 그 일꾼이 일하는 동안에도 메인 일꾼은 화면을 멈추지 않고 그려요.
규칙이 하나 있어요. 두 일꾼은 변수를 직접 공유하지 않아요. 떨어진 방에서 일하니까요. 오직 편지(메시지)를 주고받으며 대화해요. 이 편지를 보내는 게 postMessage, 편지가 도착하면 깨어나는 게 onmessage예요.
텍스트
[ 메인 스레드 ] [ Web Worker ]
(화면 담당 일꾼) (무거운 일 담당 일꾼)
│ │
│ worker.postMessage(편지) ───────▶ │ onmessage 깨어남
│ │ ... 무거운 계산 ...
│ message 받음 ◀─────── postMessage(답장)
│ │
화면은 그동안에도 계속 움직여요!
일꾼이 할 일을 따로 파일로 써요
Web Worker는 자기만의 파일에 일할 내용을 적어요. 우리는 js/filter-worker.js라는 파일을 새로 만들었어요. 이 파일 안의 코드는 메인이 아니라 워커 안에서 돌아요.
JavaScript
// instagram-clone-frontend/js/filter-worker.js
// 메인에서 편지가 오면 이 함수가 깨어나요. e.data 에 보낸 내용이 담겨 있어요.
self.onmessage = (e) => {
const { type } = e.data;
// '무거운 계산' 흉내 — 0부터 limit 까지 한 땀씩 더해요. 일부러 시간이 걸려요.
if (type === "count") {
const { limit } = e.data;
let sum = 0;
for (let i = 0; i < limit; i++) {
sum += i;
}
self.postMessage({ type: "count", sum }); // 다 끝나면 결과를 편지로 돌려보내요
return;
}
};
self는 워커가 자기 자신을 부르는 이름이에요(메인에서 window라고 부르는 것과 비슷해요). 편지가 오면 self.onmessage가 깨어나고, 일이 끝나면 self.postMessage로 결과를 돌려보내요.
여기서 핵심은 for 반복이 워커 안에서 돈다는 거예요. 몇 초가 걸리든, 이 일꾼이 따로 처리하니까 메인 일꾼(화면)은 멀쩡해요.
메인에서 일꾼을 부르고 편지를 보내요
이제 메인 쪽 코드예요. js/image-filter.js에서 워커를 만들고, 버튼을 누르면 편지를 보내요.
JavaScript
// instagram-clone-frontend/js/image-filter.js
// Vite 에게 "이 파일은 Worker 야"라고 알려주는 정식 표기예요.
// new URL(...) 로 경로를 주면, 빌드할 때 Vite 가 알아서 따로 묶어 줘요.
function createWorker() {
return new Worker(new URL("./filter-worker.js", import.meta.url), { type: "module" });
}
new Worker(...)가 일꾼을 한 명 고용하는 순간이에요. 우리는 빌드 도구로 Vite를 쓰니까(E-3에서 배웠죠), Vite가 권장하는 표기인 new URL("./filter-worker.js", import.meta.url)로 경로를 적어요. 이렇게 하면 빌드할 때 Vite가 워커 파일을 알아서 따로 묶어 줘요.
버튼을 만들고, 누르면 편지를 보내요.
JavaScript
// 'Worker 체험' 버튼 — 무거운 계산을 백그라운드로 돌려도 화면은 안 멈춰요.
const countBtn = document.createElement("button");
countBtn.type = "button";
countBtn.className = "filter-btn";
countBtn.textContent = "무거운 계산 (Worker)";
figure.append(countBtn);
countBtn.addEventListener("click", () => {
countBtn.disabled = true;
countBtn.textContent = "계산 중...";
// 메인은 이 편지만 보내고 곧장 손을 떼요 — 계산은 일꾼이 따로 해요.
worker.postMessage({ type: "count", limit: 1_000_000_000 });
});
worker.postMessage(...)로 "0부터 10억까지 세 줘"라고 편지를 보내요. 중요한 건, 메인은 이 편지를 부치고 곧장 손을 떼요. 계산이 끝날 때까지 기다리지 않아요. 답장은 나중에 따로 받아요.
JavaScript
// 일꾼이 계산을 끝내고 편지를 돌려주면 이 함수가 깨어나요.
worker.addEventListener("message", (e) => {
if (e.data.type === "count") {
console.log("백그라운드 계산 완료! 합계:", e.data.sum);
countBtn.disabled = false;
countBtn.textContent = "무거운 계산 (Worker)";
return;
}
});
직접 확인해보세요
피드 페이지 첫 게시물 사진 아래에 "무거운 계산 (Worker)" 버튼이 생겼어요. 눌러보세요. 버튼이 "계산 중..."으로 바뀌고, 몇 초 뒤 콘솔에 합계가 찍혀요.
여기서 실험을 하나 해보세요. 계산이 도는 그 몇 초 동안, 다른 버튼을 눌러보거나 스크롤해보세요. 멀쩡하게 동작하죠? 만약 이 10억 번 반복을 메인 스레드에서 돌렸다면(Step 1처럼) 화면이 통째로 얼어붙었을 거예요. 워커에게 맡겼더니 화면은 멈추지 않아요. 이게 백그라운드 일꾼의 힘이에요.
💡 여기까지 정리: Web Worker는 별도 파일에서 도는 두 번째 일꾼이에요. postMessage로 편지를 보내고 onmessage로 답을 받아요. 무거운 일을 넘기면 화면이 멈추지 않아요.
Step 3: 이미지 필터를 Worker에게 맡기기
사진 한 장이 픽셀 수백만 개예요
이제 진짜 인스타그램 기능을 만들어요. 사진에 흑백 필터 걸기예요.
흑백 필터가 왜 무거운 일일까요? 사진은 사실 작은 점(픽셀, pixel) 수백만 개가 모인 거예요. 600×600 사진이면 36만 픽셀, 큰 사진은 수백만 픽셀이죠. 흑백으로 바꾸려면 그 점 하나하나의 색을 다 계산해서 바꿔야 해요. 이걸 메인 스레드에서 하면? 맞아요, 화면이 멈춰요. 그러니 이 일이야말로 워커에게 딱 맞아요.
픽셀 하나는 숫자 네 개로 표현돼요. R(빨강) · G(초록) · B(파랑) · A(투명도), 각각 0~255 값이에요.
텍스트
한 픽셀 = 숫자 4칸: [ R , G , B , A ]
사진 전체 = 픽셀이 줄줄이:
픽셀0 픽셀1 픽셀2
[R,G,B,A] [R,G,B,A] [R,G,B,A] ...
└─4칸─┘ └─4칸─┘ └─4칸─┘
index 0~3 index 4~7 index 8~11
그래서 픽셀 배열을 다룰 땐 4칸씩 건너뛰며 한 점을 처리해요. i, i+1, i+2가 R·G·B, i+3이 투명도(A)예요.
흑백으로 바꾸는 공식
흑백은 "R·G·B를 모두 같은 회색 값으로 만들기"예요. 그럼 회색 값은 뭘로 정할까요? 단순히 셋의 평균을 써도 되지만, 사람 눈은 색마다 밝기를 다르게 느껴요. 초록을 가장 밝게, 파랑을 가장 어둡게 느끼죠. 그래서 이 비율을 씁니다.
텍스트
회색 = R × 0.299 + G × 0.587 + B × 0.114
└ 초록 비중이 제일 커요 (사람 눈 기준)
이 공식을 워커 파일에 넣어요. Step 2의 filter-worker.js에 흑백 처리 분기를 더했어요.
JavaScript
// instagram-clone-frontend/js/filter-worker.js
// (self.onmessage 안, count 분기 다음에 이어져요)
// 사진의 모든 픽셀을 흑백으로 바꿔요. 픽셀이 수백만 개라 메인에서 하면 화면이 멈춰요.
if (type === "grayscale") {
const { buffer, width, height } = e.data;
const pixels = new Uint8ClampedArray(buffer); // [R,G,B,A, R,G,B,A, ...] 네 칸이 한 점
for (let i = 0; i < pixels.length; i += 4) {
// 사람 눈이 느끼는 밝기 비율로 한 점의 회색 값을 구해요(초록을 가장 밝게 느껴요).
const gray = pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114;
pixels[i] = gray; // R
pixels[i + 1] = gray; // G
pixels[i + 2] = gray; // B
// pixels[i + 3] 은 투명도(A) — 안 건드려요
}
// 결과 버퍼를 '복사 없이' 돌려보내요(두 번째 인자 = 소유권 넘기기, Transferable).
self.postMessage({ type: "grayscale", buffer: pixels.buffer, width, height }, [pixels.buffer]);
}
Uint8ClampedArray는 0~255 사이 숫자만 담는 특별한 배열이에요(픽셀 값에 딱 맞아요). i += 4로 4칸씩 건너뛰며, 각 점의 R·G·B를 같은 회색 값으로 덮어써요.
Transferable — 복사 없이 넘기기
마지막 줄에 눈여겨볼 게 있어요. postMessage의 두 번째 인자[pixels.buffer]예요.
원래 워커에 편지를 보내면 내용이 복사돼요. 그런데 사진 픽셀은 수 메가바이트라, 복사하는 것만으로도 느려요. 그래서 "복사하지 말고 소유권만 넘겨"라고 알려주는 게 이 두 번째 인자예요. 이걸 Transferable(트랜스퍼러블, 양도 가능)이라고 해요.
텍스트
일반 메시지: 메인 [버퍼] ──복사──▶ 워커 [버퍼 복사본] (둘 다 가짐, 느림)
Transferable: 메인 [ ] ──양도──▶ 워커 [버퍼] (넘긴 쪽은 비워짐, 빠름)
소유권을 넘기면 보낸 쪽의 버퍼는 텅 비어요(더는 못 써요). 대신 큰 데이터를 순식간에 넘길 수 있어요. 사진처럼 무거운 데이터를 주고받을 때 꼭 쓰는 기법이에요.
메인에서 픽셀을 꺼내 워커로 보내기
메인 쪽(image-filter.js)에선 사진의 픽셀을 어떻게 꺼낼까요? 보이지 않는 canvas(캔버스)에 사진을 그린 뒤, 거기서 픽셀을 뽑아내요.
JavaScript
// instagram-clone-frontend/js/image-filter.js
// '흑백' 버튼을 누르면 실행돼요.
grayBtn.addEventListener("click", () => {
grayBtn.disabled = true;
// 다른 도메인(picsum) 사진의 픽셀을 읽으려면 'crossOrigin 허락'을 받아 새로 불러와요.
const source = new Image();
source.crossOrigin = "anonymous";
source.onload = () => {
// 1) 사진을 보이지 않는 canvas 에 그려서 픽셀(ImageData)을 꺼내요.
const canvas = document.createElement("canvas");
canvas.width = source.naturalWidth;
canvas.height = source.naturalHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(source, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 2) 픽셀 버퍼를 일꾼에게 '복사 없이' 넘겨요(Transferable).
worker.postMessage(
{ type: "grayscale", buffer: imageData.data.buffer, width: canvas.width, height: canvas.height },
[imageData.data.buffer]
);
};
source.src = img.currentSrc || img.src;
});
canvas.getContext("2d")로 그림판을 얻고, drawImage로 사진을 그린 다음, getImageData로 픽셀을 통째로 꺼내요. 그 픽셀 버퍼를 워커에 Transferable로 넘기는 거예요.
⚠️ crossOrigin 허락이 필요해요: 우리 사진은 picsum.photos라는 다른 도메인에서 와요. 보안 규칙상, 다른 도메인 사진의 픽셀을 그냥 읽으면 브라우저가 막아요(canvas가 "오염"됐다고 해요). 그래서 source.crossOrigin = "anonymous"로 "픽셀 읽기 허락"을 받아 사진을 새로 불러와요. picsum이 이 허락을 내주는 서버라 동작해요.
워커가 보낸 흑백 픽셀을 화면에 칠하기
워커가 흑백으로 바꾼 버퍼를 돌려주면, 새 canvas에 칠해서 원본 사진을 교체해요.
JavaScript
// 워커가 보낸 편지를 받는 곳 — type 으로 어떤 답인지 갈라요.
worker.addEventListener("message", (e) => {
if (e.data.type === "grayscale") {
// 흑백으로 바뀐 버퍼를 canvas 에 다시 칠해, 원본 사진을 흑백으로 교체해요.
const { buffer, width, height } = e.data;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.className = "post-photo-canvas";
const ctx = canvas.getContext("2d");
const pixels = new Uint8ClampedArray(buffer);
ctx.putImageData(new ImageData(pixels, width, height), 0, 0);
img.replaceWith(canvas); // 원본 <img> 를 흑백 <canvas> 로 교체
grayBtn.textContent = "흑백 완료";
}
});
putImageData로 흑백 픽셀을 canvas에 칠하고, img.replaceWith(canvas)로 원본 사진 자리에 흑백 canvas를 끼워 넣어요. 그리고 이걸 첫 게시물에 연결해요(feed.js).
JavaScript
// instagram-clone-frontend/js/feed.js
import { setupImageFilter } from "./image-filter.js";
// ... 첫 사진(LCP)을 그릴 때 ...
// H-1: 맨 위 첫 사진에만 'Worker' 데모 버튼을 달아요(무거운 일 → 백그라운드).
setupImageFilter(node.querySelector(".post-photo"));
직접 확인해보세요
첫 게시물 아래 "흑백" 버튼을 눌러보세요. 컬러 사진이 흑백으로 바뀌어요. 바뀌는 그 순간에도 스크롤이나 다른 버튼이 멀쩡하죠? 픽셀 수십만 개를 계산하는 무거운 일을 워커가 대신했으니까요.
💡 여기까지 정리: 사진은 픽셀(R·G·B·A) 묶음이에요. canvas로 픽셀을 꺼내 워커에 Transferable로 넘기고, 워커가 흑백 계산을 마친 버퍼를 다시 canvas에 칠해요. 무거운 픽셀 처리를 백그라운드로 돌렸어요.
다음 시간(H-2)엔 이 canvas로 실시간 그림을 그려요. 워커가 픽셀을 계산하고, canvas가 화면에 그려내는 이 조합이 그래픽의 출발점이에요.
Step 4: Service Worker — 요청을 가로채는 중간 일꾼
또 다른 일꾼, 그런데 역할이 달라요
지금까지의 Web Worker는 "무거운 계산"을 맡는 일꾼이었어요. 이번에 만날 Service Worker(서비스 워커)도 백그라운드 일꾼이지만, 역할이 완전히 달라요.
서비스 워커는 페이지와 네트워크 사이에 앉아, 모든 네트워크 요청을 가로채는 중간 일꾼이에요. 우편물로 비유하면, 집(페이지)과 우체국(네트워크) 사이에 개인 비서를 한 명 두는 거예요. 모든 편지가 이 비서를 거쳐 가요. 비서는 "이건 서랍에 보관해 둔 걸로 줄게", "이건 우체국에 가서 받아올게"를 직접 정할 수 있어요.
텍스트
[ 페이지 ] [ Service Worker ] [ 네트워크 ]
(우리 집) (개인 비서) (우체국)
│ │ │
│ 사진 줘 ──────────▶ │ 음, 서랍에 있나? ──┐ │
│ │ │ │
│ 사진 받음 ◀──────── │ ◀── 있으면 바로 줌 ─┘ │
│ │ 없으면 ──────────────▶ 우체국에서 받아옴
이 "가로채기" 능력 덕분에, 서비스 워커는 인터넷이 끊겨도 서랍(캐시)에서 꺼내 주는 일을 할 수 있어요. 이게 오프라인 동작의 핵심이에요.
서비스 워커는 사이트 맨 위에 둬요
서비스 워커가 사이트 전체의 요청을 가로채려면, 파일이 사이트 맨 위 경로(/sw.js)에 있어야 해요. 우리는 Vite를 쓰니까 public/ 폴더에 두면 돼요. public/에 둔 파일은 빌드할 때 사이트 맨 위로 그대로 올라가거든요(robots.txt를 G-2에서 그렇게 뒀죠).
public/sw.js를 새로 만들고, 세 가지 생명주기 이벤트의 뼈대를 잡아요.
JavaScript
// instagram-clone-frontend/public/sw.js
// 1) install — 서비스 워커가 설치되는 순간 딱 한 번 깨어나요.
self.addEventListener("install", (event) => {
console.log("[SW] 설치됨");
});
// 2) activate — 새 버전이 깨어나 페이지를 맡기 시작하는 순간.
self.addEventListener("activate", (event) => {
console.log("[SW] 활성화됨");
});
// 3) fetch — 페이지가 보내는 '모든' 네트워크 요청이 이 일꾼을 거쳐 가요.
self.addEventListener("fetch", (event) => {
// event.respondWith(...) 를 부르지 않으면 브라우저가 평소대로 네트워크로 가요.
});
서비스 워커의 일생은 세 단계예요.
install(설치): 처음 등록될 때 딱 한 번. 여기서 필요한 파일을 미리 서랍에 담아요(다음 Step).
activate(활성화): 새 버전이 깨어나 페이지를 맡기 시작할 때. 낡은 서랍을 비워요.
fetch(요청): 페이지가 보내는 모든 요청이 여기를 거쳐요. 가로채기가 일어나는 곳이에요.
페이지에서 서비스 워커를 깨워요
이제 feed.js에서 서비스 워커를 등록해요.
JavaScript
// instagram-clone-frontend/js/feed.js
// ===== H-1: 서비스 워커 등록 — 오프라인 저장소 일꾼을 깨워요 =====
// 브라우저가 지원할 때만(점진적 향상), 페이지가 다 뜬 뒤에 등록해요.
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").then(
(registration) => console.log("[SW] 등록 성공:", registration.scope),
(error) => console.error("[SW] 등록 실패:", error)
);
});
}
navigator.serviceWorker.register("/sw.js")가 비서를 고용하는 순간이에요. "serviceWorker" in navigator로 먼저 지원 여부를 확인하는 건 점진적 향상(F 모듈에서 익힌 습관이죠) — 지원 안 하는 브라우저에선 그냥 평소대로 동작하게요.
직접 확인해보세요
피드 페이지를 열고 DevTools(F12) → Application 탭 → Service Workers를 보세요. sw.js가 "activated and is running" 상태로 떠 있으면 등록 성공이에요. 콘솔엔 "[SW] 등록 성공"과 "[SW] 설치됨"이 찍혀요.
⚠️ 개발 중엔 빌드해서 확인해요: 서비스 워커는 캐시를 다루다 보니, 개발 서버보다 빌드한 결과로 확인하는 게 깔끔해요. npm run build 후 npm run preview로 열어서 테스트하세요. (그리고 서비스 워커는 https 또는 localhost에서만 동작해요 — 보안 기능이라서요.)
💡 여기까지 정리: Service Worker는 페이지와 네트워크 사이에서 요청을 가로채는 중간 일꾼이에요. install·activate·fetch 세 단계로 살아요. public/sw.js에 두고 register로 깨워요.
Step 5: Cache API — 오프라인 저장소
서랍(캐시)에 파일을 담아둬요
서비스 워커가 요청을 가로챌 수 있게 됐으니, 이제 서랍을 채울 차례예요. 이 서랍이 Cache API(캐시 API)예요. 받아온 파일(HTML·CSS·사진 등)을 이름표 붙은 보관함에 저장해 두고, 나중에 꺼내 쓰는 창고예요.
캐시를 다루는 세 가지 동작만 알면 돼요.
텍스트
caches.open("이름") → 이름표 붙은 서랍을 열어요 (없으면 새로 만들어요)
cache.put(요청, 응답) → 받아온 파일을 서랍에 넣어요
caches.match(요청) → 서랍에서 찾아 꺼내요 (없으면 비어 있음)
설치할 때 앱 껍데기를 미리 담아요
sw.js의 install 단계에서, 앱이 뜨는 데 꼭 필요한 파일을 미리 서랍에 담아요. 이걸 precache(미리 캐싱)라고 해요.
JavaScript
// instagram-clone-frontend/public/sw.js
// 캐시(받아둔 파일 보관함)에 이름표를 붙여요. 새 버전을 낼 땐 v2, v3 으로 올려요.
const CACHE_NAME = "insta-cache-v1";
// 앱이 처음 뜰 때 꼭 필요한 '껍데기' — 미리 받아 캐시에 담아 둬요(precache).
const APP_SHELL = ["/feed.html"];
// 1) install — 설치되는 순간. 앱 껍데기를 캐시에 미리 담아요.
self.addEventListener("install", (event) => {
// waitUntil: "이 약속(Promise)이 끝날 때까지 설치를 끝난 걸로 치지 마" 라는 뜻이에요.
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
);
});
event.waitUntil(...)은 "이 약속(Promise)이 끝날 때까지 설치를 마쳤다고 하지 마"라는 뜻이에요. 캐시에 다 담길 때까지 기다리게 하는 거죠. C 모듈에서 배운 Promise가 여기서 또 나와요.
새 버전이 깨어나면 낡은 서랍을 비워요
캐시 이름에 v1이라고 버전을 붙인 이유가 있어요. 나중에 앱을 고쳐 v2로 올리면, activate 단계에서 낡은 v1 서랍을 비워야 옛 파일이 안 남아요.
JavaScript
// 2) activate — 새 버전이 깨어나는 순간. 이름표가 다른 옛 캐시는 비워요.
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
)
)
);
});
caches.keys()로 모든 서랍 이름을 가져와, 지금 쓰는 CACHE_NAME이 아닌 것만 골라 지워요. filter와 map(C-4에서 배운 배열 메서드!)이 여기서도 쓰여요.
사진은 "캐시 먼저" 전략으로
이제 핵심, fetch에서 요청을 가로채 캐시를 활용해요. 사진은 "캐시 먼저(cache-first)" 전략을 써요. 서랍에 있으면 바로 꺼내 주고(빠르고, 오프라인도 OK), 없으면 네트워크에서 받아 서랍에 저장해 둬요.
JavaScript
// 3) fetch — 모든 네트워크 요청이 이 일꾼을 거쳐 가요. 여기서 캐시/네트워크를 정해요.
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return; // 저장(POST) 같은 요청은 캐시하지 않아요
// 사진(이미지)은 '캐시 먼저' — 한 번 받은 사진은 캐시에서 바로 꺼내 써요(오프라인도 보임).
if (request.destination === "image") {
event.respondWith(cacheFirst(request));
return;
}
// ... CSS·JS 도 같은 전략으로 (생략) ...
});
// 캐시 먼저: 캐시에 있으면 그걸, 없으면 네트워크에서 받아 캐시에 저장하고 돌려줘요.
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached; // 캐시에 있으면 네트워크는 건너뛰어요(빠르고, 오프라인도 OK)
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone()); // 응답은 한 번만 읽을 수 있어 복제(clone) 후 저장
return response;
}
event.respondWith(...)가 "이 요청엔 내가 답할게"라고 가로채는 부분이에요. cacheFirst 함수는 먼저 서랍을 뒤지고(caches.match), 있으면 그걸 주고, 없으면 네트워크에서 받아 저장(cache.put)한 뒤 돌려줘요.
response.clone()이 눈에 띄죠? 응답(response)은 한 번만 읽을 수 있어요. 서랍에 저장하면서 동시에 화면에도 줘야 하니, 복제본을 하나 떠서 저장해요.
직접 확인해보세요
npm run build → npm run preview로 열고, 피드를 한 번 본 뒤 DevTools → Application 탭 → Cache Storage를 펼쳐 보세요. insta-cache-v1 서랍 안에 사진·CSS·JS가 차곡차곡 담겨 있어요. 이 순간, 인터넷이 끊겨도 이 파일들은 서랍에서 꺼내 쓸 수 있는 상태가 된 거예요.
💡 여기까지 정리: Cache API는 받아온 파일을 보관하는 서랍이에요. install에서 앱 껍데기를 미리 담고, fetch에서 "캐시 먼저" 전략으로 사진을 가로채 저장해요. 이제 오프라인 동작의 절반이 완성됐어요.
Step 6: PWA 매니페스트와 설치 조건
웹을 "앱처럼" 설치되게 만들기
여기까지 오면 우리 웹은 거의 앱이에요. 오프라인에서도 동작하고, 백그라운드 일꾼도 있죠. 마지막 한 조각, 홈 화면에 설치되는 앱으로 만들어요. 이게 PWA(Progressive Web App, 점진적 웹앱)예요.
PWA = 웹의 편리함(설치 없이 링크로 접속) + 앱의 경험(홈 화면 아이콘, 전체 화면 실행, 오프라인 동작). 설치되려면 설정표 한 장이 필요해요. 그게 매니페스트(manifest)예요.
매니페스트 — 앱의 설정표
매니페스트는 "이 앱의 이름은 뭐고, 아이콘은 뭐고, 어떻게 보일지"를 적은 JSON 파일이에요. public/manifest.webmanifest로 만들어요.
display: "standalone" — 브라우저 주소창 없이 앱처럼 전체 화면으로 떠요.
background_color / theme_color — 켜질 때 배경색, 상단 바 색.
icons — 홈 화면 아이콘. 192px과 512px이 필요해요. maskable은 둥근 아이콘 틀에 맞춰 잘려도 괜찮은 버전이에요.
매니페스트를 HTML에 연결해요
index.html · feed.html · profile.html 세 페이지 <head>에 매니페스트를 연결해요.
HTML
<!-- instagram-clone-frontend/feed.html (head 안) -->
<!-- PWA — 앱처럼 설치할 수 있게 해주는 설정표(매니페스트)와 홈 화면 아이콘 -->
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
apple-touch-icon은 iPhone에서 홈 화면에 추가할 때 쓰는 아이콘이에요(애플은 매니페스트 아이콘 대신 이걸 봐요).
설치 조건은 브라우저마다 달라요
PWA가 설치되려면 조건이 있어요. ① HTTPS(또는 localhost) ② 매니페스트 ③ 등록된 서비스 워커. 우리는 셋 다 갖췄죠. 그런데 설치 방법은 브라우저마다 꽤 달라요. 이건 2026년 현재도 통일되지 않은 부분이라 꼭 짚고 가요.
텍스트
[ Android Chrome ] 조건을 갖추면 "앱 설치" 버튼이 자동으로 떠요
(주소창 오른쪽 설치 아이콘 / 자동 안내 배너)
[ 데스크톱 Chrome ] 주소창 오른쪽에 설치 아이콘이 떠요
[ iOS Safari ] 자동 안내가 없어요. 사용자가 직접
공유 버튼 → "홈 화면에 추가"를 눌러야 해요
⚠️ iOS는 자동 설치 안내가 없어요: 2026년 현재도 iOS Safari는 "앱 설치" 자동 프롬프트를 띄우지 않아요. 사용자가 공유 메뉴 → 홈 화면에 추가를 직접 골라야 설치돼요. 그래서 iOS 사용자에겐 "공유 버튼을 눌러 홈 화면에 추가해 주세요" 같은 안내를 따로 보여주는 게 좋아요. Android Chrome은 조건만 맞으면 알아서 설치 버튼이 떠요. 이 차이를 모르면 "왜 아이폰에선 설치가 안 되지?" 하고 헤매기 쉬워요.
직접 확인해보세요
빌드 후 preview로 열고, 데스크톱 Chrome 주소창 오른쪽을 보세요. 설치 아이콘(모니터에 ↓ 모양)이 떠요. 눌러보면 우리 인스타 클론이 독립 창으로 떠요 — 주소창 없이, 앱처럼요. DevTools → Application 탭 → Manifest에서 아이콘·이름이 제대로 읽혔는지도 확인할 수 있어요.
💡 여기까지 정리: 매니페스트는 앱의 설정표예요. 이름·아이콘·표시 방식을 적어 HTML에 연결하면 "설치되는 웹앱"이 돼요. 단, 설치 방법은 브라우저마다 달라요(특히 iOS는 수동).
Step 7: 오프라인 시뮬레이션과 폴백 전략
인터넷을 진짜로 끊어봐요
이제 진짜 시험이에요. 인터넷을 끊고도 우리 앱이 동작할까요?
DevTools에서 인터넷을 흉내 내 끊을 수 있어요. Network 탭 → Throttling(속도 조절) → Offline을 고르면, 브라우저가 "인터넷이 끊긴 척"을 해요. 이 상태로 새로고침해 보는 거예요.
텍스트
[ 온라인 ] 페이지 요청 → 네트워크 → 정상 응답 → 화면 OK
[ 오프라인 ] 페이지 요청 → 네트워크 ✗ 실패
└▶ 서비스 워커가 캐시에서 꺼내 줌 → 화면 OK
캐시에도 없으면 → "오프라인" 안내 페이지
페이지 이동은 "네트워크 먼저" 전략으로
사진은 Step 5에서 "캐시 먼저"로 했죠. 그런데 페이지(HTML)는 전략이 달라요. 페이지는 최신 내용이 중요하니 "네트워크 먼저(network-first)" 가 어울려요. 네트워크를 먼저 시도하고, 실패(오프라인)하면 캐시나 오프라인 안내 페이지로 넘어가요.
JavaScript
// instagram-clone-frontend/public/sw.js
// 페이지 이동(HTML 문서 요청)은 '네트워크 먼저' — 끊겼으면 오프라인 안내 페이지로.
if (request.mode === "navigate") {
event.respondWith(networkFirst(request));
return;
}
JavaScript
// 네트워크 먼저: 네트워크를 먼저 시도하고, 실패(오프라인)하면 캐시나 offline.html 로 폴백해요.
async function networkFirst(request) {
try {
return await fetch(request);
} catch (error) {
const cached = await caches.match(request);
return cached || (await caches.match("/offline.html"));
}
}
request.mode === "navigate"는 "사용자가 페이지를 이동(새로고침·링크 클릭)하는 요청"이라는 뜻이에요. try / catch(E-2 에러 처리에서 배웠죠)로 네트워크를 시도하고, 실패하면 캐시 → 오프라인 페이지 순으로 폴백(fallback, 대비책)해요.
오프라인 안내 페이지를 만들어요
네트워크도 끊기고 캐시에도 없을 때 보여줄 마지막 페이지, public/offline.html을 만들어요. 이 페이지는 바깥 파일에 의존하면 안 돼요. 오프라인이라 CSS·JS를 못 받아올 수 있으니, 스타일과 스크립트를 한 파일 안에 다 담아요.
HTML
<!-- instagram-clone-frontend/public/offline.html (발췌) -->
<!-- 이 페이지는 오프라인에서도 떠야 해서, 바깥 파일을 안 부르고 혼자 다 갖고 있어요 -->
<body>
<div class="logo">Instagram</div>
<h1>지금은 오프라인이에요</h1>
<p>인터넷 연결이 끊겼어요. 연결을 확인한 뒤 다시 시도해 주세요.</p>
<button type="button" id="retry">다시 시도</button>
<script>
// 인라인 스크립트 — 캐시에 저장된 이 페이지 안에서 바로 동작해요(바깥 파일 의존 없음)
document.getElementById("retry").addEventListener("click", () => {
location.reload();
});
</script>
</body>
이 offline.html도 설치할 때 미리 캐시에 담아야 끊긴 뒤에 꺼낼 수 있어요. 그래서 앱 껍데기 목록에 추가해요.
JavaScript
// instagram-clone-frontend/public/sw.js
// 오프라인 안내 페이지도 미리 담아 둬야, 인터넷이 끊긴 뒤에도 보여줄 수 있어요.
const APP_SHELL = ["/feed.html", "/offline.html"];
직접 확인해보세요
빌드 후 preview로 한 번 페이지를 본 다음(캐시가 채워지게), Network 탭에서 Offline을 켜고 새로고침해 보세요. 인터넷이 끊겼는데도 피드가 그대로 떠요(캐시에서 꺼냈으니까요). 사진도 한 번 본 건 그대로 보여요. 캐시에 없는 새 페이지로 이동하면 "지금은 오프라인이에요" 안내가 떠요. 끝나면 Offline을 다시 꺼주세요.
💡 여기까지 정리: 페이지는 "네트워크 먼저", 실패하면 캐시 → 오프라인 페이지로 폴백해요. 자립형 offline.html을 미리 캐싱해 두면, 인터넷이 완전히 끊겨도 깔끔한 안내를 보여줄 수 있어요.
Step 8: 실전 — 필터와 오프라인 캐싱을 합치기
두 일꾼이 한 화면에서 함께 일해요
이제 오늘 만든 두 가지를 하나의 화면에서 합쳐요.
Web Worker — 첫 사진에 흑백 필터를 걸어요(화면 안 멈춤).
Service Worker — 아바타·사진을 캐시에 담아, 오프라인에서도 보이게요.
피드를 한 번 열면, 서비스 워커가 프로필 아바타와 게시물 사진을 모두 서랍에 담아요. 그래서 인터넷이 끊긴 뒤에도 아바타가 그대로 보여요. 동시에 첫 사진의 "흑백" 버튼은 워커로 필터를 걸죠. 두 종류의 백그라운드 일꾼이 각자 제 역할을 하는 거예요.
사진 캐시는 따로, 그리고 용량을 관리해요
여기서 실전 고민이 하나 생겨요. 사진은 용량이 커서, 무한정 캐시에 쌓이면 저장 공간을 너무 많이 먹어요. 브라우저가 주는 저장 공간(quota)에도 한계가 있고요. 그래서 두 가지를 해요.
사진 캐시를 앱 껍데기와 분리해요 — 사진은 insta-images-v1이라는 별도 서랍에.
사진은 최대 개수를 정하고, 넘으면 오래된 것부터 비워요.
JavaScript
// instagram-clone-frontend/public/sw.js
const CACHE_NAME = "insta-cache-v1"; // 앱 껍데기(HTML·CSS·JS)
const IMAGE_CACHE = "insta-images-v1"; // 사진은 따로 보관 — 양이 많아 한도를 따로 관리해요
const IMAGE_LIMIT = 60; // 사진은 최대 60장까지만 — 넘으면 오래된 것부터 비워요
const VALID_CACHES = [CACHE_NAME, IMAGE_CACHE];
fetch에서 사진은 이미지 전용 서랍으로 보내고, 저장 후 한도를 넘었는지 확인해요.
JavaScript
// 사진(아바타·게시물 이미지)은 '캐시 먼저' + 한도 관리 — 한 번 받은 사진은 오프라인도 보임.
if (request.destination === "image") {
event.respondWith(cacheFirst(request, IMAGE_CACHE, IMAGE_LIMIT));
return;
}
JavaScript
// 캐시에 담긴 사진이 너무 많아지지 않게, 한도를 넘으면 오래된 것부터 덜어내요(저장 공간 절약).
async function trimCache(cacheName, maxItems) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length <= maxItems) return;
// keys() 는 넣은 순서대로예요 — 맨 앞(가장 오래된 것)을 지우고, 한도에 들 때까지 반복해요.
await cache.delete(keys[0]);
trimCache(cacheName, maxItems);
}
cache.keys()는 넣은 순서대로 목록을 줘요. 그래서 맨 앞(가장 오래 전에 넣은 것)부터 지우면, 자연스럽게 오래된 사진부터 비워지는 셈이에요. 이건 실무에서 캐시를 다룰 때 늘 챙기는 부분이에요 — 받아두기만 하고 안 비우면 사용자 기기를 가득 채우거든요.
직접 확인해보세요
빌드 후 preview에서 피드를 스크롤로 한참 내려 사진을 많이 불러온 뒤, Application 탭 → Cache Storage → insta-images-v1을 보세요. 항목이 60개를 넘지 않고 유지돼요. 그리고 Network를 Offline으로 바꿔도, 아까 본 아바타와 사진은 그대로 떠요. 첫 사진의 "흑백" 버튼도 여전히 동작하고요. 무거운 일은 워커가, 오프라인 저장은 서비스 워커가 — 두 일꾼이 함께 만든 결과예요.
💡 여기까지 정리: 사진 캐시를 앱 껍데기와 분리하고 개수 한도를 두면, 오프라인에서도 사진이 보이면서 저장 공간은 아낄 수 있어요. Web Worker(필터)와 Service Worker(캐시)가 한 화면에서 각자의 일을 해요.
Step 9: 디버깅과 성능 측정
DevTools로 일꾼들을 들여다봐요
오늘 만든 건 화면 뒤에서 도는 것들이라, DevTools로 직접 들여다보는 법을 알아야 해요. 코드를 쓰는 것만큼 중요해요. Chrome DevTools(F12)의 두 탭을 익혀요.
Application 탭 — 백그라운드 일꾼과 저장소를 한눈에.
텍스트
Application
├─ Service Workers → sw.js 가 "activated" 상태인지, 갱신/중지 버튼
├─ Cache Storage → insta-cache-v1, insta-images-v1 서랍 내용
└─ Manifest → 앱 이름·아이콘·설치 가능 여부
여기서 서비스 워커를 멈추거나(Unregister), 캐시를 비우거나, 매니페스트가 제대로 읽혔는지 확인해요. 서비스 워커가 옛날 버전으로 남아 헷갈릴 땐 "Update on reload"를 켜두면 편해요.
Performance 탭 — 어느 일꾼이 일하는지.
흑백 버튼을 누르며 Performance를 녹화하면, 메인 스레드와 별개로 워커 스레드가 일하는 모습이 보여요. 메인 스레드는 한산하고(화면이 안 멈췄다는 증거), 워커 스레드만 바쁘게 픽셀을 계산하죠.
Lighthouse로 PWA 점수를 받아요
마지막으로, 우리 앱이 진짜 PWA로 인정받는지 점수를 받아봐요. DevTools → Lighthouse 탭에서 검사를 돌리면, 설치 가능 여부·오프라인 동작·매니페스트 유효성 같은 걸 자동으로 체크해 줘요.
텍스트
Lighthouse 검사 항목 (PWA / 설치 가능성)
✓ 매니페스트가 설치 조건을 갖췄나 (name · icons · start_url · display)
✓ 서비스 워커가 등록됐나
✓ HTTPS(또는 localhost)인가
✓ 적절한 크기의 아이콘(192·512)이 있나
오늘 우리가 단계마다 갖춘 것들이 그대로 체크리스트가 돼요. 하나씩 ✓가 켜지는 걸 보면, 왜 각 Step이 필요했는지 한 번에 정리돼요.
직접 확인해보세요
빌드 후 preview에서 Lighthouse를 돌려보세요. 그리고 Performance를 녹화하며 흑백 버튼을 눌러, 메인 스레드가 한산한 채로 워커만 일하는 모습을 직접 확인해보세요. "화면이 안 멈췄다"는 게 그래프로 증명돼요.
💡 여기까지 정리: Application 탭으로 서비스 워커·캐시·매니페스트를 들여다보고, Performance로 워커가 따로 일하는 걸 확인하고, Lighthouse로 PWA 점수를 받아요. 보이지 않는 기능일수록 도구로 확인하는 습관이 중요해요.
마무리
오늘 손에 넣은 것
오늘은 브라우저가 깊이 숨겨 둔 강력한 기능들을 꺼내 썼어요. 정리하면 이래요.
텍스트
무거운 일 → Web Worker → 화면이 안 멈춰요 (흑백 필터)
요청 가로채기 → Service Worker → 네트워크와 페이지 사이의 중간 일꾼
오프라인 저장 → Cache API → 끊겨도 동작 (사진·페이지 캐싱)
설치되는 웹앱 → PWA / 매니페스트 → 홈 화면 아이콘, 앱처럼 실행
Web Worker는 무거운 계산을 맡는 두 번째 일꾼이에요. postMessage로 편지를 주고받고, 사진 픽셀처럼 큰 데이터는 Transferable로 복사 없이 넘겨요.
Service Worker는 요청을 가로채는 중간 일꾼이에요. install·activate·fetch로 살아가며, 사진은 "캐시 먼저", 페이지는 "네트워크 먼저" 전략을 써요.
PWA 매니페스트로 홈 화면에 설치되는 앱을 만들었어요. 설치 방법은 브라우저마다 다르다는 것(특히 iOS 수동)도 챙겼어요.
G-2에서 약속한 세 가지 — 백그라운드 일꾼, 오프라인 저장소, 앱처럼 든든한 웹 — 을 오늘 다 손에 넣은 거예요.
다음 시간 예고
다음 시간(H-2)엔 Canvas와 시각화를 배워요. 오늘 흑백 필터에서 잠깐 쓴 그 canvas로, 이번엔 실시간으로 그림을 그려요. 워커가 픽셀을 계산하고 canvas가 화면에 그려내는 오늘의 조합이, 다음 시간엔 requestAnimationFrame으로 초당 60프레임 애니메이션까지 나아가요. 스토리 편집기 같은 그래픽 기능의 출발점이에요.
오늘은 정말 많은 새 개념을 한 번에 넘었어요. 다 외우지 않아도 괜찮아요. "무거운 일은 워커에게, 오프라인은 서비스 워커와 캐시로" 이 큰 그림만 잡고 가면, 나머지는 코드를 다시 볼 때마다 또렷해질 거예요. 오늘도 정말 고생 많으셨어요.
과제
[구현] 사진에 흑백 말고 다른 필터 더하기
Step 3의 흑백 필터를 참고해, 세피아(sepia, 옛날 사진 같은 갈색조) 필터를 워커에 추가해보세요.
(255를 넘으면 255로 자동으로 깎여요 — Uint8ClampedArray가 알아서 해줘요.)
image-filter.js에 "세피아" 버튼을 하나 더 달아, 같은 canvas 파이프라인으로 처리해요.
흑백과 세피아 모두 "픽셀 4칸씩 순회"라는 같은 틀을 써요. 공식만 바뀐다는 걸 확인해보세요.
[구현] 오프라인일 때 댓글 저장 막고 안내하기
오프라인이면 댓글 저장(POST)이 실패해요. 사용자에게 친절히 알려주세요.
feed.js의 댓글 제출 부분에서, navigator.onLine으로 현재 온라인 여부를 먼저 확인해요.
오프라인이면 서버에 보내지 말고 "오프라인 상태예요. 연결되면 다시 시도해 주세요" 토스트를 띄워요.
힌트: if (!navigator.onLine) { showToast("..."); return; }
[탐구] 캐시 전략 비교하기
우리는 사진엔 "캐시 먼저", 페이지엔 "네트워크 먼저"를 썼어요. 세 번째 전략으로 "stale-while-revalidate(일단 캐시 주고 뒤에서 갱신)"가 있어요.
이 세 전략이 각각 어떤 상황에 어울리는지 조사해보세요.
예: 자주 안 바뀌는 로고는? 매번 최신이어야 하는 뉴스 피드는? 빨라야 하지만 가끔 갱신되면 좋은 프로필은?
정답을 외우기보다, "이 파일은 최신이 중요한가, 속도가 중요한가"를 기준으로 스스로 판단하는 연습을 해보세요.
생각해볼 주제
1. 모든 무거운 일을 Web Worker로 옮기면 더 빠를까요?
Web Worker는 화면을 멈추지 않게 해주는 좋은 도구예요. 그렇다면 모든 계산을 워커로 옮기면 항상 이득일까요? 워커를 만드는 데도 비용이 들고, 메인과 워커가 데이터를 주고받는 데도 시간이 걸려요. 아주 가벼운 계산까지 워커로 보내면 오히려 느려질 수도 있어요. "어느 정도로 무거운 일부터 워커로 보내는 게 맞을지", 그 판단 기준을 한번 생각해보세요.
2. 캐시가 오히려 문제를 일으킬 때
오프라인 저장소는 편리하지만, "옛날 파일이 캐시에 남아 새 버전이 안 보이는" 문제를 일으킬 수 있어요. 사용자가 앱을 새로고침해도 예전에 받은 낡은 화면이 계속 뜨는 거죠. 우리는 캐시 이름에 버전(v1)을 붙이고 activate에서 옛 캐시를 지우는 방법을 썼어요. 이 방법만으로 충분할지, 아니면 또 어떤 상황에서 사용자가 낡은 화면에 갇힐 수 있을지 생각해보세요.
3. 오프라인에서 "할 수 있는 일"과 "할 수 없는 일"
서비스 워커와 캐시 덕분에 오프라인에서도 화면이 떠요. 하지만 모든 게 되는 건 아니에요. 한 번도 안 본 페이지, 캐시에 없는 사진, 서버에 새로 저장해야 하는 댓글은 오프라인에서 처리할 수 없어요. 좋은 오프라인 경험이란 "다 되게 만드는 것"이 아니라 "안 되는 걸 솔직하고 친절하게 알려주는 것"에 가까워요. 우리 인스타 클론에서 오프라인일 때 어떤 기능을 막고, 어떻게 안내하면 좋을지 구체적으로 떠올려보세요.
✅ 예시 답안정답 보기
오늘 과제는 "백그라운드 일꾼과 오프라인 저장소를 직접 손보며, 화면 뒤에서 도는 것들을 눈으로 확인하는" 거예요. 정답을 외우기보다, 코드를 바꿔 보고 DevTools(F12)의 Application·Network 탭에서 결과가 어떻게 달라지는지 직접 관찰하는 게 핵심이에요. npm run build로 빌드한 뒤 npm run preview로 결과물을 띄워 두고 진행하세요.
과제 예시답안
🎯 [과제 1 예시답안] 사진에 세피아 필터 더하기
핵심 접근
흑백과 세피아는 "픽셀을 4칸씩 순회한다"는 같은 틀을 써요. 달라지는 건 한 점의 새 색을 구하는 공식뿐이에요. 이 과제의 진짜 목적은 "필터 = 픽셀 순회 + 공식"이라는 구조를 손으로 확인하는 거예요.
답안
filter-worker.js의 self.onmessage 안에 sepia 분기를 더해요. 흑백 분기와 틀이 똑같아요.
JavaScript
// instagram-clone-frontend/js/filter-worker.js (분기 추가)
if (type === "sepia") {
const { buffer, width, height } = e.data;
const pixels = new Uint8ClampedArray(buffer);
for (let i = 0; i < pixels.length; i += 4) {
// 원래 R·G·B 를 먼저 꺼내 둬요 (덮어쓰기 전에!)
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
pixels[i] = r * 0.393 + g * 0.769 + b * 0.189; // 새 R
pixels[i + 1] = r * 0.349 + g * 0.686 + b * 0.168; // 새 G
pixels[i + 2] = r * 0.272 + g * 0.534 + b * 0.131; // 새 B
}
self.postMessage({ type: "sepia", buffer: pixels.buffer, width, height }, [pixels.buffer]);
}
image-filter.js엔 "세피아" 버튼을 하나 더 달고, 흑백과 똑같은 canvas 파이프라인으로 보내요. 받는 쪽 message 핸들러에 e.data.type === "sepia" 가지만 추가하면 돼요(흑백 가지를 복사해 type만 바꾸면 끝).
텍스트
같은 틀, 공식만 다름
흑백 gray = R·0.299 + G·0.587 + B·0.114 → R=G=B=gray
세피아 newR/newG/newB 를 각각 다른 비율로 → 갈색조
└ 픽셀 4칸씩 순회는 똑같아요
채점 포인트
포인트
설명
배점 가중
원본 보존
새 색을 구하기 전에 원래 R·G·B를 먼저 변수에 담았는가
상
분기 구조
흑백과 같은 "4칸 순회" 틀을 재사용했는가
상
Transferable
결과 버퍼를 두 번째 인자로 넘겨 복사 없이 돌려보냈는가
중
버튼 연결
image-filter에 세피아 버튼 + message 가지를 더했는가
중
흔한 실수
R을 덮어쓴 뒤 G·B를 계산함 — pixels[i] = ...를 먼저 하면, 새 G·B를 구할 때 R이 이미 바뀐 값이라 색이 틀어져요. 반드시 const r/g/b로 원본을 먼저 꺼내 두고 계산하세요. 흑백은 셋 다 같은 값이라 안 걸렸지만, 세피아는 바로 티가 나요.
255를 넘겨 걱정함 — 세피아 공식은 255를 넘는 값이 나올 수 있어요. 하지만 Uint8ClampedArray가 알아서 255로 깎아 줘요(Clamped = 잘라 준다는 뜻). 직접 Math.min을 안 써도 돼요.
흑백 message 가지를 안 고침 — 버튼만 달고 받는 쪽 type === "sepia" 가지를 안 만들면, 워커는 계산했는데 화면이 안 바뀌어요.
실무 개선 포인트 (심화)
진짜 인스타그램 필터는 흑백·세피아 같은 단순 공식을 넘어, 명암·채도·색온도를 한꺼번에 조절해요. 그래도 바탕은 똑같아요 — "픽셀을 순회하며 공식을 적용". 필터가 수십 개여도 틀 하나에 공식만 갈아 끼우는 구조죠.
필터를 여러 개 만들다 보면 워커의 if 가지가 길어져요. 실무에선 공식을 함수로 분리해 두고(function sepia(r,g,b)), 순회 루프는 하나로 두고 함수만 바꿔 부르는 식으로 정리해요. 같은 일을 더 깔끔하게 하는 연습으로 좋아요.
🎯 [과제 2 예시답안] 오프라인일 때 댓글 저장 막고 안내하기
핵심 접근
오프라인에서 안 되는 일을 "그냥 실패하게" 두면 사용자는 영문을 몰라요. 안 되는 걸 미리, 친절하게 알려주는 게 좋은 오프라인 경험이에요. navigator.onLine으로 현재 연결 상태를 먼저 확인하는 습관을 익히는 과제예요.
답안
feed.js의 댓글 제출(submit) 핸들러 맨 앞에서, 보내기 전에 연결을 확인해요.
JavaScript
// instagram-clone-frontend/js/feed.js (댓글 제출 핸들러 안, 맨 앞)
feed.addEventListener("submit", async (event) => {
const form = event.target.closest(".comment-form");
if (!form) return;
event.preventDefault();
// 오프라인이면 서버에 보내지 말고, 친절히 안내하고 멈춰요.
if (!navigator.onLine) {
showToast("오프라인 상태예요. 연결되면 다시 시도해 주세요.");
return;
}
// ... 이하 기존 저장(POST) 로직 ...
});
navigator.onLine은 지금 온라인이면 true, 오프라인이면 false를 줘요. 오프라인이면 showToast로 안내하고 return으로 멈추니, 헛된 요청을 보내 한참 기다리다 실패하는 일이 없어져요.
텍스트
온라인 댓글 작성 → navigator.onLine = true → 서버에 저장 → 화면 반영
오프라인 댓글 작성 → navigator.onLine = false → "오프라인이에요" 안내 후 멈춤
↑ 헛된 요청 안 보냄
채점 포인트
포인트
설명
배점 가중
사전 확인
요청을 보내기 전에navigator.onLine을 확인했는가
상
친절한 안내
실패가 아니라 이해되는 메시지를 보여 줬는가
상
조기 종료
오프라인이면 return으로 헛된 요청을 막았는가
중
위치
핸들러 맨 앞(preventDefault 직후)에 뒀는가
중
흔한 실수
요청을 보낸 뒤에야 확인함 — fetch가 실패하길 기다렸다 처리하면, 사용자는 한참 멈춘 화면을 봐요. 보내기 전에 막는 게 핵심이에요.
navigator.onLine을 맹신함 — 이 값은 "기기가 네트워크에 연결됐나"만 알려줘요. 와이파이엔 붙었는데 인터넷은 안 되는 상황은 true로 나와요. 그래서 이건 "1차 거름망"이고, 진짜 확실한 건 요청을 보내 보고 실패를 catch로 잡는 거예요. 둘을 함께 써야 빈틈이 없어요.
안내만 하고 안 멈춤 — 토스트만 띄우고 return을 빠뜨리면, 안내해 놓고 또 헛된 요청을 보내요.
실무 개선 포인트 (심화)
더 좋은 경험은 "막기"를 넘어 나중에 보내기예요. 오프라인에 쓴 댓글을 기기에 잠시 저장해 뒀다가, 연결이 돌아오면 자동으로 보내 주는 거죠. 이걸 백그라운드 동기화(Background Sync)라고 해요. 오늘 배운 서비스 워커가 그 일을 맡아요.
연결 상태가 바뀌는 순간을 잡는 online·offline 이벤트도 있어요(D-2의 이벤트와 같은 틀). 오프라인이 되면 상단에 "오프라인 상태" 띠를 띄우고, 돌아오면 치우는 식으로 상태를 화면에 늘 보여 주면 사용자가 덜 당황해요.
🎯 [과제 3 예시답안] 캐시 전략 비교하기
핵심 접근
정답을 외우는 과제가 아니에요. "이 파일은 최신이 중요한가, 속도가 중요한가"라는 한 가지 질문으로 전략을 고르는 감각을 기르는 거예요. 세 전략의 동작을 한 줄로 정리하고, 각각 어디에 어울리는지 짝지어 보세요.
답안
세 전략을 동작 순서로 정리하면 이래요.
텍스트
캐시 먼저 캐시 확인 → 있으면 그걸 (네트워크 안 감)
(cache-first) → 없으면 네트워크 → 저장
▶ 가장 빠름, 최신 보장 안 됨
네트워크 먼저 네트워크 시도 → 되면 그걸
(network-first) → 실패하면 캐시
▶ 최신 우선, 오프라인 대비책 있음
일단 캐시, 뒤에서 갱신 캐시 있으면 바로 보여 줌 (빠름)
(stale-while-revalidate) + 동시에 네트워크로 새 버전 받아 다음을 대비
▶ 빠르면서, 다음 번엔 최신
어디에 어울리는지 짝지으면 이렇게 정리돼요.
파일
어울리는 전략
이유
로고·아이콘 (거의 안 바뀜)
캐시 먼저
최신일 필요 없고, 빠른 게 최고
뉴스 피드·실시간 데이터
네트워크 먼저
항상 최신이 중요, 끊기면 캐시로 폴백
프로필·아바타 (가끔 바뀜)
일단 캐시, 뒤에서 갱신
빨라야 하지만 가끔 갱신되면 좋음
우리 코드와 연결하면, 사진엔 "캐시 먼저"(한 번 본 사진은 안 바뀌니까), 페이지엔 "네트워크 먼저"(최신 내용이 중요하니까)를 썼죠. 과제로 아바타에 "일단 캐시, 뒤에서 갱신"을 적용해 보면 세 전략을 다 다뤄 보게 돼요.
채점 포인트
포인트
설명
배점 가중
세 전략 이해
각 전략의 동작 순서를 정확히 설명했는가
상
판단 기준
"최신 vs 속도"라는 기준으로 짝지었는가
상
우리 코드 연결
사진=캐시 먼저, 페이지=네트워크 먼저를 설명했는가
중
트레이드오프
각 전략의 약점도 짚었는가
중
흔한 실수
"캐시 먼저가 항상 빠르니 최고"라고 결론 — 빠른 대신 낡은 화면에 갇힐 위험이 있어요. 빠름엔 늘 "최신을 놓칠 수 있다"는 대가가 따라요.
전략을 사이트 전체에 하나만 적용 — 좋은 앱은 파일 종류마다 다른 전략을 섞어 써요. 우리도 사진과 페이지에 다른 전략을 썼죠.
stale-while-revalidate를 "그냥 캐시 먼저"로 오해 — 이 전략은 캐시를 주면서 동시에 뒤에서 새 버전을 받아 둬요. "이번엔 빠르게, 다음엔 최신"이 핵심이에요.
실무 개선 포인트 (심화)
실무에선 이 전략들을 매번 손으로 안 짜요. 검증된 틀(예: Workbox 같은 도구)로 "이 종류 파일엔 이 전략"을 선언만 하면 서비스 워커 코드를 자동으로 만들어 줘요. 우리가 오늘 손으로 짠 cacheFirst·networkFirst가 그 틀 안에 그대로 들어 있어요. 원리를 손으로 겪어 봤으니, 도구를 써도 무슨 일이 일어나는지 알죠.
전략 선택은 결국 제품 결정이에요. "사용자가 낡은 가격표를 보면 큰일 나는 쇼핑몰"과 "조금 늦어도 빨라야 하는 블로그"는 같은 파일이라도 전략이 달라요. 기술 선택 뒤엔 늘 "우리 서비스에선 무엇이 더 중요한가"라는 질문이 있어요.
생각해볼 주제 예시답안
1. 모든 무거운 일을 Web Worker로 옮기면 더 빠를까?
[문제 상황 요약]
Web Worker는 화면을 멈추지 않게 해주는 좋은 도구예요. 그럼 모든 계산을 워커로 옮기면 항상 이득일까요? "무거운 건 무조건 워커로"가 정답일까요?
[튜터의 가이드 및 해설]
아니에요. 워커에도 공짜가 아닌 비용이 있어요. 두 가지를 봐야 해요.
먼저 워커를 만드는 비용이에요. new Worker(...)는 새 일꾼을 고용하는 거라, 별도 환경을 띄우는 시간과 메모리가 들어요. 1밀리초면 끝날 계산을 워커로 보내겠다고 일꾼을 고용하면, 고용하는 시간이 계산보다 더 걸려요. 배보다 배꼽이 큰 거죠.
다음은 편지를 주고받는 비용이에요. 메인과 워커는 변수를 공유 못 하니 데이터를 메시지로 주고받아요. 작은 데이터면 괜찮지만, 큰 데이터를 자주 주고받으면 그 왕복 시간이 쌓여요. (그래서 우리가 사진 버퍼엔 Transferable을 써서 복사 비용을 없앤 거예요.)
그래서 기준은 이래요. "계산 시간이 충분히 길어서, 워커 고용·편지 비용을 치르고도 남는가?" 사진 필터(픽셀 수십만 개), 큰 데이터 정렬, 압축처럼 눈에 띄게 오래 걸리는 일은 워커가 이득이에요. 반면 숫자 몇 개 더하기, 짧은 문자열 처리 같은 건 그냥 메인에서 하는 게 빨라요.
결국 워커는 "화면이 멈출 만큼 무거운 일"을 위한 도구지, "모든 계산"을 위한 도구가 아니에요. 도구마다 어울리는 자리가 있다는 걸 아는 게 더 중요해요.
🎯 면접관을 홀리는 핵심 멘트
"Web Worker는 공짜가 아니에요. 일꾼을 띄우는 비용과 메시지 왕복 비용이 있죠. 그래서 저는 '화면이 멈출 만큼 무거운 일'에만 워커를 써요 — 사진 필터, 대용량 정렬처럼요. 가벼운 계산까지 워커로 보내면 오히려 고용·통신 비용이 계산보다 커져 느려져요. 큰 데이터는 Transferable로 복사 비용까지 없애고요. 도구의 이득과 비용을 같이 보는 게 핵심이에요."
2. 캐시가 오히려 사용자를 낡은 화면에 가둘 때
[문제 상황 요약]
오프라인 저장소는 편리하지만, "옛 파일이 캐시에 남아 새 버전이 안 보이는" 문제를 일으킬 수 있어요. 사용자가 새로고침해도 예전에 받은 낡은 화면이 계속 떠요. 캐시 이름에 버전(v1)을 붙이고 옛 캐시를 지우는 것만으로 충분할까요?
[튜터의 가이드 및 해설]
버전 붙이기는 좋은 출발이지만, 함정이 하나 숨어 있어요. 서비스 워커 자신도 캐시될 수 있다는 거예요.
생각해 보세요. 우리가 코드를 고쳐 캐시를 v2로 올렸어요. 그런데 그 "캐시를 v2로 올리는 코드"는 어디 있죠? sw.js 안에 있어요. 그리고 브라우저는 옛 sw.js를 들고 있을 수 있어요. 옛 sw.js는 여전히 v1만 알아요. 그래서 새 코드를 배포해도, 옛 서비스 워커가 살아 있는 한 옛 화면이 계속 떠요. 사용자가 탭을 완전히 닫았다 다시 열어야 새 워커로 바뀌죠.
여기서 두 가지 도구가 나와요. 새 워커가 설치되자마자 곧장 일을 맡게 하는 방법(install에서 skipWaiting), 그리고 새 워커가 열려 있는 모든 탭을 즉시 넘겨받게 하는 방법(activate에서 clients.claim)이에요. 이걸 쓰면 "탭을 닫았다 열어야 갱신되는" 답답함을 줄일 수 있어요.
또 하나, "무엇을 캐시 먼저로 둘지"의 판단도 중요해요. 우리는 사진(안 바뀜)만 캐시 먼저로 뒀어요. 만약 자주 바뀌는 데이터까지 캐시 먼저로 두면, 그게 바로 "낡은 화면에 갇히는" 지름길이에요. 캐시는 강력한 만큼, 무엇을 얼마나 오래 캐시할지를 신중히 정해야 해요.
결론은 이래요. 버전 관리는 필수지만 끝이 아니에요. 서비스 워커 자체의 갱신, 그리고 "최신이 중요한 것은 캐시에 가두지 않기" — 이 둘을 함께 챙겨야 사용자가 낡은 화면에 안 갇혀요.
🎯 면접관을 홀리는 핵심 멘트
"캐시의 가장 무서운 함정은 서비스 워커 자신도 캐시된다는 거예요. 새 코드를 배포해도 옛 워커가 살아 있으면 옛 화면이 계속 떠요. 그래서 버전 관리에 더해 skipWaiting·clients.claim으로 갱신을 앞당기고, '최신이 중요한 자원은 애초에 캐시 먼저로 두지 않는' 원칙을 지켜요. 캐시는 속도를 주는 대신 '낡음'이라는 위험을 같이 줘요. 무엇을 얼마나 캐시할지가 진짜 설계예요."
3. 오프라인에서 "할 수 있는 일"과 "할 수 없는 일"
[문제 상황 요약]
서비스 워커와 캐시 덕분에 오프라인에서도 화면이 떠요. 하지만 모든 게 되는 건 아니에요. 좋은 오프라인 경험은 어디까지를 목표로 해야 할까요?
[튜터의 가이드 및 해설]
먼저 선을 그어 봐요. 오프라인에서 되는 일은 "이미 받아 둔 것을 보여 주기"예요 — 한 번 본 페이지, 캐시에 있는 사진, 저장해 둔 데이터. 안 되는 일은 "지금 네트워크가 꼭 필요한 것"이에요 — 한 번도 안 본 페이지, 새 사진, 서버에 저장해야 하는 댓글·좋아요.
여기서 중요한 깨달음이 있어요. 좋은 오프라인 경험은 "전부 되게 만드는 것"이 아니에요. 그건 불가능해요. 진짜 좋은 경험은 "안 되는 걸 솔직하고 친절하게 알려주는 것"이에요.
우리 인스타 클론으로 구체화해 볼게요.
봤던 피드·사진 → 캐시에서 그대로 보여 줘요. (되는 일)
새 페이지로 이동 → "지금은 오프라인이에요" 안내 페이지. (안 되는 일을 친절히 안내)
댓글 작성 → "오프라인이에요, 연결되면 다시 시도해 주세요" 토스트로 미리 막기. 더 나아가면, 써 둔 댓글을 잠시 보관했다 연결되면 자동으로 보내기. (안 되는 일을 미루기)
좋아요 → 화면엔 일단 하트를 켜 두고(낙관적 표시), 연결되면 서버에 반영. 끝내 실패하면 되돌리기.
이렇게 "되는 건 그대로, 안 되는 건 솔직하게, 가능하면 나중에"라는 세 갈래로 나누면, 사용자는 끊긴 와중에도 앱이 "죽지 않고 살아 있다"고 느껴요. 끊김을 숨기려 애쓰기보다, 끊김을 인정하고 그 안에서 할 수 있는 최선을 보여 주는 것 — 그게 오프라인 설계의 본질이에요.
🎯 면접관을 홀리는 핵심 멘트
"좋은 오프라인 경험은 모든 걸 되게 만드는 게 아니라, 안 되는 걸 솔직하고 친절하게 알리는 거예요. 저는 기능을 세 갈래로 나눠요 — 캐시에 있는 건 그대로 보여 주고, 새 요청이 필요한 건 명확히 안내하고, 댓글처럼 미룰 수 있는 건 보관했다 연결되면 보내요. 끊김을 숨기려 하면 사용자는 더 혼란스러워요. 끊김을 인정하고 그 안의 최선을 설계하는 게 핵심입니다."