1. 들어가며
React는 네이티브 이벤트가 아닌 자체적으로 SyntheticEvent(합성 이벤트) 시스템을 사용한다.
이 시스템은 브라우저 간 이벤트 차이를 추상화해 일관된 동작을 제공하지만,
그 특성상 네이티브 환경에서는 버블링되지 않는 blur 이벤트가 버블링되어 문제가 생길 수 있다.
실제로 최근 PrimeReact UI 라이브러리에서 이와 관련된 문제(issue#7924)를 경험했다.
문제가 발생했던 구조는 "메뉴 컴포넌트 내부에서 삭제(x) 버튼을 가진 Chip 컴포넌트가 있는 구조"이다.

의도한대로라면 (x) 버튼을 클릭했을 때, 해당 Chip 컴포넌트가 삭제되어야 한다.
하지만 왜인지 삭제 버튼(x)을 여러 번 클릭해야만 삭제 이벤트가 발생하는 버그가 발생했다...!

이 문제의 원인을 추적해보니, 삭제 버튼 클릭 시 메뉴의 onBlur 이벤트가 먼저 버블링되어 실행되면서,
메뉴 상태가 초기화되고 Chip의 삭제 이벤트가 무시되는 흐름이 원인이었다.
그래서 이 문제를 해결하기 위해 event.stopPropagation()
을 적용해 버블링을 차단하는 방식으로 해결했다.
하지만 여기서 궁금증이 생겼다.
네이티브 환경에서는 버블링되지 않는 것으로 알려진 blur 이벤트가 왜 React에서는 버블링되는 것일까?
이번 시간에는 React가 제공하는 SyntheticEvent의 특징과 함께, 실제 메뉴 컴포넌트에서
겪었던 blur
이벤트 관련 버그를 어떻게 해결했는지 그 과정을 공유하고자 한다.
2. onBlur/onFocus가 버블링되는 이유와 PrimeReact 메뉴 버그 해결기
1) React SyntheticEvent(합성 이벤트)의 세가지 특징
- React의 이벤트 시스템은 브라우저에서 발생하는 네이티브 이벤트를 효율적으로 관리하기 위해 SyntheticEvent라는 고유한 이벤트 객체를 사용한다.
- 이 객체는 브라우저 간의 이벤트 처리 방식 차이를 표준화하고, React 컴포넌트 트리 전체에 걸쳐 일관된 방식으로 이벤트를 전달한다.
(1) 합성 이벤트의 특징1, 이벤트 리스너는 한 번만 등록한다.
- React는 성능 최적화와 메모리 관리를 위해 이벤트 위임(event delegation) 방식을 채택했다.
- 그래서 개별 DOM 요소마다 이벤트 리스너를 등록하는 대신, 애플리케이션의 최상단(예: #root)에 단 하나의 이벤트 리스너만을 등록한다.
- 아래 코드를 살펴보면, React가 렌더링될 때
listenToAllSupportedEvents()
를 통해 지원하는 모든 종류의 이벤트(클릭, 입력, 포커스 등)에 대한 리스너를 최상단에 한 번만 설정하는 걸 알 수 있다. (참고 코드)
// facebook/react의 listenToAllSupportedEvents.js
...
const root = createContainer(...);
markContainerAsRoot(root.current, container);
const rootContainerElement = ... ;
listenToAllSupportedEvents(rootContainerElement); // ✅ 루트에 이벤트 리스너 등록
return new ReactDOMRoot(root);
}
- 이 리스너는 이벤트의 캡처 단계(상위 요소에서 타깃 요소로 이벤트가 내려가는 흐름)와
- 버블 단계(타깃 요소에서 상위 요소로 이벤트가 올라오는 흐름) 모두를 감지하도록 설정된다.
(2) 합성 이벤트의 특징2, 네이티브 이벤트를 기반으로 SyntheticEvent로 변환된다.
- 사용자가 요소와 상호작용하면(예: 버튼 클릭, 입력 필드 포커스) 브라우저는 네이티브 이벤트를 발생시킨다.
- 이러한 네이티브 이벤트가 React의
SyntheticEvent
로 변환되는 과정은 다음과 같다.
단계 | 설명 |
---|---|
1. 루트 리스너의 이벤트 감지 | 발생한 네이티브 이벤트는 DOM 트리를 따라 전파되다가, React가 최상단에 등록해둔 단일 이벤트 리스너에 의해 감지된다. |
2. Fiber 노드 탐색 | React는 이벤트가 실제 발생한 DOM 요소를 기준으로, 이와 매핑되는 Fiber 노드(React가 컴포넌트 계층 구조와 상태를 관리하기 위해 사용하는 내부 객체)를 찾아낸다. |
3. SyntheticEvent 생성 및 래핑 | SimpleEventPlugin 과 같은 React 내부의 이벤트 플러그인이 네이티브 이벤트를 받아 SyntheticEvent 객체로 감싸고 변환한다. SyntheticEvent 객체는 stopPropagation() , preventDefault() 와 같이 W3C 표준을 따르는 메서드를 제공하며, 브라우저 간 호환성을 보장한다. |
(3) 합성 이벤트의 특징3, onFocus, onBlur 이벤트에서 버블링이 발생한다.
- HTML 표준에 따르면 focus와 blur 이벤트는 버블링되지 않는다.
- 하지만 React는 네이티브 focus 이벤트를 버블링되는 focusin 이벤트에,
- blur 이벤트를 버블링되는 focusout 이벤트에 내부적으로 매핑한다.
- 그로 인해 React의 onFocus와 onBlur 핸들러는 다른 이벤트처럼 버블링되어 부모 컴포넌트로 전파될 수 있다.
- 아래 코드는 이벤트를 등록하는 리액트 내장 함수로,
onFocus
가focusin
으로,onBlur
가focusout
으로 정의된 것을 통해 확인할 수 있다.(참고 코드)
// react/registerSimpleEvents.js
export function registerSimpleEvents() {
for (let i = 0; i < simpleEventPluginEvents.length; i++) {
const eventName = ((simpleEventPluginEvents[i]: any): string);
const domEventName = ((eventName.toLowerCase(): any): DOMEventName);
const capitalizedEvent = eventName[0].toUpperCase() + eventName.slice(1);
registerSimpleEvent(domEventName, 'on' + capitalizedEvent);
}
// Special cases where event names don't match.
registerSimpleEvent(ANIMATION_END, 'onAnimationEnd');
registerSimpleEvent(ANIMATION_ITERATION, 'onAnimationIteration');
registerSimpleEvent(ANIMATION_START, 'onAnimationStart');
registerSimpleEvent('dblclick', 'onDoubleClick');
registerSimpleEvent('focusin', 'onFocus'); // ✅ focusin -> onFocus로 등록
registerSimpleEvent('focusout', 'onBlur'); // ✅ focusout -> onBlur로 등록
registerSimpleEvent(TRANSITION_RUN, 'onTransitionRun');
registerSimpleEvent(TRANSITION_START, 'onTransitionStart');
registerSimpleEvent(TRANSITION_CANCEL, 'onTransitionCancel');
registerSimpleEvent(TRANSITION_END, 'onTransitionEnd');
}
2) React onFocus/onBlur 버블링과 이벤트 간섭: 실제 버그 해결 과정
(1) 문제 발생 현상: "왜 삭제 버튼이 한 번에 안 눌릴까?"
- 전제: 버튼을 클릭하여 드롭다운 Menu(<ul>로 구현)를 연다. 이 Menu 내부에는 Chip 컴포넌트가 표시되고, 이 Chip에는 삭제("x") 버튼이 있다.

