본문 바로가기
React

React에서 요소마다 이벤트 핸들러를 추가해도 되는 이유

by Vintz 2025. 8. 16.
반응형

강력한 패턴, 이벤트 위임(Event Delegation)

바닐라 JS로 로그인/회원가입 폼을 만들었다. 로그인까지는 무난하게 넘어갔는데, 회원가입 폼은 입력 필드가 좀 많았다. 많아진 셀렉터들과 이벤트 리스너가 신경 쓰였다. 모듈로 분리도 하고, 함수와 변수들을 정리하고, 배열 메서드로 변경하는 등 코드 구조를 개선하는 리팩토링을 해봤지만, 여전히 요소마다 등록된 리스너는 복잡해 보였다.

 

그러던 중 예전에 기술 과제 요구사항이었던 이벤트 위임이 생각이 났다. 이벤트 위임은 여러 자식 요소에 개별 이벤트 리스너를 등록하지 않고, 그들의 부모 요소에 단 하나의 이벤트 리스너를 등록해 이벤트를 관리하는 방법이다.

const form = document.querySelector('.auth-form');

form.addEventListener('input', (e) => {
  ...
});

// 유사한 동작인 'blur' 이벤트는 버블링하지 않음.
// 'blur', 'focus', 'mouseenter', 'mouseleave' 등이 있다.
form.addEventListener('focusout', (e) => {
  ...
});

입력 요소마다 이벤트 리스너를 등록하지 않고, 부모 요소인 폼에 이벤트 리스너를 등록했다. 이렇게 하나의 이벤트 리스너로 관리하면, 더 적은 코드로 처리가 가능하며, DOM 관리가 단순해진다. 여기서 폼에 새로운 필드가 추가, 제거되어도 이벤트 리스너를 수동으로 관리할 필요가 없어지는 것이다.

 

또한, 브라우저의 기본 이벤트 전파 방식인 버블링(Bubbling)을 활용해서 '단일 리스너'로 관리하기 때문에 메모리를 절약할 수 있다. 이 덕분에 제품, 피드, 글 목록처럼 많은 수의 요소를 다룰 때 특히 유용하다.

 

따라서, Single Page Application(SPA)과 같이 동적 콘텐츠가 많은 현대적인 웹 애플리케이션에서도 효과적으로 활용할 수 있다.

그럼 React는 DOM 요소마다 이벤트 리스너를 등록하는 걸까?

좋다. 근데, React는 이벤트 핸들러를 인라인으로 작성한다. 이거 괜찮을까? React가 내부적으로 최적화를 해주는 걸까? 예전부터 궁금했다.

function App() {
  return (
    <>
      <button onClick={() => console.log('Button A clicked')}>A</button>
      <button onClick={() => console.log('Button B clicked')}>B</button>
    </>
  );
}

위 예시에서 보이는 것과 달리, 사실 React는 각 버튼(DOM 요소)마다 리스너를 등록하지 않는다.

 

대신 React는 모든 이벤트가 root 요소에서 핸들링된다. root 요소에 이벤트 타입당 하나의 리스너로 위임해 React가 트리를 따라가며 적절한 컴포넌트 핸들러들을 호출한다.

React root 요소에서 모든 리스너가 핸들링 되고 있다.

즉, React는 이벤트 위임을 사용하고 있다. 개발자가 직접 구현할 필요 없이, 내부적으로 알아서 최적화 처리를 해주고 있었던 것이다.

출처 - https://legacy.reactjs.org/blog/2020/08/10/react-v17-rc.html#changes-to-event-delegation

참고로, 리액트 17 버전부터 document 레벨이 아닌 root 레벨에서 이벤트를 핸들링한다.(점진적 업그레이드를 위해 변경되었다. root 레벨에서 이벤트를 핸들링하면, 같은 페이지에 서로 다른 React 버전이 있어도 간섭하지 않는다.)

