리스트에 아이템 1000개를 하나씩 appendChild로 추가했더니 화면이 버벅입니다. DOM 조작 한 번마다 리플로우가 발생하기 때문입니다. 왜 그런지, 어떻게 최적화하는지 알고 있으신가요?

DOM은 HTML 문서를 브라우저가 해석한 트리 구조의 객체 모델 이에요. 프레임워크 없이 DOM을 직접 다루는 방법을 이해하면, React의 Virtual DOM이 왜 등장했는지 체감할 수 있습니다.


DOM이란

DOM(Document Object Model) 은 HTML 문서를 브라우저가 파싱한 뒤 만들어내는 ** 트리 구조의 객체 모델 **입니다. HTML 태그 하나하나가 노드가 되고, 이 노드들이 부모-자식 관계로 연결돼요.

PLAINTEXT
document
 └── html
      ├── head
      │    └── title
      │         └── "My Page" (텍스트 노드)
      └── body
           ├── h1
           │    └── "Hello" (텍스트 노드)
           └── p
                └── "World" (텍스트 노드)

중요한 건 **DOM은 HTML 소스코드 그 자체가 아니라 **, 브라우저가 해석한 결과물이라는 점이에요. HTML에 문법 오류가 있어도 브라우저가 보정해서 DOM을 만들어내기 때문에, 소스코드와 DOM이 다를 수 있습니다.

Node vs Element

Node와 Element의 차이가 뭔가요? DOM 트리의 모든 구성 요소는 Node 이고, Element는 Node의 하위 타입 중 하나예요.

종류설명예시
Element NodeHTML 태그에 해당하는 노드<div>, <p>, <span>
Text Node태그 안의 텍스트"Hello World"
Comment Node주석<!-- 주석 -->
Document Node문서 전체를 나타내는 루트 노드document
JS
const div = document.createElement('div');
div.textContent = 'Hello';

console.log(div.nodeType);        // 1 (Element Node)
console.log(div.firstChild.nodeType); // 3 (Text Node)

// Element는 Node를 상속한다
console.log(div instanceof Node);    // true
console.log(div instanceof Element); // true

childNodes는 텍스트 노드와 주석까지 포함하고, children은 Element만 반환합니다. 이 차이를 모르면 DOM 순회 코드에서 예상치 못한 버그를 만날 수 있어요.


DOM 선택

DOM을 조작하려면 먼저 원하는 요소를 찾아야 합니다. 방법이 여러 개 있는데, 각각 특성이 달라요.

getElementById

JS
const header = document.getElementById('main-header');

ID로 요소를 찾습니다. 가장 빨라요. 브라우저가 내부적으로 ID를 해시맵으로 관리하기 때문입니다. 단, ID는 문서 내에서 유일해야 하므로 하나만 반환해요.

querySelector / querySelectorAll

JS
// CSS 선택자 문법을 그대로 쓸 수 있다
const firstBtn = document.querySelector('.btn-primary');
const allBtns = document.querySelectorAll('.btn-primary');

CSS 선택자 문법을 사용할 수 있어서 유연합니다. querySelector는 첫 번째 매칭 요소 하나만, querySelectorAll은 매칭되는 모든 요소를 반환해요.

NodeList vs HTMLCollection

그런데 NodeList와 HTMLCollection은 뭐가 다른 걸까요? querySelectorAll이 반환하는 NodeList 와 getElementsByClassName 같은 메서드가 반환하는 HTMLCollection 은 다릅니다.

JS
const nodeList = document.querySelectorAll('.item');       // 정적 NodeList
const htmlCollection = document.getElementsByClassName('item'); // 라이브 HTMLCollection
구분NodeList (querySelectorAll)HTMLCollection (getElementsBy*)
업데이트정적 — 호출 시점의 스냅샷라이브 — DOM 변경 시 자동 갱신
forEach사용 가능사용 불가 (배열 변환 필요)
** 포함 범위**모든 노드 타입 가능Element만
JS
const list = document.getElementById('list');

