웹 성능 최적화 첫걸음: 이벤트 위임 패턴

Taesoo Kim·2025년 5월 16일
2

client-optimization

목록 보기
1/1
post-thumbnail

웹 개발을 하다 보면 사용자 인터랙션을 처리하기 위해 수많은 이벤트 리스너를 등록하게 됩니다. element.addEventListener('click', handler)와 같은 코드는 매우 익숙하죠. 클릭, 마우스 움직임, 키보드 입력 등 다양한 상황에 대응해야 합니다. 하지만 무심코 남발된 이벤트 리스너는 웹 페이지 성능에 악영향을 줄 수 있다는 사실, 알고 계셨나요? 특히 동적으로 많은 요소가 생겼다 사라지는 복잡한 웹 애플리케이션이라면 더욱 그렇습니다.

오늘은 웹 페이지 성능 문제 중 하나인 "지나치게 많은 이벤트 리스너" 문제를 해결할 수 있는 강력한 기법, 바로 이벤트 위임(Event Delegation) 패턴에 대해 쉽고 자세하게 알아보겠습니다.

이벤트 리스너, 많으면 왜 문제일까요?

다음과 같은 코드가 있다고 가정해 봅시다.

// 특정 페이지의 addWindowEvents() 메소드 예시
addWindowEvents() {
  this.dropObservable$ = fromEvent<DragEvent>(window, 'drop');
  this.pasteObservable$ = fromEvent<ClipboardEvent>(window, 'paste');
  this.copyObservable$ = fromEvent<Event>(window, 'copy');
  // ... 수많은 window 이벤트들 ...

  // 각 Observable에 대한 구독이 별도로 처리됨
  this.subscriptions.push(this.dropObservable$.subscribe(this.dropHandler.bind(this)));
  this.subscriptions.push(this.pasteObservable$.subscribe(this.pasteHandler.bind(this)));
  // ... 더 많은 구독들 ...
}

위 코드는 window 객체에 직접 수많은 이벤트 리스너를 등록하고, 각 이벤트마다 개별적인 구독(subscription)을 생성하고 있습니다. 만약 여기서 다루는 item들이 수십, 수백 개가 되고 각 item마다 클릭 이벤트를 따로 등록한다고 상상해 보세요.

// 기존 방식: 여러 개별 요소에 이벤트 리스너 연결
const items = document.querySelectorAll('.item'); // 페이지 로드 시점에 있는 item들
items.forEach(item => {
  item.addEventListener('click', this.handleItemClick);
});

이런 방식은 다음과 같은 문제점을 야기할 수 있습니다:

  1. 메모리 사용량 증가: 각 이벤트 리스너는 메모리를 차지합니다. 리스너가 많아질수록 웹 페이지가 사용하는 메모리도 늘어나고, 심하면 브라우저가 느려지거나 멈출 수도 있습니다.
  2. 성능 저하: 브라우저는 등록된 모든 리스너를 관리해야 하므로, 리스너가 많을수록 이벤트 발생 시 적절한 핸들러를 찾는 데 더 많은 시간이 소요될 수 있습니다.
  3. 동적 요소 관리의 어려움: 페이지가 로드된 후 자바스크립트로 새로운 .item 요소가 추가될 때, 위 코드(items.forEach(...))는 이미 실행된 후이므로 이 새 요소에는 이벤트 리스너가 등록되지 않습니다. 따라서 새롭게 추가된 아이템은 클릭해도 handleItemClick 함수가 실행되지 않는 문제가 발생합니다. 이를 해결하려면 요소를 추가할 때마다 수동으로 리스너를 다시 달아줘야 하는 번거로움이 생깁니다. (참고: 캡틴판교님의 블로그 - 이벤트 위임 예시 중 동적 요소 문제)

이벤트 위임 패턴: 해결책은 부모에게 있다!

