React 이벤트 시스템 해부: 브라우저에서 Fiber까지
React 이벤트 시스템 해부: 브라우저에서 Fiber까지
레거시
서론
최근에 에러바운더리에서 이벤트발생이 잡히지 않는 다는 것을 팀원이 알려줬어요. 그리고 이벤트의 isTrusted에 대해 몰라서 팝업 차단 이슈를 해결하는데 애를 먹은 경우도 있었어요. 둘의 공통점은 제가 이벤트 관리에 대한 이해도 부족이었습니다. 리액트를 사용하는 이상 이정도는 알아야했던 것 같아서 조금 부끄럽기도 하지만 이참에 확실하게 알고가기 위해 블로그를 쓰게되었습니다.
내용은 리액트에서 이벤트가 일어났을때. JS ~ React 이벤트 발생까지의 과정을 리액트 내부 코드를 해부하여 작성하였습니다. 실제 리액트 상태까지 도달하는 과정은 아마 다음 블로그에서 이어서 진행할 것 같네요.
리액트 버튼 클릭 시 생기는 일
JS의 버튼 이벤트 동작
리액트의 버튼을 클릭 즉 onClick 이벤트가 발생한다면 무슨일이 일어날까요? 우리가 익히 아는 JS의 경우 버튼 클릭시 캡쳐 단계, 타겟 단계, 버블 단계를 거칩니다.
실제로 addEventListener를 이용하여 콘솔을 보면 캡쳐링 버블링을 하는 것을 볼 수 있습니다.
엘리먼트 구조
- perent
- child
- button
- child
캡쳐, 버블 이벤트 등록
console 결과
그럼 React는 어떻게 동작할까요?
리액트의 버튼 이벤트 개념
리액트는 이벤트 위임, 합성 이벤트, 이벤트 풀링 총 3가지의 개념이 존재하였는데 현재는 이벤트 풀링이 빠진 2가지 개념으로 동작합니다.
이벤트 위임 (Event Delegation)
이벤트 위임이란 여러 요소에 각각 핸들러를 적용하는 것이 아닌 공통 조상 핸들러를 사용하는 것입니다. 특이점은 React17과 React16의 이벤트 위임 위치가 다릅니다. 16의 경우 document이며, 17의 경우 리액트를 사용하는 사람이라면 잘아는 createRoot(document.getElementById('root')) 입니다. 왜 root로 변경했을까요? 그 이유는 호환성과 JS이벤트 즉 Native Event의 문제가 있었기 때문입니다.
2가지 문제점.
호환성
- document에서 사용할 경우 Jquery나 다른 프레임워크와 함께 사용하기에는 document는 너무 범용적. Native Event
- document에서 React Event를 받을 경우 stop.propagation 실행 시 Native Event가 이미 이벤트를 실행시키는 것.
출처 : https://legacy.reactjs.org/static/bb4b10114882a50090b8ff61b3c4d0fd/21cdd/react_17_delegation.png
이벤트 위임은 공통 조상 핸들러를 사용한다고 하였습니다. 그 방법은 JS의 핸들러를 Root에서 인터셉트하여 리액트 이벤트 전파로 이어지는 것입니다. 그럼 실제로 인터셉트하는 과정이 어떻게 이루어질까요?
인터셉트를 콘솔로 확인한다면?
- window - Capture
- document - Capture
- root - Capture
- container - Capture
- container - Bubble
- React Event - button
- root - Capture
- React Event - container
- document - Capture
- Root - Bubble document - Bubble
실제 코드로 확인해본 결과 Native Event가 root도달 직전(container)에 버블링이 끝나고 React 버블링이 시작됩니다. 이는 Root Element에 Native onclick이 도달하게되면 React onClick으로 변환한다는 것을 알 수 있습니다.
그럼 실제로 Native onclick이 도달하게 되었을때. 어떤 기준으로 React onClick인지 알고 실행하여서 React Bubbling으로 전달될까요?
서론. onClick 에러는 왜 에러바운더리에 잡히지 않을까
최근 팀에서 ErrorBoundary로 이벤트 핸들러 예외를 잡지 못한 사례가 있었다.
isTrusted를 몰라 팝업 차단을 해결하지 못한 적도 있었다.
공통된 문제는 React의 이벤트 시스템이 렌더링과 별도의 관리 포인트라는 점을 간과한 것이었다.
이 글에서는 클릭 이벤트가 브라우저에서 시작해 Fiber까지 도달하는 전 과정을 실제 코드로 추적한다.
브라우저 이벤트 → React로 넘어오는 순간
일반적인 DOM 이벤트는 세 단계를 거친다.
- capture phase
- target phase
- bubble phase
React는 이 버블 단계가 root에 도달하는 순간, 네이티브 이벤트를 SyntheticEvent로 변환한다.
React 17 이전엔 이벤트 위임이 document 엘리먼트에서 위임했지만, 이제는 createRoot()로 만든 root 컨테이너 단위로 위임한다.
이 변경으로 jQuery나 여러 React 버전이 공존하는 환경에서의 충돌이 사라졌고, stopPropagation()의 동작도 예상대로 작동하게 됐다.
“React는 document의 버블 마지막 지점에서 synthetic world로 context switch한다.”
React 이벤트 해부
리액트를 등록 : createRoot
우리가 익히 아는 main.tsx에서 사용하는 createRoot 메소드이다. 이것을 통해 리액트가 시작된다.
id = “root”인 엘리먼트를 listenToAllSupportedEvents에 주입합니다.
모든 DOM Event 주입 : listenToAllSupportedEvents
- listeningMarker
- DOM 노드에 붙이는 "이미 등록됨" 플래그. _reactListening${randomKey} 형태로 여러 root가 공존해도 충돌하지 않음.
- nonDelegatedEvents:
- scroll, media 이벤트처럼 버블링이 안 되는 이벤트들.
- root까지 이벤트가 올라오지 않으므로 위임 불가 → 버블 리스너 등록을 건너뜀. (캡처 리스너는 여전히 등록됨)
- selectionchange:
- document 레벨에서만 발생하는 이벤트라 root가 아닌. ownerDocument에 별도 등록.
동작 요약 : 모든 이벤트를 root에 한 번만 등록하되, 버블링 안 되는 건 캡처만, selectionchange는 document에 따로
캡쳐, 버블 구분 : listenToNativeEvent
- 왜 굳이 boolean을 비트 플래그로 변환할까? ⇒ 중복 플래그를 관리하기 위해서
ex) 0000 0110 ⇒ IS_CAPTURE_PHASE, IS_NON_DELEGATED
addTrappedEventListener
"Trapped"은 이벤트를 "가둔다"는 의미로, React가 모든 이벤트를 가로채서 제어한다는 뜻입니다.
- 이벤트 핸들러에 우선순위를 입혀서 래퍼함수를 만듭니다.
touchstart,touchmove,wheel은 passive로 등록.- 캡쳐, 버블의 passive, 일반 이벤트를 등록
내부 코드
우선순위 적용 : createEventListenerWrapperWithPriority
getEventPriority의 우선순위 및 종류
- DiscreteEventPriority : 사용자의 의도적 행위
click,keydown,input,focus…
- ContinuousEventPriority : 연속적인 스트림
scroll,wheel, ...
- DefaultEventPriority : 이외의 것들.
error,load,transitionend…
dispatchEvent
SyntheticEvent — React의 자체 이벤트 루프
SyntheticEvent는 네이티브 이벤트를 감싸는 React 내부 표준 이벤트 객체다.
모든 브라우저에서 동일한 API(preventDefault, stopPropagation)를 보장하며, React의 캡처·버블링 시뮬레이션의 기반이 된다.
React 17부터는 이벤트 풀링(Pooling)이 제거되었다.
event.persist()를 호출할 필요가 없으며, 비동기 코드에서도 안전하다.
리스너 세팅의 실제 흐름
createRoot()는 내부에서 아래 함수를 호출한다.
- listenToAllSupportedEvents
→ React가 지원하는 모든 DOM 이벤트에 대해 리스너를 등록
- addTrappedEventListener
→ Root에 실제 DOM 리스너 등록
- createEventListenerWrapperWithPriority
→ 이벤트 우선순위(discrete, continuous, default) 지정
- passive listener 처리
→
wheel,touchmove는 preventDefault 차단을 막기 위해 passive로 등록
이벤트 우선순위와 Dispatch
createEventListenerWrapperWithPriority
React의 이벤트 시스템은 모든 이벤트에 Priority를 부여한다.
dispatchEvent() 단계에서
- 우선순위를 판단하고
- 블로킹 상태를 확인한 뒤
- 실제 dispatch로 넘어간다.
React Code :
블로킹과 하이드레이션
findInstanceBlockingEvent
React는 Fiber 트리 상태에 따라 이벤트를 일시 정지하거나 재개한다.
- Continuous 이벤트는 큐에 저장 (
queueIfContinuousEvent) - Discrete + Capture 이벤트는 즉시 hydration 시도 (
attemptSynchronousHydration)
이 로직 덕분에 SSR 중에도 사용자의 클릭이 손실되지 않는다.
동작 방법
내부 동작 요약
- 클릭된 DOM(
nativeEvent.target)에서 시작 - 그 DOM이 어떤 React Fiber에 해당하는지 찾음 (
targetInst) - 만약 그 Fiber가 아직
mounted되지 않았다면→ 부모 Fiber로 계속 올라가며 **“가장 가까운 mounted Fiber”**를 탐색
→ 그 결과가
nearestMounted - 이
nearestMounted가 어떤 타입(Component tag)인지에 따라→ 이벤트를 즉시 처리할지, **대기(Blocking)**할지 결정
🔍 예시로 풀어보기
- 버튼을 클릭했지만, React 입장에선 button과 section은 이미 unmount 상태.
- React는 부모로 올라가며
mounted된 Fiber를 찾음 →<div>발견 nearestMounted = div→ “OK, div는 살아있네. 여기서부터 이벤트를 처리하자.”
7. dispatchEventForPluginEventSystem — React의 진짜 디스패처
SyntheticEvent가 생성된 뒤 실행되는 핵심 루프다.
여기서 React는 이벤트의 “루트”를 식별하고, 큐에 추가한 뒤 캡처/버블 순서대로 실행한다.
React의 버블링은 실제 DOM 버블링이 아니다.
8. batchedUpdates — 이벤트와 렌더링의 경계
이벤트 핸들러 내에서 여러 setState()가 호출되어도
React는 이를 자동으로 batch 처리한다.
이벤트는 “렌더링 루프 외부의 트랜잭션”으로 실행되며,
이 때문에 ErrorBoundary는 이벤트 핸들러의 에러를 감지하지 못한다.
9. ErrorBoundary가 이벤트 에러를 잡지 못하는 이유
ErrorBoundary는 렌더링 / 커밋 중 발생한 오류만 처리한다.
onClick이나 onChange는 그 밖의 독립된 이벤트 루프에서 실행된다.
React 팀의 설계 철학은 명확하다.
렌더링 에러는 React의 책임,
root를 ErrorBoundary로 감싸도 이벤트 핸들러 예외는 잡히지 않는다.
10. Fiber와의 연결
dispatch가 끝나면 React는 batchedUpdates를 통해
Fiber에 상태 업데이트 스케줄을 생성한다.
이 업데이트는 lane priority에 따라 스케줄러로 전달되고,
렌더링 → 커밋 → 페인트 순으로 이어진다.
전체 타임라인은 아래와 같다.
11. 결론 — 이벤트는 렌더링의 사촌이다
React의 이벤트 시스템은 단순히 DOM 이벤트를 감싸는 것이 아니다.
렌더링 시스템과 나란히 존재하는 또 하나의 파이프라인이다.
React의 이벤트 시스템은 렌더링 이전 단계에서 시작해 Fiber로 끝나는, 독립적인 스케줄링 파이프라인이다.
참고 링크
- React 17 Event Delegation Change
- Saeloun Blog — Event Delegation
- Lukewanghanxiang — Understanding React's Event System
원하면 여기서
- 💡 이미지 포함 버전 (root→dispatch→fiber 다이어그램),
- 📘 각 섹션별 subtitle과 한줄 summary 카드형 버전,
둘 중 하나로 가공해줄 수 있어.
어떤 스타일로 완성하고 싶어? (다이어그램형 / 카드요약형)