// HTMLCollection은 라이브라서 반복 중 DOM을 수정하면 위험하다
const items = list.getElementsByClassName('item');
console.log(items.length); // 3

list.removeChild(items[0]);
console.log(items.length); // 2 — 컬렉션이 자동으로 줄어든다

// NodeList는 정적이라 안전하다
const safeItems = list.querySelectorAll('.item');
list.removeChild(safeItems[0]);
console.log(safeItems.length); // 여전히 기존 개수 유지 (DOM에서 제거됐지만 리스트는 그대로)

라이브 컬렉션은 반복문 안에서 요소를 삭제하면 인덱스가 꼬여요. 실무에서 흔한 버그 원인이니까 알아두는 게 좋습니다.


DOM 조작

createElement / appendChild

JS
const newItem = document.createElement('li');
newItem.textContent = '새 항목';
newItem.classList.add('item');

document.getElementById('list').appendChild(newItem);

createElement로 만든 요소는 아직 DOM에 붙지 않은 상태예요. appendChild를 호출해야 실제 DOM 트리에 삽입됩니다.

insertBefore

JS
const list = document.getElementById('list');
const newItem = document.createElement('li');
newItem.textContent = '맨 앞에 추가';

// 첫 번째 자식 앞에 삽입
list.insertBefore(newItem, list.firstChild);

특정 위치에 삽입하고 싶을 때 씁니다. insertAdjacentElement도 있는데, 이쪽이 위치 지정이 더 직관적이에요.

JS
const target = document.querySelector('.target');

// 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend'
target.insertAdjacentHTML('beforebegin', '<p>타겟 바로 앞</p>');
target.insertAdjacentHTML('afterend', '<p>타겟 바로 뒤</p>');

remove / cloneNode

JS
// 요소 제거
const item = document.querySelector('.item');
item.remove(); // 모던 방식

// 예전에는 부모를 통해 제거해야 했다
item.parentNode.removeChild(item);

// 요소 복제
const original = document.querySelector('.card');
const shallowCopy = original.cloneNode(false);  // 태그만 복사
const deepCopy = original.cloneNode(true);       // 자식까지 전부 복사

cloneNode(true)는 자식 요소와 텍스트까지 모두 복사하지만, ** 이벤트 리스너는 복사되지 않습니다 **. addEventListener로 등록한 핸들러는 직접 다시 붙여야 해요.


DocumentFragment

DOM에 요소를 하나씩 추가하면 매번 ** 리플로우(reflow)** 가 발생할 수 있어요. 리플로우는 레이아웃을 다시 계산하는 과정이라 비용이 큽니다.

JS
// 안 좋은 예 — 1000번의 DOM 조작
const list = document.getElementById('list');
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  list.appendChild(li); // 매번 DOM 트리가 변경됨
}

DocumentFragment 는 메모리에만 존재하는 가벼운 컨테이너입니다. 여기에 요소들을 먼저 모아두고, 한 번에 DOM에 삽입하면 리플로우를 최소화할 수 있어요.

JS
// 좋은 예 — DocumentFragment로 배치 처리
const fragment = document.createDocumentFragment();

for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  fragment.appendChild(li); // 아직 실제 DOM에는 안 붙음
}

document.getElementById('list').appendChild(fragment); // 한 번만 DOM 변경

Fragment 자체는 DOM에 삽입되지 않고, 안에 있던 자식 노드들만 옮겨집니다. 그래서 삽입 후 fragment는 비어있는 상태가 돼요.

비슷한 효과를 내는 방법으로 innerHTML을 한 번에 세팅하는 것도 있는데, 이건 보안 이슈(XSS)가 있어서 사용자 입력이 포함될 때는 조심해야 합니다. 이 부분은 뒤에서 다시 다룰게요.


이벤트 시스템

addEventListener

JS
const button = document.querySelector('#myBtn');

button.addEventListener('click', function (e) {
  console.log('클릭됨!', e.target);
});