- 문제 발생: Menu 안에 있는 Chip의 삭제 버튼을 클릭했을 때, Chip이 삭제되기를 기대하나 실제로는 그렇지 않았다.
Chip의onRemove
콜백 함수가 첫 번째 클릭에는 반응하지 않고, 여러 번 클릭해야 실행되었다.

(2) 원인: React SyntheticEvent onBlur/onFocus의 숨겨진 버블링
- Menu(
<ul>
요소)가 포커스를 가진 상태에서, 그 안에 있는 Chip의 삭제<button>
을 클릭한다. - Chip의 삭제 버튼을 클릭하는 순간, 포커스가 Menu에서 삭제 버튼으로 이동한다.
- 그럼, React에 의해 Menu에서 발생한 Synthetic onBlur 이벤트가 버블링되기 시작한다.
- 이 버블링된 onBlur 이벤트는 Menu 컴포넌트 자체에 등록된 onBlur 이벤트 핸들러를 트리거한다.
- 아래 코드는 Menu 컴포넌트의 onBlur 핸들러로, "포커스 관련 내부 상태를 초기화하는" 로직을 포함하고 있다.
그래서 이 로직이 Chip의 onRemove 콜백이 제대로 실행되기도 전에 먼저 실행되어 버린다.
// Menu의 onBlur 핸들러
const onListBlur = (event) => {
...
setFocused(false); // 💡 포커스 초기화 로직 실행
setFocusedOptionIndex(-1); // 💡 포커스 초기화 로직 실행
props.onBlur && props.onBlur(event);
};
- 결과적으로, Menu의 상태가 예기치 않게 변경되어, 정작 실행되어야 할 Chip의 onRemove 콜백 함수 처리가 중단되거나 지연되는 것이다. 😢
(3) 해결 방안
이러한 이벤트 간섭 문제를 해결하기 위해 두 가지 주요 접근 방식을 적용했다.
A. 메뉴(Menu) 컴포넌트: onBlur
핸들러 조건 구체화
blur
이벤트는 포커스가 메뉴 외부로 완전히 이동했을 때만 실행되어야 한다.event.relatedTarget
을 이용하면 포커스가 내부 요소로 이동한 것인지 외부로 나간 것인지를 판별할 수 있다.- 아래는 해당 조건을 반영한 수정 코드이다:
// Menu 컴포넌트의 onBlur 핸들러 예시
const onListBlur = (event: React.FocusEvent<HTMLUListElement>) => {
const { currentTarget, relatedTarget } = event;
// ✅ 메뉴 내부 요소로 포커스가 이동한 경우: blur 무시
if (relatedTarget && currentTarget.contains(relatedTarget as Node)) {
return;
}
// ✅ 포커스가 완전히 외부로 나간 경우에만 blur 처리
setFocused(false);
setFocusedOptionIndex(-1);
props.onBlur?.(event);
};
용어 | 설명 |
---|---|
currentTarget |
이벤트가 바인딩된 요소 (여기서는 <ul> 메뉴) |
relatedTarget |
blur 시 새로 포커스를 받은 요소 (버튼, 인풋 등) |
.contains() |
relatedTarget 이 현재 메뉴 내부에 있는지를 판단 |
B. Chip 컴포넌트: 클릭 이벤트 전파 차단
- 삭제 버튼을 클릭할 때, 이벤트가 상위 메뉴(
Menu
)까지 전파되면, 메뉴의onBlur
핸들러가 예상치 않게 실행될 수 있다. - 이를 방지하기 위해
event.stopPropagation()
을 호출해 이벤트 버블링을 차단해야 한다.
// Chip 컴포넌트에서 삭제 버튼 클릭 시 호출되는 함수
const handleChipRemove = (event) => {
// ✅ 클릭 이벤트가 상위 Menu 컴포넌트로 전파되는 것을 막는다
event.stopPropagation();
// ✅ 삭제 콜백 실행
if (props.onRemove) {
props.onRemove({ originalEvent: event, value: props.label });
}
};
이렇게 두 가지 처리를 하면, Menu 내부의 Chip에서 삭제 이벤트가 의도대로 동작하게 된다.

