이벤트 시스템 — React의 SyntheticEvent는 왜 존재하는가
브라우저에 이미 이벤트 시스템이 있는데, React는 왜 자체 이벤트 객체를 만들어서 사용할까요?
개념 정의
SyntheticEvent는 React가 브라우저의 네이티브 이벤트를 래핑 하여 만든 크로스 브라우저 이벤트 객체입니다. W3C 스펙을 따르는 일관된 인터페이스를 제공하면서, 내부적으로는 이벤트 위임(Event Delegation)으로 효율적인 이벤트 처리를 수행합니다.
왜 필요한가
- 크로스 브라우저 호환성: 브라우저마다 다른 이벤트 동작을 통일합니다
- 성능 최적화: 이벤트 위임으로 핸들러를 루트에 한 번만 등록합니다
- React 트리 기반 전파: DOM 트리가 아닌 React 컴포넌트 트리를 기준으로 이벤트가 전파됩니다
내부 동작
이벤트 위임 구조
일반적인 DOM 이벤트 처리에서는 각 요소마다 이벤트 리스너를 등록합니다. 하지만 React는 루트 컨테이너에 이벤트 핸들러를 한 번만 등록 하고, 이벤트가 발생하면 어떤 컴포넌트에서 발생했는지를 찾아 해당 핸들러를 호출합니다.
// 개발자가 작성하는 코드
function App() {
return (
<div onClick={() => console.log('div')}>
<button onClick={() => console.log('button')}>
클릭
</button>
</div>
);
}
// React 내부 동작 (개념적)
// root.addEventListener('click', (nativeEvent) => {
// const syntheticEvent = new SyntheticEvent(nativeEvent);
// // React Fiber 트리에서 해당 컴포넌트를 찾아 핸들러 실행
// });
React 17에서 달라진 점
React 16까지는 모든 이벤트를 document에 등록했지만, React 17부터는 React 루트 컨테이너 에 등록합니다.
// React 16: document.addEventListener(...)
// React 17+: rootNode.addEventListener(...)
const root = document.getElementById('root');
// 이벤트 핸들러가 여기에 등록됨
이 변경으로 한 페이지에 여러 React 앱을 독립적으로 운영할 수 있게 되었습니다.
SyntheticEvent의 구조
function handleClick(event) {
// SyntheticEvent 속성들
console.log(event.type); // 'click'
console.log(event.target); // 클릭된 실제 DOM 요소
console.log(event.currentTarget); // 핸들러가 등록된 DOM 요소
console.log(event.nativeEvent); // 브라우저 원본 이벤트
// 메서드들
event.preventDefault(); // 기본 동작 방지
event.stopPropagation(); // React 트리 내 전파 중지
}
이벤트 전파: 캡처링과 버블링
DOM 이벤트는 캡처링(위 → 아래) → 타겟 → 버블링(아래 → 위) 순서로 전파됩니다. React도 이 흐름을 지원합니다.
function EventFlow() {
return (
<div
onClickCapture={() => console.log('1. div 캡처')}
onClick={() => console.log('4. div 버블')}
>
<button
onClickCapture={() => console.log('2. button 캡처')}
onClick={() => console.log('3. button 버블')}
>
클릭
</button>
</div>
);
}
// 버튼 클릭 시 출력 순서:
// 1. div 캡처
// 2. button 캡처
// 3. button 버블
// 4. div 버블
stopPropagation의 범위
function Parent() {
return (
<div onClick={() => console.log('parent — 이것은 실행되지 않음')}>
<Child />
</div>
);
}
function Child() {
const handleClick = (e) => {
e.stopPropagation(); // React 트리 내 전파만 중지
console.log('child');
};
return <button onClick={handleClick}>클릭</button>;
}
주의할 점은 e.stopPropagation()이 React의 합성 이벤트 전파만 멈춘다는 것입니다. document에 직접 등록한 네이티브 이벤트 리스너에는 영향을 주지 않을 수 있습니다.
네이티브 이벤트와의 혼용
가끔 React의 합성 이벤트만으로는 부족할 때가 있습니다.
function VideoPlayer() {
const videoRef = useRef(null);
useEffect(() => {
const video = videoRef.current;
// React에서 지원하지 않는 이벤트는 직접 등록
const handleFullscreen = () => {
console.log('전체화면 변경');
};
video.addEventListener('fullscreenchange', handleFullscreen);
return () => video.removeEventListener('fullscreenchange', handleFullscreen);
}, []);
return <video ref={videoRef} src="/video.mp4" />;
}
혼용 시 주의점
function HybridEvents() {
const buttonRef = useRef(null);
useEffect(() => {
// 네이티브 이벤트: React보다 먼저 실행됨
buttonRef.current.addEventListener('click', () => {
console.log('1. 네이티브 핸들러');
});
}, []);
const handleClick = (e) => {
console.log('2. React 핸들러');
};
// 클릭 시: "1. 네이티브 핸들러" → "2. React 핸들러"
return <button ref={buttonRef} onClick={handleClick}>클릭</button>;
}
네이티브 이벤트 핸들러가 React의 합성 이벤트 핸들러보다 먼저 실행됩니다. 이는 React 이벤트가 위임 방식으로 루트에서 처리되기 때문입니다.
자주 사용하는 이벤트 패턴
폼 이벤트
function LoginForm() {
const handleSubmit = (e) => {
e.preventDefault(); // 폼의 기본 제출 동작 방지
// 커스텀 제출 로직
};
return (
<form onSubmit={handleSubmit}>
<input
onChange={(e) => console.log(e.target.value)}
onFocus={() => console.log('포커스 획득')}
onBlur={() => console.log('포커스 잃음')}
/>
<button type="submit">로그인</button>
</form>
);
}
키보드 이벤트
function SearchInput() {
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
// Enter 키 처리
performSearch();
}
if (e.key === 'Escape') {
// ESC 키 처리
clearInput();
}
};
return <input onKeyDown={handleKeyDown} placeholder="검색..." />;
}
이벤트 핸들러에 인자 전달
function ItemList({ items }) {
const handleDelete = (id) => {
console.log(`${id} 삭제`);
};
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name}
{/* 방법 1: 화살표 함수 */}
<button onClick={() => handleDelete(item.id)}>삭제</button>
{/* 방법 2: bind (덜 사용됨) */}
<button onClick={handleDelete.bind(null, item.id)}>삭제</button>
</li>
))}
</ul>
);
}
주의할 점
stopPropagation이 네이티브 이벤트를 막지 못하는 경우
React의 e.stopPropagation()은 React 합성 이벤트 트리 내에서의 전파만 중지합니다. document.addEventListener로 등록한 네이티브 이벤트 리스너에는 영향을 주지 않습니다. 모달 외부 클릭 감지 같은 패턴에서 이 차이를 모르면 버그가 발생합니다.
// ❌ React stopPropagation이 document 리스너를 막지 못함
function Modal({ onClose }) {
useEffect(() => {
const handleClick = () => onClose(); // 항상 실행됨
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [onClose]);
return (
<div onClick={(e) => e.stopPropagation()}>
{/* stopPropagation이 document 리스너를 막지 못함 */}
</div>
);
}
네이티브 이벤트와 합성 이벤트의 실행 순서
네이티브 이벤트 핸들러가 React 합성 이벤트보다 먼저 실행됩니다. React는 이벤트 위임 방식으로 루트에서 이벤트를 처리하기 때문입니다. 이 순서를 모르면 stopPropagation 호출 위치에 따라 예상과 다른 동작이 발생합니다.
이벤트 풀링 (React 16 이하 레거시)
React 16까지는 SyntheticEvent가 풀링 되어 핸들러 실행 후 모든 속성이 null이 되었습니다. React 17부터 풀링이 제거되었으므로 e.persist()는 더 이상 필요 없습니다. 레거시 코드에서 persist() 호출을 발견하면 안전하게 제거할 수 있습니다.
정리
| 항목 | 설명 |
|---|---|
| SyntheticEvent | 브라우저 네이티브 이벤트를 래핑한 크로스 브라우저 이벤트 객체 |
| 이벤트 위임 | React 17+에서 루트 컨테이너에 핸들러를 한 번만 등록 |
| 캡처링 | onClickCapture로 캡처링 단계 이벤트 처리 가능 |
| nativeEvent | e.nativeEvent로 원본 브라우저 이벤트 접근 |
| stopPropagation | React 트리 내 전파만 중지 — 네이티브 리스너에는 영향 없음 |
| 이벤트 풀링 | React 17에서 제거 — persist() 불필요 |
이벤트 위임 구조와 합성/네이티브 이벤트의 실행 순서를 이해하면, 전파 제어에서 겪는 버그를 빠르게 해결할 수 있습니다.