// 세 번째 인자로 옵션 전달 가능
button.addEventListener('click', handler, {
  once: true,    // 한 번만 실행 후 자동 제거
  passive: true, // preventDefault를 호출하지 않겠다는 힌트 (스크롤 성능 향상)
  capture: true  // 캡처링 단계에서 실행
});

onclick 같은 HTML 어트리뷰트 방식이나 프로퍼티 방식(btn.onclick = ...)은 하나의 핸들러만 등록할 수 있어요. addEventListener는 같은 이벤트에 여러 핸들러를 붙일 수 있어서 실무에서는 거의 이것만 씁니다.

이벤트 버블링과 캡처링

DOM 이벤트는 세 단계를 거칩니다.

PLAINTEXT
1. 캡처링 (Capturing) — window → document → html → body → ... → target
2. 타겟 (Target) — 실제 이벤트가 발생한 요소
3. 버블링 (Bubbling) — target → ... → body → html → document → window
JS
// 버블링 확인
document.querySelector('.outer').addEventListener('click', () => {
  console.log('outer'); // 3번째 출력
});

document.querySelector('.middle').addEventListener('click', () => {
  console.log('middle'); // 2번째 출력
});

document.querySelector('.inner').addEventListener('click', () => {
  console.log('inner'); // 1번째 출력 (타겟)
});
// inner를 클릭하면: inner → middle → outer 순서로 출력

캡처링 단계에서 이벤트를 잡고 싶으면 세 번째 인자에 true{ capture: true }를 넘기면 됩니다. 실무에서 캡처링을 직접 쓸 일은 많지 않지만, 자주 헷갈리는 부분이에요.

e.stopPropagation / e.preventDefault

JS
// 이벤트 전파 중단
document.querySelector('.inner').addEventListener('click', (e) => {
  e.stopPropagation(); // 부모로 이벤트가 올라가지 않음
  console.log('inner만 실행');
});

// 기본 동작 막기
document.querySelector('a').addEventListener('click', (e) => {
  e.preventDefault(); // 링크 이동 안 함
  console.log('링크 클릭했지만 이동 안 됨');
});

// 폼 제출 막기
document.querySelector('form').addEventListener('submit', (e) => {
  e.preventDefault(); // 페이지 새로고침 방지
  // AJAX로 처리
});

stopPropagation은 이벤트 전파를 멈추고, preventDefault는 브라우저의 기본 동작을 막습니다. 둘은 완전히 다른 역할이니 헷갈리지 않도록 주의하세요. return false는 jQuery에서는 둘 다 호출하는 효과가 있었지만, 바닐라 JS에서는 아무 효과 없어요.


이벤트 위임 (Event Delegation)

이벤트 위임은 정말 중요한 주제입니다. 버블링을 활용해서 **부모 요소 하나에만 이벤트 리스너를 등록하고 **, 자식 요소에서 발생한 이벤트를 부모가 대신 처리하는 패턴이에요.

왜 쓰는가

JS
// 안 좋은 예 — 각 버튼마다 리스너 등록
document.querySelectorAll('.btn').forEach((btn) => {
  btn.addEventListener('click', handleClick);
});
// 버튼이 100개면 리스너도 100개

이렇게 하면 세 가지 문제가 있어요.

  1. ** 메모리 낭비** — 리스너가 요소 수만큼 생깁니다.
  2. ** 동적 요소 처리 불가** — 나중에 추가된 버튼에는 리스너가 안 붙어요.
  3. ** 관리 복잡** — 요소를 제거할 때 리스너 해제도 신경 써야 합니다.

이벤트 위임 패턴

JS
// 좋은 예 — 부모 하나에만 리스너 등록
document.getElementById('button-container').addEventListener('click', (e) => {
  // 클릭된 게 버튼인지 확인
  const btn = e.target.closest('.btn');
  if (!btn) return;

  console.log('클릭된 버튼:', btn.dataset.id);
});

