Passive Event Listeners

sejin kim·2022년 5월 14일
26
post-thumbnail

{ passive: true }

웹 개발자라면, 이벤트 리스너를 등록할 때 사용하는 addEventListener() 메소드는 굉장히 익숙할 것입니다. 하지만 이때 이벤트 리스너의 특성을 지정하는 옵션은 대개 기본값으로 두고 명시적으로 조작할 만한 일은 많지 않아 비교적 생소할 수도 있는데, 이번 글에서는 이러한 옵션들 중 성능과 크게 연관된 passive 옵션, 즉 Passive Event Listeners라는 기능에 대해 살펴보고자 합니다.


addEventListener(type, listener, options)

Passive Event ListenersDOM Spec에서 명시하고 있는 기능 중 하나입니다. DOM 구현에 대한 표준 명세 중 Event와 관련되어 있는 내용으로, 핵심을 간단하게 말하자면 touch, wheel 등 일부 사용자 입력 이벤트에서 동작을 최적화하여 퍼포먼스를 대폭(MDN의 표현을 인용하자면 '획기적으로') 향상시킬 수 있는 웹 표준 기능입니다.

이벤트의 타입에서 유추해볼 수 있듯, 특히 터치 기반의 모바일 디바이스에서 극적인 효과를 기대해볼 수 있습니다. 다만 현재 시점에서 최신 기술/토픽은 아닌지라, 이미 주요 브라우저들은 예전부터 기본적으로 지원하고 있었으며(Chrome 51, Firefox 49, Webkit), GoogleMDN 문서 등에서는 적극적으로 사용을 권고하는 부분이기도 합니다.

일례로 웹 페이지의 성능과 사용자 경험의 품질을 분석하는 도구인 Lighthouse에서도, 해당 옵션을 사용하지 않고 이벤트 리스너를 등록한 경우 아래와 같이 리포트합니다.



때문에 가능한 상황이라면 가급적 이 옵션을 활성화하는 편이 바람직한데, 방법도 간단합니다. 아래와 같이 passive 옵션을 true로 지정하여 인수로 전달하면 됩니다.


addEventListener('touchmove', handler, { passive: true });

사실 이렇게 직접 명시하지 않더라도 브라우저는 최대한 옵션을 활성화하려 시도하는데, 이는 그만큼 퍼포먼스 향상 폭이 크기 때문입니다. 기본값은 false이지만, 가능한 경우 브라우저가 개입하여 true로 설정합니다.



위 영상은 다소 과거 기준이고 드라마틱한 케이스입니다. 현재는 브라우저 레벨의 적극적인 최적화와 진보된 컴퓨팅 파워 등으로 인해 이 정도의 차이까지는 보기 어려울 수 있습니다. 다만, 그렇다고 해서 passive 옵션의 의미가 퇴색되는 것은 아닙니다.






렌더러 프로세스의 이해

Passive Event Listeners가 어떠한 원리로 퍼포먼스를 향상시키는지에 대해 이해하기 위해서는, 먼저 브라우저가 화면을 어떻게 그리는지를 간단히 살펴볼 필요가 있습니다.


Chrome Developers, 'Inside look at modern web browser'


렌더러 프로세스란 HTML, CSS, JavaScript를 실제 웹 페이지, 눈으로 볼 수 있는 픽셀로 변환하는 작업이 수행되는 프로세스입니다(렌더링의 결과물은 비트맵 데이터이며, 비트맵 데이터는 픽셀로 구성됩니다). 이러한 작업을 보다 효율적으로 수행하기 위해, 브라우저에는 기본적으로 페이지의 모든 작업들이 처리되는 Main thread에 더해 Worker thread, Compositor thread, Raster thread 등 다양한 스레드가 존재하며, 각자의 역할을 수행합니다.

