HTML5 Web API — Intersection Observer, Web Worker, Service Worker
브라우저에는 이미 엄청나게 많은 기능이 내장되어 있다. 스크롤 감지, 백그라운드 연산, 오프라인 지원 — 이걸 전부 외부 라이브러리로 해결하려고 했던 적이 있는가?
브라우저 내장 API의 가치
jQuery 시절에는 스크롤 위치를 계산하려면 scroll 이벤트에 getBoundingClientRect()를 매 프레임 호출했다. 무거운 계산이 메인 스레드를 점유하면 UI가 버벅였고, 오프라인은 아예 생각도 못 했다.
지금은 다르다. 브라우저가 직접 제공하는 Web API만으로도 충분히 많은 걸 할 수 있다.
- Intersection Observer — 스크롤 기반 요소 감지를 브라우저에 위임
- Web Worker — 무거운 연산을 별도 스레드에서 실행
- Service Worker — 네트워크 요청을 가로채서 오프라인 지원, 캐시 전략 적용
- Geolocation API — 사용자 위치 정보 획득
- Notification API / Push API — 사용자에게 알림 전송
- Performance Observer — Core Web Vitals 같은 성능 지표 수집
라이브러리를 추가하기 전에, 브라우저가 이미 제공하는 걸 먼저 확인하는 습관이 중요하다.
Intersection Observer
왜 scroll 이벤트 대신 쓰는가
scroll 이벤트로 요소의 가시성을 판단하려면 매 스크롤마다 getBoundingClientRect()를 호출해야 한다. 이 메서드는 ** 강제 리플로우(forced reflow)**를 발생시킨다. 요소가 10개, 20개 늘어나면 성능이 눈에 띄게 떨어진다.
Intersection Observer는 이 판단을 ** 브라우저 엔진에 위임 **한다. 브라우저가 최적화된 방식으로 교차 여부를 감지하고, 조건이 충족될 때만 콜백을 호출한다.
기본 사용법
// 옵저버 생성
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 요소가 뷰포트에 진입했을 때
console.log('보인다:', entry.target);
}
});
}, {
root: null, // 기본값: 뷰포트
rootMargin: '0px', // 뷰포트 마진 (CSS margin 형식)
threshold: 0 // 0~1 사이, 또는 배열
});
// 관찰 대상 등록
observer.observe(document.querySelector('.target'));
threshold와 rootMargin
이 두 옵션이 Intersection Observer의 핵심이다.
-
threshold — 타겟 요소가 얼마나 보여야 콜백을 호출할지
0: 1px이라도 보이면 호출 (기본값)0.5: 절반이 보이면 호출1: 전체가 보여야 호출[0, 0.25, 0.5, 0.75, 1]: 각 비율마다 호출
-
rootMargin — 감지 영역을 확장하거나 축소
'100px 0px': 뷰포트 위아래로 100px 확장 → 요소가 보이기 ** 전에** 미리 감지
// 뷰포트 진입 200px 전에 미리 감지 — 이미지 프리로딩에 유용
const observer = new IntersectionObserver(callback, {
rootMargin: '200px 0px',
threshold: 0
});
실전: 이미지 Lazy Loading
<img data-src="heavy-image.jpg" class="lazy" alt="설명">
const lazyObserver = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // 실제 이미지 로드
img.classList.remove('lazy');
observer.unobserve(img); // 로드 후 관찰 해제
}
});
}, {
rootMargin: '100px 0px' // 100px 전에 미리 로드 시작
});
// 모든 lazy 이미지 등록
document.querySelectorAll('img.lazy').forEach((img) => {
lazyObserver.observe(img);
});
참고로 요즘 브라우저는
<img loading="lazy">를 네이티브로 지원한다. 단순 이미지 lazy loading이라면 이쪽이 더 간단하다. Intersection Observer는 애니메이션 트리거, 무한 스크롤 같은 ** 커스텀 로직 **이 필요할 때 빛난다.
실전: 무한 스크롤
// 리스트 마지막에 센티널(감시용) 요소 배치
const sentinel = document.querySelector('.scroll-sentinel');
const scrollObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMoreItems(); // 다음 페이지 데이터 로드
}
});
scrollObserver.observe(sentinel);
기존 scroll 이벤트 + 스크롤 위치 계산 방식보다 훨씬 깔끔하고, 성능 부담도 적다.
Web Workers
메인 스레드를 막으면 안 되는 이유
자바스크립트는 ** 싱글 스레드 **다. UI 렌더링, 이벤트 처리, 자바스크립트 실행이 전부 하나의 스레드에서 돌아간다. 여기서 무거운 연산을 돌리면?
- 버튼 클릭이 안 먹는다
- 애니메이션이 끊긴다
- 사용자가 "이 사이트 멈췄나?" 하고 떠난다
Web Worker는 ** 별도 스레드에서 자바스크립트를 실행 **할 수 있게 해준다.
기본 사용법
// worker.js — 별도 파일
self.addEventListener('message', (e) => {
const { numbers } = e.data;
// 무거운 연산 (예: 대량 데이터 정렬)
const sorted = numbers.sort((a, b) => a - b);
// 결과를 메인 스레드로 전달
self.postMessage({ sorted });
});
// main.js — 메인 스레드
const worker = new Worker('worker.js');
// 워커에 데이터 전달
worker.postMessage({
numbers: [5, 3, 8, 1, 9, 2, 7, 4, 6]
});
// 워커로부터 결과 수신
worker.addEventListener('message', (e) => {
console.log('정렬 완료:', e.data.sorted);
});
// 에러 처리
worker.addEventListener('error', (e) => {
console.error('워커 에러:', e.message);
});
// 더 이상 필요 없으면 종료
// worker.terminate();
Web Worker의 제약
Worker는 메인 스레드와 완전히 분리된 환경에서 돌아간다. 그래서:
- DOM 접근 불가 —
document,window사용 불가 - ** 통신은 postMessage만** — 데이터는 구조화된 복제(structured clone)로 전달
- ** 별도 파일 필요** — 인라인으로 만들려면 Blob URL 사용
// Blob URL로 인라인 워커 생성 (별도 파일 없이)
const workerCode = `
self.onmessage = (e) => {
const result = e.data * 2;
self.postMessage(result);
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
SharedWorker
일반 Worker는 생성한 페이지에서만 사용할 수 있다. SharedWorker 는 같은 origin의 여러 탭/윈도우에서 공유할 수 있다.
// shared-worker.js
const connections = [];
self.addEventListener('connect', (e) => {
const port = e.ports[0];
connections.push(port);
port.addEventListener('message', (event) => {
// 모든 연결된 탭에 메시지 브로드캐스트
connections.forEach((conn) => {
conn.postMessage(`브로드캐스트: ${event.data}`);
});
});
port.start();
});
// 각 탭에서
const shared = new SharedWorker('shared-worker.js');
shared.port.start();
shared.port.postMessage('안녕하세요');
shared.port.onmessage = (e) => console.log(e.data);
탭 간 실시간 상태 동기화가 필요할 때 SharedWorker가 유용하다. 다만 브라우저 지원 범위를 꼭 확인하자.
Service Worker
웹 앱을 오프라인에서도 돌릴 수 있다고?
Service Worker는 브라우저와 네트워크 사이에 위치하는 프록시 다. 네트워크 요청을 가로채서 캐시된 응답을 반환하거나, 요청을 수정하거나, 완전히 새로운 응답을 만들어낼 수 있다.
PWA(Progressive Web App)의 핵심 기술이기도 하다.
생명주기
Service Worker는 일반 자바스크립트와 다른 독립적인 생명주기 를 가진다.
- 등록(Register) — 메인 스크립트에서
navigator.serviceWorker.register()호출 - ** 설치(Install)** — 캐시할 리소스를 미리 저장
- ** 활성화(Activate)** — 이전 버전의 캐시 정리
- Fetch 가로채기 — 네트워크 요청을 인터셉트
// main.js — 등록
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then((reg) => console.log('SW 등록 성공:', reg.scope))
.catch((err) => console.error('SW 등록 실패:', err));
}
// sw.js — 설치 단계에서 캐시 준비
const CACHE_NAME = 'v1';
const PRECACHE_URLS = [
'/',
'/styles/main.css',
'/scripts/app.js',
'/offline.html'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(PRECACHE_URLS))
);
});
캐시 전략
여기가 Service Worker의 진짜 가치다. 상황에 따라 다른 캐시 전략을 적용할 수 있다.
Cache First — 캐시 우선
정적 자산(CSS, JS, 이미지)에 적합하다. 캐시에 있으면 바로 반환하고, 없을 때만 네트워크로 간다.
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((cached) => cached || fetch(event.request))
);
});
Network First — 네트워크 우선
API 응답처럼 ** 최신 데이터가 중요한** 경우. 네트워크를 먼저 시도하고, 실패하면 캐시에서 반환한다.
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
// 응답을 캐시에도 저장
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
});
Stale-While-Revalidate — 캐시 반환 + 백그라운드 갱신
** 속도와 최신성을 동시에** 확보하는 전략이다. 캐시된 응답을 즉시 반환하면서, 백그라운드에서 네트워크 응답으로 캐시를 갱신한다.
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open(CACHE_NAME).then((cache) =>
cache.match(event.request).then((cached) => {
// 백그라운드에서 캐시 갱신
const fetchPromise = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
// 캐시가 있으면 즉시 반환, 없으면 네트워크 대기
return cached || fetchPromise;
})
)
);
});
활성화 — 이전 캐시 정리
새 버전을 배포할 때, 이전 버전의 캐시를 정리하는 것도 중요하다.
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME) // 현재 버전이 아닌 캐시 삭제
.map((key) => caches.delete(key))
)
)
);
});
Service Worker는 HTTPS 환경에서만 동작한다 (localhost 제외). 보안이 보장되지 않는 환경에서 네트워크 요청을 가로채는 건 위험하니까.
Geolocation API
사용자 위치 가져오기
GPS, Wi-Fi, IP 주소 등을 조합해서 사용자의 현재 위치를 알아낸다. 당연히 ** 사용자 동의(권한)**가 필요하다.
// 한 번 위치 가져오기
navigator.geolocation.getCurrentPosition(
(position) => {
console.log('위도:', position.coords.latitude);
console.log('경도:', position.coords.longitude);
console.log('정확도:', position.coords.accuracy, '미터');
},
(error) => {
// 에러 처리
switch (error.code) {
case error.PERMISSION_DENIED:
console.log('사용자가 위치 접근을 거부했습니다');
break;
case error.POSITION_UNAVAILABLE:
console.log('위치 정보를 사용할 수 없습니다');
break;
case error.TIMEOUT:
console.log('위치 요청이 시간 초과되었습니다');
break;
}
},
{
enableHighAccuracy: true, // GPS 우선 (배터리 소모 증가)
timeout: 10000, // 10초 제한
maximumAge: 0 // 캐시된 위치 사용 안 함
}
);
watchPosition — 실시간 위치 추적
// 위치가 변할 때마다 콜백 호출
const watchId = navigator.geolocation.watchPosition(
(position) => {
updateMap(position.coords.latitude, position.coords.longitude);
},
handleError,
{ enableHighAccuracy: true }
);
// 추적 중단
navigator.geolocation.clearWatch(watchId);
배달 앱의 라이더 위치 추적, 러닝 앱의 경로 기록 같은 기능이 이 API 하나로 가능하다.
Web Storage vs IndexedDB
이 주제는 자바스크립트 영역이라 여기서는 간략하게만 비교하고 넘어간다.
| 구분 | localStorage | sessionStorage | IndexedDB |
|---|---|---|---|
| 용량 | ~5MB | ~5MB | 수백 MB 이상 |
| 유지 | 영구 (직접 삭제 전까지) | 탭 닫으면 삭제 | 영구 |
| 데이터 형식 | 문자열만 | 문자열만 | 객체, Blob 등 |
| 동기/비동기 | 동기 | 동기 | 비동기 |
| 용도 | 간단한 설정값 | 임시 상태 | 대량 구조화 데이터 |
// localStorage 기본 사용
localStorage.setItem('theme', 'dark');
const theme = localStorage.getItem('theme'); // 'dark'
localStorage.removeItem('theme');
// 객체를 저장하려면 직렬화 필요
localStorage.setItem('user', JSON.stringify({ name: '홍길동' }));
const user = JSON.parse(localStorage.getItem('user'));
IndexedDB의 비동기 API와 트랜잭션 모델은 별도 자바스크립트 글에서 자세히 다룰 예정이다.
Notification API와 Push API
Notification API — 브라우저 알림
사용자에게 시스템 알림을 보낼 수 있다. 역시 ** 권한 요청이 필수 **다.
// 1. 권한 요청
Notification.requestPermission().then((permission) => {
if (permission === 'granted') {
// 2. 알림 생성
const notification = new Notification('새 메시지', {
body: '홍길동님이 메시지를 보냈습니다.',
icon: '/icons/message.png',
tag: 'message-1' // 같은 tag의 알림은 하나만 표시
});
// 3. 알림 클릭 이벤트
notification.onclick = () => {
window.focus();
notification.close();
};
}
});
Push API — 서버에서 푸시
Notification API가 "클라이언트에서 직접 알림을 띄우는 것"이라면, Push API는 ** 서버가 보낸 메시지를 Service Worker가 받아서 알림을 띄우는 것 **이다.
// Service Worker에서 push 이벤트 수신
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
data: { url: data.url } // 클릭 시 이동할 URL
})
);
});
// 알림 클릭 처리
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
Push API를 쓰려면 서버 측에 Web Push 프로토콜 구현이 필요하다. VAPID 키 쌍을 생성하고, 구독 정보를 서버에 저장하는 과정이 수반된다. 이건 백엔드 주제에 가깝다.
Performance Observer
성능을 "감"이 아니라 "숫자"로 측정하기
Core Web Vitals(LCP, FID, CLS) 같은 성능 지표를 자바스크립트로 직접 수집할 수 있다. Google Analytics나 별도 모니터링 서비스로 보내는 RUM(Real User Monitoring) 데이터의 기반이 되는 API다.
// LCP (Largest Contentful Paint) 측정
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.startTime, 'ms');
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
// Long Task 감지 — 50ms 이상 걸리는 작업
const longTaskObserver = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
console.warn('Long Task 감지:', entry.duration, 'ms');
// 모니터링 서비스로 전송
});
});
longTaskObserver.observe({ type: 'longtask', buffered: true });
// 리소스 로딩 시간 측정
const resourceObserver = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.duration > 1000) {
console.warn('느린 리소스:', entry.name, entry.duration, 'ms');
}
});
});
resourceObserver.observe({ type: 'resource', buffered: true });
PerformanceEntry 타입 정리
| 타입 | 설명 |
|---|---|
navigation | 페이지 로딩 전체 타이밍 |
resource | 개별 리소스(이미지, CSS, JS) 로딩 시간 |
largest-contentful-paint | LCP — 가장 큰 콘텐츠가 렌더링된 시점 |
first-input | FID — 첫 사용자 입력에 대한 응답 지연 |
layout-shift | CLS — 예기치 않은 레이아웃 이동 |
longtask | 50ms 이상 메인 스레드를 점유한 작업 |
paint | First Paint, First Contentful Paint |
실무에서는
web-vitals라이브러리를 많이 쓰지만, 내부적으로 PerformanceObserver를 사용한다는 걸 알아두면 디버깅할 때 도움이 된다.
정리
| API | 핵심 역할 | 대표 사용처 |
|---|---|---|
| Intersection Observer | 요소 가시성 감지 | Lazy loading, 무한 스크롤, 애니메이션 |
| Web Worker | 별도 스레드 연산 | 대량 데이터 처리, 이미지 변환 |
| Service Worker | 네트워크 프록시 | 오프라인 지원, PWA, 캐시 전략 |
| Geolocation | 위치 정보 | 지도, 배달 추적, 날씨 |
| Notification / Push | 사용자 알림 | 채팅 알림, 뉴스 알림 |
| Performance Observer | 성능 지표 수집 | Core Web Vitals, RUM |
외부 라이브러리를 설치하기 전에, 브라우저가 이미 제공하는 API부터 확인하자. 번들 크기도 줄이고, 브라우저 최적화의 혜택도 받을 수 있다.