e.target.closest('.btn')이 핵심입니다. e.target은 실제 클릭된 요소인데, 버튼 안에 <span>이나 아이콘이 있으면 그게 target이 될 수 있어요. closest를 쓰면 클릭된 요소부터 위로 올라가면서 셀렉터에 매칭되는 가장 가까운 조상을 찾아주기 때문에 안전합니다.

동적 요소 처리

이벤트 위임의 진짜 장점이 여기서 드러납니다.

JS
const list = document.getElementById('todo-list');

// 리스너는 한 번만 등록
list.addEventListener('click', (e) => {
  if (e.target.matches('.delete-btn')) {
    e.target.closest('li').remove();
  }
  if (e.target.matches('.toggle-btn')) {
    e.target.closest('li').classList.toggle('done');
  }
});

// 나중에 동적으로 추가해도 이벤트가 알아서 작동한다
function addTodo(text) {
  const li = document.createElement('li');
  li.innerHTML = `
    <span>${text}</span>
    <button class="toggle-btn">완료</button>
    <button class="delete-btn">삭제</button>
  `;
  list.appendChild(li);
  // 별도 이벤트 등록 필요 없음!
}

React의 합성 이벤트 시스템도 내부적으로 이벤트 위임을 사용해요. 루트 요소에 이벤트를 위임하고, 이벤트가 발생하면 React의 fiber 트리를 순회하면서 해당 컴포넌트의 핸들러를 찾아 실행합니다.


바닐라 SPA 라우터 만들기

SPA(Single Page Application)는 페이지 전체를 새로고침하지 않고 URL에 따라 화면만 교체하는 방식입니다. React Router 같은 라이브러리 없이 직접 만들어보면 원리를 확실히 이해할 수 있어요.

History API — pushState와 popstate

history.pushState를 쓰면 ** 페이지 새로고침 없이 URL을 변경 **할 수 있습니다.

JS
// URL을 /about으로 변경 (새로고침 없음)
history.pushState({ page: 'about' }, '', '/about');

// 브라우저 뒤로가기/앞으로가기를 감지
window.addEventListener('popstate', (e) => {
  console.log('이동:', location.pathname);
  console.log('state:', e.state);
  renderPage(location.pathname);
});

pushState는 세 개 인자를 받아요: state 객체, title (대부분 빈 문자열), url. 주의할 점은 pushState 자체는 popstate 이벤트를 발생시키지 않는다 는 것입니다. popstate는 뒤로가기/앞으로가기 같은 브라우저 네비게이션에서만 발생해요.

바닐라 SPA 라우터 구현

JS
class Router {
  constructor() {
    this.routes = {};
    this.init();
  }

  init() {
    // 뒤로가기/앞으로가기 처리
    window.addEventListener('popstate', () => {
      this.render(location.pathname);
    });

    // 링크 클릭 가로채기
    document.addEventListener('click', (e) => {
      const link = e.target.closest('a[data-link]');
      if (!link) return;

      e.preventDefault();
      this.navigate(link.getAttribute('href'));
    });
  }

  addRoute(path, handler) {
    this.routes[path] = handler;
    return this; // 체이닝 지원
  }

  navigate(path) {
    history.pushState(null, '', path);
    this.render(path);
  }

  render(path) {
    const handler = this.routes[path] || this.routes['/404'];
    if (handler) {
      document.getElementById('app').innerHTML = handler();
    }
  }
}

// 사용
const router = new Router();

router
  .addRoute('/', () => '<h1>홈</h1><p>메인 페이지입니다.</p>')
  .addRoute('/about', () => '<h1>소개</h1><p>소개 페이지입니다.</p>')
  .addRoute('/404', () => '<h1>404</h1><p>페이지를 찾을 수 없습니다.</p>');

router.render(location.pathname);
HTML
<!-- HTML 쪽 -->
<nav>
  <a href="/" data-link></a>
  <a href="/about" data-link>소개</a>
</nav>
<div id="app"></div>

링크에 data-link 어트리뷰트를 붙이고, 이벤트 위임으로 클릭을 가로채서 pushState를 호출하는 구조예요. 여기서도 이벤트 위임이 쓰이는 걸 볼 수 있습니다.

