Chapter 11

브라우저와 이벤트

  • 11.1 브라우저의 렌더링 과정
  • 11.2 HTML 파싱과 DOM/CSSOM 생성
  • 11.3 리플로우와 리페인트
  • 11.4 script 태그의 async/defer
  • 11.5 DOM API
  • 11.6 요소 노드 취득과 DOM 조작
  • 11.7 어트리뷰트
  • 11.8 이벤트 드리븐 프로그래밍
  • 11.9 이벤트 전파와 위임
  • 11.10 커스텀 이벤트
  • 11.11 타이머 함수
  • 11.12 디바운스와 스로틀

프론트엔드 면접의 단골 질문인 브라우저 렌더링부터 DOM 조작, 이벤트 시스템, 디바운스/스로틀까지 -- 브라우저 환경에서 JS가 어떻게 동작하는지 한 흐름으로 정리해볼게.

브라우저의 렌더링은 이런 순서로 진행돼. HTML 파싱으로 DOM 트리 생성, CSS 파싱으로 CSSOM 트리 생성, 둘을 결합한 렌더 트리 생성, 렌더 트리 기반으로 레이아웃(리플로우) 계산, 화면에 페인트(리페인트). 브라우저가 HTML을 받으면 바이트 -> 문자 -> 토큰 -> 노드 -> DOM 순서로 파싱해. DOM과 CSSOM을 결합한 렌더 트리에서 display: none인 요소는 포함되지 않아 -- 실제로 화면에 그려질 것만 들어가지.

성능 얘기에서 빠질 수 없는 게 리플로우리페인트야. 리플로우는 레이아웃(위치, 크기)을 재계산하는 거라 비용이 커. 레이아웃에 영향 없는 변경(color, visibility 등)은 리페인트만 발생해.

그리고 JS 파싱은 HTML 파싱을 블로킹해. <script> 태그를 만나면 HTML 파싱을 멈추고 JS를 다운로드/실행하거든. 그래서 **defer**를 쓰는 게 좋아 -- JS 다운로드는 비동기로 하되 HTML 파싱 완료 후 실행하고, 순서도 보장돼. async는 다운로드 완료 즉시 실행이라 순서가 보장 안 돼.

렌더링된 DOM을 동적으로 조작하는 게 DOM API야. DOM 트리에는 문서 노드(document), 요소 노드, 어트리뷰트 노드, 텍스트 노드 네 종류가 있어. 요소를 취득할 때는 querySelector/querySelectorAll이 가장 범용적이고 권장되지. getElementsByClassName이 반환하는 HTMLCollection은 살아있는 객체라 순회 중 변경되면 골치 아프거든.

텍스트를 다룰 때 **textContent**는 순수 텍스트만, **innerHTML**은 HTML 마크업을 포함하는데, innerHTMLXSS 공격에 취약하니까 조심해야 해. 노드 단위로 createElement -> textContent 설정 -> appendChild로 추가하는 게 더 안전하지.

const li = document.createElement('li');
li.textContent = 'new item';
document.getElementById('list').appendChild(li);

어트리뷰트에서 재밌는 건 HTML 어트리뷰트는 초기 상태를 관리하고 getAttribute/setAttribute로 접근하고, DOM 프로퍼티는 최신 상태를 관리한다는 거야. 사용자가 input에 뭔가 입력하면 DOM 프로퍼티만 바뀌고 HTML 어트리뷰트(초기값)는 그대로지.

브라우저에서 사용자와 상호작용하는 모든 것의 기반이 이벤트야. 이벤트 핸들러는 addEventListener를 쓰는 게 정답이야 -- 여러 핸들러를 등록할 수 있어서 가장 유연하지. 이벤트는 DOM 트리를 통해 캡처링(위 -> 아래) -> 타깃 -> 버블링(아래 -> 위) 세 단계로 전파돼.

이 전파 메커니즘을 활용한 게 **이벤트 위임(Event Delegation)**이야. 리스트 아이템 100개에 각각 핸들러를 다는 대신, 상위 요소에 하나의 핸들러를 달고 event.target으로 실제 이벤트 발생 요소를 판별하는 패턴이지.

document.getElementById('list').addEventListener('click', (e) => {
  if (e.target.matches('li')) {
    // li 요소 클릭 처리
  }
});

e.preventDefault()는 기본 동작을 중단하고, e.stopPropagation()은 이벤트 전파 자체를 중단해. 커스텀 이벤트도 new CustomEvent('myEvent', { detail: { message: 'hello' } })로 만들어서 dispatchEvent로 발생시킬 수 있어.

이벤트가 빈번하게 발생하면 성능 문제가 생기는데, 이때 필요한 게 디바운스스로틀이야. 둘 다 setTimeout 기반이지. 디바운스는 연속 발생하는 이벤트를 마지막 한 번만 처리해 -- 검색 자동완성, 리사이즈, 버튼 중복 클릭 방지에 딱 맞아.

function debounce(callback, delay) {
  let timerId;
  return (...args) => {
    clearTimeout(timerId);
    timerId = setTimeout(() => callback(...args), delay);
  };
}

스로틀은 연속 발생하는 이벤트를 일정 시간 간격으로 1번만 처리해. 스크롤 이벤트, 무한 스크롤, 드래그에 쓰면 돼.

function throttle(callback, delay) {
  let timerId;
  return (...args) => {
    if (timerId) return;
    timerId = setTimeout(() => {
      callback(...args);
      timerId = null;
    }, delay);
  };
}

정리

11장 읽고 기억할 거 세 가지:

  1. 렌더링은 DOM -> CSSOM -> 렌더 트리 -> 레이아웃 -> 페인트 순서야. 리플로우가 비싸니까 불필요한 레이아웃 재계산을 줄이는 게 성능 최적화의 기본이고, defer로 JS의 HTML 파싱 블로킹도 해결하자.
  2. 이벤트 위임은 필수 패턴이야. 상위 요소에 핸들러 하나만 달고 event.target으로 분기하면 성능과 유지보수 둘 다 잡을 수 있지. 캡처링 -> 타깃 -> 버블링 순서도 기억해두자.
  3. 디바운스는 "마지막 한 번만", 스로틀은 "일정 간격으로 한 번". 실무에서는 lodash의 구현을 쓰는 게 안전하지만, 동작 원리는 알아둬야 해.