이러한 과정 - 렌더링 파이프라인을 대략적으로 나열해 보자면 아래와 같습니다.


  • Retrieving HTML: HTML 데이터를 수신합니다.
  • Parse HTML: HTML을 해석/파싱합니다.
  • Loading Subresources: CSS, JavaScript, Image 등 하위 리소스를 로드합니다.
  • Build DOM Tree: 해석된 HTML을 DOM 객체 트리로 구축합니다.
  • Build CSSOM Tree: CSS를 해석하여 CSSOM 객체 트리를 구축합니다.
  • Compute Style: 생성된 DOMCSSOM 트리를 조합하여, 페이지를 렌더링하는 데에 필요한 노드로 구성된 Render Tree를 구축합니다.
  • Layout/Reflow: 뷰포트 내에서 각 노드의 정확한 위치와 크기(지오메트리)를 계산하며, 렌더 트리에 반영합니다. 처음으로 노드의 위치와 크기를 계산하는 것을 Layout, 이후 다시 재계산하는 것은 Reflow라고 지칭합니다.
  • Paint(Rasterize): 그리기 단계. 렌더 트리의 노드들을 실제 화면의 픽셀로 변환하여 레이어로 구성합니다. 위치와 관계없는 CSS 속성만이 적용됩니다.
  • Composite: Paint 단계에서 생성한 레이어들을 합성하고, 화면을 업데이트합니다.

브라우저가 화면을 그리는 일이 생각보다 간단하지 않음을 알 수 있는데, 실제로도 막대한 비용이 소모됩니다.

프론트엔드 전반, 특히 웹 페이지 퍼포먼스 최적화를 다룰 때 자주 언급되는 단어로 reflowrepaint가 있습니다. reflow란 레이아웃에 무언가 변화가 발생하는 경우, 브라우저가 문서 내 요소들의 위치와 크기를 다시 계산하고, 일부나 전체 범위에서 다시 렌더링하게 되는 동작을 말합니다. 위의 렌더링 과정을 처음부터 다시 반복하고, 화면을 새로 그려야 하기 때문에 부담이 큽니다.

색상이나 이미지 같이 위치나 크기가 변하지 않는 정도의 변화라면 PaintComposite 프로세스만 다시 수행하기도 하는데, 이것은 repaint라고 합니다.

따라서 이러한 동작이 어느 경우에 발생하는지를 미리 파악하고, 최대한 회피(특히 reflow)하거나 그룹화하여 최소한으로 수행되게끔 하는 것이 렌더링 최적화의 핵심입니다. 개발 과정에서 생각 이상으로 쉽게, 빈번히 발생하기도 하며, 당연히 필요한 상황이어서 불가피하게 발생하는 경우 등도 있기 때문에 좀처럼 접근하기 어려운 부분이기도 합니다.

주요 모던 프론트엔드 프레임워크인 React가 주도적으로 제시한 패러다임이자 패턴인 Virtual DOM 역시 이러한 프로세스를 보다 효율적으로 수행하려는 의도에서 비롯된 아이디어라고 할 수 있습니다. (Svelte 등과 같이 Virtual DOM을 구현하지 않는 경우는 논외로 하겠습니다.)


Chrome Developers, 'Inside look at modern web browser'


다시 돌아와 렌더러 프로세스를 이어서 살펴보겠습니다. 위에서 언급한 스레드 중 Compositor thread*PaintComposite 작업을 담당합니다.


Compositor thread는 GPU와 상호작용하면서 그래픽스 작업들이 수행되는 스레드입니다. Raster thread와 함께 실제 픽셀을 화면에 그리는 작업(래스터화, Rasterize) 등을 담당하면서, CPU 집약적인 Main thread의 부담을 덜어줍니다.


마우스 이동, 휠, 터치 등 사용자의 모든 제스처는 브라우저의 관점에서 '입력' 이벤트로 취급됩니다. 이러한 이벤트들은 브라우저 프로세스가 먼저 수신하고, 이벤트의 타입과 이벤트가 발생한 좌표를 렌더러 프로세스에 전달(라우팅)해 줍니다. 그러면 렌더러 프로세스에서는 이벤트의 타겟을 찾고, 등록된 리스너를 실행하여 이벤트를 처리하게 됩니다.