Hash Router

pushState 대신 URL의 해시(#) 를 이용하는 방식도 있어요.

JS
class HashRouter {
  constructor() {
    this.routes = {};
    window.addEventListener('hashchange', () => {
      this.render(location.hash.slice(1) || '/');
    });
  }

  addRoute(path, handler) {
    this.routes[path] = handler;
    return this;
  }

  render(path) {
    const handler = this.routes[path] || this.routes['/404'];
    if (handler) {
      document.getElementById('app').innerHTML = handler();
    }
  }
}

// URL이 /#/about 형태가 된다
const router = new HashRouter();
router
  .addRoute('/', () => '<h1>홈</h1>')
  .addRoute('/about', () => '<h1>소개</h1>');

Hash Router의 장점은 ** 서버 설정 없이도 동작 **한다는 거예요. # 뒤의 내용은 서버로 전송되지 않으니까, GitHub Pages 같은 정적 호스팅에서도 별도 설정 없이 라우팅이 됩니다. 반면 History API 방식은 서버에서 모든 경로에 대해 index.html을 내려주도록 설정해야 해요.


템플릿 리터럴로 컴포넌트 패턴

바닐라 JS에서도 컴포넌트처럼 UI를 모듈화할 수 있어요. 템플릿 리터럴을 활용하면 됩니다.

JS
function TodoItem({ id, text, done }) {
  return `
    <li class="todo-item ${done ? 'done' : ''}" data-id="${id}">
      <span class="todo-text">${text}</span>
      <button class="toggle-btn" data-action="toggle">
        ${done ? '취소' : '완료'}
      </button>
      <button class="delete-btn" data-action="delete">삭제</button>
    </li>
  `;
}

function TodoList(todos) {
  return `
    <ul class="todo-list">
      ${todos.map(TodoItem).join('')}
    </ul>
  `;
}

function App(state) {
  return `
    <div class="app">
      <h1>할 일 목록 (${state.todos.filter((t) => !t.done).length}개 남음)</h1>
      <input type="text" id="todo-input" placeholder="할 일을 입력하세요" />
      <button id="add-btn">추가</button>
      ${TodoList(state.todos)}
    </div>
  `;
}

상태와 렌더링을 분리하면 더 깔끔해져요.

JS
let state = {
  todos: [
    { id: 1, text: '블로그 글 쓰기', done: false },
    { id: 2, text: 'DOM 정리', done: true },
  ],
};

function setState(newState) {
  state = { ...state, ...newState };
  render();
}

function render() {
  document.getElementById('root').innerHTML = App(state);
  bindEvents();
}

function bindEvents() {
  document.getElementById('root').addEventListener('click', (e) => {
    const action = e.target.dataset.action;
    const id = Number(e.target.closest('[data-id]')?.dataset.id);

    if (action === 'toggle') {
      setState({
        todos: state.todos.map((t) =>
          t.id === id ? { ...t, done: !t.done } : t
        ),
      });
    }

    if (action === 'delete') {
      setState({
        todos: state.todos.filter((t) => t.id !== id),
      });
    }
  });

  document.getElementById('add-btn')?.addEventListener('click', () => {
    const input = document.getElementById('todo-input');
    if (!input.value.trim()) return;

    setState({
      todos: [
        ...state.todos,
        { id: Date.now(), text: input.value.trim(), done: false },
      ],
    });
  });
}

render();

이 패턴의 문제가 보이시나요? setState 할 때마다 innerHTML로 전체를 갈아끼우고, 이벤트 리스너도 매번 다시 붙입니다. 데이터가 적을 때는 괜찮지만, 리스트가 수백 개가 되면 성능이 눈에 띄게 떨어져요. 이 한계가 바로 Virtual DOM이 등장한 배경입니다.


Virtual DOM이 왜 등장했는가

바닐라 DOM 조작에는 근본적인 한계가 있습니다.

직접 DOM 조작의 문제점

  1. ** 전체 리렌더링 비용** — innerHTML로 갈아끼우면 기존 DOM을 전부 파괴하고 새로 만들어요. 입력 필드의 포커스, 스크롤 위치, 체크박스 상태 같은 것들이 전부 날아갑니다.

  2. ** 부분 업데이트의 복잡성** — 변경된 부분만 직접 찾아서 업데이트하려면 코드가 금방 복잡해져요. 어떤 노드가 추가됐고, 어떤 노드가 삭제됐고, 어떤 속성이 바뀌었는지를 일일이 추적해야 합니다.

  3. ** 리플로우 비용** — DOM을 변경할 때마다 브라우저가 레이아웃을 다시 계산해야 할 수 있어요. 여러 요소를 개별적으로 수정하면 리플로우가 여러 번 발생합니다.

JS
// 이걸 직접 하려면 너무 고통스럽다
function updateTodoItem(id, newText, newDone) {
  const li = document.querySelector(`[data-id="${id}"]`);
  if (!li) return;

  const span = li.querySelector('.todo-text');
  if (span.textContent !== newText) {
    span.textContent = newText;
  }

  if (newDone) {
    li.classList.add('done');
  } else {
    li.classList.remove('done');
  }

  const btn = li.querySelector('.toggle-btn');
  btn.textContent = newDone ? '취소' : '완료';
}
// 항목이 늘어날수록 이런 함수를 계속 만들어야 한다

Virtual DOM의 해법

Virtual DOM은 이 문제를 ** 자동화 **한 거예요.

  1. 상태가 바뀌면 ** 새로운 가상 DOM 트리 **를 만듭니다. (JS 객체라서 빨라요)
  2. 이전 가상 DOM과 새 가상 DOM을 ** 비교(diff)** 합니다.
  3. 실제로 변경된 부분만 ** 실제 DOM에 패치(patch)** 합니다.
PLAINTEXT
상태 변경 → 새 Virtual DOM 생성 → Diff → 최소한의 실제 DOM 업데이트

개발자가 선언적으로 "이 상태일 때 UI는 이래야 한다"고 기술하면, 프레임워크가 알아서 최소한의 DOM 조작으로 현재 화면을 원하는 상태로 바꿔줍니다. 바닐라에서 직접 하던 고통스러운 diff 작업을 프레임워크가 대신 해주는 셈이에요.

다만 Virtual DOM이 항상 네이티브 DOM 조작보다 빠른 건 아닙니다. 오버헤드가 있기 때문에, 단순한 변경이라면 직접 DOM을 조작하는 게 더 빠를 수 있어요. Virtual DOM의 가치는 속도보다 "충분히 빠르면서 개발 경험이 압도적으로 좋다" 는 데 있습니다.


심화 주제

innerHTML vs textContent — 보안 문제

JS
const userInput = '<img src=x onerror="alert(\'XSS\')">';

// 위험 — XSS 공격 가능
element.innerHTML = userInput; // 스크립트가 실행될 수 있다

// 안전 — 태그가 텍스트로 이스케이프됨
element.textContent = userInput; // 문자열 그대로 출력된다

innerHTML은 HTML을 파싱해서 DOM에 삽입하기 때문에, 사용자 입력을 그대로 넣으면 악성 스크립트가 실행될 수 있어요. 사용자 입력을 표시할 때는 textContent를 쓰거나, 반드시 새니타이즈 처리를 해야 합니다.

innerText도 있는데 textContent와 달라요. innerText는 CSS를 고려해서 숨겨진 요소의 텍스트는 무시하고, 리플로우를 유발합니다. textContent는 모든 텍스트를 있는 그대로 반환하며 리플로우 없이 빨라요.

MutationObserver

DOM의 변화를 감시할 수 있는 API입니다. 특정 요소의 자식이 추가/제거되거나, 속성이 변경되는 걸 감지할 수 있어요.

JS
const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList') {
      console.log('자식 노드 변경:', mutation.addedNodes, mutation.removedNodes);
    }
    if (mutation.type === 'attributes') {
      console.log('속성 변경:', mutation.attributeName);
    }
  });
});

