개발 기술/사소하지만 놓치기 쉬운 개발 지식

[React] onBlur/onFocus가 버블링되는 이유와 PrimeReact 메뉴 버그 해결기

by GicoMomg 2025. 5. 11.

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 핸들러는 다른 이벤트처럼 버블링되어 부모 컴포넌트로 전파될 수 있다.
  • 아래 코드는 이벤트를 등록하는 리액트 내장 함수로, onFocusfocusin으로, onBlurfocusout으로 정의된 것을 통해 확인할 수 있다.(참고 코드)
// 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와 달리 버블링된다는 중요한 차이점을 항상 인지해야 한다. 특히 중첩된 인터랙티브 컴포넌트를 설계할 때는 이러한 이벤트 버블링 특성이 예기치 않은 사이드 이펙트를 유발할 수 있음을 고려하고, 필요에 따라 이벤트 전파를 제어하거나 조건부 로직을 통해 방어적으로 코드를 작성하는 것이 중요하다.
 

반응형

댓글