이때 아래 그림에서 나타나듯 Compositor는 전달받은 이벤트를 Main thread에 보내며, 결과물인 Render tree를 다시 넘겨받을 때까지 기다리게 됩니다.



하지만 passive 옵션이 활성화된 이벤트라면, Compositor threadMain thread의 작업을 기다리지 않고 자신의 작업인 PaintComposite를 즉시 수행하게 됩니다.

이벤트를 전달받는 즉시 핸들러를 실행하고, 화면에 바로 반영/합성시킬 수 있게 됨으로써 렌더링 퍼포먼스가 향상되는 원리인 것입니다.






Event.preventDefault()

즉, 다시 말하면 passive 옵션이란 Compositor thread에게 Main thread의 작업을 대기하지 않고 자신의 작업을 즉시 수행하도록 지시하는 것이라고 할 수 있습니다. 'passive' 라는 표현 역시 이벤트의 취소 여부를 (능동적으로) 관찰하지 않는 수동적인 이벤트 리스너라는 의미에서 비롯된 바일 것입니다.

다만 그렇기 때문에 사용할 수 없게 되는 메소드가 하나 있는데, 바로 취소 가능한 이벤트(Event.cancelable)의 기본 동작을 취소하는 preventDefault() 입니다.

이 메소드는 Main thread에서 처리되는데, passive 옵션은 Main thread를 의도적으로 배제하고 있으므로 호출이 불가하게 되며, 호출하는 경우에는 에러가 발생합니다. 정확히는 그냥 무시되는 것인데, Chrome 같은 경우는 아래와 같이 콘솔에 에러를 표시합니다.



passive 옵션이 활성화되지 않았다면 preventDefault()의 호출 여부를 지속적으로 관찰한다는 것이고, 이벤트 리스너의 입장에서는 이벤트가 취소될지 안 될지 알 수 없습니다. 그래서 이벤트의 취소 여부를 나타내는 Event 인터페이스의 defaultPrevented 속성 값을 확인하면서 핸들러의 작업이 완료될 때까지 기다리고 보는 것이며, 이것이 차단(blocking)을 발생시키게 되는 것입니다.


대개 passive 옵션을 설명할 때 scroll 이벤트를 예로 드는 경우가 많은데, 사실 scroll 이벤트는 취소가 불가능한 이벤트이기 때문에 passive 옵션을 신경쓰지 않아도 됩니다. 취소가 불가능하다는 것은 곧 이벤트 리스너가 렌더링을 차단할 수 없다는 의미이기 때문입니다. 관련 문서


그런데 touch, wheel과 같은 이벤트는 일부 구현상 의도된 케이스가 아닌 이상 보통은 취소해야 할 만한 상황이 잘 없습니다.

게다가 이러한 이벤트는 비용이 매우 클 수도 있습니다. 특성상 짧은 시간에 매우 빠르게, 많이 발생할 텐데, 그 때마다 이벤트를 캡처하고 핸들러가 실행되므로 DOM을 조작한다던가 하는 무거운 작업을 수행한다면 성능 이슈가 발생할 수 있습니다.



이런 경우 passive 옵션으로 이벤트를 취소하지 않을 것임을 미리 선언하고, 이렇게 절감되는 비용으로 퍼포먼스 향상을 기대해볼 수 있습니다.






브라우저 호환성

브라우저가 passive 옵션을 지원하는지에 대한 여부는 아래와 같은 방법으로 확인해볼 수 있습니다. 서두에서 언급했듯 주요 브라우저들은 기본적으로 지원하지만, 웹 개발자들의 주적이자 역사의 뒤안길로 사라지게 된 IE 같은 경우는 모든 버전에서 지원하지 않기도 합니다.

MDN을 인용하자면, 아래와 같은 코드를 예시로 기능을 감지하여 사용할 수 있다고 설명합니다.