observer.observe(document.getElementById('target'), {
  childList: true,     // 자식 노드 추가/제거 감시
  attributes: true,    // 속성 변경 감시
  subtree: true,       // 하위 트리 전체 감시
  characterData: true, // 텍스트 내용 변경 감시
});

// 감시 중단
observer.disconnect();

실무에서는 써드파티 라이브러리가 DOM을 건드리는 걸 감지하거나, 무한 스크롤에서 DOM 변화를 추적할 때 쓰입니다. React DevTools 같은 도구도 내부적으로 MutationObserver를 활용해요.

requestAnimationFrame

DOM 조작과 애니메이션을 ** 다음 리페인트 직전 **에 실행하도록 예약하는 함수입니다.

JS
// 부드러운 애니메이션 구현
function animate(element) {
  let position = 0;

  function step() {
    position += 2;
    element.style.transform = `translateX(${position}px)`;

    if (position < 300) {
      requestAnimationFrame(step); // 다음 프레임에 이어서 실행
    }
  }

  requestAnimationFrame(step);
}

// DOM 배치 읽기/쓰기 최적화
// 안 좋은 예 — 강제 동기 레이아웃 발생
elements.forEach((el) => {
  const height = el.offsetHeight; // 읽기 → 레이아웃 계산 강제
  el.style.height = height + 10 + 'px'; // 쓰기 → 레이아웃 무효화
});

