ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 중첩 래퍼(Wrapper) 컴포넌트가 많은 UI 구조에서의 이벤트 처리 전략 고민해보기 🧐
    React 2025. 11. 2. 22:12

    UI 컴포넌트를 만들 때, 때로는 기능별 래퍼(wrapper)를 여러 겹 쌓아야 할 때가 있다.
     
    예를 들면 다음과 같은 케이스들이다. 🍙

    • 스타일 래퍼 (MarginLayout, PaddingWrapper 등)
    • 기능 래퍼 (HoverEffectWrapper, FocusTrapWrapper 등)
    • UI 프레임워크 래퍼 (MUI Card 내부에 래핑된 컴포넌트 등)

    이런 구조에서는 아래와 같은 어려움을 종종 느낄 수 있다...

    • 이벤트가 어느 레이어에서 잡혀야 할지 헷갈림
    • stopPropagation / preventDefault 남용
    • 래퍼들이 서로 이벤트 처리를 가로채 버림
    • 성능 저하나 복잡한 로직 반복

    이 글에서는 이런 중첩 구조에서 어떻게 하면 더 깔끔하고 유지보수성 높은 방식으로 이벤트 처리를 할 수 있는지, 참고할 패턴과 예제를 고민해봤고, 어떻게 하면 더 좋을지 생각한 내용을 남겨두기로 하였다   ✌️
     

    React의 이벤트 시스템 기본 이해

    먼저 React의 이벤트 처리가 어떻게 동작하는지 기본기를 알아야 한다.

    • React는 네이티브 DOM 이벤트를 document 수준에서 한 번만 감지한 뒤, 내부적으로 SyntheticEvent로 전파한다.
      즉, 각 JSX 요소마다 실제 DOM에 이벤트 리스너를 다는 게 아니라, 루트 레벨에서 위임 구조를 사용한다. (Level Up Coding)
    • 이벤트 전파는 버블링(bubbling) 방식이 기본이다. 자식 요소에서 발생한 이벤트가 부모 요소로 전파된다.
    • event.stopPropagation(), event.preventDefault() 등을 통해 전파를 제어할 수 있다. (dhiwise.com)

     
    이 구조 덕분에 React에서는 개별 DOM 노드마다 이벤트 리스너를 붙이지 않아도 된다.
    하지만 래퍼가 많아지면 SyntheticEvent 내부 로직과 상호작용하면서 예상치 못한 동작이 생기기도 한다.
     

    문제 🙊 중첩 래퍼에서 발생하는 이벤트 혼란

    예를 들어, 다음과 같은 구조가 있다고 가정해보자

    <OuterWrapper>
      <MidWrapper>
        <ButtonWrapper>
          <Button onClick={...} />
        </ButtonWrapper>
      </MidWrapper>
    </OuterWrapper>
    • OuterWrapper는 클릭 영역 전체를 감지해서 외부 클릭 감지용 이벤트를 붙여놓는다
    • MidWrapper는 특정 로직 (예 -> 드래그 가능 영역)
    • ButtonWrapper는 스타일이나 MUI 래핑

     
    이럴 때 Button을 클릭하면

    • ButtonWrapper의 onClick
    • MidWrapper가 클릭 이벤트를 가로채는 로직
    • OuterWrapper가 “외부 클릭” 판정 로직

    이런 복잡한 흐름을 stopPropagation만으로 제어하려고 하면 코드가 지저분해진다.
    많은 래퍼가 있는 구조에서는 이벤트 위임(event delegation) 또는 커스텀 이벤트 중개 계층 패턴이 도움이 된다.
     

    패턴 1️⃣ 이벤트 위임 + 조건 검사 방식

    이벤트 위임(Event Delegation)은 부모 노드 하나에 이벤트 리스너를 붙이고, 이벤트가 발생한 대상(event.target)이나 closest() 등을 검사해서 실제 동작을 분기 처리하는 방법이다.

    JS 일반문서에서도 많이 다루는 패턴이기도 하고, React에서도 활용 가능하다. (GreatFrontEnd)
    예를 들면 다음과 같다

    function Wrapper({ onClick, children }) {
      const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
        const target = e.target as HTMLElement;
        // 예: 특정 클래스명 또는 data-attribute 검사
        if (target.matches(".btn") || target.closest(".btn")) {
          // 실제 버튼 클릭 처리
          onClick?.(e);
        }
        // 그 외는 무시하거나 다른 처리를 한다
      };
    
      return <div onClick={handleClick}>{children}</div>;
    }

    이렇게 하면 여러 래퍼를 거쳐도 최상위 래퍼에 이벤트를 한 번만 붙이고, 내부에서 검사해서 처리할 수 있다.
     
    이 방식이 특히 유용한 건 다음과 같은 이유들이 있다

    • 여러 컴포넌트를 감싸는 래퍼들이 많아도 이벤트 리스너는 하나만 존재
    • 동적으로 컴포넌트가 추가/제거되어도 일관된 처리 가능
    • stopPropagation을 남발하지 않아도 됨

    그러나 단점도 있다 😹

    • event.target / closest 검사 로직이 복잡해질 수 있다
    • 성능 고려가 필요 (매번 검사 로직 실행)
    • 모든 이벤트 유형에서 적용할 수 있는 건 아니다 (예: focus, blur 등은 버블링되지 않음) (GreatFrontEnd)

     

    패턴 2️⃣ 커스텀 이벤트 중개 계층 (Event Bus 또는 useEvent 패턴)

    래퍼가 많을 때, 자식 → 부모로의 직접 호출(callback) 흐름이 복잡해질 수 있다.
    이럴 때는 중앙 이벤트 중개 계층을 두는 패턴이 효과적이다.
     
    예를 들어, useEvent라는 훅을 만들어 두고, 자식 컴포넌트가 “내가 클릭됨” 이벤트를 디스패치(dispatch)하면, 여러 래퍼 레벨이 그것을 수신(subscribe)해서 처리하는 방식이다.

    이런 방식은 Props Drilling과 콜백 체인을 줄이는 데 유용하다. (DEV Community)
     
    예시로 작성한 훅은 다음과 같다.

    import { useEffect, useCallback } from "react";
    
    type EventMap = {
      clickButton: { id: string };
      // ...다른 이벤트들
    };
    
    export function useEventListener<K extends keyof EventMap>(
      eventName: K,
      handler: (detail: EventMap[K]) => void
    ) {
      useEffect(() => {
        function onEvent(e: CustomEvent<EventMap[K]>) {
          handler(e.detail);
        }
        window.addEventListener(eventName, onEvent as EventListener);
        return () => {
          window.removeEventListener(eventName, onEvent as EventListener);
        };
      }, [eventName, handler]);
    }
    
    export function dispatchEvent<K extends keyof EventMap>(
      eventName: K,
      detail: EventMap[K]
    ) {
      const e = new CustomEvent(eventName, { detail });
      window.dispatchEvent(e);
    }

     
    컴포넌트에서는 다음과 같이 사용한다.

    // 자식 컴포넌트
    <button onClick={() => dispatchEvent("clickButton", { id: "btn1" })}>Click</button>
    
    // 래퍼 컴포넌트
    useEventListener("clickButton", (detail) => {
      console.log("Wrapper got click from", detail.id);
    });

     
    이렇게 하면 중첩 래퍼가 많아도 이벤트 흐름이 더 명확해지고, 어떤 래퍼가 이벤트를 가로채야 할지 제어하기 쉬워진다.
     
    하지만, 단점을 생각해보면 다음과 같이 조심해야 할 내용들이 있다.

    • 전역 이벤트 방식이므로 이벤트 충돌 가능성 주의
    • 전파 순서 제어가 복잡할 수 있음
    • 디버깅이 어렵다

     

    좋은 예제들의 🤩 전략 조합하기

     
    중첩 래핑 구조에서는 단일 패턴만 쓰는 것보다 위임 + 중개 계층을 같이 사용하는 경우가 많다.
    예를 들면 다음과 같다.

    • 최상위 래퍼에 위임 리스너를 붙여 두고,
    • 자식 또는 내부 래퍼는 dispatchEvent 방식으로 이벤트를 보낸다
    • 최상위 래퍼는 event.target 검사 또는 useEventListener 수신 방식으로 처리

    이런 조합을 쓰면 좋은 점들이 있다 ⛈️

    • 래퍼가 많은 구조에서도 이벤트 흐름이 명확해지고
    • stopPropagation 남발을 줄일 수
    • 특정 래퍼만 이벤트를 처리하게 제한 가능

     

    고려해봐야할 내용들 ....

    • 래퍼 구조가 깊어질수록 이벤트 처리는 복잡해지기 마련이다.
    • stopPropagation 중심 로직은 유지보수가 어렵고 예기치 않은 버그를 낳기 쉽다.
    • 이벤트 위임 + 조건 검사 + 커스텀 이벤트 중개 계층의 조합은 좋은 대안이 된다.
    • 다만 성능, 디버깅, 이벤트 충돌 같은 문제는 항상 염두에 둬야 한다.

     

    참고 자료

    • React의 이벤트 시스템 설명 (“top-level delegation”) (Level Up Coding)
    • Event-driven React 컴포넌트 통신 패턴 (useEvent 패턴) (DEV Community)
    • Event Delegation 기본 개념 정리 (GreatFrontEnd)

     
    실제 작성한 예제 🤩
    https://github.com/citron03/practice-next-15/commit/099dd7dcd09405ba098ae4e6eb333a1506bb7a53

    feat: add React Event Wrapper demo page · citron03/practice-next-15@099dd7d

    + /* global CustomEvent, Event, EventListener, HTMLElement, HTMLDivElement */

    github.com

     

Designed by Tistory.