let passiveIfSupported = false;

try {
    window.addEventListener('test', null,
        Object.defineProperty({}, 'passive', {
            get: function() {
                passiveIfSupported = { passive: true }; 
            }
        })
    );
} catch(err) {}

window.addEventListener('scroll', function(event) {
    event.preventDefault(); // 사용 불가
}, passiveIfSupported);

아래는 각 브라우저별 passive 옵션 지원 여부와, 어떤 이벤트에서 기본적으로 true 처리하는지에 대한 여부입니다. 특히 Document, Window, body 노드의 경우 스크롤 중 브라우저의 Main thread를 차단할 가능성이 있어 touchstart, touchmove 이벤트의 passive 옵션을 true로 기본 적용한다는 점을 주목해 볼 만합니다. 관련 문서







실무에 적용해보기

개인적으로 이 기능을 jQuery 기반의 프로젝트에서 먼저 적용해보려고 했었는데, 기본적으로 passive 옵션을 활성화할 방법이 없어 다소 난감했던 기억이 있습니다. 내부적으로 이벤트를 처리하는 로직이 네이티브와는 조금 달라서, 옵션을 인수로 전달할 수 없었던 것입니다.

관련 자료를 조사해 보니 방법이 없진 않았는데, Event hook으로 일종의 폴리필을 구현하는 식으로 활성화할 수 있음을 알았습니다.

아래와 같은 식으로 이벤트를 확장/재정의하면, jQuery로 이벤트 리스너를 추가할 때에도 passive 옵션을 활성화할 수 있게 됩니다.


$.event.special.touchmove = {
    setup: function(_, ns, handle) {
        this.addEventListener('touchmove', handle, { passive: true });
    }
};

관련 이슈를 보면 4버전에서는 공식적으로 지원될 예정인 듯 보이나, jQuery의 버전업 자체가 더딘 상황이고, 근본적인 리팩토링도 필요하여 당장은 어렵다는 것을 알 수 있습니다.


한편, Vue에서는 v-on 디렉티브에서 passive 옵션을 지원하고 있기 때문에 아래와 같이 명시하는 형태로 아주 간단하게 활성화할 수 있습니다.


<div @touchmove.passive="onScroll">...</div>

React는 특성상 Vue에 비해서는 아무래도 방식이 조금 다른데, ref를 통해 DOM 노드에 직접 접근하는 방식으로 이벤트를 핸들링하여 passive 옵션을 활성화해야 합니다.






마치며

현재는 대부분의 케이스에서 이미 기본값으로 최적화되고 있어 실제로 이 옵션을 조작할 만한 일은 없을 수 있지만, 해당 기술에 대해 이해하고 있다면 성능 최적화 이슈를 조금 더 넓고 깊은 시각으로 접근해볼 수 있을 것입니다.

profile
퇴고를 좋아하는 주니어 웹 개발자입니다.

4개의 댓글

comment-user-thumbnail
2023년 10월 26일

좋은 내용 감사합니다.

답글 달기
comment-user-thumbnail
2024년 10월 11일

안녕하세요! 위 작성된 내용중 헷갈리는 내용이 있어서요!
"addEventListener()로 등록된 이벤트는 Compositor thread에서 처리되는데..." 라는 부분에서 이벤트 핸들러는 메인 쓰레드에서 처리된다고 알고 있는 내용고 달라서 혹시 제가 잘못 이해한건지 궁금해서 문의드립니다!

혹시 말씀하신게 스크롤이 발생할 경우, 해당 처리를 컴포지터 스레드에서 진행한다는 말씀이실까요?
아님 이벤트 발생 즉시 컴포지터 스레드에게 메인스레드 작업을 기다리지 않고 실행하라는 의미일가요?

제가 이해하고 있기로 어느 경우든 이벤트 핸들러의 실행은 메인 스레드에서 진행된다고 알고 있어서 문의드립니다!

1개의 답글