// 좋은 예 — 읽기와 쓰기를 분리
const heights = elements.map((el) => el.offsetHeight); // 읽기를 먼저 모아서
requestAnimationFrame(() => {
  elements.forEach((el, i) => {
    el.style.height = heights[i] + 10 + 'px'; // 쓰기를 한 번에
  });
});

setTimeout(fn, 16)으로 60fps를 흉내내는 코드를 가끔 보는데, requestAnimationFrame이 브라우저 리페인트 주기에 정확히 맞춰 실행되므로 훨씬 정확하고 효율적입니다. 탭이 비활성화되면 자동으로 실행을 멈춰서 배터리도 아껴줘요.

Web Worker

JavaScript는 싱글 스레드입니다. 무거운 연산이 메인 스레드를 블로킹하면 UI가 멈춰요. Web Worker는 ** 별도 스레드 **에서 스크립트를 실행할 수 있게 해줍니다.

JS
// main.js
const worker = new Worker('worker.js');

worker.postMessage({ data: largeArray }); // 워커에 데이터 전달

worker.onmessage = (e) => {
  console.log('결과:', e.data); // 워커에서 보낸 결과 수신
  updateUI(e.data);
};

worker.onerror = (e) => {
  console.error('워커 에러:', e.message);
};
JS
// worker.js
self.onmessage = (e) => {
  const result = heavyComputation(e.data); // 무거운 연산
  self.postMessage(result); // 결과 전달
};

핵심 제약은 Worker에서는 DOM에 접근할 수 없다 는 것입니다. document, window 객체가 없어요. 순수 연산(정렬, 필터링, 암호화 등)만 가능합니다. 메인 스레드와는 postMessage로만 통신하며, 데이터는 복사되어 전달돼요. (구조화된 복사 알고리즘 사용)


파생 개념

이 글에서 다룬 내용은 아래 개념들과 연결됩니다.

  • React Virtual DOM — 바닐라 DOM 조작의 한계를 해결한 추상화 레이어. 선언적 UI 프로그래밍의 기반이 돼요.
  • ** 웹 컴포넌트(Web Components)** — Custom Elements, Shadow DOM, HTML Templates를 활용해 프레임워크 없이 재사용 가능한 컴포넌트를 만드는 웹 표준 기술입니다.
  • ** 브라우저 렌더링** — DOM 조작이 왜 비용이 큰지 이해하려면 브라우저가 HTML을 파싱하고 화면에 그리기까지의 과정(Critical Rendering Path)을 알아야 해요.
댓글 로딩 중...