React로 개발을 하다 보면 이벤트 핸들링은 피할 수 없다. 버튼 클릭, 폼 입력, 마우스 호버 등 사용자와의 상호작용을 처리하기 위해서는 이벤트를 다뤄야 한다. 하지만 브라우저마다 이벤트 처리 방식이 조금씩 다르다. React는 이런 브라우저 간의 차이를 어떻게 해결했을까?
합성 이벤트
React는 브라우저 간 차이를 해결하기 위해 합성 이벤트$_{synthetic \space event}$ 개념을 도입했다. 브라우저의 네이티브 이벤트를 한 번 감싸서 모든 브라우저에서 동일하게 동작하도록 만든 것이다.
브라우저마다 이벤트를 처리하는 방식이 다르다는 점은 웹 개발에서 오랫동안 골칫거리였다. 예를 들어 Internet Explorer에서는 이벤트 객체를 전역 window 객체를 통해 접근해야 하고, 다른 브라우저들은 이벤트 핸들러의 첫 번째 매개변수로 전달한다. 또한 일부 브라우저에서는 이벤트의 기본 동작을 막는 메서드 이름도 서로 달랐다. 이런 차이를 개발자가 직접 처리하려면 코드가 복잡해지고 유지보수가 어려워진다.
React의 합성 이벤트는 이런 문제를 해결하기 위해 W3C 명세를 따르는 표준화된 이벤트 인터페이스를 제공한다. 모든 브라우저에서 동일한 속성과 메서드를 사용할 수 있다. W3C 명세는 아래 주소에서 확인할 수 있다.
https://support.ptc.com/help/arbortext/r8.3.0.0/ko/index.html#page/Program/programmer_ref/event.html
그리고 React는 이벤트 위임을 사용해 성능을 최적화하고 이벤트 객체 풀링으로 메모리 사용을 관리한다. 결과적으로 합성 이벤트 시스템 덕분에 개발자는 브라우저의 차이점을 신경 쓰지 않고도 React 애플리케이션에서 일관된 방식으로 이벤트를 다룰 수 있다.
사용 방법
이벤트 핸들러 작성하기
React에서 이벤트를 다루는 방법은 HTML과 비슷하면서도 차이점이 있다. React는 이벤트 핸들러를 작성할 때 일관된 네이밍 컨벤션과 함께 JSX를 통한 선언적인 방식을 사용한다.
선언적 방식?
HTML에서는 DOM 요소를 먼저 찾고 addEventListener를 사용해 이벤트를 연결하는 명령형 방식을 사용하지만, React는 JSX 내에서 직접 이벤트 핸들러를 지정하는 방식을 사용한다. 즉, "어떻게" 이벤트를 연결할지가 아닌 "무엇을" 할지를 설명하는 방식이다.
가장 기본적인 이벤트 핸들러는 다음과 같이 작성할 수 있다.
function Button() {
const handleClick = (event) => { // event는 React의 합성 이벤트 객체
console.log('버튼이 클릭되었습니다');
console.log(event.nativeEvent); // 필요한 경우 네이티브 DOM 이벤트에 접근
}
return (
<button onClick={handleClick}>
클릭
</button>
);
}
여러 이벤트 통합 관리
복잡한 UI를 다루다 보면 하나의 컴포넌트에서 여러 이벤트를 처리해야 할 때가 있다. 예를 들어 버튼에 클릭과 더블클릭 이벤트를 모두 처리하고 싶다면 어떻게 해야 할까? React에서는 이런 상황을 관리하기 위한 여러 가지 패턴을 제공한다. 가장 직관적인 접근 방식은 각 이벤트 타입마다 별도의 핸들러를 만드는 것이다.
function Button() {
const handleClick = () => console.log('클릭');
const handleDoubleClick = () => console.log('더블클릭');
return (
<button
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
클릭
</button>
);
}
하지만 이벤트가 많아지면 코드가 금방 복잡해지고 비슷한 로직이 여러 핸들러에 중복될 수 있으며, 새로운 이벤트를 추가할 때마다 별도의 함수를 만들어야 한다. 이런 문제들을 해결하기 위해 하나로 통합된 이벤트 핸들러를 사용하는 방법이 있다.
function Button() {
const handleEvent = (event) => {
switch(event.type) {
case 'click':
console.log('클릭했습니다');
break;
case 'dblclick':
console.log('더블클릭했습니다');
break;
default:
console.log(`다른 이벤트 발생: ${event.type}`);
}
}
return (
<button
onClick={handleEvent}
onDoubleClick={handleEvent}
>
클릭하세요
</button>
);
}
이렇게 하면 새로운 이벤트를 추가할 때 switch문에 case만 추가하면 된다. 또 여러 이벤트 간 공유해야 하는 로직도 쉽게 구현할 수 있다. 코드가 더 깔끔해지고 이벤트 핸들링에 관한 흐름을 한 눈에 파악할 수 있어 관리하기도 쉬워진다.
내부 동작 원리
합성 이벤트와 네이티브 이벤트
React의 합성 이벤트가 실제로 어떻게 동작하는지 조금 더 살펴보자.
function Button() {
const handleClick = (syntheticEvent) => {
// 합성 이벤트는 MouseEvent의 인스턴스가 아님
console.log(syntheticEvent instanceof MouseEvent); // false
// 하지만 nativeEvent는 실제 MouseEvent의 인스턴스
console.log(syntheticEvent.nativeEvent instanceof MouseEvent); // true
}
return <button onClick={handleClick}>클릭하세요</button>;
}
이 코드에서 React가 이벤트 핸들러에 전달하는 이벤트 객체(syntheticEvent
)는 실제 브라우저의 MouseEvent
와는 다른 객체다. React가 만든 합성 이벤트 객체다. 하지만 이 합성 이벤트 안에는 syntheticEvent.nativeEvent
를 통해 실제 DOM 이벤트에 접근할 수 있다. 이걸 통해 React의 이벤트 시스템이 단순 래퍼가 아닌, 새로운 추상화 계층을 제공하는 것을 알 수 있다.
React의 소스 코드를 보면 이 합성 이벤트가 어떻게 만들어지는지 알 수 있다. React는 createSyntheticEvent
라는 팩토리 함수를 사용해 이벤트 객체를 생성한다.
function createSyntheticEvent(Interface) {
function SyntheticBaseEvent(
reactName,
reactEventType,
targetInst,
nativeEvent,
nativeEventTarget
) {
this.nativeEvent = nativeEvent;
this.target = nativeEventTarget;
// Interface의 각 속성을 합성 이벤트에 복사
for (const propName in Interface) {
if (!Interface.hasOwnProperty(propName)) {
continue;
}
const normalize = Interface[propName];
if (normalize) {
this[propName] = normalize(nativeEvent);
} else {
this[propName] = nativeEvent[propName];
}
}
}
return SyntheticBaseEvent;
}
만약 마우스 클릭 이벤트가 발생했을 떄,
- React는 먼저 브라우저의 클릭 이벤트(
nativeEvent
)를 받는다. - 이 이벤트를 처리하기 위한 인터페이스(
Interface
)를 준비한다. - 인터페이스에 정의된 각 속성(
screenX
,clientX
등)을 새로운 합성 이벤트 객체에 복사한다. - 일부 속성은 브라우저별 차이를 표준화하기 위해
normalize
함수를 통해 처리한다.
브라우저마다 마우스 휠의 스크롤 값을 다르게 처리하는데, React에서 어떻게 표준화하는지 살펴보자.
const WheelEventInterface = {
...MouseEventInterface,
deltaX(event) {
return 'deltaX' in event
? event.deltaX
: // Webkit 브라우저를 위한 fallback
'wheelDeltaX' in event
? -event.wheelDeltaX
: 0;
},
deltaY(event) {
return 'deltaY' in event
? event.deltaY
: // Webkit 브라우저를 위한 fallback
'wheelDeltaY' in event
? -event.wheelDeltaY
: 0;
},
deltaZ: 0,
// deltaMode가 없는 브라우저에서는 휠 이벤트의 한 칸이 항상 +/- 120 픽셀이다.
// DOM_DELTA_LINE(1)은 viewport 크기의 5% 또는 약 40픽셀,
// DOM_DELTA_SCREEN(2)은 viewport 크기의 87.5%에 해당한다.
deltaMode: 0,
};
일반 브라우저들은 deltaX
와 deltaY
속성을 통해 스크롤 값을 제공하지만, Webkit 기반 브라우저들은 wheelDeltaX
와 wheelDeltaY
를 사용한다. 또한 값의 부호도 다른데, Webkit에서는 오른쪽/아래 방향이 음수인 반면, 표준에서는 양수다. React는 이러한 차이를 해결하기 위해 먼저 표준 속성의 존재 여부를 확인하고, 없다면 Webkit 속성을 사용하되 부호를 반전시킨다.
이렇게 React는 브라우저별 차이를 추상화해 인터페이스를 제공하기 때문에 개발자는 deltaX
와 deltaY
만 사용하면 되고 브라우저별 구현 차이는 신경 쓸 필요가 없다.
이벤트 위임 메커니즘
이벤트 위임$_{Event \space Delegation}$은 브라우저의 이벤트 버블링을 활용한 방법이다. 브라우저에서 이벤트가 발생하면 해당 요소에서 시작해 DOM 트리를 따라 위로 전파되는데, 이 특성을 이용하면 각 요소마다 이벤트 핸들러를 붙이지 않고도 상위 요소 하나에서 모든 하위 요소의 이벤트를 처리할 수 있다. React는 이 패턴을 활용해 성능을 최적화한다.
function TodoList() {
const handleClick = (id) => {
console.log(`Todo ${id} clicked`);
};
return (
<ul>
{Array.from({ length: 1000 }, (_, i) => (
<li key={i} onClick={() => handleClick(i)}>
Todo {i}
</li>
))}
</ul>
);
}
만약 각 li
요소마다 개별적으로 이벤트 리스너를 연결한다면, 아래와 같이 1000개의 이벤트 리스너가 필요할 것이다.
listItems.forEach(item => {
item.addEventListener('click', handler); // 1000개의 리스너
});
하지만 이벤트 위임을 활용하면 하나의 이벤트 리스너로 모든 하위 요소의 이벤트를 처리할 수 있다.
rootContainer.addEventListener('click', (event) => {
const target = event.target; // 이벤트가 발생한 실제 요소 찾기
if (target.matches('li')) { // 해당하는 React 이벤트 핸들러 실행
const id = target.dataset.id;
handleClick(id);
}
});
이렇게 하면 1000개가 아니라 하나의 이벤트 리스너만 있으면 된다. 클릭이 발생하면 이벤트는 자연스럽게 상위로 버블링되고, React는 루트 컨테이너에서 이 이벤트를 잡아 처리한다.
이벤트 위임의 구현체는 React 프로젝트의 ReactDOMEventListener.js
에서 찾아볼 수 있다.
function getEventPriority(domEventName) { // 이벤트 우선순위
switch (domEventName) {
case 'click':
case 'keydown':
case 'keyup':
case 'mousedown':
case 'mouseup':
return DiscreteEventPriority; // 실제로는 이벤트가 더 다양함
case 'drag':
case 'mousemove':
case 'scroll':
case 'touchmove':
return ContinuousEventPriority;
default:
return DefaultEventPriority;
}
}
export function dispatchEvent( // 이벤트 디스패치 처리
domEventName: DOMEventName, // 이벤트 이름
eventSystemFlags: EventSystemFlags, // 이벤트 시스템 설정
targetContainer: EventTarget, // 이벤트가 발생한 DOM 요소
nativeEvent: AnyNativeEvent, // 브라우저의 원래 이벤트
): void {
if (!_enabled) return; // 이벤트 시스템이 비활성화되었다면 중단
let blockedOn = findInstanceBlockingEvent(nativeEvent); // 이벤트 처리가 차단되어야 하는지 확인
if (blockedOn === null) {
// 이벤트 처리 및 clear
dispatchEventForPluginEventSystem(/*...*/);
clearIfContinuousEvent(domEventName, nativeEvent);
return;
}
// 이벤트 처리 로직 ...
}
React는 이벤트의 우선순위에 따라 다양한 디스패처를 사용해 이벤트를 처리한다. 아래 코드는 이벤트 타입에 따라 적절한 디스패처를 선택하는 팩토리 함수다.
export function createEventListenerWrapperWithPriority(
targetContainer: EventTarget,
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
): Function {
const eventPriority = getEventPriority(domEventName);
let listenerWrapper;
switch (eventPriority) {
case DiscreteEventPriority:
listenerWrapper = dispatchDiscreteEvent;
break;
case ContinuousEventPriority:
listenerWrapper = dispatchContinuousEvent;
break;
case DefaultEventPriority:
default:
listenerWrapper = dispatchEvent;
break;
}
return listenerWrapper.bind(
null,
domEventName,
eventSystemFlags,
targetContainer,
);
}
React는 이벤트의 우선순위에 따라 dispatchDiscreteEvent
, dispatchContinuousEvent
등 다양한 디스패처를 사용해 이벤트를 처리한다. 예를 들어 'click', 'keydown'과 같은 개별적인 사용자 상호작용은 DiscreteEventPriority
로, 'mousemove', 'scroll'과 같은 연속적인 이벤트는 ContinuousEventPriority
로 처리된다.
이벤트 폴링
이전 버전의 React에서는 이벤트 객체를 재사용하기 위해 '이벤트 풀링'이라는 최적화 기법을 사용했다. 이벤트 처리가 끝나면 이벤트 객체의 모든 속성을 null
로 초기화하고 다음 이벤트를 위해 재사용하는 방식이었다. React 17에서 이벤트 풀링 최적화가 완전히 제거되었다. 모던 브라우저에서는 성능 향상이 크지 않았고, 심지어 경험 많은 React 개발자들도 혼란스러워했기 때문이다.
function handleChange(e) {
setData(data => ({
...data,
// React 16 이하에서는 동작하지 않음
text: e.target.value
}));
}
React가 성능을 위해 서로 다른 이벤트 사이에서 이벤트 객체를 재사용하고, 이벤트 사이에서 모든 필드를 null
로 설정했기 때문이다. 비동기 코드에서 이벤트 객체를 참조할 때 문제가 된다.
function handleClick(event) {
// React 16 이하에서는 동작하지 않음
setTimeout(() => {
console.log(event.target.value) // null
}, 100);
// React 16에서는 이렇게 해야 했음
event.persist();
setTimeout(() => {
console.log(event.target.value) // 정상 동작
}, 100);
}
React 17부터는 이벤트 객체가 풀링되지 않으므로, persist()
를 호출할 필요가 없어졌고 이벤트 필드를 필요할 때마다 자유롭게 읽을 수 있게 되었다. e.persist()
는 여전히 React 이벤트 객체에서 사용할 수 있지만, 소스 코드를 보면 알 수 있다시피 persist()
메서드가 이제 아무 동작도 하지 않는 것을 확인할 수 있다.
https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/events/SyntheticEvent.js
/**
* 각 이벤트 루프 후에 발송된 모든 '합성 이벤트'를 해제해 풀에 다시 추가합니다.
* 이렇게 하면 풀에 다시 추가되지 않는 참조를 유지할 수 있습니다.
*/
persist: function () {
// 모던 이벤트 시스템은 풀링을 사용하지 않습니다.
},
마치며
React의 이벤트 시스템은 단순해 보이지만 내부적으로는 브라우저 호환성과 성능 최적화를 위한 설계가 녹아있다. 합성 이벤트를 통한 브라우저 표준화, 이벤트 위임을 통한 성능 최적화, 그리고 이벤트 우선순위에 따른 처리 등 다양한 기술이 적용되어 있다.
특히 React 17에서 이벤트 풀링이 제거되고 이벤트 위임의 동작 방식이 변경되는 등, React는 계속해서 개발자 경험을 개선하고 더 나은 성능을 제공하고 있다. React의 이벤트 시스템을 이해하면 브라우저에서 발생할 수 있는 이벤트 관련 문제를 더 효과적으로 디버깅하고 해결할 수 있을 것이다.
references
https://www.yes24.com/Product/Goods/133869925
https://ko.legacy.reactjs.org/docs/events.html
https://ko.react.dev/reference/react-dom/components/common#react-event-object
https://developer.mozilla.org/ko/docs/Web/API/Event
https://github.com/facebook/react/issues/13635
https://legacy.reactjs.org/blog/2020/08/10/react-v17-rc.html#no-event-pooling
'Web, Front-end > React' 카테고리의 다른 글
[React] Uncontrolled component, Controlled component (0) | 2025.01.28 |
---|---|
[React] useEffect의 내부적인 동작 (3) | 2024.12.13 |
[React] 하나의 index.ts에서 import 및 export 하기 (0) | 2024.05.09 |
[React] 컴포넌트 분리의 중요성 (0) | 2024.02.22 |
[React] 컴포넌트의 생명 주기(Life Cycle) (0) | 2024.02.18 |
컴퓨터 전공 관련, 프론트엔드 개발 지식들을 공유합니다. React, Javascript를 다룰 줄 알며 요즘에는 Typescript에도 관심이 생겨 공부하고 있습니다. 서로 소통하면서 프로젝트 하는 것을 즐기며 많은 대외활동으로 개발 능력과 소프트 스킬을 다듬어나가고 있습니다.
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!