이벤트 위임 패턴은 이러한 문제점들을 해결하는 우아한 방법입니다. 핵심 아이디어는 개별 요소에 이벤트 리스너를 다는 대신, 그들의 공통된 상위 요소(부모 또는 조상 요소)에 단 하나의 리스너를 다는 것입니다.

이벤트 위임 패턴을 적용한 예시를 살펴봅시다.

// 개선: 상위 컨테이너에 하나의 이벤트 리스너 사용
const container = document.querySelector('#item-container'); // 아이템들을 감싸는 부모 요소

container.addEventListener('click', (e) => {
  // e.target은 실제 클릭된 요소를 가리킵니다.
  // e.currentTarget은 이벤트 리스너가 등록된 요소(여기서는 container)를 가리킵니다.
  const item = e.target.closest('.item'); // 클릭된 요소 또는 가장 가까운 '.item' 부모를 찾습니다.

  if (item) {
    // '.item' 클래스를 가진 요소에서 이벤트가 발생한 경우에만 처리
    // this.handleItemClick(e, item); // 실제 실행할 함수 호출
    console.log(item.textContent + ' 클릭됨! (이벤트 위임)');
  }
});

어떻게 작동하는 걸까요?

이벤트 위임은 이벤트 버블링(Event Bubbling)이라는 자바스크립트 이벤트 모델의 특징을 활용합니다. 이벤트 버블링이란, 특정 요소에서 이벤트가 발생하면 해당 이벤트가 상위 요소로 계속 전달되는 현상을 말합니다.

  • 이벤트 버블링 심층 탐구:
    HTML DOM(문서 객체 모델)에서 어떤 요소에 이벤트(예: 'click', 'mouseover', 'keydown' 등)가 발생하면, 그 이벤트는 해당 요소에서 시작하여 DOM 트리를 따라 부모 요소로, 그리고 그 부모의 부모 요소로 계속해서 올라가며 전파됩니다. 마치 물속의 거품이 수면으로 올라오는 모습과 비슷하다고 해서 "버블링"이라는 이름이 붙었습니다. 이 전파는 문서의 최상위 객체인 window 객체에 도달할 때까지 이어집니다.

    예를 들어, 다음과 같은 HTML 구조를 생각해 봅시다:

    <form id="grandparent">FORM
      <div id="parent">DIV
        <p id="child">클릭하세요!</p>
      </div>
    </form>

    이제 각 요소(grandparent, parent, child)에 다음과 같이 클릭 이벤트 리스너를 등록했다고 가정해 보겠습니다.

    const grandparent = document.getElementById('grandparent');
    const parent = document.getElementById('parent');
    const child = document.getElementById('child');
    
    function logEventDetails(event) {
      console.log(
        `${event.currentTarget.id} 클릭됨. ` +
        `currentTarget: ${event.currentTarget.tagName}#${event.currentTarget.id}, ` +
        `target: ${event.target.tagName}#${event.target.id}`
      );
    }
    
    grandparent.addEventListener('click', logEventDetails);
    parent.addEventListener('click', logEventDetails);
    child.addEventListener('click', logEventDetails);

    만약 사용자가 p#child 요소를 클릭하면, 이벤트 버블링으로 인해 콘솔에는 다음과 같은 순서로 로그가 찍힐 것입니다:

    1. child 클릭됨. currentTarget: P#child, target: P#child
    2. parent 클릭됨. currentTarget: DIV#parent, target: P#child
    3. grandparent 클릭됨. currentTarget: FORM#grandparent, target: P#child

    여기서 event.target은 항상 이벤트가 최초 발생한 요소(p#child)를 가리키는 반면, event.currentTarget은 현재 실행 중인 이벤트 핸들러가 연결된 요소(각각 child, parent, grandparent)를 가리킵니다. 이벤트 위임에서는 event.target을 사용하여 실제로 어떤 자식 요소에서 이벤트가 시작되었는지 판단합니다.

  • 이벤트 전파의 전체 흐름 (캡처링 - 타깃 - 버블링):
    사실 이벤트가 발생하면 브라우저는 다음의 3단계를 거쳐 이벤트를 전파합니다. (참고: 인파님의 블로그 글)

    1. 캡처링 단계(Capturing Phase): 이벤트가 최상위 조상(예: window 또는 document)에서 시작하여 실제 이벤트가 발생한 타깃 요소까지 DOM 트리를 따라 내려옵니다. 이 단계에서 이벤트 리스너를 등록할 수도 있지만 (addEventListener의 세 번째 인자를 true로 설정), 일반적으로는 버블링 단계를 더 많이 활용합니다.
    2. 타깃 단계(Target Phase): 이벤트가 실제 타깃 요소에 도달하여 해당 요소에 등록된 이벤트 리스너가 실행됩니다.
    3. 버블링 단계(Bubbling Phase): 이벤트가 다시 타깃 요소에서 시작하여 DOM 트리를 따라 최상위 조상까지 올라갑니다. 우리가 주로 이야기하는 이벤트 버블링이 바로 이 단계입니다.

    이벤트 위임은 주로 이 세 번째 단계인 버블링 단계를 활용하여 상위 요소에서 이벤트를 처리합니다.

  • 이벤트 전파, 왜 필요할까요? (참고: 인파님의 블로그 글)

    1. 논리적인 이유: 자식 요소는 부모 요소의 영역 안에 존재합니다. 따라서 자식 요소를 클릭하는 것은 논리적으로 보면 부모 요소도 함께 클릭하는 것으로 해석될 수 있습니다. 이벤트 전파는 이러한 포함 관계를 자연스럽게 반영합니다.
    2. 성능 및 효율성 (이벤트 위임의 기반): 만약 여러 개의 하위 요소 각각에 동일한 종류의 이벤트를 처리해야 한다면, 각 요소마다 이벤트 리스너를 등록하는 것은 비효율적입니다. 이벤트 전파(특히 버블링) 덕분에 상위 요소 단 하나에만 이벤트 리스너를 등록하고, 이벤트가 발생한 실제 타깃을 확인하여 작업을 수행할 수 있습니다. 이것이 바로 이벤트 위임의 핵심 원리이며, 코드 중복을 줄이고 성능을 향상시키는 데 크게 기여합니다.

    이벤트 위임은 바로 이 버블링 현상을 이용하여, 개별 자식 요소 대신 공통 상위 요소에 리스너를 두고 event.target으로 실제 이벤트 발생 지점을 확인하여 동작합니다.

  1. 사용자가 container 내부의 특정 item을 클릭합니다.
  2. 클릭 이벤트는 해당 item에서 발생하고, 마치 물거품처럼 부모 요소로 전파됩니다. (item -> ... -> container)
  3. container 요소에 도달하면, 우리가 container에 등록해 둔 단 하나의 click 이벤트 리스너가 이 이벤트를 감지합니다.
  4. 리스너 함수 내에서는 event.target (이벤트가 최초 발생한 요소, 즉 item)이나 event.target.closest('.item') (이벤트 발생 요소부터 시작해 .item 선택자와 일치하는 가장 가까운 조상 요소를 반환)을 사용하여 실제로 어떤 하위 요소에서 이벤트가 시작되었는지 확인할 수 있습니다.
  5. 만약 이벤트가 우리가 원하는 .item에서 발생한 것이 맞다면 (또는 .item의 자식 요소에서 발생하여 .itemclosest로 찾을 수 있다면), 해당 item에 대한 원하는 작업을 수행합니다.

이벤트 위임 패턴의 빛나는 장점들

  • 메모리 사용량 감소: 단 하나의 리스너만 등록하므로 메모리 사용량이 현저히 줄어듭니다. 수백 개의 item이 있어도 리스너는 여전히 하나뿐입니다!
  • 성능 향상: 브라우저가 관리해야 할 리스너 수가 줄어들어 이벤트 처리 속도가 빨라질 수 있습니다.
  • 동적 요소 완벽 지원: container 내부에 새로운 .item 요소가 나중에 자바스크립트로 추가되거나 기존 .item이 삭제되어도, 이벤트 리스너를 추가하거나 제거할 필요가 없습니다. container에 이미 등록된 리스너가 모든 것을 알아서 처리해 주니까요! 이는 앞에서 언급한 "동적 요소 관리의 어려움" 문제를 깔끔하게 해결합니다.
  • 코드 간결성 및 유지보수 용이성: 이벤트 처리 로직이 한 곳에 모이므로 코드가 더 깔끔해지고 이해하기 쉬워집니다.

언제 이벤트 위임 패턴을 사용하면 좋을까요?

  • 많은 수의 자식 요소에 동일한 이벤트를 처리해야 할 때: 예를 들어, 목록(ul > li), 테이블(table > tr > td), 그리드 아이템 등에 유용합니다.
  • 동적으로 요소가 추가되거나 제거되는 경우: 새로운 요소에 일일이 리스너를 달아주는 번거로움을 피할 수 있습니다.
  • 성능 최적화가 중요한 애플리케이션: 메모리 사용을 줄이고 반응 속도를 높이는 데 기여합니다.

이벤트 전파를 제어하고 싶을 때: event.stopPropagation()

때로는 이벤트가 상위 요소로 전파되는 것을 원치 않을 수 있습니다. 예를 들어, 특정 버튼을 클릭했을 때 그 버튼의 부모 요소에 있는 클릭 이벤트는 실행되지 않도록 하고 싶을 수 있습니다. 이때 event.stopPropagation() 메소드를 사용할 수 있습니다. (참고: 캡틴판교님의 블로그 - event.stopPropagation())

childElement.addEventListener('click', function(event) {
  console.log('자식 요소 클릭됨! 여기서 전파 중단!');
  event.stopPropagation(); // 이벤트가 부모로 버블링되는 것을 막습니다.
});

parentElement.addEventListener('click', function(event) {
  // childElement에서 stopPropagation()이 호출되면 이 핸들러는 실행되지 않습니다.
  console.log('부모 요소 클릭됨 (자식에서 중단 안했다면)');
});

event.stopPropagation()을 호출하면 해당 이벤트는 현재 요소의 이벤트 핸들러 실행을 마친 후 더 이상 상위(버블링 단계) 또는 하위(캡처링 단계, 만약 캡처링 리스너에서 호출했다면)로 전파되지 않습니다.

주의사항: event.stopPropagation()은 매우 유용하지만, 남용하면 이벤트 흐름을 예측하기 어렵게 만들거나 상위 요소에서 기대하는 동작을 막을 수 있으므로 신중하게 사용해야 합니다. 예를 들어, 전체 페이지에 적용된 공통적인 분석 로깅이나 UI 동작이 특정 요소에서는 막힐 수 있습니다.

마무리하며

이벤트 위임 패턴은 웹 성능을 개선하고 코드를 더 효율적으로 관리할 수 있게 해주는 강력한 도구입니다. 처음에는 event.target을 확인하는 로직이 조금 어색하게 느껴질 수 있지만, 한번 익숙해지면 그 편리함과 강력함에 매료될 것입니다.

애플리케이션 전반에 걸쳐 과도하게 등록된 이벤트 리스너가 있다면 이벤트 위임 패턴을 적극적으로 검토해 보세요. 작은 변화가 웹 애플리케이션의 전체적인 성능과 사용자 경험에 큰 차이를 만들 수 있습니다.

다음번에는 또 다른 성능 개선 팁으로 찾아뵙겠습니다!


참고 자료

profile
뭔 생각을 해. 그냥 하는 거지 뭐

0개의 댓글