본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.
브라우저에서는 이벤트를 통해 특정 행동 시 발생하는 이벤트를 감지하고, 그에 따라 적절한 행동을 취할 수 있는 이벤트 드리븐(Event-Driven
) 방식으로 설계가 가능하다. 이러한 일련의 과정은 브라우저 내부 동작 방식과 자바스크립트에서 지원하는 여러 메서드와 프로퍼티를 통해 연계하여 구현할 수 있다.
하지만 모든 특정 동작에 대해 이벤트가 존재하는 것은 아니다. 예를 들어 특정 HTML 요소의 속성이 변경되었다던가, 아니면 새로운 HTML 요소가 문서에 추가되었다거나 또는 페이지에 존재하는 요소가 스크롤 상 어떤 지점에서 노출되는지 등의 동작은 브라우저 기본 이벤트로 지원되지 않는다. 때문이 이벤트 드리븐 방식으로는 해당 동작을 캐치할 수 없다.
물론 자바스크립트로 비슷한 기능을 구현할 수는 있겠지만, 보다 편하게 위와 같은 작업을 감지할 수 있는 내장 객체를 지원한다. 이들을 observer
라고 부른다. 해당 객체는 말 그대로 지정된 DOM
객체를 관찰하면서, 발생하는 어떤 변화에 대응해 특정 동작을 수행할 수 있다. 즉 우리가 여러 브라우저 이벤트를 다루면서 살펴본 동작 방식과 유사하다. 어떤 의미에서는 더 큰 범위라고 할 수 있겠다.
자바스크립트에서 제공하는 전체 API 중에 observer
는 총 다섯가지가 있다.
MutationObserver
IntersectionObserver
PerformanceObserver
ReportingObserver
ResizeObserver
이 중에서 가장 범용성이 높은 MutationObserver
와 IntersectionObserver
에 대해 자세히 살펴보도록 하자.
MutationObserver
는 DOM
객체를 관찰하면서 해당 객체에 어떤 변경사항이 발생했을 때 지정된 콜백함수를 실행하는 자바스크립트 내장 객체이다.
MutationObserver
를 사용하는 방법은 간단하다. MutationObserver
생성자를 통해 객체를 생성하는데, 이때 보통 실행할 콜백함수를 인수로 지정한다.
let observer = new MutationObserver(callback);
그리고 이렇게 만들어진 observer
객체에 관찰하고자 하는 DOM
객체를 observe()
메서드로 지정해주면 된다. 이때 어떤 변경점에 대해 관찰할 지 config
속성을 통해 전달할 수 있다.
observer.observe(node, config);
config
속성은 객체형태이며 대부분 true/false
의 boolean
값을 가지는 프로퍼티를 지정할 수 있다. 해당 프로퍼티는 다음과 같다.
childList
: node
의 자식 노드에 발생하는 변경사항 관측subtree
: node
의 모든 후손(descendants
)에 발생하는 변경사항 관측attributes
: node
의 속성에 발생하는 변경사항 관측attributeFilter
: boolean
이 아닌 배열을 지정하며, 원소는 속성의 이름에 대한 문자열로 지정된 속성에 대해서만 관측characterData
: node.data
에 발생하는 변경사항 관측 (주로 text content
변경 사항)attributeOldValue
: true
일 시 변경사항이 발생하기 전 기존값도 함께 전달, 그렇지 않은 경우 변경된 새로운 값만 전달characterDataOldValue
: true
일 시 변경사항이 발생하기 전 기존값도 함께 전달, 그렇지 않은 경우 변경된 새로운 값만 전달config
설정을 지정하고 observer
를 통해 특정 node
관측을 시작하면, 지정된 사항에 대해 변경사항이 발생하는 즉시 설정한 콜백함수가 실행된다. 변경사항이 콜백함수의 첫 인수로 전달되고, observer
객체 스스로는 원한다면 두 번째 인수로 사용할 수 있다. 이때 전달되는 첫 번째 인수인 변경사항은 MutationRecord
의 객체형태를 가진다.
MutationRecord
객체는 다음의 프로퍼티를 가지고 있다.
type
: 변경사항이 발생한 타입 - attributes/characterData/childList
target
: 변경사항이 발생한 타겟으로 attributes/childList
타입인 경우엔 요소(element
), characterData
타입인 경우엔 텍스트 노드addNodes/removeNodes
: 추가/제거된 노드 리스트previousSibling/nextSibling
: 추가/제거된 노드의 이전/이후 형제노드attributeName/attributeNamespace
: 변경된 속성의 이름 (namespace
는 XML
문서용)oldValue
: 변경사항이 발생하기 이전의 값으로 attributeOldValue/characterDataOldValue
속성이 true
로 설정되어 있어야 함다음의 예시를 통해 MutationObserver
를 등록하고 그로 인한 결과를 살펴보자.
<div contentEditable id="elem">Click and <b>edit</b>, please</div>
<script>
let observer = new MutationObserver(mutationRecords => {
console.log(mutationRecords);
});
// childList, subtree, characterData 변경사항 관측
// characterData의 경우 변경 이전의 값도 함께 전달
observer.observe(elem, {
childList: true,
subtree: true,
characterDataOldValue: true,
});
</script>
HTML 속성 contentEditable
은 텍스트 입력 요소가 아니라도 편집을 가능하게 해준다. 해당 요소에 어떤 변경을 한다면 등록한 observer
에서 지정된 변경사항을 캐치하고 이에 대하 콘솔창에는 다음과 같은 내용이 출력될 것이다.
mutationRecords = [{
type: 'characterData',
oldValue: 'edit',
target: <text node>,
// 다른 프로퍼티는 empty
}];
만약 <b>edit</b>
요소를 제거하는 등의 조금 더 복합적인 작업을 한다면 다음과 같은 형식의 MutationRecords
가 생성될 것이다.
mutationRecords = [
{
type: 'childList',
target: <div#elem>,
removeNodes: [<b>],
nextSibling: <text node>,
previousSibling: <text node>,
},
{
type: 'characterData',
target: <text node>,
}
];
이러한 MutationObserver
는 실무에서 어떤 영역에 유용하게 적용할 수 있을까?
만약 우리가 서드 파티 라이브러리를 필요로 하는데, 해당 라이브러리를 사용할 때 원하지 않는 요소가 강제적으로 우리 페이지에 출력되는 상황을 가정해보자. 예를 들면 라이브러리의 홍보를 위해 원하지 않는 광고 요소가 추가될 수 있다.
그리고 이러한 광고 요소를 차단하거나 방지할 수 있는 별도의 기능은 해당 라이브러리에서 제공하고 있지 않다고 가정해보자. 이럴때 우리는 MutationObserver
를 이용해서 원하지 않는 요소가 발생했음을 감지하고 DOM
객체에서 제거까지 수행할 수 있다.
그 외에도 라이브러리에서 발생하는 특정 작업 및 행동들에 대해 관측하고 싶은 경우가 있다면 MutationObserver
를 이용해 원활하게 이를 수행할 수 있다.
실제 구조적인 관점에서 MutationObserver
를 적용한 사용사례를 살펴보며 MutationObserver
의 유용함을 느껴보자.
프로그래밍 언어를 다루고 있는 블로그 페이지를 제작하고 있다고 가정해보자. 해당 페이지에서는 각 프로그래밍 언어를 설명하는 게시글이 올라올 것이기 때문에, 해당 언어를 잘 포맷팅하여 출력할 수 있도록 해야할 것이다. 이때 사용하는 기본적인 마크업 구조는 다음과 같다.
<pre class="language-javascript">
<code>
let hello = "world"
</code>
</pre>
이때 추가적으로 코드에 대해 설명하기 위해서 하이라이트와 같은 기능이 필요할 수 있다. 이 기능을 Prism.js
라는 라이브러리를 통해 구현한다고 가정해보자. 해당 라이브러리를 이용한다면 Prism.highlightElem(pre)
와 같은 방식으로 하이라이트를 코드에 추가할 수 있다. 해당 메서드를 호출하면 지정된 요소에 하이라이트를 위한 스타일과 특별한 HTML 요소가 추가되게 될 것이다.
일단 Prism.highlightElem(pre)
를 호출하는 시점은 언제가 되어야 할 지 생각해보아야 한다. 아마도 DOMContentLoaded
이벤트가 발생하는 시점에 호출하거나, 모든 HTML 요소가 페이지에 출력된 다음에 <script>
태그를 마지막에 배치하는 등의 순서를 생각해 볼 수 있다. 즉 모든 DOM
객체가 만들어진 시점에서 다음과 같이 하이라이트 메서드를 호출할 수 있다.
// 이 이후 시점은 DOM 객체가 모두 생성된 이후
// [class*="name"] css 선택자는 name이 포함된 모든 class를 의미
document.querySelectorAll('pre[class*="language"]').forEach(Prism.highlightElem);
이러한 구조로 설계한다면 모든 기능이 문제없이 잘 작동할 것이다. 그런데 이때 코드가 미리 HTML 문서 상에 작성되어 있는 것이 아니라 원격으로 가져오는 경우라면 어떻게 될까? 대표적으로 fetch
API를 통해 원격 서버에서 코드를 가져오는 경우가 있을 수 있다.
let aritcle = fetch(...);
articleElem.innerHTML = article;
원격 서버에서 코드 내용을 가져온 뒤에 다시 하이라이트를 적용해야 할 것이다. 그러나 이처럼 동적으로 텍스트를 로드하는 경우엔 어느 시점에 하이라이트 적용 메서드를 호출해야 할까? 이와 관련해서는 여러가지 방법이 있을 수 있겠지만, 만약 fetch
를 async/await
을 사용해 값을 가져오는 경우라면, 단순히 코드 내용을 HTML에 추가한 뒤에 하이라이트 메서드를 호출하면 될 것이다.
let aritcle = fetch(...);
articleElem.innerHTML = article;
let snippets = articleElem.querySelectorAll('pre[class*="language"]');
snippets.forEach(Prism.highlightElem);
그러나 이 역시 문제점이 있다. 만약 원격 서버로부터 들고 오는 코드 내용을 로드하는 곳이 굉장히 많다고 생각해보자. 콘텐츠의 로드가 끝나고 해당 코드를 강조하기 위해서 매번 하이라이트 처리 메서드를 호출해야 하는 번거로움이 발생한다.
우리는 이러한 경우에도 MutationObserver
를 이용해 보다 편리하게 통합적으로 관련 처리를 관리할 수 있다. 다음 코드 내용을 찬찬히 살펴보며 옵저버를 이용해 종합적으로 관리하는 편리함을 직접 체험해보자. 마치 전역객체에 이벤트 핸들러를 등록한 것과 같은 강력함을 보여준다.
let observer = new MutationObserver(mutations => {
// 하이라이트 처리 할 노드가 있는지 검사
for(let mutation of mutations) {
// 추가된 노드에 대해 반복하며 검사
for(let node of mutation.addedNodes) {
// 요소노드가 아니라면 건너뜀
if (!(node instanceof HTMLElement)) continue;
// 해당하는 요소는 하이라이트 처리
if (node.matches('pre[class*="language"]')) {
Prism.highlightElement(node);
}
// 이때 서브트리 형태로 구성된 노드 모두 하이라이트 처리
for (let elem of node.querySelectorAll('pre[class*="language"]')){
Prism.highlightElement(elem);
}
}
}
});
let demoElem = document.getElementById('highlight-demo');
observer.observe(demoElem, { childList: true, subtree: true });
기본적으로 옵저버가 노드의 변경사항을 관측하기 위해서는 마치 이벤트 핸들러를 달아주는 것과 마찬가지로 실제 노드에 대해 관측을 시작해야 한다. 이는 앞서 살펴본 것과 같이 observer.observe(node)
메서드를 통해 가능하다. 그 외에도 지원하는 메서드가 있다.
observer.disconnect()
DOM
변경 알림을 받는 MutationObserver
인스턴스를 중지한다. 다시 observe()
메서드가 호출되지 않는 이상 감시 콜백은 발동되지 않는다.observer.takeRecords()
mutationRecords
리스트를 반환한다. 처리되지 않은 상태라는 것은 콜백함수에 의해 작업이 수행되기 전의 상태를 의미한다. 해당 메서드가 호출되면 MutationObserver
인스턴스의 processing 큐를 비운다.// 처리되기 전 mutation 레코드를 반환
// 해당 작업은 관측이 종료되기 전에 수행되어야 함
let mutationRecords = observer.takeRecords();
// 관측 종료
observer.disconnect();
옵저버는 내부적으로
weak Map/Set
방식과 유사한 참조를 유지한다. 즉DOM
객체에서 옵저버가 관측하고 있는 특정 객체가 제거되는 경우에는 자동으로 가비지 컬렉션(Garbage Collection
)이 일어난다. 즉 관측되고 있는DOM
노드라고 해서 가비지 컬렉션을 방해하지는 않으므로 이를 위해 일일이 연결을 해제하는 등의 작업이 명시적으로 필요하지는 않다. 그러나 성능적인 이슈를 생각한다면 사용할 일이 없어졌을 경우 연결 해제를 명시하는 경우가 좋다.
IntersectionObserver
는 javascript.info
사이트에서 소개하는 기능은 아니지만 자주 사용되는 옵저버 API이기 때문에 간단하게라도 같이 설명하고 넘어가려 한다. 특히 해당 옵저를 사용하는 가장 주된 이유 중에 하나는 이미지의 동적 로딩이나 광고 배너 노출 측정 등을 효율적으로 수행할 수 있기 때문이다. 주로 이를 이용해서 Lazy Loading
을 구현하는 경우가 많다.
IntersectionObserver
는 등록한 요소가 현재 유저가 바라보는 웹 페이지에서 보이는 영역에 등장하거나 사라지는 경우를 감지할 수 있다. 기존에는 이러한 동작을 보통 scroll
이벤트를 사용해서 감지하거나 관련 동작을 구현했는데, 이는 부하가 심하게 걸리는 원인 중에 하나였다. 왜냐하면 scroll
은 유저의 스크롤링에 의해 계속 반복적으로 발생하는 이벤트이고, 그로 인해 매번 새롭게 처리 동작이 재수행 되어야 했기 때문이다.
IntersectionObserver
를 사용하면 scroll
이벤트를 사용해서 구현하던 방식보다 더 간단하게 동일 기능 구현이 가능하며, 성능적으로도 유리한 면모를 가지고 있다.
IntersectionObserver
의 기본 사용법과 Lazy Loading
및 Infinite Scrolling
등을 구현해보며 사용패턴과 옵저버의 강력함을 살펴보자.
MutationObserver
와 비슷하게 생성자를 통해 옵저버를 초기화 할 수 있다. 같은 옵저버 API이기 때문에 사용할 수 있는 메서드는 동일하지만, 생성하는 방식은 살짝 다르다.
let observer = new IntersectionObserver(callback, options);
MutationObserver
는 보다 범용적인 관점에서 DOM
노드에 발생할 변경사항을 observe()
메서드에서 config
속성을 통해 지정하지만, IntersectionObserver
는 이와 달리 그저 관찰할 DOM
노드만 지정한다. 목적 자체가 해당 노드가 뷰포트에서 교차 지점에 들어오고 나가는 것을 관측하는 용도이기 때문이다. 다만 교차 여부를 판단하기 위핸 추가 속성을 생성과정에서 options
를 통해 설정할 수 있다. options
는 객체 형태로 다음의 프로퍼티를 가지고 있다.
root
: 뷰포트로 간주할 요소를 지정한다. 해당 요소는 가시성의 판단 기준으로 작용하며 observe
메서드로 등록하는 요소는 반드시 root
요소의 자식이어야 한다. 만약 이를 지정하지 않을 경우엔 브라우저 화면에서 현재 보이는 영역이 기본 뷰포트로 지정된다. rootMargin
: 루트 요소의 마진값을 지정할 수 있다. 형식은 CSS에서 사용하는 형식을 그대로 사용할 수 있다. 교차 지점을 판단할 때 마진의 크기에 따라 어느 지점에서 교차 이벤트가 발생하는지를 설정 할 수 있다. threshold
: 콜백 함수의 호출 시점을 정하는 옵션으로 0.0과 1.0을 포함한 사이의 값을 지정한다. 또는 숫자 배열을 지정할 수 있다. 이 숫자값은 요소의 전체 영역 중에 현재 뷰포트에서 보이는 영역의 비율을 의미한다. 설정하지 않은 경우 기본 값은 0.0이 된다.콜백함수는 MutationObserver
과 동일하게 두 개의 매개변수를 받을 수 있다. 첫 번째 인수는 IntersectionObserverEntry
객체의 배열이며, 두 번째 인수는 자신을 호출한 observer
스스로가 될 수 있다. 당연히 두 번째 인수는 생략이 가능하다.
IntersectionObserverEntry
객체는 다음의 프로퍼티를 가지고 있다.
boundingClientRect
: 타겟 엘리먼트의 정보를 반환한다. 이는 target.getBoundingClientRect()
를 실행한 것과 동일한 결과를 가지고 있다. 그러나 getBoundingClientRect()
와는 달리 리플로우(reflow
) 현상이 일어나지 않기 때문에 퍼포먼스적으로 더 뛰어나다.intersectionRect
: 교차된 영역의 정보를 반환하며, 해당 정보 역시 rect.getBoundingClientRect()
를 실행한 것과 동일하다.rootBounds
: 루트 요소의 정보를 반환하며, 해당 정보 역시 root.getBoundingClientRect()
를 실행한 것과 동일하다.isIntersecting
: 타겟 요소가 교차 영역에 있는 동안엔 true
, 아니면 flase
반환한다. 이 프로퍼티를 사용하기 위해서는 threshold
속성에 0을 포함시켜야 정상적으로 동작한다.intersectionRatio
: IntersectionObserver
생성자의 옵션 중 threshold
와 비슷하다. 교차 영역에 타겟 요소가 얼마나 차지하고 있는지에 대한 비율을 반환한다. target
: 타겟 요소 자체를 반환한다.time
: 교차가 발생한 시간을 반환한다.다음 예시를 통해 IntersectionObserver
사용 예시를 살펴보자.
let observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
// 관찰 대상이 뷰포트에 들어온 경우
if(entry.interscetionRatio > 0) {
// 클래스에 show 추가
entry.target.classList.add('show');
}
// 관찰 대상이 뷰포트 밖으로 나가는 경우
else {
// 클래스에서 show 제거
entry.target.classList.remove('show');
}
});
});
const boxElemList = document.querySelectorAll('.box');
boxElemList.forEach(elem => observer.observe(elem));
기본적으로 같은 옵저버이기 때문에 MutationObserver
에서 지원하는 메서드를 모두 사용할 수 있다. 거기에 추가로 IntersectionObserver
에서는 unobserve()
메서드 역시 지원한다.
observer.unobserve()
: 타겟 요소에 대한 관찰을 멈추고 싶은 경우 사용할 수 있다. observer.disconnect()
와는 그 의미가 다르다는 것에 주의하자. 예를 들어 Lazy Loading
에서 이미 요소가 로딩이 되었다면 더 이상 해당 요소에 관측을 유지할 필요가 없으므로 이를 해제할 수 있다. 이를 통해 조금 더 성능적인 퍼포먼스를 끌어올릴 수 있다.Lazy Loading
이란 문서를 바라보는 시점에서 실제로 이미지와 같은 자원을 로딩하는 기술을 말한다. 만약 하나의 페이지에서 수백장의 이미지를 보여주고 있을때, 이를 한 번에 모두 가져온다면 로딩 시간이 길어지는 것은 물론 매우 비효율적으로 자원을 사용하게 된다. 수백장의 이미지라면 스크롤이 생기게 될 텐데, 사용자가 모든 사진을 다 보고 떠나는 경우가 아니라면 굳이 모든 이미지를 초기에 불러올 필요가 없기 때문이다. 즉 사용자에 스크롤에 맞춰 현재 보여지는 부분에 맞춰 이미지를 불러온다면 조금 더 효율적인 로딩 속도와 자원 사용이 가능하다. 이를 IntersectionObserver
로 쉽게 구현할 수 있다.
let options = {
threshold: 0
};
let observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
// 타겟 요소가 교차 지점에 있지 않은 경우
if (!entry.isIntersecting) return;
const target = entry.target;
const src = target.dataset.src;
target.querySelector('.img').style.backgroundImage = `url(https://image/${src}.com)`;
// 로드가 완료되면 더 이상 로딩 관리를 할 필요가 없기 때문에
// 해당 요소에 한해 관측을 해지
observer.unobserve(target);
);
}, options);
document.querySelectorAll('.images').forEach(elem => observer.observe(elem));
또한 IntersectionObserver
를 이용해 쉽게 구현할 수 있는 기능 중에 무한 스크롤(Infinite Scroll
)이 있다. 이 역시 Lazy Loading
과 비슷한 이유로 효율성 개선을 위해, 다량의 데이터를 한 번에 모두 불러오지 않고 일부분만 보여주되 만약 스크롤이 현재 페이지에 끝 또는 끝자락에 닿을때 새로운 데이터를 불러오는 방식을 취한다. 즉 오프셋(offset
)을 통해 계속 데이터를 불러오는 방식으로 이전에는 페이지네이션(pagination
)이라는 방식으로 많이 사용되기도 했다.
function loadItems() {
// ... 서버로부터 데이터 로드
}
let observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
loadItems();
});
});
const elem = document.getElementById('elem');
observer.observe(elem);
loadItems();
교차 영역에 진입하는 순간 loadItems
를 호출해 새로운 데이터를 쉽게 불러오고 있음을 확인할 수 있다.
그 외 나머지 Observer API
에 대해서는 간단히만 알아보고 넘아가자. 이들은 대개 실험적인 기능이라 아직 지원하는 브라우저가 많지 않거나 관련 폴리필도 구현이 제대로 되어있지 않은 경우가 많고, 또는 앞서 살펴본 두 개의 API와는 달리 사용할 일이 그렇게 많지 않은 경우에 해당한다.
ResizeObserver
는 말 그대로 DOM
객체의 크기 변화를 관측하고자 할 때 사용할 수 있다. 예를 들어 기기 너비가 일정 픽셀 이하로 줄어들었을 때 콜백함수를 호출하거나, 관련 애니메이션을 동작하게 하는 경우 등에 사용할 수 있다.
하지만 관련 기능은 주로 CSS에서 @media query
를 이용해 반응형 설계 디자인에서 처리가 가능한 부분이 많다. 때문에 그 만큼 사용빈도가 높지 않은 것 같다. 만약 CSS 외적으로 자바스크립트를 이용해 어떤 처리가 필요한 경우 도입하여 손쉽게 관련 동작을 수행할 수 있을 것이다. 다만 크롬 브라우저 외에는 아직 지원하지 않는 브라우저가 많다. 그러나 관련 폴리필은 잘 구현되어 있다.
PerformanceObserver
는 FCP(First Contentful Paint)
와 FMP(First Meaningful Paint)
등을 측정할 수 있는 성능 관련 옵저버이다. 브라우저의 성능을 측정하기 위해서는 브라우저가 최종적으로 문서를 렌더링하기 까지 일련의 과정에 대해 잘 알고 있어야 한다. 앞서 브라우저:문서 파트에서 리플로우(Reflow
)와 리페인트(Repaing
)에 대해 간단하게 언급한 바 있는데 이와 관련된 성능 측정 도구로 사용된다고 볼 수 있다.
ReportingObserver
는 매우 실험적인 기능이기에 이를 지원하는 브라우저는 거의 없다. 해당 옵저버를 통해 사용자의 window
객체를 관측하며 정책적으로 너무 오래된 메서드가 쓰이거나 보안상 위험한 접근 등이 발생하는 경우 경고를 주는 등의 기능이 필요할 때 사용할 수 있다.
https://paints-emirates.com/plumber-in-abu-dhabi