React Error Boundary가 작동하지 않는 이유

예전에 Next.js를 학습하면서, ErrorBoundary로 감싼 컴포넌트의 이벤트 핸들러에서 에러 처리가 되지 않는 것에 대해 머리가 아팠던 적이 있다. 그래서 단순히 'root 요소에서 이벤트를 핸들링 하니까, 에러를 잡지 못하는 이유는 이벤트 위임을 사용하기 때문이 아닐까?'란 생각이 들었다.

function App() {
  return (
    <ErrorBoundary>
      <MyComponent />
    </ErrorBoundary>
  );
}

// ErrorBoundary로 감싸진 컴포넌트
function MyComponent() {
  const handleClick = () => {
    throw new Error('이벤트 핸들러에서 에러 발생!');
  };

  return <button onClick={handleClick}>클릭</button>;
}

MyComponent 내부에서 이벤트가 다뤄지는 것이 아니라 최상위 요소인 root 요소에서 이벤트가 다뤄진다. 그러니까 에러가 발생한다면, ErrorBoundary 컴포넌트와는 다른 실행 컨텍스트를 가질 것이다. 그렇다면, 주된 이유는 이벤트 위임이 맞는 걸까?

 

이벤트 핸들러에서 에러 경계가 트리거 되지 않는 이유에 대한 글들을 꼼꼼히 읽어봤지만, 여전히 명확한 답은 찾지 못했다. 그래도 관련 토론에서 리액트 팀이 왜 핸들러에서 예외를 발생시켜도 에러 경계가 개입하지 않는지, 그리고 그걸 가능하게 만드는 게 얼마나 어려운지를 설명해 주었다:

  • 발생 지점을 특정하기가 어렵다. 이벤트 핸들러는 props를 통해 여러 단계로 내려갈 수 있어서, 에러가 이벤트를 트리거한 DOM에서 비롯됐는지, 핸들러를 소유한 컴포넌트에서 비롯됐는지 알기가 애매하다. 후자의 경우 소유 컴포넌트가 무엇인지조차 확정하기가 어렵다.(e.g. handleClick 함수를 여러 컴포넌트에서 import해서 사용하는 경우)
  • 설령 발생 지점을 알아냈다 해도, 동기적으로 작동하는 에러뿐이다. 비동기로 발생하는 에러는 React가 포착할 방법이 없다.
  • 이벤트 에러를 렌더 에러처럼 취급하기 시작하면, 네트워크 이벤트나 사용자 정의 함수에도 같은 규칙을 적용해야 하느냐는 문제가 생긴다. 이러면 복잡도를 크게 키운다.

결국, 진짜 이유는 이벤트 핸들러 처리 단계와 렌더링 처리 단계가 별도 흐름에서 실행된다는 것에 있었다. React 설계상 더 큰 혼란을 일으키지 않고, API의 큰 변경없이 안전하게 구현할 방법을 찾지 못했다고 결론이 난듯하다. 가장 효율적이고 최선인 설계가 현재 방식인 것이다. 그 결과로, 에러 경계는 이벤트 핸들러 내부에서 발생한 에러를 포착하지 않는다.

 

에러 경계는 '컴포넌트가 렌더 과정에서 예외를 발생'시킬 경우, React가 호출한 컴포넌트를 정확히 알 수 있어 가장 가까운 에러 경계가 대체 UI를 렌더시킬 수 있다.

 

따라서, 이벤트 위임과는 관계가 없다는 사실을 알게 되었다. 이벤트 위임을 하든 DOM 요소에 직접 바인딩을 하든, React는 렌더링 단계와 이벤트 처리 단계가 나누어져 있기 때문에, 에러 경계는 이벤트 핸들러에서 발생한 에러를 포착할 수 없다. 이 때문에 공식 가이드는 이벤트 핸들러 내에서 에러를 잡아야 하는 경우, try...catch 구문을 사용하라고 안내하고 있다.

반응형