항해 1주차 과제 프레임워크 없이 SPA 만들기 과제를 진행하던 중 이벤트 관리에 있어서 꽤나 애를 먹었었다.
그래서 SPA(특히 React)는 어떤식으로 이벤트를 관리하고 위임하는지 궁금해서 찾아본 내용을 정리하였다.
브라우저는 클릭, 키보드 입력, 네트워크 응답 등 특정 사건이 발생하면 이를 감지해 이벤트를 발생시킨다.
애플리케이션이 특정 이벤트에 반응하고 싶다면 해당 이벤트 발생 시 호출될 함수를 브라우저에 등록해 둔다.
이 함수를 이벤트 핸들러라고 하며, 등록하는 과정을 이벤트 핸들러 등록이라고 한다.
언제 이벤트가 발생할지는 알 수 없으므로 브라우저에게 호출을 맡겨두는 형태다.
예를 들어
const $button = document.querySelector('button');
$button.onclick = () => {
console.log('btn click');
};
이처럼 프로그램의 흐름을 이벤트 중심으로 제어하는 방식을 이벤트 드리븐(event-driven) 프로그래밍이라고 한다.
이벤트 위임은 여러 개의 하위 DOM 요소에 각각 이벤트 핸들러를 등록하는 대신 하나의 상위 DOM요소에 이벤트 핸들러를 등록하는 방법이다. 이벤트 위임을 통해 상위 DOM 요소에 이벤트 핸들러를 등록하면 여러 개의 하위 DOM요소에 이벤트 핸들러를 따로 등록할 필요 없다.
브라우저의 이벤트 전파 방식에는 캡처링(capturing)과 버블링(bubbling)이 있다.
캡처링: 상위 → 하위로 이벤트 내려감
버블링: 이벤트가 발생한 요소 → 상위 요소로 올라감
이벤트 위임은 이 중 버블링을 이용한다.
즉, 하위 요소(li)에서 클릭 이벤트가 발생하면, 그 이벤트는 상위 요소(ul)까지 버블링되고,
상위 요소에 등록한 이벤트 핸들러가 그 이벤트를 감지할 수 있게 된다.
const msg = document.querySelector('.msg');
const list = document.querySelector('.post-list');
list.addEventListener('click', function (e) {
if (e.target.tagName === 'LI') {
msg.textContent = `Clicked: ${e.target.textContent}`;
console.log(e.target.id);
}
});
React는 각 컴포넌트/엘리먼트 단위로 개발자가 직접 addEventListener를 호출하지 않는다. 대부분의 이벤트 리스너는 최상위 루트 컨테이너 (예: <div id="root">
)에 단 한 번만 등록된다. 여기서 React는 SyntheticEvent
라는 합성이벤트를 사용한다.
1.브라우저 native 이벤트 발생
사용자가 버튼을 클릭하면 브라우저에서 click 이벤트가 발생한다.
이때 native MouseEvent가 만들어지고, 이벤트는 DOM 트리에서 상위로 버블링된다.
2. React 루트 컨테이너에서 이벤트 감지
React는 <div id="root">
같은 루트 컨테이너에 단 한 번만 등록된 이벤트 리스너로
모든 이벤트를 감지한다. 이 리스너는 React DOM 내부에서 attach되며, addEventListener로 각 버튼, div에 직접 등록하지 않는다.
(이게 바로 React의 이벤트 위임 전략이다.)
3. SyntheticEvent로 래핑
루트 컨테이너에서 이벤트가 감지되면 React는 nativeEvent를
SyntheticMouseEvent 같은 SyntheticEvent 인스턴스로 감싼다.
이떄
와 같은 작업이 진행된다.
4. React 컴포넌트 트리에서 이벤트 핸들러 찾기
React는 Fiber 트리를 탐색하여 어디에서 이벤트가 발생했는지 해당 컴포넌트에서 어떤 onClick props가 등록되어 있는지 찾는다.
예를들어 Button 컴포넌트의 props.onClick
5. 핸들러 실행
React는 찾은 핸들러를 호출한다.
() => setCount(count + 1)
이때 event 객체에 SyntheticEvent가 첫 번째 인자로 넘어가며, e.preventDefault(), e.stopPropagation() 등을 사용할 수 있다.
6. 이후 렌더링 작업
setCount
와 같은 상태 업데이트 함수를 scheduler queue에 등록하여 일괄 처리한 뒤 컴포넌트를 리렌더링 한다.
React에서 JSX로 이벤트 핸들러를 작성하면, 브라우저 native 이벤트가 직접 전달되지 않는다.
React는 native 이벤트를 감싸서 SyntheticEvent라는 래퍼 객체로 만들어 전달한다.
<button onClick={(e) => console.log(e)}>Click me</button>
여기서 console.log(e)로 출력되는 것은 브라우저의 MouseEvent가 아니라, React가 만든 SyntheticEvent다.
1. cross-browser 불일치 문제 해결
2. Virtual DOM 업데이트와의 통합
native 이벤트는 DOM에 직접 연결되지만, React는 Virtual DOM → 실제 DOM으로 한 번에 commit하는 방식을 쓴다.
문제점.
해결방법.
<button onClick={() => setState(x + 1)}>Click me</button>
setState는 바로 값 변경하지 않고 update queue에 등록 React가 render → diff → commit 시점에 한 번에 반영한다.
3. 메모리 최적화 - 이벤트 풀링
이벤트가 한 번 발생할 때마다 event 객체를 새로 만들면 이벤트가 많을수록 GC(garbage collection) 부담이 커지게 된다.
button.addEventListener('click', (event) => {
console.log(event.type);
});
여기서 event는 매 클릭마다 새로 만들어지는 MouseEvent객체이다.
SPA에서는 클릭, 입력, 마우스 무브 같은 이벤트들이 초당 수십~수백번 생성될 수 있는게 이게 매우 비효율적이다.
즉 이벤트마다 Event 객체를 새로 만들면 → GC(garbage collection, 메모리 정리) 부담 ↑ → 퍼포먼스 저하가 발생한다.
그래서 React는 이미 사용한 SyntheticEvent를 풀(pool, 재사용용 임시 저장소) 에 넣어뒀다가 다시 꺼내 쓰는 방식을 채택했다.
1. 클릭 발생 → 풀에서 SyntheticEvent 꺼냄 → onClick에 전달
2. onClick 실행 끝 → SyntheticEvent 초기화 후 풀에 반납
3. 다음 클릭 → 같은 SyntheticEvent 인스턴스 재사용
이렇게 되면 메모리 사용량도 감소하고, GC 부담도 감소, 퍼포먼스도 안정화 된다.
하지만...
풀에서 재사용하니까, 이벤트 핸들러 실행이 끝나면 그 SyntheticEvent는 초기화되어 내용이 사라진다.
따라서 비동기 코드에서 e.target 같은 걸 쓰면 문제가 생기게된다.
button.onClick = (e) => {
setTimeout(() => {
console.log(e.target); // ❌ null 또는 초기화됨!
});
};
그래서
button.onClick = (e) => {
e.persist(); // 풀에서 해제
setTimeout(() => {
console.log(e.target); // ✅ 안전
});
};
이런식으로 썼었는데 React 17버전 이후로는 SyntheticEvent는 매번 새로 생성되게 바꿨다.(사실상 pooling을 비활성화 했다.)
최신 React에서는 pooling의 이점보다는 코드 단순화와 안정성을 택한 거라고 볼 수 있다.
React 코드베이스의 SyntheticEvent.js는 SyntheticEvent 시스템의 실체를 정의한 핵심 파일이다.
이 코드의 핵심은
브라우저 native event → React SyntheticEvent로 감싸기
그리고 이때
function createSyntheticEvent(Interface) {
/**
* SyntheticBaseEvent: 각 SyntheticEvent 인스턴스 생성자 함수
*
* ex) SyntheticMouseEvent, SyntheticKeyboardEvent 등을 만들 때 공통으로 씀
*/
function SyntheticBaseEvent(
reactName, // React 이벤트 이름 (ex: onClick)
reactEventType, // 실제 브라우저 이벤트 타입 (ex: 'click')
targetInst, // React Fiber 노드 (어떤 컴포넌트에서 발생했는지)
nativeEvent, // 브라우저 native 이벤트 객체
nativeEventTarget // native event의 target (DOM 노드)
) {
// === 1. 핵심 정보 세팅 ===
this._reactName = reactName;
this._targetInst = targetInst;
this.type = reactEventType;
this.nativeEvent = nativeEvent;
this.target = nativeEventTarget;
this.currentTarget = null; // 나중에 setCurrentEvent로 채움
// === 2. Interface 속성 복사/정리 ===
for (const propName in Interface) {
if (!Interface.hasOwnProperty(propName)) continue;
const normalize = Interface[propName];
if (normalize) {
// normalize가 함수면 → nativeEvent에서 계산 후 값 세팅
this[propName] = normalize(nativeEvent);
} else {
// 아니면 → nativeEvent에서 직접 값 복사
this[propName] = nativeEvent[propName];
}
}
// === 3. defaultPrevented 상태 감지 ===
const defaultPrevented =
nativeEvent.defaultPrevented != null
? nativeEvent.defaultPrevented // 표준 속성
: nativeEvent.returnValue === false; // IE용 fallback
this.isDefaultPrevented = defaultPrevented
? functionThatReturnsTrue
: functionThatReturnsFalse;
this.isPropagationStopped = functionThatReturnsFalse;
return this;
}
// === 4. prototype에 표준 메서드 추가 ===
assign(SyntheticBaseEvent.prototype, {
/**
* 기본 동작 방지
*/
preventDefault: function () {
this.defaultPrevented = true;
const event = this.nativeEvent;
if (!event) return;
if (event.preventDefault) {
event.preventDefault();
} else if (typeof event.returnValue !== 'unknown') {
event.returnValue = false; // IE fallback
}
this.isDefaultPrevented = functionThatReturnsTrue;
},
/**
* 이벤트 버블링 중단
*/
stopPropagation: function () {
const event = this.nativeEvent;
if (!event) return;
if (event.stopPropagation) {
event.stopPropagation();
} else if (typeof event.cancelBubble !== 'unknown') {
event.cancelBubble = true; // IE fallback
}
this.isPropagationStopped = functionThatReturnsTrue;
},
/**
* React 16까지: pooling 해제
* React 17 이후: noop
*/
persist: function () {
// Modern event system doesn't use pooling.
},
/**
* 이 SyntheticEvent가 pool에 반납되지 않아야 하는지
* modern system에선 항상 true
*/
isPersistent: functionThatReturnsTrue,
});
// === 5. 최종 생성자 반환 ===
return SyntheticBaseEvent;
}
function createSyntheticEvent(Interface)
SyntheticEvent(합성이벤트)를 생성하는 팩토리 함수.
여기에 MouseEventInterface나 KeyboardEventInterface 같은 이벤트 속성 정의 객체(Interface)를 넣으면 그에 맞는 SyntheticEvent 생성자 함수를 만들어 반환하게끔 구현되어있다.
/**
* @interface MouseEvent
* @see http://www.w3.org/TR/DOM-Level-3-Events/
*/
const MouseEventInterface: EventInterfaceType = {
...UIEventInterface,
screenX: 0,
screenY: 0,
clientX: 0,
clientY: 0,
pageX: 0,
pageY: 0,
ctrlKey: 0,
shiftKey: 0,
altKey: 0,
metaKey: 0,
getModifierState: getEventModifierState,
button: 0,
buttons: 0,
relatedTarget: function (event) {
if (event.relatedTarget === undefined)
return event.fromElement === event.srcElement
? event.toElement
: event.fromElement;
return event.relatedTarget;
},
movementX: function (event) {
if ('movementX' in event) {
return event.movementX;
}
updateMouseMovementPolyfillState(event);
return lastMovementX;
},
movementY: function (event) {
if ('movementY' in event) {
return event.movementY;
}
// Don't need to call updateMouseMovementPolyfillState() here
// because it's guaranteed to have already run when movementX
// was copied.
return lastMovementY;
},
};
export const SyntheticMouseEvent: $FlowFixMe =
createSyntheticEvent(MouseEventInterface);
이런식으로 createSyntheticEvent
MouseEventInterface
인자를 넣으면 새로운 SyntheticMouseEvent
를 만들 수 있다.
그리고 MouseEventInterface
내부에는
relatedTarget: function (event) {
if (event.relatedTarget === undefined)
return event.fromElement === event.srcElement
? event.toElement
: event.fromElement;
return event.relatedTarget;
},
이런식으로 브라우저마다 다른 이벤트 필드를 항상 relatedTarget
으로 정규화 하는 과정도 포함되어있다.
function SyntheticBaseEvent(
reactName,
reactEventType,
targetInst,
nativeEvent,
nativeEventTarget,
)
이 값들을 SyntheticEvent 인스턴스에 저장한다.
preventDefault() {
this.defaultPrevented = true;
const event = this.nativeEvent;
if (!event) return;
if (event.preventDefault) {
event.preventDefault();
} else if (typeof event.returnValue !== 'unknown') {
event.returnValue = false;
}
this.isDefaultPrevented = functionThatReturnsTrue;
}
preventDefault()
메서드는 SyntheticEvent의 defaultPrevented 플래그를 true로 설정하고 실제 브라우저의 native event에도 preventDefault()를 호출하거나, 구형 IE처럼 이 메서드가 없는 환경에서는 returnValue = false
를 사용해 기본 동작을 막는다.
최신 브라우저에서는 event.preventDefault()만으로 충분하지만, 구형 브라우저에서는 이 fallback 처리가 필요하기 때문이다.
또한 React는 내부적으로 isDefaultPrevented 속성에 항상 true를 반환하는 함수를 연결해 나중에 e.isDefaultPrevented()를 호출하면 이 이벤트에서 기본 동작 방지가 되었는지 쉽게 알 수 있도록 설계했다.
stopPropagation() {
const event = this.nativeEvent;
if (!event) return;
if (event.stopPropagation) {
event.stopPropagation();
} else if (typeof event.cancelBubble !== 'unknown') {
event.cancelBubble = true;
}
this.isPropagationStopped = functionThatReturnsTrue;
}
stopPropagation()
메서드는 native event의 이벤트 전파(버블링)를 중단한다.
최신 브라우저에서는 stopPropagation()
을 호출하고,
구형 IE에서는 cancelBubble = true
를 설정하여 동일한 효과를 만들게 설계했다.
그리고 이때도 React는 isPropagationStopped
속성에 true
를 반환하는 함수를 연결해 나중에 이벤트 전파 중단 여부를 확인할 수 있게 되어있다.
SPA를 직접 만들어보면서 겪은 시행착오 덕분에 React의 이벤트 관리 방식이 왜 이렇게 설계되었는지 궁금해졌고, 그 과정을 하나하나 파헤쳐보게 되었다.
처음에는 그냥 “onClick 썼는데 되네” 정도로만 생각했던 부분이 실제로는 cross-browser 호환 문제, 이벤트 풀링 최적화, Virtual DOM과의 통합 그리고 React scheduler까지 얽혀 있는 복잡하고 정교한 설계라는 걸 알게 되었다.
특히 SyntheticEvent는 단순히 native 이벤트를 감싸는 껍데기가 아니라 React의 상태 관리, 렌더링, 그리고 성능 최적화를 한몸처럼 엮어주는 중요한 연결 고리였다.
React가 왜 이벤트를 독자적으로 관리하는지, 그리고 그것이 개발자에게 어떤 이점을 주는지 이번 정리를 통해 더 깊이 이해할 수 있었던 것 같다.
앞으로 React를 쓸 때는 단순히 JSX에 이벤트 핸들러를 다는 수준을 넘어서 그 뒤에서 돌아가는 동작 원리와 설계 의도를 더 생각하며 써봐야겠다고 느낀다.
이 글이 나처럼 React 이벤트 시스템이 궁금했던 사람들에게 조금이나마 도움이 되길 바란다.
과제를 진행하면서 리액트에서는 이벤트를 어떻게 관리하는지 궁금했는데, 도움이 많이 되었습니다!