3. 마치며…
이번 시간에는 React의 합성 이벤트의 특성과 이 특성으로 인해 발생할 수 있는 버그를 수정해보았다.
React에서 onFocus와 onBlur 이벤트는 네이티브 DOM의 focus/blur와 달리 버블링된다는 중요한 차이점을 항상 인지해야 한다. 특히 중첩된 인터랙티브 컴포넌트를 설계할 때는 이러한 이벤트 버블링 특성이 예기치 않은 사이드 이펙트를 유발할 수 있음을 고려하고, 필요에 따라 이벤트 전파를 제어하거나 조건부 로직을 통해 방어적으로 코드를 작성하는 것이 중요하다.
'개발 기술 > 사소하지만 놓치기 쉬운 개발 지식' 카테고리의 다른 글
[JS] Delete vs Backspace: 키 이벤트 처리 시 주의사항 (with. OTP 입력기) (2) | 2025.03.29 |
---|---|
CSS interpolate-size 기반 아코디언, JS보다 빠를까? 실측 성능 테스트 (4) | 2025.03.15 |
에셋 이미지의 동적 로드와 빌드 최적화 (0) | 2025.02.14 |
Base64 인코딩과 Path 방식의 장단점 및 성능 비교(feat.에셋 로드 방식) (0) | 2025.02.09 |
[JS] Array 빌트인 함수는 정말 성능이 나쁠까? (forEach, map 등) (0) | 2025.01.24 |
댓글