애드블럭 종료 후 사이트를 이용해 주세요.

ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React의 Synthetic Event는 어떻게 작동할까?
    React 2025. 8. 14. 19:46
    728x90
    반응형

    React

     

    React의 이벤트 시스템, 즉 SyntheticEvent는 단순히 개발 편의를 위해 추가된 요소가 아닙니다.

    이것은 파편화된 브라우저 환경이라는 현실적 제약 속에서 일관되고 예측 가능한 UI 개발을 가능하게 하려는 React의 근본적인 아키텍처 설계의 산물입니다.

     

    React가 등장하기 전, 개발자들은 브라우저마다 제각각인 이벤트 모델과 비표준 API 구현 때문에 '크로스 브라우징'이라는 거대한 장벽에 끊임없이 부딪혔습니다.

    Internet Explorer의 attachEvent와 다른 브라우저의 addEventListener, 이벤트 객체의 속성 차이 등은 개발자에게 끝없는 if/else 분기문과 폴리필(Polyfill)의 늪을 선사했습니다. 

    이는 jQuery와 같은 라이브러리가 DOM을 직접 조작하며 복잡성을 해결하려던 시대적 배경과 맞물려, 코드의 유지보수성을 심각하게 저하시키는 주된 원인이었습니다.   
    이러한 문제에 대한 React의 해답은 바로 브라우저의 네이티브 이벤트를 한 번 감싼(wrap) 추상화 계층, SyntheticEvent였습니다. 

     

    SyntheticEvent의 핵심 역할은 W3C 명세를 기준으로 이벤트를 '정규화(normalize)'하여, 개발자가 어떤 브라우저에서 코드를 실행하든 동일한 API와 일관된 동작을 보장하는 것이었습니다.   
    event.preventDefault(), event.stopPropagation()과 같은 메서드들이 모든 브라우저에서 동일하게 작동하면서, 개발자들은 마침내 브라우저 호환성이라는 지루한 싸움에서 벗어나 애플리케이션의 본질적인 로직 구현에 집중할 수 있게 되었습니다.
    하지만 React의 비전은 단순한 호환성 확보에 그치지 않았습니다. 

    SyntheticEvent 시스템은 React만의 독자적인 성능 최적화 기법(이벤트 위임, 그리고 과거의 이벤트 풀링)을 구현하고, 컴포넌트 기반의 선언적 UI라는 철학을 지탱하는 핵심적인 기반으로 설계되었습니다.

    1. SyntheticEvent 깊게 알아보기

    1.1. 핵심 개념: 브라우저를 감싼 일관성의 외투

    SyntheticEvent는 이름 그대로 '합성된' 이벤트입니다. 

    이것은 실제 브라우저가 발생시키는 네이티브 DOM 이벤트가 아니라, React가 네이티브 이벤트를 한 번 감싸서(wrapping) 제공하는 자체적인 이벤트 객체입니다. 

    React 컴포넌트의 onClick이나 onChange 같은 이벤트 핸들러에 인자로 전달되는 e 객체가 바로 이 SyntheticEvent의 인스턴스입니다.


    이 래퍼(wrapper)의 가장 중요한 임무는 '정규화(Normalization)'입니다.

    예를 들어, 특정 이벤트 속성이 일부 브라우저에서는 e.which로, 다른 브라우저에서는 e.keyCode로 다르게 표현될 수 있습니다. 

    React는 이러한 차이점들을 내부적으로 흡수하여 e.key와 같이 W3C 표준에 맞는 일관된 속성으로 통일해 제공합니다. 

    덕분에 개발자는 브라우저별 예외 처리를 고민할 필요 없이, 표준화된 API(preventDefault(), stopPropagation() 등)를 사용하여 코드를 작성할 수 있습니다.   

    개발자가 JSX 문법을 통해 <button onClick={handleClick}>과 같이 이벤트 핸들러를 선언하면, React 런타임은 내부적으로 해당 DOM 요소에서 발생하는 네이티브 이벤트를 감지합니다. 

    그리고 이 네이티브 이벤트를 기반으로 SyntheticEvent 인스턴스를 생성하여 우리가 정의한 handleClick 함수에 인자로 전달하는 과정을 거칩니다.

    1.2. 내부 동작 원리: 이벤트는 어떻게 우리 코드까지 오는가

    사용자의 클릭 한 번이 우리 코드의 handleClick 함수를 실행시키기까지, React 내부에서는 정교하게 설계된 단계들이 순차적으로 진행됩니다.

    1. Native Event 발생: 사용자가 화면의 버튼을 클릭하면, 브라우저 환경에서 네이티브 click 이벤트가 발생합니다. 이 이벤트는 DOM의 표준 이벤트 전파 모델에 따라 해당 요소에서 시작하여 상위 요소로 전파(bubbling)됩니다.
    2. 최상위 리스너에서의 수신: React는 이벤트를 처리하기 위해 모든 개별 DOM 요소에 addEventListener를 등록하지 않습니다. 대신, 애플리케이션의 최상단(root)에 위치한 단일 이벤트 리스너에서 대부분의 이벤트를 통합하여 수신합니다. 이 방식이 바로 '이벤트 위임'이며, Section 2에서 자세히 다룰 것입니다.  
    3. 래핑 및 디스패치: 최상위 리스너가 네이티브 이벤트를 수신하면, React의 이벤트 시스템(내부적으로 EventPluginHub와 같은 모듈)이 이를 가로챕니다. 이 시스템은 수신된 네이티브 이벤트의 타입과 타깃 정보를 바탕으로 SyntheticEvent 객체를 생성합니다. (React 17 이전에는 이 과정에서 미리 만들어 둔 '풀(pool)'에서 객체를 가져와 재사용했습니다). 그리고 이벤트가 발생한 가장 깊은 곳의 컴포넌트가 무엇인지 식별합니다.  
    4. 핸들러 실행: React는 식별된 컴포넌트부터 시작하여 상위 컴포넌트로 올라가는 가상의 이벤트 전파 경로를 만듭니다. 이 경로를 따라 각 컴포넌트에 정의된 이벤트 핸들러(예: onClick)들을 순서대로 호출하며, 앞서 생성한 SyntheticEvent 객체를 인자로 전달합니다. 즉, React는 브라우저의 네이티브 이벤트 전파와는 별개로, 자체적인 가상 이벤트 전파 시스템을 시뮬레이션하여 핸들러를 실행하는 것입니다.

    2. 이벤트 위임으로 인한 성능 향상

    React의 이벤트 시스템이 단지 크로스 브라우징 문제 해결에만 그치지 않는다는 점은 '이벤트 위임(Event Delegation)'이라는 핵심 설계에서 명확히 드러납니다. 

    이 기법은 React가 대규모 애플리케이션에서도 높은 성능을 유지할 수 있게 하는 중요한 요소 중 하나입니다.

    2.1. 이벤트 위임 기초: Vanilla JS 예제

    이벤트 위임이 무엇인지 이해하기 위해, React가 없는 순수 JavaScript(Vanilla JS) 환경을 먼저 살펴보겠습니다. 

    이벤트 위임은 개별 자식 요소에서 발생하는 이벤트를 일일이 처리하는 대신, 그들의 공통된 상위 요소 하나에서 이벤트를 받아 처리하는 프로그래밍 패턴입니다.   

     

    가령 수백 개의 메시지(<li>)가 실시간으로 추가되고 삭제되는 채팅 UI를 만든다고 가정해 봅시다.

    만약 각 <li> 요소가 생성될 때마다 개별적으로 click 이벤트 리스너를 붙인다면, 수백 개의 리스너가 메모리에 상주하게 되어 성능 저하를 유발할 것입니다.

    이벤트 위임은 이 문제를 간단하게 해결합니다.

    모든 <li>를 감싸는 부모 <ul> 요소에 단 하나의 이벤트 리스너만 등록하는 것입니다.

    이벤트는 '버블링(bubbling)'이라는 특성 덕분에, 자식 요소에서 발생한 이벤트가 부모 요소로 전파됩니다.

    따라서 우리는 부모의 리스너 안에서 event.target 속성을 확인하여 이벤트가 실제로 어떤 자식 <li>에서 시작되었는지 알아낼 수 있습니다.   

    다음은 Vanilla JS로 구현한 이벤트 위임의 간단한 예시입니다.

    // HTML 구조:
    // <ul id="chat-messages">
    //   <li data-message-id="1">안녕하세요!</li>
    //   <li data-message-id="2">React 공부 중입니다.</li>
    //   // </ul>
    
    const chatList = document.getElementById('chat-messages');
    
    chatList.addEventListener('click', function(e) {
      // 클릭된 요소가 LI 태그인지, 그리고 그 안에 있는지 확인
      const targetLi = e.target.closest('li');
      
      if (targetLi) {
        console.log('Message clicked:', targetLi.textContent);
        console.log('Message ID:', targetLi.dataset.messageId);
        // 여기에 각 메시지 삭제, 수정 메뉴 표시 등의 로직을 구현
      }
    });

     

    이 방식을 사용하면 새로운 메시지가 동적으로 추가되어도, 추가적인 리스너를 등록할 필요 없이 기존의 단일 리스너가 모든 이벤트를 처리할 수 있습니다.

    2.2. React의 자동화된 접근 방식

    React 개발자는 이러한 이벤트 위임을 직접 구현할 필요가 없습니다. 

    React는 프레임워크 수준에서 자동으로 구현하여 제공합니다.

     

    우리가 JSX 코드에 <div onClick={handleClick}>이라고 작성할 때, React가 내부적으로 해당 div DOM 요소에 addEventListener를 직접 호출하는 것이 아닙니다.

    대신, React는 렌더링 시점에 이 정보를 기억해 두었다가, 애플리케이션의 최상위 컨테이너(React 17 이전에는 document 객체, 17 이후부터는 React 트리의 root DOM 요소)에 click, change 등 각 이벤트 타입에 대해 단 하나의 리스너만을 부착합니다.   

    사용자 인터랙션으로 네이티브 이벤트가 발생하면, 이 최상위 리스너가 이벤트를 포착합니다. 

    그러면 React의 내부 이벤트 디스패처가 event.target 정보를 분석하여, 가상 DOM 트리에서 이벤트가 발생한 컴포넌트가 무엇인지 역으로 추적합니다. 

    그리고 해당 컴포넌트와 그 상위 컴포넌트들에 정의된 onClick 핸들러들을 순서대로 실행시켜 줍니다.

    결론적으로, React 개발자는 그저 필요한 컴포넌트에 onClick과 같은 이벤트 핸들러를 선언적으로 작성하기만 하면 됩니다. 

    그러면 React가 내부적으로 가장 효율적인 이벤트 위임 방식으로 이를 처리해 주는 것입니다.

    2.3. 성능상의 이점

    React가 이벤트 위임을 기본 전략으로 채택한 이유는 명확합니다.

    이는 상당한 성능상의 이점을 가져다줍니다.

    • 메모리 사용량 감소: 수천 개의 셀을 가진 거대한 데이터 테이블이나 무한 스크롤 목록의 각 항목에 이벤트 핸들러를 개별적으로 부착하는 대신, 단 하나의 핸들러만 사용하므로 애플리케이션의 메모리 점유율이 획기적으로 줄어듭니다. 이는 특히 저사양 모바일 기기에서 애플리케이션의 안정성을 높이는 데 기여합니다.
    • 초기 렌더링 속도 향상: 애플리케이션이 처음 로드될 때, DOM에 수많은 이벤트 리스너를 부착하는 작업은 상당한 시간을 소요할 수 있습니다. 이벤트 위임은 이 과정을 단 한 번의 리스너 부착으로 대체하므로, 초기 렌더링 속도가 빨라지고 사용자가 UI를 더 빨리 볼 수 있게 됩니다.
    • 동적 요소 처리의 용이성: 앞선 채팅 예제처럼, 새로운 요소가 동적으로 목록에 추가될 때 별도의 이벤트 리스너를 다시 등록해 줄 필요가 없습니다. 부모 요소에 이미 리스너가 존재하므로, 새로 생성된 자식 요소에서 발생하는 이벤트도 아무런 추가 작업 없이 자동으로 처리됩니다.

    흥미로운 점은, React의 내장 이벤트 위임 시스템이 매우 효율적이어서, 개발자가 전통적인 JavaScript 최적화 기법인 '수동 이벤트 위임'을 React 컴포넌트 내에서 시도하는 것 자체가 오히려 '안티패턴'이 된다는 사실입니다. 

    Vanilla JS 배경을 가진 개발자는 성능 향상을 위해 수동으로 이벤트 위임을 구현하려는 유혹을 느낄 수 있습니다. 

    하지만 여러 테스트와 벤치마크 결과에 따르면, 수천 개의 개별 onClick 핸들러를 가진 컴포넌트와 수동으로 단일 위임 핸들러를 구현한 컴포넌트 사이에는 눈에 띄는 성능 차이가 거의 없습니다.
    그 이유는 React가 이미 내부적으로 수천 개의 onClick prop을 단 하나의 위임된 리스너로 변환하여 처리하기 때문입니다. 

    따라서 개발자가 수동으로 위임 로직을 작성하는 것은 React의 추상화 위에 불필요한 추상화 계층을 하나 더 쌓는 셈이 됩니다. 

    이는 성능 향상에는 전혀 기여하지 않으면서 코드의 가독성과 유지보수성만 해치는 결과를 낳습니다. 

    따라서 명심해야 할 원칙은 "React에서는 이벤트 위임을 직접 구현하지 마십시오. 프레임워크가 이미 당신을 위해 최적의 방식으로 처리하고 있습니다."

    2.4 극단적인 케이스에서의 수동 이벤트 위임

    하지만, 일반적인 규칙에는 항상 예외적인 상황이 존재합니다.

    10만 개 이상의 아이템을 가진 리스트와 같이 극단적인 상황을 가정해 본다면 어떨까요?

     

    React가 내부적으로 이벤트 위임을 사용하더라도, 10만 개의 <li> 컴포넌트는 각각의 가상 DOM 노드(Fiber)에 onClick 프롭으로 함수 참조를 가지게 됩니다.

    만약 onClick={() => handle(item.id)}와 같이 인라인 함수를 사용한다면 렌더링마다 10만 개의 함수 인스턴스가 생성되고, useCallback 등을 사용해 함수를 전달하더라도 10만 개의 참조가 메모리에 생성되는 부담은 피할 수 없습니다.

    // 10만 개의 함수 참조가 생성됨
    {items.map(item => <li key={item.id} onClick={() => handleClick(item.id)}>{item.name}</li>)}

     

    이 드문 케이스에서는, 전통적인 이벤트 위임 패턴을 수동으로 구현하는 것이 이론적으로 메모리 이점을 가질 수 있습니다.

    const handleDelegatedClick = (e) => {
      // 가장 가까운 li 태그를 찾아 data-id를 가져옴
      const targetLi = e.target.closest('li');
      if (targetLi && targetLi.dataset.id) {
        const id = targetLi.dataset.id;
        console.log(`Item ${id} clicked!`);
      }
    };

     

    하지만 이는 이론적인 논의에 가깝습니다.

    실제 애플리케이션에서 10만 개의 DOM 요소를 한 번에 렌더링 하는 것 자체가 심각한 안티패턴이기 때문입니다.

     

    이러한 문제의 진정한 해결책은 가상화(Virtualization)입니다.

    react-window나 react-virtuoso, tanStack virtual과 같은 라이브러리를 사용하면, 실제로 화면에 보이는 몇십 개의 아이템만 DOM에 렌더링 하므로 근본적인 성능 문제를 해결할 수 있습니다.

    2024.03.30 - [React] - react-virtuoso를 사용한 렌더링 최적화

     

    react-virtuoso를 사용한 렌더링 최적화

    React-Virtuoso란? React-Virtuoso는 React 애플리케이션을 위한 최첨단 가상 리스트 라이브러리입니다. 가상 스크롤링을 사용하여 대규모 데이터 세트를 효율적으로 렌더링 하고, 사용자 경험을 향상하

    kir93.co.kr

    3. 고급 이벤트 처리: 전파 제어와 비동기 함정

    SyntheticEvent의 기본 개념과 이벤트 위임을 이해했다면, 이제 더 복잡한 시나리오를 다룰 준비가 된 것입니다. 

    이벤트의 흐름을 정교하게 제어하는 방법과 비동기 로직에서 흔히 발생하는 함정을 파악하는 것은 견고한 React 애플리케이션을 구축하는 데 필수적입니다.

    3.1. 흐름 제어하기: stopPropagation의 진실

    이벤트 전파(Event Propagation)는 이벤트가 발생한 요소로부터 시작해 DOM 트리를 따라 상위 요소로 이동하는 현상을 말합니다. 

    때로는 이러한 전파를 의도적으로 막아야 할 필요가 있습니다.

    e.stopPropagation() 메서드는 이벤트가 상위 컴포넌트로 전파(버블링)되는 것을 중단시킵니다. 

    예를 들어, 버튼이 div 안에 중첩되어 있고, 버튼과 div 모두에 onClick 핸들러가 있다면, 버튼의 핸들러에서 e.stopPropagation()을 호출할 경우 div의 핸들러는 실행되지 않습니다.

    function Toolbar() {
      return (
        <div className="Toolbar" onClick={() => alert('You clicked on the toolbar!')}>
          <button onClick={(e) => {
            e.stopPropagation(); // 이 호출로 인해 상위 div의 onClick은 실행되지 않음
            alert('Playing!');
          }}>
            Play Movie
          </button>
        </div>
      );
    }

     

    여기서 매우 중요한 사실은, e.stopPropagation()이 막는 것은 React의 합성 이벤트(Synthetic Event) 전파라는 점입니다. 

    앞서 설명했듯이, React의 이벤트 핸들러는 네이티브 이벤트가 이미 DOM 트리의 최상단(React Root)까지 버블링 된 이후에 React의 가상 이벤트 시스템 내에서 호출됩니다.   

    이러한 내부 동작 방식은 React와 다른 JavaScript 라이브러리(예: jQuery)를 함께 사용하는 혼합 환경에서 예기치 않은 문제를 일으킬 수 있습니다. 

    만약 document에 직접 click 리스너를 등록한 jQuery 코드가 있고, 그 안의 React 컴포넌트에서 e.stopPropagation()을 호출했다고 가정해 봅시다. 

    이 호출은 React 컴포넌트 트리 내에서의 버블링은 막지만, 네이티브 click 이벤트 자체는 이미 document에 도달했기 때문에 jQuery 리스너의 실행을 막을 수는 없습니다.   

    이러한 시나리오를 완벽하게 제어하기 위한 궁극적인 해결책은 e.nativeEvent.stopImmediatePropagation()입니다. 

    이 메서드는 네이티브 이벤트 객체에 직접 작용하여, 현재 요소에 부착된 다른 모든 리스너의 실행을 즉시 중단시키고 상위로의 버블링 또한 막습니다. 

    React의 이벤트 시스템과 외부의 네이티브 이벤트 시스템 간의 상호작용을 완전히 차단해야 할 때 사용하는 방법입니다.

    또한, 이벤트 흐름에는 버블링과 반대 방향인 캡처 단계(Capture Phase)도 존재합니다.

    이벤트 핸들러 prop의 이름 끝에 Capture를 붙이면(예: onClickCapture), 버블링 단계보다 먼저, 이벤트가 최상위 요소에서 타깃 요소로 내려가는 캡처 단계에서 이벤트를 처리할 수 있습니다.

    이는 상위 컴포넌트가 하위 컴포넌트의 이벤트 처리에 앞서 특정 로직을 반드시 실행해야 할 때(예: 로깅, 전역 이벤트 차단) 유용하게 사용됩니다.

    3.2. 오래된 클로저의 함정

    React 함수형 컴포넌트에서 이벤트 핸들러를 다룰 때 가장 흔하게 마주치는 함정 중 하나는 '오래된 클로저(Stale Closure)' 문제입니다.

     

    클로저(Closure)란 함수가 자신이 선언될 당시의 주변 환경(lexical environment)을 기억하는 JavaScript의 고유한 특징입니다.

    React 함수형 컴포넌트에서 이벤트 핸들러와 같은 함수들은 렌더링이 실행되는 시점의 state와 props 값을 클로저를 통해 "포착"하고 "기억"합니다.   

    문제는 setTimeout이나 네트워크 요청과 같은 비동기 작업과 결합될 때 발생합니다. 

    이벤트 핸들러가 비동기 콜백이 실행될 때의 최신 상태가 아닌, 핸들러 함수가 생성될 당시의 오래된 상태 값을 참조하게 되는 것입니다.   
    다음은 이 문제를 명확하게 보여주는 예시입니다.

    import React, { useState } from 'react';
    
    function CounterWithStaleClosure() {
      const [count, setCount] = useState(0);
    
      const handleAlertClick = () => {
        // 이 함수가 생성될 때의 'count' 값(예: 0)을 클로저가 기억합니다.
        setTimeout(() => {
          // 3초 뒤 alert이 실행될 때, 그 사이에 'count' 상태가 아무리 변했어도
          // 이 클로저가 기억하는 'count'는 여전히 0입니다.
          alert('Stale count is: ' + count);
        }, 3000);
      };
    
      return (
        <div>
          <p>You clicked {count} times</p>
          <button onClick={() => setCount(count + 1)}>
            Click me
          </button>
          <button onClick={handleAlertClick}>
            Show Stale Alert
          </button>
        </div>
      );
    }

     

    위 코드에서 "Show Stale Alert" 버튼을 누른 직후 "Click me" 버튼을 여러 번 눌러 count 상태를 5로 만들어도, 3초 뒤에 뜨는 알림 창에는 "Stale count is: 0"이라고 표시됩니다. 

    이것이 바로 오래된 클로저 문제입니다. 

    다행히 이 문제를 해결할 수 있는 몇 가지 방법이 있습니다.

    해결책 1: 함수형 업데이트 (Functional Updates)

    setState 함수에 새로운 상태 값을 직접 전달하는 대신, 이전 상태 값을 인자로 받아 새로운 상태 값을 반환하는 함수를 전달하는 방식입니다. 
    React는 이 콜백 함수에 항상 가장 최신의 상태 값을 인자로 전달해 줄 것을 보장하며, 가장 권장되는 해결책입니다.

    // setCount(count + 1); // 오래된 count를 사용할 수 있음
    setCount(prevCount => prevCount + 1); // 항상 최신 상태를 기반으로 업데이트

     

    비동기 콜백에서도 마찬가지로 적용할 수 있습니다.

    const handleCorrectAlertClick = () => {
      setTimeout(() => {
        // 이 방식은 alert을 띄우기 위해 state를 직접 참조하지 않으므로,
        // 상태 업데이트 로직에 더 적합합니다.
        // alert을 띄우는 것이 목적이라면 useRef가 더 나은 선택입니다.
      }, 3000);
    };

    해결책 2: useRef 사용

    useRef는 렌더링 사이클에 영향을 주지 않고 가변적인(mutable) 값을 유지하는 데 사용됩니다.

    최신 상태 값을 ref에 동기화시켜 두면, 비동기 콜백에서도 항상 최신 값에 접근할 수 있습니다.

    const countRef = useRef(count);
    countRef.current = count; // 렌더링마다 최신 count 값으로 업데이트
    
    const handleRefAlertClick = () => {
      setTimeout(() => {
        alert('Latest count is: ' + countRef.current); // 항상 최신 값을 참조
      }, 3000);
    };

    해결책 3: useEffect 의존성 배열의 올바른 사용

    useEffect 내에서 window나 document에 이벤트 리스너를 등록하고 해제하는 경우, 해당 리스너 콜백 함수가 참조하는 모든 state나 props를 의존성 배열에 명시해야 합니다. 

    이렇게 하면 의존성이 변경될 때마다 useEffect가 다시 실행되면서, 기존 리스너는 해제되고 최신 상태를 포착한 새로운 리스너가 등록됩니다. 

    useCallback을 사용하여 이벤트 핸들러를 Memoization 할 때도 이 원칙은 동일하게 적용되어, 의존성 배열을 통해 오래된 클로저가 생성되는 것을 방지해야 합니다.

    4. 이벤트 시스템 개편 (React 17+)

    React 17 버전은 사용자에게 새로운 기능을 제공하기보다는, React의 내부 아키텍처를 현대화하고 미래를 위한 기반을 다지는 데 집중한 릴리스였다고 저는 생각합니다.

    그 증거로 이벤트 시스템의 대대적인 개편이 있었습니다.

    이 개편은 크게 두 가지 핵심적인 변화(오래된 최적화 기법인 '이벤트 풀링'의 폐지'이벤트 위임 루트'의 변경)를 통해 개발자 경험을 개선하고 아키텍처의 유연성을 확보하는 것을 목표로 했습니다.

    4.1. 과거의 유산: 이벤트 풀링

    React 16 및 그 이전 버전에서는 높은 빈도로 발생하는 이벤트를 처리할 때의 성능을 최적화하기 위해 '이벤트 풀링(Event Pooling)'을 사용했습니다.

    이벤트 풀링의 핵심 아이디어는 SyntheticEvent 객체를 매번 새로 생성하고 폐기하는 대신, 미리 만들어 둔 객체 '풀(pool)'에서 가져와 재사용하는 것이었습니다.

    이벤트 핸들러 함수의 실행이 끝나면, React는 해당 SyntheticEvent 객체의 모든 속성(예: e.target, e.type)을 null로 초기화한 뒤 다시 풀에 반환했습니다. 

    이렇게 함으로써 가비지 컬렉션의 부담을 줄여 성능을 향상하고자 했습니다.   

    하지만 이 최적화 기법은 심각한 부작용을 낳았습니다. 

    특히 비동기 코드에서 문제가 두드러졌습니다. 

    setTimeout 콜백이나 비동기 네트워크 요청의 then 블록 안에서 이벤트 객체에 접근하려고 시도하면, 해당 콜백이 실행될 시점에는 이벤트 객체가 이미 풀로 반환되어 모든 속성이 null로 변해버린 후였습니다. 

    이는 "Cannot read property 'value' of null"과 같은 예측하기 어려운 런타임 에러의 주된 원인이 되었습니다.   

    이 문제를 회피하기 위해 개발자들은 e.persist()라는 특별한 메서드를 호출해야만 했습니다.   
    e.persist()는 해당 SyntheticEvent 객체를 풀에서 영구적으로 제거하여, 비동기 콜백에서도 안전하게 참조할 수 있도록 만들어주는 역할을 했습니다.

    그러나 이 방법은 직관적이지 않았고, 저도 React를 사용한 초반에 큰 혼란을 주는 문제였다는 기억이 나네요.

    4.2. 이벤트 풀링의 폐지

    결국 React 17에서 이벤트 풀링은 완전히 제거되었습니다.

    과감한 결정의 배경에는 두 가지 중요한 이유가 있습니다.   

    첫째, 성능 이점이 미미해졌습니다. 

    이벤트 풀링이 처음 도입될 당시와 비교해 현대 브라우저의 JavaScript 엔진은 비약적으로 발전했습니다. 

    이제 이벤트 객체를 매번 새로 생성하고 가비지 컬렉터가 이를 처리하는 비용이, 이벤트 풀링을 유지하기 위한 내부 로직의 오버헤드에 비해 무시할 수 있을 만큼 작아졌습니다. 

    즉, 최적화로 얻는 이득보다 유지 비용과 복잡성이 더 커진 것입니다.   

    둘째, 개발자 경험을 심각하게 저해했습니다. 

    e.persist()의 존재는 React의 선언적이고 직관적인 모델에 어울리지 않는 명령형 API였습니다. 

    비동기 처리에 대한 깊은 이해가 없는 개발자들에게 잦은 버그의 원인이 되었고, 디버깅을 어렵게 만들었습니다.   

    이벤트 풀링이 폐지됨에 따라, React 17 이후부터 개발자는 더 이상 e.persist()를 신경 쓸 필요 없이 비동기 콜백 함수 안에서 이벤트 객체를 자유롭게 참조할 수 있게 되었습니다. 

    하위 호환성을 위해 e.persist() 메서드 자체는 SyntheticEvent 객체에 남아있지만, 이제는 아무런 기능도 수행하지 않는 빈 함수(no-op)가 되었습니다.

    4.3. 이벤트 위임의 새로운 뿌리

    React 17의 또 다른 핵심적인 변화는 이벤트 위임의 방식이 바뀐 것입니다. 

    기존에는 모든 이벤트 리스너를 document 객체에 부착했지만, 이제는 React 애플리케이션이 렌더링 되는 루트 DOM 컨테이너에 부착하는 것으로 변경되었습니다. 

    즉, ReactDOM.render(<App />, document.getElementById('root')) 코드에서 document.getElementById('root')에 해당하는 요소가 새로운 이벤트 위임의 '뿌리(root)'가 된 것입니다.

    이 변경의 가장 큰 이유는 점진적 업그레이드(Gradual Upgrades)를 더 안전하고 원활하게 지원하기 위함입니다.

     

    하나의 웹 페이지 안에 구버전의 React(예: v16)로 만들어진 부분과 신버전의 React(예: v17)로 만들어진 부분이 공존하는 시나리오를 상상해 봅시다.   

    과거 방식처럼 두 버전 모두 document에 이벤트 리스너를 등록했다면, 심각한 문제가 발생할 수 있었습니다.

    예를 들어, React 17 앱 내부에 중첩된 React 16 앱에서 e.stopPropagation()을 호출하더라도, 상위인 React 17 앱의 이벤트 리스너 실행을 막을 수 없었습니다.

    왜냐하면 네이티브 이벤트는 이미 DOM 트리를 따라 버블링 되어 document에 도달했고, document에 등록된 모든 리스너(v16, v17 리스너 모두)가 호출되기 때문입니다.   

    React 17의 새로운 방식은 이 문제를 해결합니다. 

    각 React 버전이 자신만의 독립적인 루트 컨테이너에 이벤트 리스너를 위임함으로써, 이벤트 시스템이 서로 완벽하게 격리됩니다. 

    이제 한 React 트리의 stopPropagation() 호출이 다른 버전의 React 트리에 전혀 영향을 주지 않습니다. 

    이 변화 덕분에 마이크로 프런트엔드 아키텍처를 도입하거나, 거대한 레거시 애플리케이션을 점진적으로 최신 버전으로 마이그레이션 하는 작업이 훨씬 더 안전하고 예측 가능해졌습니다.   

    아래 표는 React 17이 가져온 이벤트 시스템의 패러다임 전환을 명확하게 요약합니다.

    기능 (Feature) v17 이전 동작  v17 이후 동작
    이벤트 풀링 (Event Pooling) 성능 최적화를 위해 기본 활성화.
    이벤트 핸들러 종료 후 이벤트 객체 속성 초기화.
    제거됨.
    성능 이점이 없고 개발자에게 혼란을 유발하여 폐지.
    e.persist() 비동기 작업에서 이벤트 객체를 사용하기 위해 필수적으로 호출해야 했음. 아무 동작도 하지 않음 (No-op).
    이벤트 풀링이 사라져 더 이상 필요 없음.
    위임 루트 (Delegation Root) document 객체에 모든 이벤트 리스너를 부착. React 트리가 렌더링되는 루트 DOM 컨테이너에 부착.

    5. 현대 생태계 속 이벤트: Next.js 관점

    React의 이벤트 시스템에 대한 이해는 Page Router에서는 CRA와 거의 동일합니다.

    Page Router에서 주의할 점은 SSR 환경을 고려해야 한다는 것입니다.

    예를 들어 컴포넌트의 useEffect에서 window나 document의 이벤트를 등록하려 한다면, 이 코드는 브라우저에서만 실행되어야 하므로 if (typeof window !== 'undefined') { ... } 체크가 필요합니다.

     

    하지만 Next.js App Router가 도입된 현대적인 개발 환경에서 더욱 중요해졌습니다. 

    Next.js의 서버 컴포넌트와 클라이언트 컴포넌트 아키텍처는 이벤트 핸들링을 중심으로 구성되기 때문입니다.

    5.1. 서버 컴포넌트와 클라이언트 컴포넌트

    Next.js 13의 App Router가 제시하는 가장 큰 패러다임 변화는 컴포넌트를 두 종류로 나누는 것입니다.

    서버에서 렌더링 되고 실행되는 서버 컴포넌트(Server Components)와 사용자의 브라우저에서 렌더링 되고 실행되는 클라이언트 컴포넌트(Client Components).   

    기본적으로 Next.js의 모든 컴포넌트는 서버 컴포넌트로 간주됩니다. 

    이 설계의 주된 목적은 데이터베이스 접근이나 API 호출과 같은 데이터 페칭 로직을 서버에 가깝게 위치시키고, 브라우저로 전송되는 JavaScript의 양을 최소화하여 초기 로딩 성능(First Contentful Paint)을 극적으로 향상하는 데 있습니다.

    5.2. 상호작용과 "use client" 지시어

    이 아키텍처에서 이벤트 핸들링에 관한 규칙은 매우 명확하고 결정적입니다.

    onClick, onChange와 같은 이벤트 핸들러나, useState, useEffect와 같이 상태와 생명주기를 다루는 훅(Hook)은 오직 클라이언트 컴포넌트에서만 사용할 수 있습니다.  

     

    서버는 사용자의 클릭이나 입력과 같은 상호작용에 직접 반응할 수 없습니다.

    이벤트는 본질적으로 브라우저 환경에 속한 개념이기 때문입니다.

    따라서 서버 컴포넌트 파일에 onClick과 같은 이벤트 핸들러를 작성하면 Next.js는 즉시 에러를 발생시킵니다.

     

    컴포넌트를 클라이언트 컴포넌트로 만들기 위해서는 해당 파일의 가장 첫 줄에 "use client";라는 지시어를 명시해야 합니다.

    이 지시어가 선언된 파일과, 그 파일이 직접 import 하는 다른 모든 모듈들은 클라이언트 측 JavaScript 번들에 포함되어 브라우저로 전송됩니다. 

     

    여기서 중요한 최적화 전략은 상호작용이 필요한 최소 단위의 컴포넌트만 클라이언트 컴포넌트로 만드는 것입니다.

    예를 들어, 정적인 텍스트와 이미지가 대부분인 페이지에서 단 하나의 '좋아요' 버튼이 상호작용을 필요로 한다면, 페이지 전체를 클라이언트 컴포넌트로 만드는 대신 '좋아요' 버튼 컴포넌트만 별도의 파일로 분리하여 "use client"를 선언하는 것이 바람직합니다.

    이를 '클라이언트 경계를 가능한 한 리프 노드(leaf node)로 밀어낸다'라고 표현하며, 서버 컴포넌트의 성능 이점을 최대한 유지하는 핵심적인 기법입니다.

     

    결국, Next.js App Router 환경에서 이벤트 핸들러의 필요성은 어떤 컴포넌트가 서버에 남아야 하고 어떤 컴포넌트가 클라이언트로 가야 하는지를 결정하는 가장 실용적이고 명확한 기준 역할을 합니다.

    개발자는 "이 컴포넌트가 사용자의 클릭에 반응해야 하는가?"라는 간단한 질문을 통해 서버와 클라이언트의 경계를 어디에 그을지 쉽게 판단할 수 있습니다.

    5.3. 하이드레이션: 상호작용을 불어넣는 과정

    그렇다면 서버에서 렌더링 된 HTML은 어떻게 브라우저에서 상호작용이 가능해질까요?

    이 과정을 하이드레이션(Hydration)이라고 부릅니다.

     

    하이드레이션은 말 그대로 '물을 부어 생명을 불어넣는' 과정에 비유할 수 있습니다.

    서버는 먼저 데이터가 채워진 정적인 HTML을 렌더링 하여 브라우저로 빠르게 전송합니다.

    사용자는 즉시 콘텐츠를 볼 수 있지만, 이 상태의 HTML에는 JavaScript 로직이나 이벤트 리스너가 없어 상호작용이 불가능합니다.  

     

    그 후, 브라우저는 클라이언트 컴포넌트에 해당하는 JavaScript 코드를 다운로드하여 실행합니다.

    이때 React는 서버에서 생성된 기존 HTML 구조를 버리지 않고, 그 위에 마치 투명한 필름을 덧씌우듯 JavaScript 로직을 연결하고 필요한 이벤트 리스너들을 부착합니다.

    이 과정이 끝나면, 정적인 페이지는 비로소 onClick에 반응하고 useState로 상태를 변경할 수 있는 완전한 대화형(Interactive) 애플리케이션으로 거듭납니다.

     

    즉, Next.js에서 우리가 작성한 onClick과 같은 이벤트 핸들러가 실제로 동작하게 되는 시점은 바로 이 하이드레이션 과정이 성공적으로 완료된 후입니다.

    만약 서버가 렌더링 한 HTML과 클라이언트가 하이드레이션 과정에서 렌더링 한 초기 가상 DOM이 일치하지 않으면 '하이드레이션 에러'가 발생하며, 이는 Next.js 개발 시 흔히 마주치는 문제입니다.

    이는 이벤트 핸들러가 포함된 클라이언트 컴포넌트의 렌더링 로직을 더욱 신중하게 다뤄야 하는 이유이기도 합니다.  

    결론

    React의 이벤트 시스템, SyntheticEvent는 웹 개발의 근본적인 문제였던 브라우저 파편화를 해결하기 위해 탄생한 핵심이라고 할 수 있었습니다.

    그리고 이번 글을 통해 SyntheticEvent가 단순한 호환성 도구를 넘어, React의 선언적 패러다임과 성능 최적화 철학을 뒷받침하는 핵심 아키텍처임을 확인했습니다.

    이번 글을 핵심은 다음과 같습니다.

    • 일관성의 가치: SyntheticEvent는 브라우저 간의 차이를 숨겨 개발자가 애플리케이션의 본질에만 집중할 수 있는 일관된 개발 경험을 제공합니다. 이는 React가 빠르게 성장할 수 있었던 중요한 원동력이었습니다.
    • 자동화된 최적화: React에 내장된 이벤트 위임 메커니즘은 개발자가 별도의 노력을 기울이지 않아도 높은 성능을 보장하는 강력한 기능입니다. 이로 인해 수동적인 이벤트 위임 구현은 React 환경에서는 불필요한 안티패턴이 됩니다.
    • 지속적인 진화: React 17의 이벤트 시스템 개편은 중요한 철학적 전환을 보여줍니다. 낡고 비효율적인 최적화(이벤트 풀링)를 과감히 버리고, 개발자 경험과 현대적인 아키텍처(점진적 업그레이드, 마이크로 프런트엔드)의 유연성을 선택한 것입니다.
    • 비동기와 클로저의 중요성: 이벤트 핸들러 내에서 비동기 작업을 처리할 때 발생하는 '오래된 클로저' 문제는 JavaScript의 근본적인 동작 방식에 대한 깊은 이해가 왜 중요한지를 보여줍니다. 함수형 업데이트와 같은 패턴을 숙지하는 것은 안정적인 코드를 작성하는 데 필수적입니다.
    • 현대 프레임워크의 기준점: Next.js와 같은 최신 프레임워크에서 이벤트 핸들링의 필요성은 서버 컴포넌트와 클라이언트 컴포넌트를 구분하는 가장 명확하고 실용적인 기준점 역할을 합니다. 이는 이벤트 시스템에 대한 이해가 현대 웹 아키텍처를 설계하는 데 얼마나 중요한지를 시사합니다.

    결론적으로, React의 이벤트 시스템을 깊이 이해하는 것은 단순히 '어떻게' 이벤트를 다루는지를 넘어, React가 '왜' 그렇게 설계되었는지를 파악하는 과정입니다.

    이 지식은 우리가 마주할 복잡한 문제들을 해결하고, 더 예측 가능하며 성능이 뛰어나고 유지보수가 용이한 애플리케이션을 구축하는 데 든든하고 신뢰할 수 있는 기반이 되어줄 것입니다.

    반응형

    댓글

Designed by Tistory.