Baseline: 널리 사용 가능 *
✅ Chrome ✅ Edge ✅ Firefox ✅ Safari
이 기능은 잘 정립되어 있고 많은 디바이스와 브라우저 버전에서 동작해요. 2015년 7월부터 여러 브라우저에서 사용 가능했어요.
* 이 기능의 일부 부분은 지원 수준이 다를 수 있어요.
HTML DOM API는 HTML에 있는 각각의 요소(element)들의 기능을 정의하는 인터페이스들로 구성되어 있어요. 그리고 이 요소들이 의존하는 지원 타입들과 인터페이스들도 포함하고 있어요.
HTML DOM API에 포함된 기능 영역들은 다음과 같아요:
<canvas>의 컨텍스트와 상호작용하기 — 예를 들어 그 위에 그림을 그리는 것 같은 거요<audio>와 <video>)에 연결된 미디어 관리하기여러분, HTML DOM API는 프론트엔드 개발의 기초 중의 기초예요! 🏗️
DOM은 "Document Object Model"의 약자인데요, 쉽게 말해서 HTML 문서를 JavaScript가 이해할 수 있는 객체 형태로 표현한 것이에요. 브라우저가 HTML을 읽으면 DOM 트리라는 걸 만들고, 우리는 JavaScript로 이 트리를 조작할 수 있어요.
// 이런 코드들 본 적 있으시죠? 이게 다 HTML DOM API예요!
document.getElementById('myButton');
document.querySelector('.container');
element.innerHTML = '새로운 내용';
element.addEventListener('click', handleClick);
문서에 나열된 기능들을 보세요. 폼 데이터, 캔버스, 미디어, 드래그 앤 드롭, 히스토리... 웹에서 할 수 있는 거의 모든 상호작용이 여기에 들어있어요!
React, Vue 같은 프레임워크를 쓰더라도, 결국 내부적으로는 이 DOM API를 사용하고 있거든요. 그래서 DOM을 제대로 이해하면 프레임워크도 더 잘 이해할 수 있어요.
⚠️ DOM 조작은 비용이 꽤 들어요. 특히 .innerHTML을 반복문 안에서 여러 번 쓰면 성능이 확 떨어져요. 가능하면 변경사항을 모아서 한 번에 적용하는 게 좋아요!
그리고 2015년부터 지원됐다고 하니까 브라우저 호환성 걱정은 거의 안 해도 돼요. IE만 아니면요... (IE는 이제 공식 지원 종료됐으니 신경 안 쓰셔도 됩니다! 🎉)
안녕하세요! 프론트엔드 개발의 세계에 푹 빠져 계시군요. DOM(문서 객체 모델)은 브라우저가 웹 페이지를 어떻게 이해하고 조작하는지 알려주는 핵심 기초입니다. 처음엔 영어 문서가 다소 딱딱하고 막막하게 느껴지실 수 있지만, 현업에서 매일같이 마주쳐야 하는 필수 지식인 만큼 제가 알기 쉽게 구어체로 꼼꼼하게 풀어서 설명해 드릴게요.
요약 없이 원본의 모든 내용을 다루면서, 실무에서 어떻게 쓰이는지 강사의 경험이 담긴 팁도 곳곳에 추가해 두었으니 천천히 따라와 주세요!
이번 글에서는 HTML 요소(element)와 직접적으로 상호작용하는 HTML DOM의 핵심적인 부분들에 집중해서 살펴볼 거예요. 드래그 앤 드롭(Drag and Drop), 웹소켓(WebSockets), 웹 스토리지(Web Storage) 같은 다른 영역에 대한 내용은 해당 API 문서에서 따로 찾아보실 수 있습니다.
문서 객체 모델(DOM)은 document(문서)의 구조를 설명하는 아키텍처예요. 각각의 문서는 Document 인터페이스의 인스턴스(객체)로 표현됩니다. 그리고 이 문서는 노드(nodes) 들의 계층적인 트리(tree) 구조로 이루어져 있어요. 여기서 '노드'란 문서 안의 단일 객체(예를 들면 HTML 요소나 텍스트 데이터)를 나타내는 가장 기본적인 단위입니다.
노드 중에는 다른 노드들을 그룹으로 묶어주거나 계층 구조의 뼈대를 제공하는 조직적인 역할만 하는 것들도 있고, 문서에서 실제로 눈에 보이는 구성 요소(시각적 요소)를 나타내는 노드들도 있습니다. 모든 노드는 Node 인터페이스를 기반으로 만들어지는데요, 이 인터페이스는 노드에 대한 정보를 가져오는 속성(properties)은 물론 DOM 안에서 노드를 생성, 삭제, 정리하는 메서드(methods)들을 제공합니다.
💡 강사의 실무 팁:
현업 기술 면접에서 "Node와 Element의 차이가 무엇인가요?"라는 질문이 단골로 나옵니다. 노드(Node)는 DOM 트리를 구성하는 모든 것(텍스트 띄어쓰기, 주석 등 포함)을 의미하는 가장 큰 개념이고, 요소(Element)는 그중에서도 HTML 태그(<p>,<div>등)로 만들어진 시각적인 껍데기를 의미합니다! 노드가 빈 그릇이라면, 시각적 내용을 담는 그릇이 바로 요소인 셈이죠.
노드 자체에는 문서에 실제로 표시되는 '콘텐츠'를 포함한다는 개념이 없어요. 앞서 말했듯 그저 텅 빈 그릇에 불과합니다. 시각적인 콘텐츠를 나타낼 수 있는 노드라는 근본적인 개념은 Element 인터페이스가 도입되면서 생겨났습니다. Element 객체 인스턴스는 HTML이나 SVG 같은 XML 어휘를 사용해 만들어진 문서 안의 단일 요소를 나타냅니다.
예를 들어, 두 개의 요소가 있고 그중 하나 안에 두 개의 요소가 더 중첩되어 있는 문서를 생각해 볼까요?
Document 인터페이스는 DOM 스펙의 일부로 정의되어 있지만, HTML 스펙은 이를 크게 확장해서 웹 브라우저 환경에서 DOM을 사용할 때 필요한 특정 정보와 HTML 문서만을 위해 필요한 특별한 기능들을 추가했습니다.
HTML 표준에 의해 Document에 추가된 주요 기능들은 다음과 같아요:
<head> 블록과 본문(body)에 있는 요소들의 목록은 물론, 문서에 포함된 이미지(images), 링크(links), 스크립트(scripts) 등의 목록에 접근할 수 있게 해줍니다.copy(복사), cut(잘라내기), paste(붙여넣기) 동작만 포함되어 있습니다.Element 인터페이스는 HTML 요소들만을 특별하게 나타내기 위해 한 단계 더 발전했는데요, 바로 모든 특정 HTML 요소 클래스들의 부모 역할을 하는 HTMLElement 인터페이스를 도입한 것입니다. 이는 Element 클래스를 확장해서 요소 노드들에 HTML 고유의 일반적인 기능들을 추가해 줍니다. HTMLElement가 추가한 대표적인 속성으로는 hidden이나 innerText 같은 것들이 있죠.
HTML 문서는 각각의 노드가 HTML 요소인 DOM 트리이며, 이는 HTMLElement 인터페이스로 표현됩니다. 그리고 HTMLElement 클래스는 다시 Node를 구현하기 때문에, 모든 요소는 동시에 노드이기도 합니다 (하지만 그 반대는 성립하지 않아요!). 이런 방식을 통해 Node 인터페이스가 구현한 구조적인 기능들을 HTML 요소들도 똑같이 쓸 수 있게 되고, 요소끼리 서로 중첩되거나, 생성 및 삭제되고, 이리저리 위치를 옮기는 등의 작업이 가능해집니다.
하지만 HTMLElement 인터페이스는 범용적(generic)인 녀석이라서, 요소의 ID나 좌표, 요소를 구성하는 HTML 텍스트, 스크롤 위치 정보 등 모든 HTML 요소들이 공통으로 가지는 아주 기본적인 기능만 제공해요.
따라서 특정 요소(예: 캔버스, 오디오 등)에만 필요한 고유한 기능을 제공하기 위해서는, 핵심적인 HTMLElement 인터페이스의 기능을 확장하여 필요한 속성과 메서드를 추가한 하위 클래스(subclass)를 만들어야 합니다. 예를 들어, <canvas> 요소는 HTMLCanvasElement라는 타입의 객체로 표현돼요. HTMLCanvasElement는 캔버스 전용 기능을 제공하기 위해 height(높이) 같은 속성과 getContext() 같은 메서드를 추가하여 HTMLElement 타입을 한층 더 강화한 겁니다.
HTML 요소 클래스들의 전체적인 상속 구조(Inheritance)는 다음과 같이 생겼습니다:
보시다시피, 하나의 요소는 자신의 모든 조상(ancestors)들이 가진 속성과 메서드를 그대로 물려받습니다(상속). 예를 들어, DOM에서 HTMLAnchorElement 타입의 객체로 표현되는 <a>(앵커) 요소를 생각해 볼까요? 이 요소는 해당 클래스(HTMLAnchorElement) 문서에 설명된 앵커 전용 속성과 메서드뿐만 아니라, HTMLElement와 Element가 정의한 것들, 나아가 Node와 마침내 EventTarget이 정의한 기능들까지 모조리 다 포함하고 있습니다.
💡 강사의 실무 팁:
"왜 모든 HTML 태그에서addEventListener를 쓸 수 있을까요?" 정답은 바로 이 상속 다이어그램의 맨 꼭대기에EventTarget이 있기 때문입니다. 여러분이 React나 일반 자바스크립트로 버튼 클릭 이벤트를 만들 수 있는 건, 버튼 요소가 이 EventTarget의 능력을 물려받았기 때문이랍니다!
각각의 레벨은 요소의 핵심적인 활용도를 정의합니다. Node로부터는 다른 요소를 포함하거나 자신이 다른 요소 안에 포함될 수 있는 계층 구조에 대한 능력을 물려받습니다. 그리고 특히 눈여겨볼 점은 EventTarget으로부터 상속받아 얻게 되는 능력인데요, 바로 마우스 클릭이나 미디어 재생/일시정지 같은 다양한 이벤트를 수신하고 처리(handle)할 수 있는 막강한 권한입니다.
어떤 요소들은 서로 공통점이 많아서 그 사이에 추가적인 중간 단계 타입을 가지기도 해요. 예를 들어 <audio>와 <video> 요소는 둘 다 시청각 미디어를 재생하죠. 이에 대응하는 타입인 HTMLAudioElement와 HTMLVideoElement는 모두 HTMLMediaElement라는 공통 타입을 기반으로 하고, 이 공통 타입은 다시 HTMLElement를 기반으로 합니다. HTMLMediaElement는 오디오와 비디오 요소가 공통으로 가지는 메서드와 속성들을 정의하는 역할을 하죠.
이렇게 각 요소에 특화된 인터페이스들이 HTML DOM API의 대다수를 차지하며, 이번 글에서 중점적으로 다루는 내용입니다. DOM과 그 개념에 대한 전반적인 소개는 DOM(문서 객체 모델) 문서를 참고해 주세요.
HTML DOM이 제공하는 기능들은 웹 개발자의 도구 상자에서 가장 흔하게, 매일 쓰이는 API 중 하나입니다.
아주아주 단순한 웹 페이지가 아닌 이상, 사실상 모든 웹 애플리케이션은 HTML DOM의 기능들을 반드시 사용하게 됩니다.
HTML DOM API를 구성하는 인터페이스의 대부분은 각각의 개별 HTML 요소와 거의 1대1로 연결되거나, 비슷한 기능을 가진 작은 요소 그룹과 매핑됩니다. 거기에 더해서, HTML 요소 인터페이스들을 보조하고 지원하기 위한 몇 가지 추가적인 인터페이스와 타입들도 포함되어 있습니다.
이 인터페이스들은 특정 HTML 요소들(또는 동일한 속성과 메서드를 공유하는 관련된 요소들의 집합)을 나타냅니다. 양이 많지만, 프론트엔드 개발자라면 어떤 태그가 어떤 자바스크립트 객체와 연결되는지 눈에 익혀두시는 게 정말 중요해요!
HTMLAnchorElement (우리가 아는 태그)HTMLAreaElementHTMLAudioElementHTMLBaseElementHTMLBodyElementHTMLBRElementHTMLButtonElementHTMLCanvasElementHTMLDataElementHTMLDataListElementHTMLDetailsElementHTMLDialogElementHTMLDirectoryElement (문서 작성 중)HTMLDivElement (프론트엔드에서 가장 많이 보게 될 친구죠!)HTMLDListElementHTMLElementHTMLEmbedElementHTMLFieldSetElementHTMLFormElementHTMLHRElementHTMLHeadElementHTMLHeadingElement (h1~h6 태그들)HTMLHtmlElementHTMLIFrameElementHTMLImageElementHTMLInputElementHTMLLabelElementHTMLLegendElementHTMLLIElementHTMLLinkElementHTMLMapElementHTMLMediaElementHTMLMenuElementHTMLMetaElementHTMLMeterElementHTMLModElementHTMLObjectElementHTMLOListElementHTMLOptGroupElementHTMLOptionElementHTMLOutputElementHTMLParagraphElement (p 태그)HTMLPictureElementHTMLPreElementHTMLProgressElementHTMLQuoteElementHTMLScriptElementHTMLSelectElementHTMLSlotElementHTMLSourceElementHTMLSpanElementHTMLStyleElementHTMLTableCaptionElementHTMLTableCellElementHTMLTableColElementHTMLTableElementHTMLTableRowElementHTMLTableSectionElementHTMLTemplateElementHTMLTextAreaElementHTMLTimeElementHTMLTitleElementHTMLTrackElementHTMLUListElementHTMLUnknownElementHTMLVideoElement새로운 웹사이트를 만들 때는 사용하지 마세요!
과거의 유산일 뿐, 이제는 완전히 쓰이지 않습니다.
이 인터페이스들은 HTML을 담고 있는 브라우저 창(window)이나 문서 자체에 접근할 수 있게 해주고, 브라우저의 현재 상태나 설치된 플러그인, 다양한 환경설정 옵션들을 다룰 수 있게 해줍니다.
External (문서 작성 중)이 친구들은 폼(form)을 만들고 관리하는 데 쓰이는 <form>이나 <input> 같은 요소들이 필요로 하는 구조와 기능들을 든든하게 뒷받침해 줍니다. 사용자 입력을 다루는 프론트엔드 개발자라면 꼭 친해져야 해요.
FormDataEventHTMLFormControlsCollectionHTMLOptionsCollectionRadioNodeListValidityState (입력값 유효성 검사할 때 아주 유용하죠!)이 인터페이스들은 Canvas API가 사용하는 객체들이나 <img>, <picture> 요소들을 다룰 때 쓰입니다. 포트폴리오에 멋진 그래픽이나 애니메이션을 넣고 싶다면 이쪽을 눈여겨보세요.
CanvasGradientCanvasPatternCanvasRenderingContext2DImageBitmapImageBitmapRenderingContextImageDataOffscreenCanvasOffscreenCanvasRenderingContext2DPath2DTextMetrics미디어 인터페이스는 HTML의 시청각 태그인 <audio>와 <video>의 콘텐츠를 직접 제어할 수 있게 해줍니다.
AudioTrackAudioTrackListMediaErrorTextTrack (자막 다룰 때 씁니다)TextTrackCueTextTrackCueListTextTrackListTimeRangesTrackEventVideoTrackVideoTrackListHTML Drag and Drop API가 사용하는 인터페이스들로, 드래그할 수 있는 아이템들이나 아이템 그룹을 만들고, 실제로 마우스로 끌어서 놓는 과정 전체를 컨트롤할 때 사용합니다.
History API 인터페이스는 브라우저의 방문 기록(history) 정보에 접근할 수 있게 해주고, 이 기록을 바탕으로 현재 탭을 앞으로 가기 하거나 뒤로 가기 하도록 만들어줍니다. (React Router 같은 라이브러리들이 내부적으로 이걸 아주 유용하게 쓰고 있죠!)
BeforeUnloadEventHashChangeEventHistoryLocationPageRevealEventPageSwapEventPageTransitionEventPopStateEvent웹 컴포넌트 API(Web Components API)가 사용자 정의 요소(custom elements)를 만들고 관리할 때 사용하는 인터페이스입니다.
HTML DOM API 전반에 걸쳐 다양한 방식으로 쓰이는 보조 객체 타입들입니다. 덧붙여서, PromiseRejectionEvent는 자바스크립트의 Promise가 거부(rejected)되었을 때 전달되는 이벤트를 의미합니다.
DOMStringListDOMStringMapErrorEventHTMLAllCollectionMimeTypeMimeTypeArrayPromiseRejectionEvent몇몇 인터페이스들은 기술적으로는 HTML 스펙에 정의되어 있지만, 실제로는 다른 독립적인 API의 일부로 취급됩니다.
웹사이트가 사용자 기기에 데이터를 일시적 또는 영구적으로 저장해두고 나중에 다시 쓸 수 있게 해주는 웹 스토리지 API(Web Storage API)입니다. (로컬 스토리지, 세션 스토리지 다뤄보셨죠? 바로 이겁니다!)
웹 워커 API(Web Workers API)가 사용하는 인터페이스들로, 브라우저 메인 스레드와 분리된 백그라운드 환경에서 앱이 통신할 수 있게 하거나 창(window)끼리 메시지를 주고받도록 돕습니다.
BroadcastChannelDedicatedWorkerGlobalScopeMessageChannelMessageEventMessagePortSharedWorkerSharedWorkerGlobalScopeWorkerWorkerGlobalScopeWorkerLocationWorkerNavigator웹소켓 API(WebSockets API)가 사용하는 HTML 스펙 정의 인터페이스들입니다. 실시간 채팅 같은 걸 만들 때 필수죠!
EventSource 인터페이스는 서버 센트 이벤트(server-sent events)를 보냈거나 보내고 있는 출처(source)를 나타냅니다.
이 예제에서는 특정 입력 필드(이름 필드)에 값이 있는지 없는지에 따라 폼의 "제출(Send)" 버튼 상태(활성화/비활성화)를 실시간으로 업데이트하기 위해, <input> 요소의 input 이벤트를 어떻게 모니터링하는지 보여줍니다.
const nameField = document.getElementById("userName");
const sendButton = document.getElementById("sendButton");
sendButton.disabled = true;
// [참고: 이 문서를 열었을 때 자동으로 예제 화면으로 스크롤되는 것을 막기 위해 비활성화 해두었습니다]
// nameField.focus();
nameField.addEventListener("input", (event) => {
const elem = event.target;
const valid = elem.value.length !== 0;
if (valid && sendButton.disabled) {
sendButton.disabled = false;
} else if (!valid && !sendButton.disabled) {
sendButton.disabled = true;
}
});
이 코드는 Document 인터페이스의 getElementById() 메서드를 사용해서 ID가 userName과 sendButton인 <input> 요소들을 DOM 객체로 가져옵니다. 이렇게 객체를 가져오면, 해당 요소들에 대한 정보를 알아내고 요소를 마음대로 조작할 수 있는 속성과 메서드들에 접근할 수 있게 되죠.
"Send" 버튼을 나타내는 HTMLInputElement 객체의 disabled 속성을 true로 설정하면 버튼이 비활성화되어 클릭할 수 없게 됩니다. 또한, 사용자 이름 입력 필드는 HTMLElement로부터 상속받은 focus() 메서드를 호출해서 현재 활성화된 상태(커서가 깜빡이는 상태)로 만들 수 있습니다.
💡 강사의 실무 팁:
HTML 태그에 직접disabled라는 속성을 적는 것과, 자바스크립트에서 DOM 객체의.disabled = true프로퍼티를 수정하는 것은 본질적으로 연결되어 있습니다! DOM을 통해 프로퍼티를 변경하면 화면에 즉시 렌더링이 반영됩니다.
그 다음, 이름 입력 필드에 input 이벤트(사용자가 타이핑을 할 때마다 발생하는 이벤트) 처리를 위해 addEventListener()를 호출합니다. 이 코드는 현재 입력 필드 값의 길이(length)를 검사하는데요, 만약 길이가 0이면(아무것도 안 적혀 있으면) "Send" 버튼이 활성화되어 있을 경우 다시 비활성화시킵니다. 반대로 한 글자라도 적혀 있다면 버튼이 활성화되도록 보장해 줍니다.
이 로직 덕분에 사용자 이름 필드에 무언가 값이 들어있을 때만 "Send" 버튼이 켜지고, 비어 있으면 꺼지는 동적인 동작이 완성되는 것이죠!
이 폼을 구성하는 HTML은 다음과 같습니다:
<p>Please provide the information below. Items marked with "*" are required.</p>
<form action="" method="get">
<p>
<label for="userName" required>Your name:</label>
<input type="text" id="userName" /> (*)
</p>
<p>
<label for="userEmail">Email:</label>
<input type="email" id="userEmail" />
</p>
<input type="submit" value="Send" id="sendButton" />
</form>
이 코드를 실제로 실행하면 위에서 작성한 HTML 화면이 나타나며, Your name 칸에 글자를 입력해야만 아래의 Send 버튼이 활성화되는 것을 확인하실 수 있습니다. (MDN 플레이그라운드 환경에서 테스트해 볼 수 있는 부분입니다.)
마이크로태스크(microtask)는 자신을 생성한 함수나 프로그램이 종료된 후에 그리고 JavaScript 실행 스택이 비어있을 때만 실행되는 짧은 함수예요. 하지만 스크립트의 실행 환경을 구동하는 사용자 에이전트(user agent)가 사용하는 이벤트 루프로 제어권을 반환하기 전에 실행되죠.
이 이벤트 루프는 브라우저의 메인 이벤트 루프일 수도 있고, 웹 워커(web worker)를 구동하는 이벤트 루프일 수도 있어요. 이렇게 하면 주어진 함수가 다른 스크립트의 실행을 방해할 위험 없이 실행될 수 있으면서도, 동시에 사용자 에이전트가 마이크로태스크가 취한 행동에 반응할 기회를 갖기 전에 마이크로태스크가 실행되도록 보장해줘요.
JavaScript 프로미스(promises)와 Mutation Observer API는 둘 다 마이크로태스크 큐를 사용해서 콜백을 실행하는데요, 현재 이벤트 루프 패스가 마무리될 때까지 작업을 지연시키는 기능이 유용한 경우가 또 있어요. 써드파티 라이브러리, 프레임워크, 폴리필에서도 마이크로태스크를 사용할 수 있도록, queueMicrotask() 메서드가 Window와 WorkerGlobalScope 인터페이스에 노출되어 있어요.
마이크로태스크에 대해 제대로 논의하려면, 먼저 JavaScript 태스크가 무엇인지, 그리고 마이크로태스크가 태스크와 어떻게 다른지 알아야 해요. 이건 빠르고 단순화된 설명이지만, 더 자세한 내용을 원하시면 심화: 마이크로태스크와 JavaScript 런타임 환경 문서의 정보를 읽어보세요.
태스크(task)는 프로그램의 초기 실행, 이벤트가 비동기적으로 디스패치되는 것, 인터벌이나 타임아웃이 발동되는 것 같은 표준 메커니즘에 의해 실행되도록 예약된 모든 것을 말해요. 이런 것들은 모두 태스크 큐(task queue)에 예약돼요.
예를 들어, 다음과 같은 경우에 태스크가 태스크 큐에 추가돼요:
<script> 요소 내의 코드를 실행함으로써) 직접 실행될 때setTimeout()이나 setInterval()로 생성된 타임아웃이나 인터벌이 도달했을 때, 해당 콜백이 태스크 큐에 추가돼요.코드를 구동하는 이벤트 루프는 이러한 태스크들을 큐에 들어간 순서대로 하나씩 처리해요. 이벤트 루프의 한 번의 반복(iteration) 동안 태스크 큐에 있는 가장 오래된 실행 가능한 태스크가 실행될 거예요. 그 후에, 마이크로태스크 큐가 비어있을 때까지 마이크로태스크들이 실행되고, 그 다음 브라우저는 렌더링을 업데이트할 수 있어요. 그런 다음 브라우저는 이벤트 루프의 다음 반복으로 넘어가죠.
처음에는 마이크로태스크와 태스크의 차이가 미미해 보일 수 있어요. 그리고 실제로 비슷해요. 둘 다 JavaScript 코드로 구성되어 있고 큐에 놓여서 적절한 시점에 실행되니까요. 하지만 이벤트 루프가 반복을 시작했을 때 큐에 있던 태스크들만 하나씩 실행하는 것과 달리, 마이크로태스크 큐는 매우 다르게 처리돼요.
두 가지 핵심 차이점이 있어요:
태스크가 종료될 때마다, 이벤트 루프는 그 태스크가 다른 JavaScript 코드로 제어권을 반환하는지 확인해요. 만약 그렇지 않다면, 마이크로태스크 큐에 있는 모든 마이크로태스크를 실행해요. 따라서 마이크로태스크 큐는 이벤트 루프의 반복마다 여러 번 처리되는데, 이벤트 및 다른 콜백을 처리한 후에도 처리돼요.
마이크로태스크가 queueMicrotask()를 호출해서 더 많은 마이크로태스크를 큐에 추가하면, 새로 추가된 마이크로태스크들은 다음 태스크가 실행되기 전에 실행돼요. 이벤트 루프가 큐에 더 이상 남아있지 않을 때까지 계속 마이크로태스크를 호출하기 때문이에요. 더 많은 게 계속 추가되더라도요.
⚠️ 경고:
마이크로태스크는 자기 자신이 더 많은 마이크로태스크를 큐에 넣을 수 있고, 이벤트 루프는 큐가 비어있을 때까지 마이크로태스크를 계속 처리하기 때문에, 이벤트 루프가 끝없이 마이크로태스크를 처리하게 될 실제 위험이 있어요. 재귀적으로 마이크로태스크를 추가하는 방법에 대해 신중하게 생각하세요.
더 깊이 들어가기 전에, 다시 한번 강조하자면 대부분의 개발자들은 마이크로태스크를 많이 사용하지 않을 거예요. 전혀 안 쓸 수도 있고요. 마이크로태스크는 현대 브라우저 기반 JavaScript 개발의 매우 특화된 기능이에요. 사용자 컴퓨터에서 일어나기를 기다리는 긴 작업 목록의 다른 것들 앞에 코드가 끼어들도록 예약할 수 있게 해주죠. 이 기능을 남용하면 성능 문제로 이어질 거예요.
따라서, 일반적으로 다른 해결책이 없을 때나, 마이크로태스크를 사용해야 구현하려는 기능을 만들 수 있는 프레임워크나 라이브러리를 만들 때만 마이크로태스크를 사용해야 해요. 과거에는 프로미스를 즉시 resolve하는 방식 같은 트릭을 사용해서 마이크로태스크를 큐에 넣는 게 가능했지만, queueMicrotask() 메서드가 추가되면서 안전하게 트릭 없이 마이크로태스크를 도입하는 표준 방법이 생겼어요.
queueMicrotask()를 도입함으로써, 프로미스를 사용해서 마이크로태스크를 몰래 만들 때 발생하는 이상한 문제들을 피할 수 있어요. 예를 들어, 프로미스를 사용해서 마이크로태스크를 만들 때, 콜백에서 발생한 예외는 거부된 프로미스(rejected promises)로 보고되지 표준 예외로 보고되지 않아요. 또한 프로미스를 생성하고 파괴하는 것은 시간과 메모리 측면에서 추가 오버헤드가 발생하는데, 마이크로태스크를 적절하게 큐에 넣는 함수는 이를 피할 수 있어요.
컨텍스트가 마이크로태스크를 처리하는 동안 호출할 JavaScript Function을 queueMicrotask() 메서드에 전달하세요. 이 메서드는 현재 실행 컨텍스트에 따라 Window나 Worker 인터페이스에 의해 정의된 전역 컨텍스트에 노출되어 있어요.
queueMicrotask(() => {
/* code to run in the microtask here */
});
마이크로태스크 함수 자체는 매개변수를 받지 않고, 값을 반환하지도 않아요.
이 섹션에서는, 마이크로태스크가 특히 유용한 시나리오들을 살펴볼 거예요. 일반적으로, 결과나 데이터를 캡처하거나 확인하거나, 정리 작업을 수행하는 거예요. JavaScript 실행 컨텍스트의 메인 본문이 종료된 후에 하되, 그 전에 이벤트 핸들러, 타임아웃과 인터벌, 또는 다른 콜백들이 처리되기 전에요.
언제 유용할까요?
마이크로태스크를 사용하는 주된 이유는 결과나 데이터가 동기적으로 사용 가능한 경우에도 태스크의 일관된 순서를 보장하면서, 동시에 작업에서 사용자가 인지할 수 있는 지연 위험을 줄이기 위해서예요.
마이크로태스크를 사용해서 실행 순서가 항상 일관되도록 보장할 수 있는 한 가지 상황은 if...else 문(또는 다른 조건문)의 한 절에서는 프로미스를 사용하지만 다른 절에서는 사용하지 않을 때예요. 다음과 같은 코드를 생각해보세요:
customElement.prototype.getData = function (url) {
if (this.cache[url]) {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
} else {
fetch(url)
.then((result) => result.arrayBuffer())
.then((data) => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
});
}
};
여기서 발생하는 문제는 if...else 문의 한 분기(이미지가 캐시에 있는 경우)에서는 태스크를 사용하지만 else 절에서는 프로미스를 사용하기 때문에, 작업의 순서가 달라질 수 있는 상황이 생긴다는 거예요. 예를 들어, 아래처럼요.
element.addEventListener("load", () => console.log("Loaded data"));
console.log("Fetching data…");
element.getData();
console.log("Data fetched");
이 코드를 두 번 연속 실행하면 다음과 같은 결과가 나와요.
데이터가 캐시되지 않았을 때:
Fetching data…
Data fetched
Loaded data
데이터가 캐시되었을 때:
Fetching data…
Loaded data
Data fetched
더 나쁜 건, 때로는 요소의 data 속성이 설정되지만, 다른 때는 이 코드가 실행을 마치기 전에 완료되지 않을 수도 있다는 거예요.
if 절에서 마이크로태스크를 사용해서 두 절의 균형을 맞출 수 있어요:
customElement.prototype.getData = function (url) {
if (this.cache[url]) {
queueMicrotask(() => {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
});
} else {
fetch(url)
.then((result) => result.arrayBuffer())
.then((data) => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
});
}
};
이렇게 하면 두 절 모두 마이크로태스크 내에서 data 설정과 load 이벤트 발동을 처리하게 되어 균형을 맞춰요 (if 절에서는 queueMicrotask()를 사용하고, else 절에서는 fetch()가 사용하는 프로미스를 사용해서요).
마이크로태스크를 사용해서 여러 소스에서 오는 여러 요청을 단일 배치로 수집할 수도 있어요. 동일한 종류의 작업을 처리할 때 여러 번 호출하는 것과 관련된 잠재적 오버헤드를 피할 수 있죠.
아래 스니펫은 여러 메시지를 배열로 배치 처리하는 함수를 만들어요. 컨텍스트가 종료될 때 마이크로태스크를 사용해서 단일 객체로 보내죠.
const messageQueue = [];
let sendMessage = (message) => {
messageQueue.push(message);
if (messageQueue.length === 1) {
queueMicrotask(() => {
const json = JSON.stringify(messageQueue);
messageQueue.length = 0;
fetch("url-of-receiver", json);
});
}
};
sendMessage()가 호출되면, 지정된 메시지가 먼저 메시지 큐 배열로 푸시돼요. 그 다음이 흥미로워요.
방금 배열에 추가한 메시지가 첫 번째 메시지라면, 배치를 보낼 마이크로태스크를 큐에 넣어요. 마이크로태스크는 항상 그렇듯이 JavaScript 실행 경로가 최상위 레벨에 도달했을 때, 콜백을 실행하기 바로 직전에 실행될 거예요. 즉, 그 사이에 이루어진 sendMessage()에 대한 추가 호출들은 메시지를 메시지 큐에 푸시하지만, 배열 길이 체크 때문에 새로운 마이크로태스크는 큐에 넣지 않아요.
마이크로태스크가 실행될 때, 잠재적으로 많은 메시지들이 기다리는 배열이 있을 거예요. JSON.stringify() 메서드를 사용해서 JSON으로 인코딩하는 것으로 시작해요. 그 후, 배열의 내용은 더 이상 필요하지 않으니 messageQueue 배열을 비워요. 마지막으로, fetch() 메서드를 사용해서 JSON 문자열을 서버로 보내요.
이렇게 하면 이벤트 루프의 같은 반복 동안 이루어진 모든 sendMessage() 호출이 메시지를 같은 fetch() 작업에 추가할 수 있어요. 타임아웃 같은 다른 태스크들이 전송을 지연시킬 가능성도 없고요.
서버는 JSON 문자열을 받아서, 아마도 디코드하고 결과 배열에서 찾은 메시지들을 처리할 거예요.
이 간단한 예제에서, 마이크로태스크를 큐에 넣으면 마이크로태스크의 콜백이 이 최상위 스크립트의 본문이 실행을 마친 후에 실행되는 걸 볼 수 있어요.
다음 코드에서 queueMicrotask() 호출이 마이크로태스크를 실행하도록 예약하는 데 사용되는 걸 볼 수 있어요. 이 호출은 화면에 텍스트를 출력하는 커스텀 함수인 log()에 대한 호출로 둘러싸여 있어요.
log("Before enqueueing the microtask");
queueMicrotask(() => {
log("The microtask has run.");
});
log("After enqueueing the microtask");
Before enqueueing the microtask
After enqueueing the microtask
The microtask has run.
이 예제에서는 타임아웃이 0밀리초 후에 (또는 "가능한 한 빨리") 발동되도록 예약돼요. 이것은 (setTimeout()을 사용해서) 새로운 태스크를 예약하는 것과 마이크로태스크를 사용하는 것 사이에서 "가능한 한 빨리"가 의미하는 것의 차이를 보여줘요.
다음 코드에서 queueMicrotask()에 대한 호출이 마이크로태스크를 실행하도록 예약하는 데 사용돼요. 이 호출은 추가 메시지를 출력하는 log()에 대한 호출로 둘러싸여 있어요.
아래 코드는 0밀리초 후에 발생하도록 타임아웃을 예약하고, 그 다음 마이크로태스크를 큐에 넣어요. 이것은 log()에 대한 호출로 둘러싸여서 추가 메시지를 출력해요.
const callback = () => log("Regular timeout callback has run");
const urgentCallback = () => log("*** Oh noes! An urgent callback has run!");
log("Main program started");
setTimeout(callback, 0);
queueMicrotask(urgentCallback);
log("Main program exiting");
Main program started
Main program exiting
*** Oh noes! An urgent callback has run!
Regular timeout callback has run
메인 프로그램 본문에서 로그로 출력된 내용이 먼저 나타나고, 그 다음 마이크로태스크의 출력이, 그 다음 타임아웃의 콜백이 나타나는 걸 주목하세요. 이것은 메인 프로그램의 실행을 처리하는 태스크가 종료될 때, 타임아웃 콜백이 위치한 태스크 큐가 처리되기 전에 마이크로태스크 큐가 처리되기 때문이에요. 이걸 기억하는 데 도움이 될 팁은, 태스크와 마이크로태스크는 별도의 큐에 보관되고, 마이크로태스크가 먼저 실행된다는 거예요.
이 예제는 작업을 수행하는 함수를 추가해서 이전 예제를 약간 확장해요. 이 함수는 queueMicrotask()를 사용해서 마이크로태스크를 예약해요. 여기서 중요하게 가져가야 할 것은 마이크로태스크가 함수가 종료될 때 처리되는 게 아니라, 메인 프로그램이 종료될 때 처리된다는 거예요.
메인 프로그램 코드가 뒤따라요. 여기서 doWork() 함수는 queueMicrotask()를 호출하지만, 전체 프로그램이 종료될 때까지 마이크로태스크는 여전히 발동하지 않아요. 그때가 태스크가 종료되고 실행 스택에 다른 것이 없을 때니까요.
const callback = () => log("Regular timeout callback has run");
const urgentCallback = () => log("*** Oh noes! An urgent callback has run!");
const doWork = () => {
let result = 1;
queueMicrotask(urgentCallback);
for (let i = 2; i <= 10; i++) {
result *= i;
}
return result;
};
log("Main program started");
setTimeout(callback, 0);
log(`10! equals ${doWork()}`);
log("Main program exiting");
Main program started
10! equals 3628800
Main program exiting
*** Oh noes! An urgent callback has run!
Regular timeout callback has run
여러분, 마이크로태스크는 솔직히 고급 주제예요! 처음 JavaScript를 배우시는 분들은 이 내용이 좀 어렵게 느껴질 수 있어요. 하지만 이해하면 진짜 강력한 도구가 될 수 있어요.
실무에서 제가 마이크로태스크를 직접 사용한 경우는 많지 않았어요. 대부분의 경우 프레임워크나 라이브러리가 내부적으로 처리해주거든요. 하지만 다음과 같은 상황에서 유용했어요:
1. 상태 관리 라이브러리 만들 때
여러 상태 변경을 모아서 한 번에 처리하고 싶을 때 queueMicrotask()를 사용했어요. 렌더링 성능이 훨씬 좋아졌죠!
2. 커스텀 이벤트 시스템
이벤트가 발생한 직후, 하지만 다른 태스크가 실행되기 전에 뭔가를 처리해야 할 때 유용했어요.
⚠️ 무한 루프 조심!
문서에도 나왔지만, 마이크로태스크 안에서 계속 마이크로태스크를 추가하면 브라우저가 멈춰버려요! 저도 초반에 이거 때문에 한 번 큰일 날 뻔했어요. 😅
// 이런 건 절대 하지 마세요!
function badIdea() {
queueMicrotask(() => {
badIdea(); // 무한 루프!
});
}
⚠️ 디버깅이 어려워요
마이크로태스크는 실행 타이밍이 미묘해서, 디버깅할 때 console.log로 순서를 찍어보는 게 정말 중요해요.
💡 Promise와의 관계
사실 async/await나 .then()을 사용할 때마다 이미 마이크로태스크를 쓰고 있는 거예요! 프로미스의 .then() 핸들러는 마이크로태스크로 실행되거든요.
// 이것도 마이크로태스크를 사용하고 있어요
Promise.resolve().then(() => console.log("This runs as a microtask"));
💡 성능 최적화
배치 처리 예제처럼, 여러 번의 DOM 업데이트를 모아서 한 번에 처리할 때 정말 유용해요. 특히 큰 규모의 앱에서요.
이 주제는 당장 100% 이해 못 하셔도 괜찮아요. 필요할 때 다시 돌아와서 보시면 돼요. 일단은 "이런 게 있구나" 정도만 기억하시고, 나중에 성능 최적화가 필요하거나 복잡한 비동기 로직을 다룰 때 떠올려보세요! 🚀
디버깅을 하거나, 또는 타이밍과 태스크 및 마이크로태스크의 스케줄링 관련 문제를 해결하기 위한 최선의 접근 방식을 결정하려고 할 때, JavaScript 런타임이 내부적으로 어떻게 작동하는지에 대해 이해하면 유용할 수 있는 것들이 있어요.
JavaScript는 본질적으로 단일 스레드 언어예요. 이건 그 시대에는 긍정적인 선택이었던 시기에 설계되었어요. 일반 대중이 사용할 수 있는 멀티 프로세서 컴퓨터가 거의 없었고, 당시 JavaScript가 처리할 것으로 예상되는 코드의 양도 상대적으로 적었거든요.
시간이 지나면서, 물론 우리가 알다시피 컴퓨터는 강력한 멀티 코어 시스템으로 진화했고, JavaScript는 컴퓨팅 세계에서 가장 광범위하게 사용되는 언어 중 하나가 되었어요. 가장 인기 있는 애플리케이션들 중 엄청나게 많은 수가 적어도 부분적으로는 JavaScript 코드를 기반으로 하고 있죠. 이를 지원하기 위해서, 프로젝트들이 단일 스레드 언어의 한계를 벗어날 수 있도록 하는 방법을 찾는 것이 필요했어요.
Web API의 일부로 타임아웃과 인터벌(setTimeout()과 setInterval())이 추가된 것을 시작으로, 웹 브라우저가 제공하는 JavaScript 환경은 태스크 스케줄링, 멀티 스레드 애플리케이션 개발 등을 가능하게 하는 강력한 기능들을 포함하도록 점차 발전해왔어요. queueMicrotask()가 여기서 어떤 역할을 하는지 이해하려면, JavaScript 런타임이 코드를 스케줄링하고 실행할 때 어떻게 작동하는지 이해하는 것이 도움이 돼요.
📝 참고:
여기에 있는 세부 사항들은 일반적으로 대부분의 JavaScript 프로그래머들에게는 중요하지 않아요. 이 정보는 마이크로태스크가 왜 유용한지, 그리고 어떻게 동작하는지에 대한 기초로 제공되는 거예요. 신경 쓰지 않으셔도 된다면, 이 부분을 건너뛰고 나중에 필요하다고 느끼면 다시 돌아오셔도 돼요.
JavaScript 코드 조각이 실행될 때, 그것은 실행 컨텍스트(execution context) 안에서 실행돼요. 새로운 실행 컨텍스트를 생성하는 코드에는 세 가지 타입이 있어요:
eval() 함수를 사용하는 것도 새로운 실행 컨텍스트를 생성해요.각 컨텍스트는 본질적으로 코드 내의 스코프 레벨이에요. 이러한 코드 세그먼트 중 하나가 실행을 시작하면, 그것을 실행하기 위한 새로운 컨텍스트가 구성돼요. 그런 다음 코드가 종료되면 그 컨텍스트는 파괴돼요. 아래의 JavaScript 프로그램을 살펴볼게요:
const outputElem = document.getElementById("output");
const userLanguages = {
Mike: "en",
Teresa: "es",
};
function greetUser(user) {
function localGreeting(user) {
let greeting;
const language = userLanguages[user];
switch (language) {
case "es":
greeting = `¡Hola, ${user}!`;
break;
case "en":
default:
greeting = `Hello, ${user}!`;
break;
}
return greeting;
}
outputElem.innerText += `${localGreeting(user)}\n`;
}
greetUser("Mike");
greetUser("Teresa");
greetUser("Veronica");
이 짧은 프로그램은 세 개의 실행 컨텍스트를 포함하고 있는데, 그 중 일부는 프로그램 실행 과정 동안 여러 번 생성되고 파괴돼요. 각 컨텍스트가 생성되면, 그것은 실행 컨텍스트 스택(execution context stack)에 놓여요. 종료되면, 그 컨텍스트는 컨텍스트 스택에서 제거돼요.
프로그램이 시작되면, 전역 컨텍스트가 생성돼요.
greetUser("Mike")에 도달하면, greetUser() 함수를 위한 컨텍스트가 생성돼요. 이 실행 컨텍스트는 실행 컨텍스트 스택에 푸시돼요.
greetUser()가 localGreeting()을 호출하면, 그 함수를 실행하기 위한 또 다른 컨텍스트가 생성돼요. 이 함수가 반환되면, localGreeting()의 컨텍스트가 실행 스택에서 제거되고 파괴돼요. 프로그램 실행은 스택에서 발견된 다음 컨텍스트로 재개되는데, 그게 greetUser()예요. 이 함수는 멈췄던 곳에서 실행을 재개해요.greetUser() 함수가 반환되고 그 컨텍스트는 스택에서 제거되고 파괴돼요.greetUser("Teresa")에 도달하면, 그것을 위한 컨텍스트가 생성되고 스택에 푸시돼요.
greetUser()가 localGreeting()을 호출하면, 그 함수를 실행하기 위한 또 다른 컨텍스트가 생성돼요. 이 함수가 반환되면, localGreeting()의 컨텍스트가 실행 스택에서 제거되고 파괴돼요. greetUser()는 멈췄던 곳에서 계속 실행해요.greetUser() 함수가 반환되고 그 컨텍스트는 스택에서 제거되고 파괴돼요.greetUser("Veronica")에 도달하면, 그것을 위한 컨텍스트가 생성되고 스택에 푸시돼요.
greetUser()가 localGreeting()을 호출하면, 그 함수를 실행하기 위한 또 다른 컨텍스트가 생성돼요. 이 함수가 반환되면, localGreeting()의 컨텍스트가 실행 스택에서 제거되고 파괴돼요.greetUser() 함수가 반환되고 그 컨텍스트는 스택에서 제거되고 파괴돼요.메인 프로그램이 종료되고 그 실행 컨텍스트가 실행 스택에서 제거돼요. 스택에 남아 있는 컨텍스트가 없으므로, 프로그램 실행이 끝나요.
이런 방식으로 실행 컨텍스트를 사용함으로써, 각 프로그램과 함수는 자신만의 변수 세트와 다른 객체들을 가질 수 있어요. 각 컨텍스트는 추가로 프로그램에서 실행되어야 할 다음 줄과 그 컨텍스트의 작동에 중요한 다른 정보들을 추적해요. 이런 방식으로 컨텍스트와 컨텍스트 스택을 사용함으로써, 로컬 및 전역 변수, 함수 호출과 반환 등 프로그램이 작동하는 방식의 많은 기본 요소들이 관리될 수 있어요.
재귀 함수에 대해 특별히 언급할 점이 있어요 — 즉, 스스로를 호출하는 함수들, 여러 깊이 레벨이나 재귀를 거쳐서 호출할 수 있는 함수들 말이에요: 함수에 대한 각 재귀 호출은 새로운 실행 컨텍스트를 생성해요. 이를 통해 JavaScript 런타임이 재귀의 레벨과 그 재귀를 통한 결과의 반환을 추적할 수 있지만, 함수가 재귀할 때마다 새 컨텍스트를 생성하기 위해 더 많은 메모리가 필요하다는 의미이기도 해요.
JavaScript 코드를 실행하기 위해, 런타임 엔진은 JavaScript 코드를 실행할 에이전트(agents) 세트를 유지해요. 각 에이전트는 실행 컨텍스트들의 세트, 실행 컨텍스트 스택, 메인 스레드, 워커를 처리하기 위해 생성될 수 있는 추가 스레드들의 세트, 태스크 큐, 그리고 마이크로태스크 큐로 구성되어 있어요. 메인 스레드를 제외하고 — 일부 브라우저는 여러 에이전트에 걸쳐 공유해요 — 에이전트의 각 구성 요소는 그 에이전트에 고유해요.
여기서 런타임이 어떻게 작동하는지 조금 더 자세히 살펴볼게요.
각 에이전트는 이벤트 루프(event loop)에 의해 구동되는데, 이것은 반복적으로 처리돼요. 각 반복 동안, 최대 하나의 대기 중인 JavaScript 태스크를 실행한 다음, 대기 중인 마이크로태스크들을 모두 실행하고, 그 다음 다시 루프를 돌기 전에 필요한 렌더링과 페인팅을 수행해요.
여러분의 웹사이트나 앱의 코드는 웹 브라우저 자체의 사용자 인터페이스와 동일한 스레드(thread)에서 실행되면서, 동일한 이벤트 루프를 공유해요. 이것이 메인 스레드(main thread)인데요, 여러분 사이트의 메인 코드 바디를 실행하는 것 외에도 사용자 및 다른 이벤트들을 수신하고 전달하며, 웹 콘텐츠를 렌더링하고 페인팅하는 등의 작업을 처리해요.
그러면 이벤트 루프는 사용자와의 상호작용과 관련하여 브라우저에서 일어나는 모든 것을 구동하는데요, 하지만 우리의 목적을 위해 더 중요한 건, 그것이 스레드 내에서 실행되는 모든 코드 조각의 스케줄링과 실행을 책임진다는 거예요.
이벤트 루프에는 세 가지 타입이 있어요:
Window event loop
: 윈도우 이벤트 루프는 유사한 출처(origin)를 공유하는 모든 윈도우들을 구동하는 거예요 (하지만 아래에 설명된 것처럼 추가적인 제한이 있어요).
Worker event loop
: 워커 이벤트 루프는 워커를 구동하는 거예요. 이건 기본 web workers, shared workers, 그리고 service workers를 포함한 모든 형태의 워커를 포함해요. 워커들은 "메인" 코드와는 별도인 하나 이상의 에이전트에 보관돼요. 브라우저는 주어진 타입의 모든 워커들에 대해 단일 이벤트 루프를 사용할 수도 있고, 여러 개의 이벤트 루프를 사용하여 처리할 수도 있어요.
Worklet event loop
: worklet 이벤트 루프는 주어진 에이전트에 대한 워클릿의 코드를 실행하는 에이전트를 구동하는 데 사용되는 이벤트 루프예요. 이건 Worklet 및 AudioWorklet 타입의 워클릿들을 포함해요.
동일한 출처(origin)에서 로드된 여러 윈도우들은 동일한 이벤트 루프에서 실행될 수 있으며, 각각 이벤트 루프에 태스크를 큐잉하여 프로세서와 차례로 작업을 주고받아요. 웹 용어에서 "윈도우(window)"라는 단어는 실제로 "웹 콘텐츠가 실행되는 브라우저 레벨의 컨테이너"를 의미한다는 것을 명심하세요. 실제 창, 탭, 또는 프레임을 포함해요.
공통 출처를 가진 윈도우들 사이에서 이 이벤트 루프를 공유하는 것이 가능한 특정 상황들이 있어요:
<iframe> 내의 컨테이너라면, 그것을 포함하는 윈도우와 이벤트 루프를 공유할 가능성이 높아요.구체적인 내용은 브라우저가 어떻게 구현되었는지에 따라 브라우저마다 다를 수 있어요.
태스크(task)는 스크립트 실행을 처음 시작하거나, 비동기적으로 이벤트를 전달하는 등과 같은 표준 메커니즘에 의해 실행되도록 예약된 모든 것이에요. 이벤트를 사용하는 것 외에도, setTimeout()이나 setInterval()을 사용하여 태스크를 큐에 넣을 수 있어요.
태스크 큐와 마이크로태스크 큐의 차이는 간단하지만 매우 중요해요:
여러분의 코드는 브라우저의 사용자 인터페이스와 동일한 스레드에서, 동일한 이벤트 루프를 사용하여 실행되기 때문에, 여러분의 코드가 블로킹되거나 무한 루프에 빠지면 브라우저 자체도 멈춰버려요. 버그로 인해 발생하든 여러분의 코드가 수행하는 복잡한 작업으로 인해 발생하든, 느린 성능조차도 사용자가 느린 브라우저로 고통받게 만들 수 있어요.
여러 프로그램과 그 프로그램 내의 여러 코드 객체들이 동시에 작업을 시작하려고 할 때, 프로세서 시간이 필요한 브라우저와 함께 — 사이트와 자체 UI를 렌더링하고 그리는 시간은 말할 것도 없고, 사용자 이벤트를 처리하는 등의 시간도 필요하죠 — 요즘날에는 모든 것이 너무나 쉽게 막혀버려요.
복잡하거나 길이가 긴 작업들을 수행하기 위해 다른 스크립트들을 새로운 스레드에서 실행할 수 있도록 하는 web workers의 사용이 이 문제를 완화하는 데 도움이 돼요. 잘 설계된 웹사이트나 앱은 워커를 사용하여 복잡하거나 긴 작업을 수행하며, 메인 스레드는 웹페이지를 업데이트하고, 레이아웃하고, 렌더링하는 것 외에는 가능한 한 적은 작업을 하도록 해요.
이것은 promises 같은 비동기 JavaScript 기술을 사용하여 더욱 완화되는데요, 요청의 결과를 기다리는 동안 메인 코드가 계속 실행될 수 있게 해줘요. 하지만 더 근본적인 레벨에서 실행되는 코드 — 라이브러리나 프레임워크를 구성하는 코드 같은 것들 — 는 단일 요청이나 태스크의 결과와 무관하게, 메인 스레드에서 실행되면서도 안전한 시간에 코드를 실행하도록 스케줄링할 수 있는 방법이 필요할 수 있어요.
마이크로태스크는 이 문제에 대한 또 다른 해결책인데요, 다음 반복을 기다려야 하는 대신 다음 이벤트 루프 반복이 시작되기 전에 코드를 실행하도록 스케줄링할 수 있게 함으로써 더 세밀한 수준의 접근을 제공해요.
마이크로태스크 큐는 이미 한동안 존재해왔지만, 역사적으로는 프라미스 같은 것들을 구동하기 위해 내부적으로만 사용되었어요. 웹 개발자들에게 이를 노출하는 queueMicrotask()의 추가는 마이크로태스크를 위한 통합된 큐를 생성하는데요, JavaScript 실행 컨텍스트 스택에 남아 있는 실행 컨텍스트가 없을 때 안전하게 코드를 실행하도록 스케줄링할 수 있는 능력이 필요한 곳이라면 어디서든 사용돼요. 여러 인스턴스와 모든 브라우저 및 JavaScript 런타임에 걸쳐, 표준화된 큐 메커니즘은 이러한 마이크로태스크들이 동일한 순서로 안정적으로 작동하도록 하며, 따라서 찾기 어려운 버그를 잠재적으로 방지해요.
여러분, 이 문서는 JavaScript의 핵심 작동 원리를 다루는 아주 중요한 내용이에요! 😊
실무에서 이런 경험 많이 하실 거예요:
setTimeout이 정확히 1초 후에 실행 안 돼요?" 🤔이런 질문들의 답이 전부 이벤트 루프와 마이크로태스크에 있어요!
console.log('1: 동기 코드');
setTimeout(() => {
console.log('2: setTimeout (태스크)');
}, 0);
Promise.resolve().then(() => {
console.log('3: Promise (마이크로태스크)');
});
console.log('4: 동기 코드');
// 결과: 1 → 4 → 3 → 2
// setTimeout은 0ms여도 마이크로태스크보다 나중에 실행돼요!
문서에서 설명한 대로:
setTimeout): 다음 이벤트 루프 반복에서 실행1. 재귀 함수 조심하세요!
// ⚠️ 이렇게 하면 스택 오버플로우!
function recursiveBad(n) {
if (n === 0) return;
recursiveBad(n - 1);
}
// ✅ 이렇게 하면 안전해요
async function recursiveGood(n) {
if (n === 0) return;
await Promise.resolve(); // 마이크로태스크로 스택 비우기
recursiveGood(n - 1);
}
2. 무거운 작업은 Web Worker로!
메인 스레드에서 복잡한 계산하면 UI가 멈춰요. 제 프로젝트에서도 이미지 처리 같은 건 무조건 Worker로 옮겼어요.
3. queueMicrotask() 언제 쓸까요?
Promise보다 더 가볍게 마이크로태스크가 필요할 때 사용해요. 라이브러리 개발할 때 특히 유용해요!
⚠️ 주의: 마이크로태스크 안에서 또 마이크로태스크를 계속 만들면 무한 루프처럼 다음 렌더링이 안 일어나요. 브라우저가 먹통이 될 수 있어요!
실제로 면접에서도 이벤트 루프 질문 정말 많이 나와요. 이 문서 내용 제대로 이해하시면 JavaScript 마스터 되시는 거예요! 💪